From c2bc6b6d9158929a431f0817318f7771997be809 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 22 May 2026 14:37:18 +0800 Subject: [PATCH 1/6] Change feed state to pull records --- src/cli.ts | 43 +++++++++++----------- src/feed/state.ts | 63 +++++++++++++++++++------------- src/feed/types.ts | 17 +++++---- src/tests/cli-subscribe.test.ts | 22 +++++++++++- src/tests/feed-state.test.ts | 64 +++++++++++++++++++++++++-------- 5 files changed, 141 insertions(+), 68 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3d010d4..447f03d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,7 +24,7 @@ import type { RuntimeActionType, RuntimeAgentHost } from './runtime/types.js'; import { installAgentTemplates, type AgentInstaller, type InstallResult } from './installers.js'; import { packageVersion } from './version.js'; import { runSelfCheckForAdvisory } from './feed/selfcheck.js'; -import { loadFeedState, markAdvisorySeen, saveFeedState } from './feed/state.js'; +import { getSeenAdvisoryIds, loadFeedState, prependFeedStateEntry, saveFeedState } from './feed/state.js'; import type { Advisory, SelfCheckResult } from './feed/types.js'; import { installThreatFeedCron, @@ -325,7 +325,7 @@ async function main() { const config = ensureConfig(); const client = new AgentGuardCloudClient(config); const state = loadFeedState(); - const since = (options.since as string | undefined) ?? state.lastPulledAt; + const since = options.since as string | undefined; const quiet = Boolean(options.quiet); const cronNotifyRun = Boolean(options.cronNotifyRun); const cronTarget = validateCronTarget(options.cronTarget); @@ -358,16 +358,17 @@ async function main() { return; } - const seen = new Set(state.seenAdvisoryIds ?? []); - // Process oldest-first so the cursor can advance monotonically and we - // never skip over an advisory that failed mid-batch. + const seen = new Set(getSeenAdvisoryIds(state)); + // Process oldest-first so output stays deterministic when Cloud returns + // multiple fresh advisories. const fresh = advisories .filter((a) => !seen.has(a.id)) .sort((a, b) => (a.publishedAt < b.publishedAt ? -1 : 1)); const results: SelfCheckResult[] = []; - let cursorOk = true; // stops advancing on the first hard failure - let latestPublishedAt = state.lastPulledAt; let hardFailures = 0; + const newSeenIds: string[] = []; + const foundIds: string[] = []; + const pulledAt = new Date().toISOString(); if (quiet) { for (const advisory of fresh) { @@ -380,7 +381,6 @@ async function main() { // not been evaluated — don't mark it seen and don't advance. console.error(`! Self-check threw for ${advisory.id}: ${(err as Error).message}`); hardFailures += 1; - cursorOk = false; continue; } results.push(result); @@ -402,27 +402,28 @@ async function main() { } if (processed) { - Object.assign(state, markAdvisorySeen(state, advisory.id)); - if (cursorOk && (!latestPublishedAt || advisory.publishedAt > latestPublishedAt)) { - latestPublishedAt = advisory.publishedAt; + newSeenIds.push(advisory.id); + if (result.matchedArtifacts.length > 0) { + foundIds.push(advisory.id); } } else { - // From this point we no longer advance the pull cursor — the - // failed advisory must be re-pulled on the next run. - cursorOk = false; + // Failed advisories are left out of newSeenIds, so the ID-based + // state will re-process them on the next subscribe run. } } } else { for (const advisory of fresh) { - Object.assign(state, markAdvisorySeen(state, advisory.id)); - if (cursorOk && (!latestPublishedAt || advisory.publishedAt > latestPublishedAt)) { - latestPublishedAt = advisory.publishedAt; - } + newSeenIds.push(advisory.id); } } - state.lastPulledAt = latestPublishedAt; - saveFeedState(state); + if (newSeenIds.length > 0 || foundIds.length > 0) { + saveFeedState(prependFeedStateEntry(state, { + pulledAt, + newSeenIds, + foundIds, + })); + } const totalMatches = results.reduce((acc, r) => acc + r.matchedArtifacts.length, 0); const summary = buildSubscribeSummary({ @@ -498,7 +499,7 @@ async function main() { } // Exit codes: 2 = matches found, 1 = at least one advisory failed - // to evaluate or report (cursor was held back), 0 = clean. + // to evaluate or report, 0 = clean. if (hardFailures > 0) { console.error(`! ${hardFailures} advisory record(s) failed to process and will be re-pulled next run.`); process.exitCode = 1; diff --git a/src/feed/state.ts b/src/feed/state.ts index 0693c3a..780a58d 100644 --- a/src/feed/state.ts +++ b/src/feed/state.ts @@ -1,19 +1,14 @@ /** * Local feed-subscription state I/O. * - * Persisted at `~/.agentguard/feed-state.json` so the `subscribe` command - * doesn't re-process the same advisory across invocations / cron ticks. - * - * Kept tiny (single JSON object) on purpose — bigger ledgers go through the - * audit log path, not here. + * Persisted at `~/.agentguard/feed-state.json` as newest-first pull records so + * subscribe runs are easy to inspect when debugging feed behavior. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getAgentGuardPaths } from '../config.js'; -import type { FeedState } from './types.js'; - -const SEEN_ID_LIMIT = 1000; +import type { FeedState, FeedStateEntry } from './types.js'; function statePath(): string { return join(getAgentGuardPaths().home, 'feed-state.json'); @@ -21,36 +16,54 @@ function statePath(): string { export function loadFeedState(): FeedState { const file = statePath(); - if (!existsSync(file)) return {}; + if (!existsSync(file)) return []; try { const raw = readFileSync(file, 'utf8'); - const parsed = JSON.parse(raw) as Partial; - return { - lastPulledAt: parsed.lastPulledAt, - seenAdvisoryIds: parsed.seenAdvisoryIds ?? [], - }; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .map(normalizeEntry) + .filter((entry): entry is FeedStateEntry => Boolean(entry)); } catch { // Corrupt state file: pretend it's empty rather than crash. The next // successful subscribe will overwrite it. - return {}; + return []; } } export function saveFeedState(state: FeedState): void { const file = statePath(); mkdirSync(dirname(file), { recursive: true }); - const trimmed: FeedState = { - lastPulledAt: state.lastPulledAt, - seenAdvisoryIds: (state.seenAdvisoryIds ?? []).slice(-SEEN_ID_LIMIT), - }; - writeFileSync(file, `${JSON.stringify(trimmed, null, 2)}\n`, { mode: 0o600 }); + const normalized = state.map(normalizeEntry).filter(Boolean); + writeFileSync(file, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); +} + +export function getSeenAdvisoryIds(state: FeedState): string[] { + return [...new Set(state.flatMap((entry) => entry.newSeenIds))]; } -export function markAdvisorySeen(state: FeedState, advisoryId: string): FeedState { - const set = new Set(state.seenAdvisoryIds ?? []); - set.add(advisoryId); +export function prependFeedStateEntry( + state: FeedState, + entry: FeedStateEntry +): FeedState { + const normalized = normalizeEntry(entry); + return normalized ? [normalized, ...state] : state; +} + +function normalizeEntry(value: unknown): FeedStateEntry | null { + if (!value || typeof value !== 'object') return null; + const entry = value as Partial; + if (typeof entry.pulledAt !== 'string' || entry.pulledAt.length === 0) return null; return { - ...state, - seenAdvisoryIds: [...set], + pulledAt: entry.pulledAt, + newSeenIds: uniqueStrings(entry.newSeenIds), + foundIds: uniqueStrings(entry.foundIds), }; } + +function uniqueStrings(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set(value.filter((item): item is string => typeof item === 'string' && item.length > 0)), + ]; +} diff --git a/src/feed/types.ts b/src/feed/types.ts index 18dfa4f..ebbad9e 100644 --- a/src/feed/types.ts +++ b/src/feed/types.ts @@ -89,16 +89,21 @@ export interface Advisory { }; } +/** One local feed-subscription pull record. Newest records are stored first. */ +export interface FeedStateEntry { + /** ISO-8601 timestamp for when this subscribe run pulled the feed. */ + pulledAt: string; + /** Stable IDs of advisories newly processed in this pull. */ + newSeenIds: string[]; + /** Advisory IDs whose self-check found local matches in this pull. */ + foundIds: string[]; +} + /** * Local feed-subscription state. Persisted between `subscribe` runs so the * client doesn't re-process advisories it has already seen. */ -export interface FeedState { - /** ISO-8601 timestamp of the latest advisory `publishedAt` we've processed. */ - lastPulledAt?: string; - /** Stable IDs of advisories already evaluated; bounded LRU. */ - seenAdvisoryIds?: string[]; -} +export type FeedState = FeedStateEntry[]; /** * Result of running a single advisory's checks against the local environment. diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index 5004ae1..249958c 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; @@ -131,6 +131,26 @@ describe('CLI subscribe command modes', () => { }); }); + it('persists subscribe state as newest-first pull records', async () => { + await withFeedServer([advisory], async (cloudUrl) => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-')); + installMatchingSkill(home); + + const result = await runCli(['subscribe', '--quiet', '--no-report'], home, cloudUrl); + + assert.equal(result.exitCode, 2); + const state = JSON.parse(readFileSync(join(home, 'feed-state.json'), 'utf8')) as Array<{ + pulledAt: string; + newSeenIds: string[]; + foundIds: string[]; + }>; + assert.equal(state.length, 1); + assert.match(state[0].pulledAt, /^\d{4}-\d{2}-\d{2}T/); + assert.deepEqual(state[0].newSeenIds, ['AGS-2026-subscribe']); + assert.deepEqual(state[0].foundIds, ['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-')); diff --git a/src/tests/feed-state.test.ts b/src/tests/feed-state.test.ts index 49ff8aa..f914d21 100644 --- a/src/tests/feed-state.test.ts +++ b/src/tests/feed-state.test.ts @@ -1,9 +1,9 @@ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { loadFeedState, markAdvisorySeen, saveFeedState } from '../feed/state.js'; +import { getSeenAdvisoryIds, loadFeedState, prependFeedStateEntry, saveFeedState } from '../feed/state.js'; const originalAgentGuardHome = process.env.AGENTGUARD_HOME; @@ -20,24 +20,58 @@ describe('feed/state', () => { } }); - it('persists pull cursor and seen advisory ids', () => { + it('persists newest-first pull records', () => { isolateHome(); - saveFeedState({ - lastPulledAt: '2026-05-13T00:00:00Z', - seenAdvisoryIds: ['AGS-2026-1'], - }); + saveFeedState([ + { + pulledAt: '2026-05-13T00:00:00Z', + newSeenIds: ['AGS-2026-2'], + foundIds: ['AGS-2026-2'], + }, + { + pulledAt: '2026-05-12T00:00:00Z', + newSeenIds: ['AGS-2026-1'], + foundIds: [], + }, + ]); const state = loadFeedState(); - assert.equal(state.lastPulledAt, '2026-05-13T00:00:00Z'); - assert.deepEqual(state.seenAdvisoryIds, ['AGS-2026-1']); + assert.deepEqual(state, [ + { + pulledAt: '2026-05-13T00:00:00Z', + newSeenIds: ['AGS-2026-2'], + foundIds: ['AGS-2026-2'], + }, + { + pulledAt: '2026-05-12T00:00:00Z', + newSeenIds: ['AGS-2026-1'], + foundIds: [], + }, + ]); + assert.deepEqual(getSeenAdvisoryIds(state), ['AGS-2026-2', 'AGS-2026-1']); + }); + + it('prepends normalized records without duplicating ids inside a record', () => { + const state = prependFeedStateEntry([], { + pulledAt: '2026-05-13T00:00:00Z', + newSeenIds: ['AGS-2026-1', 'AGS-2026-1'], + foundIds: ['AGS-2026-1', 'AGS-2026-1'], + }); + + assert.deepEqual(state, [{ + pulledAt: '2026-05-13T00:00:00Z', + newSeenIds: ['AGS-2026-1'], + foundIds: ['AGS-2026-1'], + }]); }); - it('marks advisory ids as seen without duplicating them', () => { - const state = markAdvisorySeen( - { seenAdvisoryIds: ['AGS-2026-1'] }, - 'AGS-2026-1' - ); + it('does not migrate the old object state format', () => { + isolateHome(); + writeFileSync(join(process.env.AGENTGUARD_HOME!, 'feed-state.json'), JSON.stringify({ + lastPulledAt: '2026-05-13T00:00:00Z', + seenAdvisoryIds: ['AGS-2026-1'], + })); - assert.deepEqual(state.seenAdvisoryIds, ['AGS-2026-1']); + assert.deepEqual(loadFeedState(), []); }); }); From fae21c62fbb765b986f8c39abaff74f5b596308c Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 22 May 2026 15:00:58 +0800 Subject: [PATCH 2/6] Install Hermes hooks into profile configs --- CHANGELOG.md | 1 + src/installers.ts | 77 +++++++++++++++++++++++++++---------- src/tests/installer.test.ts | 44 ++++++++++++++++++++- 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9297336..17af5db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed - `agentguard init --agent` now normalizes agent names before validation, so mixed-case values such as `Hermes` initialize correctly. +- `agentguard init --agent hermes` now recursively enables AgentGuard hooks in Hermes profile `config.yaml` files, including configs with empty `hooks: {}` blocks or duplicate top-level `hooks` keys. - 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. diff --git a/src/installers.ts b/src/installers.ts index 6047bd0..274e441 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -51,13 +51,16 @@ function installOpenClaw(cwd: string | undefined, force: boolean): InstallResult } function installHermes(root: string, force: boolean): InstallResult { - const skillDir = join(root, '.hermes', 'skills', 'agentguard'); - const configExamplePath = join(root, '.hermes', 'agentguard-hooks.example.yaml'); - const configPath = join(root, '.hermes', 'config.yaml'); + const hermesRoot = join(root, '.hermes'); + const skillDir = join(hermesRoot, 'skills', 'agentguard'); + const configExamplePath = join(hermesRoot, 'agentguard-hooks.example.yaml'); copyBundledSkill(skillDir, force); writeIfAllowed(configExamplePath, hermesHooksTemplate(skillDir), force); - enableHermesHooks(configPath, skillDir, force); - return { agent: 'hermes', files: [skillDir, configExamplePath, configPath] }; + const configPaths = findHermesConfigPaths(hermesRoot); + for (const configPath of configPaths) { + enableHermesHooks(configPath, skillDir); + } + return { agent: 'hermes', files: [skillDir, configExamplePath, ...configPaths] }; } function installQClaw(root: string, force: boolean): InstallResult { @@ -311,13 +314,10 @@ function enableClawPlugin(configPath: string, pluginDir: string): void { writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); } -function enableHermesHooks(configPath: string, skillDir: string, force: boolean): void { - if (existsSync(configPath) && !force && readFileSync(configPath, 'utf8').includes(`${skillDir}/scripts/hermes-hook.js`)) { - return; - } - +function enableHermesHooks(configPath: string, skillDir: string): void { const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''; const next = mergeHermesHooks(existing, skillDir); + if (next === existing) return; mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, next); } @@ -325,18 +325,27 @@ function enableHermesHooks(configPath: string, skillDir: string, force: boolean) function mergeHermesHooks(existing: string, skillDir: string): string { const lines = existing.replace(/\s+$/g, '').split(/\r?\n/).filter((line, index, arr) => !(arr.length === 1 && index === 0 && line === '')); const hooksBlock = hermesHookEventBlock(skillDir).split('\n').filter(Boolean); - const hooksIndex = lines.findIndex((line) => /^hooks:\s*(?:#.*)?$/.test(line)); + const merged: string[] = []; + let sawHooks = false; - if (hooksIndex === -1) { - const prefix = lines.length ? `${lines.join('\n')}\n\n` : ''; - return `${prefix}hooks:\n${hooksBlock.join('\n')}\n\n${hermesAutoAcceptLine(lines)}\n`; + for (let index = 0; index < lines.length;) { + if (isTopLevelHermesHooksLine(lines[index])) { + sawHooks = true; + const hooksEnd = findNextTopLevelIndex(lines, index + 1); + merged.push('hooks:'); + merged.push(...removeHermesManagedEvents(lines.slice(index + 1, hooksEnd))); + merged.push(...hooksBlock); + index = hooksEnd; + continue; + } + merged.push(lines[index]); + index += 1; } - const hooksEnd = findNextTopLevelIndex(lines, hooksIndex + 1); - const before = lines.slice(0, hooksIndex + 1); - const body = removeHermesManagedEvents(lines.slice(hooksIndex + 1, hooksEnd)); - const after = lines.slice(hooksEnd); - const merged = [...before, ...body, ...hooksBlock, ...after]; + if (!sawHooks) { + if (merged.length > 0) merged.push(''); + merged.push('hooks:', ...hooksBlock); + } if (!merged.some((line) => /^hooks_auto_accept:\s*/.test(line))) { merged.push('', 'hooks_auto_accept: false'); @@ -345,6 +354,34 @@ function mergeHermesHooks(existing: string, skillDir: string): string { return `${merged.join('\n').replace(/\s+$/g, '')}\n`; } +function isTopLevelHermesHooksLine(line: string): boolean { + return /^hooks:\s*(?:\{\}\s*)?(?:#.*)?$/.test(line); +} + +function findHermesConfigPaths(hermesRoot: string): string[] { + const primary = join(hermesRoot, 'config.yaml'); + const found = new Set([primary]); + if (!existsSync(hermesRoot)) return [...found]; + + const visit = (dir: string): void => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + visit(path); + continue; + } + if (stat.isFile() && name === 'config.yaml') { + found.add(path); + } + } + }; + + visit(hermesRoot); + return [...found]; +} + function hermesHookEventBlock(skillDir: string): string { return ` on_session_start: - command: "env AGENTGUARD_AUTO_SCAN=1 node \\"${skillDir}/scripts/auto-scan.js\\"" diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index 4ec223b..fa2b09f 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { installAgentTemplates } from '../installers.js'; @@ -51,6 +51,48 @@ describe('Agent template installers', () => { assert.ok(config.includes('hermes-hook.js')); }); + it('enables Hermes hooks in profile configs under ~/.hermes', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-profiles-')); + const rootConfigPath = join(dir, '.hermes', 'config.yaml'); + const profileConfigPath = join(dir, '.hermes', 'profiles', 'agent2', 'config.yaml'); + mkdirSync(dirname(profileConfigPath), { recursive: true }); + mkdirSync(dirname(rootConfigPath), { recursive: true }); + writeFileSync(rootConfigPath, 'theme: dark\n'); + writeFileSync(profileConfigPath, 'profile: agent2\nhooks: {}\n'); + + const result = installAgentTemplates('hermes', { cwd: dir }); + + const rootConfig = readFileSync(rootConfigPath, 'utf8'); + const profileConfig = readFileSync(profileConfigPath, 'utf8'); + assert.ok(result.files.includes(profileConfigPath)); + assert.ok(rootConfig.includes('hermes-hook.js')); + assert.ok(profileConfig.includes('profile: agent2')); + assert.ok(profileConfig.includes('pre_tool_call:')); + assert.ok(profileConfig.includes('hermes-hook.js')); + }); + + it('updates every top-level Hermes hooks section when duplicate keys exist', () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-duplicate-hooks-')); + const configPath = join(dir, '.hermes', 'config.yaml'); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, [ + 'theme: dark', + 'hooks:', + ' custom_event:', + ' - command: "echo keep"', + 'model: local', + 'hooks: {}', + '', + ].join('\n')); + + installAgentTemplates('hermes', { cwd: dir }); + + const config = readFileSync(configPath, 'utf8'); + assert.equal((config.match(/^hooks:$/gm) ?? []).length, 2); + assert.equal((config.match(/^ pre_tool_call:$/gm) ?? []).length, 2); + assert.ok(config.includes('custom_event:')); + }); + it('writes QClaw skill template and enables plugin', () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-qclaw-')); const result = installAgentTemplates('qclaw', { cwd: dir }); From c605a9b38f0aea0e3dc4509b6c02aa9059b95b18 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 22 May 2026 15:11:27 +0800 Subject: [PATCH 3/6] Exclude AgentGuard skill from checkup scans --- CHANGELOG.md | 1 + src/cli.ts | 26 ++++++++++++++++++++++++- src/tests/cli-checkup.test.ts | 36 ++++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17af5db..de38d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed - `agentguard init --agent` now normalizes agent names before validation, so mixed-case values such as `Hermes` initialize correctly. +- `agentguard checkup` now excludes the managed GoPlus AgentGuard skill from third-party skill scans so the guard does not report its own hook/checkup scripts as user risk. - `agentguard init --agent hermes` now recursively enables AgentGuard hooks in Hermes profile `config.yaml` files, including configs with empty `hooks: {}` blocks or duplicate top-level `hooks` keys. - 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. diff --git a/src/cli.ts b/src/cli.ts index 447f03d..e5d4ebe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -773,12 +773,36 @@ function discoverSkillDirs(roots: string[]): string[] { for (const entry of entries) { if (!entry.isDirectory()) continue; const dir = join(root, entry.name); - if (existsSync(join(dir, 'SKILL.md'))) dirs.push(dir); + if (!existsSync(join(dir, 'SKILL.md'))) continue; + if (isManagedAgentGuardSkillDir(dir)) continue; + dirs.push(dir); } } return dirs; } +function isManagedAgentGuardSkillDir(dir: string): boolean { + if (!/[/\\]agentguard$/i.test(dir)) return false; + const manifest = join(dir, 'SKILL.md'); + let body = ''; + try { + body = readFileSync(manifest, 'utf8').slice(0, 16 * 1024); + } catch { + return false; + } + const hasAgentGuardIdentity = /^name:\s*agentguard\s*$/im.test(body) && + /GoPlus AgentGuard|GoPlusSecurity/i.test(body); + if (!hasAgentGuardIdentity) return false; + const expectedScripts = [ + join(dir, 'scripts', 'guard-hook.js'), + join(dir, 'scripts', 'hermes-hook.js'), + join(dir, 'scripts', 'checkup-report.js'), + ]; + if (!expectedScripts.every((path) => existsSync(path))) return false; + + return true; +} + function checkCredentialSafety(skillDirs: string[]): CheckupDimension { let score = 100; const findings: CheckupFinding[] = []; diff --git a/src/tests/cli-checkup.test.ts b/src/tests/cli-checkup.test.ts index 681b190..855a876 100644 --- a/src/tests/cli-checkup.test.ts +++ b/src/tests/cli-checkup.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -50,6 +50,40 @@ describe('CLI checkup command modes', () => { assert.equal(parsed.results, undefined); }); + it('does not count the managed AgentGuard skill as a third-party risk', async () => { + const home = mkdtempSync(join(tmpdir(), 'ag-cli-checkup-')); + const skillDir = join(home, '.claude', 'skills', 'agentguard'); + mkdirSync(join(skillDir, 'scripts'), { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), [ + '---', + 'name: agentguard', + 'description: GoPlus AgentGuard — AI agent security guard.', + 'metadata:', + ' author: GoPlusSecurity', + '---', + '', + 'Allowed for runtime protection: read ~/.ssh/ and run shell hooks.', + '', + ].join('\n')); + writeFileSync(join(skillDir, 'scripts', 'guard-hook.js'), 'process.exit(0);\n'); + writeFileSync(join(skillDir, 'scripts', 'hermes-hook.js'), 'process.exit(0);\n'); + writeFileSync(join(skillDir, 'scripts', 'checkup-report.js'), 'process.exit(0);\n'); + + const result = await runCli(['checkup', '--json'], home, { HOME: home }); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + const parsed = JSON.parse(result.stdout) as { + skills_scanned: number; + dimensions: { code_safety: { findings: Array<{ text: string }> } }; + }; + assert.equal(parsed.skills_scanned, 0); + assert.deepEqual(parsed.dimensions.code_safety.findings, [{ + severity: 'LOW', + text: 'No installed third-party skills were found to audit.', + }]); + }); + it('plain checkup falls back to text output when the HTML report generator is not packaged', async () => { const home = mkdtempSync(join(tmpdir(), 'ag-cli-checkup-')); const missingScript = join(home, 'missing-checkup-report.js'); From 1005d21ef85424c33f50b5d97b53b194338b53fb Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 22 May 2026 15:25:46 +0800 Subject: [PATCH 4/6] Fix OpenClaw cron payload schema --- src/feed/cron.ts | 5 ----- src/tests/feed-cron.test.ts | 13 ++++--------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/feed/cron.ts b/src/feed/cron.ts index 130f2b4..af6760f 100644 --- a/src/feed/cron.ts +++ b/src/feed/cron.ts @@ -152,7 +152,6 @@ export async function installOpenClawThreatFeedCron( }; } - const mode = options.quiet ? 'quiet' : 'manual'; const description = `AgentGuard Cloud threat feed subscription (${schedule})`; const message = openClawCronMessage(options.quiet); @@ -175,10 +174,6 @@ export async function installOpenClawThreatFeedCron( kind: 'agentTurn', message, timeoutSeconds: 300, - agentguard: { - mode, - command, - }, }, delivery: { mode: 'announce', diff --git a/src/tests/feed-cron.test.ts b/src/tests/feed-cron.test.ts index 6b9cc41..4a650e1 100644 --- a/src/tests/feed-cron.test.ts +++ b/src/tests/feed-cron.test.ts @@ -112,10 +112,7 @@ describe('feed/cron', () => { 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 --cron-notify-run', - }); + assert.equal('agentguard' in job.payload, false); assert.match(job.payload.message, /Mode: manual/); assert.match(job.payload.message, /Command: `agentguard subscribe --cron-notify-run`/); assert.match(job.payload.message, /agentguard subscribe --cron-notify-run/); @@ -344,7 +341,8 @@ describe('feed/cron', () => { assert.equal(job.name, 'agentguard-threat-feed'); assert.deepEqual(job.schedule, { kind: 'cron', expr: '0 * * * *', tz: 'UTC' }); assert.deepEqual(job.delivery, { mode: 'announce', channel: 'last' }); - assert.equal(job.payload.agentguard.command, 'agentguard subscribe --cron-notify-run'); + assert.equal('agentguard' in job.payload, false); + assert.match(job.payload.message, /Command: `agentguard subscribe --cron-notify-run`/); }); it('auto-installs native Hermes cron jobs for Hermes agents', async () => { @@ -491,10 +489,7 @@ describe('feed/cron', () => { assert.deepEqual(gateway.calls.map((call) => call.method), ['cron.list', 'cron.remove', 'cron.add']); assert.deepEqual(gateway.calls[1].params, { jobId: 'job-1' }); 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 --cron-notify-run', - }); + assert.equal('agentguard' in gateway.calls[2].params.payload, false); assert.match(gateway.calls[2].params.payload.message, /Mode: quiet/); 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`/); From cbade29a3114fce8c0f87a4974bd7bcdeb96143f Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 22 May 2026 15:27:04 +0800 Subject: [PATCH 5/6] Update changelog for OpenClaw cron payload fix --- CHANGELOG.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de38d5c..9fa9376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Changed +- Feed subscription state now stores newest-first pull records with per-run `newSeenIds` and `foundIds` instead of a single object snapshot. + +### Fixed +- `agentguard checkup` now excludes the managed GoPlus AgentGuard skill from third-party skill scans so the guard does not report its own hook/checkup scripts as user risk. +- `agentguard init --agent hermes` now recursively enables AgentGuard hooks in Hermes profile `config.yaml` files, including configs with empty `hooks: {}` blocks or duplicate top-level `hooks` keys. +- Fixed OpenClaw/QClaw Gateway threat-feed cron installation to send only fields accepted by OpenClaw's `agentTurn` cron payload schema. + ## [1.1.13] - 2026-05-21 ### Added @@ -14,8 +24,6 @@ ### Fixed - `agentguard init --agent` now normalizes agent names before validation, so mixed-case values such as `Hermes` initialize correctly. -- `agentguard checkup` now excludes the managed GoPlus AgentGuard skill from third-party skill scans so the guard does not report its own hook/checkup scripts as user risk. -- `agentguard init --agent hermes` now recursively enables AgentGuard hooks in Hermes profile `config.yaml` files, including configs with empty `hooks: {}` blocks or duplicate top-level `hooks` keys. - 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. From 82f98da0e91dd65929066e5063691a7181607a9d Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 22 May 2026 15:40:36 +0800 Subject: [PATCH 6/6] Migrate legacy feed state format --- src/feed/state.ts | 24 +++++++++++++++++++++++- src/tests/feed-state.test.ts | 8 ++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/feed/state.ts b/src/feed/state.ts index 780a58d..f9f4594 100644 --- a/src/feed/state.ts +++ b/src/feed/state.ts @@ -20,7 +20,10 @@ export function loadFeedState(): FeedState { try { const raw = readFileSync(file, 'utf8'); const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return []; + if (!Array.isArray(parsed)) { + const migrated = normalizeLegacyState(parsed); + return migrated ? [migrated] : []; + } return parsed .map(normalizeEntry) .filter((entry): entry is FeedStateEntry => Boolean(entry)); @@ -61,6 +64,25 @@ function normalizeEntry(value: unknown): FeedStateEntry | null { }; } +function normalizeLegacyState(value: unknown): FeedStateEntry | null { + if (!value || typeof value !== 'object') return null; + const legacy = value as { + lastPulledAt?: unknown; + seenAdvisoryIds?: unknown; + foundIds?: unknown; + }; + const newSeenIds = uniqueStrings(legacy.seenAdvisoryIds); + if (newSeenIds.length === 0) return null; + const pulledAt = typeof legacy.lastPulledAt === 'string' && legacy.lastPulledAt.length > 0 + ? legacy.lastPulledAt + : new Date(0).toISOString(); + return { + pulledAt, + newSeenIds, + foundIds: uniqueStrings(legacy.foundIds), + }; +} + function uniqueStrings(value: unknown): string[] { if (!Array.isArray(value)) return []; return [ diff --git a/src/tests/feed-state.test.ts b/src/tests/feed-state.test.ts index f914d21..72e8b03 100644 --- a/src/tests/feed-state.test.ts +++ b/src/tests/feed-state.test.ts @@ -65,13 +65,17 @@ describe('feed/state', () => { }]); }); - it('does not migrate the old object state format', () => { + it('migrates the old object state format', () => { isolateHome(); writeFileSync(join(process.env.AGENTGUARD_HOME!, 'feed-state.json'), JSON.stringify({ lastPulledAt: '2026-05-13T00:00:00Z', seenAdvisoryIds: ['AGS-2026-1'], })); - assert.deepEqual(loadFeedState(), []); + assert.deepEqual(loadFeedState(), [{ + pulledAt: '2026-05-13T00:00:00Z', + newSeenIds: ['AGS-2026-1'], + foundIds: [], + }]); }); });