Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
285 changes: 281 additions & 4 deletions src/action/detectors/exec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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
*/
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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;
}
Loading
Loading