Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>` first or
# pass --cron-target explicitly.
agentguard subscribe --cron "0 * * * *"
Expand Down
6 changes: 3 additions & 3 deletions skills/agentguard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>`, `--action-type <type>`, `--tool-name <name>`, `--session-id <id>`, `--decision-mode <local-first|cloud>`, `--json` | Evaluates one runtime action from stdin or hook environment |
| `agentguard subscribe` | `--since <iso>`, `--json`, `--quiet`, `--no-report`, `--cron <expr>`, `--cron-target <auto|openclaw|qclaw|hermes|system>`, `--cron-name <name>`, `--force`, `--cron-run` | Pulls Cloud threat advisories and optionally self-checks local skills |
| `agentguard subscribe` | `--since <iso>`, `--json`, `--quiet`, `--no-report`, `--cron <expr>`, `--cron-target <auto|openclaw|qclaw|hermes|system>`, `--cron-name <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 <id>` | `--json` | CLI threat-feed self-check for one advisory; this is a targeted mode, not the default health-check workflow |

Expand Down Expand Up @@ -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 <expr>` 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 <claude-code|codex|openclaw|hermes|qclaw>` first or pass `--cron-target openclaw`, `--cron-target qclaw`, `--cron-target hermes`, or `--cron-target system` explicitly. Pass `--cron-name <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 <expr>` 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 <claude-code|codex|openclaw|hermes|qclaw>` first or pass `--cron-target openclaw`, `--cron-target qclaw`, `--cron-target hermes`, or `--cron-target system` explicitly. Pass `--cron-name <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 <iso>` 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 <iso>` 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.

---

Expand Down
17 changes: 16 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,14 @@ async function main() {
.option('--cron-name <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)
Expand All @@ -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.');
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 15 additions & 10 deletions src/feed/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export async function installOpenClawThreatFeedCron(
): Promise<OpenClawCronInstallResult> {
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 {
Expand Down Expand Up @@ -181,7 +181,8 @@ export async function installOpenClawThreatFeedCron(
},
},
delivery: {
mode: 'none',
mode: 'announce',
channel: 'last',
},
},
gateway
Expand Down Expand Up @@ -217,7 +218,7 @@ async function installOpenClawNativeThreatFeedCron(
): Promise<OpenClawCronInstallResult> {
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 {
Expand Down Expand Up @@ -253,6 +254,9 @@ async function installOpenClawNativeThreatFeedCron(
message,
'--timeout-seconds',
'300',
'--announce',
'--channel',
'last',
'--thinking',
'off',
];
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
42 changes: 42 additions & 0 deletions src/tests/cli-subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
25 changes: 15 additions & 10 deletions src/tests/feed-cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading