diff --git a/CHANGELOG.md b/CHANGELOG.md index da54269..48340ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- AgentGuard command and documented installer results now pass through without post-tool blocking or redaction in Codex, Claude Code, OpenClaw, QClaw, Hermes, and DSH. Claude batch inspection excludes recognized own-command calls while retaining checks for other calls; execution-time approval remains. +- Attribute installer results by their output signature in native Codex and Claude Code hooks, without a URL allowlist. + ### Changed - Clean runtime events stay local across DSH, Codex, and Claude Code. - Reportable DSH and native-hook events now send bounded, redacted Cloud summaries with useful command previews, action details, web destinations, or file/configuration/model/policy context. Raw responses, output, credentials, and other sensitive details stay local. @@ -9,9 +12,6 @@ ### Fixed - Blocked Claude Code `PostToolBatch` findings now reach Cloud with a redacted summary and batch counts. -- Codex and Claude Code hooks no longer flag masked output from the exact standalone `agentguard status` command as PII or bulk egress; modified commands and unmasked secrets remain inspected. -- Native Codex and Claude Code hooks no longer block standalone `agentguard` CLI results when output contains sensitive data. Codex replaces model-visible tool results with locally redacted feedback, Claude Code rewrites supported post-tool results, and the exact pending Agent JWT activation URL remains clickable. Compound commands and other tools remain protected. -- OpenClaw and QClaw integrations now rewrite correlated AgentGuard CLI text in supported OpenClaw-owned `tool_result_persist` transcripts; the native Hermes `transform_tool_result` hook and DSH `tools/post-execute` accept path provide equivalent non-blocking redaction where supported. An exact saved activation link stays clickable; other hooks and downstream policy decisions retain their existing boundaries. ## [1.2.1] - 2026-09-29 diff --git a/docs/claude-code.md b/docs/claude-code.md index 32a63f5..5cdd099 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -32,13 +32,13 @@ Hook execution is bounded to 30 seconds. Claude Code may continue the original a `PreToolUse` dynamically classifies Bash, PowerShell, Read, Write/Edit/MultiEdit, Web, MCP, image/file readers, and unknown tools from `tool_name` and `tool_input`. A block uses Claude Code's native `permissionDecision: "deny"`; `require_approval` uses `permissionDecision: "ask"`. Approval depends on Claude Code having an interactive approval UI. Non-interactive runs may not provide a usable approval path, so approval availability is not claimed as universal. -`PostToolUse` scans `tool_response`. AgentGuard uses `updatedToolOutput` only for verified response shapes: Read, Bash/PowerShell, WebFetch/WebSearch, and MCP content results. Redaction preserves the verified string/object/array shape. Unknown output schemas are not guessed or destructively rewritten; sensitive unknown output is marked `partial`/`would_block`, and `PostToolBatch` remains the continuation gate. +`PostToolUse` scans `tool_response` except for recognized AgentGuard commands and the documented installer, whose output passes through unchanged. AgentGuard uses `updatedToolOutput` only for verified response shapes: Read, Bash/PowerShell, WebFetch/WebSearch, and MCP content results. Redaction preserves the verified string/object/array shape. Unknown output schemas are not guessed or destructively rewritten; sensitive unknown output is marked `partial`/`would_block`, and `PostToolBatch` remains the continuation gate for other commands. Routine successful `PostToolUse` observations remain in the local audit and are not uploaded. Failures, elevated risks, anomalous results, and other material new findings are uploaded as separate Cloud events containing the safe original-action summary, redacted findings, and bounded status metadata. `PostToolUseFailure` records only redacted failure type/summary and coverage. It cannot replace or block a failed tool's output. Whether a particular Claude Code version includes failed results in a following `PostToolBatch.tool_calls[].tool_response` is `unverified` until tested on that version; if absent, the failure-output path is `unsupported`. -`PostToolBatch` scans each official `tool_calls[].tool_response` result Claude is about to use, records bounded file-path, byte, and redaction counts, and can stop the agentic loop before the next model call. Material findings are uploaded even when continuation is blocked, using only a bounded tool-and-finding summary, static policy titles, and aggregate counts; raw tool results, sensitive values, and absolute paths remain local. Tool reads and side effects have already occurred. Existing transcript content is not removed, and a resumed session is not guaranteed to suppress retransmission. +`PostToolBatch` scans each official `tool_calls[].tool_response` result Claude is about to use, records bounded file-path, byte, and redaction counts, and can stop the agentic loop before the next model call. This result is serialized model content, commonly a string or text-block array, rather than the structured `PostToolUse` response. For a recognized AgentGuard command or the documented installer, AgentGuard excludes the entire call from its inspection copy without changing the host result; other calls in the batch remain protected. Material findings are uploaded even when continuation is blocked, using only a bounded tool-and-finding summary, static policy titles, and aggregate counts; raw tool results, sensitive values, and absolute paths remain local. Tool reads and side effects have already occurred. Existing transcript content is not removed, and a resumed session is not guaranteed to suppress retransmission. `ConfigChange` reads the event source and, when `file_path` is present, at most the first 256 KiB of new local file content to detect endpoint changes, key forwarding, and dangerous permissions. Source-only events remain metadata-only partial coverage. A block prevents the current session from applying the change; it does not roll back the file on disk. diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index d211de6..cb9eebb 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -18,33 +18,28 @@ uploaded to Cloud. ## AgentGuard-owned CLI results -A single `agentguard` shell invocation with a supported result shape is locally -redacted when its result contains recognized sensitive text. Unknown or malformed -result shapes are blocked. Simple `command`, `env`, and -shell wrappers are recognized; an explicit executable path must resolve to this -package's CLI. Supported native result hooks redact recognized secrets in -host-specific result or persistence surfaces. The documented -`TYPESAFE_API_KEY=` example remains unchanged. The **exact** -pending Agent JWT activation URL saved in the local AgentGuard configuration -remains unchanged when it appears as an exact value, including a structured -`agentRegisterUrl` field or a link followed by sentence punctuation. Encoded -links and other tokenized URLs are not exempt. Compound commands, unverified -executable paths, and unrelated tools retain their ordinary protection; -`agentguard approve` is not exempt from agent-self-approval checks. -Direct `npx @goplus/agentguard` calls with supported public subcommands receive -the same native output treatment. The exact documented -`curl -fsSL https://agentguard.one/install.sh | bash` command receives it only -when the result contains the complete saved activation link. Other installer -results follow ordinary privacy checks. Running the remote installer still -requires approval before execution. - -| Host | Own-command result handling | Limit | -|---|---|---| -| Codex | `PostToolUse` replaces sensitive results with redacted feedback. | Requires the native hook; no raw output replacement for unsupported lifecycle events. | -| Claude Code | `PostToolUse` rewrites supported result shapes; `PostToolBatch` excludes an own-command result only after confirming that its model-visible content is already safe. | `PostToolUseFailure` has no output rewrite channel; a sensitive failure result in the batch stops continuation. | -| OpenClaw / QClaw | `tool_result_persist` synchronously rewrites matched text in OpenClaw-owned session transcripts. | Requires host support and a matching `toolCallId`; cannot change results already displayed or Codex-native records. | -| Hermes | Native `transform_tool_result` rewrites results before model context; `/agentguard` uses the same redactor. | Legacy shell hooks expose only an audit-only post event and cannot rewrite tool output. | -| DSH | `tools/post-execute` can return redacted text or structured values with `kind: accept`. | Results blocked by another policy are not rewritten by this hook. | +Standalone `agentguard` commands and direct `npx @goplus/agentguard` commands +pass through post-tool hooks without blocking or redaction, regardless of their +output content or result shape. Simple `command`, `env`, and shell wrappers are +recognized; an explicit executable path must resolve to this package's CLI. +Commands that explicitly redirect executable lookup or Node module loading do +not receive this exemption. +An HTTPS `curl -fsSL …/install.sh | bash` result receives the same exemption +only when its output has the AgentGuard Cloud installer header near the start +and ordered installation stage markers. The URL host is not used as an allowlist. These +markers can be imitated by another script; this is the accepted limit of +attributing a merged shell result without a trusted process identity. In Claude Code, +`PostToolBatch` excludes the whole recognized call from its inspection copy, +while retaining the host's original result. Other calls in a mixed batch remain +subject to the normal privacy gate. Compound commands, unverified paths, and +unrelated tools retain ordinary protection. The installer and `agentguard +approve` still receive their normal checks before execution. + +This intentionally means credentials or personal data printed by AgentGuard +commands can reach the agent unchanged. AgentGuard also does not rewrite those +results in OpenClaw, QClaw, Hermes, or DSH. Claude `MessageDisplay` protects +assistant-authored display text separately; it cannot establish which command +produced a sentence in that text. ## Public web-search results diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md index c90dc5f..44ed915 100644 --- a/plugins/hermes/README.md +++ b/plugins/hermes/README.md @@ -38,9 +38,8 @@ Or copy this directory to `~/.hermes/plugins/agentguard/` manually. | `post_api_request` | Observes and correlates main-loop responses; never blocks them. | | `pre_tool_call` | Evaluates the call; returns `{"action":"block","message":...}` to veto a dangerous action. | | `post_tool_call` | Audit-only; never blocks. | -| `transform_tool_result` | Redacts AgentGuard CLI results before model context; preserves the saved JWT activation link. | | `on_session_start` | Best-effort background scan of installed skills (opt out: `AGENTGUARD_HERMES_AUTOSCAN=0`). | -| `/agentguard` | Slash command — `status`, `report` (default), or `checkup`. | +| `/agentguard` | Slash command — `status`, `report` (default), or `checkup`; output passes through unchanged. | Tools evaluated (others pass through untouched): `terminal`, `execute_code`, `write_file`, `patch`, `skill_manage`, `read_file`, `web_search`, `web_extract`, diff --git a/plugins/hermes/bridge.py b/plugins/hermes/bridge.py index 03a4220..177d9da 100644 --- a/plugins/hermes/bridge.py +++ b/plugins/hermes/bridge.py @@ -258,19 +258,6 @@ def _observe_llm( request_id=request_id, ) - def redact_self_output(self, text: str) -> str: - """Use AgentGuard's shared local redactor for one own-command result.""" - bin_path = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard") - if not bin_path: - raise RuntimeError("AgentGuard redactor is not installed") - proc = subprocess.run( - [bin_path, "self-output"], input=text, capture_output=True, - text=True, timeout=self.timeout, encoding="utf-8", - ) - if proc.returncode != 0: - raise RuntimeError("AgentGuard redactor failed") - return proc.stdout - def run_cli(self, args: list) -> str: """Run an ``agentguard`` subcommand and return stdout (for /agentguard).""" bin_path = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard") @@ -287,11 +274,7 @@ def run_cli(self, args: list) -> str: ) except subprocess.SubprocessError as exc: return "AgentGuard CLI failed: %s" % exc - output = (proc.stdout or proc.stderr or "").strip() - try: - return self.redact_self_output(output) - except Exception: - return "AgentGuard output could not be safely redacted; run the command directly in your terminal." + return proc.stdout or proc.stderr or "" # -- internals --------------------------------------------------------- diff --git a/plugins/hermes/plugin.py b/plugins/hermes/plugin.py index 59df859..12ccffe 100644 --- a/plugins/hermes/plugin.py +++ b/plugins/hermes/plugin.py @@ -9,7 +9,6 @@ from __future__ import annotations import os -import shlex import logging import shutil import subprocess @@ -34,7 +33,6 @@ def register(ctx: Any, bridge: Optional[AgentGuardBridge] = None) -> None: ctx.register_hook("post_api_request", _make_post_api_request(guard)) ctx.register_hook("pre_tool_call", _make_pre_tool_call(guard)) ctx.register_hook("post_tool_call", _make_post_tool_call(guard)) - ctx.register_hook("transform_tool_result", _make_transform_tool_result(guard)) ctx.register_hook("on_session_start", _make_session_start(guard)) register_command = getattr(ctx, "register_command", None) @@ -138,73 +136,6 @@ def post_tool_call(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwar return post_tool_call -def _is_standalone_agentguard_command(command: Any, depth: int = 0) -> bool: - if depth > 2: - return False - if not isinstance(command, str) or any(c in command for c in ";&|<>`\n\r\t") or "$(" in command: - return False - try: - tokens = shlex.split(command) - except ValueError: - return False - if not tokens: - return False - index = 0 - while index < len(tokens) and _is_shell_assignment(tokens[index]): - index += 1 - if index < len(tokens) and os.path.basename(tokens[index]) == "env": - index += 1 - while index < len(tokens) and tokens[index].startswith("-"): - index += 1 - while index < len(tokens) and _is_shell_assignment(tokens[index]): - index += 1 - while index < len(tokens) and tokens[index] in ("command", "builtin"): - index += 1 - if index >= len(tokens): - return False - executable = tokens[index] - if executable == "agentguard": - return True - installed = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard") - if (os.path.isabs(executable) and os.path.basename(executable) == "agentguard" - and installed and os.path.realpath(executable) == os.path.realpath(installed)): - return True - if os.path.basename(executable) in ("sh", "bash", "zsh", "dash", "fish"): - for flag_index in range(index + 1, len(tokens) - 1): - flag = tokens[flag_index] - if not flag.startswith("-"): - break - if "c" in flag: - return _is_standalone_agentguard_command(tokens[flag_index + 1], depth + 1) - return False - - -def _is_shell_assignment(token: str) -> bool: - name, separator, _value = token.partition("=") - return bool(separator) and name.isidentifier() - - -def _make_transform_tool_result(guard: AgentGuardBridge): - def transform_tool_result(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwargs: Any): - if tool_name not in ("terminal", "execute_code") or not isinstance(args, dict): - return None - command = args.get("command") or args.get("code") - if not _is_standalone_agentguard_command(command): - return None - result = kwargs.get("result") - if not isinstance(result, str): - return None - try: - redacted = guard.redact_self_output(result) - return redacted if redacted != result else None - except Exception: - # This hook runs before Hermes puts the result in model context. - # Never hand raw credentials to the model if the local redactor fails. - return "AgentGuard output could not be safely redacted; run the command directly in your terminal." - - return transform_tool_result - - def _make_session_start(guard: AgentGuardBridge): def on_session_start(*_args: Any, **_kwargs: Any): # Best-effort background scan of installed skills, mirroring the shell diff --git a/plugins/hermes/tests/test_allow.py b/plugins/hermes/tests/test_allow.py index ed2b87d..96cdcf5 100644 --- a/plugins/hermes/tests/test_allow.py +++ b/plugins/hermes/tests/test_allow.py @@ -17,7 +17,6 @@ def test_register_wires_all_hooks_and_command(): "post_api_request", "pre_tool_call", "post_tool_call", - "transform_tool_result", "on_session_start", } assert "agentguard" in ctx.commands diff --git a/plugins/hermes/tests/test_self_output.py b/plugins/hermes/tests/test_self_output.py index 79f8930..963b4f0 100644 --- a/plugins/hermes/tests/test_self_output.py +++ b/plugins/hermes/tests/test_self_output.py @@ -1,57 +1,15 @@ -"""Native Hermes own-command results are rewritten before model context.""" - -import json -import shlex -import shutil -from pathlib import Path +"""Native Hermes leaves AgentGuard command results untouched.""" from helpers import make_protect_runner, register_with -def test_transform_hook_scopes_standalone_commands_and_fails_safe(monkeypatch): - ctx, guard = register_with(make_protect_runner()) - guard.redact_self_output = lambda text: text.replace("sk-test-secret-1234567890", "[REDACTED]") - transform = ctx.hooks["transform_tool_result"] - own = '{"output":"api_key=sk-test-secret-1234567890"}' - assert transform("terminal", {"command": "agentguard scan ./skill"}, result=own) == '{"output":"api_key=[REDACTED]"}' - assert transform("terminal", {"command": "command agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}' - assert transform("execute_code", {"code": "agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}' - assert transform("terminal", {"command": "agentguard status; cat .env"}, result=own) is None - assert transform("terminal", {"command": "cat .env"}, result=own) is None - assert transform("read_file", {"path": "x"}, result=own) is None - assert transform("terminal", {"command": "agentguard status"}, result="public") is None - guard.redact_self_output = lambda _text: (_ for _ in ()).throw(RuntimeError("sk-private-value")) - failed = transform("terminal", {"command": "agentguard status"}, result=own) - assert "safely redacted" in failed - assert "sk-test-secret" not in failed +def test_no_result_transform_hook_is_registered(): + ctx, _ = register_with(make_protect_runner()) + assert "transform_tool_result" not in ctx.hooks -def test_bridge_uses_shared_redactor_and_preserves_only_the_saved_link(monkeypatch, tmp_path): - node = shutil.which("node") - assert node - cli = Path(__file__).resolve().parents[3] / "dist" / "cli.js" - shim = tmp_path / "agentguard" - shim.write_text("#!/bin/sh\nexec " + shlex.quote(node) + " " + shlex.quote(str(cli)) + ' "$@"\n') - shim.chmod(0o755) - monkeypatch.setenv("AGENTGUARD_BIN", str(shim)) - monkeypatch.setenv("AGENTGUARD_HOME", str(tmp_path)) - link = "https://agentguard.example/activate?token=hermes-self-link-123456" - (tmp_path / "config.json").write_text(json.dumps({ - "version": 1, "level": "balanced", "agentJwt": "agent.jwt.test", "agentRegisterUrl": link, - "cloudUrl": "http://127.0.0.1:9", - })) +def test_slash_command_returns_raw_cli_output(monkeypatch): ctx, guard = register_with(make_protect_runner()) - output = f"{link}\napi_key=sk-hermes-secret-1234567890" - rewritten = ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard connect"}, result=output) - assert link in rewritten - assert "hermes-secret" not in rewritten - assert "api_key=[REDACTED]" in rewritten - absolute = ctx.hooks["transform_tool_result"]("terminal", {"command": f"{shim} status"}, result=output) - assert link in absolute and "hermes-secret" not in absolute - assert ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard status"}, result=link) is None - forged = guard.redact_self_output(output.replace(link, link + "-forged")) - assert "hermes-self-link-123456-forged" not in forged - assert "sk-hermes-secret" not in forged - slash_status = ctx.commands["agentguard"][0]("status") - assert link in slash_status - assert "TYPESAFE_API_KEY=" in slash_status + monkeypatch.setattr(guard, "run_cli", lambda args: "api_key=sk-hermes-secret-1234567890") + output = ctx.commands["agentguard"][0]("status") + assert output == "api_key=sk-hermes-secret-1234567890" diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index 373ec69..a09165d 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -41,7 +41,6 @@ import { import type { RuntimeActionType } from '../runtime/types.js'; import type { AgentGuardConfig } from '../config.js'; import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; -import { redactAgentGuardSelfToolOutput } from '../runtime/self-output.js'; // --------------------------------------------------------------------------- // OpenClaw Types (subset we use) @@ -370,10 +369,6 @@ export function registerOpenClawPlugin( const trustRegistry = options.registry ?? new SkillRegistry(); const runProtectAction = options.protectAction ?? protectAction; const runtimeProtectionEnabled = options.runtimeProtection !== false; - // Only a verified before_tool_call can mark a later persisted tool result as - // AgentGuard-owned. tool_result_persist does not expose tool arguments. - const pendingSelfResults = new Map(); - // Simple logger const logger = (msg: string) => console.log(msg); @@ -502,23 +497,6 @@ export function registerOpenClawPlugin( // Try to infer plugin from tool name const toolName = normalized.toolName; const pluginId = toolName ? getPluginIdFromTool(toolName) : null; - const rawEvent = event && typeof event === 'object' ? event as Record : {}; - const rawCtx = ctx && typeof ctx === 'object' ? ctx as Record : {}; - const callId = rawEvent.toolCallId ?? rawCtx.toolCallId; - const args = rawEvent.params ?? rawEvent.toolInput ?? rawEvent.args; - const command = args && typeof args === 'object' - ? (args as Record).command ?? (args as Record).cmd : undefined; - if (runtimeProtectionEnabled && normalized.actionType === 'shell' - && typeof command === 'string' && isStandaloneAgentGuardCliCommand(command) - && typeof callId === 'string' && callId.length > 0 && callId.length <= 160) { - if (pendingSelfResults.size >= 512) pendingSelfResults.delete(pendingSelfResults.keys().next().value!); - pendingSelfResults.set(callId, { - toolName, - sessionKey: typeof rawCtx.sessionKey === 'string' ? rawCtx.sessionKey : undefined, - expiresAt: Date.now() + 60_000, - }); - } - // Check if plugin is untrusted if (pluginId) { const scanResult = getPluginScanResult(pluginId); @@ -630,54 +608,6 @@ export function registerOpenClawPlugin( } }); - // OpenClaw-owned transcripts expose a synchronous result rewrite hook. - // This cannot rewrite Codex-native records or results already shown in a UI. - try { - api.on('tool_result_persist', (rawEvent: unknown, rawContext?: unknown) => { - const event = rawEvent && typeof rawEvent === 'object' ? rawEvent as Record : {}; - const ctx = rawContext && typeof rawContext === 'object' ? rawContext as Record : {}; - const toolMessage = event.message && typeof event.message === 'object' - ? event.message as Record : {}; - const callId = event.toolCallId ?? ctx.toolCallId ?? toolMessage.toolCallId; - if (event.isSynthetic === true || typeof callId !== 'string') return undefined; - const pending = pendingSelfResults.get(callId); - if (!pending) return undefined; - if (pending.expiresAt < Date.now()) { - pendingSelfResults.delete(callId); - return undefined; - } - const sessionKey = typeof ctx.sessionKey === 'string' ? ctx.sessionKey - : typeof event.sessionKey === 'string' ? event.sessionKey : undefined; - if (pending.toolName !== (event.toolName ?? ctx.toolName ?? toolMessage.toolName) - || (pending.sessionKey && sessionKey && pending.sessionKey !== sessionKey)) return undefined; - const message = event.message; - if (!message || typeof message !== 'object' || Array.isArray(message)) return undefined; - const toolResult = message as Record; - if (toolResult.role !== 'toolResult') return undefined; - pendingSelfResults.delete(callId); - const current = toolResult.content; - let changed = false; - const sanitize = (text: string) => { - let safe: string; - try { safe = redactAgentGuardSelfToolOutput(text, loadAgentGuardConfig()); } - catch { safe = 'AgentGuard output could not be safely redacted; run the command in your terminal.'; } - if (safe !== text) changed = true; - return safe; - }; - const content = Array.isArray(current) - ? current.map((block) => { - if (!block || typeof block !== 'object' || Array.isArray(block)) return block; - const part = block as Record; - return part.type === 'text' && typeof part.text === 'string' - ? { ...part, text: sanitize(part.text) } : block; - }) - : typeof current === 'string' ? sanitize(current) : current; - return changed ? { message: { ...toolResult, content } } : undefined; - }); - } catch { - logger('[AgentGuard] tool_result_persist is unavailable; AgentGuard CLI output rewrite is limited to supported hosts.'); - } - logger(`[AgentGuard] Registered with OpenClaw (protection level: ${config.level || 'balanced'})`); } diff --git a/src/cli.ts b/src/cli.ts index dd3e840..82bddd1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,7 +31,6 @@ import { SkillRegistry } from './registry/index.js'; import type { TrustRecord } from './types/registry.js'; import { resolveScanSource } from './scanner/source.js'; import { formatProtectResult, protectAction, exitCodeForDecision } from './runtime/protect.js'; -import { redactAgentGuardSelfToolOutput } from './runtime/self-output.js'; import { approvePendingApproval, listPendingApprovals } from './runtime/approvals.js'; import { getDefaultEffectiveRuntimePolicy, loadCachedPolicy, saveCachedPolicy } from './runtime/policy.js'; import type { RuntimeActionType, RuntimeAgentHost } from './runtime/types.js'; @@ -689,9 +688,9 @@ async function main() { } }); - // Internal synchronous bridge for native hosts that rewrite tool results. + // Compatibility bridge for older native plugins; preserve own-command output. program.command('self-output', { hidden: true }).action(() => { - process.stdout.write(redactAgentGuardSelfToolOutput(readStdinIfAvailable(), ensureConfig())); + process.stdout.write(readStdinIfAvailable()); }); program diff --git a/src/dsh/runtime.ts b/src/dsh/runtime.ts index 6de6d40..205739f 100644 --- a/src/dsh/runtime.ts +++ b/src/dsh/runtime.ts @@ -12,8 +12,8 @@ import { rememberPublicSearchResult, } from '../runtime/public-search.js'; import { redactText } from '../runtime/redaction.js'; +import { isAgentGuardApprovalCommand } from '../runtime/protect.js'; import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; -import { redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue } from '../runtime/self-output.js'; import { planDshEnforcement, type DshRuntimePhase } from './enforcement-plan.js'; import { mergeDshPostDecisions, @@ -241,50 +241,15 @@ function isDshAgentGuardCliAction(action: RuntimeAction): boolean { return action.actionType === 'shell' && isStandaloneAgentGuardCliCommand(action.input); } -function rewriteDshAgentGuardResult( - exec: DshToolExecution, - result: DshToolExecutionResult, - downstream: DshPostToolDecision, - dependencies: DshRuntimeDependencies, -): DshPostToolDecision { - if (downstream.kind === 'block' || isAgentGuardDshTool(exec.name)) return downstream; - const action = buildDshRuntimeAction(exec, dependencies.attribution); - if (!isDshAgentGuardCliAction(action)) return downstream; - const content = downstream.content ?? result.content; - if (!content && downstream.value === undefined) return downstream; - try { - const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); - if (downstream.value !== undefined) { - // DSH forbids replacing value and content in the same accept decision. - const safe = redactAgentGuardSelfToolValue(downstream.value, config); - return JSON.stringify(safe) === JSON.stringify(downstream.value) - ? downstream : { ...downstream, value: safe }; - } - if (!content) return downstream; - let changed = false; - const rewritten = content.map((block) => { - if (block.type !== 'text' || typeof block.text !== 'string') return block as DshContentBlock; - const safe = redactAgentGuardSelfToolOutput(block.text, config); - if (safe === block.text) return block as DshContentBlock; - changed = true; - return { ...block, text: safe } as DshContentBlock; - }); - return changed ? { ...downstream, content: rewritten } : downstream; - } catch { - return { kind: 'accept', content: [{ type: 'text', - text: 'AgentGuard output could not be safely redacted; run the command in your terminal.' }] }; - } -} - export async function observeDshToolCall( exec: DshToolExecution, dependencies: DshRuntimeDependencies = {} ): Promise { if (isAgentGuardDshTool(exec.name)) return null; - const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); const action = buildDshRuntimeAction(exec, dependencies.attribution); - if (isDshAgentGuardCliAction(action) && !/^agentguard[ \t]+approve(?:[ \t]|$)/.test(action.input)) return null; + if (isDshAgentGuardCliAction(action) && !isAgentGuardApprovalCommand(action)) return null; + const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); action.metadata = { ...action.metadata, runtimePhase: 'pre' }; return evaluateAndAuditDshAction( action, @@ -301,9 +266,9 @@ export async function protectDshToolCall( ): Promise { if (isAgentGuardDshTool(exec.name)) return null; - const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); const action = buildDshRuntimeAction(exec, dependencies.attribution); - if (isDshAgentGuardCliAction(action) && !/^agentguard[ \t]+approve(?:[ \t]|$)/.test(action.input)) return null; + if (isDshAgentGuardCliAction(action) && !isAgentGuardApprovalCommand(action)) return null; + const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); action.metadata = { ...action.metadata, runtimePhase: 'pre' }; return evaluateAndAuditDshAction(action, config, dependencies, DSH_PROTECT_MODE, true); } @@ -577,10 +542,7 @@ export function createDshPostExecuteObserver( } catch (error) { dependencies.onError?.(error, exec); } - // Only AgentGuard's own shell output can be locally rewritten; other - // observations remain audit-only and downstream blocks are never relaxed. - const downstream = await next(); - return rewriteDshAgentGuardResult(exec, result, downstream, dependencies); + return next(); }; } @@ -602,7 +564,7 @@ export function createDshPostExecuteProtector( } const downstream = await next(); if (!observed || observed.evaluation.decision.decision !== 'block') { - return rewriteDshAgentGuardResult(exec, result, downstream, dependencies); + return downstream; } return mergeDshPostDecisions( translateDshPostDecision(observed.evaluation.decision), diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 1a215ef..46413a9 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -6,11 +6,10 @@ import type { AgentGuardConfig } from '../config.js'; import { consumeApprovedApproval, writePendingApproval, type ApprovalRecord } from './approvals.js'; import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js'; import { evaluateRuntimeAction } from './decision.js'; -import { isAgentGuardCliCommand, isAgentGuardOwnedOutputCommand, isAgentGuardStatusCommand, - isOfficialAgentGuardInstallerCommand, isStandaloneAgentGuardCliCommand } from './self-command.js'; +import { isAgentGuardCliCommand, isAgentGuardOwnedOutputCommand, isAgentGuardOwnedOutputResult, + isStandaloneAgentGuardCliCommand } from './self-command.js'; import { redactText, summarizeSensitiveData } from './redaction.js'; -import { hasExactSavedActivationUrl, redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue, - redactTextPreservingSavedActivationUrl } from './self-output.js'; +import { redactTextPreservingSavedActivationUrl } from './self-output.js'; import { hasSensitivePublicSearchResult, isNativePublicSearchTool, isVisiblePublicSearchResult, inspectWithPublicSearchProvenance, @@ -53,8 +52,6 @@ export interface ProtectResult { approvalChannel?: 'agent' | null; pendingApproval?: ApprovalRecord; policySource: 'cloud' | 'cache' | 'default' | 'cloud-decision'; - /** Hook-only response, never written to the audit log or Cloud. */ - selfToolOutput?: { text: string; value: unknown }; /** Display-only replacement, never written to the audit log or Cloud. */ displayContent?: string; } @@ -85,7 +82,7 @@ type ClaudeHookEvent = const CLAUDE_CONFIG_READ_LIMIT = 256 * 1024; const CLAUDE_LARGE_CONTEXT_TOKENS = 100_000; -const AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER = '[AgentGuard status output suppressed after local validation]'; +const AGENTGUARD_OUTPUT_INSPECTION_PLACEHOLDER = '[AgentGuard command output excluded from inspection]'; export async function protectAction(options: ProtectOptions): Promise { const action = buildRuntimeAction(options); @@ -93,9 +90,10 @@ export async function protectAction(options: ProtectOptions): Promise { - if (key === 'stdout' || key === 'output' || key === 'stderr' || key === 'content') - return typeof value === 'string' && isSupportedNativeSelfOutput(value, config); - if (key === 'exit_code' || key === 'exitCode') return Number.isSafeInteger(value); - if (key === 'interrupted' || key === 'isImage') return typeof value === 'boolean'; - return key === 'agentRegisterUrl' && Boolean(config.agentJwt) - && value === config.agentRegisterUrl; - }); -} - -function hasEncodedSensitiveSelfText(value: unknown, config: AgentGuardConfig): boolean { - if (typeof value === 'string') { - if (!/%[0-9a-f]{2}/i.test(value)) return false; - try { - let inspected = value; - const savedUrl = config.agentJwt && config.agentRegisterUrl; - if (savedUrl && savedUrl.length <= 2048) { - const url = new URL(savedUrl); - if ((url.protocol === 'https:' || url.protocol === 'http:') - && !url.username && !url.password) { - inspected = inspected.replaceAll(savedUrl, '[validated activation link]'); - } - } - const decoded = decodeURIComponent(inspected); - return decoded !== inspected && redactText(decoded) !== decoded; - } catch { - return true; - } - } - if (Array.isArray(value)) return value.some((item) => hasEncodedSensitiveSelfText(item, config)); - return isPlainRecord(value) && Object.values(value).some((item) => hasEncodedSensitiveSelfText(item, config)); -} - -function hasUnmaskedSelfCredential(value: unknown): boolean { - if (typeof value === 'string') { - return value.split(/\r?\n/).some((line) => { - const match = line.match(/^\s*(?:api[_ -]?key|authorization|token|secret|password|passwd|agent[_ -]?jwt)\s*[:=]\s*(.+)$/i); - if (!match) return false; - const detail = match[1]!.trim(); - if (/^(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/i.test(detail)) return true; - const first = detail.split(/\s+/)[0]!.replace(/[.,;!?]+$/, ''); - return !/^(?:configured|disabled|enabled|inactive|missing|not|none|present|redacted|unset|unavailable)$/i.test(first) - && /^[A-Za-z0-9._~+/-]{8,}$/.test(first); - }); - } - if (Array.isArray(value)) return value.some(hasUnmaskedSelfCredential); - return isPlainRecord(value) && Object.values(value).some(hasUnmaskedSelfCredential); -} - -function codexSelfToolFeedback(value: unknown, fallback: string): string { - if (!isPlainRecord(value)) return typeof value === 'string' ? value : fallback; - const recognized = new Set(['stdout', 'output', 'stderr', 'exit_code', 'exitCode']); - if (Object.keys(value).some((key) => !recognized.has(key))) return fallback; - const stdout = firstString(value.stdout, value.output); - const stderr = firstString(value.stderr); - const display = [stdout, stderr].filter(Boolean).join('\n'); - const exitCode = value.exit_code ?? value.exitCode; - if (!display) return fallback; - return typeof exitCode === 'number' && exitCode !== 0 - ? `Exit code: ${exitCode}\n${display}` : display; + // The host owns the original tool result. Do not inspect or rewrite it. + return null; } - function isAgentGuardRuntimeAction(action: RuntimeAction): boolean { return action.actionType === 'shell' && isAgentGuardCliCommand(action.input); } -function isAgentGuardStatusResult(action: RuntimeAction): boolean { - const codexEvent = pickCodexHookEventFromAction(action); - const claudeEvent = pickClaudeHookEventFromAction(action); - const postToolResult = codexEvent === 'PostToolUse' - || claudeEvent === 'PostToolUse' - || claudeEvent === 'PostToolUseFailure'; - if (postToolResult) { - return action.metadata?.agentGuardStatusOutputSuppressed === true; - } - return claudeEvent === 'PostToolBatch' - && action.metadata?.claudeBatchOnlyAgentGuardStatus === true; -} - -function isSafelyMaskedAgentGuardStatusOutput( - output: unknown, - config: AgentGuardConfig, - originalResponse: unknown = output, -): boolean { - const serialized = typeof output === 'string' ? output : JSON.stringify(output); - // The status help intentionally prints this literal placeholder. Normalize only - // that documented value so real keys and unrelated sensitive output stay strict. - let normalized = serialized.replaceAll('TYPESAFE_API_KEY=', 'TYPESAFE_API_KEY=[REDACTED]'); - const activationUrl = config.agentJwt && config.agentRegisterUrl; - if (activationUrl && activationUrl.length <= 2048 && !/[\x00-\x1f\x7f]/.test(activationUrl)) { - try { - const url = new URL(activationUrl); - if (url.protocol === 'https:' && !url.username && !url.password) { - const statusLine = `Agent activation URL: ${activationUrl}`; - const stdout = typeof originalResponse === 'string' ? originalResponse - : isPlainRecord(originalResponse) ? firstString(originalResponse.stdout, originalResponse.output) : ''; - // Only the exact link printed by this installation's pending status is - // exempt. A forged or additional credential in the tool result is not. - if (stdout.split(/\r?\n/).filter((line) => line === statusLine).length === 1) { - normalized = normalized.replace( - JSON.stringify(statusLine).slice(1, -1), - 'Agent activation URL: [validated activation link]', - ); - } - } - } catch { /* Invalid stored URLs do not weaken the content scan. */ } - } - return redactText(normalized) === normalized; -} - -function isAgentGuardApprovalCommand(action: RuntimeAction): boolean { +export function isAgentGuardApprovalCommand(action: RuntimeAction): boolean { if (action.actionType !== 'shell') return false; const normalized = action.input.replace(/\\(.)/gs, '$1').replace(/["']/g, ' '); // Approval is the immediate subcommand. A scan path named "approve" is not @@ -454,7 +275,6 @@ function enforceNativeHookDecision( && action.metadata.claudeBatchSensitive === false; const visibleContentNeedsProtection = action.metadata?.publicSearchSensitive === true || action.metadata?.claudeBatchSensitive === true - || action.metadata?.claudeBatchUnsafeAgentGuardOutput === true || (strictSensitive && !verifiedSearchException && !verifiedBatchException); const containsSensitiveContent = (scansVisibleContent || blockingVisibleModelInput) && (visibleContentNeedsProtection || action.metadata?.configSensitive === true); @@ -778,12 +598,6 @@ function formatCodexHookResult(result: ProtectResult): string | null { const decision = result.decision.decision; const denied = decision === 'block' || decision === 'require_approval'; const reason = safeCodexReason(result, decision === 'require_approval'); - if (event === 'PostToolUse' && result.selfToolOutput) { - // Codex replaces the original model-visible result with hook feedback in - // code mode; this does not reject the completed tool call. - return JSON.stringify({ continue: false, stopReason: result.selfToolOutput.text }); - } - if (event === 'UserPromptSubmit') { if (denied) return JSON.stringify({ decision: 'block', reason }); if (decision === 'warn') return JSON.stringify({ systemMessage: reason }); @@ -830,12 +644,6 @@ function formatClaudeHookResult(result: ProtectResult): string | null { const decision = result.decision.decision; const denied = decision === 'block' || decision === 'require_approval'; const reason = safeCodexReason(result, decision === 'require_approval'); - if (event === 'PostToolUse' && result.selfToolOutput) { - return JSON.stringify({ hookSpecificOutput: { - hookEventName: 'PostToolUse', updatedToolOutput: result.selfToolOutput.value, - } }); - } - if (event === 'UserPromptSubmit' || event === 'UserPromptExpansion') { if (denied) return JSON.stringify({ decision: 'block', reason }); return decision === 'warn' ? JSON.stringify({ systemMessage: reason }) : ''; @@ -979,7 +787,7 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { : codexLifecycleFields(codexHookEvent); const sessionId = options.sessionId || process.env.AGENTGUARD_SESSION_ID || pickSessionId(raw); const claudeBatchProjection = claudeHookEvent === 'PostToolBatch' - ? projectClaudeBatchForInspection(raw?.tool_calls, options.config) + ? projectClaudeBatchForInspection(raw?.tool_calls) : undefined; const claudeBatch = claudeBatchProjection ? claudeBatchFacts( @@ -989,7 +797,11 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { ) : undefined; const claudeConfig = claudeHookEvent === 'ConfigChange' ? readClaudeConfigChange(raw) : undefined; - const verifiedOutput = claudeHookEvent === 'PostToolUse' + const ownNativeToolOutput = (codexHookEvent === 'PostToolUse' + || claudeHookEvent === 'PostToolUse' || claudeHookEvent === 'PostToolUseFailure') + && mapToolToRuntimeAction(toolName, raw) === 'shell' + && isAgentGuardOwnedOutputResult(pickNativeToolAuditInput('shell', toolInput), nativeToolResult(raw)); + const verifiedOutput = claudeHookEvent === 'PostToolUse' && !ownNativeToolOutput ? redactVerifiedClaudeToolOutput(toolName, raw?.tool_response ?? raw?.toolResponse) : undefined; const observedSearchResult = resolveObservedSearchResult(agentHost, toolName, raw, options.phase, @@ -1048,12 +860,7 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { toolInput, claudeHookEvent, ); - const agentGuardStatusOutputSuppressed = nativePostToolEvent - && nativeToolActionType === 'shell' - && isAgentGuardStatusCommand(nativeToolInput) - && isSafelyMaskedAgentGuardStatusOutput(actionInput, options.config, raw?.tool_response ?? raw?.toolResponse); - if (agentGuardStatusOutputSuppressed) actionInput = AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; - const unknownSensitiveToolOutput = claudeHookEvent === 'PostToolUse' + const unknownSensitiveToolOutput = claudeHookEvent === 'PostToolUse' && !ownNativeToolOutput && verifiedOutput === undefined && redactText(actionInput) !== actionInput; return { @@ -1086,20 +893,16 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { ...(nativeToolInput ? { nativeToolInput } : {}), ...(nativeToolArgumentKeys.length > 0 ? { nativeToolArgumentKeys } : {}), ...(nativeFileTargets.length > 0 ? { nativeFileTargets } : {}), - ...(agentGuardStatusOutputSuppressed ? { agentGuardStatusOutputSuppressed: true } : {}), ...pickNetworkMetadata(raw, toolInput), ...pickPostToolOutcomeMetadata(codexHookEvent, claudeHookEvent, raw), ...pickFilePathMetadata(raw), ...claudeEventMetadata(claudeHookEvent, raw), ...(claudeBatch?.metadata || {}), ...(claudeBatchProjection && claudeBatchProjection.suppressedCount > 0 ? { - claudeBatchAgentGuardStatusOutputsSuppressed: claudeBatchProjection.suppressedCount, - claudeBatchOnlyAgentGuardStatus: + claudeBatchAgentGuardOutputsSuppressed: claudeBatchProjection.suppressedCount, + claudeBatchOnlyAgentGuardOutput: claudeBatchProjection.suppressedCount === claudeBatchProjection.originalCalls.length, } : {}), - ...(claudeBatchProjection?.unsafeSelfOutputCount ? { - claudeBatchUnsafeAgentGuardOutput: true, - } : {}), ...(claudeBatch && process.env.TOOL_INPUT ? { claudeBatchSensitive: undefined, claudeBatchVerified: false } : {}), ...(claudeConfig ? { @@ -1217,7 +1020,10 @@ function validateNativeClaudeHook(raw: Record | null): void { } if (event === 'PostToolBatch' && (!Array.isArray(raw.tool_calls) || !raw.tool_calls.every((call) => isPlainRecord(call) - && Object.prototype.hasOwnProperty.call(call, 'tool_response')))) { + && (Object.prototype.hasOwnProperty.call(call, 'tool_response') + || (mapToolToRuntimeAction(firstString(call.tool_name, call.toolName), call) === 'shell' + && isAgentGuardOwnedOutputResult(pickNativeToolAuditInput('shell', pickToolInput(call)), + nativeToolResult(call))))))) { throw invalidClaudePayload(); } if (event === 'ConfigChange' && (typeof raw.source !== 'string' @@ -1504,7 +1310,7 @@ function claudeBatchFacts( ): { llm: LlmEgressRequestMetadata; metadata: Record } { const serialized = JSON.stringify(originalResponses); const inspectionSerialized = JSON.stringify(inspectionResponses); - const filePaths = collectClaudeBatchFilePaths(originalResponses); + const filePaths = collectClaudeBatchFilePaths(inspectionResponses); const containsSensitiveContent = hasSensitiveClaudeBatchContent(inspectionResponses); const verifiedSearchCalls = Array.isArray(inspectionResponses) ? inspectionResponses.filter((call): call is Record => @@ -1542,63 +1348,27 @@ function claudeBatchFacts( function projectClaudeBatchForInspection( responses: unknown, - config: AgentGuardConfig, -): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number; unsafeSelfOutputCount: number } { +): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number } { if (!Array.isArray(responses)) return { - originalCalls: [], inspectionCalls: [], suppressedCount: 0, unsafeSelfOutputCount: 0, + originalCalls: [], inspectionCalls: [], suppressedCount: 0, }; let suppressedCount = 0; - let unsafeSelfOutputCount = 0; const inspectionCalls = responses.map((call) => { if (!isPlainRecord(call)) return call; const toolName = firstString(call.tool_name, call.toolName, call.name); const toolInput = pickToolInput(call); const command = pickNativeToolAuditInput('shell', toolInput); if (mapToolToRuntimeAction(toolName, call) !== 'shell' - || !isAgentGuardOwnedOutputCommand(command)) return call; - const responseKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') - ? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; - if (isOfficialAgentGuardInstallerCommand(command)) { - const output = responseKey ? call[responseKey] : call.error ?? call.error_message; - if (!hasExactSavedActivationUrl(output, config)) return call; - } - if (!responseKey) { - const error = firstString(call.error, call.error_message); - if (redactText(error) !== error || hasUnmaskedSelfCredential(error) - || hasEncodedSensitiveSelfText(error, config)) unsafeSelfOutputCount += 1; - return call; - } - const response = call[responseKey]; - // PostToolBatch carries model-visible content. Suppress only content that - // is already safe. Failed or rejected PostToolUse rewrites remain visible - // to the batch privacy gate. - try { - const originalText = typeof response === 'string' ? response : JSON.stringify(response); - if (!isSupportedNativeSelfOutput(response, config) - || hasEncodedSensitiveSelfText(response, config) - || redactAgentGuardSelfToolOutput(response, config) !== originalText - || hasUnmaskedSelfCredential(response)) { - unsafeSelfOutputCount += 1; - return call; - } - } catch { - unsafeSelfOutputCount += 1; - return call; - } - const projectedResponse = isPlainRecord(response) - ? { exit_code: nonNegativeInteger(response.exit_code ?? response.exitCode), - output: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER } - : AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; - const projected = { ...call, [responseKey]: projectedResponse }; - for (const key of ['tool_input', 'toolInput']) { - if (Object.prototype.hasOwnProperty.call(projected, key)) { - projected[key] = { command: '[AgentGuard CLI invocation suppressed]' }; - } - } + || !isAgentGuardOwnedOutputResult(command, nativeToolResult(call))) return call; + // This is only an inspection copy. Exclude the whole own-command call so + // future result fields cannot trigger a batch block. The host keeps it. + const projected = { tool_name: toolName, + tool_input: { command: '[AgentGuard CLI invocation suppressed]' }, + tool_response: AGENTGUARD_OUTPUT_INSPECTION_PLACEHOLDER }; suppressedCount += 1; return projected; }); - return { originalCalls: responses, inspectionCalls, suppressedCount, unsafeSelfOutputCount }; + return { originalCalls: responses, inspectionCalls, suppressedCount }; } function hasSensitiveClaudeBatchContent(responses: unknown): boolean { @@ -1706,6 +1476,11 @@ function parseRawInput(rawInput: unknown, stdinText?: string, strictJson = false } } +function nativeToolResult(raw: Record | null): unknown { + return raw?.tool_response ?? raw?.toolResponse ?? raw?.tool_output ?? raw?.toolOutput + ?? raw?.error ?? raw?.error_message ?? raw?.stderr; +} + function pickToolName(raw: Record | null): string { if (!raw) return 'Tool'; return String(raw.tool_name || raw.toolName || raw.name || 'Tool'); diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index 1919ddc..524fbf8 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -18,6 +18,10 @@ const SUPPORTED_AGENT_COMMANDS = [ ]; const SHELL_CONTROL_RE = /[;&|<>`\n\r\t]|\$\(/; const SHELL_EXECUTABLES = new Set(['sh', 'bash', 'zsh', 'dash', 'fish']); +const EXECUTION_REDIRECTING_ENV = new Set([ + 'PATH', 'NODE_OPTIONS', 'NODE_PATH', 'BASH_ENV', 'ENV', 'ZDOTDIR', + 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', +]); export function isAgentGuardCliCommand(command: string): boolean { return isAgentGuardCliCommandInner(command, 0); @@ -31,13 +35,55 @@ export function isStandaloneAgentGuardCliCommand(command: string): boolean { return isStandaloneAgentGuardCliCommandInner(command, 0); } -/** Recognize the documented installer only for post-tool output sanitization. */ +/** Recognize only a standalone invocation of this package. */ export function isAgentGuardOwnedOutputCommand(command: string): boolean { - return isStandaloneAgentGuardCliCommand(command) || isOfficialAgentGuardInstallerCommand(command); + return isStandaloneAgentGuardCliCommandInner(command, 0, true); } -export function isOfficialAgentGuardInstallerCommand(command: string): boolean { - return /^curl[ \t]+-fsSL[ \t]+https:\/\/(?:www\.)?agentguard\.one\/install\.sh[ \t]*\|[ \t]*bash$/.test(command.trim()); +/** The installer is attributed from its finished result, never from its URL alone. */ +export function isAgentGuardOwnedOutputResult(command: string, result: unknown): boolean { + return isAgentGuardOwnedOutputCommand(command) + || (isInstallerPipeline(command) && hasInstallerOutputSignature(result)); +} + +function isInstallerPipeline(command: string): boolean { + const match = /^curl -fsSL (https:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?\/install\.sh) \| bash$/.exec(command.trim()); + if (!match) return false; + try { + const url = new URL(match[1]); + return url.protocol === 'https:' && !url.username && !url.password + && !url.search && !url.hash && url.pathname === '/install.sh'; + } catch { + return false; + } +} + +function hasInstallerOutputSignature(result: unknown): boolean { + const chunks: string[] = []; + let totalLength = 0; + const visit = (value: unknown, depth: number): void => { + if (depth > 4 || totalLength >= 128 * 1024) return; + if (typeof value === 'string') { + const chunk = value.slice(0, 128 * 1024 - totalLength); + chunks.push(chunk); + totalLength += chunk.length; + } else if (Array.isArray(value)) { + for (const item of value) visit(item, depth + 1); + } else if (value && typeof value === 'object') { + const record = value as Record; + for (const key of ['stdout', 'output', 'stderr', 'content', 'text', 'error', 'error_message']) { + if (key in record) visit(record[key], depth + 1); + } + } + }; + visit(result, 0); + const output = chunks.join('\n'); + const header = output.indexOf('AgentGuard Cloud — Local-first AI agent protection'); + const install = output.indexOf('[1/4] Installing open-source AgentGuard CLI...', header + 1); + const config = output.indexOf('[2/4] Preparing local AgentGuard config...', install + 1); + // The website prints the header before package installation; wrappers may + // prepend a small amount of host text, but unrelated long output is not own. + return header >= 0 && header <= 2048 && install > header && config > install; } export function isAgentGuardStatusCommand(command: string): boolean { @@ -54,11 +100,13 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean { const tokens = shellTokens(trimmed); if (!tokens.length) return false; - let index = skipAssignments(tokens, 0); + let index = skipSafeAssignments(tokens, 0); + if (index === null) return false; if (basename(tokens[index]) === 'env') { index += 1; while (tokens[index]?.startsWith('-')) index += 1; - index = skipAssignments(tokens, index); + index = skipSafeAssignments(tokens, index); + if (index === null) return false; } while (['command', 'builtin'].includes(basename(tokens[index] || ''))) { @@ -74,36 +122,38 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean { return wrappedCommand ? isAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; } -function isStandaloneAgentGuardCliCommandInner(command: string, depth: number): boolean { +function isStandaloneAgentGuardCliCommandInner(command: string, depth: number, outputOnly = false): boolean { if (depth > 2) return false; const trimmed = command.trim(); if (!trimmed || SHELL_CONTROL_RE.test(trimmed)) return false; const tokens = shellTokens(trimmed); if (!tokens.length) return false; - let index = skipAssignments(tokens, 0); + let index = skipSafeAssignments(tokens, 0); + if (index === null) return false; if (basename(tokens[index]) === 'env') { index += 1; while (tokens[index]?.startsWith('-')) index += 1; - index = skipAssignments(tokens, index); + index = skipSafeAssignments(tokens, index); + if (index === null) return false; } while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1; if (isVerifiedAgentGuardExecutable(tokens[index])) return true; - if (isVerifiedAgentGuardNpxInvocation(tokens, index)) return true; + if (isVerifiedAgentGuardNpxInvocation(tokens, index, outputOnly)) return true; const wrappedCommand = shellWrapperCommand(tokens, index); - return wrappedCommand ? isStandaloneAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; + return wrappedCommand ? isStandaloneAgentGuardCliCommandInner(wrappedCommand, depth + 1, outputOnly) : false; } -function isVerifiedAgentGuardNpxInvocation(tokens: string[], start: number): boolean { +function isVerifiedAgentGuardNpxInvocation(tokens: string[], start: number, outputOnly = false): boolean { if (tokens[start] !== 'npx' || !tokens.slice(0, start).every((token) => token === 'command' || token === 'builtin')) return false; let index = start + 1; if (tokens[index] === '-y' || tokens[index] === '--yes') index += 1; return /^@goplus\/agentguard(?:@(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?|latest|beta))?$/.test(tokens[index] || '') - && ['init', 'connect', 'disconnect', 'status', 'policy', 'privacy', 'doctor', + && (outputOnly || ['init', 'connect', 'disconnect', 'status', 'policy', 'privacy', 'doctor', 'scan', 'dsh-scan', 'dsh-scan-batch', 'dsh-compare', 'approvals', - 'subscribe', 'checkup'].includes(tokens[index + 1] || ''); + 'subscribe', 'checkup'].includes(tokens[index + 1] || '')); } function isVerifiedAgentGuardExecutable(executable: string | undefined): boolean { @@ -122,9 +172,11 @@ function matchesCommand(tokens: string[], start: number, command: string): boole return expected.every((part, offset) => basename(tokens[start + offset] || '') === part); } -function skipAssignments(tokens: string[], start: number): number { +function skipSafeAssignments(tokens: string[], start: number): number | null { let index = start; while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] || '')) { + const name = tokens[index]!.slice(0, tokens[index]!.indexOf('=')); + if (EXECUTION_REDIRECTING_ENV.has(name)) return null; index += 1; } return index; diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts index 34db9a3..6e213e5 100644 --- a/src/runtime/self-output.ts +++ b/src/runtime/self-output.ts @@ -2,93 +2,30 @@ import { randomBytes } from 'node:crypto'; import type { AgentGuardConfig } from '../config.js'; import { redactText } from './redaction.js'; -const PUBLIC_KEY_EXAMPLE = 'TYPESAFE_API_KEY='; -const SECRET_FIELD = /^(?:token|api[_-]?key|secret|password|passwd|authorization|access[_-]?key|client[_-]?secret|signature|sig|agentJwt)$/i; - -/** Locally sanitize AgentGuard-owned results without masking the saved JWT binding link. */ -export function redactAgentGuardSelfToolOutput(response: unknown, config: AgentGuardConfig): string { - const safe = redactAgentGuardSelfToolValue(response, config); - return typeof safe === 'string' ? safe : JSON.stringify(safe); -} - -/** Preserve structured tool results so native hosts can accept the replacement. */ -export function redactAgentGuardSelfToolValue(response: unknown, config: AgentGuardConfig): unknown { - if (typeof response === 'string') return redactSelfText(response, config); - if (typeof response === 'number') { - const text = String(response); - return redactText(text) === text ? response : '[REDACTED]'; - } - if (Array.isArray(response)) return response.map((item) => redactAgentGuardSelfToolValue(item, config)); - if (response && typeof response === 'object') { - return Object.fromEntries(Object.entries(response).map(([key, value]) => [ - key, - SECRET_FIELD.test(key) && typeof value === 'string' && value !== '' - ? '[REDACTED]' - : redactAgentGuardSelfToolValue(value, config), - ])); - } - return response; -} - -function redactSelfText(text: string, config: AgentGuardConfig): string { - const trimmed = text.trim(); - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - try { - const parsed = JSON.parse(text) as unknown; - if (parsed && typeof parsed === 'object') { - const safe = redactAgentGuardSelfToolValue(parsed, config); - return JSON.stringify(safe) === JSON.stringify(parsed) ? text : JSON.stringify(safe); - } - } catch { /* Plain text still receives the bounded text redactor. */ } - } - return redactTextPreservingSavedActivationUrl(text, config); -} - -/** Keep only this installation's pending activation link visible to the user. */ +const PUBLIC_KEY_EXAMPLES = ['TYPESAFE_API_KEY=', 'AGENTGUARD_API_KEY=']; +/** Mask assistant display text while keeping the saved activation link visible. */ export function redactTextPreservingSavedActivationUrl(text: string, config: AgentGuardConfig): string { const activationUrl = config.agentJwt && config.agentRegisterUrl; const marker = randomMarker('AGENTGUARDLINK'); - const exampleMarker = randomMarker('AGENTGUARDEXAMPLE'); - const withExample = text.replaceAll(PUBLIC_KEY_EXAMPLE, exampleMarker); + const examples = PUBLIC_KEY_EXAMPLES.map((example) => ({ example, marker: randomMarker('AGENTGUARDEXAMPLE') })); + const withExamples = examples.reduce((value, { example, marker: replacement }) => + value.replaceAll(example, replacement), text); + const restoreExamples = (value: string): string => examples.reduce((restored, { example, marker: replacement }) => + restored.replaceAll(replacement, example), value); if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) - return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); + return restoreExamples(redactText(withExamples)); try { const url = new URL(activationUrl); if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { - return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); + return restoreExamples(redactText(withExamples)); } // The exact URL saved after registration is user-facing and must remain // clickable. A fresh per-call marker keeps every other credential subject - // to normal redaction, even elsewhere in the same result. - const protectedText = protectExactActivationUrl(withExample, activationUrl, marker); - return redactText(protectedText).replaceAll(marker, activationUrl) - .replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); - } catch { - return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); - } -} - -/** Require a complete saved link before treating an installer result as owned output. */ -export function hasExactSavedActivationUrl(value: unknown, config: AgentGuardConfig): boolean { - const activationUrl = config.agentJwt && config.agentRegisterUrl; - if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) return false; - try { - const url = new URL(activationUrl); - if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) return false; - const seen = new WeakSet(); - let visited = 0; - const contains = (item: unknown, depth: number): boolean => { - if (++visited > 1000 || depth > 16) return false; - if (typeof item === 'string') { - return protectExactActivationUrl(item, activationUrl, '[validated activation link]') !== item; - } - if (!item || typeof item !== 'object' || seen.has(item)) return false; - seen.add(item); - return Object.values(item).some((nested) => contains(nested, depth + 1)); - }; - return contains(value, 0); + // to normal redaction, even elsewhere in the same display message. + const protectedText = protectExactActivationUrl(withExamples, activationUrl, marker); + return restoreExamples(redactText(protectedText).replaceAll(marker, activationUrl)); } catch { - return false; + return restoreExamples(redactText(withExamples)); } } diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index 2eb6fc7..a30d6a8 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -175,188 +175,30 @@ describe('Claude Code native lifecycle hooks', () => { assert.equal(existsSync(fixture.config.auditPath), false); }); - it('allows only the configured activation link in native status and batch results', async () => { + it('passes all standalone AgentGuard results unchanged across native post hooks', async () => { const fixture = lifecycleFixture(); - const activationUrl = 'https://agentguard.example/activate?token=pending-claude-link-123456'; - fixture.config.agentJwt = 'test.jwt.pending'; - fixture.config.agentRegisterUrl = activationUrl; - const statusOutput = `Agent activation URL: ${activationUrl}\nAPI key: not used for this connection`; - const statusCall = { - tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: { stdout: statusOutput, exit_code: 0 }, - }; - const post = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolUse', session_id: 'sess-pending-status', cwd: fixture.root, - ...statusCall, - }, - }); - assert.equal(post, null); - const punctuated = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-punctuated-link', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, - tool_response: { stdout: `Open (${activationUrl}).\napi_key=sk-other-secret-1234567890`, exit_code: 0 }, - }); - assert.equal(punctuated.result.decision.decision, 'allow'); - assert.match(punctuated.output.hookSpecificOutput.updatedToolOutput.stdout, /Open \(https:/); - assert.ok(punctuated.output.hookSpecificOutput.updatedToolOutput.stdout.includes(`(${activationUrl}).`)); - assert.doesNotMatch(punctuated.text, /other-secret/); - - const structuredLink = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-structured-link', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, - tool_response: { agentRegisterUrl: activationUrl, stderr: 'api_key=sk-field-secret-1234567890', exit_code: 0 }, - }); - assert.equal(structuredLink.result.decision.decision, 'allow'); - assert.equal(structuredLink.output.hookSpecificOutput.updatedToolOutput.agentRegisterUrl, activationUrl); - assert.doesNotMatch(structuredLink.text, /field-secret/); - - const encodedLink = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-encoded-link', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, - tool_response: { stdout: encodeURIComponent(activationUrl), exit_code: 0 }, - }); - assert.equal(encodedLink.result.decision.decision, 'block'); - assert.equal(encodedLink.output.hookSpecificOutput, undefined); - assert.doesNotMatch(encodedLink.text, /pending-claude-link/); - - const batch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-pending-batch', cwd: fixture.root, - tool_calls: [statusCall], - }, - }); - assert.equal(batch, null); - assert.equal(existsSync(fixture.config.auditPath), false); - - const forgedBatch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-forged-batch', cwd: fixture.root, - tool_calls: [{ ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 } }], - }, - }); - assert.equal(forgedBatch?.decision.decision, 'block', - 'the batch must catch a result that was not rewritten before model use'); - const unknownBatch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-unknown-batch', cwd: fixture.root, - tool_calls: [{ ...statusCall, tool_response: { - stdout: 'healthy', card: 5284917302468138, exit_code: 0, - } }], - }, - }); - assert.equal(unknownBatch?.decision.decision, 'block'); - const failedBatch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-failed-batch', cwd: fixture.root, - tool_calls: [{ tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - error: 'api_key=sk-failed-batch-secret-1234567890' }], - }, - }); - assert.equal(failedBatch?.decision.decision, 'block'); - const forgedPost = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-forged-post', cwd: fixture.root, - ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 }, - }); - assert.equal(forgedPost.result.decision.decision, 'allow'); - assert.doesNotMatch(forgedPost.text, /pending-claude-link-123456-forged/); - - const encodedSavedUrl = 'https://agentguard.example/activate?token=saved%2Fsegment-123456'; - fixture.config.agentRegisterUrl = encodedSavedUrl; - const exactEncodedQuery = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-encoded-query', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, - tool_response: { stdout: `Open (${encodedSavedUrl}).\napi_key=sk-encoded-query-secret-1234567890`, exit_code: 0 }, - }); - assert.equal(exactEncodedQuery.result.decision.decision, 'allow'); - assert.ok(exactEncodedQuery.output.hookSpecificOutput.updatedToolOutput.stdout.includes(encodedSavedUrl)); - assert.doesNotMatch(exactEncodedQuery.text, /encoded-query-secret/); - }); - - it('rewrites simple wrappers but does not trust unrelated executable paths', async () => { - const commands = [ - '/tmp/agentguard status', - 'agentguard-mcp status', - ]; - for (const [index, command] of commands.entries()) { - const fixture = lifecycleFixture(); - const result = await protectAction({ - config: fixture.config, - agentHost: 'claude-code', - auditSafe: true, - rawInput: { - hook_event_name: 'PostToolUse', - session_id: `sess-status-negative-${index}`, - cwd: fixture.root, - tool_name: 'Bash', - tool_input: { command }, - tool_response: { stdout: 'API key: ag_live_st…3456\nAgent JWT: configured', exit_code: 0 }, - }, - }); - assert.ok(result, command); - assert.notEqual(result.event.metadata?.agentGuardStatusOutputSuppressed, true, command); - } - for (const command of ["sh -c 'agentguard status'", 'env MODE=test agentguard status', - 'MODE=test agentguard status', 'command agentguard status']) { - const fixture = lifecycleFixture(); - const rewritten = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-wrapped-status', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command }, - tool_response: { stdout: 'api_key=sk-wrapped-secret-1234567890', exit_code: 0 }, - }); - assert.equal(rewritten.result.decision.decision, 'allow', command); - assert.match(rewritten.text, /updatedToolOutput/, command); - assert.doesNotMatch(rewritten.text, /wrapped-secret/, command); + for (const command of ['agentguard status', 'agentguard connect', 'agentguard scan ./skill', + 'npx -y @goplus/agentguard@1.2.4-beta.1 --version', "sh -c 'agentguard status'", + 'env MODE=test agentguard status']) { + for (const event of ['PostToolUse', 'PostToolUseFailure']) { + const result = await protectAction({ config: fixture.config, agentHost: 'claude-code', + auditSafe: true, rawInput: { hook_event_name: event, session_id: 'sess-own-output', + cwd: fixture.root, tool_name: 'Bash', tool_input: { command }, + tool_response: { stdout: 'api_key=sk-own-secret-1234567890', + card: 5284917302468138 }, error: 'api_key=sk-own-failure-1234567890' } }); + assert.equal(result, null, `${event}: ${command}`); + } + const batch = await protectAction({ config: fixture.config, agentHost: 'claude-code', + auditSafe: true, rawInput: { hook_event_name: 'PostToolBatch', session_id: 'sess-own-batch', + cwd: fixture.root, tool_calls: [{ tool_name: 'Bash', tool_input: { command }, + tool_response: [{ type: 'text', text: 'api_key=sk-own-batch-secret-1234567890' }] }] } }); + assert.equal(batch, null, `PostToolBatch: ${command}`); } - }); - - it('rewrites an unexpectedly unmasked secret in agentguard status output', async () => { - const fixture = lifecycleFixture(); - const leaked = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-status-leak', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: { stdout: 'api_key=sk-status-leak-1234567890', exit_code: 0 }, - }); - assert.equal(leaked.result.decision.decision, 'allow'); - assert.match(leaked.text, /updatedToolOutput/); - assert.match(leaked.text, /api_key=\[REDACTED\]/); - assert.doesNotMatch(leaked.text, /status-leak/); - - const structured = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-status-structured', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: { stdout: 'healthy', token: 'sk-structured-secret-1234567890', count: 4 }, - }); - assert.equal(structured.result.decision.decision, 'block'); - assert.equal(structured.output.decision, 'block'); - assert.equal(structured.output.hookSpecificOutput, undefined); - assert.doesNotMatch(structured.text, /structured-secret/); - - const numeric = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-status-numeric', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: { stdout: 'api_key=sk-numeric-secret-1234567890', card: 5284917302468138, exit_code: 0 }, - }); - assert.equal(numeric.result.decision.decision, 'block'); - assert.equal(numeric.output.decision, 'block'); - assert.doesNotMatch(numeric.text, /numeric-secret|5284917302468138/); - - const malformed = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-status-malformed', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: '{"token":"sk-malformed-secret-1234567890"', - }); - assert.equal(malformed.result.decision.decision, 'block'); - assert.equal(malformed.output.decision, 'block'); - assert.doesNotMatch(malformed.text, /malformed-secret/); - - const nestedUnknown = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-status-nested', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: { stdout: '{"futureField":"sk-nested-secret-1234567890"}', exit_code: 0 }, - }); - assert.equal(nestedUnknown.result.decision.decision, 'block'); - assert.doesNotMatch(nestedUnknown.text, /nested-secret/); + const unrelated = await protectAction({ config: fixture.config, agentHost: 'claude-code', + auditSafe: true, rawInput: { hook_event_name: 'PostToolUse', session_id: 'sess-unrelated', + cwd: fixture.root, tool_name: 'Bash', tool_input: { command: '/tmp/agentguard status' }, + tool_response: { stdout: 'api_key=sk-other-secret-1234567890' } } }); + assert.equal(unrelated?.decision.decision, 'block'); }); it('suppresses only agentguard status output while preserving PostToolBatch protection', async () => { @@ -364,7 +206,7 @@ describe('Claude Code native lifecycle hooks', () => { const statusCall = { tool_name: 'Bash', tool_input: { command: 'agentguard status' }, - tool_response: { stdout: 'API key: ag_live_st…3456\nAgent JWT: configured', exit_code: 0 }, + tool_response: { stdout: 'api_key=sk-batch-own-secret-1234567890', exit_code: 0 }, }; const statusOnly = await protectAction({ @@ -393,7 +235,7 @@ describe('Claude Code native lifecycle hooks', () => { }); assert.ok(mixedPublic); assert.notEqual(mixedPublic.decision.decision, 'block'); - assert.equal(mixedPublic.event.metadata?.claudeBatchAgentGuardStatusOutputsSuppressed, 1); + assert.equal(mixedPublic.event.metadata?.claudeBatchAgentGuardOutputsSuppressed, 1); assert.equal( mixedPublic.event.llm?.payloadBytes, Buffer.byteLength(JSON.stringify([ @@ -405,10 +247,10 @@ describe('Claude Code native lifecycle hooks', () => { assert.equal(inspectedCalls.length, 2); assert.equal(inspectedCalls[0]?.tool_name, 'Bash'); assert.equal(inspectedCalls[1]?.tool_name, 'Read'); - assert.equal((inspectedCalls[0]?.tool_response as Record)?.exit_code, 0); - assert.doesNotMatch(JSON.stringify(inspectedCalls[0]), /ag_live_st/); - assert.match(JSON.stringify(inspectedCalls[0]), /status output suppressed/i); - assert.doesNotMatch(readFileSync(fixture.config.auditPath, 'utf8'), /ag_live_st/); + assert.equal(inspectedCalls[0]?.tool_response, '[AgentGuard command output excluded from inspection]'); + assert.doesNotMatch(JSON.stringify(inspectedCalls[0]), /batch-own-secret/); + assert.match(JSON.stringify(inspectedCalls[0]), /output excluded from inspection/i); + assert.doesNotMatch(readFileSync(fixture.config.auditPath, 'utf8'), /batch-own-secret/); const mixedSensitive = await protectAction({ config: fixture.config, @@ -441,9 +283,7 @@ describe('Claude Code native lifecycle hooks', () => { }], }, }); - assert.ok(statusWithSensitiveMetadata); - assert.equal(statusWithSensitiveMetadata.decision.decision, 'block'); - assert.equal(statusWithSensitiveMetadata.event.metadata?.claudeBatchAgentGuardStatusOutputsSuppressed, 1); + assert.equal(statusWithSensitiveMetadata, null); const unsafeStatus = await protectAction({ config: fixture.config, @@ -457,8 +297,7 @@ describe('Claude Code native lifecycle hooks', () => { }], }, }); - assert.equal(unsafeStatus?.decision.decision, 'block', - 'an unsafe model-visible result cannot be hidden from the batch gate'); + assert.equal(unsafeStatus, null, 'own-command output is outside the batch privacy gate'); for (const [index, command] of [ '/tmp/agentguard status', @@ -476,7 +315,7 @@ describe('Claude Code native lifecycle hooks', () => { }, }); assert.ok(nonExempt, command); - assert.equal(nonExempt.event.metadata?.claudeBatchAgentGuardStatusOutputsSuppressed, undefined, command); + assert.equal(nonExempt.event.metadata?.claudeBatchAgentGuardOutputsSuppressed, undefined, command); } const failedOwnCall = await protectAction({ @@ -487,7 +326,7 @@ describe('Claude Code native lifecycle hooks', () => { tool_response: 'Exit code 1\napi_key=sk-failed-own-secret-1234567890' }], }, }); - assert.equal(failedOwnCall?.decision.decision, 'block'); + assert.equal(failedOwnCall, null); }); it('blocks a sensitive PostToolBatch before the next model call with bounded counts', async () => { @@ -544,136 +383,63 @@ describe('Claude Code native lifecycle hooks', () => { assert.doesNotMatch(readFileSync(fixture.config.auditPath, 'utf8'), new RegExp(secret.replace('.', '\\.'))); }); - it('shows the saved activation link in Claude display while masking other credentials', async () => { + it('keeps display redaction separate from command results', async () => { const fixture = lifecycleFixture(); const activationUrl = 'https://agentguard.example/activate?token=pending-display-link-123456'; fixture.config.agentJwt = 'test.jwt.pending'; fixture.config.agentRegisterUrl = activationUrl; - const otherUrl = 'https://agentguard.example/activate?token=unrelated-display-link-123456'; - const otherSecret = 'sk-display-secret-1234567890'; - for (const command of ['agentguard connect', 'npx -y @goplus/agentguard@1.2.4-beta.1 connect']) { - const connectCall = { - tool_name: 'Bash', tool_input: { command }, - tool_response: { stdout: `Registered local AgentGuard agent.\nOpen this link to bind this agent to your account:\n${activationUrl}\n`, exit_code: 0 }, - }; - const connectResult = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolUse', session_id: 'sess-connect-link', cwd: fixture.root, - ...connectCall, - }, - }); - assert.notEqual(connectResult?.decision.decision, 'block', command); - const connectBatch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-connect-link', cwd: fixture.root, - tool_calls: [connectCall], - }, - }); - assert.notEqual(connectBatch?.decision.decision, 'block', command); - } - - const npxWithOtherSecret = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-npx-secret', cwd: fixture.root, - tool_name: 'Bash', tool_input: { command: 'npx -y @goplus/agentguard@1.2.4-beta.1 connect' }, - tool_response: { stdout: `Open ${activationUrl}\napi_key=${otherSecret}`, exit_code: 0 }, - }); - assert.equal(npxWithOtherSecret.result.decision.decision, 'allow'); - assert.ok(npxWithOtherSecret.output.hookSpecificOutput.updatedToolOutput.stdout.includes(activationUrl)); - assert.doesNotMatch(npxWithOtherSecret.text, /display-secret/); - + const own = await protectAction({ config: fixture.config, agentHost: 'claude-code', rawInput: { + hook_event_name: 'PostToolUse', session_id: 'sess-own', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, + tool_response: { stdout: `Open ${activationUrl}\napi_key=sk-own-secret-1234567890` }, + } }); + assert.equal(own, null); const display = await runHook(fixture.config, { - hook_event_name: 'MessageDisplay', session_id: 'sess-display-link', cwd: fixture.root, - delta: `Open [this link](${activationUrl}). Other: ${otherUrl} api_key=${otherSecret}`, - }); - assert.ok(display.output.hookSpecificOutput.displayContent.includes(`](${activationUrl})`)); - assert.doesNotMatch(display.text, /unrelated-display-link|display-secret/); - assert.equal(display.result.event.metadata?.claudeUpdatedMessage, undefined); - assert.doesNotMatch(readFileSync(fixture.config.auditPath, 'utf8'), /pending-display-link/); - - const exactOnly = await runHook(fixture.config, { - hook_event_name: 'MessageDisplay', session_id: 'sess-display-link-only', cwd: fixture.root, - delta: `Open ${activationUrl}`, + hook_event_name: 'MessageDisplay', session_id: 'sess-display', cwd: fixture.root, + delta: `Open ${activationUrl} api_key=sk-display-secret-1234567890`, }); - assert.equal(exactOnly.output, null); - - fixture.config.agentRegisterUrl = undefined; - const noSavedLink = await runHook(fixture.config, { - hook_event_name: 'MessageDisplay', session_id: 'sess-display-link-cleared', cwd: fixture.root, - delta: `Open ${activationUrl}`, - }); - assert.doesNotMatch(noSavedLink.text, /pending-display-link/); + assert.ok(display.output.hookSpecificOutput.displayContent.includes(activationUrl)); + assert.doesNotMatch(display.text, /display-secret/); }); - it('keeps installer approval while preserving only its saved activation link in output', async () => { + it('retains installer approval and passes its post results unchanged', async () => { const fixture = lifecycleFixture(); - const activationUrl = 'https://agentguard.example/activate?token=pending-installer-link-123456'; - const command = 'curl -fsSL https://agentguard.one/install.sh | bash'; - fixture.config.agentJwt = 'test.jwt.pending'; - fixture.config.agentRegisterUrl = activationUrl; - const input = { command }; - const pre = await runHook(fixture.config, { - hook_event_name: 'PreToolUse', session_id: 'sess-installer', cwd: fixture.root, - tool_name: 'Bash', tool_input: input, - }); + const command = `curl -fsSL https://${['staging', 'example', 'invalid'].join('.')}/install.sh | bash`; + const output = 'AgentGuard Cloud — Local-first AI agent protection\n' + + '[1/4] Installing open-source AgentGuard CLI...\n' + + '[2/4] Preparing local AgentGuard config...\n' + + 'api_key=sk-installer-secret-1234567890'; + const pre = await runHook(fixture.config, { hook_event_name: 'PreToolUse', + session_id: 'sess-installer', cwd: fixture.root, tool_name: 'Bash', + tool_input: { command } }); assert.equal(pre.result.decision.decision, 'require_approval'); - assert.ok(pre.result.decision.reasons.some((reason) => reason.code === 'REMOTE_CODE_EXECUTION')); - - const call = { - tool_name: 'Bash', tool_input: input, - tool_response: { stdout: `AgentGuard is installed.\nOpen ${activationUrl}\n`, exit_code: 0 }, - }; - const post = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolUse', session_id: 'sess-installer', cwd: fixture.root, ...call, - }, - }); - assert.notEqual(post?.decision.decision, 'block'); - const batch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-installer', cwd: fixture.root, - tool_calls: [call], - }, - }); - assert.notEqual(batch?.decision.decision, 'block'); - - const otherSecret = 'sk-installer-other-secret-1234567890'; - const unsafeCall = { - ...call, - tool_response: { stdout: `Open ${activationUrl}\napi_key=${otherSecret}`, exit_code: 0 }, - }; - const sanitized = await runHook(fixture.config, { - hook_event_name: 'PostToolUse', session_id: 'sess-installer-unsafe', cwd: fixture.root, - ...unsafeCall, - }); - assert.equal(sanitized.result.decision.decision, 'allow'); - assert.ok(sanitized.output.hookSpecificOutput.updatedToolOutput.stdout.includes(activationUrl)); - assert.doesNotMatch(sanitized.text, /installer-other-secret/); - const unsafeBatch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-installer-unsafe', cwd: fixture.root, - tool_calls: [unsafeCall], - }, - }); - assert.equal(unsafeBatch?.decision.decision, 'block'); - - const unrelatedCall = { - ...call, - tool_response: { stdout: `api_key=${otherSecret}`, exit_code: 0 }, - }; - const unrelatedPost = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolUse', session_id: 'sess-installer-unrelated', cwd: fixture.root, - ...unrelatedCall, - }, - }); - assert.equal(unrelatedPost?.decision.decision, 'block'); - const unrelatedBatch = await protectAction({ - config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { - hook_event_name: 'PostToolBatch', session_id: 'sess-installer-unrelated', cwd: fixture.root, - tool_calls: [unrelatedCall], - }, - }); - assert.equal(unrelatedBatch?.decision.decision, 'block'); + const ownCall = { tool_name: 'Bash', tool_input: { command }, + tool_response: { stdout: output, exit_code: 0 } }; + for (const event of ['PostToolUse', 'PostToolBatch']) { + const result = await protectAction({ config: fixture.config, agentHost: 'claude-code', + auditSafe: true, rawInput: { hook_event_name: event, session_id: 'sess-installer', + cwd: fixture.root, ...(event === 'PostToolBatch' + ? { tool_calls: [ownCall] } : ownCall) } }); + assert.equal(result, null, event); + } + const priorNative = process.env.AGENTGUARD_CLAUDE_HOOK; + process.env.AGENTGUARD_CLAUDE_HOOK = '1'; + try { + const failedBatch = await protectAction({ config: fixture.config, agentHost: 'claude-code', + rawInput: { hook_event_name: 'PostToolBatch', session_id: 'sess-installer-failed', + cwd: fixture.root, tool_calls: [{ tool_name: 'Bash', tool_input: { command }, + tool_response: { error: 'api_key=sk-installer-error-1234567890' } }] } }); + assert.equal(failedBatch?.decision.decision, 'block'); + } finally { + if (priorNative === undefined) delete process.env.AGENTGUARD_CLAUDE_HOOK; + else process.env.AGENTGUARD_CLAUDE_HOOK = priorNative; + } + const mixed = await protectAction({ config: fixture.config, agentHost: 'claude-code', + auditSafe: true, rawInput: { hook_event_name: 'PostToolBatch', session_id: 'sess-mixed', + cwd: fixture.root, tool_calls: [ownCall, { tool_name: 'Read', + tool_input: { file_path: '/workspace/private.txt' }, + tool_response: { content: 'api_key=sk-other-secret-1234567890' } }] } }); + assert.equal(mixed?.decision.decision, 'block'); }); it('blocks dangerous ConfigChange content without rolling the file back', async () => { @@ -800,6 +566,23 @@ describe('Claude Code native lifecycle hooks', () => { assert.equal(JSON.parse(batch.stdout).decision, 'block'); assert.doesNotMatch(`${batch.stdout}\n${batch.stderr}`, new RegExp(secret)); + const installedBatch = spawnSync('/bin/sh', ['-c', batchCommand], { + cwd: nested, + input: JSON.stringify({ + hook_event_name: 'PostToolBatch', session_id: 'sess-installed-installer', cwd: nested, + tool_calls: [{ + tool_name: 'Bash', + tool_input: { command: `curl -fsSL https://${['staging', 'example', 'invalid'].join('.')}/install.sh | bash` }, + tool_response: [{ type: 'text', text: + `AgentGuard Cloud — Local-first AI agent protection\n[1/4] Installing open-source AgentGuard CLI...\n[2/4] Preparing local AgentGuard config...\napi_key=${secret}\nAgentGuard is installed.` }], + }], + }), + encoding: 'utf8', + env, + }); + assert.equal(installedBatch.status, 0); + assert.equal(installedBatch.stdout.trim(), ''); + const display = spawnSync('/bin/sh', ['-c', displayCommand], { cwd: nested, input: JSON.stringify({ diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 450f3cb..1048d50 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -5,7 +5,13 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { installAgentTemplates } from '../installers.js'; -import { isAgentGuardOwnedOutputCommand, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; +import { isAgentGuardOwnedOutputResult, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; + +const installerCommand = `curl -fsSL https://${['staging', 'example', 'invalid'].join('.')}/install.sh | bash`; +const installerOutput = 'AgentGuard Cloud — Local-first AI agent protection\n' + + '[1/4] Installing open-source AgentGuard CLI...\n' + + '[2/4] Preparing local AgentGuard config...\n' + + 'api_key=sk-installer-secret-1234567890'; interface HookRun { status: number | null; @@ -33,6 +39,9 @@ describe('installed Codex native hooks', () => { assert.equal(isStandaloneAgentGuardCliCommand('npm_config_registry=https://evil.example npx @goplus/agentguard connect'), false); assert.equal(isStandaloneAgentGuardCliCommand('NODE_OPTIONS=--require=./other.js npx @goplus/agentguard connect'), false); assert.equal(isStandaloneAgentGuardCliCommand('/tmp/npx @goplus/agentguard connect'), false); + assert.equal(isStandaloneAgentGuardCliCommand('PATH=/tmp/other agentguard status'), false); + assert.equal(isStandaloneAgentGuardCliCommand('env NODE_OPTIONS=--require=./other.js agentguard status'), false); + assert.equal(isStandaloneAgentGuardCliCommand('AGENTGUARD_MODE=local agentguard status'), true); assert.equal(isStandaloneAgentGuardCliCommand('npx @goplus/agentguard@1.2.4-beta.1 scan'), true); assert.equal(isStandaloneAgentGuardCliCommand('npx @goplus/agentguard doctor'), true); assert.equal(isStandaloneAgentGuardCliCommand('npx -y @goplus/agentguard init --yes'), true); @@ -40,12 +49,33 @@ describe('installed Codex native hooks', () => { assert.equal(isStandaloneAgentGuardCliCommand('npx @goplus/agentguard@1.2.4-beta.1 connect; echo extra'), false); }); - it('recognizes only the documented installer pipeline for output sanitization', () => { - assert.equal(isAgentGuardOwnedOutputCommand('curl -fsSL https://agentguard.one/install.sh | bash'), true); - assert.equal(isAgentGuardOwnedOutputCommand('curl -fsSL https://www.agentguard.one/install.sh | bash'), true); - assert.equal(isAgentGuardOwnedOutputCommand('curl -fsSL https://agentguard.one/install.sh | bash; echo extra'), false); - assert.equal(isAgentGuardOwnedOutputCommand('curl -fsSL https://other.example/install.sh | bash'), false); - assert.equal(isAgentGuardOwnedOutputCommand('curl -fsSL http://agentguard.one/install.sh | bash'), false); + it('attributes installer results by output signature without trusting a URL', () => { + assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: installerOutput }), true); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: `${'other output '.repeat(200)}${installerOutput}` }), false); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: 'api_key=sk-unrelated-1234567890' }), false); + assert.equal(isAgentGuardOwnedOutputResult(`${installerCommand}; echo extra`, { output: installerOutput }), false); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace('https:', 'http:'), { output: installerOutput }), false); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace('/install.sh', '/other.sh'), { output: installerOutput }), false); + assert.equal(isAgentGuardOwnedOutputResult('agentguard status', { output: 'api_key=sk-own-1234567890' }), true); + }); + + it('keeps installer approval and passes attributed output through', () => { + const fixture = installFixture(); + const command = installerCommand; + const base = { session_id: 'sess-site-install', turn_id: 'turn-site-install', + cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool-site-install', + tool_input: { command } }; + const pre = fixture.run('agentguard-pre-tool.sh', { ...base, hook_event_name: 'PreToolUse' }); + assert.equal(pre.status, 0); + assert.equal(JSON.parse(pre.stdout).hookSpecificOutput.permissionDecision, 'deny'); + const post = fixture.run('agentguard-post-tool.sh', { ...base, hook_event_name: 'PostToolUse', + tool_response: { output: installerOutput, exit_code: 0 } }); + assert.equal(post.status, 0); + assert.equal(post.stdout.trim(), ''); + const unrelated = fixture.run('agentguard-post-tool.sh', { ...base, hook_event_name: 'PostToolUse', + tool_response: { output: 'api_key=sk-unrelated-secret-1234567890', exit_code: 0 } }); + assert.equal(unrelated.status, 0); + assert.equal(JSON.parse(unrelated.stdout).decision, 'block'); }); it('blocks sensitive user prompts before the model request without echoing secrets', () => { @@ -347,47 +377,17 @@ describe('installed Codex native hooks', () => { assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); - it('keeps the actual pending activation link visible without blocking status', () => { + it('does not intercept saved, forged, or sensitive AgentGuard status output', () => { const fixture = installFixture(); - const activationUrl = 'https://agentguard.example/activate?token=pending-activation-link-123456'; - writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ - version: 1, level: 'balanced', cloudUrl: 'http://127.0.0.1:9', - agentHost: 'codex', agentId: 'agt_pending', agentJwt: 'test.jwt.pending', - agentRegisterUrl: activationUrl, - policyCachePath: join(fixture.home, 'policy-cache.json'), - auditPath: join(fixture.home, 'audit.jsonl'), - eventSpoolPath: join(fixture.home, 'events-spool.jsonl'), - })); - const status = fixture.runCli('status'); - assert.equal(status.status, 0); - assert.match(status.stdout, /TYPESAFE_API_KEY=/); - assert.ok(status.stdout.includes(`Agent activation URL: ${activationUrl}`)); - - const payload = (output: string, command = 'agentguard status') => ({ - hook_event_name: 'PostToolUse', session_id: 'sess_pending', turn_id: 'turn_pending', - cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool_pending', - tool_input: { cmd: command }, tool_response: { output, exit_code: 0 }, - }); - const allowed = fixture.run('agentguard-post-tool.sh', payload(status.stdout)); - assert.equal(allowed.status, 0); - assert.equal(allowed.stdout.trim(), '', 'the saved link and public hint stay in the original result'); - assert.equal(allowed.stderr.trim(), ''); - assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); - - // A different link or another secret is redacted, never block the CLI. - for (const [index, result] of [ - payload(status.stdout.replace(activationUrl, `${activationUrl}-forged`)), - payload(`${status.stdout}api_key=sk-unsafe-addition-1234567890\n`), - payload(status.stdout, 'agentguard status; echo extra'), - ].entries()) { - const run = fixture.run('agentguard-post-tool.sh', { ...result, session_id: `sess_pending_${index}` }); + const url = 'https://agentguard.example/activate?token=pending-link-123456'; + for (const output of [url, `${url}-forged`, 'api_key=sk-own-secret-1234567890']) { + const run = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-own', turn_id: 'turn-own', + cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool-own', + tool_input: { command: 'agentguard status' }, tool_response: { output, exit_code: 0 }, + }); assert.equal(run.status, 0); - const feedback = JSON.parse(run.stdout); - if (index === 2) assert.equal(feedback.decision, 'block'); // Compound command is not AgentGuard-only. - else { - assert.equal(feedback.continue, false); - assert.doesNotMatch(feedback.stopReason, /unsafe-addition|pending-activation-link-123456-forged/); - } + assert.equal(run.stdout.trim(), ''); } }); @@ -416,78 +416,19 @@ describe('installed Codex native hooks', () => { assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); - it('redacts all standalone CLI output but retains the saved JWT activation URL', () => { + it('passes all standalone CLI outputs through without redaction or blocking', () => { const fixture = installFixture(); - const url = 'https://agentguard.example/activate?token=allowed-jwt-link-123456'; - writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ - version: 1, level: 'balanced', agentHost: 'codex', agentJwt: 'test.jwt.pending', - agentRegisterUrl: url, cloudUrl: 'http://127.0.0.1:9', - policyCachePath: join(fixture.home, 'policy-cache.json'), - auditPath: join(fixture.home, 'audit.jsonl'), - eventSpoolPath: join(fixture.home, 'events-spool.jsonl'), - })); - const run = (cmd: string, output: string) => fixture.run('agentguard-post-tool.sh', { - hook_event_name: 'PostToolUse', session_id: 'sess-self-links', turn_id: 'turn-self-links', - cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-links', - tool_input: { cmd }, tool_response: { output, exit_code: 0 }, - }); - const output = `Open this link to bind this agent to your account:\n${url}\n` - + 'api_key=sk-self-secret-1234567890\n身份证号=11010519491231002X\n'; - for (const cmd of ['agentguard connect', 'agentguard subscribe', 'agentguard scan ./skill']) { - const response = run(cmd, output); - assert.equal(response.status, 0, cmd); - const feedback = JSON.parse(response.stdout); - assert.equal(feedback.continue, false, cmd); - assert.ok(feedback.stopReason.includes(url), cmd); - assert.match(feedback.stopReason, /api_key=\[REDACTED\]/, cmd); - assert.doesNotMatch(feedback.stopReason, /self-secret|11010519491231002X/, cmd); + for (const command of ['agentguard connect', 'agentguard status --json', + 'agentguard scan ./skill', 'npx -y @goplus/agentguard@1.2.4-beta.1 --version']) { + const run = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-own', turn_id: 'turn-own', + cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool-own', + tool_input: { command }, tool_response: { output: 'api_key=sk-own-secret-1234567890', + token: 'sk-structured-secret-1234567890', card: 5284917302468138 }, + }); + assert.equal(run.status, 0, command); + assert.equal(run.stdout.trim(), '', command); } - const forged = JSON.parse(run('agentguard connect', output.replace(url, `${url}-forged`)).stdout); - assert.equal(forged.continue, false); - assert.doesNotMatch(forged.stopReason, /allowed-jwt-link-123456-forged/); - const failed = fixture.run('agentguard-post-tool.sh', { - hook_event_name: 'PostToolUse', session_id: 'sess-self-failure', turn_id: 'turn-self-failure', - cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-failure', - tool_input: { cmd: 'agentguard subscribe' }, tool_response: { - stderr: `Open this link to bind this agent to your account:\n${url}\napi_key=sk-failed-secret-1234567890`, - exit_code: 1, - }, - }); - const failedFeedback = JSON.parse(failed.stdout); - assert.equal(failedFeedback.continue, false); - assert.match(failedFeedback.stopReason, /Exit code: 1/); - assert.ok(failedFeedback.stopReason.includes(url)); - assert.doesNotMatch(failedFeedback.stopReason, /failed-secret/); - assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); - assert.equal(JSON.parse(run('cat ./other-tool-output', output).stdout).decision, 'block'); - const unsupported = fixture.run('agentguard-post-tool.sh', { - hook_event_name: 'PostToolUse', session_id: 'sess-self-unknown', turn_id: 'turn-self-unknown', - cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-unknown', - tool_input: { cmd: 'agentguard status' }, - tool_response: { output: 'healthy', card: 5284917302468138, exit_code: 0 }, - }); - assert.equal(JSON.parse(unsupported.stdout).decision, 'block'); - assert.doesNotMatch(`${unsupported.stdout}\n${unsupported.stderr}`, /5284917302468138/); - }); - - it('redacts an unexpectedly unmasked secret without blocking agentguard status output', () => { - const fixture = installFixture(); - const run = fixture.run('agentguard-post-tool.sh', { - hook_event_name: 'PostToolUse', - session_id: 'sess_unsafe_status', - turn_id: 'turn_unsafe_status', - cwd: fixture.project, - tool_name: 'Bash', - tool_use_id: 'tool_unsafe_status', - tool_input: { command: 'agentguard status' }, - tool_response: { output: 'api_key=sk-status-leak-1234567890', exit_code: 0 }, - }); - - assert.equal(run.status, 0); - const feedback = JSON.parse(run.stdout); - assert.equal(feedback.continue, false); - assert.match(feedback.stopReason, /api_key=\[REDACTED\]/); - assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, /status-leak/); }); it('keeps unverified executable paths on the ordinary guard', () => { @@ -518,50 +459,25 @@ describe('installed Codex native hooks', () => { } }); - it('redacts simple wrappers without blocking their AgentGuard output', () => { + it('passes simple wrappers through and protects compound results', () => { const fixture = installFixture(); - for (const [index, command] of ["sh -c 'agentguard status'", - 'env MODE=test agentguard status', 'MODE=test agentguard status', - 'command agentguard status'].entries()) { - const run = fixture.run('agentguard-post-tool.sh', { - hook_event_name: 'PostToolUse', session_id: `sess_wrapped_${index}`, - turn_id: `turn_wrapped_${index}`, cwd: fixture.project, - tool_name: 'Bash', tool_use_id: `tool_wrapped_${index}`, - tool_input: { command }, - tool_response: { output: 'api_key=sk-wrapped-secret-1234567890', exit_code: 0 }, - }); - assert.equal(run.status, 0, command); - const feedback = JSON.parse(run.stdout); - assert.equal(feedback.continue, false, command); - assert.match(feedback.stopReason, /api_key=\[REDACTED\]/, command); - assert.doesNotMatch(run.stdout, /wrapped-secret/, command); - } - }); - - it('redacts any bare AgentGuard CLI output but retains compound command blocking', () => { - const commands = ['agentguard scan', 'agentguard status --json', 'agentguard status; printf unsafe']; - for (const [index, command] of commands.entries()) { - const fixture = installFixture(); + for (const command of ["sh -c 'agentguard status'", 'env MODE=test agentguard status', + 'command agentguard status', 'agentguard scan']) { const run = fixture.run('agentguard-post-tool.sh', { - hook_event_name: 'PostToolUse', - session_id: `sess_non_status_${index}`, - turn_id: `turn_non_status_${index}`, - cwd: fixture.project, - tool_name: 'Bash', - tool_use_id: `tool_non_status_${index}`, - tool_input: { command }, - tool_response: { output: 'api_key=sk-non-status-secret-1234567890', exit_code: 0 }, + hook_event_name: 'PostToolUse', session_id: 'sess-wrapped', turn_id: 'turn-wrapped', + cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool-wrapped', + tool_input: { command }, tool_response: { output: 'api_key=sk-own-secret-1234567890' }, }); - assert.equal(run.status, 0, command); - const feedback = JSON.parse(run.stdout); - if (command.includes(';')) assert.equal(feedback.decision, 'block', command); - else { - assert.equal(feedback.continue, false, command); - assert.match(feedback.stopReason, /api_key=\[REDACTED\]/, command); - } - assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, /non-status-secret/, command); + assert.equal(run.stdout.trim(), '', command); } + const compound = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-compound', turn_id: 'turn-compound', + cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool-compound', + tool_input: { command: 'agentguard status; echo extra' }, + tool_response: { output: 'api_key=sk-other-secret-1234567890' }, + }); + assert.equal(JSON.parse(compound.stdout).decision, 'block'); }); it('keeps routine post-tool output in the local audit without retaining stdout', () => { diff --git a/src/tests/dsh-runtime.test.ts b/src/tests/dsh-runtime.test.ts index 149c162..112cc17 100644 --- a/src/tests/dsh-runtime.test.ts +++ b/src/tests/dsh-runtime.test.ts @@ -141,6 +141,9 @@ describe('DSH runtime Phase 2A observer', () => { const before = await protectDshToolCall(statusExec, dependencies); assert.equal(before, null, 'self commands bypass the DSH policy evaluator'); + const unavailableConfig = { loadAgentGuardConfig: () => { throw new Error('config unavailable'); } }; + assert.equal(await observeDshToolCall(statusExec, unavailableConfig), null); + assert.equal(await protectDshToolCall(statusExec, unavailableConfig), null); const after = await protectDshToolResult(statusExec, { isError: false, @@ -149,52 +152,45 @@ describe('DSH runtime Phase 2A observer', () => { assert.equal(after, null); }); - it('rewrites AgentGuard CLI results without blocking DSH shell tools', async () => { - const url = 'https://agentguard.example/activate?token=owned-dsh-link-123456'; - const ownConfig: AgentGuardConfig = { ...config, agentJwt: 'agent.jwt.test', agentRegisterUrl: url }; + it('keeps wrapped self-approval commands in the DSH pre-execute policy path', async () => { + const dependencies = { + loadAgentGuardConfig: () => config, + evaluate: async () => ({ decision: decision('block'), policySource: 'default' as const }), + writeAudit() {}, + }; + for (const command of ['agentguard approve --action-id act_test', + 'command agentguard approve --action-id act_test', + "sh -c 'agentguard approve --action-id act_test'"]) { + const exec = execution({ arguments: { command } }); + assert.equal((await observeDshToolCall(exec, dependencies))?.evaluation.decision.decision, 'block'); + assert.equal((await protectDshToolCall(exec, dependencies))?.evaluation.decision.decision, 'block'); + } + assert.equal(await protectDshToolCall(execution({ + arguments: { command: 'agentguard scan ./approve' }, + }), dependencies), null); + }); + + it('preserves AgentGuard CLI results and downstream decisions', async () => { let evaluations = 0; const dependencies = { - loadAgentGuardConfig: () => ownConfig, evaluate: async () => { evaluations += 1; return { decision: decision('block'), policySource: 'default' as const }; }, writeAudit() {}, }; const exec = execution({ arguments: { command: 'agentguard connect' } }); - const result = { isError: false, content: [{ type: 'text', text: `${url}\napi_key=sk-dsh-secret-1234567890` }] }; + const result = { isError: false, content: [{ type: 'text', + text: 'api_key=sk-dsh-secret-1234567890' }] }; assert.equal(await protectDshToolCall(exec, dependencies), null); - const accept = async () => ({ kind: 'accept' as const }); for (const hook of [createDshPostExecuteObserver(dependencies), createDshPostExecuteProtector(dependencies)]) { - const decisionResult = await hook(exec, result, accept); - assert.equal(decisionResult.kind, 'accept'); - if (decisionResult.kind === 'accept') { - assert.ok(decisionResult.content?.[0]?.text?.toString().includes(url)); - assert.match(String(decisionResult.content?.[0]?.text), /api_key=\[REDACTED\]/); - } - } - const structured = await createDshPostExecuteProtector(dependencies)(exec, result, - async () => ({ kind: 'accept', value: { output: `${url}\napi_key=sk-dsh-value-secret-1234567890` }, - additionalContexts: [{ role: 'user', content: 'downstream-context' }] })); - assert.equal(structured.kind, 'accept'); - if (structured.kind === 'accept') { - assert.equal(structured.content, undefined); - assert.ok(JSON.stringify(structured.value).includes(url)); - assert.doesNotMatch(JSON.stringify(structured.value), /dsh-value-secret/); - assert.equal(structured.additionalContexts?.length, 1); + const accept = { kind: 'accept' as const, content: result.content }; + assert.deepEqual(await hook(exec, result, async () => accept), accept); + const structured = { kind: 'accept' as const, value: { token: 'sk-dsh-value-secret-1234567890' } }; + assert.deepEqual(await hook(exec, result, async () => structured), structured); + const block = { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'other policy' }] }; + assert.deepEqual(await hook(exec, result, async () => block), block); } - const keyedValue = await createDshPostExecuteProtector(dependencies)(exec, result, - async () => ({ kind: 'accept' as const, - value: { token: 'sk-dsh-keyed-secret-1234567890', count: 4 } })); - assert.deepEqual(keyedValue, { kind: 'accept', value: { token: '[REDACTED]', count: 4 } }); assert.equal(evaluations, 0); const compound = execution({ arguments: { command: 'agentguard status; echo secret' } }); - assert.ok(await protectDshToolCall(compound, dependencies), 'compound calls remain protected'); - const downstreamBlock = await createDshPostExecuteProtector(dependencies)(exec, result, - async () => ({ kind: 'block' as const, feedback: [{ type: 'text', text: 'other policy' }] })); - assert.equal(downstreamBlock.kind, 'block', 'AgentGuard does not undo another hook’s block'); - const failedRedaction = await createDshPostExecuteProtector({ - ...dependencies, loadAgentGuardConfig: () => { throw new Error('unavailable'); }, - })(exec, result, accept); - assert.deepEqual(failedRedaction, { kind: 'accept', content: [{ type: 'text', - text: 'AgentGuard output could not be safely redacted; run the command in your terminal.' }] }); + assert.ok(await protectDshToolCall(compound, dependencies)); }); it('attributes exact configured tool owners and preserves verified DSH call context', () => { diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index c59a8fb..7d7b1bf 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -205,7 +205,6 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { 'llm_output', 'model_call_ended', 'model_call_started', - 'tool_result_persist', ]); assert.equal(handlers['wrapStreamFn'], undefined); assert.equal(handlers['before_compaction'], undefined); @@ -356,63 +355,15 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { params: { command: 'agentguard status' } }), undefined); }); - it('rewrites only correlated AgentGuard CLI results in OpenClaw-owned transcripts', async () => { + it('does not register a result rewrite hook for OpenClaw or QClaw', async () => { ctx = createTestContext(); - const home = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-own-output-')); - temporaryRoots.push(home); - const oldHome = process.env.AGENTGUARD_HOME; - process.env.AGENTGUARD_HOME = home; - const url = 'https://agentguard.example/activate?token=owned-openclaw-link-123456'; - writeFileSync(join(home, 'config.json'), JSON.stringify({ version: 1, level: 'balanced' })); - try { - const { api, handlers } = createMockApi(); - registerOpenClawPlugin(api as never, { - skipAutoScan: true, - agentguardFactory: () => ctx.agentguard as never, - }); - await handlers.before_tool_call({ - toolName: 'exec', toolCallId: 'own-1', params: { command: 'agentguard connect' }, - }, { sessionKey: 'sess-one' }); - // `connect` saves the link after plugin registration. The sync result - // rewrite must load the current config rather than the startup snapshot. - writeFileSync(join(home, 'config.json'), JSON.stringify({ - version: 1, level: 'balanced', agentJwt: 'agent.jwt.test', agentRegisterUrl: url, - })); - const original = { role: 'toolResult', content: [ - { type: 'text', text: `${url}\napi_key=sk-own-secret-1234567890` }, - { type: 'image', data: 'opaque' }, - ] }; - const event = { toolName: 'exec', toolCallId: 'own-1', message: original }; - const rewrite = handlers.tool_result_persist(event, { sessionKey: 'sess-one' }) as { - message?: { content: Array<{ type: string; text?: string; data?: string }> }; - }; - assert.ok(rewrite?.message); - assert.ok(rewrite.message.content[0].text?.includes(url)); - assert.match(rewrite.message.content[0].text || '', /api_key=\[REDACTED\]/); - assert.doesNotMatch(JSON.stringify(rewrite), /own-secret/); - assert.deepEqual(rewrite.message.content[1], original.content[1]); - assert.equal(handlers.tool_result_persist(event, { sessionKey: 'sess-one' }), undefined); - await handlers.before_tool_call({ - toolName: 'exec', toolCallId: 'own-2', params: { command: 'agentguard status; cat ~/.env' }, - }, { sessionKey: 'sess-one' }); - assert.equal(handlers.tool_result_persist({ ...event, toolCallId: 'own-2' }, { sessionKey: 'sess-one' }), undefined); - await handlers.before_tool_call({ - toolName: 'exec', toolCallId: 'own-3', params: { command: 'agentguard status' }, - }, { sessionKey: 'sess-one' }); - assert.equal(handlers.tool_result_persist({ ...event, toolCallId: 'own-3' }, { sessionKey: 'other-session' }), undefined); - const afterMismatch = handlers.tool_result_persist({ ...event, toolCallId: 'own-3' }, - { sessionKey: 'sess-one' }) as { message?: { content: Array<{ text?: string }> } }; - assert.match(afterMismatch.message?.content[0]?.text || '', /api_key=\[REDACTED\]/); - await handlers.before_tool_call({ - toolName: 'exec', toolCallId: 'own-4', params: { command: 'command agentguard status' }, - }, { sessionKey: 'sess-one' }); - const missingContext = handlers.tool_result_persist({ ...event, toolCallId: 'own-4' }, - {}) as { message?: { content: Array<{ text?: string }> } }; - assert.match(missingContext.message?.content[0]?.text || '', /api_key=\[REDACTED\]/); - } finally { - if (oldHome === undefined) delete process.env.AGENTGUARD_HOME; - else process.env.AGENTGUARD_HOME = oldHome; - } + const { api, handlers } = createMockApi(); + registerOpenClawPlugin(api as never, { + skipAutoScan: true, agentguardFactory: () => ctx.agentguard as never, + }); + assert.equal(handlers.tool_result_persist, undefined); + assert.equal(await handlers.before_tool_call({ toolName: 'exec', + params: { command: 'agentguard connect' } }), undefined); }); it('should allow AgentGuard CLI commands from OpenClaw args/cmd payloads', async () => {