feat(extension): [CLI-1653] dynamic, out-of-process extensions over gRPC#651
feat(extension): [CLI-1653] dynamic, out-of-process extensions over gRPC#651mattdolan-snyk wants to merge 27 commits into
Conversation
Add a Phase 1 dynamic extension mechanism so extensions can be loaded from prebuilt binaries at runtime without rebuilding the host CLI. Extensions run as separate processes and communicate over gRPC via hashicorp/go-plugin (the Terraform/Vault model), so a crashing extension cannot take down the host and no extension code is loaded into the host's address space. - pkg/extension/proto: gRPC Extension service (Discover + Execute), generated with buf (no protoc required). - convert.go: workflow.Data/payload/config marshaling across the boundary. - plugin.go: go-plugin handshake + client/server adapters; pluginConn seam. - serve.go: plugin-author SDK (Serve/Registrar) mirroring workflow.Callback. - loader.go: host-side Loader implementing workflow.ExtensionInit; explicit path-based discovery, config snapshot scoped to declared flags, failing extensions skipped (non-fatal), injected dialer for testing. - app: WithExtensionPaths option + factory wiring, replacing the "scan here for extension binaries" placeholder. - Tests: conversion round-trips, gRPC adapters over plugin.TestGRPCConn, loader registration/proxying with a fake dialer, and end-to-end build+load of a real binary through the subprocess dialer. - docs/dynamic-extensions-design.md: design doc covering protocol, security model, and Phase 2 seams (host callbacks, signing/registry/allowlist). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Extensions now receive a real workflow.InvocationContext and can call the Snyk API using the host's authentication, without ever holding the user's credentials. Network access uses the "option C" model: for the duration of each invocation the host runs a loopback authenticating reverse proxy (pkg/extension/authproxy.go). The extension makes plain HTTP requests to a 127.0.0.1 endpoint guarded by a per-invocation secret; the host strips the secret, rewrites onto the configured API_URL, and forwards via its authenticated RoundTripper (injecting auth, default headers, proxy and TLS). Credentials stay host-side; the proxy is scoped to the single configured upstream and torn down when the call returns. This needs no gRPC broker — the child reaches the proxy over an ordinary socket. - proto: ExecuteRequest carries network_proxy_url + network_proxy_token. - invocationcontext.go: plugin-side workflow.InvocationContext. Network is live (via the proxy); config exposes declared flags + the proxied API_URL; logger and UI write to stderr (stdout is the go-plugin protocol channel); analytics is local and Engine is nil for now (documented). - serve.go: Handler is now a type alias for workflow.Callback, so an in-process workflow callback can be served as an extension unchanged. - loader.go: makeProxy starts/stops the auth proxy per invocation. - testdata/exampleplugin: adds an authenticated API-call workflow alongside the config example. - Tests: auth proxy secret enforcement + injection; end-to-end proof that a loaded extension binary makes an authenticated call with the host injecting the token. - docs: document option C, the InvocationContext bridging table, and remaining callback gaps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Extensions can now invoke sibling workflows on the host engine and record analytics into the host's batch, completing the InvocationContext so an extension behaves like an in-process workflow. These services must call back into live host objects, so the host exposes a second gRPC service (HostCallback) that the extension dials over go-plugin's GRPCBroker (bidirectional), set up per invocation: - GetEngine().Invoke(id, ...) -> HostCallback.Invoke: the host runs the sibling workflow in its full context (auth, network, other extensions). Extensions and built-ins compose the same way in-process workflows do: by identifier through the engine's public Invoke, never by calling each other's code. - GetAnalytics() recording (AddExtension*Value, AddError) -> the invocation's host analytics, prefixed by the calling workflow id. - proto: ExecuteRequest.broker_id + HostCallback service (Invoke, AddExtensionValue, ReportError). - workflow.ResolveInvokeOptions: resolves opaque EngineInvokeOptions so an Invoke can be forwarded across the boundary. - hostcallback.go: host-side HostCallback server bound to the per-invocation engine + analytics. - proxies.go: plugin-side remoteEngine (workflow.Engine) and remoteAnalytics. - plugin.go/serve.go: capture the broker on both ends; host AcceptAndServe per invocation; plugin dials and wires the callbacks into the InvocationContext. - testdata/exampleplugin: adds a workflow that invokes a sibling and records analytics. - Tests: callback server + proxies over in-memory gRPC; subprocess e2e proving an extension invokes a host sibling workflow and its analytics reach the host. - docs: new host-callbacks section, proxy-scope clarification, updated tables. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Add a component diagram (pre-existing pieces vs. additions, colour-coded) and an invocation sequence diagram for the dynamic extension mechanism, and link them from the design doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Close the two highest-priority test gaps identified in review. - Concurrency: TestEndToEnd_ConcurrentInvocations drives 24 parallel invocations of a single loaded extension, split across flw://hello.fetch (per-invocation option-C auth proxy) and flw://hello.callsibling (per-invocation callback broker), asserting isolation and correctness. Passes under `go test -race`. - Dialer error paths: TestGRPCDialer_ErrorsOnMissingBinary checks the real dialer returns an error (no conn/cleanup) when the binary can't launch; TestLoader_RealDialer_SkipsNonPluginBinary points the real go-plugin dialer at a binary that never completes the handshake (testdata/notaplugin) and verifies the loader logs-and-skips it without aborting engine init or registering a workflow. grpcDialer coverage 60% -> 73%; the whole package remains race-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Addresses review: a dynamically loaded extension must not silently shadow a bundled workflow. engine.Register is last-writer-wins and the loader runs after the built-in initializers, so a dynamic workflow reusing an existing identifier would previously replace the built-in. The loader now skips (with a warning) any workflow whose identifier is already registered — a bundled extension, or one from an earlier-loaded binary — so bundled extensions take precedence by default. Override is opt-in via the new config key internal_extension_allow_override (extension.WithAllowOverride), wired through app.CreateAppEngineWithOptions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Addresses review: there was no go:generate hook for the protobuf, so keeping the generated code in sync relied on running buf by hand. Bring it into the repo's standard flow: buf, protoc-gen-go and protoc-gen-go-grpc are added to go.mod's tool block, buf.gen.yaml invokes the plugins via `go tool` (so codegen uses the pinned versions with no separate PATH install), and a //go:generate directive in pkg/extension/proto regenerates the code. `make generate` (go generate ./...) now regenerates it like every other generated package. Regenerated the committed files through this path (import grouping now matches the pinned generator). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
Addresses review: keep the pkg API surface minimal. AuthProxy/NewAuthProxy and the Handshake var were exported but used only within pkg/extension, so unexport them (authProxy/newAuthProxy, handshake). The redundant BaseURL()/Secret() getters are dropped in favour of direct field access now that the type is package-private, and Close() becomes stop(). The public API is now just what extension authors and host integrators need: Serve/Registrar/Handler/WithFlags/Hidden, and Loader/NewLoader/WithPaths/ WithLogger/WithAllowOverride/ConfigurationKey*. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
- examples/extensions/whoami: a minimal example extension that adds an example.whoami command delegating to the host's built-in whoami workflow — demonstrating GetEngine().Invoke of a bundled workflow with host auth. - Makefile: `make build-examples` builds examples/extensions/... into .bin/. - docs/dynamic-extensions.md: a concise, low-jargon getting-started guide (try the example, write your own, what an extension can do, gotchas), linked from the README. Points to the design doc / diagrams for internals. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HSNJqEVLGMLDjJs634CU5
|
| Status | Scan Engine | Total (0) | ||||
|---|---|---|---|---|---|---|
| Open Source Security | 0 | 0 | 0 | 0 | See details | |
| Licenses | 0 | 0 | 0 | 0 | See details | |
| ✅ | Code Security | 0 | 0 | 0 | 0 | 0 issues |
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.
|
| Status | Scan Engine | Total (0) | ||||
|---|---|---|---|---|---|---|
| Open Source Security | 0 | 0 | 0 | 0 | See details | |
| Licenses | 0 | 0 | 0 | 0 | See details | |
| ✅ | Code Security | 0 | 0 | 0 | 0 | 0 issues |
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.
This comment has been minimized.
This comment has been minimized.
| if c.broker != nil && req.invocation != nil { | ||
| brokerID := c.broker.NextId() | ||
| invocation := req.invocation | ||
| go c.broker.AcceptAndServe(brokerID, func(opts []grpc.ServerOption) *grpc.Server { |
There was a problem hiding this comment.
Should-fix (concurrency / resource leak): The host launches `go c.broker.AcceptAndServe(brokerID, …)` on every invocation with a non-nil invocation (and makeProxy always sets it), but the plugin only dials the broker at serve.go:124 — after three early returns (NotFound, input-decode InvalidArgument at serve.go:109, identifier-parse InvalidArgument at serve.go:114). On any of those error paths the plugin returns without dialing, so this AcceptAndServe goroutine + its grpc.Server block until the plugin client is killed. Ordinary bad input reaches the decode path, so a long-lived host (snyk-ls) leaks one goroutine + listener per errored Execute for the session lifetime. Bound the accept to the Execute RPC (per-call context / stop the broker server when Execute returns) rather than relying on the plugin to always dial.\n\n— AI review
There was a problem hiding this comment.
Good catch on the mechanism, though the specific leak path here is already closed: the extension's own Execute (serve.go) now dials the host broker as the very first thing it does, before any error return (unknown workflow, bad input, bad identifier) — see the earlier fix in this PR at d674285. So a plugin that reaches Execute at all will always dial back.
The residual gap you're pointing at — the RPC to the extension failing/timing out before it ever reaches Execute (so nothing on the plugin side can dial) — is real and not addressed. Bounding AcceptAndServe to the surrounding Execute call's own lifetime (stop the broker server unconditionally once c.client.Execute returns, rather than trusting the plugin) would close that too, but touches go-plugin broker internals I'd want to test more carefully before landing. Leaving as a known residual gap for now rather than rushing it — happy to pick up as a follow-up if this needs closing sooner.
| // extensions run as gRPC subprocesses and are registered like built-ins. | ||
| if config != nil { | ||
| if paths := config.GetStringSlice(extension.ConfigurationKeyPaths); len(paths) > 0 { | ||
| loader := extension.NewLoader( |
There was a problem hiding this comment.
Should-fix (lifecycle / subprocess leak): CreateAppEngineWithOptions builds loader := extension.NewLoader(...), registers loader.Init, then discards the reference. Loader.Close() (loader.go:231) — the only path that runs the accumulated client.Kill cleanups — is unreachable in production (no non-test caller). A long-lived host that recreates engines per session spawns one exec.Command subprocess per extension at Init() and never reaps them; orphaned processes + gRPC conns accumulate for the host lifetime. Either return/store the loader and call Close() on shutdown (or register plugin.CleanupClients), or delete Close() if you truly rely only on go-plugin parent-death detection so it is not a false promise.\n\n— AI review
There was a problem hiding this comment.
Fixed in 165f2fa: the loader initializer now closes over its *extension.Loader instance, and CreateAppEngineWithCloser returns a close func wired to it so callers that create engines dynamically can reap the subprocesses on shutdown. CreateAppEngineWithOptions is unchanged for existing callers that don't need it.
| // This replaces the previous "scan here for extension binaries" placeholder: | ||
| // extensions run as gRPC subprocesses and are registered like built-ins. | ||
| if config != nil { | ||
| if paths := config.GetStringSlice(extension.ConfigurationKeyPaths); len(paths) > 0 { |
There was a problem hiding this comment.
Should-fix (regression — CLI flag silently ignored): Loader registration is gated on config.GetStringSlice(ConfigurationKeyPaths) evaluated at engine construction. The documented usage (loader.go:20-24) is a repeatable --plugin-path flag bound to this key, but CLIs normally create the engine, then parse flags, then Init(). At construction the flag is unparsed → slice empty → loader.Init is never registered, and even after flags parse nothing loads. The previous placeholder lived inside Init() (late); this moved the decision early. Register a loader initializer unconditionally and read the paths when it runs, so flag-supplied paths take effect. (Only env/opt values present at creation currently work.)\n\n— AI review
There was a problem hiding this comment.
Fixed in 165f2fa (same commit as the Close() fix, since both were the same root cause): the loader initializer is now registered unconditionally and reads extension.ConfigurationKeyPaths when it actually runs at Init() time, not at engine construction. Added a regression test (TestCreateAppEngine_ExtensionPathSetAfterConstruction_StillLoads) that sets the path after CreateAppEngineWithOptions returns, mirroring the real CLI startup order.
| invocation: invocation, | ||
| } | ||
| for _, flag := range spec.GetFlags() { | ||
| req.config[flag.GetName()] = config.GetString(flag.GetName()) |
There was a problem hiding this comment.
Should-fix (credential-isolation bypass — undermines the core premise): The host forwards config to the extension by iterating over flag names the extension itself declared via Discover, with no denylist: req.config[flag.GetName()] = config.GetString(flag.GetName()). Sensitive keys live in the same config namespace (snyk_token, api, snyk_cfg_api, snyk_oauth_token — see pkg/configuration/constants.go). A malicious/compromised extension that declares a flag named snyk_token/api/snyk_oauth_token makes the host read the real credential and serialize it into the ExecuteRequest sent to the extension process — defeating the "extension never holds credentials" guarantee the auth-proxy (option C) exists to provide. Add a reserved/sensitive-key denylist (or forbid extension flag names that collide with host config keys) on this loop.\n\n— AI review
There was a problem hiding this comment.
Confirmed and fixed in aab491a: makeProxy now checks each declared flag name against a denylist of reserved host credential keys (snyk_token, snyk_oauth_token, the legacy api token alias, OAuth client-id/client-secret) before exporting its value, and skips (with a warning log) any collision instead. Added TestLoader_DoesNotExportSensitiveConfigValue covering the exact scenario you described.
| return nil, status.Errorf(codes.InvalidArgument, "decoding input: %v", err) | ||
| } | ||
|
|
||
| output, err := s.engine.Invoke(id, workflow.WithInput(input)) |
There was a problem hiding this comment.
Should-fix (unbounded cross-process recursion + trust boundary): Invoke calls s.engine.Invoke(id, …) for any identifier the extension supplies, with no allowlist and no self-invocation/depth guard. Two issues: (1) an extension can drive any registered host workflow in the host's full context (in-process host workflows use the real credentials directly), not just siblings as the comments imply; (2) if the extension invokes its own flw://x, the host re-enters the same proxy → new auth proxy + broker stream + Execute → recurses, amplifying subprocesses/goroutines/listeners across the boundary until FD/goroutine/OOM exhaustion (vs a bounded in-process stack). Scope to an allowlist/capability grant and add a depth or cycle guard. Separately, this host-side broker gRPC server has no panic-recovery interceptor, so a host workflow that panics on plugin-supplied input crashes the whole host — add a recover() unary interceptor.\n\n— AI review
There was a problem hiding this comment.
Split into two fixes in 5ea06d2:
- Recursion/amplification: added a round-trip depth guard. Each hop is tracked via a context value threaded through
workflow.WithContexton the host side (no proto change needed — every hop in the chain is host-process-local except the two gRPC calls, which already carry a context both ways).Invokenow rejects the call once depth exceeds a fixed max (10) instead of letting a self-referential chain run until the host exhausts file descriptors/goroutines/memory. - Panic isolation: added a
recover()unary interceptor on the host-side HostCallback gRPC server, so a host workflow panicking on plugin-supplied input returns a gRPC error instead of crashing the host process.
On the broader point — extension can drive any registered host workflow, not just declared siblings — leaving that as-is rather than adding an allowlist: it matches how in-process workflows already call each other via engine.Invoke (no allowlist there either), and constraining it further felt like a separate design decision worth its own discussion rather than bundling into this fix. Happy to pick that up if you think it needs to be scoped down.
| l.cleanups = append(l.cleanups, cleanup) | ||
| l.mu.Unlock() | ||
|
|
||
| specs, err := conn.Discover(context.Background()) |
There was a problem hiding this comment.
Should-fix (violates the "broken extension must never block CLI startup" invariant, loader.go:105-107): loadOne dials and discovers with context.Background() (l.dialer(context.Background(), …) at :118 and conn.Discover(context.Background()) here), and grpcDialer ignores the passed context. go-plugin bounds only the handshake; once a plugin handshakes and then hangs inside Discover, this call has no deadline and Loader.Init blocks the CLI indefinitely — exactly the failure the stated invariant forbids. Add a bounded context and propagate a real ctx into the dialer.\n\n— AI review
There was a problem hiding this comment.
Fixed in aab491a (same commit as the credential-denylist fix): loadOne now bounds dial + Discover to a 10s timeout instead of context.Background(), and grpcDialer derives go-plugin's StartTimeout from that same deadline instead of ignoring the passed context.
| func (c *pluginInvocationContext) GetEngine() workflow.Engine { return c.engine } | ||
|
|
||
| // GetRuntimeInfo returns nil in this phase; runtime info is not yet bridged. | ||
| func (c *pluginInvocationContext) GetRuntimeInfo() runtimeinfo.RuntimeInfo { return nil } |
There was a problem hiding this comment.
Should-fix (nil deref / interface contract violation): GetRuntimeInfo() unconditionally returns nil. Host consumers deref it without a nil check — e.g. report_analytics_workflow.go does invocationCtx.GetRuntimeInfo().GetName()/.GetVersion(). In-process this is populated; run the same workflow as an extension and it panics inside the subprocess. Populate RuntimeInfo from the request (or from host-forwarded values) instead of returning nil.\n\n— AI review
There was a problem hiding this comment.
Fixed in c92c5b8: ExecuteRequest now carries the host's runtime info name/version, and the plugin side wraps it into a real (possibly empty, but never nil) runtimeinfo.RuntimeInfo — matching in-process behavior exactly when the host has one set, and failing safe instead of panicking when it doesn't.
|
|
||
| // InvokeWithConfig forwards the invocation; per-invocation config overrides are | ||
| // not propagated across the boundary (the sibling runs with the host's config). | ||
| func (e *remoteEngine) InvokeWithConfig(id workflow.Identifier, _ configuration.Configuration) ([]workflow.Data, error) { |
There was a problem hiding this comment.
Should-fix (silent wrong behaviour for sibling invokes): InvokeWithConfig(id, _ configuration.Configuration) drops its config argument and calls invoke(id, nil); InvokeRequest carries no config, so any per-invocation override is silently discarded. An extension mirroring the in-repo cfg.Set(FLAG_EXPERIMENTAL, true); GetEngine().InvokeWithConfig(WORKFLOWID_WHOAMI, cfg) pattern gets the override dropped → sibling fails ("experimental feature") or returns wrong results with no signal. Relatedly GetWorkflow always returns (nil,false) and GetWorkflows returns nil (proxies.go:120-121), so guard-then-invoke patterns silently skip valid host workflows. Forward config across the boundary and back the lookup methods with the host engine.\n\n— AI review
There was a problem hiding this comment.
The InvokeWithConfig dropped-config half was already fixed earlier in this PR (commit a84dd20 — config overrides are now diffed and forwarded across the boundary via InvokeRequest.config, applied by hostCallbackServer.Invoke on top of the host's own config).
The GetWorkflow/GetWorkflows stub half is still open — deliberately not doing it here. Backing it truthfully needs a new HostCallback RPC (there's no existing way to ask the host "is this identifier registered" or "list your workflows" across the boundary), which is a bigger, separate change. Tracking it as follow-up rather than rushing a proto addition into this pass.
AI review summary — dynamic out-of-process gRPC extensionsReviewed What's solid: credential isolation holds — the raw token never crosses into the plugin; auth is injected host-side by the loopback proxy's transport, the per-invocation secret uses Should-fix (posted inline)
Suggestions (non-blocking)
Supply chain: new deps ( — AI review |
Execute only dialed the broker after decoding input and parsing the identifier. An early error (bad input, bad identifier, unknown workflow) returned without dialing, leaving the host's per-invocation AcceptAndServe goroutine waiting forever for a connection that never comes.
WithExtensionPaths wrote paths onto whatever configuration the engine held at the time it ran. If WithConfiguration replaced that configuration afterward, the paths were silently discarded and no dynamic extensions were registered. WithConfiguration now carries forward any paths already recorded on the prior configuration.
A second Init() call re-spawned every configured extension binary. Workflow registration already skips the resulting identifier collisions, but the orphaned subprocesses from the first call keep running. Init now no-ops after the first successful call.
remoteEngine.Invoke resolved workflow.WithConfig/WithContext/ WithInstrumentationCollector but only ever forwarded input to the host, so a sibling workflow invoked from an extension always ran with the host's own configuration regardless of caller overrides. InvokeRequest gains a config map, populated with the keys where the resolved configuration differs from the extension's base config. hostCallbackServer.Invoke applies them on top of its own configuration before invoking the sibling. WithContext and WithInstrumentationCollector remain host-only by design: a Go context and an analytics.InstrumentationCollector cannot cross the process boundary.
|
Addressing the 3 findings from the review guide:
|
…on remoteEngine Two follow-on bugs from the previous config-forwarding and RuntimeInfo fixes in this PR: 1. configOverrides diffed override against e.config's own live value. Since invocation.GetConfiguration() and remoteEngine.config are the same object, the common "mutate my own config in place, then Invoke" pattern compared a value against itself and always produced an empty diff -- the mutation never reached the host. Fixed by snapshotting config's values once, before the extension's handler runs (configSnapshot), and diffing against that snapshot instead of against the live (mutable) object. While here: skip slice-typed values (e.g. AUTHENTICATION_SUBDOMAINS) in the diff. InvokeRequest.config is a flat map[string]string; forwarding a stringified slice would have hostCallbackServer.Invoke overwrite it with cfg.Set(key, aSingleString), corrupting later GetStringSlice reads for that key. 2. remoteEngine.GetRuntimeInfo() still hardcoded nil -- the earlier fix only covered pluginInvocationContext.GetRuntimeInfo(). An extension calling GetEngine().GetRuntimeInfo() (as opposed to invocation.GetRuntimeInfo() directly) got nil instead of the mirrored host value. Also removed remoteAnalytics's writes to its embedded local analytics.Analytics in the overridden recording methods: that local state is never read back in this process (the host forward is what actually flushes it), so the writes were dead weight.
A nil *zerolog.Logger passed to WithLogger overwrote NewLoader's default no-op logger, and would nil-panic on the Loader's first log call. WithLogger now ignores a nil argument and keeps the default.
…sing 0 specsToFlagSet discarded strconv.Atoi's error and fell through to a 0 default with no diagnostic. An extension declaring a malformed int flag default now gets a warning naming the flag and the bad value.
The listener is loopback-only and secret-guarded, so this isn't a real-world exposure, but an http.Server with no ReadHeaderTimeout is a gosec G112 finding worth avoiding regardless.
|
Fixed the config-override type-safety issue in dcb1282: The "Subprocess Resource Leak" finding here is the same Loader.Close() issue already fixed earlier in this PR (165f2fa) — this review ran against the pre-fix commit. |
This comment has been minimized.
This comment has been minimized.
Init's idempotency guard (added earlier in this PR) meant a Loader reused after Close -- e.g. via engine.Init() called again on the same engine after CreateAppEngineWithCloser's closer ran -- would silently return nil without relaunching anything, leaving the engine's proxy workflows registered against killed subprocesses. Close can't simply relaunch: the engine may already hold proxy workflows under identifiers a second load would collide with. Close is now documented and enforced as terminal: Init after Close returns an error instead of a silent, broken no-op.
…onfiguration Both functions appended new paths onto existing ones with no dedup. A path present on both sides -- e.g. WithExtensionPaths called twice with an overlapping path, or already present on the configuration WithConfiguration is carrying paths forward from -- made the Loader launch two subprocesses for the same binary and register duplicate proxy workflows (silently dropped unless allow-override is set).
This comment has been minimized.
This comment has been minimized.
configuration.Configuration.GetString only handles string and []string underlying values and silently returns "" for anything else. Two call sites used it unconditionally regardless of the actual value type: 1. makeProxy exported every declared flag's value via GetString, so a bool or int flag the user set on the CLI (true, 5, ...) exported as "" instead of its real value -- the extension only ever saw FlagSpec defaults. Fixed by dispatching on the flag's declared type (flagValueAsString), mirroring specsToFlagSet's reverse mapping. 2. configSnapshot/configOverrides used GetString to detect changes for a sibling Invoke. Since GetString returns "" for both a false and a true bool (or any int), a bool/int override was indistinguishable from no change at all and silently dropped. Fixed with configValueAsString, which dispatches on the runtime type of the raw config value instead.
|
Two points from this review pass:
|
This comment has been minimized.
This comment has been minimized.
| if overrides := req.GetConfig(); len(overrides) > 0 && s.config != nil { | ||
| cfg := s.config.Clone() | ||
| for key, value := range overrides { | ||
| cfg.Set(key, value) |
There was a problem hiding this comment.
Critical (credential exfiltration — new since last review): The inbound HostCallback.Invoke path applies extension-supplied config overrides onto a clone of the host's real, token-bearing configuration with no key filtering — cfg := s.config.Clone() then cfg.Set(key, value) for every entry in req.GetConfig(). This is the reverse of the outbound path (loader.go sensitiveConfigKeys), which deliberately refuses to export credential-colliding keys so the extension never holds the token.
The gap is exploitable: the auth middleware decides whether to attach the token by prefix-matching the request URL against config.GetString(API_URL) from this same cloned config (pkg/networking/middleware/auth_header.go ShouldRequireAuthentication). A malicious/compromised extension can call the callback with config[API_URL]="https://attacker.example" (or AUTHENTICATION_ADDITIONAL_URLS) and then invoke any host workflow that makes an authenticated API call — the host runs the sibling with the real credentials and sends them to the attacker's host. That defeats the auth-proxy isolation this whole feature is built around. Verified independently by three reviewers and traced to the middleware source.
Compounding: s.engine.Invoke(id, ...) (line 104) runs any workflow the extension names — there is still no allow-list restricting a plugin to a declared/sibling set (raised in the prior review).
Fix: host-side, reject/strip override keys in sensitiveConfigKeys and the network-routing keys (configuration.API_URL, AUTHENTICATION_ADDITIONAL_URLS, proxy / TLS-skip keys) before cfg.Set; ideally allow only keys declared by the invoked workflow's flags. Add a test asserting an extension-supplied API_URL override does not change the host sibling's effective endpoint/credentials.
— AI review
There was a problem hiding this comment.
Confirmed and fixed in 28f2a67 — you're right, this defeated the whole isolation design. hostCallbackServer.Invoke now only applies an override for a key the invoked workflow itself declared as configurable (via its ConfigurationOptions/flags at Register time), mirroring how the outbound (host-to-extension) direction already only exports a workflow's own declared FlagSpec keys. An override for API_URL, AUTHENTICATION_ADDITIONAL_URLS, or any other key the target workflow didn't register is refused outright — no fallback, no partial application. Added TestHostCallbackServer_RejectsConfigOverrideForUndeclaredKey covering exactly the API_URL exfiltration path you traced.
On the allow-list point (any workflow, no sibling restriction): still the same open design question from the prior review round — left as-is pending a product/security decision on the intended blast radius, not re-litigating that here.
| } | ||
|
|
||
| func CreateAppEngineWithOptions(opts ...Opts) workflow.Engine { | ||
| engine, _ := createAppEngine(opts...) |
There was a problem hiding this comment.
Should-fix (residual subprocess leak + unsynchronized loader): The prior review flagged that Loader.Close() was unreachable; this revision adds a closer and CreateAppEngineWithCloser, but the most-used constructor CreateAppEngineWithOptions still discards it (engine, _ := createAppEngine(opts...)). Any existing caller (CLI, snyk-ls) that adopts --plugin-path through this constructor and creates engines per-session / repeatedly leaks the launched extension subprocesses, their gRPC conns, goroutines, and per-invocation auth-proxy listeners for the host lifetime, with no signal. Consider registering the loader's Close on an engine shutdown hook so the default path is safe, rather than relying on callers to switch to the closer variant.
Separately, the loader variable in createAppEngine is written by the extension-initializer closure (runs inside engine.Init()) and read by the returned closer with no synchronization — Loader.mu guards the struct internals but not this app-level pointer. A daemon that runs closer on a shutdown goroutine while Init() is still executing races and may observe a nil/partial loader, skipping Close() and leaking subprocesses.
— AI review
There was a problem hiding this comment.
Two separate points here:
- CreateAppEngineWithOptions not getting the closer — same as my reply on the previous round: by design, additive API, existing callers keep today's behavior. Not re-litigating.
- Unsynchronized loader variable — real bug, fixed in c807005. The extension initializer writes loader from inside engine.Init(); the closer reads it. Both were plain closured-variable accesses with no synchronization, so a shutdown handler calling closer() concurrently with a still-running Init() (e.g. slow-loading extension) could observe a torn/nil read and skip Close(), leaking the subprocess. Now guarded by a mutex. Added a race-detector regression test (TestCreateAppEngineWithCloser_ConcurrentInitAndCloseIsRaceFree) exercising Init() and closer() concurrently — clean under
go test -race.
AI re-review — dynamic out-of-process gRPC extensionsNew commits landed since the last AI review (which was against Prior findings now addressed (verified) ✅
New this review🔴 Critical — inbound host-callback config-override credential exfiltration ( 🟠 Should-fix — residual subprocess leak + unsynchronized loader ( Still open from the prior review (not re-posted inline to avoid duplicates)
Supply chain: new deps ( — AI review |
CRITICAL: hostCallbackServer.Invoke applied every key/value pair an extension supplied in InvokeRequest.config directly onto a clone of the host's real, credential-bearing configuration, with no restriction on which keys could be touched. The auth middleware (networking/middleware.ShouldRequireAuthentication) decides whether to attach the real token to a request by matching the request's URL against configuration.API_URL and AUTHENTICATION_ADDITIONAL_URLS from that same config. An extension invoking any host workflow that makes an authenticated API call could set config[API_URL] (or AUTHENTICATION_ADDITIONAL_URLS) to an attacker-controlled host and have the host send the user's real credential there -- the reverse of the credential-isolation problem already fixed on the outbound (host-to-extension) path, and defeating the auth-proxy design this whole feature is built around. Fixed by only ever applying an override for a key the *invoked workflow itself* declared as configurable (via the ConfigurationOptions passed to engine.Register), mirroring how the outbound direction already only exports a workflow's own declared FlagSpec keys. A key the target workflow didn't register is refused outright rather than silently dropped-with-fallback, so there's no path through which an arbitrary host configuration key -- credential or otherwise -- can be overridden by an extension. Updated two existing tests whose sibling workflow declared no flags at all (so the override they relied on is now correctly refused) to declare the "greeting" flag they override, matching how a real workflow would need to opt in. Added TestHostCallbackServer_RejectsConfigOverrideForUndeclaredKey covering the exact API_URL exfiltration scenario.
loadOne registered the process cleanup in l.cleanups before calling Discover, but only ran cleanups otherwise on Loader.Close(). A process whose Discover call failed never has any workflow registered against it, so nothing would ever reference or clean it up except Close -- it kept running for the Loader's full lifetime for no reason.
The extension initializer writes loader from inside engine.Init(); the closer returned by CreateAppEngineWithCloser reads it. Both are plain accesses to a closured variable with no synchronization, so a shutdown handler invoking closer() concurrently with a still-running Init() (e.g. on a slow-loading extension) is a data race -- the closer could observe a torn/nil read and skip Close(), leaking the subprocess. Added a race-detector regression test exercising Init() and closer() concurrently.
PR Reviewer Guide 🔍
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Reviewed by Cursor Bugbot for commit c807005. Configure here.
| continue | ||
| } | ||
| cfg.Set(key, value) | ||
| applied = true |
There was a problem hiding this comment.
Inbound overrides skip credential denylist
Medium Severity
Sibling HostCallback.Invoke applies extension-supplied config overrides only against the invoked workflow’s declared flags, not the host’s sensitiveConfigKeys denylist used on outbound snapshots. If a host workflow ever registers a flag whose name matches a credential key, an extension could push that value onto a clone of the host configuration for that invocation.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit c807005. Configure here.
| // keys (see networking/middleware.ShouldRequireAuthentication), so an | ||
| // override reaching them would make the host attach the user's real | ||
| // token to a request destined for a host the extension chose. | ||
| allowed := invokableConfigKeys(s.engine, id) |
There was a problem hiding this comment.
Critical (credential redirect — the new inbound override filter is bypassable): This revision adds invokableConfigKeys to filter extension-supplied config overrides, but it keys the defense on an attacker-controlled allowlist: the permitted keys are exactly the flags the invoked workflow declared, and a dynamically-loaded extension controls its own workflows' declared flags. Discover returns FlagSpecs → specsToFlagSet (serve.go) turns every spec into a pflag with no rejection of reserved key names → registerWorkflow registers it as-is. So a malicious extension can populate the allowlist with a sensitive routing key.
Exploit (self-invoke): the extension declares a workflow flag named snyk_api (= configuration.API_URL) and/or internal_additional_auth_urls (= AUTHENTICATION_ADDITIONAL_URLS), then calls HostCallback.Invoke(id=<its own wf>, config={"snyk_api":"https://attacker.example"}). invokableConfigKeys returns {"snyk_api":true}, the check here passes, cfg.Set("snyk_api", …) + WithConfig(cfg) poison the invocation, and startAuthProxy reads API_URL and forwards the request — with the host's real token injected host-side — to the attacker's host. This is exactly the "attach the user's real token to a host the extension chose" threat the comment above describes; the allowlist does not stop it.
Fix: gate the inbound path on an unconditional denylist (credentials and auth-routing keys: snyk_token, snyk_oauth_token, snyk_api/API_URL, internal_additional_auth_urls, internal_auth_subdomain, plus the full outbound sensitiveConfigKeys set), rejecting them regardless of what the workflow declared — and reject those reserved names in specsToFlagSet/registerWorkflow so they never enter any workflow's ConfigurationOptions. Note: the outbound sensitiveConfigKeys denylist (loader.go) currently omits the URL-routing keys, so it is no backstop. (The case-folding path is fine — both sides lowercase and viper normalizes keys — so consistency there is not the issue; the allowlist-poisoning is.)
— AI review
| loader *extension.Loader | ||
| ) | ||
| engine.AddExtensionInitializer(func(engine workflow.Engine) error { | ||
| loaderMu.Lock() |
There was a problem hiding this comment.
Should-fix (mutex makes the pointer race-free but does not serialize the Init-vs-close lifecycle it claims to guard): The comment above says loaderMu defends against "a shutdown handler racing a still-running Init()", but the lock is dropped between the read at :467 and the store at :486, so check-then-act is not atomic and the leak windows remain:
- Init-after-close leak:
closer()(:491) takes the lock, readsloader == nil(initializer hasn't stored yet), unlocks, closes nothing. The initializer then storesnewLoaderand runsnewLoader.Init(engine)(:488), spawning extension subprocess(es) after shutdown — nothing ever callsClose()on them. - Double-init leak: two concurrent initializer runs both read
existing == nilbefore either stores, bothNewLoader+Init(double-spawn), and only the last-stored loader is everClose()d — the earlier one's subprocesses leak.
go test -race (and TestCreateAppEngineWithCloser_ConcurrentInitAndCloseIsRaceFree) verifies data-race freedom but cannot catch either logical leak, so the new test gives false confidence. Fix: hold loaderMu across the whole check-create-store, and add a closed bool under the same mutex so the initializer refuses to start a loader once closer has run (and closer closes whichever loader is set). The root-cause-correct home for the shutdown guard is Loader.Init itself. If Init and shutdown are in fact always single-threaded, drop the mutex and document that contract instead of half-implementing serialization. (Separately, the default constructor CreateAppEngineWithOptions still discards this closer — raised in the prior review — so the loader is never closed on that path regardless.)
— AI review
AI re-review — dynamic out-of-process gRPC extensions3 new commits landed since the last AI review (which was against Prior findings now addressed (verified) ✅
New blocking findings (posted inline)
Suggestions (non-blocking)
Security scan: new deps ( — AI review |
| loaderMu sync.Mutex | ||
| loader *extension.Loader | ||
| ) | ||
| engine.AddExtensionInitializer(func(engine workflow.Engine) error { |
There was a problem hiding this comment.
The inner function can be moved to a separate function, so this current method (createAppEngine) doesn't grow too much and keeps it easy to read
| func CreateAppEngineWithOptions(opts ...Opts) workflow.Engine { | ||
| engine, _ := createAppEngine(opts...) | ||
| return engine | ||
| } |
There was a problem hiding this comment.
Note that createAppEngine always creates that close function - you probably expect to use the close function here or return it in same way you do for CreateAppEngineWithCloser


Description
Adds a way to load CLI extensions dynamically — from a prebuilt binary discovered at runtime — instead of only compiling them into the CLI. This unblocks developing an extension without rebuilding the host on every change, and lays the groundwork for downloadable / third-party extensions.
Extensions run out of process and talk to the host over gRPC via
hashicorp/go-plugin(the Terraform/Vault model), so a crashing extension can't take down the CLI and no extension code is loaded into the host's address space. Despite the boundary, an extension is written like an in-process workflow:extension.Handleris a type alias forworkflow.Callback, and it receives a realworkflow.InvocationContext.What an extension gets:
GetNetworkAccess().GetHttpClient()returns a client the host authenticates for it, via a per-invocation loopback proxy. The user's credentials stay in the host process; the extension never holds them, and they can only reach the configured Snyk API (not arbitrary hosts).GetEngine().Invoke(id, …)runs a built-in or another extension's workflow on the host, in its full context.AddExtension*Value/AddErrorfeed the host's batch.Developer loop:
go buildyour extension → point a host at it (app.WithExtensionPaths(...), or a--plugin-pathflag /internal_extension_pathsconfig key) → iterate with no host rebuild. Getting-started guide:docs/dynamic-extensions.md. Two runnable examples:examples/extensions/whoami(minimal, calls a host workflow) andpkg/extension/testdata/exampleplugin(config, authenticated API call, sibling invoke + analytics).Safety / scope: this is the trusted-code posture — isolation for robustness, not a sandbox (a loaded binary runs with the user's OS privileges). A dynamic extension cannot silently override a bundled workflow (opt in with
internal_extension_allow_override). The enterprise trust layer (signature/registry verification + a default-deny allowlist) is intentionally a later phase; the loader's dialer is the single chokepoint where verify-before-launch slots in.Blast radius: the
workflow.Engine,Register/Invoke,InvocationContext, and content-typedDatacontracts are unchanged; built-in (compile-time) extensions behave exactly as before. The only core addition isworkflow.ResolveInvokeOptions, a helper that lets anInvokebe forwarded across the boundary. Protobuf codegen is wired intogo generate/make generate.Design + internals:
docs/dynamic-extensions-design.md· diagrams:docs/dynamic-extensions-architecture.md.Architecture (blue = pre-existing, green = added here)
flowchart TB classDef existing fill:#dbeafe,stroke:#3b82f6,color:#0b1f44; classDef new fill:#d4f8d4,stroke:#2ea043,color:#06210d; subgraph HOST["Host CLI process"] APP["app.CreateAppEngineWithOptions"]:::existing ENG["workflow.Engine<br/>registry + Invoke dispatch"]:::existing LWF["localworkflows.Init<br/>built-in extensions (compile-time import)"]:::existing BW["built-in workflow callback<br/>e.g. flw://output"]:::existing IC["InvocationContext<br/>Config · NetworkAccess · Analytics · UI · Logger"]:::existing LOADER["extension.Loader<br/>(a workflow.ExtensionInit)"]:::new PROXY["proxy callback<br/>1 per discovered workflow"]:::new AP["auth proxy<br/>loopback, option C"]:::new HCB["HostCallback server<br/>sibling Invoke · analytics"]:::new end subgraph EXT["Extension process — separate binary (subprocess)"] SERVE["extension.Serve"]:::new EH["Handler = workflow.Callback"]:::new PIC["plugin InvocationContext<br/>remoteEngine · remoteAnalytics · NetworkAccess"]:::new end API["Snyk API"]:::existing APP --> ENG APP --> LWF LWF -. "AddExtensionInitializer / Register" .-> ENG ENG --> BW BW --> IC APP --> LOADER LOADER -. "launch + Discover, then Register proxy" .-> ENG ENG -->|"Invoke(id)"| PROXY PROXY -->|"gRPC: Execute<br/>(config snapshot, input,<br/>proxy URL+secret, broker id)"| SERVE SERVE --> EH --> PIC PIC -->|"plain HTTP + secret"| AP AP -->|"authenticated HTTPS<br/>(host injects credentials)"| API PIC -->|"gRPC broker (bidirectional)"| HCB HCB -->|"Engine.Invoke(sibling)"| ENG HCB -->|"record"| ICChecklist
go test ./pkg/extension/... ./pkg/app/... ./pkg/workflow/...passes, including subprocess end-to-end tests (load-without-rebuild, authenticated API call with host-injected token, sibling invoke + analytics over the broker) and a 24-way concurrency test that passes under-race. (Full-repomake testnot run here.)make generate) — protobuf now regenerates viago generate(buf + protoc plugins pinned in thetoolblock); committed files were regenerated through it.make lint) —gofmt,goimports, andgo vetare clean;golangci-lintneeds a CI run (its binary targeted an older Go than this module locally).🤖 Generated with Claude Code
Note
High Risk
Touches authentication (loopback proxy, credential export rules) and cross-process invocation of host workflows—security-sensitive paths with broad new surface area, though heavily tested.
Overview
Adds dynamic extensions: standalone binaries loaded at runtime via
hashicorp/go-pluginand gRPC, registered on the existingworkflow.Enginelike built-ins—no host rebuild for extension iteration.New
pkg/extensioncovers discovery/Execute, a per-invocation loopback auth proxy so extensions call the Snyk API with host-injected credentials (not raw tokens), and HostCallback bridging siblingInvoke, analytics, and config overrides with guards (declared-flag-only overrides, credential key blocking, invocation depth cap).appwiresWithExtensionPaths, reads paths atInit()(post-flag-parse), dedupes paths, andCreateAppEngineWithCloserto tear down subprocesses.workflow.ResolveInvokeOptionsis the small core helper for forwardingInvokeoptions across the boundary.Docs (
dynamic-extensions*.md),make build-examples, and thewhoamiexample round out the developer loop; dependencies add gRPC/protobuf/buf tooling.Reviewed by Cursor Bugbot for commit c807005. Bugbot is set up for automated code reviews on this repo. Configure here.