From 0d564c86ae3bd0d5389996e41addced4f5a2caef Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 11:18:15 +0800 Subject: [PATCH 1/9] feat: add policy show command --- skills/agentguard/SKILL.md | 1 + src/cli.ts | 40 ++++++++++++++++++++++- src/tests/cli-policy.test.ts | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 0e3e8dd..790ad60 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -85,6 +85,7 @@ Supported CLI commands and options: | `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 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 | | `agentguard protect` | `--agent `, `--action-type `, `--tool-name `, `--session-id `, `--decision-mode `, `--json` | Evaluates one runtime action from stdin or hook environment | | `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | diff --git a/src/cli.ts b/src/cli.ts index df606ab..f126bb7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { import type { AgentGuardConfig } from './config.js'; import { SkillScanner } from './scanner/index.js'; import { formatProtectResult, protectAction, exitCodeForDecision } from './runtime/protect.js'; -import { saveCachedPolicy } from './runtime/policy.js'; +import { getDefaultEffectiveRuntimePolicy, loadCachedPolicy, saveCachedPolicy } from './runtime/policy.js'; import type { RuntimeActionType, RuntimeAgentHost } from './runtime/types.js'; import { installAgentTemplates, type AgentInstaller } from './installers.js'; import { packageVersion } from './version.js'; @@ -169,6 +169,44 @@ async function main() { } }); + policy + .command('show') + .description('Show the cached effective runtime policy, or the bundled default policy when no cache exists') + .option('--json', 'Print JSON output') + .action((options) => { + const config = ensureConfig(); + const cachedPolicy = loadCachedPolicy(config.policyCachePath); + const source = cachedPolicy ? 'cache' : 'default'; + const shownPolicy = cachedPolicy ?? getDefaultEffectiveRuntimePolicy(); + + if (options.json) { + console.log(JSON.stringify({ + success: true, + source, + cachePath: config.policyCachePath, + policy: shownPolicy, + }, null, 2)); + return; + } + + console.log(`Policy source: ${source}`); + console.log(`Policy version: ${shownPolicy.policyVersion}`); + console.log(`Mode: ${shownPolicy.mode}`); + console.log(`Updated at: ${shownPolicy.updatedAt}`); + console.log(`Cache path: ${config.policyCachePath}`); + console.log('Decisions:'); + for (const [name, decision] of Object.entries(shownPolicy.decisions)) { + console.log(`- ${name}: ${decision}`); + } + console.log(`Protected paths: ${shownPolicy.protectedPaths.length}`); + console.log(`Blocked command patterns: ${shownPolicy.blockedCommandPatterns.length}`); + console.log(`Allowed command patterns: ${shownPolicy.allowedCommandPatterns.length}`); + console.log(`Approval action types: ${shownPolicy.approvalActionTypes.join(', ') || 'none'}`); + console.log(`Network default outbound: ${shownPolicy.network.defaultOutbound}`); + console.log(`Blocked domains: ${shownPolicy.network.blockedDomains.length}`); + console.log(`Approval domains: ${shownPolicy.network.approvalDomains.length}`); + }); + program .command('doctor') .description('Check local AgentGuard setup') diff --git a/src/tests/cli-policy.test.ts b/src/tests/cli-policy.test.ts index a32f6b8..7938914 100644 --- a/src/tests/cli-policy.test.ts +++ b/src/tests/cli-policy.test.ts @@ -11,6 +11,68 @@ import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; const execFileAsync = promisify(execFile); describe('policy CLI', () => { + it('shows the cached effective policy as JSON', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-policy-show-')); + const policy = getDefaultEffectiveRuntimePolicy(); + policy.policyVersion = 'runtime-show-cache'; + policy.mode = 'strict'; + policy.blockedCommandPatterns = ['show-cache-danger']; + const cachePath = join(home, 'policy-cache.json'); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + policyCachePath: cachePath, + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + writeFileSync(cachePath, JSON.stringify(policy)); + + const cliPath = resolve('dist/cli.js'); + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'show', '--json'], { + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const result = JSON.parse(stdout) as { + success: boolean; + source: string; + cachePath: string; + policy: typeof policy; + }; + assert.equal(result.success, true); + assert.equal(result.source, 'cache'); + assert.equal(result.cachePath, cachePath); + assert.equal(result.policy.policyVersion, 'runtime-show-cache'); + assert.deepEqual(result.policy.blockedCommandPatterns, ['show-cache-danger']); + }); + + it('shows the bundled default policy when no cache exists', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-policy-show-default-')); + const cachePath = join(home, 'policy-cache.json'); + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + policyCachePath: cachePath, + auditPath: join(home, 'audit.jsonl'), + eventSpoolPath: join(home, 'events-spool.jsonl'), + })); + + const cliPath = resolve('dist/cli.js'); + const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'show', '--json'], { + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const result = JSON.parse(stdout) as { + success: boolean; + source: string; + policy: { policyVersion: string }; + }; + assert.equal(result.success, true); + assert.equal(result.source, 'default'); + assert.equal(result.policy.policyVersion, 'runtime-local-v0.1'); + }); + it('pulls the effective Cloud policy into the local cache', async () => { const home = mkdtempSync(join(tmpdir(), 'agentguard-policy-cli-')); const policy = getDefaultEffectiveRuntimePolicy(); From a81b858fdaf2676c0e85f011ea91a918a741765f Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 11:58:22 +0800 Subject: [PATCH 2/9] feat: select cron backend by agent host --- CHANGELOG.md | 10 ++ README.md | 15 +- skills/agentguard/SKILL.md | 10 +- src/cli.ts | 36 +++-- src/config.ts | 8 ++ src/feed/cron.ts | 271 ++++++++++++++++++++++++++++++++++-- src/tests/cli-init.test.ts | 25 ++++ src/tests/feed-cron.test.ts | 118 ++++++++++++++++ 8 files changed, 463 insertions(+), 30 deletions(-) create mode 100644 src/tests/cli-init.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8455eee..d0cb4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Added +- Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, while Claude Code and Codex use system crontab. +- `agentguard init --agent ` now persists the selected agent host in local config for later cron backend selection. + +### Changed +- Threat-feed cron installation now fails fast when the OpenClaw Gateway preflight is unavailable instead of hiding `cron.list` errors until `cron.add`. +- `agentguard subscribe --cron` now requires a saved agent host when `--cron-target auto` is used; run `agentguard init --agent ` first or pass an explicit cron target. + ## [1.1.9] - 2026-05-20 ### Added diff --git a/README.md b/README.md index 8a70f85..108f360 100644 --- a/README.md +++ b/README.md @@ -75,16 +75,23 @@ agentguard subscribe # report local matches back to Cloud. agentguard subscribe --quiet -# Optional: install an OpenClaw isolated cron job that checks every hour and -# asks you to review newly published advisories. -# Requires the local OpenClaw Gateway at 127.0.0.1:18789. +# Optional: run once, then install a cron job that checks every hour and asks +# you to review newly published advisories. Auto uses the agent host saved by +# `agentguard init --agent`: OpenClaw uses native OpenClaw cron, while Claude +# Code/Codex use system crontab. If no agent host is saved, run +# `agentguard init --agent ` first or pass --cron-target explicitly. agentguard subscribe --cron "0 * * * *" +# Override cron backend selection when needed. +agentguard subscribe --cron "0 * * * *" --cron-target system +agentguard subscribe --cron "0 * * * *" --cron-target openclaw +# System cron writes output to ~/.agentguard/feed-cron.log. + # Or install the hourly cron in quiet mode so matches are self-checked and # reported automatically. agentguard subscribe --cron "0 * * * *" --quiet -# Replace an existing OpenClaw cron job with the same name +# Replace an existing cron job with the same name agentguard subscribe --cron "0 * * * *" --force # Machine-readable output always includes a cron status object: diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 790ad60..5ac0c69 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -80,7 +80,7 @@ Supported CLI commands and options: | CLI command | Options | Notes | |---|---|---| -| `agentguard init` | `--level `, `--agent `, `--cloud `, `--force` | Creates local config and optionally installs agent templates | +| `agentguard init` | `--level `, `--agent `, `--cloud `, `--force` | Creates local config, persists the selected agent host, and optionally installs agent templates | | `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 | @@ -88,7 +88,7 @@ Supported CLI commands and options: | `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 | | `agentguard protect` | `--agent `, `--action-type `, `--tool-name `, `--session-id `, `--decision-mode `, `--json` | Evaluates one runtime action from stdin or hook environment | -| `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | +| `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | | `agentguard checkup --against-advisory ` | `--json` | CLI threat-feed self-check for one advisory; this is a targeted mode, not the default health-check workflow | If the user writes `/agentguard cli `, execute `agentguard ` directly. @@ -186,6 +186,8 @@ agentguard subscribe --json agentguard subscribe --since 2026-05-01T00:00:00.000Z agentguard subscribe --no-report agentguard subscribe --cron "0 * * * *" +agentguard subscribe --cron "0 * * * *" --cron-target system +agentguard subscribe --cron "0 * * * *" --cron-target openclaw agentguard subscribe --cron "0 * * * *" --quiet agentguard subscribe --cron "0 * * * *" --cron-name agentguard-threat-feed agentguard subscribe --cron "0 * * * *" --force @@ -193,7 +195,9 @@ agentguard subscribe --cron "0 * * * *" --force Without `--quiet`, `agentguard subscribe` pulls new threat-feed advisories and notifies the user to review them manually. With `--quiet`, it runs the full automated flow: pull new advisories, self-check local skills, report local matches back to Cloud, and notify only when local matches are found. -When `--cron ` is used, the CLI registers an OpenClaw isolated cron job through the local OpenClaw Gateway at `127.0.0.1:18789` using a standard five-field crontab expression such as `"0 * * * *"`. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. The cron delivery is intentionally silent (`delivery.mode = "none"`); the isolated turn executes `agentguard subscribe --json --cron-run` or `agentguard subscribe --quiet --json --cron-run` depending on whether `--quiet` was used during installation. Non-quiet cron sends the configured notification when new advisories are found; quiet cron sends it when local matches are found. +When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, while `claude-code` and `codex` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw` / `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. + +System cron writes output to `~/.agentguard/feed-cron.log`; it does not send OpenClaw agent-channel notifications. `agentguard subscribe --json` always includes a stable `cron` object with `requested`, `installed`, and optional `result` fields. If cron installation fails, the command exits non-zero instead of printing a misleading success summary. diff --git a/src/cli.ts b/src/cli.ts index f126bb7..7438c40 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,9 +27,10 @@ import { runSelfCheckForAdvisory } from './feed/selfcheck.js'; import { loadFeedState, markAdvisorySeen, saveFeedState } from './feed/state.js'; import type { Advisory, SelfCheckResult } from './feed/types.js'; import { - installOpenClawThreatFeedCron, + installThreatFeedCron, validateCronExpression, type OpenClawCronInstallResult, + type CronBackend, } from './feed/cron.js'; async function main() { @@ -67,7 +68,10 @@ async function main() { if (!['claude-code', 'codex', 'openclaw'].includes(options.agent)) { throw new Error('Invalid agent. Use claude-code, codex, or openclaw.'); } - const result = installAgentTemplates(options.agent as AgentInstaller, { force: options.force }); + const agent = options.agent as AgentInstaller; + config.agentHost = agent; + saveConfig(config); + const result = installAgentTemplates(agent, { force: options.force }); console.log(`Installed ${result.agent} template:`); for (const file of result.files) console.log(`- ${file}`); } @@ -118,6 +122,7 @@ async function main() { console.log(`Protection level: ${config.level}`); console.log(`Cloud URL: ${config.cloudUrl || 'not configured'}`); console.log(`API key: ${maskApiKey(config.apiKey)}`); + console.log(`Agent host: ${config.agentHost || 'not configured'}`); console.log(`Policy cache: ${config.policyCachePath}`); console.log(`Audit log: ${config.auditPath}`); }); @@ -279,9 +284,10 @@ async function main() { .option('--json', 'Emit machine-readable summary instead of human text') .option('--quiet', 'Run the full pull, self-check, and match-reporting flow with minimal output') .option('--no-report', 'Skip uploading self-check results back to Cloud') - .option('--cron ', 'Install an OpenClaw cron job with a five-field cron expression, for example "0 * * * *"') - .option('--cron-name ', 'OpenClaw cron job name', 'agentguard-threat-feed') - .option('--force', 'Replace an existing OpenClaw cron job with the same name') + .option('--cron ', 'Install a cron job with a five-field cron expression, for example "0 * * * *"') + .option('--cron-target ', 'Cron backend: auto, openclaw, or system', 'auto') + .option('--cron-name ', 'Cron job name', 'agentguard-threat-feed') + .option('--force', 'Replace an existing cron job with the same name') .option('--cron-run', 'Internal: run from the OpenClaw cron prompt without trying to install cron again') .action(async (options) => { const config = ensureConfig(); @@ -289,6 +295,7 @@ async function main() { const state = loadFeedState(); const since = (options.since as string | undefined) ?? state.lastPulledAt; const quiet = Boolean(options.quiet); + const cronTarget = validateCronTarget(options.cronTarget); const cronExpression = options.cron && !options.cronRun ? validateCronExpression(options.cron as string) : undefined; @@ -391,11 +398,14 @@ async function main() { if (options.cron && !options.cronRun) { summary.cron.requested = true; try { - summary.cron.result = await installOpenClawThreatFeedCron({ + summary.cron.result = await installThreatFeedCron({ name: options.cronName as string, cronExpression: cronExpression!, quiet, force: Boolean(options.force), + backend: cronTarget, + agentHost: config.agentHost, + agentGuardHome: getAgentGuardPaths().home, }); summary.cron.installed = true; } catch (err) { @@ -431,9 +441,14 @@ async function main() { } } if (summary.cron.result) { - const action = summary.cron.result.created ? 'Installed' : 'OpenClaw cron job already exists'; + const label = summary.cron.result.backend ?? 'cron'; + const action = summary.cron.result.created ? `Installed ${label} cron job` : `${label} cron job already exists`; console.log(`${action} "${summary.cron.result.name}" (${summary.cron.result.schedule}, ${summary.cron.result.timezone}).`); - console.log('Notification rule: non-quiet cron notifies on new advisories; quiet cron notifies on local matches.'); + if (summary.cron.result.backend === 'system') { + console.log(`System cron output: ${join(getAgentGuardPaths().home, 'feed-cron.log')}`); + } else { + console.log('Notification rule: non-quiet cron notifies on new advisories; quiet cron notifies on local matches.'); + } } // Exit codes: 2 = matches found, 1 = at least one advisory failed @@ -518,6 +533,11 @@ async function main() { await program.parseAsync(process.argv); } +function validateCronTarget(value: unknown): CronBackend { + if (value === 'auto' || value === 'openclaw' || value === 'system') return value; + throw new Error('Invalid cron target. Use auto, openclaw, or system.'); +} + function readStdinIfAvailable(): string { if (process.stdin.isTTY) return ''; try { diff --git a/src/config.ts b/src/config.ts index 0889949..fb1db16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ import { homedir } from 'node:os'; export interface AgentGuardConfig { version: 1; level: 'strict' | 'balanced' | 'permissive'; + agentHost?: 'claude-code' | 'codex' | 'openclaw'; cloudUrl?: string; apiKey?: string; connectedAt?: string; @@ -74,6 +75,7 @@ export function loadConfig(): AgentGuardConfig { ...parsed, version: 1, level: normalizeLevel(parsed.level) ?? fallback.level, + agentHost: normalizeAgentHost(parsed.agentHost), cloudUrl: parsed.cloudUrl || fallback.cloudUrl, policyCachePath: parsed.policyCachePath || fallback.policyCachePath, auditPath: parsed.auditPath || fallback.auditPath, @@ -152,6 +154,12 @@ function normalizeLevel(value: unknown): AgentGuardConfig['level'] | null { : null; } +function normalizeAgentHost(value: unknown): AgentGuardConfig['agentHost'] | undefined { + return value === 'claude-code' || value === 'codex' || value === 'openclaw' + ? value + : undefined; +} + function chmodBestEffort(path: string, mode: number): void { try { chmodSync(path, mode); diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 2ef1d31..148b160 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -1,10 +1,19 @@ import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type CronBackend = 'auto' | 'openclaw' | 'system'; +export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'system'; +export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw'; export interface OpenClawCronInstallResult { name: string; schedule: string; timezone: string; created: boolean; + backend?: ResolvedCronBackend; + command?: string; } export interface OpenClawGatewayOptions { @@ -14,6 +23,13 @@ export interface OpenClawGatewayOptions { request?: (method: string, params: unknown) => Promise; } +export interface CommandResult { + stdout: string; + stderr: string; +} + +export type CommandRunner = (command: string, args: string[], input?: string) => Promise; + interface OpenClawCronJob { id?: string; name?: string; @@ -35,6 +51,57 @@ export function localTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } +export async function installThreatFeedCron( + options: { + name: string; + cronExpression: string; + quiet: boolean; + force: boolean; + backend?: CronBackend; + agentHost?: CronAgentHost; + agentGuardHome?: string; + timezone?: string; + }, + adapters: { + gateway?: OpenClawGatewayOptions; + runCommand?: CommandRunner; + } = {} +): Promise { + const backend = options.backend ?? 'auto'; + if (backend === 'auto' && !options.agentHost) { + throw new Error( + 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw` or `--cron-target system`.' + ); + } + if (backend === 'system' || (backend === 'auto' && (options.agentHost === 'claude-code' || options.agentHost === 'codex'))) { + return installSystemThreatFeedCron(options, adapters.runCommand); + } + + if (backend === 'openclaw' || (backend === 'auto' && options.agentHost === 'openclaw')) { + let nativeError: Error | null = null; + try { + const result = await installOpenClawNativeThreatFeedCron(options, adapters.runCommand); + result.backend = 'openclaw'; + return result; + } catch (err) { + nativeError = err as Error; + } + + try { + const result = await installOpenClawThreatFeedCron(options, adapters.gateway); + result.backend = 'openclaw-gateway'; + return result; + } catch (gatewayError) { + throw new Error( + `Could not install OpenClaw cron. Native openclaw command failed: ${nativeError.message}. ` + + `Gateway fallback failed: ${(gatewayError as Error).message}` + ); + } + } + + throw new Error('Invalid cron target. Use auto, openclaw, or system.'); +} + export async function installOpenClawThreatFeedCron( options: { name: string; @@ -47,6 +114,7 @@ export async function installOpenClawThreatFeedCron( ): Promise { const schedule = validateCronExpression(options.cronExpression); const timezone = options.timezone ?? localTimeZone(); + const command = threatFeedCommand(options.quiet); const existing = await findOpenClawCronJobsByName(options.name, gateway); if (existing.length > 0 && !options.force) { return { @@ -54,25 +122,14 @@ export async function installOpenClawThreatFeedCron( schedule, timezone, created: false, + backend: 'openclaw-gateway', + command, }; } const mode = options.quiet ? 'quiet' : 'manual'; - const command = `agentguard subscribe${options.quiet ? ' --quiet' : ''} --json --cron-run`; const description = `AgentGuard Cloud threat feed subscription (${schedule})`; - const message = [ - `Mode: ${mode}.`, - `Command: \`${command}\`.`, - `Run exactly the command above.`, - '', - 'Rules:', - '- If the JSON field `hardFailures` is greater than 0, output a short error summary and do not send a notification.', - '- If the JSON field `shouldNotify` is true, send `notification.body` exactly as-is using the current session notification context.', - '- If `shouldNotify` is false, output "skipped" and finish without sending any message.', - '- If the command fails or the JSON cannot be parsed, output a short error summary and do not send a notification.', - '', - 'Follow these rules exactly.', - ].join('\n'); + const message = openClawCronMessage(options.quiet); if (existing.length > 0) { await removeOpenClawCronJobs(existing, gateway); @@ -112,6 +169,8 @@ export async function installOpenClawThreatFeedCron( schedule, timezone, created: true, + backend: 'openclaw-gateway', + command, }; } @@ -119,10 +178,192 @@ async function findOpenClawCronJobsByName( name: string, gateway: OpenClawGatewayOptions ): Promise { - const listed = await openClawGatewayRequest('cron.list', {}, gateway).catch(() => null); + const listed = await openClawGatewayRequest('cron.list', {}, gateway); return extractOpenClawCronJobs(listed).filter((job) => job.name === name); } +async function installOpenClawNativeThreatFeedCron( + options: { + name: string; + cronExpression: string; + quiet: boolean; + force: boolean; + timezone?: string; + }, + runCommand: CommandRunner = execCommand +): Promise { + const schedule = validateCronExpression(options.cronExpression); + const timezone = options.timezone ?? localTimeZone(); + const command = threatFeedCommand(options.quiet); + const message = openClawCronMessage(options.quiet); + const existing = await runCommand('openclaw', ['cron', 'list']).catch(() => null); + if (existing && existing.stdout.includes(options.name) && !options.force) { + return { + name: options.name, + schedule, + timezone, + created: false, + backend: 'openclaw', + command, + }; + } + + const args = [ + 'cron', + 'add', + '--name', + options.name, + '--description', + `AgentGuard Cloud threat feed subscription (${schedule})`, + '--cron', + schedule, + '--tz', + timezone, + '--session', + 'isolated', + '--message', + message, + '--timeout-seconds', + '300', + '--thinking', + 'off', + ]; + if (options.force) args.push('--force'); + await runCommand('openclaw', args); + return { + name: options.name, + schedule, + timezone, + created: true, + backend: 'openclaw', + command, + }; +} + +async function installSystemThreatFeedCron( + options: { + name: string; + cronExpression: string; + quiet: boolean; + force: boolean; + agentGuardHome?: string; + timezone?: string; + }, + runCommand: CommandRunner = execCommand +): Promise { + const schedule = validateCronExpression(options.cronExpression); + const timezone = options.timezone ?? localTimeZone(); + const command = threatFeedCommand(options.quiet); + const home = options.agentGuardHome ?? join(homedir(), '.agentguard'); + const begin = `# AgentGuard begin ${options.name}`; + const end = `# AgentGuard end ${options.name}`; + const pathPrefix = process.env.PATH ? `PATH="${process.env.PATH}" ` : ''; + const line = `${schedule} ${pathPrefix}AGENTGUARD_HOME="${home}" ${command} >> "${join(home, 'feed-cron.log')}" 2>&1`; + const existing = await runCommand('crontab', ['-l']).then((result) => result.stdout, () => ''); + const hasExisting = existing.includes(begin); + if (hasExisting && !options.force) { + return { + name: options.name, + schedule, + timezone, + created: false, + backend: 'system', + command, + }; + } + + const withoutExisting = removeAgentGuardCronBlock(existing, options.name).trimEnd(); + const next = `${withoutExisting}${withoutExisting ? '\n' : ''}${begin}\n${line}\n${end}\n`; + await runCommand('crontab', ['-'], next); + return { + name: options.name, + schedule, + timezone, + created: true, + backend: 'system', + command, + }; +} + +function threatFeedCommand(quiet: boolean): string { + return `agentguard subscribe${quiet ? ' --quiet' : ''} --json --cron-run`; +} + +function openClawCronMessage(quiet: boolean): string { + const mode = quiet ? 'quiet' : 'manual'; + const command = threatFeedCommand(quiet); + return [ + `Mode: ${mode}.`, + `Command: \`${command}\`.`, + `Run exactly the command above.`, + '', + 'Rules:', + '- If the JSON field `hardFailures` is greater than 0, output a short error summary and do not send a notification.', + '- If the JSON field `shouldNotify` is true, send `notification.body` exactly as-is using the current session notification context.', + '- If `shouldNotify` is false, output "skipped" and finish without sending any message.', + '- If the command fails or the JSON cannot be parsed, output a short error summary and do not send a notification.', + '', + 'Follow these rules exactly.', + ].join('\n'); +} + +function removeAgentGuardCronBlock(value: string, name: string): string { + const begin = `# AgentGuard begin ${name}`; + const end = `# AgentGuard end ${name}`; + const lines = value.split(/\r?\n/); + const kept: string[] = []; + let skipping = false; + for (const line of lines) { + if (line.trim() === begin) { + skipping = true; + continue; + } + if (line.trim() === end) { + skipping = false; + continue; + } + if (!skipping) kept.push(line); + } + return kept.join('\n'); +} + +function execCommand(command: string, args: string[], input?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + fn(); + }; + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + finish(() => reject(new Error(`${command} ${args.join(' ')} timed out after 10000ms`))); + }, 10000); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', (err) => { + finish(() => reject(err)); + }); + child.on('close', (code) => { + if (code === 0) { + finish(() => resolve({ stdout, stderr })); + return; + } + finish(() => reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}: ${stderr || stdout}`.trim()))); + }); + if (input) child.stdin.write(input); + child.stdin.end(); + }); +} + async function removeOpenClawCronJobs( jobs: OpenClawCronJob[], gateway: OpenClawGatewayOptions diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts new file mode 100644 index 0000000..8dfe4b6 --- /dev/null +++ b/src/tests/cli-init.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +describe('init CLI', () => { + 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-')); + const cliPath = resolve('dist', 'cli.js'); + + await execFileAsync(process.execPath, [cliPath, 'init', '--agent', 'codex', '--force'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { agentHost?: string }; + assert.equal(config.agentHost, 'codex'); + }); +}); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index 4058be7..cf35e9a 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -1,9 +1,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { + installThreatFeedCron, installOpenClawThreatFeedCron, openClawGatewayRequest, validateCronExpression, + type CommandRunner, } from '../feed/cron.js'; type RpcCall = { method: string; params: any }; @@ -59,6 +61,122 @@ describe('feed/cron', () => { assert.match(job.payload.message, /hardFailures/); }); + it('auto-installs system crontab jobs for Codex and Claude Code agents', async () => { + const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const runner: CommandRunner = async (command, args, input) => { + calls.push({ command, args, input }); + if (command === 'crontab' && args[0] === '-l') { + return { stdout: '# existing\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: true, + force: false, + backend: 'auto', + agentHost: 'codex', + agentGuardHome: '/tmp/ag-home', + timezone: 'UTC', + }, + { runCommand: runner } + ); + + assert.equal(result.backend, 'system'); + assert.equal(result.created, true); + assert.equal(calls[0].command, 'crontab'); + assert.deepEqual(calls[0].args, ['-l']); + assert.equal(calls[1].command, 'crontab'); + assert.deepEqual(calls[1].args, ['-']); + assert.match(calls[1].input ?? '', /# AgentGuard begin agentguard-threat-feed/); + assert.match(calls[1].input ?? '', /agentguard subscribe --quiet --json --cron-run/); + assert.match(calls[1].input ?? '', /AGENTGUARD_HOME="\/tmp\/ag-home"/); + }); + + it('uses native OpenClaw cron command before Gateway fallback for OpenClaw agents', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + if (args.join(' ') === 'cron list') return { stdout: '', stderr: '' }; + return { stdout: 'created', stderr: '' }; + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + agentHost: 'openclaw', + timezone: 'UTC', + }, + { runCommand: runner } + ); + + assert.equal(result.backend, 'openclaw'); + assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list', 'cron add']); + assert.ok(calls[1].args.includes('--timeout-seconds')); + assert.ok(calls[1].args.includes('300')); + }); + + it('requires init --agent when auto has no saved agent host', async () => { + await assert.rejects( + () => + installThreatFeedCron({ + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + timezone: 'UTC', + }), + /agentguard init --agent/ + ); + }); + + it('falls back to OpenClaw Gateway when native OpenClaw cron command fails', async () => { + const gateway = fakeGateway(); + const runner: CommandRunner = async () => { + throw new Error('openclaw command not found'); + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + agentHost: 'openclaw', + timezone: 'UTC', + }, + { runCommand: runner, gateway: { request: gateway.request } } + ); + + assert.equal(result.backend, 'openclaw-gateway'); + assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.add']); + }); + + it('fails fast when OpenClaw Gateway cron.list is unavailable', async () => { + await assert.rejects( + () => + installOpenClawThreatFeedCron( + { name: 'agentguard-threat-feed', cronExpression: '0 * * * *', quiet: false, force: false, timezone: 'UTC' }, + { + async request(method) { + if (method === 'cron.list') throw new Error('Gateway unavailable'); + return { ok: true }; + }, + } + ), + /Gateway unavailable/ + ); + }); + it('leaves an existing cron job untouched unless force is set', async () => { const gateway = fakeGateway([{ id: 'job-1', name: 'agentguard-threat-feed' }]); From 8f21c88451c826afeb578b33d8c845d846464aa0 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 12:03:53 +0800 Subject: [PATCH 3/9] feat: support hermes and qclaw init agents --- CHANGELOG.md | 1 + README.md | 4 ++- skills/agentguard/SKILL.md | 4 +-- src/cli.ts | 6 ++-- src/config.ts | 4 +-- src/feed/cron.ts | 6 ++-- src/installers.ts | 64 +++++++++++++++++++++++++++++++++++-- src/runtime/types.ts | 2 ++ src/tests/cli-init.test.ts | 16 ++++++++++ src/tests/installer.test.ts | 17 ++++++++++ 10 files changed, 110 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cb4cb..a019312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, while Claude Code and Codex use system crontab. - `agentguard init --agent ` now persists the selected agent host in local config for later cron backend selection. +- `agentguard init --agent` now supports `hermes` and `qclaw` in addition to `claude-code`, `codex`, and `openclaw`. ### Changed - Threat-feed cron installation now fails fast when the OpenClaw Gateway preflight is unavailable instead of hiding `cron.list` errors until `cron.add`. diff --git a/README.md b/README.md index 108f360..1eb91cd 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ agentguard subscribe --quiet # Optional: run once, then install a cron job that checks every hour and asks # you to review newly published advisories. Auto uses the agent host saved by # `agentguard init --agent`: OpenClaw uses native OpenClaw cron, while Claude -# Code/Codex use system crontab. If no agent host is saved, run +# Code/Codex/Hermes/QClaw use system crontab. If no agent host is saved, run # `agentguard init --agent ` first or pass --cron-target explicitly. agentguard subscribe --cron "0 * * * *" @@ -106,6 +106,8 @@ agentguard checkup --against-advisory AGS-2026-0042 agentguard init --agent claude-code agentguard init --agent codex agentguard init --agent openclaw +agentguard init --agent hermes +agentguard init --agent qclaw ```
diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 5ac0c69..30cb54f 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -80,7 +80,7 @@ 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 agent templates | +| `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 | @@ -195,7 +195,7 @@ agentguard subscribe --cron "0 * * * *" --force Without `--quiet`, `agentguard subscribe` pulls new threat-feed advisories and notifies the user to review them manually. With `--quiet`, it runs the full automated flow: pull new advisories, self-check local skills, report local matches back to Cloud, and notify only when local matches are found. -When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, while `claude-code` and `codex` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw` / `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. +When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, while `claude-code`, `codex`, `hermes`, and `qclaw` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw` / `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. System cron writes output to `~/.agentguard/feed-cron.log`; it does not send OpenClaw agent-channel notifications. diff --git a/src/cli.ts b/src/cli.ts index 7438c40..1a79f5c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,7 +45,7 @@ async function main() { .command('init') .description('Create ~/.agentguard/config.json and local runtime paths') .option('--level ', 'Protection level: strict | balanced | permissive') - .option('--agent ', 'Install hook/template for claude-code, codex, or openclaw') + .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') .action((options) => { @@ -65,8 +65,8 @@ async function main() { console.log(`AgentGuard initialized at ${paths.home}`); console.log(`Config: ${paths.configPath}`); if (options.agent) { - if (!['claude-code', 'codex', 'openclaw'].includes(options.agent)) { - throw new Error('Invalid agent. Use claude-code, codex, or openclaw.'); + if (!['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw'].includes(options.agent)) { + throw new Error('Invalid agent. Use claude-code, codex, openclaw, hermes, or qclaw.'); } const agent = options.agent as AgentInstaller; config.agentHost = agent; diff --git a/src/config.ts b/src/config.ts index fb1db16..0186ebb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ import { homedir } from 'node:os'; export interface AgentGuardConfig { version: 1; level: 'strict' | 'balanced' | 'permissive'; - agentHost?: 'claude-code' | 'codex' | 'openclaw'; + agentHost?: 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; cloudUrl?: string; apiKey?: string; connectedAt?: string; @@ -155,7 +155,7 @@ function normalizeLevel(value: unknown): AgentGuardConfig['level'] | null { } function normalizeAgentHost(value: unknown): AgentGuardConfig['agentHost'] | undefined { - return value === 'claude-code' || value === 'codex' || value === 'openclaw' + return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' ? value : undefined; } diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 148b160..11810b4 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; export type CronBackend = 'auto' | 'openclaw' | 'system'; export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'system'; -export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw'; +export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; export interface OpenClawCronInstallResult { name: string; @@ -70,10 +70,10 @@ export async function installThreatFeedCron( const backend = options.backend ?? 'auto'; if (backend === 'auto' && !options.agentHost) { throw new Error( - 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw` or `--cron-target system`.' + 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw` or `--cron-target system`.' ); } - if (backend === 'system' || (backend === 'auto' && (options.agentHost === 'claude-code' || options.agentHost === 'codex'))) { + if (backend === 'system' || (backend === 'auto' && options.agentHost !== 'openclaw')) { return installSystemThreatFeedCron(options, adapters.runCommand); } diff --git a/src/installers.ts b/src/installers.ts index 2726728..144fec7 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,8 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; -export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw'; +export type AgentInstaller = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; export interface InstallResult { agent: AgentInstaller; @@ -14,6 +14,8 @@ export function installAgentTemplates(agent: AgentInstaller, options: { cwd?: st if (agent === 'claude-code') return installClaudeCode(root, Boolean(options.force)); if (agent === 'codex') return installCodex(root, Boolean(options.force)); if (agent === 'openclaw') return installOpenClaw(options.cwd, Boolean(options.force)); + if (agent === 'hermes') return installHermes(root, Boolean(options.force)); + if (agent === 'qclaw') return installQClaw(root, Boolean(options.force)); throw new Error(`Unsupported agent installer: ${agent}`); } @@ -57,12 +59,38 @@ function installOpenClaw(cwd: string | undefined, force: boolean): InstallResult return { agent: 'openclaw', files: [packagePath, pluginPath, manifestPath, configPath] }; } +function installHermes(root: string, force: boolean): InstallResult { + const skillDir = join(root, '.hermes', 'skills', 'agentguard'); + const configExamplePath = join(root, '.hermes', 'agentguard-hooks.example.yaml'); + copyBundledSkill(skillDir, force); + writeIfAllowed(configExamplePath, hermesHooksTemplate(skillDir), force); + return { agent: 'hermes', files: [skillDir, configExamplePath] }; +} + +function installQClaw(root: string, force: boolean): InstallResult { + const skillDir = join(root, '.qclaw', 'skills', 'agentguard'); + copyBundledSkill(skillDir, force); + return { agent: 'qclaw', files: [skillDir] }; +} + function writeIfAllowed(path: string, content: string, force: boolean): void { if (existsSync(path) && !force) return; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, { mode: path.endsWith('.sh') ? 0o755 : undefined }); } +function copyBundledSkill(targetDir: string, force: boolean): void { + if (existsSync(targetDir) && !force) return; + mkdirSync(dirname(targetDir), { recursive: true }); + const sourceDir = resolve(__dirname, '..', 'skills', 'agentguard'); + if (!existsSync(sourceDir)) { + mkdirSync(targetDir, { recursive: true }); + writeIfAllowed(join(targetDir, 'SKILL.md'), codexSkillTemplate(), force); + return; + } + cpSync(sourceDir, targetDir, { recursive: true, force }); +} + function claudeHookScript(): string { return `#!/bin/sh set -eu @@ -152,6 +180,36 @@ function codexHookTemplate(): unknown { }; } +function hermesHooksTemplate(skillDir: string): string { + return `# Copy this block into ~/.hermes/config.yaml. +hooks: + on_session_start: + - command: "env AGENTGUARD_AUTO_SCAN=1 node \\"${skillDir}/scripts/auto-scan.js\\"" + timeout: 30 + + pre_tool_call: + - matcher: "terminal|execute_code" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "write_file|patch|skill_manage" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "read_file" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + - matcher: "web_search|web_extract|browser_navigate" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 10 + + post_tool_call: + - matcher: "terminal|execute_code|write_file|patch|skill_manage|read_file|web_search|web_extract|browser_navigate" + command: "node \\"${skillDir}/scripts/hermes-hook.js\\"" + timeout: 5 + +hooks_auto_accept: false +`; +} + function openClawPluginTemplate(): string { return `const { registerOpenClawPlugin } = require('@goplus/agentguard'); diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 55195ae..4caf165 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -17,6 +17,8 @@ export type RuntimeAgentHost = | 'claude-code' | 'codex' | 'openclaw' + | 'hermes' + | 'qclaw' | 'cursor' | 'gemini' | 'copilot' diff --git a/src/tests/cli-init.test.ts b/src/tests/cli-init.test.ts index 8dfe4b6..589930a 100644 --- a/src/tests/cli-init.test.ts +++ b/src/tests/cli-init.test.ts @@ -22,4 +22,20 @@ describe('init CLI', () => { const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { agentHost?: string }; assert.equal(config.agentHost, 'codex'); }); + + it('accepts Hermes and QClaw agent installers', async () => { + for (const agent of ['hermes', 'qclaw']) { + const home = mkdtempSync(join(tmpdir(), `agentguard-init-${agent}-home-`)); + const cwd = mkdtempSync(join(tmpdir(), `agentguard-init-${agent}-cwd-`)); + const cliPath = resolve('dist', 'cli.js'); + + await execFileAsync(process.execPath, [cliPath, 'init', '--agent', agent, '--force'], { + cwd, + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { agentHost?: string }; + assert.equal(config.agentHost, agent); + } + }); }); diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index b1a07b6..3985891 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -23,6 +23,23 @@ describe('Agent template installers', () => { assert.ok(readFileSync(join(dir, '.codex', 'agentguard-hook.example.json'), 'utf8').includes('AGENTGUARD_AGENT_HOST=codex')); }); + it('writes Hermes skill and hook config example', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-')); + const result = installAgentTemplates('hermes', { cwd: dir }); + + assert.equal(result.agent, 'hermes'); + assert.ok(existsSync(join(dir, '.hermes', 'skills', 'agentguard', 'SKILL.md'))); + assert.ok(readFileSync(join(dir, '.hermes', 'agentguard-hooks.example.yaml'), 'utf8').includes('hermes-hook.js')); + }); + + it('writes QClaw skill template', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-qclaw-')); + const result = installAgentTemplates('qclaw', { cwd: dir }); + + assert.equal(result.agent, 'qclaw'); + assert.ok(existsSync(join(dir, '.qclaw', 'skills', 'agentguard', 'SKILL.md'))); + }); + it('writes and enables OpenClaw plugin template', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-')); const result = installAgentTemplates('openclaw', { cwd: dir }); From 010a0e42d767b2733985091e96a790c4a57d9768 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 12:37:23 +0800 Subject: [PATCH 4/9] feat: support hermes cron backend --- CHANGELOG.md | 2 +- README.md | 7 ++- skills/agentguard/SKILL.md | 5 +- src/cli.ts | 6 +- src/feed/cron.ts | 111 ++++++++++++++++++++++++++++++++++-- src/tests/feed-cron.test.ts | 69 ++++++++++++++++++++++ 6 files changed, 187 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a019312..ab13fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Added -- Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, while Claude Code and Codex use system crontab. +- Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, Hermes can use native Hermes cron, while Claude Code, Codex, and QClaw use system crontab. - `agentguard init --agent ` now persists the selected agent host in local config for later cron backend selection. - `agentguard init --agent` now supports `hermes` and `qclaw` in addition to `claude-code`, `codex`, and `openclaw`. diff --git a/README.md b/README.md index 1eb91cd..b22db59 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,18 @@ agentguard subscribe --quiet # Optional: run once, then install a cron job that checks every hour and asks # you to review newly published advisories. Auto uses the agent host saved by -# `agentguard init --agent`: OpenClaw uses native OpenClaw cron, while Claude -# Code/Codex/Hermes/QClaw use system crontab. If no agent host is saved, run +# `agentguard init --agent`: OpenClaw uses native OpenClaw cron, Hermes uses +# native Hermes cron, while Claude Code/Codex/QClaw use system crontab. If no agent host is saved, run # `agentguard init --agent ` first or pass --cron-target explicitly. agentguard subscribe --cron "0 * * * *" # Override cron backend selection when needed. agentguard subscribe --cron "0 * * * *" --cron-target system agentguard subscribe --cron "0 * * * *" --cron-target openclaw +agentguard subscribe --cron "0 * * * *" --cron-target hermes # System cron writes output to ~/.agentguard/feed-cron.log. +# Hermes cron writes a no-agent script under ~/.hermes/scripts/ and requires +# Hermes Gateway for automatic scheduled execution. # Or install the hourly cron in quiet mode so matches are self-checked and # reported automatically. diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 30cb54f..5254495 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -88,7 +88,7 @@ Supported CLI commands and options: | `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 | | `agentguard protect` | `--agent `, `--action-type `, `--tool-name `, `--session-id `, `--decision-mode `, `--json` | Evaluates one runtime action from stdin or hook environment | -| `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | +| `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | | `agentguard checkup --against-advisory ` | `--json` | CLI threat-feed self-check for one advisory; this is a targeted mode, not the default health-check workflow | If the user writes `/agentguard cli `, execute `agentguard ` directly. @@ -188,6 +188,7 @@ agentguard subscribe --no-report agentguard subscribe --cron "0 * * * *" agentguard subscribe --cron "0 * * * *" --cron-target system agentguard subscribe --cron "0 * * * *" --cron-target openclaw +agentguard subscribe --cron "0 * * * *" --cron-target hermes agentguard subscribe --cron "0 * * * *" --quiet agentguard subscribe --cron "0 * * * *" --cron-name agentguard-threat-feed agentguard subscribe --cron "0 * * * *" --force @@ -195,7 +196,7 @@ agentguard subscribe --cron "0 * * * *" --force Without `--quiet`, `agentguard subscribe` pulls new threat-feed advisories and notifies the user to review them manually. With `--quiet`, it runs the full automated flow: pull new advisories, self-check local skills, report local matches back to Cloud, and notify only when local matches are found. -When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, while `claude-code`, `codex`, `hermes`, and `qclaw` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw` / `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. +When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, `hermes` uses native `hermes cron create` with a no-agent script under `~/.hermes/scripts/`, while `claude-code`, `codex`, and `qclaw` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw`, `--cron-target hermes`, or `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. System cron writes output to `~/.agentguard/feed-cron.log`; it does not send OpenClaw agent-channel notifications. diff --git a/src/cli.ts b/src/cli.ts index 1a79f5c..5c48c21 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -285,7 +285,7 @@ async function main() { .option('--quiet', 'Run the full pull, self-check, and match-reporting flow with minimal output') .option('--no-report', 'Skip uploading self-check results back to Cloud') .option('--cron ', 'Install a cron job with a five-field cron expression, for example "0 * * * *"') - .option('--cron-target ', 'Cron backend: auto, openclaw, or system', 'auto') + .option('--cron-target ', 'Cron backend: auto, openclaw, hermes, or system', 'auto') .option('--cron-name ', 'Cron job name', 'agentguard-threat-feed') .option('--force', 'Replace an existing cron job with the same name') .option('--cron-run', 'Internal: run from the OpenClaw cron prompt without trying to install cron again') @@ -534,8 +534,8 @@ async function main() { } function validateCronTarget(value: unknown): CronBackend { - if (value === 'auto' || value === 'openclaw' || value === 'system') return value; - throw new Error('Invalid cron target. Use auto, openclaw, or system.'); + if (value === 'auto' || value === 'openclaw' || value === 'hermes' || value === 'system') return value; + throw new Error('Invalid cron target. Use auto, openclaw, hermes, or system.'); } function readStdinIfAvailable(): string { diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 11810b4..536c87e 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -1,10 +1,11 @@ import http from 'node:http'; import { spawn } from 'node:child_process'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export type CronBackend = 'auto' | 'openclaw' | 'system'; -export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'system'; +export type CronBackend = 'auto' | 'openclaw' | 'hermes' | 'system'; +export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'hermes' | 'system'; export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; export interface OpenClawCronInstallResult { @@ -14,6 +15,7 @@ export interface OpenClawCronInstallResult { created: boolean; backend?: ResolvedCronBackend; command?: string; + script?: string; } export interface OpenClawGatewayOptions { @@ -60,6 +62,7 @@ export async function installThreatFeedCron( backend?: CronBackend; agentHost?: CronAgentHost; agentGuardHome?: string; + hermesHome?: string; timezone?: string; }, adapters: { @@ -70,13 +73,17 @@ export async function installThreatFeedCron( const backend = options.backend ?? 'auto'; if (backend === 'auto' && !options.agentHost) { throw new Error( - 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw` or `--cron-target system`.' + 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw`, `--cron-target hermes`, or `--cron-target system`.' ); } - if (backend === 'system' || (backend === 'auto' && options.agentHost !== 'openclaw')) { + if (backend === 'system' || (backend === 'auto' && options.agentHost !== 'openclaw' && options.agentHost !== 'hermes')) { return installSystemThreatFeedCron(options, adapters.runCommand); } + if (backend === 'hermes' || (backend === 'auto' && options.agentHost === 'hermes')) { + return installHermesNativeThreatFeedCron(options, adapters.runCommand); + } + if (backend === 'openclaw' || (backend === 'auto' && options.agentHost === 'openclaw')) { let nativeError: Error | null = null; try { @@ -99,7 +106,7 @@ export async function installThreatFeedCron( } } - throw new Error('Invalid cron target. Use auto, openclaw, or system.'); + throw new Error('Invalid cron target. Use auto, openclaw, hermes, or system.'); } export async function installOpenClawThreatFeedCron( @@ -240,6 +247,67 @@ async function installOpenClawNativeThreatFeedCron( }; } +async function installHermesNativeThreatFeedCron( + options: { + name: string; + cronExpression: string; + quiet: boolean; + force: boolean; + agentGuardHome?: string; + hermesHome?: string; + timezone?: string; + }, + runCommand: CommandRunner = execCommand +): Promise { + const schedule = validateCronExpression(options.cronExpression); + const timezone = options.timezone ?? localTimeZone(); + const command = threatFeedCommand(options.quiet); + let existing: CommandResult; + try { + existing = await runCommand('hermes', ['cron', 'list']); + } catch (err) { + throw new Error(`Could not list Hermes cron jobs. Is Hermes installed and available on PATH? ${(err as Error).message}`); + } + if (existing.stdout.includes(options.name) && !options.force) { + return { + name: options.name, + schedule, + timezone, + created: false, + backend: 'hermes', + command, + }; + } + + if (existing.stdout.includes(options.name) && options.force) { + await runCommand('hermes', ['cron', 'remove', options.name]); + } + + const script = await writeHermesThreatFeedScript(options); + await runCommand('hermes', [ + 'cron', + 'create', + schedule, + '--name', + options.name, + '--deliver', + 'local', + '--script', + script, + '--no-agent', + ]); + + return { + name: options.name, + schedule, + timezone, + created: true, + backend: 'hermes', + command, + script, + }; +} + async function installSystemThreatFeedCron( options: { name: string; @@ -289,6 +357,39 @@ function threatFeedCommand(quiet: boolean): string { return `agentguard subscribe${quiet ? ' --quiet' : ''} --json --cron-run`; } +async function writeHermesThreatFeedScript(options: { + name: string; + quiet: boolean; + agentGuardHome?: string; + hermesHome?: string; +}): Promise { + const hermesHome = (options.hermesHome ?? process.env.HERMES_HOME?.trim()) || join(homedir(), '.hermes'); + const scriptsDir = join(hermesHome, 'scripts'); + await mkdir(scriptsDir, { recursive: true }); + const scriptName = `${sanitizeHermesScriptName(options.name)}.sh`; + const scriptPath = join(scriptsDir, scriptName); + const lines = [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + `export AGENTGUARD_HOME=${shellQuote(options.agentGuardHome ?? join(homedir(), '.agentguard'))}`, + process.env.PATH ? `export PATH=${shellQuote(process.env.PATH)}` : '', + `exec ${threatFeedCommand(options.quiet)}`, + '', + ].filter(Boolean); + await writeFile(scriptPath, lines.join('\n'), { mode: 0o700 }); + await chmod(scriptPath, 0o700).catch(() => undefined); + return scriptName; +} + +function sanitizeHermesScriptName(value: string): string { + const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + return normalized ? `agentguard-${normalized}` : 'agentguard-threat-feed'; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + function openClawCronMessage(quiet: boolean): string { const mode = quiet ? 'quiet' : 'manual'; const command = threatFeedCommand(quiet); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index cf35e9a..aca9d94 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -1,5 +1,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { installThreatFeedCron, installOpenClawThreatFeedCron, @@ -123,6 +126,50 @@ describe('feed/cron', () => { assert.ok(calls[1].args.includes('300')); }); + it('auto-installs native Hermes cron jobs for Hermes agents', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const hermesHome = mkdtempSync(join(tmpdir(), 'agentguard-hermes-')); + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + if (args.join(' ') === 'cron list') return { stdout: 'No scheduled jobs.', stderr: '' }; + return { stdout: 'created', stderr: '' }; + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: true, + force: false, + backend: 'auto', + agentHost: 'hermes', + agentGuardHome: '/tmp/ag-home', + hermesHome, + timezone: 'UTC', + }, + { runCommand: runner } + ); + + assert.equal(result.backend, 'hermes'); + assert.equal(result.script, 'agentguard-agentguard-threat-feed.sh'); + assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list', 'cron create']); + assert.deepEqual(calls[1].args, [ + 'cron', + 'create', + '0 * * * *', + '--name', + 'agentguard-threat-feed', + '--deliver', + 'local', + '--script', + 'agentguard-agentguard-threat-feed.sh', + '--no-agent', + ]); + const script = readFileSync(join(hermesHome, 'scripts', 'agentguard-agentguard-threat-feed.sh'), 'utf8'); + assert.match(script, /export AGENTGUARD_HOME='\/tmp\/ag-home'/); + assert.match(script, /exec agentguard subscribe --quiet --json --cron-run/); + }); + it('requires init --agent when auto has no saved agent host', async () => { await assert.rejects( () => @@ -138,6 +185,28 @@ describe('feed/cron', () => { ); }); + it('fails fast when Hermes cron list is unavailable', async () => { + const runner: CommandRunner = async () => { + throw new Error('hermes command not found'); + }; + + await assert.rejects( + () => + installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'hermes', + timezone: 'UTC', + }, + { runCommand: runner } + ), + /Could not list Hermes cron jobs/ + ); + }); + it('falls back to OpenClaw Gateway when native OpenClaw cron command fails', async () => { const gateway = fakeGateway(); const runner: CommandRunner = async () => { From dc8ee4323e52adc274de8916b2ae474ce8b63740 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 14:05:04 +0800 Subject: [PATCH 5/9] Guide AgentGuard postinstall next steps --- setup.sh | 20 +++++++------------- skills/agentguard/SKILL.md | 20 +++++++++++++++++++- src/postinstall.ts | 9 +++++++++ src/tests/postinstall.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 src/tests/postinstall.test.ts diff --git a/setup.sh b/setup.sh index 9834657..b803fc6 100755 --- a/setup.sh +++ b/setup.sh @@ -224,22 +224,16 @@ echo "" echo " ✅ GoPlus AgentGuard is installed!" echo "" echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " 🦞 NEXT STEP: Run your first security checkup" +echo " NEXT STEPS" echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -if [ "$PLATFORM" = "claude-code" ]; then - echo " Open Claude Code and type:" -else - echo " Send your OpenClaw bot:" +AGENT_HOST="$PLATFORM" +if [ "$PLATFORM" = "openclaw-workspace" ] || [ "$PLATFORM" = "openclaw-managed" ]; then + AGENT_HOST="openclaw" fi -echo "" -echo " /agentguard checkup" -echo "" -echo " This will:" -echo " • Scan all your installed skills for threats" -echo " • Check credentials, permissions & network exposure" -echo " • Generate a full HTML security report" -echo " • Deliver the report directly to you" +echo " agentguard init --agent $AGENT_HOST" +echo " agentguard connect" +echo " agentguard checkup" echo "" echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 5254495..fbdd3f4 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -53,8 +53,25 @@ cd ~/.openclaw/skills/agentguard && node scripts/checkup-report.js ## Command Routing +### Post-Install Guidance + +When AgentGuard has just been installed, or the user asks what to do next after +installing AgentGuard, recommend these steps in this order instead of deriving a +longer list from every supported command: + +```bash +agentguard init --agent +agentguard connect +agentguard checkup +``` + +Use the current agent host for `` when it is known; otherwise leave +`` as a placeholder. + 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 - **`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 @@ -74,7 +91,7 @@ This skill is allowed to run `agentguard *`, so CLI commands and flags are avail The skill's routed subcommands take priority over similarly named CLI commands. Do not route these through the packaged CLI unless the user explicitly prefixes the request with `/agentguard cli`: `scan`, `action`, `patrol`, `trust`, `report`, `config`, `checkup`, `hermes-hooks`. -Use CLI passthrough for the CLI-only commands below, for explicit `/agentguard cli ` requests, or for the targeted `checkup --against-advisory ` mode described below. +Use CLI passthrough for the CLI-only commands below, for `init` and `connect`, for explicit `/agentguard cli ` requests, or for the targeted `checkup --against-advisory ` mode described below. Supported CLI commands and options: @@ -89,6 +106,7 @@ Supported CLI commands and options: | `agentguard doctor` | none | Checks local setup and Cloud reachability when connected | | `agentguard protect` | `--agent `, `--action-type `, `--tool-name `, `--session-id `, `--decision-mode `, `--json` | Evaluates one runtime action from stdin or hook environment | | `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | +| `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 | If the user writes `/agentguard cli `, execute `agentguard ` directly. diff --git a/src/postinstall.ts b/src/postinstall.ts index b88e01a..374dbcd 100644 --- a/src/postinstall.ts +++ b/src/postinstall.ts @@ -2,10 +2,19 @@ import { ensureConfig, getAgentGuardPaths } from './config.js'; +function printNextSteps(): void { + console.log('Next steps:'); + console.log(' agentguard init --agent '); + console.log(' agentguard connect'); + console.log(' agentguard checkup'); +} + try { ensureConfig(); const paths = getAgentGuardPaths(); console.log(`AgentGuard local config ready: ${paths.configPath}`); } catch { // Postinstall must never break package installation. +} finally { + printNextSteps(); } diff --git a/src/tests/postinstall.test.ts b/src/tests/postinstall.test.ts new file mode 100644 index 0000000..20a9642 --- /dev/null +++ b/src/tests/postinstall.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +describe('postinstall', () => { + it('prints the expected next steps after preparing local config', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentguard-postinstall-home-')); + const postinstallPath = resolve('dist', 'postinstall.js'); + + const { stdout } = await execFileAsync(process.execPath, [postinstallPath], { + env: { ...process.env, AGENTGUARD_HOME: home }, + }); + + assert.match(stdout, /AgentGuard local config ready:/); + assert.match(stdout, /agentguard init --agent /); + assert.match(stdout, /agentguard connect/); + assert.match(stdout, /agentguard checkup/); + }); +}); From 0df51bc6c865eea310647655897a919af7509731 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 14:23:09 +0800 Subject: [PATCH 6/9] Support QClaw gateway cron target --- CHANGELOG.md | 2 +- README.md | 9 +++++--- skills/agentguard/SKILL.md | 5 +++-- src/cli.ts | 6 +++--- src/feed/cron.ts | 41 +++++++++++++++++++++++++++---------- src/tests/feed-cron.test.ts | 27 ++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab13fc1..9c58a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Added -- Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, Hermes can use native Hermes cron, while Claude Code, Codex, and QClaw use system crontab. +- Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, QClaw can use its Gateway at `127.0.0.1:28789`, Hermes can use native Hermes cron, while Claude Code and Codex use system crontab. - `agentguard init --agent ` now persists the selected agent host in local config for later cron backend selection. - `agentguard init --agent` now supports `hermes` and `qclaw` in addition to `claude-code`, `codex`, and `openclaw`. diff --git a/README.md b/README.md index b22db59..6901ea4 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,17 @@ agentguard subscribe --quiet # Optional: run once, then install a cron job that checks every hour and asks # you to review newly published advisories. Auto uses the agent host saved by -# `agentguard init --agent`: OpenClaw uses native OpenClaw cron, Hermes uses -# native Hermes cron, while Claude Code/Codex/QClaw use system crontab. If no agent host is saved, run -# `agentguard init --agent ` first or pass --cron-target explicitly. +# `agentguard init --agent`: OpenClaw uses native OpenClaw cron with Gateway +# fallback at 127.0.0.1:18789, QClaw uses QClaw Gateway at 127.0.0.1:28789, +# Hermes uses native Hermes cron, while Claude Code/Codex use system crontab. +# If no agent host is saved, run `agentguard init --agent ` first or +# pass --cron-target explicitly. agentguard subscribe --cron "0 * * * *" # Override cron backend selection when needed. agentguard subscribe --cron "0 * * * *" --cron-target system agentguard subscribe --cron "0 * * * *" --cron-target openclaw +agentguard subscribe --cron "0 * * * *" --cron-target qclaw agentguard subscribe --cron "0 * * * *" --cron-target hermes # System cron writes output to ~/.agentguard/feed-cron.log. # Hermes cron writes a no-agent script under ~/.hermes/scripts/ and requires diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index fbdd3f4..d03fec3 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -105,7 +105,7 @@ Supported CLI commands and options: | `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 | | `agentguard protect` | `--agent `, `--action-type `, `--tool-name `, `--session-id `, `--decision-mode `, `--json` | Evaluates one runtime action from stdin or hook environment | -| `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | +| `agentguard subscribe` | `--since `, `--json`, `--quiet`, `--no-report`, `--cron `, `--cron-target `, `--cron-name `, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills | | `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 | @@ -206,6 +206,7 @@ agentguard subscribe --no-report agentguard subscribe --cron "0 * * * *" agentguard subscribe --cron "0 * * * *" --cron-target system agentguard subscribe --cron "0 * * * *" --cron-target openclaw +agentguard subscribe --cron "0 * * * *" --cron-target qclaw agentguard subscribe --cron "0 * * * *" --cron-target hermes agentguard subscribe --cron "0 * * * *" --quiet agentguard subscribe --cron "0 * * * *" --cron-name agentguard-threat-feed @@ -214,7 +215,7 @@ agentguard subscribe --cron "0 * * * *" --force Without `--quiet`, `agentguard subscribe` pulls new threat-feed advisories and notifies the user to review them manually. With `--quiet`, it runs the full automated flow: pull new advisories, self-check local skills, report local matches back to Cloud, and notify only when local matches are found. -When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, `hermes` uses native `hermes cron create` with a no-agent script under `~/.hermes/scripts/`, while `claude-code`, `codex`, and `qclaw` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw`, `--cron-target hermes`, or `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. +When `--cron ` is used, the CLI first runs the subscribe flow once, then installs a recurring job using a standard five-field crontab expression such as `"0 * * * *"`. `--cron-target auto` is the default and uses the agent host saved by `agentguard init --agent`: `openclaw` uses the native `openclaw cron add` command and falls back to the OpenClaw Gateway at `127.0.0.1:18789`, `qclaw` uses the QClaw Gateway at `127.0.0.1:28789`, `hermes` uses native `hermes cron create` with a no-agent script under `~/.hermes/scripts/`, while `claude-code` and `codex` install a user crontab entry. If no agent host is saved, auto asks the user to run `agentguard init --agent ` first or pass `--cron-target openclaw`, `--cron-target qclaw`, `--cron-target hermes`, or `--cron-target system` explicitly. Pass `--cron-name ` to choose the job name. If a job with the same name already exists, the CLI leaves it untouched unless `--force` is passed. System cron writes output to `~/.agentguard/feed-cron.log`; it does not send OpenClaw agent-channel notifications. diff --git a/src/cli.ts b/src/cli.ts index 5c48c21..b61bcee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -285,7 +285,7 @@ async function main() { .option('--quiet', 'Run the full pull, self-check, and match-reporting flow with minimal output') .option('--no-report', 'Skip uploading self-check results back to Cloud') .option('--cron ', 'Install a cron job with a five-field cron expression, for example "0 * * * *"') - .option('--cron-target ', 'Cron backend: auto, openclaw, hermes, or system', 'auto') + .option('--cron-target ', 'Cron backend: auto, openclaw, qclaw, hermes, or system', 'auto') .option('--cron-name ', 'Cron job name', 'agentguard-threat-feed') .option('--force', 'Replace an existing cron job with the same name') .option('--cron-run', 'Internal: run from the OpenClaw cron prompt without trying to install cron again') @@ -534,8 +534,8 @@ async function main() { } function validateCronTarget(value: unknown): CronBackend { - if (value === 'auto' || value === 'openclaw' || value === 'hermes' || value === 'system') return value; - throw new Error('Invalid cron target. Use auto, openclaw, hermes, or system.'); + if (value === 'auto' || value === 'openclaw' || value === 'qclaw' || value === 'hermes' || value === 'system') return value; + throw new Error('Invalid cron target. Use auto, openclaw, qclaw, hermes, or system.'); } function readStdinIfAvailable(): string { diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 536c87e..9a2d63b 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -4,8 +4,8 @@ import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export type CronBackend = 'auto' | 'openclaw' | 'hermes' | 'system'; -export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'hermes' | 'system'; +export type CronBackend = 'auto' | 'openclaw' | 'qclaw' | 'hermes' | 'system'; +export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'qclaw-gateway' | 'hermes' | 'system'; export type CronAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw'; export interface OpenClawCronInstallResult { @@ -21,6 +21,7 @@ export interface OpenClawCronInstallResult { export interface OpenClawGatewayOptions { host?: string; port?: number; + label?: string; timeoutMs?: number; request?: (method: string, params: unknown) => Promise; } @@ -73,10 +74,10 @@ export async function installThreatFeedCron( const backend = options.backend ?? 'auto'; if (backend === 'auto' && !options.agentHost) { throw new Error( - 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw`, `--cron-target hermes`, or `--cron-target system`.' + 'Cron target auto requires a saved agent host. Run `agentguard init --agent ` first, or pass `--cron-target openclaw`, `--cron-target qclaw`, `--cron-target hermes`, or `--cron-target system`.' ); } - if (backend === 'system' || (backend === 'auto' && options.agentHost !== 'openclaw' && options.agentHost !== 'hermes')) { + if (backend === 'system' || (backend === 'auto' && options.agentHost !== 'openclaw' && options.agentHost !== 'qclaw' && options.agentHost !== 'hermes')) { return installSystemThreatFeedCron(options, adapters.runCommand); } @@ -106,7 +107,16 @@ export async function installThreatFeedCron( } } - throw new Error('Invalid cron target. Use auto, openclaw, hermes, or system.'); + if (backend === 'qclaw' || (backend === 'auto' && options.agentHost === 'qclaw')) { + const result = await installOpenClawThreatFeedCron( + options, + qclawGatewayOptions(adapters.gateway) + ); + result.backend = 'qclaw-gateway'; + return result; + } + + throw new Error('Invalid cron target. Use auto, openclaw, qclaw, hermes, or system.'); } export async function installOpenClawThreatFeedCron( @@ -357,6 +367,14 @@ function threatFeedCommand(quiet: boolean): string { return `agentguard subscribe${quiet ? ' --quiet' : ''} --json --cron-run`; } +function qclawGatewayOptions(gateway: OpenClawGatewayOptions = {}): OpenClawGatewayOptions { + return { + ...gateway, + port: gateway.port ?? 28789, + label: gateway.label ?? 'QClaw Gateway', + }; +} + async function writeHermesThreatFeedScript(options: { name: string; quiet: boolean; @@ -508,6 +526,7 @@ export function openClawGatewayRequest( }); const host = options.host ?? '127.0.0.1'; const port = options.port ?? 18789; + const label = options.label ?? 'OpenClaw Gateway'; const timeoutMs = options.timeoutMs ?? 5000; return new Promise((resolve, reject) => { @@ -537,7 +556,7 @@ export function openClawGatewayRequest( let data = ''; res.setEncoding('utf8'); res.on('error', (err) => { - fail(new Error(`OpenClaw Gateway ${method} response failed: ${err.message}`)); + fail(new Error(`${label} ${method} response failed: ${err.message}`)); }); res.on('data', (chunk) => { data += chunk; @@ -547,15 +566,15 @@ export function openClawGatewayRequest( try { parsed = data ? JSON.parse(data) : null; } catch { - fail(new Error(`OpenClaw Gateway returned non-JSON response: ${data}`)); + fail(new Error(`${label} returned non-JSON response: ${data}`)); return; } if (parsed?.error) { - fail(new Error(`OpenClaw Gateway ${method} failed: ${parsed.error.message ?? JSON.stringify(parsed.error)}`)); + fail(new Error(`${label} ${method} failed: ${parsed.error.message ?? JSON.stringify(parsed.error)}`)); return; } if (res.statusCode && res.statusCode >= 400) { - fail(new Error(`OpenClaw Gateway ${method} failed with HTTP ${res.statusCode}`)); + fail(new Error(`${label} ${method} failed with HTTP ${res.statusCode}`)); return; } succeed(parsed?.result ?? parsed); @@ -563,10 +582,10 @@ export function openClawGatewayRequest( } ); req.on('error', (err) => { - fail(new Error(`Could not reach OpenClaw Gateway at ${host}:${port}: ${err.message}`)); + fail(new Error(`Could not reach ${label} at ${host}:${port}: ${err.message}`)); }); req.setTimeout(timeoutMs, () => { - const err = new Error(`OpenClaw Gateway ${method} request timed out after ${timeoutMs}ms`); + const err = new Error(`${label} ${method} request timed out after ${timeoutMs}ms`); fail(err); req.destroy(err); }); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index aca9d94..b56af97 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -126,6 +126,33 @@ describe('feed/cron', () => { assert.ok(calls[1].args.includes('300')); }); + it('auto-installs QClaw Gateway cron jobs for QClaw agents', async () => { + const gateway = fakeGateway(); + const runner: CommandRunner = async () => { + throw new Error('system cron should not be used for qclaw auto target'); + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + agentHost: 'qclaw', + timezone: 'UTC', + }, + { runCommand: runner, gateway: { request: gateway.request } } + ); + + assert.equal(result.backend, 'qclaw-gateway'); + assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.add']); + const job = gateway.calls[1].params[0]; + assert.equal(job.name, 'agentguard-threat-feed'); + assert.deepEqual(job.schedule, { kind: 'cron', expr: '0 * * * *', tz: 'UTC' }); + assert.equal(job.payload.agentguard.command, 'agentguard subscribe --json --cron-run'); + }); + it('auto-installs native Hermes cron jobs for Hermes agents', async () => { const calls: Array<{ command: string; args: string[] }> = []; const hermesHome = mkdtempSync(join(tmpdir(), 'agentguard-hermes-')); From e9c5c72da058429bd723a64add0f1bdeed8292ad Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 14:39:38 +0800 Subject: [PATCH 7/9] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c58a0d..dcf2122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- Added `agentguard policy show` to inspect the cached effective runtime policy, with `--json` output and fallback to the bundled default policy when no cache exists. - Added `agentguard subscribe --cron-target ` so OpenClaw can use native cron with Gateway fallback, QClaw can use its Gateway at `127.0.0.1:28789`, Hermes can use native Hermes cron, while Claude Code and Codex use system crontab. - `agentguard init --agent ` now persists the selected agent host in local config for later cron backend selection. - `agentguard init --agent` now supports `hermes` and `qclaw` in addition to `claude-code`, `codex`, and `openclaw`. @@ -10,6 +11,8 @@ ### Changed - Threat-feed cron installation now fails fast when the OpenClaw Gateway preflight is unavailable instead of hiding `cron.list` errors until `cron.add`. - `agentguard subscribe --cron` now requires a saved agent host when `--cron-target auto` is used; run `agentguard init --agent ` first or pass an explicit cron target. +- `agentguard status` now shows the saved agent host when one is configured. +- Install and postinstall guidance now recommends `agentguard init --agent `, `agentguard connect`, and `agentguard checkup` as the focused next steps. ## [1.1.9] - 2026-05-20 From 668cbe2e5e59358de4b7b328ac677b6f04a8311c Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 14:45:58 +0800 Subject: [PATCH 8/9] Harden system cron installation --- CHANGELOG.md | 1 + src/feed/cron.ts | 58 ++++++++++++++++++++++++++++++++----- src/tests/feed-cron.test.ts | 26 +++++++++++++++-- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf2122..5d56549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - `agentguard subscribe --cron` now requires a saved agent host when `--cron-target auto` is used; run `agentguard init --agent ` first or pass an explicit cron target. - `agentguard status` now shows the saved agent host when one is configured. - Install and postinstall guidance now recommends `agentguard init --agent `, `agentguard connect`, and `agentguard checkup` as the focused next steps. +- System cron installation now writes and invokes a validated AgentGuard wrapper script instead of embedding config-derived paths directly in crontab. ## [1.1.9] - 2026-05-20 diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 9a2d63b..01a4777 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -2,7 +2,7 @@ import http from 'node:http'; import { spawn } from 'node:child_process'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join } from 'node:path'; export type CronBackend = 'auto' | 'openclaw' | 'qclaw' | 'hermes' | 'system'; export type ResolvedCronBackend = 'openclaw' | 'openclaw-gateway' | 'qclaw-gateway' | 'hermes' | 'system'; @@ -332,11 +332,17 @@ async function installSystemThreatFeedCron( const schedule = validateCronExpression(options.cronExpression); const timezone = options.timezone ?? localTimeZone(); const command = threatFeedCommand(options.quiet); - const home = options.agentGuardHome ?? join(homedir(), '.agentguard'); - const begin = `# AgentGuard begin ${options.name}`; - const end = `# AgentGuard end ${options.name}`; - const pathPrefix = process.env.PATH ? `PATH="${process.env.PATH}" ` : ''; - const line = `${schedule} ${pathPrefix}AGENTGUARD_HOME="${home}" ${command} >> "${join(home, 'feed-cron.log')}" 2>&1`; + const home = validateCronFilesystemPath(options.agentGuardHome ?? join(homedir(), '.agentguard'), 'AGENTGUARD_HOME'); + const jobId = sanitizeCronJobId(options.name); + const begin = `# AgentGuard begin ${jobId}`; + const end = `# AgentGuard end ${jobId}`; + const script = await writeSystemThreatFeedScript({ + name: options.name, + quiet: options.quiet, + agentGuardHome: home, + }); + const logPath = validateCronFilesystemPath(join(home, 'feed-cron.log'), 'system cron log path'); + const line = `${schedule} ${shellQuote(script)} >> ${shellQuote(logPath)} 2>&1`; const existing = await runCommand('crontab', ['-l']).then((result) => result.stdout, () => ''); const hasExisting = existing.includes(begin); if (hasExisting && !options.force) { @@ -347,10 +353,11 @@ async function installSystemThreatFeedCron( created: false, backend: 'system', command, + script, }; } - const withoutExisting = removeAgentGuardCronBlock(existing, options.name).trimEnd(); + const withoutExisting = removeAgentGuardCronBlock(existing, jobId).trimEnd(); const next = `${withoutExisting}${withoutExisting ? '\n' : ''}${begin}\n${line}\n${end}\n`; await runCommand('crontab', ['-'], next); return { @@ -360,6 +367,7 @@ async function installSystemThreatFeedCron( created: true, backend: 'system', command, + script, }; } @@ -399,6 +407,42 @@ async function writeHermesThreatFeedScript(options: { return scriptName; } +async function writeSystemThreatFeedScript(options: { + name: string; + quiet: boolean; + agentGuardHome: string; +}): Promise { + const scriptsDir = join(options.agentGuardHome, 'scripts'); + await mkdir(scriptsDir, { recursive: true }); + const scriptPath = validateCronFilesystemPath(join(scriptsDir, `${sanitizeCronJobId(options.name)}.sh`), 'system cron script path'); + const lines = [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + `export AGENTGUARD_HOME=${shellQuote(options.agentGuardHome)}`, + process.env.PATH ? `export PATH=${shellQuote(process.env.PATH)}` : '', + `exec ${threatFeedCommand(options.quiet)}`, + '', + ].filter(Boolean); + await writeFile(scriptPath, lines.join('\n'), { mode: 0o700 }); + await chmod(scriptPath, 0o700).catch(() => undefined); + return scriptPath; +} + +function validateCronFilesystemPath(value: string, label: string): string { + if (!isAbsolute(value)) { + throw new Error(`${label} must be an absolute path for system cron installation.`); + } + if (/[\0\r\n'"]/.test(value)) { + throw new Error(`${label} must not contain quotes or newlines for system cron installation.`); + } + return value; +} + +function sanitizeCronJobId(value: string): string { + const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + return normalized || 'agentguard-threat-feed'; +} + function sanitizeHermesScriptName(value: string): string { const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); return normalized ? `agentguard-${normalized}` : 'agentguard-threat-feed'; diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index b56af97..fd5c33f 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -66,6 +66,7 @@ describe('feed/cron', () => { it('auto-installs system crontab jobs for Codex and Claude Code agents', async () => { const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const home = mkdtempSync(join(tmpdir(), 'agentguard-system-')); const runner: CommandRunner = async (command, args, input) => { calls.push({ command, args, input }); if (command === 'crontab' && args[0] === '-l') { @@ -82,7 +83,7 @@ describe('feed/cron', () => { force: false, backend: 'auto', agentHost: 'codex', - agentGuardHome: '/tmp/ag-home', + agentGuardHome: home, timezone: 'UTC', }, { runCommand: runner } @@ -95,8 +96,27 @@ describe('feed/cron', () => { assert.equal(calls[1].command, 'crontab'); assert.deepEqual(calls[1].args, ['-']); assert.match(calls[1].input ?? '', /# AgentGuard begin agentguard-threat-feed/); - assert.match(calls[1].input ?? '', /agentguard subscribe --quiet --json --cron-run/); - assert.match(calls[1].input ?? '', /AGENTGUARD_HOME="\/tmp\/ag-home"/); + assert.match(calls[1].input ?? '', /agentguard-system-.*\/scripts\/agentguard-threat-feed\.sh/); + assert.doesNotMatch(calls[1].input ?? '', /AGENTGUARD_HOME=/); + const script = readFileSync(join(home, 'scripts', 'agentguard-threat-feed.sh'), 'utf8'); + assert.match(script, new RegExp(`export AGENTGUARD_HOME='${home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`)); + assert.match(script, /exec agentguard subscribe --quiet --json --cron-run/); + }); + + it('rejects unsafe AgentGuard home paths for system crontab jobs', async () => { + await assert.rejects( + () => + installThreatFeedCron({ + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: true, + force: false, + backend: 'system', + agentGuardHome: '/tmp/ag-home"; touch /tmp/pwned #', + timezone: 'UTC', + }), + /must not contain quotes or newlines/ + ); }); it('uses native OpenClaw cron command before Gateway fallback for OpenClaw agents', async () => { From 78114d8ad5b64b9a15dc4eddafe70bef7fdd0ead Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 21 May 2026 14:53:59 +0800 Subject: [PATCH 9/9] Tighten OpenClaw native cron handling --- src/feed/cron.ts | 46 ++++++++++++++++++++- src/tests/feed-cron.test.ts | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 01a4777..277a2ea 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -93,6 +93,9 @@ export async function installThreatFeedCron( return result; } catch (err) { nativeError = err as Error; + if (!(nativeError instanceof CronBackendUnavailableError)) { + throw nativeError; + } } try { @@ -213,8 +216,13 @@ async function installOpenClawNativeThreatFeedCron( const timezone = options.timezone ?? localTimeZone(); const command = threatFeedCommand(options.quiet); const message = openClawCronMessage(options.quiet); - const existing = await runCommand('openclaw', ['cron', 'list']).catch(() => null); - if (existing && existing.stdout.includes(options.name) && !options.force) { + let existing: CommandResult; + try { + existing = await runCommand('openclaw', ['cron', 'list']); + } catch (err) { + throw new CronBackendUnavailableError(`Could not list native OpenClaw cron jobs. Is OpenClaw installed and available on PATH? ${(err as Error).message}`); + } + if (nativeCronListHasExactName(existing.stdout, options.name) && !options.force) { return { name: options.name, schedule, @@ -257,6 +265,40 @@ async function installOpenClawNativeThreatFeedCron( }; } +class CronBackendUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'CronBackendUnavailableError'; + } +} + +function nativeCronListHasExactName(stdout: string, name: string): boolean { + const jsonJobs = extractOpenClawCronJobs(parseJsonOrNull(stdout)); + if (jsonJobs.some((job) => job.name === name)) return true; + + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .some((line) => nativeCronListLineHasExactName(line, name)); +} + +function nativeCronListLineHasExactName(line: string, name: string): boolean { + const quoted = line.match(/(["'])(.*?)\1/); + if (quoted?.[2] === name) return true; + + const cells = line.split(/\s{2,}|\t+/).map((cell) => cell.trim()).filter(Boolean); + return cells.includes(name); +} + +function parseJsonOrNull(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + async function installHermesNativeThreatFeedCron( options: { name: string; diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index fd5c33f..6b31626 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -146,6 +146,86 @@ describe('feed/cron', () => { assert.ok(calls[1].args.includes('300')); }); + it('does not treat native OpenClaw cron name substrings as existing jobs', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + if (args.join(' ') === 'cron list') { + return { stdout: 'agentguard-threat-feed-extra 0 * * * *\n', stderr: '' }; + } + return { stdout: 'created', stderr: '' }; + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + agentHost: 'openclaw', + timezone: 'UTC', + }, + { runCommand: runner } + ); + + assert.equal(result.created, true); + assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list', 'cron add']); + }); + + it('leaves exact native OpenClaw cron names untouched unless force is set', async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + return { + stdout: JSON.stringify({ jobs: [{ name: 'agentguard-threat-feed' }] }), + stderr: '', + }; + }; + + const result = await installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + agentHost: 'openclaw', + timezone: 'UTC', + }, + { runCommand: runner } + ); + + assert.equal(result.created, false); + assert.deepEqual(calls.map((call) => call.args.slice(0, 2).join(' ')), ['cron list']); + }); + + it('does not fall back to OpenClaw Gateway when native OpenClaw cron add fails', async () => { + const gateway = fakeGateway(); + const runner: CommandRunner = async (_command, args) => { + if (args.join(' ') === 'cron list') return { stdout: '', stderr: '' }; + throw new Error('invalid native OpenClaw cron arguments'); + }; + + await assert.rejects( + () => + installThreatFeedCron( + { + name: 'agentguard-threat-feed', + cronExpression: '0 * * * *', + quiet: false, + force: false, + backend: 'auto', + agentHost: 'openclaw', + timezone: 'UTC', + }, + { runCommand: runner, gateway: { request: gateway.request } } + ), + /invalid native OpenClaw cron arguments/ + ); + assert.deepEqual(gateway.calls, []); + }); + it('auto-installs QClaw Gateway cron jobs for QClaw agents', async () => { const gateway = fakeGateway(); const runner: CommandRunner = async () => {