From 850cc48e557fbe8ff679e15638fd7f0c264d19d9 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 23 Jun 2026 21:35:01 -0600 Subject: [PATCH 1/2] feat(proxy): record sandbox env bindings --- README.md | 4 + docs/AGENT_PROXY.md | 4 +- .../agent_shell_ca_env_test.go | 38 ++++++++ go/cmd/coding-ethos-run/main_test.go | 7 ++ go/cmd/coding-ethos-run/runtime_exec.go | 97 ++++++++++++++++--- go/internal/managedcapture/capture.go | 1 + go/internal/sandbox/sandbox.go | 3 + 7 files changed, 142 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dd698559..4567a677 100644 --- a/README.md +++ b/README.md @@ -661,6 +661,10 @@ status report includes `agent_api_proxy`, and pass-through requests record body-free `proxy.pass_through` evidence with `payload_body_retained=false`. +When the sandboxed agent shell receives explicit proxy routing or interception +CA trust, sandbox evidence records only the injected variable names in +`env_bindings`; proxy URLs and CA paths are not copied into the evidence. + Agent search, glob, and read PostToolUse hooks add compact code-intel enrichment when the repo-local index is available. The TOON hint includes detected repo paths, likely symbols, direct graph edges, repeated-failure diff --git a/docs/AGENT_PROXY.md b/docs/AGENT_PROXY.md index 842be9aa..0238dbfd 100644 --- a/docs/AGENT_PROXY.md +++ b/docs/AGENT_PROXY.md @@ -206,7 +206,9 @@ CA trust is scoped to the sandboxed child only. The interception CA certificate is bound into the child via a `ReadPaths` bind plus the `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, and `NODE_EXTRA_CA_CERTS` environment variables, so OpenSSL-, Python-requests-, and Node-based agents trust the minted -leaves. The host trust store is never modified. +leaves. Sandbox evidence records only the injected environment variable names in +`env_bindings`, never proxy URLs or CA paths. The host trust store is never +modified. When interception cannot run for an allow-listed host (for example a leaf cannot be minted), `proxy.interception.on_error` decides the outcome: `fail_closed` diff --git a/go/cmd/coding-ethos-run/agent_shell_ca_env_test.go b/go/cmd/coding-ethos-run/agent_shell_ca_env_test.go index 83f5f8bd..ad7b53b9 100644 --- a/go/cmd/coding-ethos-run/agent_shell_ca_env_test.go +++ b/go/cmd/coding-ethos-run/agent_shell_ca_env_test.go @@ -4,6 +4,7 @@ package main import ( + "slices" "strings" "testing" ) @@ -56,3 +57,40 @@ func TestAgentShellProcessEnvReplacesHostCAWhenInterceptionEnabled(t *testing.T) } } } + +func TestAgentShellProcessEnvOverridesHostProxyWhenRoutingEnabled(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://host-proxy.invalid:8080") + t.Setenv("HTTPS_PROXY", "http://host-proxy.invalid:8080") + t.Setenv("http_proxy", "http://host-proxy.invalid:8080") + t.Setenv("https_proxy", "http://host-proxy.invalid:8080") + t.Setenv(envAgentAPIProxyEnabled, "1") + t.Setenv(envAgentAPIProxyURL, "http://127.0.0.1:18080") + + env := agentShellProcessEnv(t.TempDir(), "/wrapper/git", "/real/git", "") + + for _, key := range agentShellProxyEnvNames() { + value, found := envValue(env, key) + if !found || value != "http://127.0.0.1:18080" { + t.Fatalf("%s = %q (found=%v), want explicit agent proxy URL", key, value, found) + } + } +} + +func TestAgentShellEnvBindingsRecordNamesOnly(t *testing.T) { + t.Setenv(envAgentAPIProxyEnabled, "1") + t.Setenv(envAgentAPIProxyURL, "http://127.0.0.1:18080") + + got := agentShellEnvBindings("/sandbox/intercept-ca.pem") + + for _, want := range append(agentShellProxyEnvNames(), agentShellCAEnvNames()...) { + if !slices.Contains(got, want) { + t.Fatalf("env bindings missing %q: %#v", want, got) + } + } + + for _, leaked := range []string{"127.0.0.1", "18080", "/sandbox/intercept-ca.pem"} { + if strings.Contains(strings.Join(got, "\n"), leaked) { + t.Fatalf("env bindings leaked value %q: %#v", leaked, got) + } + } +} diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 01e87116..33015075 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -1478,6 +1478,8 @@ func TestAgentShellSandboxPlanRoutesThroughNativeWrapper(t *testing.T) { t.Setenv("TMPDIR", "/tmp/stale") t.Setenv("WAYLAND_DISPLAY", "wayland-1") t.Setenv("XAUTHORITY", filepath.Join(home, ".Xauthority")) + t.Setenv(envAgentAPIProxyEnabled, "1") + t.Setenv(envAgentAPIProxyURL, "http://127.0.0.1:18080") paths := runtimeTestPaths(t) paths.GitDir = filepath.Join( @@ -1517,6 +1519,11 @@ func TestAgentShellSandboxPlanRoutesThroughNativeWrapper(t *testing.T) { if !slices.Contains(env, "CODING_ETHOS_AGENT_SHELL_SANDBOX=1") { t.Fatalf("agent-shell env does not mark sandbox execution: %#v", env) } + for _, want := range agentShellProxyEnvNames() { + if !slices.Contains(plan.Evidence.EnvBindings, want) { + t.Fatalf("sandbox evidence missing env binding %q: %#v", want, plan.Evidence) + } + } if !slices.Contains( env, "TMPDIR="+filepath.Join(paths.Root, sandbox.SandboxTempWritePath), diff --git a/go/cmd/coding-ethos-run/runtime_exec.go b/go/cmd/coding-ethos-run/runtime_exec.go index 23025196..c0c8ffd6 100644 --- a/go/cmd/coding-ethos-run/runtime_exec.go +++ b/go/cmd/coding-ethos-run/runtime_exec.go @@ -197,6 +197,7 @@ func agentShellSandboxPlan( agentWritePaths = append(agentWritePaths, agentWriteDirs...) interceptEvidence := agentShellInterceptEvidence(paths) interceptCACertPath := agentShellInterceptCACertPath(interceptEvidence) + envBindings := agentShellEnvBindings(interceptCACertPath) plan, err := sandbox.BuildPlan(sandbox.Request{ Cwd: paths.InvocationCWD, @@ -210,6 +211,7 @@ func agentShellSandboxPlan( StrategicIntent: agentShellStrategicIntent(), WritePaths: append(agentShellProtectedWritePaths(paths), agentWritePaths...), ReadPaths: agentShellInterceptReadPaths(interceptEvidence), + EnvBindings: envBindings, AllowGitWrites: true, RequiresGit: true, RequiresNetwork: true, @@ -547,6 +549,7 @@ func agentShellProcessEnv( root, gitWrapper, realGitBind, interceptCACertPath string, ) []string { env := os.Environ() + proxyEnv := agentAPIProxyRoutingEnv() wrapperDir := filepath.Dir(gitWrapper) pathValue := wrapperDir + string(os.PathListSeparator) + os.Getenv("PATH") tempDir := filepath.Join(root, sandbox.SandboxTempWritePath) @@ -555,16 +558,15 @@ func agentShellProcessEnv( // replacement CA; otherwise they are preserved so a configured custom trust // bundle continues to apply inside the agent shell. replaceCAEnv := strings.TrimSpace(interceptCACertPath) != "" + replaceProxyEnv := len(proxyEnv) > 0 + filterOptions := agentShellEnvFilterOptions{ + ReplaceCAEnv: replaceCAEnv, + ReplaceProxyEnv: replaceProxyEnv, + } filtered := make([]string, 0, len(env)+agentShellInjectedEnv) for _, item := range env { - if strings.HasPrefix(item, "PATH=") || - strings.HasPrefix(item, realgit.Env+"=") || - strings.HasPrefix(item, "CODING_ETHOS_AGENT_SHELL_SANDBOX=") || - strings.HasPrefix(item, "GPG_TTY=") || - strings.HasPrefix(item, "TMPDIR=") || - (replaceCAEnv && agentShellFilteredCAEnv(item)) || - agentShellFilteredGUIEnv(item) { + if agentShellFilteredEnv(item, filterOptions) { continue } @@ -591,9 +593,33 @@ func agentShellProcessEnv( ) } + if replaceProxyEnv { + filtered = append(filtered, agentShellProxyEnv(proxyEnv)...) + } + return filtered } +type agentShellEnvFilterOptions struct { + ReplaceCAEnv bool + ReplaceProxyEnv bool +} + +func agentShellFilteredEnv(item string, options agentShellEnvFilterOptions) bool { + return agentShellFilteredRuntimeEnv(item) || + (options.ReplaceCAEnv && agentShellFilteredCAEnv(item)) || + (options.ReplaceProxyEnv && agentShellFilteredProxyEnv(item)) || + agentShellFilteredGUIEnv(item) +} + +func agentShellFilteredRuntimeEnv(item string) bool { + return strings.HasPrefix(item, "PATH=") || + strings.HasPrefix(item, realgit.Env+"=") || + strings.HasPrefix(item, "CODING_ETHOS_AGENT_SHELL_SANDBOX=") || + strings.HasPrefix(item, "GPG_TTY=") || + strings.HasPrefix(item, "TMPDIR=") +} + // agentShellFilteredCAEnv reports whether an inherited env entry names one of // the CA trust variables the sandbox replaces, so a host value never leaks past // the injected interception trust binding. @@ -603,6 +629,54 @@ func agentShellFilteredCAEnv(item string) bool { strings.HasPrefix(item, "NODE_EXTRA_CA_CERTS=") } +func agentShellFilteredProxyEnv(item string) bool { + for _, name := range agentShellProxyEnvNames() { + if strings.HasPrefix(item, name+"=") { + return true + } + } + + return false +} + +func agentShellProxyEnv(proxyEnv map[string]string) []string { + items := []string{} + + for _, name := range agentShellProxyEnvNames() { + value := strings.TrimSpace(proxyEnv[name]) + if value != "" { + items = append(items, name+"="+value) + } + } + + return items +} + +func agentShellEnvBindings(interceptCACertPath string) []string { + bindings := []string{} + proxyEnv := agentAPIProxyRoutingEnv() + + for _, name := range agentShellProxyEnvNames() { + if strings.TrimSpace(proxyEnv[name]) != "" { + bindings = append(bindings, name) + } + } + + if strings.TrimSpace(interceptCACertPath) != "" { + bindings = append(bindings, agentShellCAEnvNames()...) + } + + return bindings +} + +func agentShellProxyEnvNames() []string { + return []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} +} + +func agentShellCAEnvNames() []string { + return []string{"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} +} + // agentShellInterceptCAEnv returns the CA trust variables that point the // sandboxed child at the local interception CA, or nil when interception is // disabled and no cert path was bound. @@ -612,11 +686,12 @@ func agentShellInterceptCAEnv(interceptCACertPath string) []string { return nil } - return []string{ - "SSL_CERT_FILE=" + path, - "REQUESTS_CA_BUNDLE=" + path, - "NODE_EXTRA_CA_CERTS=" + path, + items := make([]string, 0, len(agentShellCAEnvNames())) + for _, name := range agentShellCAEnvNames() { + items = append(items, name+"="+path) } + + return items } func agentShellGPGTTY() string { diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index ddf28847..188698da 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -1265,6 +1265,7 @@ func cloneSandboxEvidence(evidence sandbox.Evidence) sandbox.Evidence { ) evidence.ReadPaths = append([]string(nil), evidence.ReadPaths...) evidence.WritePaths = append([]string(nil), evidence.WritePaths...) + evidence.EnvBindings = append([]string(nil), evidence.EnvBindings...) return evidence } diff --git a/go/internal/sandbox/sandbox.go b/go/internal/sandbox/sandbox.go index aa60d66a..403772de 100644 --- a/go/internal/sandbox/sandbox.go +++ b/go/internal/sandbox/sandbox.go @@ -118,6 +118,7 @@ type Capabilities struct { GitTargetPaths []string `json:"git_target_paths,omitempty"` ReadPaths []string `json:"read_paths,omitempty"` WritePaths []string `json:"write_paths,omitempty"` + EnvBindings []string `json:"env_bindings,omitempty"` CPUQuotaPercent int `json:"cpu_quota_percent,omitempty"` MemoryMB int `json:"memory_mb,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` @@ -158,6 +159,7 @@ type Evidence struct { Reason string `json:"reason,omitempty"` ReadPaths []string `json:"read_paths,omitempty"` WritePaths []string `json:"write_paths,omitempty"` + EnvBindings []string `json:"env_bindings,omitempty"` HiddenCredentialDirs []string `json:"hidden_credential_dirs,omitempty"` Tags []string `json:"tags,omitempty"` Command []string `json:"command,omitempty"` @@ -681,6 +683,7 @@ func (request Request) evidence() Evidence { Tags: append([]string(nil), request.Capabilities.Tags...), ReadPaths: readPaths, WritePaths: writePaths, + EnvBindings: append([]string(nil), request.Capabilities.EnvBindings...), TimeoutSeconds: request.Capabilities.TimeoutSeconds, MemoryMB: request.Capabilities.MemoryMB, CPUQuotaPercent: request.Capabilities.CPUQuotaPercent, From f271dad466e05a81163dc62ffe412fb707b0d980 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 23 Jun 2026 21:42:39 -0600 Subject: [PATCH 2/2] chore(syncstate): reuse pass status constant --- go/internal/syncstate/state.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/go/internal/syncstate/state.go b/go/internal/syncstate/state.go index 3dc14ed6..b116095b 100644 --- a/go/internal/syncstate/state.go +++ b/go/internal/syncstate/state.go @@ -34,6 +34,7 @@ const ( sourceStatusCurrent = "current" sourceStatusMissing = "missing" sourceStatusStale = "stale" + reportStatusPass = "pass" stateDirMode = 0o700 stateFileMode = 0o600 ) @@ -253,7 +254,7 @@ func Plan(repoRoot, requestedAction string, artifacts []Artifact) Report { report.PlannedWriteCount = plannedWriteCount(report.Artifacts) if report.PlannedWriteCount == 0 { - report.Status = "pass" + report.Status = reportStatusPass } else { report.Status = artifactStatusPlanned } @@ -295,7 +296,7 @@ func RepairPlan(repoRoot string) (Report, error) { report.PlannedWriteCount = plannedWriteCount(report.Artifacts) if report.PlannedWriteCount == 0 { - report.Status = "pass" + report.Status = reportStatusPass } else { report.Status = artifactStatusPlanned } @@ -466,7 +467,7 @@ func doctorStatus(report Report) string { } } - return "pass" + return reportStatusPass } func plannedWriteCount(artifacts []ArtifactReport) int {