fix(egress): attribute subprocess proxy audit events to task/invocation (#338)#339
Conversation
…on (#338) Egress events for subprocess traffic (skill scripts, browser, DB/CLI binaries routed via HTTP_PROXY) were missing task_id and correlation_id. The in-process enforcer path tags them from ctx via EmitFromContext, but the egress proxy is a separate 127.0.0.1 HTTP forward proxy — the request arriving from a subprocess carries no Go context, so OnAttempt had nothing to attach. Re-attach identity across the process boundary via proxy credentials: - exec.go stamps the request's task + correlation IDs into the HTTP_PROXY URL userinfo (proxyURLWithIdentity). HTTP clients replay userinfo as a Basic Proxy-Authorization header on every request and CONNECT. - egress_proxy.go decodes it (identityFromRequest) and threads the IDs onto a new EgressAttempt struct passed to OnAttempt. - runner.go sets TaskID + CorrelationID on the proxy egress events. Degrades gracefully: binaries that ignore proxy creds are still enforced and audited, just without identity (pre-fix behaviour). Proxy-Authorization is stripped before forwarding upstream; CONNECT is a blind relay. Tests: identityFromRequest decode table, end-to-end attribution through a real proxied request (HTTP + CONNECT), proxyURLWithIdentity stamping, and executor HTTP_PROXY injection.
…ents (#338) Update audit-logging.md (example + source note + plain-Emit exception table), egress-control.md (audit-events section), and the comprehensive forge.md skill to reflect that subprocess proxy egress events now carry correlation_id + task_id, recovered from the Proxy-Authorization creds the subprocess replays. Note the graceful no-identity fallback for credential-ignoring binaries.
initializ-mk
left a comment
There was a problem hiding this comment.
Review — attribute subprocess proxy audit events to task/invocation (#338)
Approve. Clean, correctly-scoped fix. I traced every load-bearing claim to source and all hold; findings below are Low/nit only.
Claims verified against code (not the description)
- No credential leak upstream —
Proxy-AuthorizationisDel'd on the cloned outbound request (egress_proxy.go:130) whileidentityFromRequestreads the incoming request first (:114). CONNECT never forwards headers — it's a rawio.Copybyte relay, TLS negotiated inside the tunnel. ✓ - Graceful degradation — zero identity on absent/malformed/wrong-scheme/non-base64/no-colon (all 8 table-tested); emits empty IDs, byte-identical to pre-#338. ✓
- The integration question (would this be silently empty in production?) —
SkillTool.Executeforwards ctx verbatim toexecutor.Run(skill_tool.go:81), and that ctx is the A2A-request-scoped ctx withWithTaskID(params.ID)set in the handlers. This reuses the exact ctx the in-process enforcer already reads successfully — not new plumbing that could silently be empty. ✓ - Good defensive choices:
url.UserPassword+u.String()(percent-encodes, so odd IDs can't break the env-var URL); spoofing-within-trust-boundary correctly framed as a known correlation (not authz) limitation.
Findings (all Low / nit)
- [Low] Colon in a
task_idmis-splits attribution — see inline onidentityFromRequest. - [Nit] CONNECT identity path isn't end-to-end tested — see inline.
- [Info] IDs now travel in
HTTP_PROXYuserinfo (readable via/proc/self/environby the subprocess + children). Correlation identifiers, not secrets — noting only for completeness.
CI green across all 6 platforms + lint + integration. Nice work.
| if err != nil { | ||
| return egressIdentity{} | ||
| } | ||
| task, corr, found := strings.Cut(string(raw), ":") |
There was a problem hiding this comment.
[Low] Colon in a task ID mis-splits attribution. This strings.Cut runs on the flattened Basic credential, and Basic auth has no internal escaping. So even though url.UserPassword percent-encodes the URL correctly, the credential is base64(taskID + ":" + corrID) — a taskID of "a:b" produces base64("a:b:corr"), and Cut at the first : yields task="a", corr="b:corr".
params.id is client-supplied, so a task ID containing : corrupts its own correlation. Impact is limited to self-inflicted attribution noise (and the deliberate-spoofing case is already documented), so this is Low — but worth either a one-line note that IDs must be colon-free, or a fixed-shape encoding that can't collide with the separator.
| @@ -132,8 +155,9 @@ func (p *EgressProxy) handleHTTP(w http.ResponseWriter, req *http.Request) { | |||
| // hostname, then blind-relays bytes without decrypting TLS. | |||
| func (p *EgressProxy) handleConnect(w http.ResponseWriter, req *http.Request) { | |||
There was a problem hiding this comment.
[Nit] CONNECT identity attribution isn't end-to-end tested. handleConnect calls the same identityFromRequest, but the new TestEgressProxyAttributesIdentity only drives the plain-HTTP path — the PR body's "CONNECT exercised via… relay" covers the tunnel, not identity surfacing through it. Risk is minimal since it's the identical function call, but given the recurring lesson (a handler passing in isolation while the real wired path differs), a CONNECT-tunnel identity assertion would close the loop cheaply.
…view) Review findings on PR #339: - [Low] A task ID containing a raw ':' (params.id is client-supplied) would mis-split the flattened Basic credential, since Basic auth has no internal escaping and identityFromRequest cut at the first colon. Encode each ID with base64url (alphabet [A-Za-z0-9-_], colon-free) before it goes into the HTTP_PROXY userinfo, and decode each half on the proxy side. The separator can no longer collide with the data. Added a colon-bearing round-trip case and an invalid-inner-encoding case. - [Nit] Added TestEgressProxyAttributesIdentityCONNECT: drives a real HTTPS CONNECT tunnel through the proxy and asserts the IDs surface via handleConnect (the prior end-to-end test only covered plain HTTP). Uses a colon-bearing task ID to exercise the encoding through the tunnel too. Finding #3 (IDs readable via /proc/self/environ) is correlation data, not secrets — acknowledged inline in the proxyURLWithIdentity doc comment.
|
Thanks for the thorough trace — addressed in Finding #1 (colon in task ID mis-splits) — fixed. Went with the fixed-shape encoding rather than a doc caveat, since Finding #2 (CONNECT identity not end-to-end tested) — fixed. Added Finding #3 (IDs in
|
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review — fix commit 59f7736 ✅
Both findings from my prior review are resolved cleanly; nothing new introduced.
[Low] Colon in a task ID mis-splits attribution — fixed correctly
The IDs are now base64url-encoded (base64.RawURLEncoding) before entering the userinfo, and decoded on the proxy side (identityFromRequest). This is the right fix and it's structural, not cosmetic: base64url's alphabet is [A-Za-z0-9-_], so an encoded half can never contain the : separator — the Basic-credential ambiguity is eliminated rather than discouraged. Edge cases I checked all hold:
- Empty half (
task only/empty task):RawURLEncodingencodes""→""and decodes back with no error, so the empty ID round-trips. - Malformed inner (
!!!:@@@): decode fails → zero identity → fail-closed, matching the graceful-degradation contract. Newinner halves not base64urlcase pins it. - Encoder/decoder are exact inverses: on the wire
base64(b64url(task) ":" b64url(corr))↔ split-then-RawURLEncoding.Decodeat the proxy.
[Nit] CONNECT identity not end-to-end tested — fixed
New TestEgressProxyAttributesIdentityCONNECT drives a real httptest.NewTLSServer through an actual CONNECT tunnel and asserts the ID surfaces on OnAttempt — and it deliberately uses a colon-bearing task ID (urn:task:9), so it exercises the separator-safe encoding through the real HTTPS path in the same test. Exactly the loop-closer I asked for.
The comments on both sides now accurately describe the two-layer scheme and correctly note it's separator-safety, not confidentiality (these are correlation IDs, not secrets) — no misleading claims left behind.
Lint green; Test suite running at time of posting (test-only + a localized encode/decode swap, prior commit was green across all 6 platforms). Approve — ready to merge once Test lands.
The initial fix only covered SkillCommandExecutor, but the cli_execute
builtin (curl/gh/kubectl/... driven by the LLM) injects HTTP_PROXY through
its own buildEnv path. So subprocess egress from cli_execute still emitted
source=proxy audit events with no task_id/correlation_id — observed live:
{"event":"egress_blocked","fields":{"domain":"initializ.ai",
"mode":"allowlist","source":"proxy"}} ← no task_id/correlation_id
Thread ctx into CLIExecuteTool.buildEnv and run the proxy URL through the
same proxyURLWithIdentity stamping, so curl (and every other cli_execute
binary) replays the base64url task/correlation IDs as Proxy-Authorization.
These are the only two HTTP_PROXY-injecting executors; the browser uses
chromedp's own proxy path and is out of scope here.
Test: TestBuildEnv_ProxyIdentityStamped pins the stamped HTTP_PROXY for
cli_execute using the exact task/correlation IDs from the live repro.
…view) Review findings on PR #339: - [Low] A task ID containing a raw ':' (params.id is client-supplied) would mis-split the flattened Basic credential, since Basic auth has no internal escaping and identityFromRequest cut at the first colon. Encode each ID with base64url (alphabet [A-Za-z0-9-_], colon-free) before it goes into the HTTP_PROXY userinfo, and decode each half on the proxy side. The separator can no longer collide with the data. Added a colon-bearing round-trip case and an invalid-inner-encoding case. - [Nit] Added TestEgressProxyAttributesIdentityCONNECT: drives a real HTTPS CONNECT tunnel through the proxy and asserts the IDs surface via handleConnect (the prior end-to-end test only covered plain HTTP). Uses a colon-bearing task ID to exercise the encoding through the tunnel too. Finding #3 (IDs readable via /proc/self/environ) is correlation data, not secrets — acknowledged inline in the proxyURLWithIdentity doc comment.
What
Closes #338. Subprocess egress audit events (
egress_allowed/egress_blockedwith"source": "proxy") now carrytask_idandcorrelation_id, so proxied traffic from skill scripts, the browser, and DB/CLI binaries can be correlated back to the task/invocation that caused it.Why they were missing
Two emission paths, only one carried identity:
ctxviaEmitFromContext— fine.127.0.0.1HTTP forward proxy. Subprocesses reach it throughHTTP_PROXY, so the request that lands at the proxy has no Go context —OnAttempt(domain, allowed)had nothing to attach.Approach
Carry identity across the process boundary in the proxy credentials:
exec.gostamps the task + correlation IDs into theHTTP_PROXYuserinfo —http://<taskID>:<correlationID>@127.0.0.1:<port>(proxyURLWithIdentity).Proxy-Authorization: Basic base64(taskID:correlationID)on every request andCONNECT.identityFromRequest) and threads the IDs onto a newEgressAttemptstruct;runner.gosetsTaskID+CorrelationIDon the event.Safety
Proxy-Authorizationis stripped before the request is forwarded upstream;CONNECTis a blind byte relay (no headers forwarded).Tests
identityFromRequestdecode table (both IDs, partial, case-insensitive scheme, malformed, missing).proxyURLWithIdentitystamping table +SkillCommandExecutorHTTP_PROXYinjection.forge-core/security(incl.-race) andforge-cli/tools+runtimesuites green; lint clean.