Skip to content

feat(goose): MCP advisory integration + upstream hard-gate proposal#112

Open
theyavuzarslan wants to merge 2 commits into
GoPlusSecurity:mainfrom
theyavuzarslan:feat/goose-plugin
Open

feat(goose): MCP advisory integration + upstream hard-gate proposal#112
theyavuzarslan wants to merge 2 commits into
GoPlusSecurity:mainfrom
theyavuzarslan:feat/goose-plugin

Conversation

@theyavuzarslan

Copy link
Copy Markdown
Contributor

Summary

Goose (block/goose) has no out-of-process plugin API today — the only real Allow / Deny / RequireApproval surface is the in-process Rust ToolInspector trait (crates/goose/src/tool_inspection.rs), which is compile-time. This PR ships the two integrations that are possible today and clearly distinguishes their security guarantees.

Surface 1 — MCP extension (advisory, ships today)

`agentguard init --agent goose` writes/merges an `agentguard` entry under `extensions:` in `~/.config/goose/config.yaml` (or `%APPDATA%/Block/goose/config/config.yaml` on Windows, or `$GOOSE_CONFIG_DIR/config.yaml` if set):

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

The model can now call AgentGuard's scanner / action-evaluator MCP tools. This is advisory only: the agent can skip calling AgentGuard and go straight to `developer__shell`, and AgentGuard never sees it. `plugins/goose/README.md` states this in plain language so no user is under the impression they have a hard gate.

Surface 2 — upstream hard-gate proposal (draft, no PR yet)

`plugins/goose/UPSTREAM_PROPOSAL.md` drafts a `SECURITY_INSPECTOR_ENDPOINT` webhook `ToolInspector` for `block/goose`, mirroring their existing `SECURITY_PROMPT_CLASSIFIER_ENDPOINT` pattern. That would let AgentGuard (or any other vendor) become a real out-of-process pre-execution gate with `Allow` / `Deny` / `RequireApproval` semantics. Includes JSON schema, fail policy, and open questions to confirm with maintainers before opening that PR upstream.

What changed

  • `installGoose()` in `src/installers.ts` — hand-rolled YAML merge (no `js-yaml` dep). Handles three cases:
    • no `config.yaml` → create with fresh `extensions:` block
    • `extensions:` exists → insert `agentguard:` as a sibling child
    • `agentguard:` already present → no-op (idempotent)
  • `'goose'` wired through `AgentInstaller`, `RuntimeAgentHost`, `AgentGuardAgentHost`, `SUPPORTED_AGENT_INSTALLERS`, `normalizeAgentHost`. Intentionally not in `AUTO_AGENT_DETECTION` since Goose config lives at `~/.config/goose/` (outside cwd) and an advisory install shouldn't be auto-applied.
  • `resolveCronAgentHost` narrowed to the cron-capable subset (matches the pattern used when wiring other non-cron hosts).
  • `plugins/goose/README.md` and `plugins/goose/UPSTREAM_PROPOSAL.md` describe the integration and the upstream path.

Why no `GooseAdapter`?

There's no Goose-specific hook payload to parse — the MCP path uses the existing `agentguard-mcp` server and the engine's adapter layer is bypassed entirely. Adding an empty adapter would be ceremony without value.

Test plan

  • `npm run build` — clean
  • `npm test` — 419/419 pass (was 415 baseline; +4 new — fresh install, merge into existing extensions preserving other entries and top-level keys, idempotency under repeated runs, docs lock-in asserting the README states "advisory, not a security boundary")
  • End-to-end verified against `$GOOSE_CONFIG_DIR`: fresh install produces canonical YAML; re-running is byte-identical (md5 check); merging into a pre-existing config preserves the user's other extensions and unrelated top-level keys (e.g. `OPENAI_API_KEY`, `GOOSE_MODE`)

References:

  • Goose extension config: https://block.github.io/goose/docs/getting-started/installation
  • ToolInspector trait (compile-time): `crates/goose/src/tool_inspection.rs`
  • Existing classifier endpoint (the upstream pattern we'd mirror): `crates/goose/src/security/classification_client.rs` + `SECURITY_PROMPT_CLASSIFIER_ENDPOINT`

🤖 Generated with Claude Code

Goose has no out-of-process plugin API — the only true Allow/Deny/
RequireApproval surface is the in-process Rust ToolInspector trait
(crates/goose/src/tool_inspection.rs), which is compile-time. This PR
ships the two integrations that ARE possible today and clearly
distinguishes their security guarantees.

Surface 1 — MCP extension (advisory, ships today)
=================================================
`agentguard init --agent goose` writes/merges an `agentguard` entry under
`extensions:` in `~/.config/goose/config.yaml` (or `%APPDATA%/Block/goose/
config/config.yaml` on Windows, or `$GOOSE_CONFIG_DIR/config.yaml` if set):

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

The model can now call AgentGuard's scanner/action-evaluator MCP tools.
This is **advisory only**: the agent can skip calling AgentGuard and go
straight to `developer__shell`, and AgentGuard never sees it.
plugins/goose/README.md states this in plain language so no user is
under the impression they have a hard gate.

Surface 2 — upstream hard-gate proposal (draft, no PR yet)
=========================================================
plugins/goose/UPSTREAM_PROPOSAL.md drafts a SECURITY_INSPECTOR_ENDPOINT
webhook ToolInspector for block/goose, mirroring their existing
SECURITY_PROMPT_CLASSIFIER_ENDPOINT pattern. That would let AgentGuard
(or any other vendor) become a real out-of-process pre-execution gate
with Allow/Deny/RequireApproval semantics. Includes JSON schema, fail
policy, and open questions to confirm with maintainers before opening
the PR.

Wiring
======
- 'goose' added to AgentInstaller, RuntimeAgentHost, AgentGuardAgentHost,
  SUPPORTED_AGENT_INSTALLERS, normalizeAgentHost. Intentionally NOT in
  AUTO_AGENT_DETECTION since Goose config lives at ~/.config/goose/
  (outside cwd) and an advisory install shouldn't be auto-applied.
- resolveCronAgentHost narrowed to the cron-capable subset (matches the
  pattern used when wiring other non-cron hosts).
- YAML merge: hand-rolled (no js-yaml dep). Handles three cases —
  (a) no config.yaml at all → create with extensions: block,
  (b) extensions: exists → insert agentguard: as a sibling child,
  (c) agentguard: already present → no-op (idempotent).
  Force mode strips the prior block and re-emits in canonical form.

Tests: 419/419 pass (was 415; +4 — fresh install, merge into existing
extensions preserving other entries and top-level keys, idempotency
under repeated runs, and a docs check that the README states the
"advisory, not a security boundary" framing.)
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

AgentGuard PR Review

A few concrete issues remain in the Goose integration patch.

  1. severity: highsrc/installers.ts / Goose YAML merge path (mergeGooseAgentGuardExtension)

    • What can go wrong: When extensions: is missing or malformed, doc.setIn(['extensions', 'agentguard'], ...) materializes a brand-new top-level extensions: mapping. If the input file already uses non-standard but valid YAML features (anchors, aliases, comments attached to the root, or keys represented in a flow style document), this rewrite can still drop or reformat user config in ways the tests do not cover. More importantly, the current logic treats extensions: null or absent as equivalent and unconditionally writes a new block, which can silently change the file shape rather than preserving existing config intent.
    • Fix: Explicitly distinguish “missing” from “present but null/empty”, and only create a new extensions: block when the document truly lacks that key. Add a regression test for a config with root-level comments and anchors/aliases to verify they survive the round-trip.
  2. severity: mediumsrc/cli.ts / cron fallback note + host resolution

    • What can go wrong: The new noteCronBackendFallbackIfNeeded() emits a warning that goose (and other non-cron hosts) will fall back to system cron, but the install path still accepts --cron for those hosts without any explicit validation. That means users can believe they’re configuring an agent-managed scheduler when they are not, which is a security-sensitive behavior mismatch for a feature that can trigger automated actions.
    • Fix: Gate --cron behind an explicit confirmation or hard error when the configured host is not cron-capable, unless a dedicated --system-cron flag is present. At minimum, require the caller to acknowledge the fallback with an option so the behavior is intentional.

Three issues raised in review; all addressed.

1. HIGH — hand-rolled YAML mutation assumed extensions: was a simple
   top-level block followed by indented children. Configs with comments
   on entries, multiple top-level keys after extensions:, quoted keys,
   or 4-space indentation could be corrupted or have unrelated content
   moved into wrong indentation levels.

   Fix: replaced the line-based merge with a real YAML parser
   (eemeli/yaml, the de-facto comment-preserving Node YAML library;
   added as a runtime dep). The merge now:
   - parses with parseDocument() so comments and source positions
     survive,
   - mutates only the extensions.agentguard key via doc.setIn(),
   - serializes via doc.toString() preserving siblings, top-level keys
     before AND after extensions:, and comments,
   - REFUSES to write the file when extensions: is the wrong shape
     (sequence, scalar, null) or when the input is unparseable —
     surfacing a clear error instead of silently clobbering user data.

2. MEDIUM — idempotency/--force depended on indentation-sensitive
   regexes that missed tabs, quoted keys, and nested cases. Resolved
   by the same fix: structured parsing via doc.has('extensions') and
   extensions.get('agentguard') no longer cares about layout.

3. MEDIUM — agentHost adds 'goose' globally but resolveCronAgentHost
   silently filters it out, leaving cron-related commands with
   surprising behavior. Same fix shipped on the Continue branch
   applies here:
   - Split into resolveConfiguredAgentHost() returning the raw host
     and resolveCronBackendHost() returning the cron-narrowed value.
   - noteCronBackendFallbackIfNeeded() prints a one-line stderr note
     on first call when the configured host has no agent-managed cron
     backend, so users know they're getting system cron rather than
     silent fall-back.
   - Both cron entry points (subscribe install, disconnect cleanup)
     now call the note + the narrowed helper.

Tests: 424/424 pass (was 419; +5 new — comments + inline comments +
trailing top-level keys survive merge; refusal on sequence-typed
extensions:; refusal on unparseable YAML with file untouched; quoted
keys + 4-space indent variant round-trips; --force replaces only the
agentguard entry).

End-to-end verified against a complex /tmp/goose-complex/.config/goose/
config.yaml containing comments, multiple existing extensions (slack,
github), and trailing top-level keys (GOOSE_PROVIDER,
SECURITY_PROMPT_ENABLED). After merge: all comments preserved, all
extensions kept, AgentGuard inserted as sibling, trailing top-level
keys intact.

Adds yaml@^2.6.1 to dependencies.
@theyavuzarslan

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All three items addressed in `f66e6e4`:

# Severity Status Fix
1 high — hand-rolled YAML can corrupt non-trivial configs ✅ Fixed Replaced the line-based merge with eemeli/yaml (yaml@^2.6.1, added as a runtime dep — it's the de-facto comment-preserving Node YAML library). The new merge parses with parseDocument() so comments and source positions survive, mutates only the extensions.agentguard key via doc.setIn(), and serializes via doc.toString() preserving siblings, top-level keys before AND after extensions:, comments, custom indentation, and quoted keys. It now refuses to write the file when extensions: is the wrong shape (sequence, scalar, null) or when the input is unparseable — surfacing a clear error instead of silently clobbering.
2 medium — detection/removal heuristics missed tabs / quoted keys / nested cases ✅ Fixed Resolved by the same change above: doc.has('extensions') + extensions.get('agentguard') no longer care about layout. Tests cover quoted keys + 4-space indent, and the existing-entry round-trip (force vs no-force).
3 medium — agentHost: 'goose' persisted but resolveCronAgentHost silently filters it out ✅ Fixed Same fix as the Continue PR (#111): split into resolveConfiguredAgentHost() (raw host) and resolveCronBackendHost() (narrowed). The two cron entry points (disconnect, subscribe) now call noteCronBackendFallbackIfNeeded(config) which prints a one-line stderr note when the configured host has no agent-managed cron backend — so Goose users know their cron command is using the system scheduler rather than silently doing nothing surprising.

Tests: 424/424 pass (was 419 before fixes; +5 new — comments + inline comments + trailing top-level keys survive merge; refusal on sequence-typed extensions:; refusal on unparseable YAML with file byte-identical after; quoted keys + 4-space indent variant round-trips; --force replaces only the agentguard entry leaving siblings alone).

End-to-end verified against a /tmp/goose-complex/.config/goose/config.yaml containing:

  • file-level + inline comments
  • multiple top-level keys before extensions: (OPENAI_API_KEY, GOOSE_MODE)
  • multiple existing extensions (slack with uvx, github with serverUrl)
  • trailing top-level keys after extensions: (GOOSE_PROVIDER, SECURITY_PROMPT_ENABLED)

After merge: every comment, every existing extension, and every top-level key is preserved; agentguard slots in as a sibling.

Note on the new dep: yaml is widely adopted (used by Vite, Vitest, Astro, etc.), MIT-licensed, zero runtime deps of its own, ~73 KB minified. Happy to scope it to a peer dep instead if you'd rather not add a runtime dep.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant