From e948b088041bdc5ac1707e40f436e5edd67426c7 Mon Sep 17 00:00:00 2001 From: AtropinolTT Date: Sun, 16 Aug 2026 08:22:58 +0800 Subject: [PATCH] feat(policy): gate credential-bearing file writes and .env as protected metadata containsSecretMaterial() matches strong credential/private-key patterns (AKIA/ASIA, ghp_/gho_/ghu_/ghs_/github_pat_, sk-(proj-)?, and BEGIN RSA|OPENSSH|EC|ECDSA|DSA|ENCRYPTED PRIVATE KEY blocks) in file-mutation payloads - write/edit content fields and str_replace_editor old_str/new_str/file_text. Hits route to ask instead of the fast-path allow: classifier-eligible for write/edit (their content fields are redacted by the classifier sanitizer), direct ask for str_replace_editor (its payload fields are not covered by the sanitizer's content-key redaction, so the credential would otherwise reach the classifier in cleartext). .env and .env.* (multi-segment included) join isProtectedProjectPath, so env-file writes require explicit authorization. The str_replace_editor secret check runs before the protected-path check so credential writes to .env can never be routed to the classifier in cleartext. Weak patterns (password = "...") are deliberately excluded - they are routine in fixtures/templates. Credential hits are ask rather than hard deny, consistent with the DESIGN.md principle that blanket rules should not block targets the user explicitly authorized. Tests: table-driven policy tests covering every regex branch across write/edit and str_replace_editor (credential -> ask; ordinary fixture write -> allow; .env* -> ask; multi-segment .env.development.local -> ask). Full suite: 68/68 passing; tsc --noEmit clean (host + client). --- src/paths.ts | 1 + src/policy.ts | 22 ++++++++++++ tests/policy.spec.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/src/paths.ts b/src/paths.ts index 3e6fb0d..2441caf 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -149,6 +149,7 @@ export function isProtectedProjectPath(target: string, roots: PolicyRoots): bool if (first !== undefined && ['.git', '.vscode', '.idea', '.husky', '.dsh'].includes(first)) return true const base = api.basename(normalized).toLowerCase() return ['.gitconfig', '.gitmodules', '.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile', '.mcp.json'].includes(base) + || base === '.env' || /^\.env(?:\.[a-z0-9-]+)+$/.test(base) } /** Deterministic destructive-target fuse. */ diff --git a/src/policy.ts b/src/policy.ts index 3082793..b07603d 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -35,6 +35,18 @@ function containsCredentialMaterial(argumentsValue: unknown): boolean { .test(serializedArguments(argumentsValue)) } +/** + * Strong credential material written into project files. Weak patterns such + * as `password = "..."` are deliberately excluded: they are routine in test + * fixtures and templates, and the classifier payload redacts content fields. + */ +function containsSecretMaterial(argumentsValue: unknown): boolean { + // gho_ (GitHub OAuth access token) 官方格式为前缀后 40 位;ghp_/ghu_/ghs_ 为 36 位。 + // 初版 plan/spec 称四者同为 36 位与官方格式不符,最终 review 修正为拆分为 40/36 两支。 + return /(?:-----BEGIN (?:RSA |OPENSSH |EC |ECDSA |DSA |ENCRYPTED )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\bgho_[A-Za-z0-9]{40}\b|\bgh[pus]_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b|\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b)/i + .test(serializedArguments(argumentsValue)) +} + const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|destroy|remove|erase|purge|drop|truncate|wipe|unlink|rmdir|reset|revoke)(?:$|[_-])/i const EXTERNAL_WRITE_TOOL = /(?:^|[_-])(?:deploy|publish|push|upload|send|post|release|merge|submit|create[-_]?(?:issue|pull[-_]?request))(?:$|[_-])/i const SECURITY_CHANGE_TOOL = /(?:^|[_-])(?:chmod|chown|permission|permissions|policy|grant|revoke|role|credential|credentials|secret|secrets|auth)(?:$|[_-])/i @@ -158,6 +170,9 @@ export function assessTool(exec: Readonly, roots: PolicyRoots, ar if (!isWithin(roots.workspace, normalized) || isProtectedProjectPath(normalized, roots)) { return { decision: 'ask', reason: `mutation of external or protected path requires specific user authorization: ${normalized}`, classifierEligible: true } } + if (containsSecretMaterial(exec.arguments)) { + return { decision: 'ask', reason: 'file content contains credential or private-key material; specific user authorization required', classifierEligible: true } + } return { decision: 'allow', reason: 'routine project-local file edit', classifierEligible: false } } @@ -177,6 +192,13 @@ export function assessTool(exec: Readonly, roots: PolicyRoots, ar ? { decision: 'allow', reason: 'read-only project inspection', classifierEligible: false } : { decision: 'ask', reason: `reading outside the workspace requires semantic review: ${normalized}`, classifierEligible: true } } + // 密钥检查必须先于路径检查:str_replace_editor 的 old_str/new_str/file_text 不在分类器 + // CONTENT_KEYS 脱敏范围内,携带凭据的 protected/external 路径写入(如 AKIA… 写 .env)若先 + // 命中路径分支会以 classifierEligible:true 把凭据明文送进分类器(I-2 复刻),必须硬 ask。 + // view 命令在上面已提前返回,不受此检查影响。 + if (containsSecretMaterial(exec.arguments)) { + return { decision: 'ask', reason: 'file content contains credential or private-key material; specific user authorization required', classifierEligible: false } + } if (!isWithin(roots.workspace, normalized) || isProtectedProjectPath(normalized, roots)) { return { decision: 'ask', reason: `mutation of external or protected path requires specific user authorization: ${normalized}`, classifierEligible: true } } diff --git a/tests/policy.spec.ts b/tests/policy.spec.ts index f36d22e..598ca9d 100644 --- a/tests/policy.spec.ts +++ b/tests/policy.spec.ts @@ -54,6 +54,90 @@ describe('tool policy', () => { expect(hardDenyReason(outbound, roots)).toMatch(/credential/) }) + const SECRET_PAYLOADS: Array<{ name: string; content: string }> = [ + { name: 'aws-access-key', content: `export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE` }, + { name: 'aws-session-key', content: `export AWS_SESSION_TOKEN=ASIAIOSFODNN7EXAMPLE` }, + { name: 'github-classic', content: `token = ghp_012345678901234567890123456789012345` }, + { name: 'github-fine-grained', content: `token = github_pat_0123456789abcdef_0123456789abcdef` }, + { name: 'github-user', content: `token = ghu_012345678901234567890123456789012345` }, + { name: 'github-org', content: `token = ghs_012345678901234567890123456789012345` }, + // gho_ (GitHub OAuth access token) 官方格式为前缀后 40 位字母数字(非 36 位), + // 载荷必须用真实 40 位形态,否则回归测试会验证错误的短 token(最终 review 修正) + { name: 'github-app', content: `token = gho_0123456789abcdef0123456789abcdef01234567` }, + { name: 'llm-key-plain', content: `sk-0123456789abcdef0123456789abcdef` }, + { name: 'llm-key-proj', content: `sk-proj-0123456789abcdef0123456789abcdef` }, + { name: 'openssh-key', content: `-----BEGIN OPENSSH PRIVATE KEY-----\nabc\ndef\n-----END OPENSSH PRIVATE KEY-----` }, + { name: 'rsa-key', content: `-----BEGIN RSA PRIVATE KEY-----\nabc\ndef\n-----END RSA PRIVATE KEY-----` }, + { name: 'ec-key', content: `-----BEGIN EC PRIVATE KEY-----\nabc\ndef\n-----END EC PRIVATE KEY-----` }, + { name: 'ecdsa-key', content: `-----BEGIN ECDSA PRIVATE KEY-----\nabc\ndef\n-----END ECDSA PRIVATE KEY-----` }, + { name: 'dsa-key', content: `-----BEGIN DSA PRIVATE KEY-----\nabc\ndef\n-----END DSA PRIVATE KEY-----` }, + { name: 'encrypted-key', content: `-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\ndef\n-----END ENCRYPTED PRIVATE KEY-----` }, + ] + + it('routes credential-bearing write/edit payloads to the classifier', () => { + const artifacts = new ArtifactRegistry() + for (const { content } of SECRET_PAYLOADS) { + expect(assessTool(execution('write', { file_path: '/work/repo/creds.txt', content }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: true }) + expect(assessTool(execution('edit', { file_path: '/work/repo/creds.txt', new_string: content }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: true }) + } + }) + + it('routes credential-bearing str_replace_editor payloads to direct ask', () => { + const artifacts = new ArtifactRegistry() + for (const { content } of SECRET_PAYLOADS) { + expect(assessTool(execution('str_replace_editor', { command: 'str_replace', path: '/work/repo/src/a.ts', old_str: 'x', new_str: content }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: false }) + } + // file_text 字段(Task 2 create 用例的覆盖在此保留):containsSecretMaterial 扫 + // serializedArguments,与字段名无关,但保留一条显式断言防回归 + const privateKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\ndef\n-----END OPENSSH PRIVATE KEY-----' + expect(assessTool(execution('str_replace_editor', { command: 'create', path: '/work/repo/keys/k.pem', file_text: privateKey }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: false }) + // insert 命令与 str_replace/create 走同一条 containsSecretMaterial 检查(最终 review 补) + expect(assessTool(execution('str_replace_editor', { command: 'insert', path: '/work/repo/src/a.ts', insert_line: 1, new_str: 'AKIAIOSFODNN7EXAMPLE' }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: false }) + }) + + it('asks directly for credential-bearing str_replace_editor writes to protected and external paths', () => { + const artifacts = new ArtifactRegistry() + // 回归(Critical 1):密钥检查必须先于路径检查——str_replace_editor 的 old_str/new_str/ + // file_text 不在分类器 CONTENT_KEYS 脱敏范围内,写 .env/外部路径携带凭据时若先命中 + // 路径分支会以 classifierEligible:true 把凭据明文送进分类器(I-2 复刻),必须硬 ask + expect(assessTool(execution('str_replace_editor', { command: 'str_replace', path: '/work/repo/.env', old_str: 'x', new_str: 'AKIAIOSFODNN7EXAMPLE' }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: false }) + expect(assessTool(execution('str_replace_editor', { command: 'create', path: '/work/repo/.env', file_text: 'export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE' }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: false }) + expect(assessTool(execution('str_replace_editor', { command: 'str_replace', path: '/outside/a.ts', old_str: 'x', new_str: 'sk-0123456789abcdef0123456789abcdef' }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: false }) + }) + + it('keeps ordinary project writes on the fast path', () => { + const artifacts = new ArtifactRegistry() + expect(assessTool(execution('write', { file_path: '/work/repo/readme.md', content: '# hello\npassword = "example" in a fixture' }), roots, artifacts)) + .toMatchObject({ decision: 'allow' }) + // 弱模式排除(write 与 edit 各一,spec §3.1③ 第 3 项;edit 断言为最终 review 补) + expect(assessTool(execution('edit', { file_path: '/work/repo/readme.md', new_string: 'password = "example" in a fixture' }), roots, artifacts)) + .toMatchObject({ decision: 'allow' }) + }) + + it('treats .env and .env.* as protected project metadata', () => { + const artifacts = new ArtifactRegistry() + for (const filePath of ['/work/repo/.env', '/work/repo/.env.local', '/work/repo/.env.production']) { + expect(assessTool(execution('write', { file_path: filePath, content: 'DEBUG=1' }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: true }) + } + }) + + it('treats multi-segment .env files as protected metadata', () => { + const artifacts = new ArtifactRegistry() + for (const filePath of ['/work/repo/.env.development.local', '/work/repo/.env.production.local']) { + expect(assessTool(execution('write', { file_path: filePath, content: 'DEBUG=1' }), roots, artifacts)) + .toMatchObject({ decision: 'ask', classifierEligible: true }) + } + }) + it('fast-paths audited Harness session and read-only tools', () => { const artifacts = new ArtifactRegistry() for (const name of ['todo_write', 'ask_user_question', 'create_goal', 'exit_plan_mode', 'skill', 'report', 'job_list', 'job_kill', 'schedule_list', 'session_search']) {