diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4c74c..1d9a6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,13 @@ ## [1.1.22] - 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. + ### Changed - `agentguard subscribe` cron internals (`--cron-run` and `--cron-notify-run`) now only pull feed advisories instead of re-subscribing on every scheduled run, preserving Cloud-side unsubscribe choices. - OpenClaw Cloud connect guidance now documents the Agent JWT flow explicitly: initialized OpenClaw installs can run `agentguard connect` without an API key, while API-key auth remains available for explicit API-key connections. +- `agentguard init --agent openclaw` now installs the AgentGuard skill alongside the runtime plugin so OpenClaw agents can learn the local approval/retry flow. ### Fixed - Supported agent CLI commands such as `openclaw`, `qclaw`, `hermes`, `codex`, and `claude` are now treated like AgentGuard self-commands so normal agent management commands are not audited, reported, or blocked by AgentGuard hooks while compound shell commands remain protected. diff --git a/docs/codex.md b/docs/codex.md index 71a6167..23876ee 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -36,3 +36,15 @@ Use these mappings for Codex-style hooks or skills: - MCP tool calls → `mcp_tool` When Cloud is connected, Codex events are synced as redacted previews. Confirmation still happens through the local agent permission flow, not a Cloud approval page. + +If a protected action returns `confirm`, AgentGuard stores a short-lived pending +approval and includes an approval command: + +```bash +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 +`agentguard approvals list --json`; use `agentguard approve --last --once` +only when there is exactly one relevant unexpired pending approval. diff --git a/docs/openclaw.md b/docs/openclaw.md index a270904..ca353a6 100644 --- a/docs/openclaw.md +++ b/docs/openclaw.md @@ -10,7 +10,7 @@ To install and enable the AgentGuard OpenClaw plugin: agentguard init --agent openclaw ``` -This creates a local plugin under `~/.openclaw/plugins/agentguard` and enables it in `~/.openclaw/openclaw.json`. +This creates a local plugin under `~/.openclaw/plugins/agentguard`, installs the AgentGuard skill under `~/.openclaw/skills/agentguard`, and enables the plugin in `~/.openclaw/openclaw.json`. ```ts import { registerOpenClawPlugin } from '@goplus/agentguard'; @@ -48,6 +48,25 @@ agentguard protect AgentGuard accepts OpenClaw-style JSON with `toolName` and `params`, plus Claude-style `tool_name` and `tool_input`. +OpenClaw cannot safely pause and resume a protected tool call, so AgentGuard +blocks `require_approval` actions locally and stores a short-lived pending +approval. The block reason includes: + +```bash +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, +inspect pending approvals first: + +```bash +agentguard approvals list --json +``` + +Use `agentguard approve --last --once` only when there is exactly one relevant +unexpired pending approval; otherwise approve the specific `actionId`. + ## Docker demo See `examples/openclaw-docker/` for a minimal Docker demo that installs `@goplus/agentguard`, runs `agentguard init --agent openclaw`, and provides a starter plugin. diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 699abc7..67d7c43 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -103,6 +103,8 @@ Supported CLI commands and options: | `agentguard status` | none | Shows local config, active Cloud auth method, policy cache, audit path | | `agentguard policy pull` | `--json` | Pulls Cloud effective runtime policy into the local cache | | `agentguard policy show` | `--json` | Shows the cached effective runtime policy, or the bundled default policy when no cache exists | +| `agentguard approve` | `--action-id ` or `--last`, `--once`, `--json` | Approves one existing pending runtime action; never approve without explicit user confirmation | +| `agentguard approvals list` | `--json` | Lists unexpired pending runtime approvals | | `agentguard doctor` | none | Checks local setup and Cloud reachability when connected | | `agentguard protect` | `--agent `, `--action-type `, `--tool-name `, `--session-id `, `--decision-mode `, `--json` | Evaluates one runtime action from stdin or hook environment | | `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run`, `--cron-notify-run` | Pulls Cloud threat advisories and optionally self-checks local skills | @@ -117,6 +119,8 @@ Connect behavior: If the user writes `/agentguard cli `, execute `agentguard ` 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. + 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 ` or explicitly writes `/agentguard cli checkup ...`. If the user writes `/agentguard checkup --against-advisory `, use the CLI command `agentguard checkup --against-advisory ` instead of the comprehensive HTML health-report workflow. diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index abf3222..45a13f7 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -675,7 +675,8 @@ function runtimeResultToBeforeToolCallResult( (decision === 'require_approval' ? ' OpenClaw cannot safely resume this call after an external approval, so AgentGuard blocked it locally.' : '') + - (reasonSummary ? ` Reasons: ${reasonSummary}.` : ''); + (reasonSummary ? ` Reasons: ${reasonSummary}.` : '') + + (result.pendingApproval ? ` Approve once: agentguard approve --action-id ${result.pendingApproval.actionId} --once` : ''); if (decision === 'require_approval') { return { block: true, blockReason: reason }; diff --git a/src/cli.ts b/src/cli.ts index cb89462..87932b8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import { import type { AgentGuardAgentHost, AgentGuardConfig } from './config.js'; import { SkillScanner } from './scanner/index.js'; import { formatProtectResult, protectAction, exitCodeForDecision } from './runtime/protect.js'; +import { approvePendingApproval, listPendingApprovals } from './runtime/approvals.js'; import { getDefaultEffectiveRuntimePolicy, loadCachedPolicy, saveCachedPolicy } from './runtime/policy.js'; import type { RuntimeActionType, RuntimeAgentHost } from './runtime/types.js'; import { installAgentTemplates, type AgentInstaller, type InstallResult } from './installers.js'; @@ -360,6 +361,55 @@ async function main() { process.exitCode = result.risk_level === 'critical' ? 2 : 0; }); + program + .command('approve') + .description('Approve one pending runtime action') + .option('--action-id ', 'Pending action id returned by agentguard protect') + .option('--last', 'Approve the most recent unambiguous pending action') + .option('--once', 'Approve only the next matching retry') + .option('--json', 'Print JSON output') + .action((options) => { + if (!options.once) { + throw new Error('Approvals must be scoped with --once.'); + } + const config = ensureConfig(); + const approved = approvePendingApproval(config.approvalStorePath!, { + actionId: options.actionId, + last: Boolean(options.last), + once: true, + sessionId: process.env.AGENTGUARD_SESSION_ID, + }); + if (options.json) { + console.log(JSON.stringify({ success: true, approval: approved }, null, 2)); + } else { + console.log(`Approved once: ${approved.actionId}`); + console.log(`Expires: ${approved.expiresAt}`); + } + }); + + const approvals = program + .command('approvals') + .description('Inspect pending runtime approvals'); + + approvals + .command('list') + .description('List unexpired pending approvals') + .option('--json', 'Print JSON output') + .action((options) => { + const config = ensureConfig(); + const pending = listPendingApprovals(config.approvalStorePath!); + if (options.json) { + console.log(JSON.stringify({ success: true, approvals: pending }, null, 2)); + } else if (pending.length === 0) { + console.log('No pending approvals.'); + } else { + for (const approval of pending) { + console.log(`${approval.actionId} ${approval.actionType} ${approval.toolName} expires=${approval.expiresAt}`); + console.log(` ${approval.inputPreview}`); + } + } + }); + program .command('protect') .description('Evaluate one runtime action from stdin or hook environment') diff --git a/src/config.ts b/src/config.ts index 68c5937..2a6adfa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ export interface AgentGuardConfig { policyCachePath: string; auditPath: string; eventSpoolPath: string; + approvalStorePath?: string; } export interface AgentGuardPaths { @@ -29,6 +30,7 @@ export interface AgentGuardPaths { policyCachePath: string; auditPath: string; eventSpoolPath: string; + approvalStorePath: string; } const DEFAULT_CLOUD_URL = 'https://agentguard.gopluslabs.io'; @@ -42,6 +44,7 @@ export function getAgentGuardPaths(): AgentGuardPaths { policyCachePath: join(home, 'policy-cache.json'), auditPath: join(home, 'audit.jsonl'), eventSpoolPath: join(home, 'events-spool.jsonl'), + approvalStorePath: join(home, 'approvals.json'), }; } @@ -54,6 +57,7 @@ export function defaultConfig(): AgentGuardConfig { policyCachePath: paths.policyCachePath, auditPath: paths.auditPath, eventSpoolPath: paths.eventSpoolPath, + approvalStorePath: paths.approvalStorePath, }; } @@ -90,6 +94,7 @@ export function loadConfig(): AgentGuardConfig { policyCachePath: parsed.policyCachePath || fallback.policyCachePath, auditPath: parsed.auditPath || fallback.auditPath, eventSpoolPath: parsed.eventSpoolPath || fallback.eventSpoolPath, + approvalStorePath: parsed.approvalStorePath || fallback.approvalStorePath, }; } catch { return fallback; diff --git a/src/index.ts b/src/index.ts index 5cd6068..e76d8de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,14 @@ export { type ProtectOptions, type ProtectResult, } from './runtime/protect.js'; +export { + approvePendingApproval, + cleanupExpiredApprovals, + consumeApprovedApproval, + listPendingApprovals, + writePendingApproval, + type ApprovalRecord, +} from './runtime/approvals.js'; export { redactText, redactPreview, redactReasons } from './runtime/redaction.js'; export { getDefaultEffectiveRuntimePolicy, diff --git a/src/installers.ts b/src/installers.ts index 2f1fa25..e813ab3 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -82,11 +82,9 @@ function installHermes(root: string, force: boolean): InstallResult { function installQClaw(root: string, force: boolean): InstallResult { const qclawRoot = join(root, '.qclaw'); - const skillDir = join(qclawRoot, 'skills', 'agentguard'); const configPath = join(qclawRoot, 'qclaw.json'); - copyBundledSkill(skillDir, force); const pluginResult = installClawPlugin('qclaw', qclawRoot, configPath, force); - return { agent: 'qclaw', files: [skillDir, ...pluginResult.files] }; + return { agent: 'qclaw', files: pluginResult.files }; } function writeIfAllowed(path: string, content: string, force: boolean): void { @@ -179,6 +177,17 @@ Expected decisions: - \`warn\`: show warning and continue - \`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 +"yes", "approve", "confirm", "continue", "go ahead", "execute", "run it", +"同意", "确认", "批准", "继续", or "执行" as explicit approval for the most +recent protected action. 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 +unexpired pending approval. If multiple pending approvals exist, ask the user to +choose a specific action id. `; } @@ -228,16 +237,18 @@ hooks_auto_accept: false function installClawPlugin(agent: 'openclaw' | 'qclaw', root: string, configPath: string, force: boolean): InstallResult { const pluginDir = join(root, 'plugins', 'agentguard'); + const skillDir = join(root, 'skills', 'agentguard'); const packagePath = join(pluginDir, 'package.json'); const pluginPath = join(pluginDir, 'index.js'); const manifestPath = join(pluginDir, 'openclaw.plugin.json'); + copyBundledSkill(skillDir, force); writeIfAllowed(packagePath, JSON.stringify(openClawPackageManifest(agent), null, 2) + '\n', force); writeIfAllowed(pluginPath, openClawPluginTemplate(), force); writeIfAllowed(manifestPath, JSON.stringify(openClawPluginManifest(), null, 2) + '\n', force); enableClawPlugin(configPath, pluginDir); - return { agent, files: [packagePath, pluginPath, manifestPath, configPath] }; + return { agent, files: [skillDir, packagePath, pluginPath, manifestPath, configPath] }; } function inferOpenClawCompanionInstallTargets(root: string, configPath: string): ClawInstallTarget[] { diff --git a/src/runtime/approvals.ts b/src/runtime/approvals.ts new file mode 100644 index 0000000..31a5064 --- /dev/null +++ b/src/runtime/approvals.ts @@ -0,0 +1,276 @@ +import { createHash } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { RuntimeAction, RuntimeDecision } from './types.js'; +import { redactPreview, redactReasons } from './redaction.js'; + +export const DEFAULT_PENDING_APPROVAL_TTL_MS = 5 * 60 * 1000; +const LAST_APPROVAL_WINDOW_MS = 2 * 60 * 1000; +const LOCK_STALE_MS = 30 * 1000; +const LOCK_TIMEOUT_MS = 5 * 1000; + +export type ApprovalRecordStatus = 'pending' | 'approved'; + +export interface ApprovalRecord { + actionId: string; + status: ApprovalRecordStatus; + once: boolean; + actionFingerprint: string; + sessionId: string; + agentHost: RuntimeAction['agentHost']; + actionType: RuntimeAction['actionType']; + toolName: string; + inputPreview: string; + cwd?: string; + reasonTitles: string[]; + riskScore: number; + riskLevel: RuntimeDecision['riskLevel']; + policyVersion: string; + createdAt: string; + expiresAt: string; + approvedAt?: string; +} + +export interface ApprovalStore { + version: 1; + records: ApprovalRecord[]; +} + +export function actionFingerprint(action: RuntimeAction): string { + const canonical = JSON.stringify({ + sessionId: action.sessionId, + agentHost: action.agentHost, + actionType: action.actionType, + toolName: action.toolName, + input: normalizeActionInput(action.input), + cwd: action.cwd || '', + }); + return createHash('sha256').update(canonical).digest('hex'); +} + +export function writePendingApproval( + storePath: string, + action: RuntimeAction, + decision: RuntimeDecision, + now = new Date() +): ApprovalRecord { + const store = readApprovalStore(storePath, now); + 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), + sessionId: redactPreview(action.sessionId, 160), + agentHost: action.agentHost, + actionType: action.actionType, + toolName: redactPreview(action.toolName, 160), + inputPreview: redactPreview(action.input), + cwd: action.cwd ? redactPreview(action.cwd, 500) : undefined, + reasonTitles: redactReasons(decision.reasons).map((reason) => reason.title).filter(Boolean).slice(0, 5), + riskScore: decision.riskScore, + riskLevel: decision.riskLevel, + policyVersion: redactPreview(decision.policyVersion, 160), + createdAt: now.toISOString(), + expiresAt, + }; + + const records = store.records.filter((item) => item.actionId !== record.actionId); + records.push(record); + writeApprovalStore(storePath, { version: 1, records }, now); + return record; +} + +export function listPendingApprovals(storePath: string, now = new Date()): ApprovalRecord[] { + return readApprovalStore(storePath, now).records + .filter((record) => record.status === 'pending') + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); +} + +export function approvePendingApproval( + storePath: string, + options: { actionId?: string; last?: boolean; sessionId?: string; once?: boolean }, + now = new Date() +): ApprovalRecord { + const store = readApprovalStore(storePath, now); + const pending = store.records.filter((record) => record.status === 'pending'); + const selected = selectPendingApproval(pending, options, now); + const approved: ApprovalRecord = { + ...selected, + status: 'approved', + once: options.once !== false, + approvedAt: now.toISOString(), + }; + writeApprovalStore(storePath, { + version: 1, + records: store.records.map((record) => record.actionId === selected.actionId ? approved : record), + }, now); + return approved; +} + +export function consumeApprovedApproval( + storePath: string, + action: RuntimeAction, + now = new Date() +): ApprovalRecord | null { + return withApprovalStoreLock(storePath, () => { + const store = readApprovalStore(storePath, now); + const fingerprint = actionFingerprint(action); + const approved = store.records.find((record) => + record.status === 'approved' && + record.actionFingerprint === fingerprint && + Date.parse(record.expiresAt) > now.getTime() + ); + if (!approved) return null; + + const records = approved.once + ? store.records.filter((record) => record.actionId !== approved.actionId) + : store.records; + writeApprovalStore(storePath, { version: 1, records }, now); + return approved; + }); +} + +export function cleanupExpiredApprovals(storePath: string, now = new Date()): number { + if (!existsSync(storePath)) return 0; + const raw = readStoreFile(storePath); + const records = raw.records.filter((record) => Date.parse(record.expiresAt) > now.getTime()); + const removed = raw.records.length - records.length; + if (removed > 0) writeApprovalStore(storePath, { version: 1, records }, now); + return removed; +} + +function selectPendingApproval( + records: ApprovalRecord[], + options: { actionId?: string; last?: boolean; sessionId?: string }, + now: Date +): ApprovalRecord { + if (options.actionId) { + const found = records.find((record) => record.actionId === options.actionId); + if (!found) throw new Error(`No pending approval found for action ${options.actionId}.`); + return found; + } + + if (!options.last) { + throw new Error('Specify --action-id or --last.'); + } + + const sessionMatches = options.sessionId + ? records.filter((record) => record.sessionId === options.sessionId) + : []; + if (sessionMatches.length === 1) return sessionMatches[0]; + if (sessionMatches.length > 1) { + throw new Error('Multiple pending approvals match this session. Run `agentguard approvals list` and approve a specific action id.'); + } + + const recent = records.filter((record) => now.getTime() - Date.parse(record.createdAt) <= LAST_APPROVAL_WINDOW_MS); + if (recent.length === 1) return recent[0]; + if (recent.length > 1) { + throw new Error('Multiple recent pending approvals exist. Run `agentguard approvals list` and approve a specific action id.'); + } + throw new Error('No pending approval is available to approve.'); +} + +function readApprovalStore(storePath: string, now: Date): ApprovalStore { + if (!existsSync(storePath)) return { version: 1, records: [] }; + const raw = readStoreFile(storePath); + const records = raw.records.filter((record) => Date.parse(record.expiresAt) > now.getTime()); + if (records.length !== raw.records.length) { + writeApprovalStore(storePath, { version: 1, records }, now); + } + return { version: 1, records }; +} + +function readStoreFile(storePath: string): ApprovalStore { + try { + const parsed = JSON.parse(readFileSync(storePath, 'utf8')) as Partial; + return { + version: 1, + records: Array.isArray(parsed.records) ? parsed.records.filter(isApprovalRecord) : [], + }; + } catch { + return { version: 1, records: [] }; + } +} + +function writeApprovalStore(storePath: string, store: ApprovalStore, now: Date): void { + const records = store.records.filter((record) => Date.parse(record.expiresAt) > now.getTime()); + if (records.length === 0) { + rmSync(storePath, { force: true }); + return; + } + mkdirSync(dirname(storePath), { recursive: true, mode: 0o700 }); + writeFileSync(storePath, `${JSON.stringify({ version: 1, records }, null, 2)}\n`, { mode: 0o600 }); + chmodBestEffort(storePath, 0o600); +} + +function isApprovalRecord(value: unknown): value is ApprovalRecord { + if (!value || typeof value !== 'object') return false; + const record = value as Partial; + return ( + typeof record.actionId === 'string' && + (record.status === 'pending' || record.status === 'approved') && + typeof record.actionFingerprint === 'string' && + typeof record.sessionId === 'string' && + typeof record.expiresAt === 'string' + ); +} + +function normalizeActionInput(input: string): string { + return input.trim(); +} + +function withApprovalStoreLock(storePath: string, fn: () => T): T { + const lockPath = `${storePath}.lock`; + acquireApprovalStoreLock(lockPath); + try { + return fn(); + } finally { + rmSync(lockPath, { recursive: true, force: true }); + } +} + +function acquireApprovalStoreLock(lockPath: string): void { + const startedAt = Date.now(); + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + while (true) { + try { + mkdirSync(lockPath, { mode: 0o700 }); + return; + } catch (err) { + if (!isAlreadyExistsError(err)) throw err; + removeStaleLock(lockPath); + if (Date.now() - startedAt > LOCK_TIMEOUT_MS) { + throw new Error('Timed out waiting for AgentGuard approval store lock.'); + } + sleepSync(25); + } + } +} + +function removeStaleLock(lockPath: string): void { + try { + const ageMs = Date.now() - statSync(lockPath).mtimeMs; + if (ageMs > LOCK_STALE_MS) { + rmSync(lockPath, { recursive: true, force: true }); + } + } catch { + // Another process may have released the lock between mkdir attempts. + } +} + +function isAlreadyExistsError(err: unknown): boolean { + return Boolean(err && typeof err === 'object' && (err as { code?: string }).code === 'EEXIST'); +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function chmodBestEffort(path: string, mode: number): void { + try { + chmodSync(path, mode); + } catch { + // Best-effort hardening for platforms/filesystems that support chmod. + } +} diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 573e081..fb96802 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -1,6 +1,8 @@ import { cwd } from 'node:process'; +import { dirname, join } from 'node:path'; import { AgentGuardCloudClient } from '../cloud/client.js'; import type { AgentGuardConfig } from '../config.js'; +import { consumeApprovedApproval, writePendingApproval, type ApprovalRecord } from './approvals.js'; import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js'; import { evaluateLocalAction } from './evaluator.js'; import { resolveRuntimePolicy } from './policy.js'; @@ -22,6 +24,7 @@ export interface ProtectResult { decision: RuntimeDecision; event: RuntimeAuditEvent; approvalChannel?: 'agent' | null; + pendingApproval?: ApprovalRecord; policySource: 'cloud' | 'cache' | 'default' | 'cloud-decision'; } @@ -29,6 +32,7 @@ export async function protectAction(options: ProtectOptions): Promise { const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-force-default-cwd-')); const cliPath = resolve('dist', 'cli.js'); const pluginDir = join(cwd, '.openclaw', 'plugins', 'agentguard'); + const skillDir = join(cwd, '.openclaw', 'skills', 'agentguard'); mkdirSync(pluginDir, { recursive: true }); + mkdirSync(skillDir, { recursive: true }); writeFileSync(join(pluginDir, 'index.js'), 'old plugin template'); + writeFileSync(join(skillDir, 'SKILL.md'), 'old skill template'); await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'openclaw'], { cwd, @@ -197,6 +200,9 @@ describe('init CLI', () => { const template = readFileSync(join(pluginDir, 'index.js'), 'utf8'); assert.notEqual(template, 'old plugin template'); assert.match(template, /loadAgentGuard/); + const skill = readFileSync(join(skillDir, 'SKILL.md'), 'utf8'); + assert.notEqual(skill, 'old skill template'); + assert.match(skill, /agentguard approve --last --once/); }); it('preserves existing agent templates with --no-force', async () => { @@ -204,8 +210,11 @@ describe('init CLI', () => { const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-no-force-cwd-')); const cliPath = resolve('dist', 'cli.js'); const pluginDir = join(cwd, '.openclaw', 'plugins', 'agentguard'); + const skillDir = join(cwd, '.openclaw', 'skills', 'agentguard'); mkdirSync(pluginDir, { recursive: true }); + mkdirSync(skillDir, { recursive: true }); writeFileSync(join(pluginDir, 'index.js'), 'old plugin template'); + writeFileSync(join(skillDir, 'SKILL.md'), 'old skill template'); await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'openclaw', '--no-force'], { cwd, @@ -213,6 +222,7 @@ describe('init CLI', () => { }); assert.equal(readFileSync(join(pluginDir, 'index.js'), 'utf8'), 'old plugin template'); + assert.equal(readFileSync(join(skillDir, 'SKILL.md'), 'utf8'), 'old skill template'); }); it('accepts Hermes and QClaw agent installers', async () => { diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index fbba39c..faed118 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -101,13 +101,14 @@ describe('Agent template installers', () => { const config = JSON.parse(readFileSync(join(dir, '.qclaw', 'qclaw.json'), 'utf8')); assert.equal(result.agent, 'qclaw'); + assert.ok(result.files.includes(join(dir, '.qclaw', 'skills', 'agentguard'))); assert.ok(existsSync(join(dir, '.qclaw', 'skills', 'agentguard', 'SKILL.md'))); assert.deepEqual(packageJson.qclaw.extensions, ['./index.js']); assert.equal(config.plugins.entries.agentguard.enabled, true); assert.deepEqual(config.plugins.load.paths, [pluginDir]); }); - it('writes and enables OpenClaw plugin template', () => { + it('writes OpenClaw skill and enables plugin template', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-')); const result = installAgentTemplates('openclaw', { cwd: dir }); @@ -117,7 +118,9 @@ describe('Agent template installers', () => { const manifest = readFileSync(join(pluginDir, 'openclaw.plugin.json'), 'utf8'); const config = JSON.parse(readFileSync(join(dir, '.openclaw', 'openclaw.json'), 'utf8')); - assert.equal(result.files.length, 4); + assert.equal(result.files.length, 5); + assert.ok(result.files.includes(join(dir, '.openclaw', 'skills', 'agentguard'))); + assert.ok(existsSync(join(dir, '.openclaw', 'skills', 'agentguard', 'SKILL.md'))); assert.deepEqual(packageJson.openclaw.extensions, ['./index.js']); assert.deepEqual(packageJson.openclaw.runtimeExtensions, ['./index.js']); assert.ok(template.includes('registerOpenClawPlugin')); @@ -147,10 +150,14 @@ describe('Agent template installers', () => { const workspaceConfig = JSON.parse(readFileSync(join(workspaceRoot, 'openclaw.json'), 'utf8')); assert.ok(result.files.includes(join(mainRoot, 'openclaw.json'))); + assert.ok(existsSync(join(mainRoot, 'skills', 'agentguard', 'SKILL.md'))); + assert.ok(existsSync(join(workspaceRoot, 'skills', 'agentguard', 'SKILL.md'))); assert.ok(existsSync(join(mainPluginDir, 'openclaw.plugin.json'))); assert.ok(existsSync(join(workspacePluginDir, 'openclaw.plugin.json'))); assert.deepEqual(mainConfig.plugins.load.paths, [mainPluginDir]); assert.deepEqual(workspaceConfig.plugins.load.paths, [workspacePluginDir]); + assert.ok(existsSync(join(mainRoot, 'skills', 'agentguard', 'SKILL.md'))); + assert.ok(existsSync(join(workspaceRoot, 'skills', 'agentguard', 'SKILL.md'))); } finally { if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; else process.env.OPENCLAW_STATE_DIR = previousStateDir; diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index b0788bf..03442cb 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -7,6 +7,7 @@ import { evaluateLocalAction } from '../runtime/evaluator.js'; import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; import { redactText } from '../runtime/redaction.js'; import { flushEventSpool, spoolEvent } from '../runtime/audit.js'; +import { actionFingerprint, approvePendingApproval, cleanupExpiredApprovals, listPendingApprovals } from '../runtime/approvals.js'; import { exitCodeForDecision, formatProtectResult, protectAction } from '../runtime/protect.js'; import type { ProtectResult } from '../runtime/protect.js'; import { connectAgentJwt, connectCloud, disconnectCloud, getAgentGuardPaths } from '../config.js'; @@ -591,6 +592,91 @@ describe('Runtime Cloud bridge', () => { } }); + it('stores pending approvals with expiration and cleans expired records', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-approval-expiry-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + approvalStorePath: join(dir, 'approvals.json'), + }; + + const result = await protectAction({ + config, + agentHost: 'codex', + stdinText: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'cat ~/.ssh/id_rsa.pub' }, + session_id: 'sess_approval_expiry', + }), + }); + + assert.equal(result?.decision.decision, 'require_approval'); + assert.equal(result?.pendingApproval?.status, 'pending'); + assert.ok(result?.pendingApproval?.expiresAt); + assert.equal(listPendingApprovals(config.approvalStorePath!).length, 1); + + const removed = cleanupExpiredApprovals( + config.approvalStorePath!, + new Date(Date.parse(result!.pendingApproval!.expiresAt) + 1) + ); + + assert.equal(removed, 1); + assert.equal(listPendingApprovals(config.approvalStorePath!).length, 0); + }); + + it('approves one pending action once and consumes the grant on retry', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-approval-once-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + approvalStorePath: join(dir, 'approvals.json'), + }; + const stdinText = JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'cat ~/.ssh/id_rsa.pub' }, + session_id: 'sess_approval_once', + }); + + const blocked = await protectAction({ config, agentHost: 'codex', stdinText }); + assert.equal(blocked?.decision.decision, 'require_approval'); + + const approved = approvePendingApproval(config.approvalStorePath!, { + actionId: blocked!.decision.actionId, + once: true, + }); + assert.equal(approved.status, 'approved'); + + const allowedRetry = await protectAction({ config, agentHost: 'codex', stdinText }); + assert.equal(allowedRetry?.decision.decision, 'allow'); + assert.equal(allowedRetry?.event.decision, 'allow'); + assert.equal(allowedRetry?.event.metadata?.approvedByLocalGrant, true); + assert.match(readFileSync(config.auditPath, 'utf8'), /approvedByLocalGrant/); + + const blockedAgain = await protectAction({ config, agentHost: 'codex', stdinText }); + assert.equal(blockedAgain?.decision.decision, 'require_approval'); + }); + + it('does not collapse internal whitespace when fingerprinting approved actions', () => { + const base = { + sessionId: 'sess_fingerprint', + agentHost: 'codex' as const, + actionType: 'shell' as const, + toolName: 'Bash', + cwd: '/workspace', + }; + + assert.notEqual( + actionFingerprint({ ...base, input: 'printf "a b"' }), + actionFingerprint({ ...base, input: 'printf "a b"' }) + ); + }); + it('formats Claude Code agent approval as a PreToolUse ask response', () => { const result: ProtectResult = { policySource: 'cloud',