diff --git a/CHANGELOG.md b/CHANGELOG.md index 2104efb..d14f3fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed - `agentguard init --agent` now normalizes agent names before validation, so mixed-case values such as `Hermes` initialize correctly. - Hermes hook runtime decisions now use the shared AgentGuard Cloud sync path and emit a more broadly compatible block response for `pre_tool_call`. +- `agentguard subscribe --cron` OpenClaw/QClaw jobs now use host `announce` delivery to the last chat route with an internal `--cron-notify-run` command that prints either the notification body or `NO_REPLY`, avoiding missing Telegram `chatId` errors while keeping no-op ticks silent. - `agentguard subscribe --cron` Gateway installation now preserves legacy HTTP Gateway compatibility, falls back to OpenClaw-compatible WebSocket RPC when needed, sends QClaw the `cron.add` object payload expected by the Gateway schema, and handles fragmented WebSocket responses. - `setup.sh` now falls back to the Claude Code skill directory when no supported agent platform is detected, while keeping `--target` available for custom layouts. - AgentGuard skill system-crontab guidance now validates cron expressions and skill paths, quotes paths with spaces, and avoids embedding notification secrets in crontab entries. diff --git a/README.md b/README.md index 7deeb92..0b83f77 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ agentguard subscribe --quiet # `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. +# OpenClaw/QClaw cron jobs deliver through the host's last chat route only when +# AgentGuard prints a notification body; no-notification runs print NO_REPLY. # If no agent host is saved, run `agentguard init --agent ` first or # pass --cron-target explicitly. agentguard subscribe --cron "0 * * * *" diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 95d1894..d9bd45d 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`, `--cron-notify-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 | @@ -225,13 +225,13 @@ 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`, `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. +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. OpenClaw/QClaw cron jobs use host `announce` delivery to the last chat route and run internal `--cron-notify-run`, which prints either the exact notification body or `NO_REPLY`; this keeps no-op cron ticks silent without embedding chat IDs in the job. 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. `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. -`--since ` overrides the persisted feed cursor for one run. `--no-report` skips uploading local matches back to Cloud in quiet mode. `--cron-run` is internal and should only be used by the OpenClaw cron prompt unless the user explicitly asks to reproduce cron behavior. +`--since ` overrides the persisted feed cursor for one run. `--no-report` skips uploading local matches back to Cloud in quiet mode. `--cron-run` and `--cron-notify-run` are internal and should only be used by installed cron jobs unless the user explicitly asks to reproduce cron behavior. --- diff --git a/src/cli.ts b/src/cli.ts index 5e67f83..3d010d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -320,12 +320,14 @@ async function main() { .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') + .option('--cron-notify-run', 'Internal: run from an OpenClaw cron prompt and print only the notification body or NO_REPLY') .action(async (options) => { const config = ensureConfig(); const client = new AgentGuardCloudClient(config); const state = loadFeedState(); const since = (options.since as string | undefined) ?? state.lastPulledAt; const quiet = Boolean(options.quiet); + const cronNotifyRun = Boolean(options.cronNotifyRun); const cronTarget = validateCronTarget(options.cronTarget); const cronExpression = options.cron && !options.cronRun ? validateCronExpression(options.cron as string) @@ -335,13 +337,20 @@ async function main() { try { advisories = await client.pullAdvisories(since); } catch (err) { + if (cronNotifyRun) { + console.log('NO_REPLY'); + process.exitCode = 0; + return; + } console.error(`! Could not reach AgentGuard Cloud: ${(err as Error).message}`); process.exitCode = 1; return; } if (advisories === null) { // 404 — older Cloud build without the feed endpoint. Not an error. - if (options.json) { + if (cronNotifyRun) { + console.log('NO_REPLY'); + } else if (options.json) { console.log(JSON.stringify({ supported: false, shouldNotify: false, results: [], cron: { requested: false, installed: false } })); } else if (!quiet) { console.log('AgentGuard Cloud does not expose /api/v1/feed/advisories yet — nothing to do.'); @@ -445,6 +454,12 @@ async function main() { } } + if (cronNotifyRun) { + console.log(summary.shouldNotify && summary.hardFailures === 0 ? summary.notification?.body ?? 'NO_REPLY' : 'NO_REPLY'); + process.exitCode = 0; + return; + } + if (options.json) { console.log(JSON.stringify(summary, null, 2)); return; diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 966732d..130f2b4 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -139,7 +139,7 @@ export async function installOpenClawThreatFeedCron( ): Promise { const schedule = validateCronExpression(options.cronExpression); const timezone = options.timezone ?? localTimeZone(); - const command = threatFeedCommand(options.quiet); + const command = threatFeedCommand(options.quiet, { notifyRun: true }); const existing = await findOpenClawCronJobsByName(options.name, gateway); if (existing.length > 0 && !options.force) { return { @@ -181,7 +181,8 @@ export async function installOpenClawThreatFeedCron( }, }, delivery: { - mode: 'none', + mode: 'announce', + channel: 'last', }, }, gateway @@ -217,7 +218,7 @@ async function installOpenClawNativeThreatFeedCron( ): Promise { const schedule = validateCronExpression(options.cronExpression); const timezone = options.timezone ?? localTimeZone(); - const command = threatFeedCommand(options.quiet); + const command = threatFeedCommand(options.quiet, { notifyRun: true }); const message = openClawCronMessage(options.quiet); let existing: CommandResult; try { @@ -253,6 +254,9 @@ async function installOpenClawNativeThreatFeedCron( message, '--timeout-seconds', '300', + '--announce', + '--channel', + 'last', '--thinking', 'off', ]; @@ -416,8 +420,9 @@ async function installSystemThreatFeedCron( }; } -function threatFeedCommand(quiet: boolean): string { - return `agentguard subscribe${quiet ? ' --quiet' : ''} --json --cron-run`; +function threatFeedCommand(quiet: boolean, options: { notifyRun?: boolean } = {}): string { + const modeFlag = options.notifyRun ? '--cron-notify-run' : '--json --cron-run'; + return `agentguard subscribe${quiet ? ' --quiet' : ''} ${modeFlag}`; } function qclawGatewayOptions(gateway: OpenClawGatewayOptions = {}): OpenClawGatewayOptions { @@ -499,17 +504,17 @@ function shellQuote(value: string): string { function openClawCronMessage(quiet: boolean): string { const mode = quiet ? 'quiet' : 'manual'; - const command = threatFeedCommand(quiet); + const command = threatFeedCommand(quiet, { notifyRun: true }); 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.', + '- The command prints either the exact notification body or `NO_REPLY`.', + '- Output the command stdout exactly as your final response.', + '- Do not summarize, transform, add labels, or send a separate message.', + '- If the command fails or prints no stdout, output `NO_REPLY`.', '', 'Follow these rules exactly.', ].join('\n'); diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index 8182dee..5004ae1 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -130,4 +130,46 @@ describe('CLI subscribe command modes', () => { assert.deepEqual((reports[0] as { advisoryId: string }).advisoryId, 'AGS-2026-subscribe'); }); }); + + it('--cron-notify-run prints only the manual notification body when new advisories exist', async () => { + await withFeedServer([advisory], async (cloudUrl) => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-')); + + const result = await runCli(['subscribe', '--cron-notify-run'], home, cloudUrl); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /^AgentGuard found new threat-feed advisories/m); + assert.match(result.stdout, /AGS-2026-subscribe/); + assert.doesNotMatch(result.stdout, /Pulled \d+ advisory/); + }); + }); + + it('--cron-notify-run prints NO_REPLY when nothing should notify', async () => { + await withFeedServer([], async (cloudUrl) => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-')); + + const result = await runCli(['subscribe', '--cron-notify-run'], home, cloudUrl); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, 'NO_REPLY\n'); + }); + }); + + it('--quiet --cron-notify-run prints only the match notification body and exits zero', async () => { + await withFeedServer([advisory], async (cloudUrl, reports) => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-')); + installMatchingSkill(home); + + const result = await runCli(['subscribe', '--quiet', '--cron-notify-run'], home, cloudUrl); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /^AgentGuard threat-feed self-check found local matches:/m); + assert.match(result.stdout, /AGS-2026-subscribe: 1 match/); + assert.doesNotMatch(result.stdout, /Self-check found/); + assert.equal(reports.length, 1); + }); + }); }); diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index a62ce63..6b9cc41 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -94,7 +94,7 @@ describe('feed/cron', () => { assert.throws(() => validateCronExpression('0 * * * * *'), /Invalid --cron/); }); - it('adds an OpenClaw cron job with silent delivery and cron schedule', async () => { + it('adds an OpenClaw cron job with announce-last delivery and cron schedule', async () => { const gateway = fakeGateway(); const result = await installOpenClawThreatFeedCron( @@ -109,17 +109,17 @@ describe('feed/cron', () => { const job = gateway.calls[1].params; assert.equal(job.name, 'agentguard-threat-feed'); assert.deepEqual(job.schedule, { kind: 'cron', expr: '0 * * * *', tz: 'Asia/Shanghai' }); - assert.deepEqual(job.delivery, { mode: 'none' }); + assert.deepEqual(job.delivery, { mode: 'announce', channel: 'last' }); assert.equal(job.sessionTarget, 'isolated'); assert.equal(job.payload.kind, 'agentTurn'); assert.deepEqual(job.payload.agentguard, { mode: 'manual', - command: 'agentguard subscribe --json --cron-run', + command: 'agentguard subscribe --cron-notify-run', }); assert.match(job.payload.message, /Mode: manual/); - assert.match(job.payload.message, /Command: `agentguard subscribe --json --cron-run`/); - assert.match(job.payload.message, /agentguard subscribe --json --cron-run/); - assert.match(job.payload.message, /hardFailures/); + assert.match(job.payload.message, /Command: `agentguard subscribe --cron-notify-run`/); + assert.match(job.payload.message, /agentguard subscribe --cron-notify-run/); + assert.match(job.payload.message, /NO_REPLY/); }); it('auto-installs system crontab jobs for Codex and Claude Code agents', async () => { @@ -234,6 +234,9 @@ describe('feed/cron', () => { 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')); + assert.ok(calls[1].args.includes('--announce')); + assert.ok(calls[1].args.includes('--channel')); + assert.ok(calls[1].args.includes('last')); }); it('does not treat native OpenClaw cron name substrings as existing jobs', async () => { @@ -340,7 +343,8 @@ describe('feed/cron', () => { const job = gateway.calls[1].params; 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'); + assert.deepEqual(job.delivery, { mode: 'announce', channel: 'last' }); + assert.equal(job.payload.agentguard.command, 'agentguard subscribe --cron-notify-run'); }); it('auto-installs native Hermes cron jobs for Hermes agents', async () => { @@ -489,11 +493,12 @@ describe('feed/cron', () => { assert.deepEqual(gateway.calls[2].params.schedule, { kind: 'cron', expr: '*/5 * * * *', tz: 'UTC' }); assert.deepEqual(gateway.calls[2].params.payload.agentguard, { mode: 'quiet', - command: 'agentguard subscribe --quiet --json --cron-run', + command: 'agentguard subscribe --quiet --cron-notify-run', }); assert.match(gateway.calls[2].params.payload.message, /Mode: quiet/); - assert.match(gateway.calls[2].params.payload.message, /Command: `agentguard subscribe --quiet --json --cron-run`/); - assert.match(gateway.calls[2].params.payload.message, /agentguard subscribe --quiet --json --cron-run/); + assert.deepEqual(gateway.calls[2].params.delivery, { mode: 'announce', channel: 'last' }); + assert.match(gateway.calls[2].params.payload.message, /Command: `agentguard subscribe --quiet --cron-notify-run`/); + assert.match(gateway.calls[2].params.payload.message, /agentguard subscribe --quiet --cron-notify-run/); }); it('does not add a replacement if force removal fails', async () => {