feat(credentials): JIT credential dispensing per tool call#236
Conversation
6030ae9 to
84c91ec
Compare
initializ-mk
left a comment
There was a problem hiding this comment.
Reviewed the full diff, hand-traced the SigV4 path, verified the env-injection scoping empirically, and ran the credentials + cli_execute suites (all green). This is a clean, well-tested design with no blocking correctness bug — but I'm requesting changes to close one security-critical test gap and to fold the follow-ups into this PR before merge.
What's correct (for the record)
- SigV4 canonicalization is correct — method / URI / empty query / canonical-headers (trailing
\n) / signed-headers / payload-hash; the required blank line falls out of the trailing newline + join;stsscope, session-token header, and signing-key derivation all match spec. - Injection is properly scoped — creds go into
cmd.Env(child only), neveros.Setenv; minted per call; GC'd after. Nil-injector is a clean no-op. - Audit stays metadata-only — events carry
env_keys/header_keys(names), never values.
Requested changes (this PR)
1. (Security-critical) Regression-test the JIT override; don't rely solely on Go's exec dedup
The docs promise JIT creds "override any same-name values from the operator's static passthrough." That only holds because Go's os/exec dedups env keeping the last entry — I confirmed it (K=source-broad then K=jit-scoped -> child sees jit-scoped on go1.26), and buildEnv does pass EnvPassthrough through, so the conflict path is real (an operator who lists AWS_ACCESS_KEY_ID in env_passthrough). But no test asserts it. If that runtime behavior changed, or a refactor prepended instead of appended, the subprocess would silently keep the broad source creds — a silent privilege escalation.
- Add a test that seeds a conflicting key via
buildEnv/env_passthroughand asserts the JIT value wins in the subprocess. - Prefer belt-and-suspenders: explicitly strip conflicting keys from
cmd.Envbefore appending the JIT env, rather than trusting exec dedup.
2. Cache STS credentials within their TTL (no per-call AssumeRole)
Credential.Materialize does a full STS AssumeRole on every tool invocation, even though the interface doc contemplates caching. An agent that runs aws N times makes N AssumeRole calls -> latency before each exec + account-level throttling. Since the provider ignores args (no per-action scope-down), every call for a given spec yields an equivalent credential, so caching within the TTL is safe and strictly better.
- Cache the materialization per spec and reuse until near expiry (leave a skew), re-minting only when expired.
3. Wire the JIT Headers path into http_request (the HTTP/API half of R9)
Materialization.Headers / Handle.Headers() are defined but have no consumer — only cli_execute calls the injector. As-is, JIT credentials only reach subprocess env, not HTTP/API endpoints, so the Headers surface is dead code and the PR implies more coverage than it delivers.
- Wire the injector into
http_request(materialize on the call, injectHandle.Headers()into the outbound request,Closeafter) so per-call credentials work for API endpoints too — the natural sibling of thecli_executepath.
4. Make credential_revoked honest about self-expiring creds
STS and static set Revoke == nil, so Close emits credential_revoked even though nothing was invalidated (the STS token is still live until its 15m expiry). An operator can't distinguish "hard-revoked" from "tool finished, token self-expires later."
- Add a field (e.g.
revoked: bool/self_expiring: true) so the audit reflects what actually happened.
5. Minor
-
truncateslices bytes, not runes (comment says runes) — can emit invalid UTF-8 in an error string; slice on runes or note it. -
types(a widely-imported package) now importscredentials— a coupling that will dragcredentials' future deps into everything importingtypes; consider keepingCredentialSpecminimal / breaking the direction ifcredentialsgrows non-stdlib deps. - Injected creds land in subprocess stdout -> the LLM (the JIT test asserts
AWS_ACCESS_KEY_ID=…in stdout). Fine and disclosed, but cross-reference the #221deny_outputredaction as the mitigation in the threat-model doc.
Explicitly OUT of scope (do not add here)
- Vault dynamic-secrets provider — that belongs to Vault's capability/role, not this PR. The plugin interface is the right seam for it later; no need to build it now. (Same for k8s TokenRequest / other backend providers — the interface is enough for this PR.)
Verdict
Request changes. #1 (override test) and #3 (headers wiring) are the ones I'd block on — #1 because a silent privilege downgrade is the worst failure mode here, #3 because the HTTP story is currently implied but not delivered. #2 (caching) matters as soon as this sees real aws usage. Once those land, this is a solid R9 foundation.
…est revoke Addresses initializ-mk's review on #236. ## Security-critical: strip conflicting env keys before JIT append `cli_execute` no longer relies on Go's os/exec dedup semantics to ensure JIT credentials override same-named env_passthrough entries. Explicit `stripEnvKeys` pass runs before the JIT append, so a same-name key from env_passthrough is removed rather than duplicated — even if a future refactor changed the append order or exec's dedup behavior, the JIT value still wins. Regression test `TestCLIExecute_JITOverridesEnvPassthrough` seeds a broad source key, runs `env` under JIT, and asserts exactly one line in stdout with the JIT value. ## Fill the dead-code Headers path: wire http_request Materialization.Headers had no consumer pre-fix. Added `(&httpRequestTool{}).WithCredentialInjector(inj)` and threaded through `builtins.RegisterAll(reg, builtins.Options{...})`. The runner now passes the same injector to both cli_execute (env) and http_request (headers), so R9 covers the HTTP/API surface. JIT headers override LLM-authored headers of the same name — an LLM must not be able to bypass operator credential scoping by inlining a competing Authorization. Test: `TestHTTPRequest_JITHeadersOverrideLLMHeaders`. ## STS credential caching within TTL Per-Credential cache serves the same materialization until the STS-issued Expiration minus a 60s skew. Eliminates the pre-fix "one AssumeRole per tool invocation" pattern that stacked latency + risked account-level STS throttling. Tests: - `TestSTSProvider_MaterializeCachesWithinTTL` (5 calls → 1 STS hit) - `TestSTSProvider_ExpiredCacheReMints` (clock past expiry → re-mint) ## Honest credential_revoked Event now carries `revoked: bool` and `self_expiring: bool`. STS + static providers (no Revoke callback) emit `revoked=false, self_expiring=true` — operators can distinguish "actually invalidated" from "tool finished, token still live to TTL." Vault provider (future) will emit `revoked=true`. Tests: - `TestHandleClose_SelfExpiringWhenNoRevoke` - `TestHandleClose_HardRevokeWhenProviderRevokes` ## Minor - `truncate` now slices on runes (was bytes) — no invalid UTF-8 in error messages when STS returns a body with multi-byte chars. - Coupling note on `types.ForgeConfig.Credentials`: the base `credentials` package MUST stay stdlib-only; provider deps live under `credentials/<name>/`. Comment added to types/config.go. - Threat-model doc cross-references R4a `deny_output` (#209) as the mitigation for the "subprocess dumps env → LLM sees creds" footgun. Includes an example SKILL.md redact pattern list. ## Out of scope Vault dynamic-secrets provider explicitly deferred (reviewer confirmed). The Provider interface is the seam; it can land in a follow-up PR.
|
Thanks for the review @initializ-mk — all five points addressed in c7c6620. 🔴 #1 JIT override + regression test — Belt-and-suspenders. 🔴 #3 http_request headers wiring — Filled the dead-code path. 🟠 #2 STS caching — Per-Credential cache keyed off the STS-issued Expiration minus a 60s skew. 🟠 #4 credential_revoked honesty — Event now carries 🟡 Minor
Out of scope confirmed — Vault dynamic-secrets provider explicitly deferred; the Provider interface is the seam and it can land in a follow-up. Full |
Governance R9. Ships the credentials package + Injector + two reference providers, wired into cli_execute so declarative CredentialSpecs mint fresh scope-down creds on every tool invocation. - forge-core/credentials/: Provider + Credential interfaces, Registry, DefaultRegistry. Injector coordinates resolve → mint → audit → revoke; Handle scopes credentials to a single tool call. - forge-core/credentials/static/: passthrough env/headers provider for bootstrapping + tests. - forge-core/credentials/sts/: AWS STS AssumeRole reference provider. SDK-free (hand-rolled SigV4 signer matching Forge's Bedrock signer). Enforces AWS's 15m–12h duration bounds; carries external_id + session policy for role-trust scoping. - forge-cli/tools/cli_execute: WithCredentialInjector wires the runner-supplied injector; Execute materializes on each call and merges env into the subprocess before exec. - ForgeConfig.Credentials []CredentialSpec: operators declare per-tool JIT specs in forge.yaml. Empty → pre-R9 behavior. - Audit events: credential_issued, credential_revoked, credential_failed. Payloads carry provider/tool/binary/ttl/ env_keys — never the credential material itself. Docs at docs/security/least-privilege-credentials.md.
…est revoke Addresses initializ-mk's review on #236. ## Security-critical: strip conflicting env keys before JIT append `cli_execute` no longer relies on Go's os/exec dedup semantics to ensure JIT credentials override same-named env_passthrough entries. Explicit `stripEnvKeys` pass runs before the JIT append, so a same-name key from env_passthrough is removed rather than duplicated — even if a future refactor changed the append order or exec's dedup behavior, the JIT value still wins. Regression test `TestCLIExecute_JITOverridesEnvPassthrough` seeds a broad source key, runs `env` under JIT, and asserts exactly one line in stdout with the JIT value. ## Fill the dead-code Headers path: wire http_request Materialization.Headers had no consumer pre-fix. Added `(&httpRequestTool{}).WithCredentialInjector(inj)` and threaded through `builtins.RegisterAll(reg, builtins.Options{...})`. The runner now passes the same injector to both cli_execute (env) and http_request (headers), so R9 covers the HTTP/API surface. JIT headers override LLM-authored headers of the same name — an LLM must not be able to bypass operator credential scoping by inlining a competing Authorization. Test: `TestHTTPRequest_JITHeadersOverrideLLMHeaders`. ## STS credential caching within TTL Per-Credential cache serves the same materialization until the STS-issued Expiration minus a 60s skew. Eliminates the pre-fix "one AssumeRole per tool invocation" pattern that stacked latency + risked account-level STS throttling. Tests: - `TestSTSProvider_MaterializeCachesWithinTTL` (5 calls → 1 STS hit) - `TestSTSProvider_ExpiredCacheReMints` (clock past expiry → re-mint) ## Honest credential_revoked Event now carries `revoked: bool` and `self_expiring: bool`. STS + static providers (no Revoke callback) emit `revoked=false, self_expiring=true` — operators can distinguish "actually invalidated" from "tool finished, token still live to TTL." Vault provider (future) will emit `revoked=true`. Tests: - `TestHandleClose_SelfExpiringWhenNoRevoke` - `TestHandleClose_HardRevokeWhenProviderRevokes` ## Minor - `truncate` now slices on runes (was bytes) — no invalid UTF-8 in error messages when STS returns a body with multi-byte chars. - Coupling note on `types.ForgeConfig.Credentials`: the base `credentials` package MUST stay stdlib-only; provider deps live under `credentials/<name>/`. Comment added to types/config.go. - Threat-model doc cross-references R4a `deny_output` (#209) as the mitigation for the "subprocess dumps env → LLM sees creds" footgun. Includes an example SKILL.md redact pattern list. ## Out of scope Vault dynamic-secrets provider explicitly deferred (reviewer confirmed). The Provider interface is the seam; it can land in a follow-up PR.
c7c6620 to
c34c558
Compare
initializ-mk
left a comment
There was a problem hiding this comment.
Request changes — the four fixes from the earlier round are all correct and tested, but standing up a live agent to exercise the scenarios surfaced a blocker: the credentials block cannot be configured from forge.yaml at all.
Fixes from the prior review — verified correct
Checked against the fix commit (builds clean; credentials + tools suites green):
- Env override strip —
cli_executenowstripEnvKeys(cmd.Env, jitEnv)before appending the JIT env, so a JIT key deterministically overrides a passthrough of the same name rather than relying on exec dedup order. The security-criticalTestCLIExecute_JITOverridesEnvPassthroughis present. - STS cache — thread-safe fast/slow path, reuses the minted credential until
expireAt - 60sskew, deep-clones on return. No more AssumeRole per tool call. - http_request wiring — the tool now materializes and
req.Header.Sets the JIT headers and defers revoke; the Headers path is finally consumed. - Honest revoked event —
credential_revokedcarriesrevoked/self_expiring(false/true for static + STS self-expiring creds).
Blocker: credentials[].spec is unparseable in forge.yaml
forge validate on the documented format fails at config load:
credentials:
- tool: cli_execute
binary: env
provider: static
spec:
env: { JIT_TOKEN: jit-secret-abc123 } # the shape shown in docs / config.goError: parsing forge config: yaml: unmarshal errors:
cannot unmarshal !!map into json.RawMessage
CredentialSpec.Spec (forge-core/credentials/credentials.go) is a json.RawMessage ([]byte) tagged yaml:"spec" with no custom UnmarshalYAML. yaml.v3 has no built-in path from a YAML node into json.RawMessage, so it errors. I tried both authoring forms:
- documented map ->
cannot unmarshal !!map into json.RawMessage - JSON string (
spec: '{"env":{...}}') ->cannot unmarshal !!str into json.RawMessage
Both fail. The operator-facing feature -- declare per-tool JIT specs in forge.yaml -- does not work through its only interface. The unit tests miss it because they construct CredentialSpec{Spec: json.RawMessage(...)} directly in Go, bypassing YAML entirely; nothing exercises ParseForgeConfig on a credentials block.
Fix: add UnmarshalYAML(*yaml.Node) to CredentialSpec that decodes the outer fields and re-encodes the spec sub-node to JSON (yaml.Node -> map[string]any -> json.Marshal -> RawMessage). This exact pattern already lives in the repo -- BinRequirement.UnmarshalYAML at forge-skills/contract/types.go:118. Please also add a config round-trip test (ParseForgeConfig on a credentials: block -> Injector builds successfully) so this can't regress -- that's the gap that let it through.
How I verified
Built the PR binary and a demo agent (jit-demo): a static provider injecting JIT_TOKEN into cli_execute's env and X-Jit-Auth into http_request, with a driver that reads the verdict from the audit log (credential_issued / credential_revoked with revoked:false, and JIT_TOKEN=... in the subprocess output). The harness is ready; it's currently blocked because the agent can't start with a credentials block. Once the UnmarshalYAML fix lands I'll re-run it to confirm the env-injection and the new header-injection paths end to end.
|
Follow-up on the parse blocker: the shipped docs are the repro. Every
So a user copy-pasting either example from this PR's own documentation gets a startup error, not a working agent. Same root cause as the review -- One extra thing for whoever implements the |
…-trips Addresses @initializ-mk's #236 second-round blocker. CredentialSpec.Spec is a `json.RawMessage` — yaml.v3 has no built-in path to decode a YAML node into that type, so any operator loading the documented `credentials:` block from forge.yaml hit: cannot unmarshal !!map into json.RawMessage The whole operator-facing feature was unusable through its only interface. Existing unit tests missed it because they construct `CredentialSpec{Spec: json.RawMessage(...)}` in Go, bypassing the yaml layer. Fix mirrors the pattern in forge-skills/contract/types.go's `BinRequirement.UnmarshalYAML`: decode the outer fields via a type-alias that swaps `Spec` for a `yaml.Node`, then re-encode the sub-node through `map[string]any → json.Marshal` so providers keep receiving canonical `json.RawMessage` bytes. Empty spec (`Kind == 0`) decodes to nil so providers that infer everything from env still work. Regression tests in `forge-core/types/config_credentials_test.go`: - ParseForgeConfig on a full `credentials:` block extracts both outer fields and the JSON-encoded spec (asserts substrings so key ordering is stable across yaml.v3 versions) - End-to-end: ParseForgeConfig → credentials.NewInjector succeeds (registers static provider via blank import) — the fix catches breaks in either half of the round-trip - Empty spec accepted (provider config elsewhere) - Direct JSON round-trip still works — UnmarshalYAML doesn't regress the JSON path used by OCI packaging + tests Live-verified end-to-end with a demo agent (`/tmp/jit-demo`): `forge validate` passes; `forge run` logs "JIT credential injector wired specs=2"; a driver program parses the yaml, builds the Injector, invokes cli_execute + http_request, and confirms JIT_TOKEN + AWS_ACCESS_KEY_ID in the subprocess env and X-Jit-Auth on the outbound request header. Audit events: credential_issued + credential_revoked pair per tool call with `revoked:false, self_expiring:true` (the honesty fix from the prior round holds).
|
Fix in 901d2a4 — End-to-end verified live on my machine, same shape as your harness: Built the binary from the fix commit, dropped this ```yaml
Results:
Regression tests at
Full `go test ./forge-core/... ./forge-cli/...` sweep + `gofmt -w` + `golangci-lint run` all clean. |
initializ-mk
left a comment
There was a problem hiding this comment.
Approving — the 901d2a4 fix resolves my second-round blocker, and I re-validated the whole feature live end to end (twice) against a binary built from the branch head.
Blocker resolved
CredentialSpec.UnmarshalYAML is the right fix and mirrors the in-repo BinRequirement.UnmarshalYAML pattern: decode the outer fields via a type-alias that swaps Spec for a yaml.Node, then Decode -> json.Marshal so providers keep receiving canonical json.RawMessage bytes; empty spec: (Kind == 0) decodes to nil for env-inferring providers. The four regression tests in forge-core/types/config_credentials_test.go (block round-trip, parse -> NewInjector, empty spec, JSON-path independence) lock it in. All green.
The duration/ttl-as-string concern from my follow-up is a non-issue: both sts.Duration and static.TTL are typed as plain string (STS calls time.ParseDuration on it), so the re-encoded "15m" decodes cleanly.
Live end-to-end validation
Built a binary from 901d2a4, ran a demo agent with a static provider injecting JIT_TOKEN into cli_execute/env and X-Jit-Auth into http_request, driven by a real model:
forge validatepasses on the demo config AND on the verbatimsts_assume_roleexample fromdocs/security/least-privilege-credentials.md(both failed before the fix).- Startup logs
JIT credential injector wired specs=2. - Per tool call:
credential_issued+credential_revokedpair,revoked:false, self_expiring:true(the honesty fix from the prior round holds). - Injection confirmed at the destination:
JIT_TOKEN=jit-secret-abc123in thecli_executesubprocess env, and httpbin echoed backX-Jit-Authon thehttp_requestcall (the new header-wiring path). Stable across repeat runs.
Non-blocking follow-up (not gating this PR)
The regression tests cover the static provider round-trip; worth adding an sts_assume_role round-trip too (ParseForgeConfig on the doc's STS block -> NewInjector). It's the same string-typed field so there's no correctness risk today, but it pins the shape the docs advertise. Fine as a fast-follow.
Nice work turning this around.
Closes #215.
Summary
forge-core/credentials/package:Provider/Credentialinterfaces,Registry,Injector, per-invocationHandle.static— passthrough env/headers (bootstrap + tests).sts_assume_role— AWS STS AssumeRole with hand-rolled SigV4 (SDK-free, matches Forge's Bedrock signer pattern). Enforces AWS 15m–12h duration bounds; supportsexternal_id+ inline session policy.ForgeConfig.Credentials []CredentialSpec— operators declare per-tool JIT specs inforge.yaml. Empty → pre-R9 behavior.CLIExecuteTool.WithCredentialInjector(...).cli_executematerializes credentials on every Execute call, merges env into the subprocess, and revokes on defer.credential_issued,credential_revoked,credential_failed— payloads carry provider/tool/binary/ttl/env_keys, never the credential material itself.Acceptance criteria (from #215)
credentials.Credential+credentials.Providerinterfaces inforge-core/credentials/sts_assume_roleprovider ships as the reference implementationforge.yaml)BeforeToolExec-equivalent materializes the credential and scopes the tool's env accordingly (viaInjector.Materializeinsidecli_execute.Execute)credential_issued+credential_revokedaudit events with provider, TTL, scopedocs/security/least-privilege-credentials.mdTest plan
go test ./forge-core/credentials/...— interfaces, registry, static provider, STS mock server (request form, SigV4 shape, error paths, duration bounds)go test ./forge-cli/tools/...— end-to-end cli_execute injection: env visible in subprocess, audit events fire, binary-scoping works, no-injector fallback is a no-opgo test ./forge-core/... ./forge-cli/...— full sweep greengofmt -w+golangci-lint runall clean