diff --git a/agent-governance-opencode/config/default-policy.json b/agent-governance-opencode/config/default-policy.json index f04bb034f..da040da8a 100644 --- a/agent-governance-opencode/config/default-policy.json +++ b/agent-governance-opencode/config/default-policy.json @@ -76,7 +76,11 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(cat|less|more|head|tail|sed|awk)\\b[^\\n\\r]*(\\.env(\\.[\\w-]+)?|id_rsa|id_ed25519|~/.ssh|/\\.ssh/|/\\.aws/|/\\.azure/|/\\.config/gcloud|/\\.config/gh/hosts\\.yml|/\\.docker/config\\.json|/\\.kube/config|/\\.netrc|/\\.git-credentials|/\\.npmrc|/\\.pypirc|secrets?\\.json)", + "source": "\\b(cat|less|more|head|tail|sed|awk|nl|tac|xxd|od|hexdump|strings|base64|base32|cut|rev|cp|mv|install|dd|tee|grep|rg)\\b[^\\n\\r]*(\\.env(\\.[\\w-]+)?|id_rsa|id_ed25519|~/.ssh|/\\.ssh/|/\\.aws/|/\\.azure/|/\\.config/gcloud|/\\.config/gh/hosts\\.yml|/\\.docker/config\\.json|/\\.kube/config|/\\.netrc|/\\.git-credentials|/\\.npmrc|/\\.pypirc|secrets?\\.json|/proc/(?:\\d+|self|thread-self)/environ)", + "flags": "i" + }, + { + "source": "(?:^|[\\s;&|(])(?:<|source\\b)\\s+['\"]?(?:\\S*/)?(\\.env(\\.[\\w-]+)?|id_rsa|id_ed25519|/\\.ssh/|/\\.aws/|/\\.azure/|/\\.netrc|/\\.git-credentials|/\\.npmrc|/\\.pypirc|secrets?\\.json|/proc/(?:\\d+|self|thread-self)/environ)", "flags": "i" }, { @@ -84,7 +88,7 @@ "flags": "i" }, { - "source": "\\bprintenv\\b|\\benv\\s*(?:$|\\|)", + "source": "\\bprintenv\\b|(?:^|[\\s;&|(])env\\b\\s*(?:$|[|>])|\\bdeclare\\s+-x\\b\\s*(?:$|[|>])|\\bexport\\s+-p\\b|(?:^|[\\s;&|(])set\\b\\s*(?:$|[|>])", "flags": "i" }, { @@ -111,7 +115,7 @@ "flags": "i" }, { - "source": "(^|/)proc/\\d+/environ$", + "source": "(^|/)proc/(?:\\d+|self|thread-self)/environ$", "flags": "i" } ], diff --git a/agent-governance-opencode/lib/policy.mjs b/agent-governance-opencode/lib/policy.mjs index 8523add64..e9e3198c4 100644 --- a/agent-governance-opencode/lib/policy.mjs +++ b/agent-governance-opencode/lib/policy.mjs @@ -922,10 +922,12 @@ function isSafeEnvTemplateReadCommand(commandText) { return false; } - const sensitiveTokens = tokenizeCommand(commandText) - .map(stripCommandToken) - .filter(Boolean) - .filter((token) => token.includes(".env")); + const tokens = tokenizeCommand(commandText).map(stripCommandToken).filter(Boolean); + // For copy/move commands the final path argument is a write destination, not a + // read, so scaffolding a real .env from a template (`cp .env.example .env`) must + // not be classified as reading the sensitive destination. + const readTokens = dropCopyDestinationToken(tokens); + const sensitiveTokens = readTokens.filter((token) => token.includes(".env")); return ( sensitiveTokens.length > 0 && @@ -933,6 +935,24 @@ function isSafeEnvTemplateReadCommand(commandText) { ); } +function dropCopyDestinationToken(tokens) { + const commandName = getLastPathSegment(String(tokens[0] ?? "")).toLowerCase(); + if (!/^(cp|mv|install)$/.test(commandName)) { + return tokens; + } + // With an explicit target-directory option (`-t DIR` / `--target-directory`) + // the destination is that option's argument, not the trailing token, so every + // remaining path is a source and nothing should be dropped. + if (tokens.some((token) => /^(?:-t|--target-directory)(?:=|$)/i.test(token))) { + return tokens; + } + const lastPathIndex = tokens.reduce( + (acc, token, index) => (index === 0 || token.startsWith("-") ? acc : index), + -1, + ); + return lastPathIndex < 0 ? tokens : tokens.filter((_, index) => index !== lastPathIndex); +} + export function evaluateDirectResourceAccess(policy, context) { const candidates = collectDirectResourceCandidates({ cwd: context.cwd, diff --git a/agent-governance-opencode/test/policy.test.mjs b/agent-governance-opencode/test/policy.test.mjs index 76d5ada1b..d048489df 100644 --- a/agent-governance-opencode/test/policy.test.mjs +++ b/agent-governance-opencode/test/policy.test.mjs @@ -186,3 +186,101 @@ test("corrupt audit logs are reported invalid and fail closed on new decisions", await rm(root, { recursive: true, force: true }); }); + +test("evaluateOpenCodeTool denies credential reads regardless of the reader binary", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-opencode-secret-")); + const state = await loadPolicy({ auditPath: join(root, "audit.json") }); + + // The rule must key on the sensitive target, not on an allow-list of readers: + // copy/encode/dump utilities and shell redirection are all credential reads. + const denied = [ + "cp .env /tmp/x", + "xxd ~/.ssh/id_rsa", + "strings id_rsa", + "base64 .env", + "dd if=.env", + "read k < .env", + "cat /proc/self/environ", + "cp -t /exfil .env", // GNU target-directory form: .env is still the source + ]; + for (const command of denied) { + const result = await evaluateOpenCodeTool(state, { + tool: "bash", + args: { command }, + sessionId: "secret-session", + cwd: root, + }); + assert.equal(result.effect, "deny", `expected deny for: ${command}`); + } + + // The .env template allow-list and unrelated copies must not be denied, + // including scaffolding a real .env from a template + for (const command of [ + "cat .env.example", + "cp README.md docs/", + "cp .env.example .env", + "cp -t /dest .env.example", // GNU target-directory form of the template copy + ]) { + const result = await evaluateOpenCodeTool(state, { + tool: "bash", + args: { command }, + sessionId: "secret-allow-session", + cwd: root, + }); + assert.notEqual(result.effect, "deny", `expected non-deny for: ${command}`); + } + + await rm(root, { recursive: true, force: true }); +}); + +test("evaluateOpenCodeTool denies environment dumps in redirect and builtin forms", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-opencode-envdump-")); + const state = await loadPolicy({ auditPath: join(root, "audit.json") }); + + for (const command of ["env > /tmp/leak", "declare -x", "export -p"]) { + const result = await evaluateOpenCodeTool(state, { + tool: "bash", + args: { command }, + sessionId: "envdump-session", + cwd: root, + }); + assert.equal(result.effect, "deny", `expected deny for: ${command}`); + } + + // Legitimate uses of env/set/declare and incidental ".env" mentions must not + // be denied: `declare -x VAR=val` exports a variable, and a + // string containing `<.env>` is not an `env >` redirection. + for (const command of [ + "env FOO=bar node app.js", + "set -e", + "declare -x MYVAR=1", + 'echo "put creds in <.env>"', + ]) { + const result = await evaluateOpenCodeTool(state, { + tool: "bash", + args: { command }, + sessionId: "envdump-allow-session", + cwd: root, + }); + assert.notEqual(result.effect, "deny", `expected non-deny for: ${command}`); + } + + await rm(root, { recursive: true, force: true }); +}); + +test("evaluateOpenCodeTool denies /proc/self/environ reads via the read tool", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-opencode-proc-")); + const state = await loadPolicy({ auditPath: join(root, "audit.json") }); + + for (const filePath of ["/proc/1234/environ", "/proc/self/environ", "/proc/thread-self/environ"]) { + const result = await evaluateOpenCodeTool(state, { + tool: "read", + args: { filePath }, + sessionId: "proc-session", + cwd: root, + }); + assert.equal(result.effect, "deny", `expected deny for: ${filePath}`); + } + + await rm(root, { recursive: true, force: true }); +});