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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 26 additions & 2 deletions src/adapters/openclaw-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ export function registerOpenClawPlugin(
if (hookDecision) {
return hookDecision;
}
if (isApprovedLocalRuntimeRetry(runtimeResult)) {
return undefined;
}
} catch (err) {
if (
options.runtimeFailureMode !== 'fallback' &&
Expand Down Expand Up @@ -583,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';
Expand Down Expand Up @@ -614,8 +621,14 @@ function mapOpenClawToolToRuntimeAction(

function readOpenClawParams(event: unknown): Record<string, unknown> | 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 {
Expand Down Expand Up @@ -695,6 +708,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'];
}
Expand All @@ -703,6 +720,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

function firstRecord(...values: unknown[]): Record<string, unknown> | undefined {
for (const value of values) {
if (isRecord(value)) return value;
}
return undefined;
}

/**
* Default export for OpenClaw plugin registration.
*
Expand Down
10 changes: 8 additions & 2 deletions src/adapters/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ export class OpenClawAdapter implements HookAdapter {

parseInput(raw: unknown): HookInput {
const event = raw as Record<string, unknown>;
const toolInput =
(event.params as Record<string, unknown>) ||
(event.toolInput as Record<string, unknown>) ||
(event.tool_input as Record<string, unknown>) ||
(event.args as Record<string, unknown>) ||
{};
return {
toolName: (event.toolName as string) || '',
toolInput: (event.params as Record<string, unknown>) || {},
toolName: (event.toolName as string) || (event.tool_name as string) || '',
toolInput,
eventType: 'pre', // before_tool_call = pre
raw: event,
};
Expand Down
56 changes: 56 additions & 0 deletions src/runtime/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ export async function evaluateLocalAction(
policy: EffectiveRuntimePolicy,
action: RuntimeAction
): Promise<RuntimeDecision> {
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) =>
Expand All @@ -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 || '';
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 35 additions & 5 deletions src/runtime/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,25 +264,55 @@ function mapToolToRuntimeAction(toolName: string, raw: Record<string, unknown> |
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';
}

function pickInput(raw: Record<string, unknown> | null, actionType: RuntimeActionType): string {
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<string, unknown> | 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<string, unknown> | undefined {
for (const value of values) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
}
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<string, unknown> | null): string {
const sessionId = raw?.session_id || raw?.sessionId;
return typeof sessionId === 'string' ? sessionId : `sess_local_${Date.now()}`;
Expand Down
19 changes: 15 additions & 4 deletions src/runtime/self-command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const SUPPORTED_AGENT_BINARIES = new Set([
const SUPPORTED_AGENT_COMMANDS = [
'agentguard',
'agentguard-mcp',
'claude',
Expand All @@ -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();
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/tests/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');
Expand Down
60 changes: 60 additions & 0 deletions src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -495,6 +510,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();
Expand Down
Loading
Loading