diff --git a/CHANGELOG.md b/CHANGELOG.md index 55954e7..d07a057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.1.17] - 2026-05-26 +## [1.1.18] - 2026-05-26 ### Added - Added Agent JWT registration and activation links for OpenClaw-backed Cloud connections. @@ -13,9 +13,18 @@ - Connect and disconnect flows now keep API key and Agent JWT credentials mutually clean. - `agentguard subscribe --cron` now installs OpenClaw jobs with `delivery.mode = none` / `--no-deliver`, then lets the normal internal `--cron-run` path auto-detect the saved OpenClaw host and send notifications directly to the latest deliverable session route instead of relying on `channel:last` announce fallback. - `agentguard subscribe --cron --cron-target openclaw` now rejects saved-host mismatches, so an existing non-OpenClaw `agentHost` can no longer install an OpenClaw cron job that would run without any working notification route. +- `agentguard init --agent ` now overwrites managed hook/template files by default so upgraded OpenClaw plugin templates are refreshed without requiring `--force`; use `--no-force` to preserve existing files. +- OpenClaw runtime approval-required decisions now hard-block tool calls instead of routing through the OpenClaw plugin approval channel, avoiding accidental auto-allow for sensitive local file access. +- OpenClaw Gateway WebSocket fallback now signs the connect handshake with the saved local device identity when available. ### Fixed +- Fixed Cloud runtime decisions that return `require_approve` instead of `require_approval`. - Improved disconnected Cloud guidance and Agent JWT reauth handling. +- Fixed OpenClaw plugin registration after global npm installs by generating a package-root fallback loader in the local OpenClaw plugin template. +- Added OpenClaw plugin startup/hook activation metadata so AgentGuard loads as a runtime hook plugin during gateway startup. +- Fixed runtime protected-path matching so shell commands and file reads against `~/.ssh/**` also match absolute home paths such as `/Users/.../.ssh/id_ed25519.pub`. +- Fixed OpenClaw Gateway cron setup to fall back from CLI invocation to direct Gateway RPC when plugin protocol compatibility prevents CLI Gateway access. +- Fixed OpenClaw Gateway WebSocket fallback protocol negotiation for current v4 gateways and made invalid local device identity keys degrade to unsigned connect params instead of failing the fallback. ## [1.1.14] - 2026-05-22 diff --git a/openclaw.plugin.json b/openclaw.plugin.json index d444cbb..1a20053 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -3,6 +3,10 @@ "name": "GoPlus AgentGuard", "description": "AI agent security framework — blocks dangerous commands, prevents data leaks, and protects secrets", "skills": ["./skills"], + "activation": { + "onStartup": true, + "onCapabilities": ["hook"] + }, "configSchema": { "type": "object", "properties": { diff --git a/package-lock.json b/package-lock.json index fa781fe..9bfb4e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@goplus/agentguard", - "version": "1.1.17", + "version": "1.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@goplus/agentguard", - "version": "1.1.17", + "version": "1.1.18", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e62129f..e10804d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@goplus/agentguard", - "version": "1.1.17", + "version": "1.1.18", "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", diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index 673589b..3bc940d 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -655,7 +655,7 @@ function runtimeResultToBeforeToolCallResult( ): OpenClawBeforeToolCallResult | undefined { if (!result) return undefined; - const decision = result.decision.decision; + const decision = normalizeRuntimePolicyDecision(result.decision.decision); if (decision !== 'block' && decision !== 'require_approval') { return undefined; } @@ -674,16 +674,8 @@ function runtimeResultToBeforeToolCallResult( ` (risk ${result.decision.riskScore}/100, ${result.decision.riskLevel}; policy ${result.decision.policyVersion}).` + (reasonSummary ? ` Reasons: ${reasonSummary}.` : ''); - if (decision === 'require_approval' && result.approvalChannel === 'agent') { - return { - requireApproval: { - title: 'AgentGuard approval required', - description: reason, - severity: openClawApprovalSeverity(result.decision.riskLevel), - timeoutMs: 60_000, - timeoutBehavior: 'deny', - }, - }; + if (decision === 'require_approval') { + return { block: true, blockReason: reason }; } return { block: true, @@ -691,12 +683,6 @@ function runtimeResultToBeforeToolCallResult( }; } -function openClawApprovalSeverity(riskLevel: ProtectResult['decision']['riskLevel']): 'info' | 'warning' | 'critical' { - if (riskLevel === 'critical' || riskLevel === 'high') return 'critical'; - if (riskLevel === 'medium') return 'warning'; - return 'info'; -} - function shouldSurfaceRuntimeApproval(result: ProtectResult): boolean { return ( result.policySource === 'cloud-decision' || @@ -705,6 +691,10 @@ function shouldSurfaceRuntimeApproval(result: ProtectResult): boolean { ); } +function normalizeRuntimePolicyDecision(decision: ProtectResult['decision']['decision'] | string): ProtectResult['decision']['decision'] { + return decision === 'require_approve' ? 'require_approval' : decision as ProtectResult['decision']['decision']; +} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/cli.ts b/src/cli.ts index d30dcce..aacc39f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -63,7 +63,9 @@ async function main() { .option('--agent ', 'Install hook/template for claude-code, codex, openclaw, hermes, or qclaw') .option('--cloud ', 'AgentGuard Cloud URL to store in local config') .option('--force', 'Overwrite existing hook/template files') + .option('--no-force', 'Do not overwrite existing hook/template files') .action((options) => { + const forceTemplates = options.force !== false; let config = ensureConfig(); if (options.level) { if (!['strict', 'balanced', 'permissive'].includes(options.level)) { @@ -82,7 +84,7 @@ async function main() { if (options.agent) { const normalizedAgent = String(options.agent).trim().toLowerCase(); if (normalizedAgent === 'auto') { - const results = initAutoAgents(config, Boolean(options.force)); + const results = initAutoAgents(config, forceTemplates); if (results.detected.length === 0) { console.log('No supported agent directories found. Looked for .claude, .openclaw, .hermes, .qclaw, and .codex.'); } else if (results.installed.length === 0) { @@ -104,7 +106,7 @@ async function main() { config.agentHost = agent; config.agentHosts = appendAgentHost(config.agentHosts, agent); saveConfig(config); - const result = installAgentTemplates(agent, { force: options.force }); + const result = installAgentTemplates(agent, { force: forceTemplates }); console.log(`Installed ${result.agent} template:`); for (const file of result.files) console.log(`- ${file}`); } diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 46e0136..6c5ddf3 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -27,6 +27,7 @@ export interface OpenClawGatewayOptions { url?: string; label?: string; timeoutMs?: number; + runCommand?: CommandRunner; request?: (method: string, params: unknown) => Promise; } @@ -53,6 +54,8 @@ class GatewayHttpFallbackError extends Error {} const OPENCLAW_STATE_DIRNAME = '.openclaw'; const OPENCLAW_LEGACY_STATE_DIRNAME = '.clawdbot'; const OPENCLAW_IDENTITY_PATH = ['identity', 'device.json'] as const; +const OPENCLAW_GATEWAY_MIN_PROTOCOL = 3; +const OPENCLAW_GATEWAY_MAX_PROTOCOL = 4; const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); const ED25519_PKCS8_PRIVATE_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); @@ -731,18 +734,92 @@ export function openClawGatewayRequest( const port = options.port ?? 18789; const label = options.label ?? 'OpenClaw Gateway'; const timeoutMs = options.timeoutMs ?? 5000; + if (shouldUseOpenClawGatewayCli(options)) { + return openClawGatewayCliRequest({ + method, + params, + label, + timeoutMs, + runCommand: options.runCommand ?? execCommand, + }).catch(() => openClawGatewayNetworkRequest({ host, port, method, params, label, timeoutMs, url: options.url })); + } + + return openClawGatewayNetworkRequest({ host, port, method, params, label, timeoutMs, url: options.url }); +} + +function openClawGatewayNetworkRequest(options: { + host: string; + port: number; + method: string; + params: unknown; + label: string; + timeoutMs: number; + url?: string; +}): Promise { if (options.url) { - return openClawGatewayWebSocketRequest({ url: options.url, method, params, label, timeoutMs }); + return openClawGatewayWebSocketRequest({ + url: options.url, + method: options.method, + params: options.params, + label: options.label, + timeoutMs: options.timeoutMs, + }); } - return openClawGatewayHttpRequest({ host, port, method, params, label, timeoutMs }).catch((err) => { + return openClawGatewayHttpRequest({ + host: options.host, + port: options.port, + method: options.method, + params: options.params, + label: options.label, + timeoutMs: options.timeoutMs, + }).catch((err) => { if (err instanceof GatewayHttpFallbackError) { - return openClawGatewayWebSocketRequest({ url: `ws://${host}:${port}`, method, params, label, timeoutMs }); + return openClawGatewayWebSocketRequest({ + url: `ws://${options.host}:${options.port}`, + method: options.method, + params: options.params, + label: options.label, + timeoutMs: options.timeoutMs, + }); } throw err; }); } +function shouldUseOpenClawGatewayCli(options: OpenClawGatewayOptions): boolean { + if (options.url || options.host || options.port) return false; + return !options.label || options.label === 'OpenClaw Gateway'; +} + +async function openClawGatewayCliRequest(options: { + method: string; + params: unknown; + label: string; + timeoutMs: number; + runCommand: CommandRunner; +}): Promise { + const result = await options.runCommand('openclaw', [ + 'gateway', + 'call', + options.method, + '--params', + JSON.stringify(options.params ?? {}), + '--timeout', + String(options.timeoutMs), + '--json', + ]); + const trimmed = result.stdout.trim(); + if (!trimmed) { + throw new Error(`${options.label} ${options.method} command returned no JSON output.`); + } + try { + return JSON.parse(trimmed); + } catch { + throw new Error(`${options.label} ${options.method} command returned non-JSON output: ${trimmed}`); + } +} + function openClawGatewayHttpRequest(options: { host: string; port: number; @@ -1098,8 +1175,8 @@ function encodeWebSocketFrame(text: string, opcode = 0x1): Buffer { function openClawConnectParams(connectNonce?: string): unknown { return { - minProtocol: 3, - maxProtocol: 3, + minProtocol: OPENCLAW_GATEWAY_MIN_PROTOCOL, + maxProtocol: OPENCLAW_GATEWAY_MAX_PROTOCOL, client: { id: 'cli', version: 'agentguard', @@ -1136,33 +1213,37 @@ function buildOpenClawGatewayDeviceAuth(connectNonce?: string): { device: Record if (!connectNonce?.trim()) return undefined; const identity = loadOpenClawDeviceIdentity(); if (!identity) return undefined; - const signedAtMs = Date.now(); - const payload = buildOpenClawDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: 'cli', - clientMode: 'cli', - role: 'operator', - scopes: [ - 'operator.admin', - 'operator.read', - 'operator.write', - 'operator.approvals', - 'operator.pairing', - 'operator.talk.secrets', - ], - signedAtMs, - nonce: connectNonce, - platform: process.platform, - }); - return { - device: { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signOpenClawDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, + try { + const signedAtMs = Date.now(); + const payload = buildOpenClawDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: 'cli', + clientMode: 'cli', + role: 'operator', + scopes: [ + 'operator.admin', + 'operator.read', + 'operator.write', + 'operator.approvals', + 'operator.pairing', + 'operator.talk.secrets', + ], + signedAtMs, nonce: connectNonce, - }, - }; + platform: process.platform, + }); + return { + device: { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signOpenClawDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: connectNonce, + }, + }; + } catch { + return undefined; + } } function buildOpenClawDeviceAuthPayload(params: { diff --git a/src/installers.ts b/src/installers.ts index 274e441..7d0eea5 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -224,7 +224,27 @@ function installClawPlugin(agent: 'openclaw' | 'qclaw', root: string, configPath } function openClawPluginTemplate(): string { - return `const { registerOpenClawPlugin } = require('@goplus/agentguard'); + const packageRoot = resolve(__dirname, '..'); + return `const agentGuardPackageRoot = ${JSON.stringify(packageRoot)}; + +function loadAgentGuard() { + try { + return require('@goplus/agentguard'); + } catch (firstError) { + try { + return require(agentGuardPackageRoot); + } catch (fallbackError) { + const error = new Error( + 'Unable to load @goplus/agentguard from OpenClaw plugin. ' + + 'Tried package resolution and fallback path: ' + agentGuardPackageRoot + ); + error.cause = fallbackError; + throw error; + } + } +} + +const { registerOpenClawPlugin } = loadAgentGuard(); function register(api) { registerOpenClawPlugin(api, { @@ -269,6 +289,10 @@ function openClawPluginManifest(): unknown { id: 'agentguard', name: 'GoPlus AgentGuard', description: 'AI agent security framework - blocks dangerous commands, prevents data leaks, and protects secrets', + activation: { + onStartup: true, + onCapabilities: ['hook'], + }, configSchema: { type: 'object', properties: { diff --git a/src/runtime/evaluator.ts b/src/runtime/evaluator.ts index a5786ae..26dde9a 100644 --- a/src/runtime/evaluator.ts +++ b/src/runtime/evaluator.ts @@ -1,4 +1,5 @@ import { ActionScanner } from '../action/index.js'; +import { homedir } from 'node:os'; import { DEFAULT_CAPABILITY } from '../types/skill.js'; import type { ActionData, ActionEvidence, ActionType } from '../types/action.js'; import type { @@ -69,6 +70,17 @@ function customPolicyReasons(policy: EffectiveRuntimePolicy, action: RuntimeActi )); } } + for (const pathPattern of policy.protectedPaths) { + if (matchesPath(input, pathPattern)) { + reasons.push(reason( + 'SECRET_ACCESS', + 'high', + 'Protected path access', + 'The agent attempted to access a path protected by runtime policy.', + pathPattern + )); + } + } for (const domain of policy.network.blockedDomains) { if (domain && lower.includes(domain.toLowerCase())) { reasons.push(reason( @@ -238,7 +250,45 @@ function matchesPattern(input: string, pattern: string): boolean { function matchesPath(input: string, pattern: string): boolean { if (!pattern) return false; - const normalizedInput = input.replace(/\\/g, '/'); - const needle = pattern.replace(/\\/g, '/').replace(/\*\*/g, '').replace(/\*/g, ''); - return Boolean(needle) && normalizedInput.includes(needle); + const normalizedInput = normalizePathLike(input); + return expandHomePattern(pattern).map(normalizePathLike).some((candidate) => { + if (!candidate) return false; + if (!candidate.includes('*')) { + return normalizedInput.includes(candidate); + } + return new RegExp(globToRegexSource(candidate)).test(normalizedInput); + }); +} + +function normalizePathLike(value: string): string { + return value.replace(/\\/g, '/'); +} + +function expandHomePattern(pattern: string): string[] { + if (!pattern.startsWith('~/')) { + return [pattern]; + } + return [pattern, `${homedir().replace(/\\/g, '/')}/${pattern.slice(2)}`]; +} + +function globToRegexSource(pattern: string): string { + let source = ''; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + if (char === '*') { + if (pattern[index + 1] === '*') { + source += '.*'; + index += 1; + } else { + source += '[^/\\s\'"]*'; + } + continue; + } + source += escapeRegex(char); + } + return source; +} + +function escapeRegex(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); } diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 419f21d..5d68343 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -36,14 +36,14 @@ export async function protectAction(options: ProtectOptions): Promise client.fetchEffectivePolicy() : undefined, }); - decision = await evaluateLocalAction(policy, action); + decision = normalizeRuntimeDecision(await evaluateLocalAction(policy, action)); policySource = source; } @@ -79,6 +79,14 @@ export async function protectAction(options: ProtectOptions): Promise { assert.equal(config.agentHost, 'codex'); }); + it('overwrites existing agent templates by default', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-force-default-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-force-default-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + const pluginDir = join(cwd, '.openclaw', 'plugins', 'agentguard'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, 'index.js'), 'old plugin template'); + + await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'openclaw'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home, OPENCLAW_STATE_DIR: join(cwd, '.openclaw') }, + }); + + const template = readFileSync(join(pluginDir, 'index.js'), 'utf8'); + assert.notEqual(template, 'old plugin template'); + assert.match(template, /loadAgentGuard/); + }); + + it('preserves existing agent templates with --no-force', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-init-no-force-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-no-force-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + const pluginDir = join(cwd, '.openclaw', 'plugins', 'agentguard'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, 'index.js'), 'old plugin template'); + + await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'openclaw', '--no-force'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home, OPENCLAW_STATE_DIR: join(cwd, '.openclaw') }, + }); + + assert.equal(readFileSync(join(pluginDir, 'index.js'), 'utf8'), 'old plugin template'); + }); + it('accepts Hermes and QClaw agent installers', async () => { for (const agent of ['hermes', 'qclaw']) { const home = mkdtempSync(join(tmpdir(), `agentguard-init-${agent}-home-`)); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index fc81949..686115e 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -587,6 +587,34 @@ describe('feed/cron', () => { ); }); + it('prefers the OpenClaw CLI Gateway call for default local OpenClaw requests', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const result = await openClawGatewayRequest('sessions.list', { limit: 1 }, { + timeoutMs: 1234, + runCommand: async (command, args) => { + calls.push({ command, args }); + return { + stdout: JSON.stringify({ sessions: [{ key: 'session-1' }] }), + stderr: '', + }; + }, + }); + + assert.deepEqual(result, { sessions: [{ key: 'session-1' }] }); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.command, 'openclaw'); + assert.deepEqual(calls[0]!.args, [ + 'gateway', + 'call', + 'sessions.list', + '--params', + '{"limit":1}', + '--timeout', + '1234', + '--json', + ]); + }); + it('keeps the default HTTP JSON-RPC Gateway path and legacy cron.add params', async () => { let requestBody: any; const server = http.createServer((req, res) => { @@ -609,6 +637,9 @@ describe('feed/cron', () => { host: '127.0.0.1', port: serverPort(server), timeoutMs: 100, + runCommand: async () => { + throw new Error('explicit host/port should skip OpenClaw CLI'); + }, }); assert.deepEqual(result, { ok: true }); @@ -752,6 +783,8 @@ describe('feed/cron', () => { }); assert.deepEqual(result, { jobs: [] }); + assert.equal(connectParams.minProtocol, 3); + assert.equal(connectParams.maxProtocol, 4); assert.equal(connectParams.client.id, 'cli'); assert.equal(connectParams.device.id, identity.deviceId); assert.equal(connectParams.device.publicKey, publicKeyRawBase64UrlFromPem(identity.publicKeyPem)); @@ -785,4 +818,88 @@ describe('feed/cron', () => { await closeServer(server); } }); + + it('omits device auth instead of failing when OpenClaw identity keys are invalid', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-bad-state-')); + const identityDir = join(stateDir, 'identity'); + mkdirSync(identityDir, { recursive: true }); + writeFileSync(join(identityDir, 'device.json'), JSON.stringify({ + version: 1, + deviceId: 'bad-device', + publicKeyPem: 'not a public key', + privateKeyPem: 'not a private key', + })); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + let connectParams: any; + + const server = net.createServer((socket) => { + let handshakeComplete = false; + let buffer = Buffer.alloc(0); + let clientRequests = 0; + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!handshakeComplete) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = buffer.subarray(0, headerEnd + 4).toString('utf8'); + const key = /^Sec-WebSocket-Key:\s*(.+)$/im.exec(header)?.[1]?.trim(); + assert.ok(key); + const accept = createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '', + '', + ].join('\r\n')); + handshakeComplete = true; + buffer = buffer.subarray(headerEnd + 4); + socket.write(encodeServerWebSocketFrame(JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'nonce-bad-identity' }, + }))); + } + + while (true) { + const parsed = readClientWebSocketFrame(buffer); + if (!parsed) break; + buffer = parsed.rest; + clientRequests += 1; + const frame = JSON.parse(parsed.payload); + if (clientRequests === 1) { + connectParams = frame.params; + socket.write(encodeServerWebSocketFrame(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} }))); + } else { + socket.write(encodeServerWebSocketFrame(JSON.stringify({ + type: 'res', + id: frame.id, + ok: true, + payload: { jobs: [] }, + }))); + } + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const result = await openClawGatewayRequest('cron.list', {}, { + url: `ws://127.0.0.1:${serverPort(server)}`, + timeoutMs: 500, + }); + + assert.deepEqual(result, { jobs: [] }); + assert.equal(connectParams.client.id, 'cli'); + assert.equal(connectParams.device, undefined); + } finally { + if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = previousStateDir; + await closeServer(server); + } + }); }); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index e1bba83..59014ef 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -400,7 +400,7 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.ok(result?.blockReason?.includes('cloud-test')); }); - it('should ask in the OpenClaw agent channel when runtime policy requires approval', async () => { + it('should block in OpenClaw when runtime policy requires approval', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); registerOpenClawPlugin(api as never, { @@ -434,16 +434,55 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { }) as { ask?: boolean; askReason?: string; - requireApproval?: { title?: string; description?: string; severity?: string; timeoutBehavior?: string }; + block?: boolean; + blockReason?: string; } | undefined; assert.equal(result?.ask, undefined); assert.equal(result?.askReason, undefined); - assert.equal(result?.requireApproval?.title, 'AgentGuard approval required'); - assert.equal(result?.requireApproval?.severity, 'critical'); - assert.equal(result?.requireApproval?.timeoutBehavior, 'deny'); - assert.ok(result?.requireApproval?.description?.includes('requires approval')); - assert.ok(result?.requireApproval?.description?.includes('Protected path')); + assert.equal(result?.block, true); + assert.ok(result?.blockReason?.includes('requires approval')); + assert.ok(result?.blockReason?.includes('Protected path')); + }); + + it('should normalize require_approve runtime decisions before blocking in OpenClaw', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + registerOpenClawPlugin(api as never, { + skipAutoScan: true, + registry: ctx.agentguard.registry as never, + protectAction: async () => ({ + policySource: 'cloud-decision', + approvalChannel: 'agent', + event: {} as never, + decision: { + actionId: 'act_approval_alias', + decision: 'require_approve' as never, + riskScore: 75, + riskLevel: 'high', + policyVersion: 'cloud-test', + reasons: [ + { + code: 'SECRET_ACCESS', + severity: 'high', + title: 'Protected path', + description: 'Protected path access requires approval.', + }, + ], + }, + }), + }); + + const result = await handlers['before_tool_call']({ + toolName: 'Read', + params: { path: '/workspace/.env' }, + }) as { + block?: boolean; + blockReason?: string; + } | undefined; + + assert.equal(result?.block, true); + assert.ok(result?.blockReason?.includes('requires approval')); }); it('should return { block: true } for rm -rf /', async () => { @@ -464,7 +503,7 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.ok(result!.blockReason?.includes('AgentGuard'), 'Reason should mention AgentGuard'); }); - it('should ask before writing .env via OpenClaw', async () => { + it('should block before writing .env via OpenClaw', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); registerOpenClawPlugin(api as never, { @@ -475,10 +514,10 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { const result = await handlers['before_tool_call']({ toolName: 'write', params: { path: '/project/.env' }, - }) as { requireApproval?: { description?: string } } | undefined; + }) as { block?: boolean; blockReason?: string } | undefined; - assert.ok(result?.requireApproval, 'Should ask before writing .env'); - assert.ok(result?.requireApproval?.description?.includes('requires approval')); + assert.equal(result?.block, true, 'Should block before writing .env'); + assert.ok(result?.blockReason?.includes('requires approval')); }); it('should handle after_tool_call without error', async () => { diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 58b2f59..3c57402 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { evaluateLocalAction } from '../runtime/evaluator.js'; import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; import { redactText } from '../runtime/redaction.js'; @@ -27,6 +27,36 @@ describe('Runtime Cloud bridge', () => { assert.ok(!redacted.includes('abc123')); }); + it('requires approval for shell commands reading SSH keys by absolute home path', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + const sshPublicKeyPath = `${homedir()}/.ssh/id_ed25519.pub`; + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'openclaw', + actionType: 'shell', + toolName: 'exec', + input: `cat ${sshPublicKeyPath}`, + }); + + assert.equal(decision.decision, 'require_approval'); + assert.ok(decision.reasons.some((reason) => reason.code === 'SECRET_ACCESS')); + }); + + it('matches protected paths against absolute home paths for file reads', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + const sshPublicKeyPath = `${homedir()}/.ssh/id_ed25519.pub`; + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_test', + agentHost: 'openclaw', + actionType: 'file_read', + toolName: 'read', + input: sshPublicKeyPath, + }); + + assert.equal(decision.decision, 'require_approval'); + assert.ok(decision.reasons.some((reason) => reason.code === 'SECRET_ACCESS')); + }); + it('rejects malformed keys and non-HTTPS Cloud URLs', () => { const previousHome = process.env.AGENTGUARD_HOME; process.env.AGENTGUARD_HOME = mkdtempSync(join(tmpdir(), 'agentguard-config-'));