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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added
- Added MCPB bundle assets and a reproducible MCP Desktop Extension build path, including the bundle manifest, AgentGuard directory icon, privacy policy metadata, production dependency staging, release workflow, build docs, and manifest tests. (#106)
- Added a native Hermes plugin that `agentguard init --agent hermes` installs and enables, with pre-tool blocking, post-tool audit handling, session-start skill scans, a `/agentguard` slash command, Hermes plugin docs, and Python plugin tests. (#107)

### 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.
Expand All @@ -18,6 +22,8 @@
- Runtime network policies now enforce `network.defaultOutbound` and `network.blockedDomains` for direct network/browser tool calls instead of only checking shell commands.
- Runtime blocked-domain matching now compares structured URL hosts and paths instead of raw substrings, avoiding false positives such as `notexample.com` matching `example.com`; curl/wget download-and-execute commands are detected with real regex patterns.
- Hermes hook templates now split `web_search` from URL-bearing web/browser tools and recognize open-style URL tools consistently.
- MCPB release builds now harden staging and packaging so the generated bundle stamps the package version, includes only production server dependencies, preserves required bundle metadata, and validates manifest expectations before publishing. (#106)
- Native Hermes plugin packaging now excludes test cache artifacts, validates required mapped fields, preserves command arguments consistently, and only allows the unsafe `npx` fallback when explicitly enabled. (#107)

## [1.1.27] - 2026-05-29

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ GoPlus AgentGuard follows the [Agent Skills](https://agentskills.io) open standa
|----------|---------|----------|
| **Claude Code** | Full | Skill + hooks auto-guard, transcript-based skill tracking |
| **OpenClaw** | Full | Plugin hooks + **auto-scan on load** + tool→plugin mapping + **daily patrol** |
| **Hermes Agent** | Plugin + Hooks | Native plugin (`hermes plugins enable agentguard`) for `pre_tool_call` / `post_tool_call`, or legacy shell hooks |
| **Hermes Agent** | Plugin + Hooks | Native plugin for `pre_tool_call` / `post_tool_call` (enabled by `agentguard init --agent hermes`), or legacy shell hooks |
| **OpenAI Codex CLI** | Skill | Scan/action/trust commands |
| **Gemini CLI** | Skill | Scan/action/trust commands |
| **Cursor** | Skill | Scan/action/trust commands |
Expand Down
8 changes: 4 additions & 4 deletions docs/hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ is identical; they differ only in how they are installed and managed.
## Native plugin (recommended)

The plugin is managed the Hermes-native way (`hermes plugins
enable/disable/list`), runs before shell hooks, adds a `/agentguard` slash
command, and needs no manual edits to `~/.hermes/config.yaml`.
enable/disable/list`), runs before shell hooks, and adds a `/agentguard`
slash command. `agentguard init --agent hermes` installs the plugin and enables
it in `~/.hermes/config.yaml`.

```bash
# Build the engine so the plugin can reach it
Expand All @@ -18,8 +19,7 @@ npm run build
# Install the plugin into ~/.hermes/plugins/agentguard/
agentguard init --agent hermes

# Hermes plugins are opt-in — enable it
hermes plugins enable agentguard
# Confirm it is enabled
hermes plugins list
```

Expand Down
2 changes: 1 addition & 1 deletion mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": "0.3",
"name": "agentguard",
"display_name": "GoPlus AgentGuard",
"version": "1.1.27",
"version": "1.1.28-beta.2",
"icon": "icon.png",
"description": "Security guard for AI agents — blocks malicious skills, prevents data leaks, protects secrets.",
"long_description": "GoPlus AgentGuard is an AI-agent security framework. It exposes MCP tools for scanning skills, looking up and managing a trust registry, evaluating runtime actions against policy, and simulating Web3 transactions. 20+ detection rules cover dangerous commands, secret exfiltration, and risky on-chain actions.",
Expand Down
10 changes: 5 additions & 5 deletions plugins/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ place.
# Installs the plugin into ~/.hermes/plugins/agentguard/
agentguard init --agent hermes

# Hermes plugins are opt-in — enable it:
hermes plugins enable agentguard
hermes plugins list # confirm it is enabled
# Confirm it is enabled:
hermes plugins list
```

Or copy this directory to `~/.hermes/plugins/agentguard/` manually.
Expand Down Expand Up @@ -58,8 +57,9 @@ Hermes `pre_tool_call` has no native "ask"/confirm decision, so AgentGuard's
| `AGENTGUARD_HERMES_ALLOW_NPX` | `0` | `1` permits the `npx -y @goplus/agentguard` fallback when no local binary is found. Off by default — `npx` fetches an unpinned package over the network, which is unsafe for a security gate. |
| `AGENTGUARD_HERMES_AUTOSCAN` | `1` | `0` disables the session-start skill scan. |

**Activation:** installing only copies files; the plugin is **inactive** until you
run `hermes plugins enable agentguard` (Hermes plugins are opt-in).
**Activation:** `agentguard init --agent hermes` installs the plugin and enables
it in `~/.hermes/config.yaml`. It takes effect on the next Hermes session. If you
copy this directory manually, run `hermes plugins enable agentguard`.

**Fail policy:** for the security-sensitive tools above, the plugin fails
**closed** (blocks) on `pre_tool_call` when the engine cannot be reached, and also
Expand Down
20 changes: 17 additions & 3 deletions scripts/build-mcpb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# manifest.json <- from mcpb/manifest.json, version stamped from package.json
# icon.png <- from mcpb/icon.png (512x512)
# server/
# dist/ <- compiled TypeScript (tsc output)
# dist/ <- compiled TypeScript runtime output
# node_modules/ <- production dependencies only
# package.json
# package-lock.json
Expand Down Expand Up @@ -47,8 +47,9 @@ if [ -n "${EXPECTED_VERSION:-}" ] && [ "${EXPECTED_VERSION#v}" != "$VERSION" ];
exit 1
fi

# 1. Clean install + compile
npm ci
# 1. Clean install + compile. Ignore lifecycle scripts here so release builds
# cannot mutate user/runner AgentGuard config via postinstall.
npm ci --ignore-scripts
npm run build

# 2. Fresh staging tree
Expand All @@ -63,6 +64,11 @@ cp mcpb/icon.png "$STAGE/icon.png"
cp -R dist "$STAGE/server/dist"
cp README.md LICENSE package.json package-lock.json "$STAGE/server/"

# Keep the desktop extension runtime-only: tests, type declarations, and source
# maps are useful in npm/source builds but should not ship in the installed MCPB.
rm -rf "$STAGE/server/dist/tests"
find "$STAGE/server/dist" -type f \( -name '*.map' -o -name '*.d.ts' \) -delete

# 5. Production dependencies only
( cd "$STAGE/server" && npm ci --omit=dev --ignore-scripts )

Expand All @@ -80,4 +86,12 @@ if [ -n "$UNEXPECTED" ]; then
exit 1
fi

DISALLOWED='^server/dist/tests/|^server/dist/.*(\.map|\.d\.ts)$'
DISALLOWED_FILES="$(unzip -Z1 "$OUT" | grep -v '/$' | grep -E "$DISALLOWED" || true)"
if [ -n "$DISALLOWED_FILES" ]; then
echo "ERROR: non-runtime TypeScript artifacts in bundle:" >&2
echo "$DISALLOWED_FILES" >&2
exit 1
fi

echo "==> Built $OUT (contents verified against allowlist)"
14 changes: 9 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ async function main() {
for (const result of results.installed) {
console.log(`Installed ${result.agent} template:`);
for (const file of result.files) console.log(`- ${file}`);
if (result.agent === 'hermes') printHermesNativePluginEnabled();
}
for (const failure of results.failed) {
console.error(`! Failed to initialize ${failure.agent}: ${failure.error}`);
Expand All @@ -116,11 +117,7 @@ async function main() {
console.log(`Installed ${result.agent} template:`);
for (const file of result.files) console.log(`- ${file}`);
if (agent === 'hermes' && !shellHooks) {
console.log('');
console.log('⚠ The AgentGuard plugin is INSTALLED but INACTIVE — no protection yet.');
console.log(' Activate it (takes effect on the next Hermes session):');
console.log(' hermes plugins enable agentguard');
console.log(' Or re-run with --shell-hooks to wire the always-on legacy shell hooks instead.');
printHermesNativePluginEnabled();
}
}
});
Expand Down Expand Up @@ -962,6 +959,13 @@ function printInstalledGuidance(): void {
console.log('Run `agentguard --help` to see all commands.');
}

function printHermesNativePluginEnabled(): void {
console.log('');
console.log('Hermes native plugin enabled in config.yaml.');
console.log('It takes effect on the next Hermes session.');
console.log('Use `hermes plugins list` to verify, or re-run with --shell-hooks for the legacy shell-hook flow.');
}

function printInitGuidanceIfNeeded(config: AgentGuardConfig): void {
if (hasSavedAgentHost(config)) return;
console.log('');
Expand Down
109 changes: 106 additions & 3 deletions src/installers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ function installHermes(cwd: string | undefined, force: boolean, opts: { shellHoo
}
files.push(...configPaths);
} else {
// Default path: install the native Hermes plugin (opt-in to enable via
// `hermes plugins enable agentguard`). No config.yaml edits.
// Default path: install and enable the native Hermes plugin.
const pluginDir = join(hermesRoot, 'plugins', 'agentguard');
const configPath = join(hermesRoot, 'config.yaml');
copyBundledHermesPlugin(pluginDir, force);
files.push(pluginDir);
enableHermesNativePlugin(configPath);
files.push(pluginDir, configPath);
}

return { agent: 'hermes', files };
Expand Down Expand Up @@ -130,6 +131,108 @@ function copyBundledHermesPlugin(targetDir: string, force: boolean): void {
}
}

function enableHermesNativePlugin(configPath: string): void {
const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
const next = mergeHermesNativePluginEnabled(existing);
if (next === existing) return;
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, next);
}

function mergeHermesNativePluginEnabled(existing: string): string {
const lines = existing.replace(/\s+$/g, '').split(/\r?\n/).filter((line, index, arr) => !(arr.length === 1 && index === 0 && line === ''));
const merged: string[] = [];
let sawPlugins = false;

for (let index = 0; index < lines.length;) {
if (isTopLevelHermesPluginsLine(lines[index])) {
sawPlugins = true;
const pluginsEnd = findNextTopLevelIndex(lines, index + 1);
merged.push('plugins:');
merged.push(...enableHermesPluginInPluginsBlock(lines.slice(index + 1, pluginsEnd)));
index = pluginsEnd;
continue;
}
merged.push(lines[index]);
index += 1;
}

if (!sawPlugins) {
if (merged.length > 0) merged.push('');
merged.push('plugins:', ' enabled:', ' - agentguard');
}

return `${merged.join('\n').replace(/\s+$/g, '')}\n`;
}

function isTopLevelHermesPluginsLine(line: string): boolean {
return /^plugins:\s*(?:\{\}\s*)?(?:#.*)?$/.test(line);
}

function enableHermesPluginInPluginsBlock(lines: string[]): string[] {
const enabledPlugins = uniqueStrings([...readHermesEnabledPlugins(lines), 'agentguard']);
const kept = removeHermesPluginEnabled(lines);
return [' enabled:', ...enabledPlugins.map((plugin) => ` - ${plugin}`), ...kept];
}

function removeHermesPluginEnabled(lines: string[]): string[] {
const kept: string[] = [];
for (let index = 0; index < lines.length;) {
const match = /^ enabled:\s*(?:#.*)?$/.exec(lines[index]);
if (match) {
index += 1;
while (index < lines.length && !/^ [A-Za-z0-9_-]+:\s*(?:#.*)?$/.test(lines[index]) && !/^\S/.test(lines[index])) {
index += 1;
}
continue;
}

const inlineList = /^ enabled:\s*\[(.*)\]\s*(?:#.*)?$/.exec(lines[index]);
if (inlineList) {
index += 1;
continue;
}

kept.push(lines[index]);
index += 1;
}
return kept;
}

function readHermesEnabledPlugins(lines: string[]): string[] {
const names: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const inlineList = /^ enabled:\s*\[(.*)\]\s*(?:#.*)?$/.exec(lines[index]);
if (inlineList) {
for (const item of inlineList[1].split(',')) {
const name = parseHermesYamlScalar(item);
if (name) names.push(name);
}
continue;
}

if (!/^ enabled:\s*(?:#.*)?$/.test(lines[index])) continue;
index += 1;
while (index < lines.length && !/^ [A-Za-z0-9_-]+:\s*(?:#.*)?$/.test(lines[index]) && !/^\S/.test(lines[index])) {
const item = /^ -\s*(.+?)\s*(?:#.*)?$/.exec(lines[index]);
const name = item ? parseHermesYamlScalar(item[1]) : '';
if (name) names.push(name);
index += 1;
}
index -= 1;
}
return names;
}

function parseHermesYamlScalar(value: string | undefined): string {
const trimmed = (value || '').trim();
if (!trimmed) return '';
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}

function installQClaw(root: string, force: boolean): InstallResult {
const qclawRoot = join(root, '.qclaw');
const configPath = join(qclawRoot, 'qclaw.json');
Expand Down
6 changes: 5 additions & 1 deletion src/tests/cli-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ describe('init CLI', () => {
assert.equal(config.agentHost, agent);
if (agent === 'hermes') {
assert.ok(existsSync(join(hermesHome, 'plugins', 'agentguard', 'plugin.yaml')));
assert.ok(readFileSync(join(hermesHome, 'config.yaml'), 'utf8').includes('- agentguard'));
}
}
});
Expand All @@ -264,7 +265,8 @@ describe('init CLI', () => {
assert.equal(config.agentHost, 'hermes');
assert.match(stdout, /Installed hermes template:/);
assert.ok(stdout.includes(join(hermesHome, 'plugins', 'agentguard')));
assert.match(stdout, /hermes plugins enable agentguard/);
assert.match(stdout, /Hermes native plugin enabled in config\.yaml/);
assert.ok(readFileSync(join(hermesHome, 'config.yaml'), 'utf8').includes('- agentguard'));
});

it('auto-initializes detected agents in detection order', async () => {
Expand All @@ -289,10 +291,12 @@ describe('init CLI', () => {
assert.ok(existsSync(join(cwd, '.openclaw', 'plugins', 'agentguard', 'openclaw.plugin.json')));
assert.ok(existsSync(join(cwd, '.hermes', 'skills', 'agentguard')));
assert.ok(existsSync(join(cwd, '.hermes', 'plugins', 'agentguard', 'plugin.yaml')));
assert.ok(readFileSync(join(cwd, '.hermes', 'config.yaml'), 'utf8').includes('- agentguard'));
assert.ok(existsSync(join(cwd, '.codex', 'skills', 'agentguard', 'SKILL.md')));
assert.ok(existsSync(join(cwd, '.codex', 'agentguard-hook.json')));
assert.match(stdout, /Installed openclaw template:/);
assert.match(stdout, /Installed hermes template:/);
assert.match(stdout, /Hermes native plugin enabled in config\.yaml/);
assert.match(stdout, /Installed codex template:/);
});

Expand Down
32 changes: 29 additions & 3 deletions src/tests/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ describe('Agent template installers', () => {
assert.ok(readFileSync(join(dir, '.codex', 'agentguard-hook.json'), 'utf8').includes('AGENTGUARD_AGENT_HOST=codex'));
});

it('installs the native Hermes plugin by default (no config.yaml edits)', () => {
it('installs and enables the native Hermes plugin by default', () => {
const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-plugin-'));
const result = installAgentTemplates('hermes', { cwd: dir });
const pluginDir = join(dir, '.hermes', 'plugins', 'agentguard');
const configPath = join(dir, '.hermes', 'config.yaml');
const config = readFileSync(configPath, 'utf8');

assert.equal(result.agent, 'hermes');
assert.ok(existsSync(join(pluginDir, 'plugin.yaml')));
Expand All @@ -36,10 +38,34 @@ describe('Agent template installers', () => {
// Tests are excluded from the bundled copy.
assert.ok(!existsSync(join(pluginDir, 'tests')));
assert.ok(result.files.includes(pluginDir));
assert.ok(result.files.includes(configPath));
// The bundled skill is still installed for the engine fallback / auto-scan.
assert.ok(existsSync(join(dir, '.hermes', 'skills', 'agentguard', 'SKILL.md')));
// Default mode must not write shell hooks into config.yaml.
assert.ok(!existsSync(join(dir, '.hermes', 'config.yaml')));
assert.match(config, /^plugins:\n enabled:\n - agentguard\n$/);
assert.ok(!config.includes('pre_tool_call:'));
});

it('preserves existing Hermes native plugin config while enabling AgentGuard', () => {
const dir = mkdtempSync(join(tmpdir(), 'agentguard-hermes-plugin-existing-'));
const configPath = join(dir, '.hermes', 'config.yaml');
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, [
'theme: dark',
'plugins:',
' enabled:',
' - other-plugin',
' disabled:',
' - old-plugin',
'',
].join('\n'));

installAgentTemplates('hermes', { cwd: dir });

const config = readFileSync(configPath, 'utf8');
assert.ok(config.includes('theme: dark'));
assert.ok(config.includes(' enabled:\n - other-plugin\n - agentguard'));
assert.ok(config.includes(' disabled:\n - old-plugin'));
assert.ok(!config.includes('pre_tool_call:'));
});

it('writes Hermes skill and enables hook config with --shell-hooks', () => {
Expand Down
Loading
Loading