Skip to content

feat(continue): add native Continue plugin (CLI hooks)#111

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

feat(continue): add native Continue plugin (CLI hooks)#111
theyavuzarslan wants to merge 2 commits into
GoPlusSecurity:mainfrom
theyavuzarslan:feat/continue-plugin

Conversation

@theyavuzarslan

Copy link
Copy Markdown
Contributor

Summary

Adds a Continue (continuedev/continue) integration parallel to the Hermes and Cline adapters, so AgentGuard can gate every tool call in the Continue CLI (cn) before it runs.

Continue's CLI ships a Claude-Code-compatible PreToolUse / PostToolUse hook system at extensions/cli/src/hooks/ — stdin JSON in, stdout JSON out, with permissionDecision: \"allow\" | \"deny\" | \"ask\". This PR wires AgentGuard's engine to that surface.

Scope note: The VS Code / JetBrains Continue extension does not currently expose a tool-call hook surface — it only has soft rules and ToolOverride (all-or-nothing per-tool disable). So this PR targets the CLI only; an extension-side PR would need an upstream Continue change first.

What changed

  • src/adapters/continue.tsContinueAdapter mapping Continue tool names (run_terminal_command, create_new_file / edit_existing_file / single_find_and_replace / multi_edit, read_file / read_file_range / read_currently_open_file, fetch_url_content, search_web) to the shared adapter contract. Out-of-scope tools (grep_search, file_glob_search, ls, codebase, MCP-server tools) return null so AgentGuard doesn't block third-party tools it can't reason about.
  • skills/agentguard/scripts/continue-hook.js — file-hook bridge emitting Continue's exact decision shape:
    { \"hookSpecificOutput\": { \"hookEventName\": \"PreToolUse\",
                              \"permissionDecision\": \"deny\",
                              \"permissionDecisionReason\": \"...\" } }
    deny / ask / allow map 1:1 to AgentGuard's block / require_approval / allow — no lossy translation.
  • agentguard init --agent continue — drops the skill at ~/.continue/skills/agentguard and merges PreToolUse / PostToolUse entries into ~/.continue/settings.local.json. Pre-existing user hooks are preserved; re-running is idempotent.
  • 'continue' wired through AgentInstaller, RuntimeAgentHost, AgentGuardAgentHost, SUPPORTED_AGENT_INSTALLERS, AUTO_AGENT_DETECTION, normalizeAgentHost. resolveCronAgentHost narrowed to the cron-capable subset.

Lessons applied from #110 review

  • No spawnSync shim. The merged settings.json command is node <absolute-path-to-bundled-script>. Single process, no stdin-passthrough question.
  • Path resolution uses fileURLToPath() + dirname() (no string-replace), and only loads the bundled engine when its path actually exists.
  • multi_edit batches scan every edit's filepath and elevate any path that isSensitivePath flags as the envelope's primary, so a batch of [src/foo.ts, .env.production, src/bar.ts] cannot smuggle the .env target past the engine. Adapter tests lock the contract in.
  • Fail-closed by default, single env var AGENTGUARD_CONTINUE_FAIL_OPEN=1 inverts. No cross-surface divergence.

Install / use

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

Restart Continue (`cn`). Hooks fire on every tool call.

Test plan

  • `npm run build` — clean
  • `npm test` — 446/446 pass (was 418 baseline; +28 new — adapter unit covering parseInput / mapToolToActionType / buildEnvelope / multi_edit worst-case, hook E2E covering allow/deny/ask, out-of-scope passthrough, post-tool audit, missing-field block, fail-closed default, fail-open override, invalid-stdin prompt exit; installer tests covering fresh install, preserving prior hooks, idempotency)
  • End-to-end against a fresh temp `.continue` dir:
    • `run_terminal_command rm -rf /` → `permissionDecision: "deny"`
    • `run_terminal_command echo hello` → `{}`
    • `create_new_file /project/.env` → `permissionDecision: "ask"`

References:

🤖 Generated with Claude Code

Adds a Continue integration parallel to the Hermes and Cline adapters, so
AgentGuard can gate every tool call in the Continue CLI (`cn`) before it
runs. Targets the CLI's Claude-Code-compatible hook system documented at
continuedev/continue/extensions/cli/src/hooks/types.ts. The VS Code / JetBrains
extension does not yet expose a tool-call hook surface, so this PR scopes
to the CLI; an extension-side PR would need an upstream Continue change.

Surfaces:
- ContinueAdapter (src/adapters/continue.ts) maps Continue tool names
  (run_terminal_command, create_new_file/edit_existing_file/single_find_and_replace
  /multi_edit, read_file/read_file_range/read_currently_open_file, fetch_url_content,
  search_web) to the shared HookAdapter contract. Out-of-scope tools
  (grep_search, file_glob_search, ls, codebase, MCP server tools) return null
  so AgentGuard does not block third-party tools it cannot reason about.
- File-hook bridge (skills/agentguard/scripts/continue-hook.js) handles
  Continue's stdin JSON / stdout JSON protocol. Emits Continue's documented
  `{ hookSpecificOutput: { hookEventName, permissionDecision, ... } }`
  shape — deny / ask / allow map 1:1 with no lossy translation.
- `agentguard init --agent continue` drops the skill at
  ~/.continue/skills/agentguard and merges PreToolUse / PostToolUse entries
  into ~/.continue/settings.local.json. Pre-existing hooks are preserved;
  re-running is idempotent.

Review lessons applied (carried over from the Cline plugin review):
- No spawnSync shim. The settings.json `command` is `node <absolute-path>`,
  pointing directly at the bundled bridge. One process boundary.
- Path resolution uses fileURLToPath() + dirname() (no string-replace),
  and only attempts the bundled engine when its path exists.
- multi_edit batches scan every edit's filepath and elevate any path that
  isSensitivePath flags as the envelope's primary, so a batch of
  [src/foo.ts, .env.production, src/bar.ts] cannot smuggle the .env target
  past the engine. Adapter tests lock the contract in.
- Fail-closed by default; single env var AGENTGUARD_CONTINUE_FAIL_OPEN=1
  inverts. No cross-surface divergence to maintain.

Wiring:
- 'continue' added to AgentInstaller, RuntimeAgentHost, AgentGuardAgentHost,
  SUPPORTED_AGENT_INSTALLERS, AUTO_AGENT_DETECTION, normalizeAgentHost.
- resolveCronAgentHost narrowed to the cron-capable subset so the new host
  doesn't leak into cron backends that don't target it (mirrors the Cline fix).

Tests: 446/446 pass (was 418 on this branch's base; +28 new — adapter unit
covering parseInput / mapToolToActionType / buildEnvelope / multi_edit
worst-case, hook E2E covering allow/deny/ask, out-of-scope passthrough,
post-tool audit, missing-field block, fail-closed default, fail-open
override, invalid-stdin prompt exit; installer tests covering fresh
install, preserving prior hooks, idempotency).

End-to-end verified against a fresh temp .continue dir:
- run_terminal_command rm -rf /  -> permissionDecision: "deny"
- run_terminal_command echo hello -> {}
- create_new_file /project/.env  -> permissionDecision: "ask"
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

AgentGuard PR Review

I found a few concrete issues in the Continue integration patch.

  1. severity: high — skills/agentguard/scripts/continue-hook.js / src/installers.ts
    The hook command is written as node ${JSON.stringify(hookScriptPath)} in the installer, which is shell-quoting unsafe and can break or be interpreted incorrectly when the path contains spaces, quotes, or backslashes. That can cause the hook to fail to start, silently disabling protection for Continue users.
    Fix: Generate the command with shell-appropriate escaping for the target shell, or avoid shell parsing entirely by configuring Continue to invoke node with an argument array if supported.

  2. severity: high — skills/agentguard/scripts/continue-hook.js
    In the pre-hook path, validation failures are blocked only when FAIL_OPEN is false. With AGENTGUARD_CONTINUE_FAIL_OPEN=1, malformed but in-scope tool payloads (for example run_terminal_command without a command) are allowed through. That is unsafe for a security gate because it explicitly permits unknown/partial requests.
    Fix: Treat malformed or missing required fields as hard-deny regardless of fail-open; reserve fail-open only for engine load/runtime failures.

  3. severity: medium — skills/agentguard/scripts/continue-hook.js
    readStdin() returns null on JSON parse failure, and main() then applies the same fail-open logic. That means invalid stdin can be accepted when AGENTGUARD_CONTINUE_FAIL_OPEN=1, which weakens the hook protocol in an attacker-controlled input path.
    Fix: Always deny on invalid or unparsable stdin; do not let fail-open override malformed hook payloads.

  4. severity: medium — src/installers.ts (installContinue / ensureContinueHookEvent)
    The dedup/removal logic only checks whether an existing hook command string contains continue-hook.js. If a user has a different command wrapper pointing at the same script, or if the command format changes, re-install can append duplicates or fail to remove the prior AgentGuard entry. That can lead to multiple hook executions per tool call.
    Fix: Store a stable marker in the hook entry or compare the resolved script path more robustly, and remove/rewrite the exact AgentGuard-managed hook entry instead of substring matching.

…t, cron)

Four issues raised in review; all addressed in this commit.

1. MEDIUM — installer used JSON.stringify(hookScriptPath) for the hook
   command, which is JSON quoting, not shell quoting. On a path with
   spaces, single quotes, or backslashes the resulting `node "..."` could
   parse incorrectly when Continue's shell-based hook runner spawns it.
   Fix: continueHookCommand() now POSIX-single-quotes the path (with the
   classic '\\'' close-escape-reopen trick for embedded apostrophes) on
   non-Windows, and double-quotes on win32 with a clear error if the path
   contains a double quote (NTFS doesn't allow them in paths anyway).
   Dedup also relaxed to substring-match on `continue-hook.js` so an old
   JSON-quoted entry from a prior install is recognized and replaced
   rather than appended-alongside.

2. HIGH — continue-hook.js could fall open on malformed in-scope payloads
   when AGENTGUARD_CONTINUE_FAIL_OPEN=1 was set, because the validation
   error path checked FAIL_OPEN before blocking. That meant a half-formed
   `run_terminal_command` (no `command` field) with FAIL_OPEN=1 was being
   allowed. The fail-open override exists for engine-unavailable cases,
   not for "we don't know what we're being asked to allow."
   Fix: malformed in-scope payloads and unparseable stdin now block
   regardless of FAIL_OPEN. Validation tightened: multi_edit edits[] is
   walked entry-by-entry and rejected if any entry isn't an object or is
   missing a filepath (with no top-level fallback); fetch_url_content URL
   is parse-checked; file-bearing tools reject NUL bytes; shell command
   capped at 64 KiB.

3. MEDIUM — ContinueAdapter.buildEnvelope dropped multi_edit edit content
   (only paths made it into actionData). Policy that wants to reason about
   destructive batches (e.g. detect `old_string=non-empty, new_string=''`
   as a delete) had nothing to work with.
   Fix: actionData now includes `operations: [{path, kind, old_preview,
   new_preview, is_delete}]` normalized across multi_edit /
   single_find_and_replace / create_new_file. Previews are clamped to
   ≤256 chars + ellipsis so the envelope stays bounded.

4. MEDIUM — resolveCronAgentHost silently narrowed `continue` to
   undefined. The behavior was correct (continue has no agent-managed
   cron backend, system cron picks up the slack) but the silent narrow
   meant users couldn't tell why their cron command had no host-specific
   behavior.
   Fix: split into two helpers. resolveConfiguredAgentHost() always
   returns the raw host. resolveCronBackendHost() narrows for callers
   that need a CronAgentHost. The two cron entry points (`disconnect`,
   `subscribe`) now call noteCronBackendFallbackIfNeeded(config) which
   emits a one-line stderr note when a configured host has no
   agent-managed backend — once per process. The old name
   resolveCronAgentHost is retained as a back-compat shim.

Tests: 455/455 pass (was 446 before; +9 new — shell-quoting + path-with-
spaces + stale-format dedup in installer; FAIL_OPEN does not bypass
validation; multi_edit non-object edits[] rejection; fetch_url_content
URL parse rejection; multi_edit operations envelope including
is_delete; preview clamp; create_new_file create-op surface).

End-to-end verified against /tmp/continue\ space\ test/ — command field
in settings.local.json is `node '/tmp/continue space test/.../continue-
hook.js'` (POSIX-shell-safe), and a deletion-style multi_edit batch is
correctly elevated to `permissionDecision: "ask"` with sensitive_path
surfaced.
@theyavuzarslan

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All four items addressed in `c74e6ee`:

# Severity Status Fix
1 medium — JSON.stringify on hook path broke shell parsing ✅ Fixed New continueHookCommand() POSIX-single-quotes the path with the classic '\\'' close-escape-reopen trick on non-Windows, and double-quotes on win32 (with a clear error if the path contains a ", which NTFS doesn't allow anyway). The dedup check was also relaxed to substring match on continue-hook.js so a prior install written with the old JSON-quoted format is recognized as ours and replaced, not appended-alongside. Verified end-to-end: a path like /tmp/continue space test/... now produces node '/tmp/continue space test/.../continue-hook.js'.
2 high — FAIL_OPEN=1 could allow malformed in-scope payloads ✅ Fixed Removed the fail-open escape on validation errors and on unparseable stdin. Both now block regardless of AGENTGUARD_CONTINUE_FAIL_OPEN — the override exists for engine-unavailable cases, not for "we got a half-formed shell command and aren't sure what it is." Validation also tightened: multi_edit.edits[] is walked entry-by-entry (rejected if any entry isn't an object or is missing a filepath with no top-level fallback); fetch_url_content URL is parse-checked via new URL(); file-path-bearing tools reject NUL bytes; shell command capped at 64 KiB.
3 medium — multi_edit envelope dropped edit content ✅ Fixed ContinueAdapter.buildEnvelope now populates actionData.operations: [{path, kind, old_preview, new_preview, is_delete}] normalized across multi_edit, single_find_and_replace, and create_new_file. Policy can now distinguish e.g. a deletion (old_string non-empty + new_string === '') from a benign rename. Previews are clamped to ≤256 chars + ellipsis so the envelope stays bounded. Adapter tests lock in the contract (is_delete detection, preview clamp, create-op surfacing).
4 medium — resolveCronAgentHost silently strips continue ✅ Fixed Split into two helpers. resolveConfiguredAgentHost(config) always returns the raw host. resolveCronBackendHost(config) narrows for callers that need a CronAgentHost. The two cron entry points (disconnect, subscribe) now call noteCronBackendFallbackIfNeeded(config) which emits a one-line stderr note when a configured host has no agent-managed cron backend — once per process. Old name resolveCronAgentHost retained as a back-compat shim.

Tests: 455/455 pass (was 446 before fixes; +9 new — shell-quoting + path-with-spaces + stale-format dedup in installer; FAIL_OPEN does NOT bypass validation; multi_edit non-object edits[] rejection; fetch_url_content URL parse rejection; multi_edit operations envelope including is_delete; preview clamp; create_new_file create-op surface).

Happy to split into smaller commits if you'd prefer.

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