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 @@ -5,6 +5,7 @@
### Changed
- Web search actions now use a dedicated `web_search` runtime action across Claude Code, Hermes, OpenClaw, MCP, and the skill CLI, so query-only searches are handled separately from URL fetches and no longer trigger invalid-URL network approval flows.
- Direct web fetch and browser navigation GET requests keep the default `network.defaultOutbound: warn` behavior as audit-only, while mutating or high-risk network requests still require confirmation or blocking.
- `agentguard connect` and `agentguard subscribe` now support Hermes Agent JWT registration when Hermes is initialized or detected via `HERMES_HOME`/`~/.hermes`, while preserving the existing OpenClaw notification behavior.

### Fixed
- `agentguard init --agent hermes` now targets `HERMES_HOME` or `~/.hermes` for explicit installs instead of creating a nested `.hermes` directory under the current working directory, while only updating the root Hermes config and profile configs.
Expand Down
46 changes: 34 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ async function main() {
const apiKey = options.key || options.apiKey || process.env.AGENTGUARD_API_KEY;
if (!apiKey) {
let config = ensureConfig();
if (!isOpenClawAgentConfigured(config)) {
throw new Error('AgentGuard Cloud connect supports API-key auth or OpenClaw Agent JWT registration. No API key was provided, and OpenClaw has not been initialized. Run `agentguard init --agent openclaw`, then rerun `agentguard connect`; or pass --key, --api-key, or AGENTGUARD_API_KEY for API-key auth.');
if (!isAgentJwtHostConfigured(config)) {
throw new Error('AgentGuard Cloud connect supports API-key auth or Agent JWT registration for OpenClaw and Hermes. No API key was provided, and no supported Agent JWT host has been initialized. Run `agentguard init --agent openclaw` or `agentguard init --agent hermes`, then rerun `agentguard connect`; or pass --key, --api-key, or AGENTGUARD_API_KEY for API-key auth.');
}
config = withDetectedOpenClawAgentHost(config);
config = withDetectedAgentJwtHost(config);
const cloudUrl = normalizeCloudUrl(options.cloud || options.url || config.cloudUrl || 'https://agentguard.gopluslabs.io');
if (config.agentId && config.agentJwt) {
const existingConfig = { ...config, cloudUrl };
Expand Down Expand Up @@ -465,8 +465,8 @@ async function main() {

let registration: AgentCredentialRegistration | null = null;
if (!client.connected) {
if (!isOpenClawAgentConfigured(config)) {
const message = 'AgentGuard Cloud is not connected. Run `agentguard connect --key <key>` first, or run `agentguard init --agent openclaw` to use Agent JWT registration.';
if (!isAgentJwtHostConfigured(config)) {
const message = 'AgentGuard Cloud is not connected. Run `agentguard connect --key <key>` first, or run `agentguard init --agent openclaw` or `agentguard init --agent hermes` to use Agent JWT registration.';
if (cronNotifyRun) {
console.log('NO_REPLY');
} else if (options.json) {
Expand Down Expand Up @@ -502,7 +502,7 @@ async function main() {
await client.subscribeFeed();
} catch (err) {
if (err instanceof CloudRequestError && err.status === 401) {
if (!isOpenClawAgentConfigured(config)) {
if (!isAgentJwtHostConfigured(config)) {
console.error('! AgentGuard Cloud credential was rejected. Run `agentguard connect --key <key>` again.');
process.exitCode = 1;
return;
Expand Down Expand Up @@ -544,7 +544,7 @@ async function main() {
process.exitCode = 1;
return;
}
if (!isOpenClawAgentConfigured(config)) {
if (!isAgentJwtHostConfigured(config)) {
console.error('! AgentGuard Cloud credential was rejected. Run `agentguard connect --key <key>` again.');
process.exitCode = 1;
return;
Expand Down Expand Up @@ -1381,7 +1381,7 @@ async function runCloudRequestWithAgentJwtReauth<T>(options: {
if (
!(err instanceof CloudRequestError && err.status === 401) ||
!options.config.agentJwt ||
!isOpenClawAgentConfigured(options.config)
!isAgentJwtHostConfigured(options.config)
) {
throw err;
}
Expand Down Expand Up @@ -1465,12 +1465,26 @@ function isOpenClawAgentConfigured(config: AgentGuardConfig): boolean {
return config.agentHost === 'openclaw' || config.agentHosts?.includes('openclaw') === true || detectOpenClawRuntime();
}

function withDetectedOpenClawAgentHost(config: AgentGuardConfig): AgentGuardConfig {
if (hasSavedAgentHost(config) || !detectOpenClawRuntime()) return config;
function isHermesAgentConfigured(config: AgentGuardConfig): boolean {
return config.agentHost === 'hermes' || config.agentHosts?.includes('hermes') === true || detectHermesRuntime();
}

function isAgentJwtHostConfigured(config: AgentGuardConfig): boolean {
return isOpenClawAgentConfigured(config) || isHermesAgentConfigured(config);
}

function withDetectedAgentJwtHost(config: AgentGuardConfig): AgentGuardConfig {
if (hasSavedAgentHost(config)) return config;
if (detectOpenClawRuntime()) return withDetectedAgentHost(config, 'openclaw');
if (detectHermesRuntime()) return withDetectedAgentHost(config, 'hermes');
return config;
}

function withDetectedAgentHost(config: AgentGuardConfig, agentHost: AgentGuardAgentHost): AgentGuardConfig {
const next: AgentGuardConfig = {
...config,
agentHost: 'openclaw',
agentHosts: appendAgentHost(config.agentHosts, 'openclaw'),
agentHost,
agentHosts: appendAgentHost(config.agentHosts, agentHost),
};
saveConfig(next);
return next;
Expand All @@ -1486,6 +1500,14 @@ function detectOpenClawRuntime(): boolean {
return existsSync(join(homedir(), '.openclaw', 'openclaw.json'));
}

function detectHermesRuntime(): boolean {
const hermesHome = process.env.HERMES_HOME?.trim();
if (hermesHome && (existsSync(hermesHome) || existsSync(join(hermesHome, 'config.yaml')))) return true;

const defaultHome = join(homedir(), '.hermes');
return existsSync(join(defaultHome, 'config.yaml')) || existsSync(defaultHome);
}

function resolveOpenClawGatewayOptionsFromEnv(): OpenClawGatewayOptions {
const url = process.env.AGENTGUARD_OPENCLAW_GATEWAY_URL?.trim();
const host = process.env.AGENTGUARD_OPENCLAW_GATEWAY_HOST?.trim();
Expand Down
93 changes: 90 additions & 3 deletions src/tests/cli-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ const projectRoot = resolve(__dirname, '..', '..');
const CLI_PATH = join(projectRoot, 'dist', 'cli.js');
const ISOLATED_OPENCLAW_ENV = {
AGENTGUARD_OPENCLAW_GATEWAY_URL: '',
AGENTGUARD_OPENCLAW_GATEWAY_HOST: '',
AGENTGUARD_OPENCLAW_GATEWAY_HOST: '127.0.0.1',
AGENTGUARD_OPENCLAW_GATEWAY_TOKEN: '',
AGENTGUARD_OPENCLAW_GATEWAY_PORT: '',
AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '',
AGENTGUARD_OPENCLAW_GATEWAY_PORT: '9',
AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '200',
OPENCLAW_CONFIG_PATH: '',
OPENCLAW_STATE_DIR: '',
HERMES_HOME: '',
};

function runCli(
Expand Down Expand Up @@ -338,6 +339,71 @@ describe('CLI connect Agent JWT mode', () => {
assert.match(result.stderr, /init --agent openclaw/);
});

it('uses Hermes Agent JWT registration when Hermes has been initialized', async () => {
const requests: Array<{ url?: string; method?: string; body?: any }> = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
requests.push({ url: req.url, method: req.method, body: body ? JSON.parse(body) : undefined });
if (req.method === 'POST' && req.url === '/api/agent/register') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
success: true,
data: {
agentId: 'agt_hermes_cli_test',
jwt: 'agent.jwt.hermes-cli-test',
registerUrl: 'https://agentguard.example/activate?token=hermes-cli-test',
},
}));
return;
}
res.statusCode = 404;
res.end(JSON.stringify({ success: false }));
});
});
await new Promise<void>((resolvePromise) => server.listen(0, '127.0.0.1', resolvePromise));
try {
const address = server.address();
assert.ok(address && typeof address === 'object');
const cloudUrl = `http://127.0.0.1:${(address as AddressInfo).port}`;
const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-hermes-'));
writeFileSync(join(home, 'config.json'), JSON.stringify({
version: 1,
level: 'balanced',
cloudUrl,
agentHost: 'hermes',
agentHosts: ['hermes'],
policyCachePath: join(home, 'policy-cache.json'),
auditPath: join(home, 'audit.jsonl'),
eventSpoolPath: join(home, 'events-spool.jsonl'),
}));

const result = await runCli(['connect', '--url', cloudUrl], home);

assert.equal(result.exitCode, 0);
assert.equal(result.stderr, '');
assert.match(result.stdout, /Registered local AgentGuard agent \(agt_hermes_cli_test\)/);
assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=hermes-cli-test/);
assert.equal(requests[0].body.metadata.agentHost, 'hermes');
assert.deepEqual(requests[0].body.metadata.agentHosts, ['hermes']);
const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as {
agentId?: string;
agentJwt?: string;
agentRegisterUrl?: string;
agentHost?: string;
};
assert.equal(config.agentHost, 'hermes');
assert.equal(config.agentId, 'agt_hermes_cli_test');
assert.equal(config.agentJwt, 'agent.jwt.hermes-cli-test');
assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=hermes-cli-test');
} finally {
await new Promise<void>((resolvePromise) => server.close(() => resolvePromise()));
}
});

it('uses detected OpenClaw runtime for no-key connect before requiring an API key', async () => {
const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-openclaw-env-'));
const openClawState = join(home, '.openclaw');
Expand All @@ -358,4 +424,25 @@ describe('CLI connect Agent JWT mode', () => {
assert.equal(config.agentHost, 'openclaw');
assert.deepEqual(config.agentHosts, ['openclaw']);
});

it('uses detected Hermes runtime for no-key connect before requiring an API key', async () => {
const home = mkdtempSync(join(tmpdir(), 'ag-cli-connect-hermes-env-'));
const hermesHome = join(home, '.hermes');
mkdirSync(hermesHome, { recursive: true });
writeFileSync(join(hermesHome, 'config.yaml'), 'hooks: {}\n');

const result = await runCli(['connect', '--url', 'http://127.0.0.1:9'], home, {
HERMES_HOME: hermesHome,
});
const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as {
agentHost?: string;
agentHosts?: string[];
};

assert.equal(result.exitCode, 1);
assert.doesNotMatch(result.stderr, /Missing API key/);
assert.match(result.stderr, /Could not register AgentGuard agent/);
assert.equal(config.agentHost, 'hermes');
assert.deepEqual(config.agentHosts, ['hermes']);
});
});
17 changes: 13 additions & 4 deletions src/tests/cli-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import { promisify } from 'node:util';
import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js';

const execFileAsync = promisify(execFile);
const ISOLATED_OPENCLAW_ENV = {
AGENTGUARD_OPENCLAW_GATEWAY_URL: '',
AGENTGUARD_OPENCLAW_GATEWAY_HOST: '127.0.0.1',
AGENTGUARD_OPENCLAW_GATEWAY_TOKEN: '',
AGENTGUARD_OPENCLAW_GATEWAY_PORT: '9',
AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '200',
OPENCLAW_CONFIG_PATH: '',
OPENCLAW_STATE_DIR: '',
};

describe('policy CLI', () => {
it('shows the cached effective policy as JSON', async () => {
Expand All @@ -30,7 +39,7 @@ describe('policy CLI', () => {

const cliPath = resolve('dist/cli.js');
const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'show', '--json'], {
env: { ...process.env, AGENTGUARD_HOME: home },
env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home },
});

const result = JSON.parse(stdout) as {
Expand Down Expand Up @@ -60,7 +69,7 @@ describe('policy CLI', () => {

const cliPath = resolve('dist/cli.js');
const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'show', '--json'], {
env: { ...process.env, AGENTGUARD_HOME: home },
env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home },
});

const result = JSON.parse(stdout) as {
Expand Down Expand Up @@ -108,7 +117,7 @@ describe('policy CLI', () => {

const cliPath = resolve('dist/cli.js');
const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'pull', '--json'], {
env: { ...process.env, AGENTGUARD_HOME: home },
env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home },
});

const result = JSON.parse(stdout) as { success: boolean; policyVersion: string; cachePath: string };
Expand Down Expand Up @@ -180,7 +189,7 @@ describe('policy CLI', () => {

const cliPath = resolve('dist/cli.js');
const { stdout } = await execFileAsync(process.execPath, [cliPath, 'policy', 'pull', '--json'], {
env: { ...process.env, AGENTGUARD_HOME: home },
env: { ...process.env, ...ISOLATED_OPENCLAW_ENV, AGENTGUARD_HOME: home },
});

const result = JSON.parse(stdout) as { success: boolean; policyVersion: string };
Expand Down
90 changes: 87 additions & 3 deletions src/tests/cli-subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ const projectRoot = resolve(__dirname, '..', '..');
const CLI_PATH = join(projectRoot, 'dist', 'cli.js');
const ISOLATED_OPENCLAW_ENV = {
AGENTGUARD_OPENCLAW_GATEWAY_URL: '',
AGENTGUARD_OPENCLAW_GATEWAY_HOST: '',
AGENTGUARD_OPENCLAW_GATEWAY_HOST: '127.0.0.1',
AGENTGUARD_OPENCLAW_GATEWAY_TOKEN: '',
AGENTGUARD_OPENCLAW_GATEWAY_PORT: '',
AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '',
AGENTGUARD_OPENCLAW_GATEWAY_PORT: '9',
AGENTGUARD_OPENCLAW_GATEWAY_TIMEOUT_MS: '200',
OPENCLAW_CONFIG_PATH: '',
OPENCLAW_STATE_DIR: '',
HERMES_HOME: '',
};

function runCli(
Expand Down Expand Up @@ -224,6 +225,89 @@ describe('CLI subscribe command modes', () => {
}
});

it('registers a Hermes local agent before subscribing when no Cloud credential exists', async () => {
const requests: Array<{ url?: string; method?: string; authorization?: string; body?: any }> = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
requests.push({
url: req.url,
method: req.method,
authorization: req.headers.authorization,
body: body ? JSON.parse(body) : undefined,
});
res.setHeader('content-type', 'application/json');
if (req.method === 'POST' && req.url === '/api/agent/register') {
res.end(JSON.stringify({
success: true,
data: {
agentId: 'agt_hermes_subscribe',
jwt: 'agent.jwt.hermes-subscribe',
registerUrl: 'https://agentguard.example/activate?token=hermes-subscribe',
},
}));
return;
}
if (req.method === 'POST' && req.url === '/api/v1/feed/subscribe') {
res.end(JSON.stringify({ success: true, data: { id: 'sub_hermes', status: 'active' } }));
return;
}
if (req.method === 'GET' && req.url?.startsWith('/api/v1/feed/advisories')) {
res.end(JSON.stringify({ success: true, data: { advisories: [] } }));
return;
}
res.statusCode = 404;
res.end(JSON.stringify({ success: false }));
});
});
await new Promise<void>((resolvePromise) => server.listen(0, '127.0.0.1', resolvePromise));
try {
const address = server.address();
assert.ok(address && typeof address === 'object');
const cloudUrl = `http://127.0.0.1:${(address as AddressInfo).port}`;
const home = mkdtempSync(join(tmpdir(), 'ag-cli-subscribe-hermes-register-'));
mkdirSync(home, { recursive: true });
writeFileSync(join(home, 'config.json'), JSON.stringify({
version: 1,
level: 'balanced',
cloudUrl,
agentHost: 'hermes',
agentHosts: ['hermes'],
policyCachePath: join(home, 'policy-cache.json'),
auditPath: join(home, 'audit.jsonl'),
eventSpoolPath: join(home, 'events-spool.jsonl'),
}));

const result = await runCliNoConfigWrite(['subscribe', '--json'], home);

assert.equal(result.exitCode, 0);
assert.equal(result.stderr, '');
assert.deepEqual(requests.map((request) => `${request.method} ${request.url}`), [
'POST /api/agent/register',
'POST /api/v1/feed/subscribe',
'GET /api/v1/feed/advisories',
]);
assert.equal(requests[0].body.metadata.agentHost, 'hermes');
assert.equal(requests[1].authorization, 'Bearer agent.jwt.hermes-subscribe');
assert.equal(requests[2].authorization, 'Bearer agent.jwt.hermes-subscribe');
const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as {
agentId?: string;
agentJwt?: string;
agentRegisterUrl?: string;
agentHost?: string;
};
assert.equal(config.agentHost, 'hermes');
assert.equal(config.agentId, 'agt_hermes_subscribe');
assert.equal(config.agentJwt, 'agent.jwt.hermes-subscribe');
assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=hermes-subscribe');
} finally {
await new Promise<void>((resolvePromise) => server.close(() => resolvePromise()));
}
});

it('cron internal subscribe runs pull advisories without subscribing first', async () => {
for (const args of [
['subscribe', '--json', '--cron-run'],
Expand Down
Loading