Skip to content
Open
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
10 changes: 7 additions & 3 deletions agent-governance-opencode/config/default-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,19 @@
"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"
},
{
"source": "\\b(Get-Content|gc|type)\\b[^\\n\\r]*(\\.env(\\.[\\w-]+)?|id_rsa|id_ed25519|\\\\\\.ssh\\\\|\\\\\\.aws\\\\|\\\\\\.azure\\\\|\\\\\\.config\\\\gcloud|\\\\\\.config\\\\gh\\\\hosts\\.yml|\\\\\\.docker\\\\config\\.json|\\\\\\.kube\\\\config|\\\\\\.netrc|\\\\\\.git-credentials|\\\\\\.npmrc|\\\\\\.pypirc|secrets?\\.json)",
"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"
},
{
Expand All @@ -111,7 +115,7 @@
"flags": "i"
},
{
"source": "(^|/)proc/\\d+/environ$",
"source": "(^|/)proc/(?:\\d+|self|thread-self)/environ$",
"flags": "i"
}
],
Expand Down
28 changes: 24 additions & 4 deletions agent-governance-opencode/lib/policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -922,17 +922,37 @@ 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 &&
sensitiveTokens.every((token) => SAFE_ENV_TEMPLATE_NAME.test(getLastPathSegment(token)))
);
}

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,
Expand Down
98 changes: 98 additions & 0 deletions agent-governance-opencode/test/policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Loading