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
1 change: 1 addition & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
22 changes: 22 additions & 0 deletions src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,6 +170,9 @@ export function assessTool(exec: Readonly<ToolExecution>, 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 }
}

Expand All @@ -177,6 +192,13 @@ export function assessTool(exec: Readonly<ToolExecution>, 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 }
}
Expand Down
84 changes: 84 additions & 0 deletions tests/policy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand Down