From 8858fdfee06448ee841cc0104b9163ec6d00038a Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 28 May 2026 20:20:49 +0800 Subject: [PATCH 1/4] fix openclaw one-time approval retry --- src/adapters/openclaw-plugin.ts | 7 +++++ src/tests/integration.test.ts | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index 45a13f7..877d85f 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -459,6 +459,9 @@ export function registerOpenClawPlugin( if (hookDecision) { return hookDecision; } + if (isApprovedLocalRuntimeRetry(runtimeResult)) { + return undefined; + } } catch (err) { if ( options.runtimeFailureMode !== 'fallback' && @@ -695,6 +698,10 @@ function shouldSurfaceRuntimeApproval(result: ProtectResult): boolean { ); } +function isApprovedLocalRuntimeRetry(result: ProtectResult | null): boolean { + return result?.decision.decision === 'allow' && result.event.metadata?.approvedByLocalGrant === true; +} + function normalizeRuntimePolicyDecision(decision: ProtectResult['decision']['decision'] | string): ProtectResult['decision']['decision'] { return decision === 'require_approve' ? 'require_approval' : decision as ProtectResult['decision']['decision']; } diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 295381e..14e15d4 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -495,6 +495,51 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.ok(result?.blockReason?.includes('requires approval')); }); + it('should allow OpenClaw retries that consumed a local one-time approval', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + registerOpenClawPlugin(api as never, { + skipAutoScan: true, + agentguardFactory: () => ctx.agentguard as never, + protectAction: async () => ({ + policySource: 'default', + approvalChannel: undefined, + event: { + actionId: 'act_retry', + sessionId: 'openclaw-session', + agentHost: 'openclaw', + actionType: 'shell', + toolName: 'exec', + input: 'cat ~/.ssh/id_ed25519.pub', + decision: 'allow', + riskScore: 55, + riskLevel: 'high', + reasons: [], + policyVersion: 'runtime-test', + metadata: { + approvedByLocalGrant: true, + approvalActionId: 'act_original', + }, + }, + decision: { + actionId: 'act_retry', + decision: 'allow', + riskScore: 55, + riskLevel: 'high', + policyVersion: 'runtime-test', + reasons: [], + }, + }), + }); + + const result = await handlers['before_tool_call']({ + toolName: 'exec', + params: { command: 'cat ~/.ssh/id_ed25519.pub' }, + }) as { block?: boolean; blockReason?: string } | undefined; + + assert.equal(result, undefined); + }); + it('should return { block: true } for rm -rf /', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); From 98c53c83d7712439508072515b23afa0a3203597 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 28 May 2026 20:21:06 +0800 Subject: [PATCH 2/4] 1.1.24 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4732560..80a9568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@goplus/agentguard", - "version": "1.1.23", + "version": "1.1.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@goplus/agentguard", - "version": "1.1.23", + "version": "1.1.24", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 332f800..4b02d52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@goplus/agentguard", - "version": "1.1.23", + "version": "1.1.24", "description": "GoPlus AgentGuard — Security guard for AI agents. Blocks dangerous commands, prevents data leaks, protects secrets. 20 detection rules, runtime action evaluation, trust registry.", "main": "dist/index.js", "types": "dist/index.d.ts", From bee0d8f65564ae865667768d10c8d36a4b287db0 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 28 May 2026 20:21:25 +0800 Subject: [PATCH 3/4] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a81cd..5d538f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.1.23] - 2026-05-28 +## [1.1.24] - 2026-05-28 ### Added - Added local one-time runtime approval grants: `agentguard approve --action-id --once`, `agentguard approve --last --once`, and `agentguard approvals list` let agents retry a previously intercepted protected action after explicit user approval, with short-lived pending approvals and audited approved retries. From fcce7de9ed29c6ffcca41deb2dcb776abd67f606 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 28 May 2026 21:28:52 +0800 Subject: [PATCH 4/4] Fix agent CLI self-command allowlist --- src/adapters/openclaw-plugin.ts | 21 ++++++- src/adapters/openclaw.ts | 10 ++- src/runtime/evaluator.ts | 56 +++++++++++++++++ src/runtime/protect.ts | 40 ++++++++++-- src/runtime/self-command.ts | 19 ++++-- src/tests/adapter.test.ts | 9 +++ src/tests/integration.test.ts | 15 +++++ src/tests/runtime-cloud.test.ts | 105 ++++++++++++++++++++++++++++++++ 8 files changed, 262 insertions(+), 13 deletions(-) diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index 877d85f..37ed975 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -586,6 +586,10 @@ function mapOpenClawToolToRuntimeAction( return 'network'; } + const record = isRecord(event) ? event : undefined; + if (typeof record?.command === 'string' || typeof record?.cmd === 'string') { + return 'shell'; + } const params = readOpenClawParams(event); if (typeof params?.command === 'string' || typeof params?.cmd === 'string') { return 'shell'; @@ -617,8 +621,14 @@ function mapOpenClawToolToRuntimeAction( function readOpenClawParams(event: unknown): Record | undefined { const record = isRecord(event) ? event : undefined; - const params = record?.params ?? record?.toolInput ?? record?.tool_input; - return isRecord(params) ? params : undefined; + const params = firstRecord( + record?.params, + record?.toolInput, + record?.tool_input, + record?.args, + record?.input + ); + return params; } function isSecuritySensitiveRuntimeAction(actionType: RuntimeActionType): boolean { @@ -710,6 +720,13 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function firstRecord(...values: unknown[]): Record | undefined { + for (const value of values) { + if (isRecord(value)) return value; + } + return undefined; +} + /** * Default export for OpenClaw plugin registration. * diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts index 1c84fd5..bd17cca 100644 --- a/src/adapters/openclaw.ts +++ b/src/adapters/openclaw.ts @@ -29,9 +29,15 @@ export class OpenClawAdapter implements HookAdapter { parseInput(raw: unknown): HookInput { const event = raw as Record; + const toolInput = + (event.params as Record) || + (event.toolInput as Record) || + (event.tool_input as Record) || + (event.args as Record) || + {}; return { - toolName: (event.toolName as string) || '', - toolInput: (event.params as Record) || {}, + toolName: (event.toolName as string) || (event.tool_name as string) || '', + toolInput, eventType: 'pre', // before_tool_call = pre raw: event, }; diff --git a/src/runtime/evaluator.ts b/src/runtime/evaluator.ts index 26dde9a..9b0317b 100644 --- a/src/runtime/evaluator.ts +++ b/src/runtime/evaluator.ts @@ -33,6 +33,17 @@ export async function evaluateLocalAction( policy: EffectiveRuntimePolicy, action: RuntimeAction ): Promise { + if (isAllowedByCommandPolicy(policy, action)) { + return { + actionId: `act_local_${Date.now()}_${process.pid}`, + decision: 'allow', + riskScore: 0, + riskLevel: 'safe', + reasons: [], + policyVersion: policy.policyVersion || 'runtime-local-v0.1', + }; + } + const customReasons = customPolicyReasons(policy, action); const ossDecision = await evaluateWithOssActionScanner(policy, action); const ossReasons = (ossDecision?.risk_tags || []).map((tag, index) => @@ -53,6 +64,11 @@ export async function evaluateLocalAction( }; } +function isAllowedByCommandPolicy(policy: EffectiveRuntimePolicy, action: RuntimeAction): boolean { + if (action.actionType !== 'shell') return false; + return policy.allowedCommandPatterns.some((pattern) => matchesAllowedCommand(action.input, pattern)); +} + function customPolicyReasons(policy: EffectiveRuntimePolicy, action: RuntimeAction): PolicyReason[] { const reasons: PolicyReason[] = []; const input = action.input || ''; @@ -248,6 +264,46 @@ function matchesPattern(input: string, pattern: string): boolean { return compact !== pattern && input.includes(compact); } +function matchesAllowedCommand(input: string, pattern: string): boolean { + const trimmedInput = input.trim(); + const trimmedPattern = pattern.trim(); + if (!trimmedInput || !trimmedPattern) return false; + + const inputHasControl = hasShellControl(trimmedInput); + const patternHasControl = hasShellControl(trimmedPattern); + if (inputHasControl && !patternHasControl) return false; + + const normalizedInput = normalizeCommand(trimmedInput); + const normalizedPattern = normalizeCommand(trimmedPattern); + if (normalizedPattern === '*') return true; + if (/[?*]/.test(normalizedPattern) || normalizedPattern.includes('...')) { + const regex = new RegExp(`^${globCommandToRegexSource(normalizedPattern)}$`); + return regex.test(normalizedInput); + } + + return normalizedInput === normalizedPattern || + (!inputHasControl && normalizedInput.startsWith(`${normalizedPattern} `)); +} + +function normalizeCommand(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function hasShellControl(command: string): boolean { + return /[;&|<>`\n\r\t]|\$\(/.test(command); +} + +function globCommandToRegexSource(pattern: string): string { + const normalized = pattern.replace(/\s*\.\.\.\s*/g, ' * '); + let source = ''; + for (const char of normalized) { + if (char === '*') source += '.*'; + else if (char === '?') source += '.'; + else source += escapeRegex(char); + } + return source; +} + function matchesPath(input: string, pattern: string): boolean { if (!pattern) return false; const normalizedInput = normalizePathLike(input); diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index fb96802..8dbada8 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -264,6 +264,7 @@ function mapToolToRuntimeAction(toolName: string, raw: Record | if (['Write', 'Edit', 'MultiEdit'].includes(toolName) || lower.includes('write')) return 'file_write'; if (lower.includes('web') || lower.includes('browser')) return 'network'; if (raw?.actionType && typeof raw.actionType === 'string') return raw.actionType as RuntimeActionType; + if (raw?.action_type && typeof raw.action_type === 'string') return raw.action_type as RuntimeActionType; return 'other'; } @@ -271,18 +272,47 @@ function pickInput(raw: Record | null, actionType: RuntimeActio if (!raw) return ''; if (typeof raw.input === 'string') return raw.input; if (typeof raw.content === 'string') return raw.content; - const toolInput = (raw.tool_input || raw.toolInput || raw.params) as Record | undefined; - if (toolInput && typeof toolInput === 'object') { - if (actionType === 'shell' && typeof toolInput.command === 'string') return toolInput.command; - const filePath = toolInput.file_path || toolInput.path; + if (actionType === 'shell') { + const command = firstString(raw.command, raw.cmd); + if (command) return command; + } + const toolInput = firstRecord( + raw.tool_input, + raw.toolInput, + raw.params, + raw.args, + raw.input + ); + if (toolInput) { + if (actionType === 'shell') { + const command = firstString(toolInput.command, toolInput.cmd); + if (command) return command; + } + const filePath = toolInput.file_path || toolInput.filePath || toolInput.path || toolInput.target; if ((actionType === 'file_read' || actionType === 'file_write') && typeof filePath === 'string') return filePath; - const url = toolInput.url || toolInput.query; + const url = toolInput.url || toolInput.uri || toolInput.href || toolInput.query; if (typeof url === 'string') return url; return JSON.stringify(toolInput); } return JSON.stringify(raw); } +function firstRecord(...values: unknown[]): Record | undefined { + for (const value of values) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + } + return undefined; +} + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value; + } + return ''; +} + function pickSessionId(raw: Record | null): string { const sessionId = raw?.session_id || raw?.sessionId; return typeof sessionId === 'string' ? sessionId : `sess_local_${Date.now()}`; diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index 759845b..7f39d7e 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -1,4 +1,4 @@ -const SUPPORTED_AGENT_BINARIES = new Set([ +const SUPPORTED_AGENT_COMMANDS = [ 'agentguard', 'agentguard-mcp', 'claude', @@ -7,8 +7,13 @@ const SUPPORTED_AGENT_BINARIES = new Set([ 'openclaw', 'qclaw', 'hermes', -]); -const SHELL_CONTROL_RE = /[;&|<>`]|\$\(/; + 'cursor', + 'cursor-agent', + 'gemini', + 'copilot', + 'gh copilot', +]; +const SHELL_CONTROL_RE = /[;&|<>`\n\r\t]|\$\(/; export function isAgentGuardCliCommand(command: string): boolean { const trimmed = command.trim(); @@ -28,7 +33,13 @@ export function isAgentGuardCliCommand(command: string): boolean { index += 1; } - return SUPPORTED_AGENT_BINARIES.has(basename(tokens[index] || '')); + return SUPPORTED_AGENT_COMMANDS.some((command) => matchesCommand(tokens, index, command)); +} + +function matchesCommand(tokens: string[], start: number, command: string): boolean { + const expected = command.split(/\s+/); + if (start + expected.length > tokens.length) return false; + return expected.every((part, offset) => basename(tokens[start + offset] || '') === part); } function skipAssignments(tokens: string[], start: number): number { diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index 72bbe95..87d1bd7 100644 --- a/src/tests/adapter.test.ts +++ b/src/tests/adapter.test.ts @@ -208,6 +208,15 @@ describe('OpenClawAdapter', () => { assert.deepEqual(input.toolInput, {}); }); + it('should fall back to args/cmd payloads', () => { + const input = adapter.parseInput({ + toolName: 'terminal', + args: { cmd: 'agentguard disconnect' }, + }); + assert.equal(input.toolName, 'terminal'); + assert.deepEqual(input.toolInput, { cmd: 'agentguard disconnect' }); + }); + it('should handle empty event', () => { const input = adapter.parseInput({}); assert.equal(input.toolName, ''); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 14e15d4..9cb600c 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -264,6 +264,21 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.equal(result, undefined, 'Ordinary OpenClaw exec command should be allowed'); }); + it('should allow AgentGuard CLI commands from OpenClaw args/cmd payloads', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + registerOpenClawPlugin(api as never, { + skipAutoScan: true, + registry: ctx.agentguard.registry as never, + }); + + const result = await handlers['before_tool_call']({ + toolName: 'terminal', + args: { cmd: 'agentguard disconnect' }, + }); + assert.equal(result, undefined, 'AgentGuard self-command should be allowed'); + }); + it('should run runtime protection for OpenClaw tool calls', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 03442cb..6f097f0 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -308,6 +308,81 @@ describe('Runtime Cloud bridge', () => { } }); + it('skips AgentGuard CLI commands from alternate tool argument shapes', async () => { + const originalFetch = globalThis.fetch; + const dir = mkdtempSync(join(tmpdir(), 'agentguard-self-cli-args-')); + const requests: string[] = []; + + globalThis.fetch = (async (input: Parameters[0]) => { + requests.push(String(input)); + throw new Error('unexpected cloud request'); + }) as typeof fetch; + + try { + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + }; + + const result = await protectAction({ + config, + actionType: 'shell', + stdinText: JSON.stringify({ + toolName: 'terminal', + args: { cmd: 'agentguard disconnect' }, + sessionId: 'sess_test', + }), + }); + + assert.equal(result, null); + assert.deepEqual(requests, []); + assert.equal(existsSync(config.auditPath), false); + assert.equal(existsSync(config.eventSpoolPath), false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('honors allowed command patterns without allowing compound shell commands', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + policy.blockedCommandPatterns = ['agentguard']; + policy.allowedCommandPatterns = ['agentguard']; + + const allowed = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'openclaw', + actionType: 'shell', + toolName: 'exec', + input: 'agentguard disconnect', + }); + assert.equal(allowed.decision, 'allow'); + assert.equal(allowed.riskScore, 0); + assert.deepEqual(allowed.reasons, []); + + const compound = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'openclaw', + actionType: 'shell', + toolName: 'exec', + input: 'agentguard status; rm -rf /', + }); + assert.equal(compound.decision, 'block'); + + const multiline = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'openclaw', + actionType: 'shell', + toolName: 'exec', + input: 'agentguard status\nrm -rf /', + }); + assert.equal(multiline.decision, 'block'); + }); + it('skips supported agent CLI commands before local audit or Cloud reporting', async () => { const originalFetch = globalThis.fetch; const dir = mkdtempSync(join(tmpdir(), 'agentguard-agent-cli-')); @@ -336,6 +411,11 @@ describe('Runtime Cloud bridge', () => { 'codex --version', 'claude mcp list', 'claude-code --version', + 'cursor-agent --version', + 'cursor --version', + 'gemini --version', + 'copilot --version', + 'gh copilot explain "git status"', 'env AGENTGUARD_AGENT_HOST=openclaw openclaw gateway restart', 'command codex --version', ]) { @@ -384,6 +464,31 @@ describe('Runtime Cloud bridge', () => { assert.equal(existsSync(config.auditPath), true); }); + it('does not skip multiline shell commands just because they start with agent CLIs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-agent-multiline-cli-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + }; + + const result = await protectAction({ + config, + stdinText: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'openclaw gateway status\nrm -rf /' }, + session_id: 'sess_test', + }), + }); + + assert.ok(result); + assert.equal(result.decision.decision, 'block'); + assert.equal(existsSync(config.auditPath), true); + }); + it('does not skip compound shell commands just because they mention agent CLIs', async () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-agent-compound-cli-')); const config: AgentGuardConfig = {