diff --git a/CHANGELOG.md b/CHANGELOG.md index d07a057..fbc4974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ ### Added - Added Agent JWT registration and activation links for OpenClaw-backed Cloud connections. - Added Cloud feed subscription support for the default advisory ecosystems. -- `agentguard status` now shows saved Agent JWT registration details. +- `agentguard status` now shows the active Cloud auth method, including API-key and Agent JWT connection details. ### Changed - Cloud flows now prefer Agent JWT auth when available, with API key support preserved. - Threat-feed notifications now include Cloud remediation guidance when available. - Connect and disconnect flows now keep API key and Agent JWT credentials mutually clean. +- `agentguard disconnect` now removes the managed threat-feed subscribe cron job from the configured agent backend and clears saved cron metadata. - `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. @@ -19,6 +20,8 @@ ### Fixed - Fixed Cloud runtime decisions that return `require_approve` instead of `require_approval`. +- Fixed OpenClaw Agent JWT connect so OpenClaw runtime detection can start registration without requiring an API key or prior AgentGuard init. +- Fixed AgentGuard runtime self-handling so direct `agentguard` and `agentguard-mcp` CLI commands are not audited, reported, or blocked by AgentGuard's own hooks while compound shell commands remain protected. - 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. diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 3215c51..699abc7 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -71,7 +71,7 @@ required post-install steps. Parse `$ARGUMENTS` to determine the subcommand: - **`init [args...]`** — Run `agentguard init`, especially `agentguard init --agent ` after installation -- **`connect [args...]`** — Run `agentguard connect` to connect optional Cloud policy, audit, and approvals +- **`connect [args...]`** — Run `agentguard connect` to connect optional Cloud policy, audit, and approvals. AgentGuard supports either API-key auth or Agent JWT auth; only one Cloud auth method is required. - **`scan `** — Scan a skill or codebase for security risks - **`action `** — Evaluate whether a runtime action is safe - **`patrol [run|setup|status]`** — Daily security patrol for OpenClaw environments @@ -98,9 +98,9 @@ Supported CLI commands and options: | CLI command | Options | Notes | |---|---|---| | `agentguard init` | `--level `, `--agent `, `--cloud `, `--force` | Creates local config, persists the selected agent host, and optionally installs templates for `claude-code`, `codex`, `openclaw`, `hermes`, or `qclaw` | -| `agentguard connect` | `--key `, `--api-key `, `--url `, `--cloud ` | Prefer `AGENTGUARD_API_KEY` over passing secrets in flags | -| `agentguard disconnect` | none | Removes local Cloud API key, connection timestamp, pending event spool, and cached Cloud policy; keeps Cloud URL, audit log, and installed hooks/templates | -| `agentguard status` | none | Shows local config, Cloud URL/API key status, policy cache, audit path | +| `agentguard connect` | `--key `, `--api-key `, `--url `, `--cloud ` | API-key auth and Agent JWT auth are alternatives; configure only one. Prefer `AGENTGUARD_API_KEY` over passing secrets in flags | +| `agentguard disconnect` | none | Removes local Cloud credentials, pending event spool, cached Cloud policy, and the managed `agentguard-threat-feed` subscribe cron job; keeps Cloud URL, audit log, and installed hooks/templates | +| `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 doctor` | none | Checks local setup and Cloud reachability when connected | @@ -109,6 +109,12 @@ Supported CLI commands and options: | `agentguard checkup` | `--json` | Runs the local agent health checkup | | `agentguard checkup --against-advisory ` | `--json` | CLI threat-feed self-check for one advisory; this is a targeted mode, not the default health-check workflow | +Connect behavior: + +- Always execute `agentguard connect ...` directly when the user asks for it. Do not answer that an API key must be obtained before running the command. +- `agentguard connect` with no `--key`, `--api-key`, or `AGENTGUARD_API_KEY` is valid in OpenClaw environments: the CLI uses Agent JWT registration, prints an activation link, and may notify the latest OpenClaw channel. +- Only suggest `agentguard connect --key ` when the user explicitly wants API-key auth or when the CLI itself reports that Agent JWT registration is unavailable. If the CLI says OpenClaw is not initialized, suggest `agentguard init --agent openclaw` and then rerun `agentguard connect`. + If the user writes `/agentguard cli `, execute `agentguard ` directly. 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 ...`. diff --git a/src/adapters/engine.ts b/src/adapters/engine.ts index cd44412..868580a 100644 --- a/src/adapters/engine.ts +++ b/src/adapters/engine.ts @@ -7,6 +7,7 @@ import { getSkillTrustPolicy, isActionAllowedByCapabilities, } from './common.js'; +import { isAgentGuardCliCommand } from '../runtime/self-command.js'; /** * Evaluate a hook event using the common AgentGuard decision engine. @@ -20,6 +21,9 @@ export async function evaluateHook( options: EngineOptions ): Promise { const input = adapter.parseInput(rawInput); + if (isAgentGuardHookCommand(adapter, input)) { + return { decision: 'allow' }; + } // Post-tool events → audit only if (input.eventType === 'post') { @@ -116,3 +120,9 @@ export async function evaluateHook( return { decision: 'allow' }; } } + +function isAgentGuardHookCommand(adapter: HookAdapter, input: HookInput): boolean { + if (adapter.mapToolToActionType(input.toolName) !== 'exec_command') return false; + const command = input.toolInput.command; + return typeof command === 'string' && isAgentGuardCliCommand(command); +} diff --git a/src/cli.ts b/src/cli.ts index aacc39f..c871fa5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,9 +32,11 @@ import { CloudRequestError } from './cloud/client.js'; import { notifyOpenClawMessage, notifyOpenClawRegistrationLink } from './cloud/openclaw-notify.js'; import { installThreatFeedCron, + removeThreatFeedCron, validateCronExpression, type OpenClawCronInstallResult, type CronBackend, + type ThreatFeedCronRemovalResult, type OpenClawGatewayOptions, } from './feed/cron.js'; @@ -122,10 +124,11 @@ async function main() { .action(async (options) => { const apiKey = options.key || options.apiKey || process.env.AGENTGUARD_API_KEY; if (!apiKey) { - const config = ensureConfig(); + let config = ensureConfig(); if (!isOpenClawAgentConfigured(config)) { throw new Error('Missing API key. Pass --key, --api-key, set AGENTGUARD_API_KEY, or run `agentguard init --agent openclaw` before using Agent JWT registration.'); } + config = withDetectedOpenClawAgentHost(config); const cloudUrl = normalizeCloudUrl(options.cloud || options.url || config.cloudUrl || 'https://agentguard.gopluslabs.io'); if (config.agentId && config.agentJwt) { const existingConfig = { ...config, cloudUrl }; @@ -183,10 +186,18 @@ async function main() { program .command('disconnect') .description('Disconnect local AgentGuard from AgentGuard Cloud') - .action(() => { + .action(async () => { + const currentConfig = ensureConfig(); + const cronRemoval = await removeThreatFeedCron({ + name: currentConfig.threatFeedCronName || 'agentguard-threat-feed', + backend: 'auto', + agentHost: resolveCronAgentHost(currentConfig), + agentGuardHome: getAgentGuardPaths().home, + }); const config = disconnectCloud(); console.log('Disconnected from AgentGuard Cloud.'); console.log('Removed local Cloud API key, Agent JWT, connection timestamp, pending event spool, and cached Cloud policy.'); + printCronRemovalSummary(cronRemoval); console.log(`Local protection remains active using the built-in policy. Audit log: ${config.auditPath}`); }); @@ -199,10 +210,7 @@ async function main() { console.log(`Config: ${paths.configPath}`); console.log(`Protection level: ${config.level}`); console.log(`Cloud URL: ${config.cloudUrl || 'not configured'}`); - console.log(`API key: ${maskApiKey(config.apiKey)}`); - console.log(`Agent ID: ${config.agentId || 'not configured'}`); - console.log(`Agent JWT: ${config.agentJwt ? 'configured' : 'not configured'}`); - console.log(`Agent activation URL: ${config.agentRegisterUrl || 'not configured'}`); + printCloudAuthStatus(config); console.log(`Agent host: ${config.agentHost || 'not configured'}`); console.log(`Agent hosts: ${config.agentHosts?.join(', ') || 'not configured'}`); console.log(`Policy cache: ${config.policyCachePath}`); @@ -628,6 +636,11 @@ async function main() { agentHost: resolveCronAgentHost(config), agentGuardHome: getAgentGuardPaths().home, }); + saveConfig({ + ...config, + threatFeedCronName: summary.cron.result.name, + threatFeedCronInstalledAt: new Date().toISOString(), + }); summary.cron.installed = true; } catch (err) { summary.cron.error = (err as Error).message; @@ -875,6 +888,46 @@ function printInitGuidanceIfNeeded(config: AgentGuardConfig): void { console.log(` ${REQUIRED_INIT_COMMAND}`); } +function printCloudAuthStatus(config: AgentGuardConfig): void { + if (config.agentJwt) { + console.log('Cloud auth: connected via Agent JWT'); + console.log('API key: not used for this connection'); + console.log(`Agent ID: ${config.agentId || 'configured'}`); + console.log('Agent JWT: configured'); + console.log(`Agent activation URL: ${config.agentRegisterUrl || 'not configured'}`); + return; + } + if (config.apiKey) { + console.log('Cloud auth: connected via API key'); + console.log(`API key: ${maskApiKey(config.apiKey)}`); + console.log('Agent JWT: not used for this connection'); + return; + } + + console.log('Cloud auth: not connected'); + console.log('API key: not configured'); + console.log('Agent JWT: not configured'); +} + +function printCronRemovalSummary(results: ThreatFeedCronRemovalResult[]): void { + const removed = results.filter((result) => result.removed); + if (removed.length > 0) { + console.log(`Removed AgentGuard subscribe cron job "${removed[0]!.name}" from: ${removed.map((result) => result.backend).join(', ')}.`); + return; + } + + const errors = results.filter((result) => result.error); + if (errors.length > 0) { + console.log('No AgentGuard subscribe cron job was removed; some cron backends were unavailable.'); + for (const result of errors) { + console.error(`! ${result.backend}: ${result.error}`); + } + return; + } + + console.log('No AgentGuard subscribe cron job was found.'); +} + function resolveCronAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined { return config.agentHost ?? config.agentHosts?.[0]; } @@ -1296,7 +1349,28 @@ function printAgentActivationRequired( } function isOpenClawAgentConfigured(config: AgentGuardConfig): boolean { - return config.agentHost === 'openclaw' || config.agentHosts?.includes('openclaw') === true; + return config.agentHost === 'openclaw' || config.agentHosts?.includes('openclaw') === true || detectOpenClawRuntime(); +} + +function withDetectedOpenClawAgentHost(config: AgentGuardConfig): AgentGuardConfig { + if (hasSavedAgentHost(config) || !detectOpenClawRuntime()) return config; + const next: AgentGuardConfig = { + ...config, + agentHost: 'openclaw', + agentHosts: appendAgentHost(config.agentHosts, 'openclaw'), + }; + saveConfig(next); + return next; +} + +function detectOpenClawRuntime(): boolean { + const configPath = process.env.OPENCLAW_CONFIG_PATH?.trim(); + if (configPath && existsSync(configPath)) return true; + + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim(); + if (stateDir && (existsSync(stateDir) || existsSync(join(stateDir, 'openclaw.json')))) return true; + + return existsSync(join(homedir(), '.openclaw', 'openclaw.json')); } function resolveOpenClawGatewayOptionsFromEnv(): OpenClawGatewayOptions { diff --git a/src/config.ts b/src/config.ts index ee96172..693b38c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,8 @@ export interface AgentGuardConfig { agentRegisterUrl?: string; agentRegisteredAt?: string; connectedAt?: string; + threatFeedCronName?: string; + threatFeedCronInstalledAt?: string; policyCachePath: string; auditPath: string; eventSpoolPath: string; @@ -142,6 +144,7 @@ export function connectAgentJwt(options: { agentRegisteredAt: new Date().toISOString(), connectedAt: new Date().toISOString(), }; + delete next.apiKey; saveConfig(next); return next; } @@ -165,6 +168,8 @@ export function disconnectCloud(): AgentGuardConfig { delete next.agentRegisterUrl; delete next.agentRegisteredAt; delete next.connectedAt; + delete next.threatFeedCronName; + delete next.threatFeedCronInstalledAt; rmSync(current.eventSpoolPath, { force: true }); rmSync(current.policyCachePath, { force: true }); saveConfig(next); diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 6c5ddf3..d70c28b 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; 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 { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; import net from 'node:net'; import { homedir } from 'node:os'; @@ -21,6 +21,13 @@ export interface OpenClawCronInstallResult { script?: string; } +export interface ThreatFeedCronRemovalResult { + name: string; + backend: ResolvedCronBackend; + removed: boolean; + error?: string; +} + export interface OpenClawGatewayOptions { host?: string; port?: number; @@ -149,6 +156,72 @@ export async function installThreatFeedCron( throw new Error('Invalid cron target. Use auto, openclaw, qclaw, hermes, or system.'); } +export async function removeThreatFeedCron( + options: { + name: string; + backend?: CronBackend | 'all'; + agentHost?: CronAgentHost; + agentGuardHome?: string; + hermesHome?: string; + }, + adapters: { + gateway?: OpenClawGatewayOptions; + runCommand?: CommandRunner; + } = {} +): Promise { + const backend = options.backend ?? 'auto'; + if (backend === 'all') { + return removeThreatFeedCronFromBackends(options, ['system', 'hermes', 'openclaw', 'openclaw-gateway', 'qclaw-gateway'], adapters); + } + if (backend === 'system') { + return [await removeSystemThreatFeedCron(options, adapters.runCommand)]; + } + if (backend === 'hermes') { + return [await removeHermesThreatFeedCron(options, adapters.runCommand)]; + } + if (backend === 'openclaw') { + return removeThreatFeedCronFromBackends(options, ['openclaw', 'openclaw-gateway'], adapters); + } + if (backend === 'qclaw') { + return [await removeGatewayThreatFeedCron(options, qclawGatewayOptions(adapters.gateway), 'qclaw-gateway')]; + } + + const targets: ResolvedCronBackend[] = ['system']; + if (options.agentHost === 'hermes') targets.push('hermes'); + if (options.agentHost === 'openclaw') targets.push('openclaw', 'openclaw-gateway'); + if (options.agentHost === 'qclaw') targets.push('qclaw-gateway'); + return removeThreatFeedCronFromBackends(options, targets, adapters); +} + +async function removeThreatFeedCronFromBackends( + options: { + name: string; + agentGuardHome?: string; + hermesHome?: string; + }, + backends: ResolvedCronBackend[], + adapters: { + gateway?: OpenClawGatewayOptions; + runCommand?: CommandRunner; + } +): Promise { + const results: ThreatFeedCronRemovalResult[] = []; + for (const backend of backends) { + if (backend === 'system') { + results.push(await removeSystemThreatFeedCron(options, adapters.runCommand)); + } else if (backend === 'hermes') { + results.push(await removeHermesThreatFeedCron(options, adapters.runCommand)); + } else if (backend === 'openclaw') { + results.push(await removeOpenClawNativeThreatFeedCron(options, adapters.runCommand)); + } else if (backend === 'openclaw-gateway') { + results.push(await removeGatewayThreatFeedCron(options, adapters.gateway, 'openclaw-gateway')); + } else if (backend === 'qclaw-gateway') { + results.push(await removeGatewayThreatFeedCron(options, qclawGatewayOptions(adapters.gateway), 'qclaw-gateway')); + } + } + return results; +} + export async function installOpenClawThreatFeedCron( options: { name: string; @@ -500,6 +573,87 @@ async function installSystemThreatFeedCron( }; } +async function removeSystemThreatFeedCron( + options: { + name: string; + agentGuardHome?: string; + }, + runCommand: CommandRunner = execCommand +): Promise { + const home = validateCronFilesystemPath(options.agentGuardHome ?? join(homedir(), '.agentguard'), 'AGENTGUARD_HOME'); + const jobId = sanitizeCronJobId(options.name); + try { + const existing = await runCommand('crontab', ['-l']).then((result) => result.stdout, () => ''); + const next = removeAgentGuardCronBlock(existing, jobId).trimEnd(); + if (next === existing.trimEnd()) { + return { name: options.name, backend: 'system', removed: false }; + } + await runCommand('crontab', ['-'], next ? `${next}\n` : ''); + await rm(join(home, 'scripts', `${jobId}.sh`), { force: true }).catch(() => undefined); + return { name: options.name, backend: 'system', removed: true }; + } catch (err) { + return { name: options.name, backend: 'system', removed: false, error: (err as Error).message }; + } +} + +async function removeHermesThreatFeedCron( + options: { + name: string; + hermesHome?: string; + }, + runCommand: CommandRunner = execCommand +): Promise { + try { + const existing = await runCommand('hermes', ['cron', 'list']); + if (!existing.stdout.includes(options.name)) { + return { name: options.name, backend: 'hermes', removed: false }; + } + await runCommand('hermes', ['cron', 'remove', options.name]); + const hermesHome = (options.hermesHome ?? process.env.HERMES_HOME?.trim()) || join(homedir(), '.hermes'); + await rm(join(hermesHome, 'scripts', `${sanitizeHermesScriptName(options.name)}.sh`), { force: true }).catch(() => undefined); + return { name: options.name, backend: 'hermes', removed: true }; + } catch (err) { + return { name: options.name, backend: 'hermes', removed: false, error: (err as Error).message }; + } +} + +async function removeOpenClawNativeThreatFeedCron( + options: { + name: string; + }, + runCommand: CommandRunner = execCommand +): Promise { + try { + const existing = await runCommand('openclaw', ['cron', 'list']); + if (!nativeCronListHasExactName(existing.stdout, options.name)) { + return { name: options.name, backend: 'openclaw', removed: false }; + } + await runCommand('openclaw', ['cron', 'remove', options.name]); + return { name: options.name, backend: 'openclaw', removed: true }; + } catch (err) { + return { name: options.name, backend: 'openclaw', removed: false, error: (err as Error).message }; + } +} + +async function removeGatewayThreatFeedCron( + options: { + name: string; + }, + gateway: OpenClawGatewayOptions = {}, + backend: 'openclaw-gateway' | 'qclaw-gateway' = 'openclaw-gateway' +): Promise { + try { + const jobs = await findOpenClawCronJobsByName(options.name, gateway); + if (jobs.length === 0) { + return { name: options.name, backend, removed: false }; + } + await removeOpenClawCronJobs(jobs, gateway); + return { name: options.name, backend, removed: jobs.some((job) => Boolean(job.id)) }; + } catch (err) { + return { name: options.name, backend, removed: false, error: (err as Error).message }; + } +} + function threatFeedCommand( quiet: boolean, options: { notifyRun?: boolean } = {} diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 5d68343..593dd8a 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -4,6 +4,7 @@ import type { AgentGuardConfig } from '../config.js'; import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js'; import { evaluateLocalAction } from './evaluator.js'; import { resolveRuntimePolicy } from './policy.js'; +import { isAgentGuardCliCommand } from './self-command.js'; import type { RuntimeAction, RuntimeAgentHost, RuntimeAuditEvent, RuntimeActionType, RuntimeDecision } from './types.js'; export interface ProtectOptions { @@ -27,6 +28,7 @@ export interface ProtectResult { export async function protectAction(options: ProtectOptions): Promise { const action = buildRuntimeAction(options); if (!action.input) return null; + if (isAgentGuardRuntimeAction(action)) return null; const client = new AgentGuardCloudClient(options.config); if (client.connected) { @@ -79,6 +81,10 @@ export async function protectAction(options: ProtectOptions): Promise`]|\$\(/; + +export function isAgentGuardCliCommand(command: string): boolean { + const trimmed = command.trim(); + if (!trimmed || SHELL_CONTROL_RE.test(trimmed)) return false; + + const tokens = shellTokens(trimmed); + if (!tokens.length) return false; + + let index = skipAssignments(tokens, 0); + if (basename(tokens[index]) === 'env') { + index += 1; + while (tokens[index]?.startsWith('-')) index += 1; + index = skipAssignments(tokens, index); + } + + while (['command', 'builtin'].includes(basename(tokens[index] || ''))) { + index += 1; + } + + return AGENTGUARD_BINARIES.has(basename(tokens[index] || '')); +} + +function skipAssignments(tokens: string[], start: number): number { + let index = start; + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] || '')) { + index += 1; + } + return index; +} + +function basename(value: string): string { + return value.replace(/\\/g, '/').split('/').pop() || value; +} + +function shellTokens(command: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (const char of command) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\' && quote !== "'") { + escaped = true; + continue; + } + if ((char === '"' || char === "'") && !quote) { + quote = char; + continue; + } + if (char === quote) { + quote = null; + continue; + } + if (/\s/.test(char) && !quote) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + current += char; + } + + if (escaped) current += '\\'; + if (current) tokens.push(current); + return quote ? [] : tokens; +} diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 728bc19..42121ee 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; @@ -11,12 +11,17 @@ import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); -function runCli(args: string[], home: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { +function runCli( + args: string[], + home: string, + extraEnv: Record = {} +): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolvePromise) => { const child = spawn('node', [CLI_PATH, ...args], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, + ...extraEnv, AGENTGUARD_HOME: home, HOME: home, }, @@ -223,4 +228,25 @@ describe('CLI connect Agent JWT mode', () => { assert.equal(result.stdout, ''); assert.match(result.stderr, /init --agent openclaw/); }); + + it('uses detected OpenClaw runtime for no-key connect before requiring an API key', async () => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-openclaw-env-')); + const openClawState = join(home, '.openclaw'); + mkdirSync(openClawState, { recursive: true }); + writeFileSync(join(openClawState, 'openclaw.json'), '{}'); + + const result = await runCli(['connect', '--url', 'http://127.0.0.1:9'], home, { + OPENCLAW_STATE_DIR: openClawState, + }); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + agentHost?: string; + agentHosts?: string[]; + }; + + assert.equal(result.exitCode, 1); + assert.doesNotMatch(result.stderr, /Missing API key/); + assert.match(result.stderr, /Could not register AgentGuard agent/); + assert.equal(config.agentHost, 'openclaw'); + assert.deepEqual(config.agentHosts, ['openclaw']); + }); }); diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index 4d7039b..a0a96f7 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { promisify } from 'node:util'; @@ -41,6 +41,132 @@ describe('init CLI', () => { assert.doesNotMatch(stdout, /agentguard checkup/); }); + it('shows API-key Cloud auth without requiring Agent JWT fields', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-status-api-key-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-status-api-key-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_status_key_123456', + agentHost: 'codex', + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + }, null, 2)); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'status'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + assert.match(stdout, /Cloud auth: connected via API key/); + assert.match(stdout, /API key: ag_live_/); + assert.match(stdout, /Agent JWT: not used for this connection/); + assert.doesNotMatch(stdout, /Agent ID: not configured/); + }); + + it('shows Agent JWT Cloud auth without requiring API key fields', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-status-jwt-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-status-jwt-cwd-')); + const cliPath = resolve('dist', 'cli.js'); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + agentId: 'agt_status_test', + agentJwt: 'agent.jwt.status', + agentRegisterUrl: 'https://agentguard.example/activate?token=status', + agentHost: 'openclaw', + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + }, null, 2)); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'status'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + assert.match(stdout, /Cloud auth: connected via Agent JWT/); + assert.match(stdout, /API key: not used for this connection/); + assert.match(stdout, /Agent ID: agt_status_test/); + assert.match(stdout, /Agent JWT: configured/); + assert.doesNotMatch(stdout, /API key: not configured/); + }); + + it('disconnect removes the managed system subscribe cron job', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-disconnect-cron-home-')); + const cwd = mkdtempSync(join(tmpdir(), 'agentguard-disconnect-cron-cwd-')); + const bin = join(cwd, 'bin'); + const crontabPath = join(cwd, 'crontab.txt'); + const cliPath = resolve('dist', 'cli.js'); + mkdirSync(home, { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_status_key_123456', + agentHost: 'codex', + threatFeedCronName: 'agentguard-custom-feed', + threatFeedCronInstalledAt: '2026-05-27T00:00:00.000Z', + policyCachePath: join(home, 'policy-cache.json'), + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + }, null, 2)); + writeFileSync(crontabPath, [ + '# AgentGuard begin agentguard-custom-feed', + '0 * * * * /tmp/agentguard-custom-feed.sh', + '# AgentGuard end agentguard-custom-feed', + '15 * * * * /tmp/other-job.sh', + '', + ].join('\n')); + const fakeCrontab = join(bin, 'crontab'); + writeFileSync(fakeCrontab, [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const path = process.env.FAKE_CRONTAB_PATH;', + 'if (process.argv[2] === "-l") {', + ' if (fs.existsSync(path)) process.stdout.write(fs.readFileSync(path, "utf8"));', + ' process.exit(0);', + '}', + 'if (process.argv[2] === "-") {', + ' let data = "";', + ' process.stdin.on("data", (chunk) => data += chunk);', + ' process.stdin.on("end", () => fs.writeFileSync(path, data));', + '}', + '', + ].join('\n')); + chmodSync(fakeCrontab, 0o755); + + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'disconnect'], { + cwd, + env: { + ...process.env, + AGENTGUARD_HOME: home, + FAKE_CRONTAB_PATH: crontabPath, + PATH: `${bin}:${process.env.PATH || ''}`, + }, + }); + + const crontab = readFileSync(crontabPath, 'utf8'); + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { + apiKey?: string; + threatFeedCronName?: string; + threatFeedCronInstalledAt?: string; + }; + assert.equal(config.apiKey, undefined); + assert.equal(config.threatFeedCronName, undefined); + assert.equal(config.threatFeedCronInstalledAt, undefined); + assert.match(stdout, /Removed AgentGuard subscribe cron job "agentguard-custom-feed" from: system/); + assert.doesNotMatch(crontab, /AgentGuard begin agentguard-custom-feed/); + assert.match(crontab, /other-job/); + }); + it('persists the selected agent host in AgentGuard config', async () => { const home = mkdtempSync(join(tmpdir(), 'agentguard-init-home-')); const cwd = mkdtempSync(join(tmpdir(), 'agentguard-init-cwd-')); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index 686115e..19259dc 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { installThreatFeedCron, installOpenClawThreatFeedCron, + removeThreatFeedCron, openClawGatewayRequest, validateCronExpression, type CommandRunner, @@ -200,6 +201,65 @@ describe('feed/cron', () => { assert.match(script, /exec agentguard subscribe --quiet --json --cron-run/); }); + it('removes the managed system crontab block without touching other entries', async () => { + const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const home = mkdtempSync(join(tmpdir(), 'agentguard-system-remove-')); + const current = [ + '# existing', + '# AgentGuard begin agentguard-threat-feed', + '0 * * * * /tmp/agentguard-threat-feed.sh', + '# AgentGuard end agentguard-threat-feed', + '15 * * * * /tmp/other-job.sh', + '', + ].join('\n'); + const runner: CommandRunner = async (command, args, input) => { + calls.push({ command, args, input }); + if (command === 'crontab' && args[0] === '-l') { + return { stdout: current, stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + + const result = await removeThreatFeedCron( + { + name: 'agentguard-threat-feed', + backend: 'system', + agentGuardHome: home, + }, + { runCommand: runner } + ); + + assert.deepEqual(result, [{ name: 'agentguard-threat-feed', backend: 'system', removed: true }]); + assert.deepEqual(calls.map((call) => call.args[0]), ['-l', '-']); + assert.match(calls[1].input ?? '', /# existing/); + assert.match(calls[1].input ?? '', /other-job/); + assert.doesNotMatch(calls[1].input ?? '', /AgentGuard begin agentguard-threat-feed/); + }); + + it('removes OpenClaw gateway cron jobs by default subscribe name', async () => { + const gateway = fakeGateway([{ id: 'job-1', name: 'agentguard-threat-feed' }]); + + const result = await removeThreatFeedCron( + { + name: 'agentguard-threat-feed', + backend: 'openclaw', + }, + { + async runCommand() { + throw new Error('native openclaw unavailable'); + }, + gateway: { request: gateway.request }, + } + ); + + assert.deepEqual(result.map((item) => item.backend), ['openclaw', 'openclaw-gateway']); + assert.equal(result[0].removed, false); + assert.match(result[0].error ?? '', /native openclaw unavailable/); + assert.equal(result[1].removed, true); + assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.remove']); + assert.deepEqual(gateway.calls[1].params, { jobId: 'job-1' }); + }); + it('rejects unsafe AgentGuard home paths for system crontab jobs', async () => { await assert.rejects( () => diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 3c57402..126f3ae 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -160,6 +160,32 @@ describe('Runtime Cloud bridge', () => { } }); + it('clears API key credentials when connecting with an Agent JWT', () => { + const previousHome = process.env.AGENTGUARD_HOME; + process.env.AGENTGUARD_HOME = mkdtempSync(join(tmpdir(), 'agentguard-connect-jwt-')); + try { + connectCloud({ + apiKey: 'ag_live_test_key_123456', + cloudUrl: 'https://agentguard.example', + }); + + const config = connectAgentJwt({ + agentId: 'agt_jwt_shadow_test', + agentJwt: 'agent.jwt.shadow', + agentRegisterUrl: 'https://agentguard.example/activate?token=shadow', + cloudUrl: 'https://agentguard.example', + }); + + assert.equal(config.apiKey, undefined); + assert.equal(config.agentId, 'agt_jwt_shadow_test'); + assert.equal(config.agentJwt, 'agent.jwt.shadow'); + assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=shadow'); + } finally { + if (previousHome === undefined) delete process.env.AGENTGUARD_HOME; + else process.env.AGENTGUARD_HOME = previousHome; + } + }); + it('evaluates local action with cached Cloud policy shape', async () => { const policy = getDefaultEffectiveRuntimePolicy(); policy.policyVersion = 'runtime-test'; @@ -242,6 +268,70 @@ describe('Runtime Cloud bridge', () => { assert.ok(!audit.includes('secret-value')); }); + it('skips AgentGuard CLI commands before local audit or Cloud reporting', async () => { + const originalFetch = globalThis.fetch; + const dir = mkdtempSync(join(tmpdir(), 'agentguard-self-cli-')); + const requests: string[] = []; + + globalThis.fetch = (async (input: Parameters[0]) => { + requests.push(String(input)); + throw new Error('unexpected cloud request'); + }) as typeof fetch; + + try { + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + }; + + const result = await protectAction({ + config, + stdinText: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'AGENTGUARD_AGENT_HOST=codex agentguard protect --json' }, + session_id: 'sess_test', + }), + }); + + assert.equal(result, null); + assert.deepEqual(requests, []); + assert.equal(existsSync(config.auditPath), false); + assert.equal(existsSync(config.eventSpoolPath), false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('does not skip compound shell commands just because they mention agentguard', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-compound-cli-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + }; + + const result = await protectAction({ + config, + stdinText: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'agentguard status; rm -rf /' }, + session_id: 'sess_test', + }), + }); + + assert.ok(result); + assert.equal(result.decision.decision, 'block'); + assert.equal(existsSync(config.auditPath), true); + }); + it('protectAction still returns policy decision when local audit write fails', async () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-audit-fail-')); const policy = getDefaultEffectiveRuntimePolicy();