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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

### Changed
- Shell metacharacter-only runtime findings now stay below the approval threshold: benign commands with redirects or simple shell metacharacters are scored as low risk, auto-allowed locally, and no longer generate audit events, Cloud sync, or pending approvals on that signal alone.
- Runtime approval prompts and AgentGuard skill guidance now require agents to show the exact `agentguard approve --action-id ... --once` command and wait for explicit user approval for that exact action before approving and retrying.

### Fixed
- OpenClaw runtime protection now recognizes alternate tool name fields such as `tool_name`, `name`, and `id`, and classifies `exec`/`execute` tools as shell actions before policy evaluation.
- Repeated matching protected actions now reuse the existing pending approval id instead of creating duplicate pending approvals.
- AgentGuard approval/self commands wrapped through simple shell launchers such as `/bin/zsh -lc` are now treated as self-commands and skipped by runtime protection.

## [1.1.25] - 2026-05-28

### Added
Expand Down
5 changes: 3 additions & 2 deletions docs/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ approval and includes an approval command:
agentguard approve --action-id act_local_... --once
```

Run that command only after the user explicitly approves, then retry the
original action once. If the action id was not visible, inspect
Show that command to the user before running it. Run it only after the user
explicitly approves that exact action; do not let the agent approve its own
blocked command proactively. Then retry the original action once. If the action id was not visible, inspect
`agentguard approvals list --json`; use `agentguard approve --last --once`
only when there is exactly one relevant unexpired pending approval.
5 changes: 3 additions & 2 deletions docs/openclaw.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ approval. The block reason includes:
agentguard approve --action-id act_local_... --once
```

Run that command only after the user explicitly approves, then retry the
original action once. If the action id was not visible in the OpenClaw message,
Show that command to the user before running it. Run it only after the user
explicitly approves that exact action; do not let the agent approve its own
blocked command proactively. Then retry the original action once. If the action id was not visible in the OpenClaw message,
inspect pending approvals first:

```bash
Expand Down
2 changes: 1 addition & 1 deletion skills/agentguard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Connect behavior:

If the user writes `/agentguard cli <args...>`, execute `agentguard <args...>` directly.

When AgentGuard returns `confirm` or a block reason that includes `Approve once: agentguard approve --action-id ... --once`, do not retry the protected action until the user explicitly approves. Treat user replies such as "yes", "approve", "approved", "confirm", "confirmed", "continue", "go ahead", "execute", "run it", "同意", "确认", "批准", "继续", or "执行" as explicit approval for the most recent protected action. After approval, run exactly the provided `agentguard approve --action-id ... --once` command, then retry the original action once. If the action id is unavailable, use `agentguard approvals list --json`; only use `agentguard approve --last --once` when there is exactly one relevant unexpired pending approval. If multiple pending approvals exist, ask the user to choose a specific action id.
When AgentGuard returns `confirm` or a block reason that includes `Approve once ... agentguard approve --action-id ... --once`, do not retry the protected action until the user explicitly approves. Show the exact approval command to the user before running it. Never run an approval command proactively, and never infer approval from context or from the agent's own plan. Treat user replies such as "yes", "approve", "approved", "confirm", "confirmed", "continue", "go ahead", "execute", "run it", "同意", "确认", "批准", "继续", or "执行" as explicit approval for the most recent protected action only after the user has seen the command and understands which action is being approved. After approval, run exactly the provided `agentguard approve --action-id ... --once` command, then retry the original action once. If the action id is unavailable, use `agentguard approvals list --json`; only use `agentguard approve --last --once` when there is exactly one relevant unexpired pending approval. If multiple pending approvals exist, ask the user to choose a specific action id.

Do **not** route plain `/agentguard scan`, `/agentguard action`, `/agentguard patrol`, `/agentguard trust`, `/agentguard report`, `/agentguard config`, `/agentguard checkup`, `/agentguard checkup --json`, or natural-language requests like "run agentguard checkup" through the packaged CLI. Those are this skill's higher-level workflows. Only use the packaged CLI checkup path when the user includes `--against-advisory <id>` or explicitly writes `/agentguard cli checkup ...`.

Expand Down
36 changes: 13 additions & 23 deletions src/action/detectors/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ const AUDIT_COMMAND_PREFIXES = [
];

/**
* Shell metacharacters that disqualify a command from the safe list
* Shell metacharacters that disqualify a command from the safe list.
* Runtime policy scores these as low-risk signals so benign commands with
* metacharacters do not enter an approval flow on this signal alone.
*/
const SHELL_METACHAR_PATTERN = /[;|&`$(){}<>!#\n\t]/;
const SHELL_METACHAR_PATTERN = /\$\(|&&|\|\||>>|[|&;^!`%$<>"'*?\\\n\r\t]/;

/**
* Fork bomb patterns (regex-based for variants with spaces)
Expand Down Expand Up @@ -275,27 +277,15 @@ export function analyzeExecCommand(
}
}

// Check for shell injection patterns
const shellInjectionPatterns = [
/;\s*\w+/, // ; command
/\|\s*\w+/, // | command
/`[^`]+`/, // `command`
/\$\([^)]+\)/, // $(command)
/&&\s*\w+/, // && command
/\|\|\s*\w+/, // || command
];

for (const pattern of shellInjectionPatterns) {
if (pattern.test(fullCommand)) {
riskTags.push('SHELL_INJECTION_RISK');
evidence.push({
type: 'shell_injection',
field: 'command',
description: 'Command contains shell metacharacters',
});
if (riskLevel === 'low') riskLevel = 'medium';
break;
}
// Check for shell metacharacters. These are advisory by themselves; combined
// with dangerous commands or sensitive access, the stronger finding controls.
if (SHELL_METACHAR_PATTERN.test(fullCommand)) {
riskTags.push('SHELL_INJECTION_RISK');
evidence.push({
type: 'shell_injection',
field: 'command',
description: 'Command contains shell metacharacters',
});
}

// Check environment variables for secrets
Expand Down
29 changes: 22 additions & 7 deletions src/adapters/openclaw-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,8 @@ export function registerOpenClawPlugin(
api.on('before_tool_call', async (event: unknown, ctx?: unknown) => {
try {
// Try to infer plugin from tool name
const toolEvent = event as { toolName?: string };
const pluginId = toolEvent.toolName ? getPluginIdFromTool(toolEvent.toolName) : null;
const toolName = readOpenClawToolName(event);
const pluginId = toolName ? getPluginIdFromTool(toolName) : null;

// Check if plugin is untrusted
if (pluginId) {
Expand All @@ -444,14 +444,14 @@ export function registerOpenClawPlugin(
}

if (runtimeProtectionEnabled) {
const runtimeActionType = mapOpenClawToolToRuntimeAction(toolEvent.toolName, event);
const runtimeActionType = mapOpenClawToolToRuntimeAction(toolName, event);
try {
const runtimeResult = await runProtectAction({
config,
rawInput: event,
agentHost: 'openclaw',
actionType: runtimeActionType,
toolName: toolEvent.toolName,
toolName,
sessionId: readOpenClawSessionId(event, ctx),
decisionMode: options.decisionMode ?? 'local-first',
});
Expand Down Expand Up @@ -509,8 +509,8 @@ export function registerOpenClawPlugin(
api.on('after_tool_call', async (event: unknown) => {
try {
const input = adapter.parseInput(event);
const toolEvent = event as { toolName?: string };
const pluginId = toolEvent.toolName ? getPluginIdFromTool(toolEvent.toolName) : null;
const toolName = readOpenClawToolName(event);
const pluginId = toolName ? getPluginIdFromTool(toolName) : null;
writeAuditLog(input, null, pluginId);
} catch {
// Non-critical
Expand Down Expand Up @@ -544,6 +544,8 @@ function mapOpenClawToolToRuntimeAction(
normalized === 'command' ||
normalized === 'terminal' ||
normalized === 'run' ||
normalized.includes('exec') ||
normalized.includes('execute') ||
normalized.includes('shell') ||
normalized.includes('terminal') ||
normalized.includes('command') ||
Expand Down Expand Up @@ -619,6 +621,12 @@ function mapOpenClawToolToRuntimeAction(
return 'other';
}

function readOpenClawToolName(event: unknown): string | undefined {
const record = isRecord(event) ? event : undefined;
const value = record?.toolName ?? record?.tool_name ?? record?.name ?? record?.id;
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function readOpenClawParams(event: unknown): Record<string, unknown> | undefined {
const record = isRecord(event) ? event : undefined;
const params = firstRecord(
Expand Down Expand Up @@ -689,7 +697,7 @@ function runtimeResultToBeforeToolCallResult(
? ' OpenClaw cannot safely resume this call after an external approval, so AgentGuard blocked it locally.'
: '') +
(reasonSummary ? ` Reasons: ${reasonSummary}.` : '') +
(result.pendingApproval ? ` Approve once: agentguard approve --action-id ${result.pendingApproval.actionId} --once` : '');
(result.pendingApproval ? ` ${approvalInstruction(result.pendingApproval.actionId)}` : '');

if (decision === 'require_approval') {
return { block: true, blockReason: reason };
Expand All @@ -716,6 +724,13 @@ function normalizeRuntimePolicyDecision(decision: ProtectResult['decision']['dec
return decision === 'require_approve' ? 'require_approval' : decision as ProtectResult['decision']['decision'];
}

function approvalInstruction(actionId: string): string {
return (
`Approve once (only after explicit user approval): agentguard approve --action-id ${actionId} --once.` +
' Do not run this approval command yourself unless the user explicitly approves this exact action.'
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
Expand Down
8 changes: 5 additions & 3 deletions src/installers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,13 @@ Expected decisions:
- \`confirm\`: ask for approval in the agent channel before continuing
- \`block\`: stop the action

When a response includes \`Approve once: agentguard approve --action-id ... --once\`,
ask the user before running that approval command. Treat replies such as
When a response includes \`Approve once ... agentguard approve --action-id ... --once\`,
show the exact approval command to the user and ask before running it. Do not
run an approval command proactively or infer approval from context. Treat replies such as
"yes", "approve", "confirm", "continue", "go ahead", "execute", "run it",
"同意", "确认", "批准", "继续", or "执行" as explicit approval for the most
recent protected action. After approval, run the exact
recent protected action only after the user has seen the command and understood
which action is being approved. After approval, run the exact
\`agentguard approve --action-id ... --once\` command and retry the original
action once. If the id is unavailable, inspect \`agentguard approvals list --json\`;
use \`agentguard approve --last --once\` only when there is exactly one relevant
Expand Down
9 changes: 8 additions & 1 deletion src/runtime/approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,19 @@ export function writePendingApproval(
now = new Date()
): ApprovalRecord {
const store = readApprovalStore(storePath, now);
const fingerprint = actionFingerprint(action);
const existing = store.records.find((record) =>
record.status === 'pending' &&
record.actionFingerprint === fingerprint
);
if (existing) return existing;

const expiresAt = new Date(now.getTime() + DEFAULT_PENDING_APPROVAL_TTL_MS).toISOString();
const record: ApprovalRecord = {
actionId: decision.actionId,
status: 'pending',
once: true,
actionFingerprint: actionFingerprint(action),
actionFingerprint: fingerprint,
sessionId: redactPreview(action.sessionId, 160),
agentHost: action.agentHost,
actionType: action.actionType,
Expand Down
12 changes: 9 additions & 3 deletions src/runtime/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export async function evaluateLocalAction(
const reasons = redactReasons([...customReasons, ...ossReasons]);
const riskScore = riskScoreFor(reasons, ossDecision?.risk_level || 'safe');
const riskLevel = riskLevelFor(riskScore);
const decision = decisionFor(policy, reasons, riskLevel, ossDecision?.decision);
const decision = shouldAutoAllowRuntimeDecision(riskScore, riskLevel)
? 'allow'
: decisionFor(policy, reasons, riskLevel, ossDecision?.decision);

return {
actionId: `act_local_${Date.now()}_${process.pid}`,
Expand Down Expand Up @@ -211,7 +213,7 @@ function normalizeOssReason(tag: string, evidence: ActionEvidence | undefined, a
return reason('NETWORK_RISK', 'medium', 'Network action', 'The local OSS runtime detected network activity.', evidenceText);
}
if (tag === 'SHELL_INJECTION_RISK') {
return reason('SHELL_INJECTION_RISK', 'medium', 'Shell metacharacters', 'The local OSS runtime detected shell metacharacters.', evidenceText);
return reason('SHELL_INJECTION_RISK', 'low', 'Shell metacharacters', 'The local OSS runtime detected shell metacharacters.', evidenceText);
}
return reason(tag, 'medium', tag.replace(/_/g, ' ').toLowerCase(), 'The local OSS runtime detected a risky action.', evidenceText);
}
Expand Down Expand Up @@ -245,7 +247,7 @@ function riskScoreFor(reasons: PolicyReason[], ossRiskLevel: RuntimeRiskLevel):
if (reasons.some((item) => item.severity === 'critical') || ossRiskLevel === 'critical') return 95;
if (reasons.some((item) => item.severity === 'high') || ossRiskLevel === 'high') return 55;
if (reasons.some((item) => item.severity === 'medium') || ossRiskLevel === 'medium') return 20;
if (reasons.length > 0 || ossRiskLevel === 'low') return reasons.length > 0 ? 5 : 0;
if (reasons.length > 0 || ossRiskLevel === 'low') return reasons.length > 0 ? 10 : 0;
return 0;
}

Expand All @@ -257,6 +259,10 @@ function riskLevelFor(score: number): RuntimeRiskLevel {
return 'safe';
}

function shouldAutoAllowRuntimeDecision(riskScore: number, riskLevel: RuntimeRiskLevel): boolean {
return riskScore < 20 || riskLevel === 'safe';
}

function matchesPattern(input: string, pattern: string): boolean {
if (!pattern) return false;
if (input.includes(pattern)) return true;
Expand Down
17 changes: 13 additions & 4 deletions src/runtime/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function protectAction(options: ProtectOptions): Promise<ProtectRes
if (approvedGrant) {
decision = { ...decision, decision: 'allow' };
}
if (isEmptySafeDecision(decision)) return null;
if (shouldSuppressRuntimeReport(decision)) return null;

const event: RuntimeAuditEvent = {
...action,
Expand Down Expand Up @@ -120,8 +120,8 @@ function normalizeRuntimeDecision(decision: RuntimeDecision): RuntimeDecision {
return decision;
}

function isEmptySafeDecision(decision: RuntimeDecision): boolean {
return decision.riskScore === 0 && decision.riskLevel === 'safe' && decision.reasons.length === 0;
function shouldSuppressRuntimeReport(decision: RuntimeDecision): boolean {
return decision.riskScore < 20 || decision.riskLevel === 'safe';
}

export function formatProtectResult(result: ProtectResult, json = false): string {
Expand All @@ -140,6 +140,7 @@ export function formatProtectResult(result: ProtectResult, json = false): string
reasons: result.decision.reasons,
approvalChannel: result.approvalChannel,
approvalCommand: result.pendingApproval ? approvalCommand(result.pendingApproval) : undefined,
approvalInstruction: result.pendingApproval ? approvalInstruction(result.pendingApproval) : undefined,
approvalExpiresAt: result.pendingApproval?.expiresAt,
policySource: result.policySource,
}, null, 2);
Expand Down Expand Up @@ -191,6 +192,7 @@ function formatAgentApproval(result: ProtectResult): string | null {
approvalChannel: 'agent',
message: reason,
approvalCommand: result.pendingApproval ? approvalCommand(result.pendingApproval) : undefined,
approvalInstruction: result.pendingApproval ? approvalInstruction(result.pendingApproval) : undefined,
approvalExpiresAt: result.pendingApproval?.expiresAt,
}, null, 2);
}
Expand All @@ -214,7 +216,14 @@ function formatApprovalReason(result: ProtectResult): string {

function approvalHint(result: ProtectResult): string {
if (!result.pendingApproval) return '';
return ` Approve once: ${approvalCommand(result.pendingApproval)}`;
return ` ${approvalInstruction(result.pendingApproval)}`;
}

function approvalInstruction(record: ApprovalRecord): string {
return (
`Approve once (only after explicit user approval): ${approvalCommand(record)}.` +
' Do not run this approval command yourself unless the user explicitly approves this exact action.'
);
}

function approvalCommand(record: ApprovalRecord): string {
Expand Down
25 changes: 24 additions & 1 deletion src/runtime/self-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ const SUPPORTED_AGENT_COMMANDS = [
'gh copilot',
];
const SHELL_CONTROL_RE = /[;&|<>`\n\r\t]|\$\(/;
const SHELL_EXECUTABLES = new Set(['sh', 'bash', 'zsh', 'dash', 'fish']);

export function isAgentGuardCliCommand(command: string): boolean {
return isAgentGuardCliCommandInner(command, 0);
}

function isAgentGuardCliCommandInner(command: string, depth: number): boolean {
if (depth > 2) return false;
const trimmed = command.trim();
if (!trimmed || SHELL_CONTROL_RE.test(trimmed)) return false;

Expand All @@ -33,7 +39,12 @@ export function isAgentGuardCliCommand(command: string): boolean {
index += 1;
}

return SUPPORTED_AGENT_COMMANDS.some((command) => matchesCommand(tokens, index, command));
if (SUPPORTED_AGENT_COMMANDS.some((command) => matchesCommand(tokens, index, command))) {
return true;
}

const wrappedCommand = shellWrapperCommand(tokens, index);
return wrappedCommand ? isAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false;
}

function matchesCommand(tokens: string[], start: number, command: string): boolean {
Expand All @@ -50,6 +61,18 @@ function skipAssignments(tokens: string[], start: number): number {
return index;
}

function shellWrapperCommand(tokens: string[], start: number): string | null {
if (!SHELL_EXECUTABLES.has(basename(tokens[start] || ''))) return null;

for (let index = start + 1; index < tokens.length; index += 1) {
const token = tokens[index] || '';
if (!token.startsWith('-') || token === '-') return null;
if (token.includes('c')) return tokens[index + 1] || null;
}

return null;
}

function basename(value: string): string {
return value.replace(/\\/g, '/').split('/').pop() || value;
}
Expand Down
9 changes: 9 additions & 0 deletions src/tests/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ describe('Exec Command Detector', () => {
assert.ok(result.risk_tags.includes('SHELL_INJECTION_RISK') || result.risk_tags.includes('DANGEROUS_COMMAND'));
});

it('should treat shell metacharacters alone as low risk', () => {
for (const command of ['echo a>b', 'echo a&b', 'echo test!', 'echo a^b']) {
const result = analyzeExecCommand({ command }, true);
assert.equal(result.risk_level, 'low', command);
assert.ok(result.risk_tags.includes('SHELL_INJECTION_RISK'), command);
assert.ok(!result.should_block, command);
}
});

it('should allow safe commands even when exec not allowed', () => {
const result = analyzeExecCommand({ command: 'ls -la' }, false);
assert.equal(result.risk_level, 'low');
Expand Down
Loading
Loading