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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
69 changes: 47 additions & 22 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -772,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[] = [];
Expand Down
5 changes: 0 additions & 5 deletions src/feed/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -175,10 +174,6 @@ export async function installOpenClawThreatFeedCron(
kind: 'agentTurn',
message,
timeoutSeconds: 300,
agentguard: {
mode,
command,
},
},
delivery: {
mode: 'announce',
Expand Down
83 changes: 59 additions & 24 deletions src/feed/state.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,91 @@
/**
* 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');
}

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<FeedState>;
return {
lastPulledAt: parsed.lastPulledAt,
seenAdvisoryIds: parsed.seenAdvisoryIds ?? [],
};
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
const migrated = normalizeLegacyState(parsed);
return migrated ? [migrated] : [];
}
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),
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 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<FeedStateEntry>;
if (typeof entry.pulledAt !== 'string' || entry.pulledAt.length === 0) return null;
return {
pulledAt: entry.pulledAt,
newSeenIds: uniqueStrings(entry.newSeenIds),
foundIds: uniqueStrings(entry.foundIds),
};
writeFileSync(file, `${JSON.stringify(trimmed, null, 2)}\n`, { mode: 0o600 });
}

export function markAdvisorySeen(state: FeedState, advisoryId: string): FeedState {
const set = new Set(state.seenAdvisoryIds ?? []);
set.add(advisoryId);
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 {
...state,
seenAdvisoryIds: [...set],
pulledAt,
newSeenIds,
foundIds: uniqueStrings(legacy.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)),
];
}
17 changes: 11 additions & 6 deletions src/feed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading