From 657d19dd90877b1b48cbc522c96324ed472745f7 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Tue, 26 May 2026 21:22:56 +0800 Subject: [PATCH] fix: harden OpenClaw gateway integration --- CHANGELOG.md | 10 +- openclaw.plugin.json | 4 + package-lock.json | 4 +- package.json | 2 +- src/adapters/openclaw-plugin.ts | 18 +-- src/cli.ts | 11 +- src/feed/cron.ts | 268 +++++++++++++++++++++++++++++++- src/installers.ts | 26 +++- src/runtime/evaluator.ts | 56 ++++++- src/tests/cli-init.test.ts | 34 ++++ src/tests/feed-cron.test.ts | 264 ++++++++++++++++++++++++++++++- src/tests/integration.test.ts | 31 ++-- src/tests/runtime-cloud.test.ts | 32 +++- 13 files changed, 706 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3bedf5..d07a057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.1.16] - 2026-05-25 +## [1.1.18] - 2026-05-26 ### Added - Added Agent JWT registration and activation links for OpenClaw-backed Cloud connections. @@ -13,10 +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 d18cd51..9bfb4e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@goplus/agentguard", - "version": "1.1.16", + "version": "1.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@goplus/agentguard", - "version": "1.1.16", + "version": "1.1.18", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e61c516..e10804d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@goplus/agentguard", - "version": "1.1.16", + "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 5a32351..3bc940d 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -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' || diff --git a/src/cli.ts b/src/cli.ts index ce8c317..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}`); } @@ -693,8 +695,11 @@ async function main() { } if (summary.cron.result) { const label = summary.cron.result.backend ?? 'cron'; - const action = summary.cron.result.created ? `Installed ${label} cron job` : `${label} cron job already exists`; + const action = summary.cron.result.created ? `Installed ${label} cron job` : `${label} cron job already exists and was left unchanged`; console.log(`${action} "${summary.cron.result.name}" (${summary.cron.result.schedule}, ${summary.cron.result.timezone}).`); + if (!summary.cron.result.created) { + console.log('Existing cron jobs are not reconfigured unless --force is passed; rerun with --force to apply the requested quiet/manual mode and schedule.'); + } if (summary.cron.result.backend === 'system') { console.log(`System cron output: ${join(getAgentGuardPaths().home, 'feed-cron.log')}`); } else { diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 9ce75e5..6c5ddf3 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; -import { randomBytes, randomUUID, createHash } from 'node:crypto'; +import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign as signPayload } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; import http from 'node:http'; import net from 'node:net'; @@ -26,6 +27,7 @@ export interface OpenClawGatewayOptions { url?: string; label?: string; timeoutMs?: number; + runCommand?: CommandRunner; request?: (method: string, params: unknown) => Promise; } @@ -41,8 +43,22 @@ interface OpenClawCronJob { name?: string; } +interface OpenClawDeviceIdentity { + deviceId: string; + publicKeyPem: string; + privateKeyPem: string; +} + 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'); + export function validateCronExpression(value: string): string { const expr = value.trim(); const fields = expr.split(/\s+/); @@ -718,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; @@ -931,11 +1021,12 @@ function openClawGatewayWebSocketRequest(options: { return; } if (frame?.type === 'event' && frame.event === 'connect.challenge') { + const nonce = extractOpenClawConnectNonce(frame); socket.write(encodeWebSocketFrame(JSON.stringify({ type: 'req', id: connectRequestId, method: 'connect', - params: openClawConnectParams(), + params: openClawConnectParams(nonce), }))); return; } @@ -1082,10 +1173,10 @@ function encodeWebSocketFrame(text: string, opcode = 0x1): Buffer { return Buffer.concat([header, mask, masked]); } -function openClawConnectParams(): unknown { +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', @@ -1102,9 +1193,170 @@ function openClawConnectParams(): unknown { 'operator.pairing', 'operator.talk.secrets', ], + ...(buildOpenClawGatewayDeviceAuth(connectNonce) ?? {}), }; } function gatewayFrameErrorMessage(frame: any): string { return frame?.error?.message ?? JSON.stringify(frame?.error ?? frame); } + +function extractOpenClawConnectNonce(frame: unknown): string | undefined { + if (!frame || typeof frame !== 'object') return undefined; + const payload = (frame as { payload?: unknown }).payload; + if (!payload || typeof payload !== 'object') return undefined; + const nonce = (payload as { nonce?: unknown }).nonce; + return typeof nonce === 'string' && nonce.trim() ? nonce : undefined; +} + +function buildOpenClawGatewayDeviceAuth(connectNonce?: string): { device: Record } | undefined { + if (!connectNonce?.trim()) return undefined; + const identity = loadOpenClawDeviceIdentity(); + if (!identity) return undefined; + 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: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + nonce: string; + platform?: string; + deviceFamily?: string; + token?: string | null; +}): string { + return [ + 'v3', + params.deviceId, + params.clientId, + params.clientMode, + params.role, + params.scopes.join(','), + String(params.signedAtMs), + params.token ?? '', + params.nonce, + normalizeDeviceMetadataForAuth(params.platform), + normalizeDeviceMetadataForAuth(params.deviceFamily), + ].join('|'); +} + +function normalizeDeviceMetadataForAuth(value: string | undefined): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function loadOpenClawDeviceIdentity(): OpenClawDeviceIdentity | null { + try { + const raw = readFileSync(resolveOpenClawDeviceIdentityPath(), 'utf8'); + return normalizeOpenClawDeviceIdentity(JSON.parse(raw)); + } catch { + return null; + } +} + +function resolveOpenClawDeviceIdentityPath(): string { + return join(resolveOpenClawStateDir(), ...OPENCLAW_IDENTITY_PATH); +} + +function resolveOpenClawStateDir(): string { + const override = process.env.OPENCLAW_STATE_DIR?.trim(); + if (override) return override; + const current = join(homedir(), OPENCLAW_STATE_DIRNAME); + if (existsSync(current)) return current; + const legacy = join(homedir(), OPENCLAW_LEGACY_STATE_DIRNAME); + if (existsSync(legacy)) return legacy; + return current; +} + +function normalizeOpenClawDeviceIdentity(value: unknown): OpenClawDeviceIdentity | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + if ( + record.version === 1 && + typeof record.deviceId === 'string' && + typeof record.publicKeyPem === 'string' && + typeof record.privateKeyPem === 'string' + ) { + return { + deviceId: record.deviceId, + publicKeyPem: record.publicKeyPem, + privateKeyPem: record.privateKeyPem, + }; + } + if ( + typeof record.deviceId === 'string' && + typeof record.publicKey === 'string' && + typeof record.privateKey === 'string' + ) { + const publicKeyRaw = base64UrlDecode(record.publicKey); + const privateKeyRaw = base64UrlDecode(record.privateKey); + if (publicKeyRaw.length !== 32 || privateKeyRaw.length !== 32) return null; + return { + deviceId: record.deviceId, + publicKeyPem: pemEncode('PUBLIC KEY', Buffer.concat([ED25519_SPKI_PREFIX, publicKeyRaw])), + privateKeyPem: pemEncode('PRIVATE KEY', Buffer.concat([ED25519_PKCS8_PRIVATE_PREFIX, privateKeyRaw])), + }; + } + return null; +} + +function signOpenClawDevicePayload(privateKeyPem: string, payload: string): string { + return base64UrlEncode(signPayload(null, Buffer.from(payload, 'utf8'), createPrivateKey(privateKeyPem))); +} + +function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { + const publicKey = createPublicKey(publicKeyPem); + const spki = publicKey.export({ type: 'spki', format: 'der' }) as Buffer; + const raw = spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ? spki.subarray(ED25519_SPKI_PREFIX.length) + : spki; + return base64UrlEncode(raw); +} + +function base64UrlEncode(value: Buffer): string { + return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); +} + +function base64UrlDecode(value: string): Buffer { + const normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, 'base64'); +} + +function pemEncode(label: 'PUBLIC KEY' | 'PRIVATE KEY', der: Buffer): string { + const body = der.toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''; + return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`; +} 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/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index 710d86f..4d7039b 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -55,6 +55,40 @@ describe('init CLI', () => { 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 f040e16..686115e 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { createHash, createPublicKey, generateKeyPairSync, verify } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; import { tmpdir } from 'node:os'; @@ -16,6 +16,8 @@ import { type RpcCall = { method: string; params: any }; +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + async function closeServer(server: http.Server | net.Server): Promise { await new Promise((resolve, reject) => { server.close((err) => { @@ -86,6 +88,45 @@ function fakeGateway(jobs: Array<{ id: string; name: string }> = []): { }; } +function base64UrlEncode(value: Buffer): string { + return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); +} + +function base64UrlDecode(value: string): Buffer { + const normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, 'base64'); +} + +function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { + const publicKey = createPublicKey(publicKeyPem); + const spki = publicKey.export({ type: 'spki', format: 'der' }) as Buffer; + const raw = spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ? spki.subarray(ED25519_SPKI_PREFIX.length) + : spki; + return base64UrlEncode(raw); +} + +function writeOpenClawIdentity(stateDir: string): { deviceId: string; publicKeyPem: string; privateKeyPem: string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + const deviceId = createHash('sha256') + .update(base64UrlDecode(publicKeyRawBase64UrlFromPem(publicKeyPem))) + .digest('hex'); + const identityDir = join(stateDir, 'identity'); + mkdirSync(identityDir, { recursive: true }); + writeFileSync(join(identityDir, 'device.json'), JSON.stringify({ + version: 1, + deviceId, + publicKeyPem, + privateKeyPem, + createdAtMs: Date.now(), + })); + return { deviceId, publicKeyPem, privateKeyPem }; +} + describe('feed/cron', () => { it('validateCronExpression rejects non-five-field values', () => { assert.equal(validateCronExpression('0 * * * *'), '0 * * * *'); @@ -546,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) => { @@ -568,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 }); @@ -642,4 +714,192 @@ describe('feed/cron', () => { await closeServer(server); } }); + + it('sends signed device identity during the WebSocket connect handshake when OpenClaw identity exists', async () => { + const stateDir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-state-')); + const identity = writeOpenClawIdentity(stateDir); + 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-1' }, + }))); + } + + 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.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)); + assert.equal(connectParams.device.nonce, 'nonce-1'); + assert.equal(typeof connectParams.device.signedAt, 'number'); + const signedPayload = [ + 'v3', + identity.deviceId, + 'cli', + 'cli', + 'operator', + 'operator.admin,operator.read,operator.write,operator.approvals,operator.pairing,operator.talk.secrets', + String(connectParams.device.signedAt), + '', + 'nonce-1', + process.platform, + '', + ].join('|'); + assert.equal( + verify( + null, + Buffer.from(signedPayload, 'utf8'), + createPublicKey(identity.publicKeyPem), + base64UrlDecode(connectParams.device.signature), + ), + true, + ); + } finally { + if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = previousStateDir; + 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 e05e0fa..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,19 +434,18 @@ 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 asking in OpenClaw', async () => { + it('should normalize require_approve runtime decisions before blocking in OpenClaw', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); registerOpenClawPlugin(api as never, { @@ -478,12 +477,12 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { toolName: 'Read', params: { path: '/workspace/.env' }, }) as { - requireApproval?: { title?: string; description?: string; severity?: string; timeoutBehavior?: string }; + block?: boolean; + blockReason?: string; } | undefined; - assert.equal(result?.requireApproval?.title, 'AgentGuard approval required'); - assert.equal(result?.requireApproval?.severity, 'critical'); - assert.ok(result?.requireApproval?.description?.includes('requires approval')); + assert.equal(result?.block, true); + assert.ok(result?.blockReason?.includes('requires approval')); }); it('should return { block: true } for rm -rf /', async () => { @@ -504,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, { @@ -515,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-'));