Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/AGENT_PROXY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
38 changes: 38 additions & 0 deletions go/cmd/coding-ethos-run/agent_shell_ca_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package main

import (
"slices"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
}
}
7 changes: 7 additions & 0 deletions go/cmd/coding-ethos-run/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down
97 changes: 86 additions & 11 deletions go/cmd/coding-ethos-run/runtime_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -210,6 +211,7 @@ func agentShellSandboxPlan(
StrategicIntent: agentShellStrategicIntent(),
WritePaths: append(agentShellProtectedWritePaths(paths), agentWritePaths...),
ReadPaths: agentShellInterceptReadPaths(interceptEvidence),
EnvBindings: envBindings,
AllowGitWrites: true,
RequiresGit: true,
RequiresNetwork: true,
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions go/internal/managedcapture/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions go/internal/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions go/internal/syncstate/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
sourceStatusCurrent = "current"
sourceStatusMissing = "missing"
sourceStatusStale = "stale"
reportStatusPass = "pass"
stateDirMode = 0o700
stateFileMode = 0o600
)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -466,7 +467,7 @@ func doctorStatus(report Report) string {
}
}

return "pass"
return reportStatusPass
}

func plannedWriteCount(artifacts []ArtifactReport) int {
Expand Down
Loading