Skip to content
Open
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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"commander": "12.1.0",
"glob": "13.0.6",
"open": "10.2.0",
"yaml": "^2.6.1",
"zod": "3.25.76"
},
"devDependencies": {
Expand Down
82 changes: 82 additions & 0 deletions plugins/goose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# GoPlus AgentGuard — Goose integration (advisory, MCP-based)

Goose ([block/goose](https://github.com/block/goose)) is **not** integrated
the same way as Cline, Hermes, or Continue. This README explains the
limitation honestly and documents the install paths that ship today.

## TL;DR

```bash
npm i -g @goplus/agentguard
agentguard init --agent goose
```

That writes `agentguard` as an MCP extension in `~/.config/goose/config.yaml`:

```yaml
extensions:
agentguard:
type: stdio
command: agentguard-mcp
args: []
timeout: 300
enabled: true
description: GoPlus AgentGuard MCP — security scanner + action evaluator
```

Restart Goose. AgentGuard's scanner and action evaluator are now MCP-callable.

## Important: this is advisory, not a hard gate

Goose currently has **no out-of-process plugin API** for intercepting tool
calls. The only true `Allow / Deny / RequireApproval` surface is the in-process
Rust `ToolInspector` trait (`crates/goose/src/tool_inspection.rs`), which is
compile-time — there is no dynamic loader for it.

What an MCP extension can do:

- **Provide tools the model may choose to call** (`scan_skill`, `evaluate_action`, etc.)
- **Refuse to return data** when called

What an MCP extension **cannot** do:

- Sit on the execution path of every Goose tool call
- Block a `developer__shell` call the model never routed through AgentGuard

In other words, if the agent decides to call `shell` with `rm -rf /` and
never asks AgentGuard's MCP tools first, AgentGuard never gets a chance to
veto it. The model is free to skip you. **Treat this as defense-in-depth,
not a security boundary.**

For a real hard gate, see [UPSTREAM_PROPOSAL.md](./UPSTREAM_PROPOSAL.md) — a
draft proposal to add a `SECURITY_INSPECTOR_ENDPOINT` webhook to Goose,
mirroring its existing `SECURITY_PROMPT_CLASSIFIER_ENDPOINT`. That would
turn AgentGuard into a genuine pre-execution inspector with `block` /
`require_approval` returns.

## What the MCP integration is useful for

- **Pre-commit / pre-PR scanning** of skills the agent wants to install
- **Action evaluation** when the agent explicitly asks "is this dangerous?"
- **Audit logging** of evaluations the model did make through AgentGuard

These are real value-adds even without a hard gate, but be clear with users
about the boundary.

## Manual install

If you'd rather not run `agentguard init --agent goose`, copy the snippet
above into your existing `~/.config/goose/config.yaml` under `extensions:`
(or create the file if it doesn't exist). On Windows the path is
`%APPDATA%\Block\goose\config\config.yaml`.

The installer preserves any prior `extensions:` entries and is idempotent —
re-running won't duplicate the block.

## Reference

- Goose extensions / MCP: https://block.github.io/goose/docs/getting-started/installation
- ToolInspector trait (compile-time): `crates/goose/src/tool_inspection.rs`
- Existing classifier endpoint (the model we're proposing to copy):
`crates/goose/src/security/classification_client.rs` +
`SECURITY_PROMPT_CLASSIFIER_ENDPOINT`
118 changes: 118 additions & 0 deletions plugins/goose/UPSTREAM_PROPOSAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Goose upstream proposal — generic security webhook inspector

This document drafts a proposal to file against [block/goose](https://github.com/block/goose)
that would give third-party security tools (AgentGuard, but also others) a
real out-of-process hard gate on tool execution.

This is **not** a PR yet — it's the design that should accompany one.

## Problem

Goose currently has two ways to wire in security policy on tool calls:

1. **`ToolInspector` trait** (Rust, in-process, compile-time). The real
`Allow / Deny / RequireApproval` decision surface, but there is no
dynamic loader for it — third parties cannot ship one without forking
and rebuilding Goose.
2. **MCP extensions**. Model-callable. The agent can skip your tools, so
this is advisory only — it is not a security boundary.

This means there is no supported way today for an external security service
(static analyzer, runtime engine, anomaly detector) to gate Goose's tool
execution without forking the binary.

## Prior art inside Goose

Goose already accepts an external HTTP endpoint for one specific
classification job. From `crates/goose/src/security/classification_client.rs`
and the config:

```
SECURITY_PROMPT_CLASSIFIER_ENDPOINT
SECURITY_PROMPT_ENABLED
```

The classifier endpoint receives JSON, returns a classification, and Goose
honors the result. This is the pattern we're asking to generalize.

## Proposal

Add a new `WebhookToolInspector` that registers automatically when a
configured endpoint is set:

```yaml
# ~/.config/goose/config.yaml
SECURITY_INSPECTOR_ENABLED: true
SECURITY_INSPECTOR_ENDPOINT: "http://127.0.0.1:7777/inspect"
SECURITY_INSPECTOR_TIMEOUT_MS: 1500
SECURITY_INSPECTOR_FAIL_OPEN: false # default: fail-closed
SECURITY_INSPECTOR_AUTH_HEADER: "X-Token: …"
```

At startup, `ToolInspectionManager::add_inspector` registers a built-in
`WebhookToolInspector` if `SECURITY_INSPECTOR_ENABLED == true`. It calls the
endpoint for every `ToolRequest`, with a JSON body like:

```json
{
"session_id": "sess_…",
"tool_requests": [
{ "tool": "developer__shell", "input": { "command": "rm -rf /" }, "call_id": "…" }
],
"messages": [ /* optional, truncated */ ],
"goose_mode": "auto"
}
```

And expects back:

```json
{
"results": [
{ "call_id": "…",
"action": "Deny",
"reason": "destructive command",
"policy_version": "…" }
]
}
```

`action` is one of `Allow`, `Deny`, `RequireApproval`. The webhook's response
maps 1:1 to `InspectionAction` — no impedance mismatch with the existing
trait.

## Why this is a small change

- It doesn't change the `ToolInspector` trait. The new inspector is just
another implementer.
- It mirrors a pattern Goose already accepts (the classifier endpoint), so
it should pass the "is this in scope for this project?" smell test.
- Fail-open vs fail-closed is configurable, with a security-safe default
(fail-closed).
- No new permissions to plumb through the trust system; existing
`ToolInspectionManager` handles aggregation.

## What we'd ship in the PR

1. `crates/goose/src/security/webhook_inspector.rs` — `WebhookToolInspector` impl
2. Config keys + parsing in `crates/goose/src/config/`
3. Registration in `crates/goose/src/agents/` startup
4. Tests (mocking the endpoint with `wiremock` or similar)
5. A docs page in `docs/security/` covering trade-offs and recommended deploys

## Open questions to confirm with maintainers before opening the PR

- Naming: `SECURITY_INSPECTOR_*` vs `TOOL_INSPECTOR_*` vs scoped to a single
vendor key. We'd prefer the generic name so other security vendors can use
the same surface.
- Should the webhook also see `PostToolUse` events for audit-only logging,
or is pre-execution enough? (Our preference: pre-only, keep the surface
minimal; audit lives elsewhere.)
- Should responses be allowed to mutate the tool input (`overrideInput`),
as Cline and Continue hooks do? Probably no for v1 — keeps the threat
model simple.

---

**Status:** draft. We (GoPlus) intend to file an issue first to gauge
maintainer interest before opening a PR. Anyone in this repo can take it on.
68 changes: 62 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,18 @@ import {
type CronBackend,
type ThreatFeedCronRemovalResult,
type OpenClawGatewayOptions,
type CronAgentHost,
} from './feed/cron.js';

const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw'];
const SUPPORTED_AGENT_INSTALLERS: AgentInstaller[] = ['claude-code', 'codex', 'openclaw', 'hermes', 'qclaw', 'goose'];
const AUTO_AGENT_DETECTION: Array<{ agent: AgentInstaller; dir: string }> = [
{ agent: 'claude-code', dir: '.claude' },
{ agent: 'openclaw', dir: '.openclaw' },
{ agent: 'hermes', dir: '.hermes' },
{ agent: 'qclaw', dir: '.qclaw' },
{ agent: 'codex', dir: '.codex' },
// 'goose' is intentionally NOT in auto-detection: config lives at
// ~/.config/goose/ (outside cwd), and an MCP install is advisory-only.
];
const REQUIRED_INIT_COMMAND = 'agentguard init --agent auto';

Expand All @@ -64,7 +67,7 @@ async function main() {
.command('init')
.description('Create ~/.agentguard/config.json and local runtime paths')
.option('--level <level>', 'Protection level: strict | balanced | permissive')
.option('--agent <agent>', 'Install hook/template for claude-code, codex, openclaw, hermes, or qclaw')
.option('--agent <agent>', 'Install hook/template for claude-code, codex, openclaw, hermes, qclaw, or goose (MCP advisory)')
.option('--cloud <url>', 'AgentGuard Cloud URL to store in local config')
.option('--shell-hooks', 'For Hermes: install legacy shell hooks instead of the native plugin')
.option('--force', 'Overwrite existing hook/template files')
Expand Down Expand Up @@ -106,7 +109,7 @@ async function main() {
return;
}
if (!SUPPORTED_AGENT_INSTALLERS.includes(normalizedAgent as AgentInstaller)) {
throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, or qclaw.');
throw new Error('Invalid agent. Use auto, claude-code, codex, openclaw, hermes, qclaw, or goose.');
}
const agent = normalizedAgent as AgentInstaller;
config.agentHost = agent;
Expand Down Expand Up @@ -202,10 +205,11 @@ async function main() {
.description('Disconnect local AgentGuard from AgentGuard Cloud')
.action(async () => {
const currentConfig = ensureConfig();
noteCronBackendFallbackIfNeeded(currentConfig);
const cronRemoval = await removeThreatFeedCron({
name: currentConfig.threatFeedCronName || 'agentguard-threat-feed',
backend: 'auto',
agentHost: resolveCronAgentHost(currentConfig),
agentHost: resolveCronBackendHost(currentConfig),
agentGuardHome: getAgentGuardPaths().home,
});
const config = disconnectCloud();
Expand Down Expand Up @@ -703,13 +707,14 @@ async function main() {
if (options.cron && !options.cronRun) {
summary.cron.requested = true;
try {
noteCronBackendFallbackIfNeeded(config);
summary.cron.result = await installThreatFeedCron({
name: options.cronName as string,
cronExpression: cronExpression!,
quiet,
force: Boolean(options.force),
backend: cronTarget,
agentHost: resolveCronAgentHost(config),
agentHost: resolveCronBackendHost(config),
agentGuardHome: getAgentGuardPaths().home,
}, {
gateway: resolveOpenClawGatewayOptionsFromEnv(),
Expand Down Expand Up @@ -1062,10 +1067,61 @@ function printCronRemovalSummary(results: ThreatFeedCronRemovalResult[]): void {
console.log('No AgentGuard subscribe cron job was found.');
}

function resolveCronAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined {
/**
* Hosts that have a cron-targeted backend (OpenClaw / Hermes use agent-managed
* cron; the rest fall through to system cron). Hosts not in this set still
* accept cron commands — they just default to the system backend.
*/
const CRON_CAPABLE_HOSTS = new Set<AgentGuardAgentHost>([
'claude-code',
'codex',
'openclaw',
'hermes',
'qclaw',
]);

/**
* Return the host configured for this AgentGuard install. Always returns the
* raw host (never silently strips) so messaging code can name it correctly.
*/
function resolveConfiguredAgentHost(config: AgentGuardConfig): AgentGuardAgentHost | undefined {
return config.agentHost ?? config.agentHosts?.[0];
}

/**
* Narrow the configured host to one of the cron-capable backends used by
* `installThreatFeedCron` / `removeThreatFeedCron`. Returns undefined when
* the host has no specific cron backend — callers should treat that as
* "fall back to system cron" rather than as "no host configured".
*/
function resolveCronBackendHost(config: AgentGuardConfig): CronAgentHost | undefined {
const host = resolveConfiguredAgentHost(config);
return host && CRON_CAPABLE_HOSTS.has(host) ? (host as CronAgentHost) : undefined;
}

/**
* If the user has a host configured that has no agent-specific cron backend
* (e.g. `goose`, `continue`), print a one-line stderr note so they know cron
* is falling back to the system scheduler. Idempotent per-process.
*/
let cronFallbackNoteShown = false;
function noteCronBackendFallbackIfNeeded(config: AgentGuardConfig): void {
if (cronFallbackNoteShown) return;
const host = resolveConfiguredAgentHost(config);
if (!host || CRON_CAPABLE_HOSTS.has(host)) return;
cronFallbackNoteShown = true;
console.error(
`Note: agent host "${host}" has no agent-managed cron backend. Cron commands will use the system scheduler. ` +
`Run \`agentguard init --agent openclaw\` or \`agentguard init --agent hermes\` if you'd like an agent-managed schedule instead.`
);
}

// Back-compat name retained so existing call sites compile without churn.
// New code should use `resolveCronBackendHost` for clarity.
function resolveCronAgentHost(config: AgentGuardConfig): CronAgentHost | undefined {
return resolveCronBackendHost(config);
}

function readStdinIfAvailable(): string {
if (process.stdin.isTTY) return '';
try {
Expand Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

export type AgentGuardAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw';
export type AgentGuardAgentHost = 'claude-code' | 'codex' | 'openclaw' | 'hermes' | 'qclaw' | 'goose';

export interface AgentGuardConfig {
version: 1;
Expand Down Expand Up @@ -226,7 +226,7 @@ function normalizeLevel(value: unknown): AgentGuardConfig['level'] | null {
}

function normalizeAgentHost(value: unknown): AgentGuardAgentHost | undefined {
return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw'
return value === 'claude-code' || value === 'codex' || value === 'openclaw' || value === 'hermes' || value === 'qclaw' || value === 'goose'
? value
: undefined;
}
Expand Down
Loading
Loading