diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba14a7c..8ffe322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,17 @@ jobs: - name: Test run: npm test + + - name: Use Python for Hermes tests + if: matrix.node-version == 22 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Hermes test dependencies + if: matrix.node-version == 22 + run: python -m pip install pytest==8.4.2 + + - name: Test Hermes plugin + if: matrix.node-version == 22 + run: python -m pytest plugins/hermes/tests -q diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a15e7..4edf24e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed +- 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.2] - 2026-10-16 ### Changed diff --git a/docs/dsh-runtime.md b/docs/dsh-runtime.md index d737ec5..8b58005 100644 --- a/docs/dsh-runtime.md +++ b/docs/dsh-runtime.md @@ -61,6 +61,8 @@ The reason passed into DSH contains only a bounded risk score, normalized policy ## Response containment boundary +Single AgentGuard CLI shell commands are exempt from AgentGuard's own pre-execute policy evaluation (except `agentguard approve`). Simple shell wrappers are recognized, while explicit executable paths must resolve to the installed AgentGuard CLI. When their post-execute result exposes text or a structured value, the runtime can return an `accept` decision with locally redacted sensitive fields while preserving the exact saved pending Agent JWT activation URL and public key examples. It never weakens a downstream `block` decision. + Network results pass through `tools/post-execute`. AgentGuard extracts a bounded response preview plus available status, content type, headers, and byte count, then evaluates response and network-volume anomalies. Post-execute remains audit-only in `observe` and in the default `protect` configuration. Set `postResponseMode: block-malicious` together with `mode: protect` to suppress only results whose final AgentGuard post-execute decision is `block`. The original result value and rendered content are not returned; DSH receives bounded AgentGuard feedback that excludes the untrusted preview and evidence. A stronger downstream block is preserved. diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 6fb2a8b..4742147 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -16,6 +16,30 @@ Redacted request and response observations may be written to the local audit file, but routine model-request records and all model-response records are not 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. + +| 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. | + ## Public web-search results Bare card- or national-ID-shaped digits in a **verified, host-owned web-search diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md index 2dfc6c1..c90dc5f 100644 --- a/plugins/hermes/README.md +++ b/plugins/hermes/README.md @@ -38,6 +38,7 @@ 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`. | diff --git a/plugins/hermes/bridge.py b/plugins/hermes/bridge.py index 958588d..03a4220 100644 --- a/plugins/hermes/bridge.py +++ b/plugins/hermes/bridge.py @@ -258,6 +258,19 @@ 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") @@ -274,7 +287,11 @@ def run_cli(self, args: list) -> str: ) except subprocess.SubprocessError as exc: return "AgentGuard CLI failed: %s" % exc - return (proc.stdout or proc.stderr or "").strip() + 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." # -- internals --------------------------------------------------------- diff --git a/plugins/hermes/plugin.py b/plugins/hermes/plugin.py index 12ccffe..59df859 100644 --- a/plugins/hermes/plugin.py +++ b/plugins/hermes/plugin.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import shlex import logging import shutil import subprocess @@ -33,6 +34,7 @@ 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) @@ -136,6 +138,73 @@ 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 96cdcf5..ed2b87d 100644 --- a/plugins/hermes/tests/test_allow.py +++ b/plugins/hermes/tests/test_allow.py @@ -17,6 +17,7 @@ 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 new file mode 100644 index 0000000..79f8930 --- /dev/null +++ b/plugins/hermes/tests/test_self_output.py @@ -0,0 +1,57 @@ +"""Native Hermes own-command results are rewritten before model context.""" + +import json +import shlex +import shutil +from pathlib import Path + +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_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", + })) + 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 diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index cfe6eab..373ec69 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -40,6 +40,8 @@ import { } from '../runtime/protect.js'; 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) @@ -74,8 +76,8 @@ interface OpenClawPluginApi { source: string; registrationMode?: string; pluginConfig?: Record; - on(event: string, handler: (event: unknown, ctx?: unknown) => Promise): void; - on(event: string, options: Record, handler: (event: unknown, ctx?: unknown) => Promise): void; + on(event: string, handler: (event: unknown, ctx?: unknown) => Promise | unknown): void; + on(event: string, options: Record, handler: (event: unknown, ctx?: unknown) => Promise | unknown): void; } // --------------------------------------------------------------------------- @@ -368,6 +370,9 @@ 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); @@ -497,6 +502,22 @@ 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) { @@ -585,6 +606,12 @@ export function registerOpenClawPlugin( try { const input = adapter.parseInput(event); const [normalized] = adapter.normalizeLifecycleEvent('after_tool_call', event, ctx); + const actionArgs = event && typeof event === 'object' ? event as Record : {}; + const args = actionArgs.params ?? actionArgs.toolInput ?? actionArgs.args; + const command = args && typeof args === 'object' + ? (args as Record).command ?? (args as Record).cmd : undefined; + if (normalized.actionType === 'shell' && typeof command === 'string' + && isStandaloneAgentGuardCliCommand(command)) return; const toolName = normalized.toolName; const pluginId = toolName ? getPluginIdFromTool(toolName) : null; if (runtimeProtectionEnabled) { @@ -603,6 +630,54 @@ 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 5333654..dd3e840 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ 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'; @@ -688,6 +689,11 @@ async function main() { } }); + // Internal synchronous bridge for native hosts that rewrite tool results. + program.command('self-output', { hidden: true }).action(() => { + process.stdout.write(redactAgentGuardSelfToolOutput(readStdinIfAvailable(), ensureConfig())); + }); + program .command('protect') .description('Evaluate one runtime action from stdin or hook environment') diff --git a/src/dsh/runtime.ts b/src/dsh/runtime.ts index 47ebab0..6de6d40 100644 --- a/src/dsh/runtime.ts +++ b/src/dsh/runtime.ts @@ -12,6 +12,8 @@ import { rememberPublicSearchResult, } from '../runtime/public-search.js'; import { redactText } from '../runtime/redaction.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, @@ -97,6 +99,8 @@ export interface DshContentBlock { export type DshPostToolDecision = { kind: 'accept'; content?: ReadonlyArray; + value?: unknown; + additionalContexts?: ReadonlyArray; } | { kind: 'block'; feedback: ReadonlyArray; @@ -233,6 +237,45 @@ export function buildDshRuntimeAction( }; } +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 = {} @@ -241,6 +284,7 @@ export async function observeDshToolCall( const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); const action = buildDshRuntimeAction(exec, dependencies.attribution); + if (isDshAgentGuardCliAction(action) && !/^agentguard[ \t]+approve(?:[ \t]|$)/.test(action.input)) return null; action.metadata = { ...action.metadata, runtimePhase: 'pre' }; return evaluateAndAuditDshAction( action, @@ -259,6 +303,7 @@ export async function protectDshToolCall( const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); const action = buildDshRuntimeAction(exec, dependencies.attribution); + if (isDshAgentGuardCliAction(action) && !/^agentguard[ \t]+approve(?:[ \t]|$)/.test(action.input)) return null; action.metadata = { ...action.metadata, runtimePhase: 'pre' }; return evaluateAndAuditDshAction(action, config, dependencies, DSH_PROTECT_MODE, true); } @@ -532,8 +577,10 @@ export function createDshPostExecuteObserver( } catch (error) { dependencies.onError?.(error, exec); } - // Response observations are audit-only and never replace or block the result. - return next(); + // 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); }; } @@ -554,7 +601,9 @@ export function createDshPostExecuteProtector( return next(); } const downstream = await next(); - if (!observed || observed.evaluation.decision.decision !== 'block') return downstream; + if (!observed || observed.evaluation.decision.decision !== 'block') { + return rewriteDshAgentGuardResult(exec, result, downstream, dependencies); + } return mergeDshPostDecisions( translateDshPostDecision(observed.evaluation.decision), downstream diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index c66b064..862d695 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -6,8 +6,9 @@ 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, isAgentGuardStatusCommand } from './self-command.js'; +import { isAgentGuardCliCommand, isAgentGuardStatusCommand, isStandaloneAgentGuardCliCommand } from './self-command.js'; import { redactText, summarizeSensitiveData } from './redaction.js'; +import { redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue } from './self-output.js'; import { hasSensitivePublicSearchResult, isNativePublicSearchTool, isVisiblePublicSearchResult, inspectWithPublicSearchProvenance, @@ -50,6 +51,8 @@ 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 }; } type CodexHookEvent = @@ -86,6 +89,8 @@ 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; +} + + function isAgentGuardRuntimeAction(action: RuntimeAction): boolean { return action.actionType === 'shell' && isAgentGuardCliCommand(action.input); } @@ -210,17 +359,46 @@ function isAgentGuardStatusResult(action: RuntimeAction): boolean { && action.metadata?.claudeBatchOnlyAgentGuardStatus === true; } -function isSafelyMaskedAgentGuardStatusOutput(output: unknown): boolean { +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. - const normalized = serialized.replaceAll('TYPESAFE_API_KEY=', 'TYPESAFE_API_KEY=[REDACTED]'); + 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 { 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 + // an attempt to approve an action. + if (/(?:^|[^A-Za-z0-9_-])(?:[^\s;|&()]*[\\/])?agentguard[ \t]+approve(?:[ \t]|$|[;|&()])/i.test(normalized)) + return true; + if (isStandaloneAgentGuardCliCommand(action.input)) return false; + // Keep detecting indirect self-approval through aliases and shell expansion. return /(?:^|[^A-Za-z0-9_-])(?:[^\s;|&()]*[\\/])?agentguard(?:[^A-Za-z0-9_-]|$)/i.test(normalized) && /(?:^|[^A-Za-z0-9_-])approve(?:[^A-Za-z0-9_-]|$)/i.test(normalized); } @@ -262,6 +440,7 @@ 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); @@ -585,6 +764,11 @@ 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 }); @@ -632,6 +816,11 @@ 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 }); @@ -650,8 +839,11 @@ function formatClaudeHookResult(result: ProtectResult): string | null { return decision === 'warn' ? JSON.stringify({ systemMessage: reason }) : ''; } if (event === 'PostToolUse') { - if (Object.prototype.hasOwnProperty.call(result.event.metadata || {}, 'claudeUpdatedToolOutput')) { + const hasUpdatedOutput = Object.prototype.hasOwnProperty.call( + result.event.metadata || {}, 'claudeUpdatedToolOutput'); + if (hasUpdatedOutput) { return JSON.stringify({ + ...(denied ? { decision: 'block', reason } : {}), hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: result.event.metadata?.claudeUpdatedToolOutput, @@ -659,7 +851,8 @@ function formatClaudeHookResult(result: ProtectResult): string | null { }); } return denied || decision === 'warn' - ? JSON.stringify({ systemMessage: `${reason} Output schema was not rewritten; PostToolBatch remains the continuation gate.` }) + ? JSON.stringify({ ...(denied ? { decision: 'block', reason } : {}), + systemMessage: `${reason} Output schema was not rewritten; PostToolBatch remains the continuation gate.` }) : ''; } if (event === 'PostToolBatch') { @@ -771,7 +964,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) + ? projectClaudeBatchForInspection(raw?.tool_calls, options.config) : undefined; const claudeBatch = claudeBatchProjection ? claudeBatchFacts( @@ -846,7 +1039,7 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { const agentGuardStatusOutputSuppressed = nativePostToolEvent && nativeToolActionType === 'shell' && isAgentGuardStatusCommand(nativeToolInput) - && isSafelyMaskedAgentGuardStatusOutput(actionInput); + && isSafelyMaskedAgentGuardStatusOutput(actionInput, options.config, raw?.tool_response ?? raw?.toolResponse); if (agentGuardStatusOutputSuppressed) actionInput = AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; const unknownSensitiveToolOutput = claudeHookEvent === 'PostToolUse' && verifiedOutput === undefined && redactText(actionInput) !== actionInput; @@ -892,6 +1085,9 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { claudeBatchOnlyAgentGuardStatus: claudeBatchProjection.suppressedCount === claudeBatchProjection.originalCalls.length, } : {}), + ...(claudeBatchProjection?.unsafeSelfOutputCount ? { + claudeBatchUnsafeAgentGuardOutput: true, + } : {}), ...(claudeBatch && process.env.TOOL_INPUT ? { claudeBatchSensitive: undefined, claudeBatchVerified: false } : {}), ...(claudeConfig ? { @@ -1337,54 +1533,58 @@ function claudeBatchFacts( function projectClaudeBatchForInspection( responses: unknown, -): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number } { - if (!Array.isArray(responses)) { - return { originalCalls: [], inspectionCalls: [], suppressedCount: 0 }; - } + config: AgentGuardConfig, +): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number; unsafeSelfOutputCount: number } { + if (!Array.isArray(responses)) return { + originalCalls: [], inspectionCalls: [], suppressedCount: 0, unsafeSelfOutputCount: 0, + }; let suppressedCount = 0; + let unsafeSelfOutputCount = 0; const inspectionCalls = responses.map((call) => { - if (!isClaudeAgentGuardStatusCall(call) || !isPlainRecord(call)) return call; - const outputKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') - ? 'tool_response' - : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; - if (!outputKey) return call; - const outputProjection = projectAgentGuardStatusOutput(call[outputKey]); - if (!outputProjection.suppressed) return call; - const projected = { ...call }; - projected[outputKey] = outputProjection.value; + if (!isPlainRecord(call)) return call; + const toolName = firstString(call.tool_name, call.toolName, call.name); + const toolInput = pickToolInput(call); + if (mapToolToRuntimeAction(toolName, call) !== 'shell' + || !isStandaloneAgentGuardCliCommand(pickNativeToolAuditInput('shell', toolInput))) return call; + const responseKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') + ? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; + 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]' }; + } + } suppressedCount += 1; return projected; }); - return { originalCalls: responses, inspectionCalls, suppressedCount }; -} - -function projectAgentGuardStatusOutput(output: unknown): { value: unknown; suppressed: boolean } { - if (!isSafelyMaskedAgentGuardStatusOutput(output)) return { value: output, suppressed: false }; - if (typeof output === 'string') { - return { value: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER, suppressed: true }; - } - if (!isPlainRecord(output)) return { value: output, suppressed: false }; - - const projected = { ...output }; - let suppressed = false; - for (const key of ['stdout', 'output']) { - if (typeof projected[key] !== 'string') continue; - projected[key] = AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; - suppressed = true; - } - return { value: suppressed ? projected : output, suppressed }; -} - -function isClaudeAgentGuardStatusCall(call: unknown): boolean { - if (!isPlainRecord(call)) return false; - const toolName = firstString(call.tool_name, call.toolName, call.name); - const toolInput = pickToolInput(call); - const actionType = mapToolToRuntimeAction(toolName, call); - if (actionType !== 'shell') return false; - const command = pickNativeToolAuditInput(actionType, toolInput); - if (!command || !isAgentGuardStatusCommand(command)) return false; - const output = call.tool_response ?? call.toolResponse; - return projectAgentGuardStatusOutput(output).suppressed; + return { originalCalls: responses, inspectionCalls, suppressedCount, unsafeSelfOutputCount }; } function hasSensitiveClaudeBatchContent(responses: unknown): boolean { diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index 9b91b4e..64bafbe 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -1,3 +1,6 @@ +import { realpathSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; + const SUPPORTED_AGENT_COMMANDS = [ 'agentguard', 'agentguard-mcp', @@ -20,6 +23,13 @@ export function isAgentGuardCliCommand(command: string): boolean { return isAgentGuardCliCommandInner(command, 0); } +/** Accept a single AgentGuard invocation, including simple shell wrappers. + * An explicit executable path must resolve to this package's CLI entrypoint. + */ +export function isStandaloneAgentGuardCliCommand(command: string): boolean { + return isStandaloneAgentGuardCliCommandInner(command, 0); +} + export function isAgentGuardStatusCommand(command: string): boolean { const trimmed = command.trim(); if (!trimmed || /[\r\n]/.test(trimmed)) return false; @@ -53,6 +63,36 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean { return wrappedCommand ? isAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; } +function isStandaloneAgentGuardCliCommandInner(command: string, depth: number): 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); + if (basename(tokens[index]) === 'env') { + index += 1; + while (tokens[index]?.startsWith('-')) index += 1; + index = skipAssignments(tokens, index); + } + while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1; + if (isVerifiedAgentGuardExecutable(tokens[index])) return true; + + const wrappedCommand = shellWrapperCommand(tokens, index); + return wrappedCommand ? isStandaloneAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; +} + +function isVerifiedAgentGuardExecutable(executable: string | undefined): boolean { + if (executable === 'agentguard') return true; + if (!executable || basename(executable) !== 'agentguard' || !isAbsolute(executable)) return false; + try { + return realpathSync(executable) === realpathSync(join(__dirname, '..', 'cli.js')); + } catch { + return false; + } +} + function matchesCommand(tokens: string[], start: number, command: string): boolean { const expected = command.split(/\s+/); if (start + expected.length > tokens.length) return false; diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts new file mode 100644 index 0000000..34e6fb6 --- /dev/null +++ b/src/runtime/self-output.ts @@ -0,0 +1,91 @@ +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. */ } + } + const activationUrl = config.agentJwt && config.agentRegisterUrl; + const marker = randomMarker('AGENTGUARDLINK'); + const exampleMarker = randomMarker('AGENTGUARDEXAMPLE'); + const withExample = text.replaceAll(PUBLIC_KEY_EXAMPLE, exampleMarker); + if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) + return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); + 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); + } + // 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); + } +} + +/** Letters cannot accidentally match numeric PII rules during redaction. */ +function randomMarker(prefix: string): string { + const letters = Array.from(randomBytes(16), (byte) => + String.fromCharCode(65 + (byte >> 4), 65 + (byte & 15))).join(''); + return `${prefix}${letters}END`; +} + +/** Protect only a complete occurrence of the saved URL, not a forged prefix. */ +function protectExactActivationUrl(text: string, url: string, marker: string): string { + let result = ''; + let cursor = 0; + while (cursor < text.length) { + const start = text.indexOf(url, cursor); + if (start < 0) break; + const end = start + url.length; + const before = text[start - 1]; + const after = text[end]; + const startsAtBoundary = before === undefined || /[\s"'`(<\[{=:]/.test(before); + const endsAtBoundary = after === undefined || /[\s"'`<>\])}]/.test(after) + || /[.,;!?]/.test(after) + && (text[end + 1] === undefined || /[\s"'`<>\])}]/.test(text[end + 1]!)); + result += text.slice(cursor, start) + (startsAtBoundary && endsAtBoundary ? marker : url); + cursor = end; + } + return result + text.slice(cursor); +} diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index 5a6b2da..d303236 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -175,16 +175,108 @@ describe('Claude Code native lifecycle hooks', () => { assert.equal(existsSync(fixture.config.auditPath), false); }); - it('does not exempt wrapped, prefixed, alternate-path, or extended status commands', async () => { + it('allows only the configured activation link in native status and batch results', 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 = [ - "sh -c 'agentguard status'", - 'env MODE=test agentguard status', - 'MODE=test agentguard status', - 'command agentguard status', - 'builtin agentguard status', '/tmp/agentguard status', 'agentguard-mcp status', - 'agentguard status --json', ]; for (const [index, command] of commands.entries()) { const fixture = lifecycleFixture(); @@ -204,17 +296,67 @@ describe('Claude Code native lifecycle hooks', () => { 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); + } }); - it('still protects an unexpectedly unmasked secret in agentguard status output', async () => { + 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, 'block'); + 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/); }); it('suppresses only agentguard status output while preserving PostToolBatch protection', async () => { @@ -315,16 +457,12 @@ describe('Claude Code native lifecycle hooks', () => { }], }, }); - assert.ok(unsafeStatus); - assert.equal(unsafeStatus.decision.decision, 'block'); + assert.equal(unsafeStatus?.decision.decision, 'block', + 'an unsafe model-visible result cannot be hidden from the batch gate'); for (const [index, command] of [ - "sh -c 'agentguard status'", - 'env MODE=test agentguard status', - 'command agentguard status', - 'builtin agentguard status', '/tmp/agentguard status', - 'agentguard status --json', + 'agentguard status; echo extra', ].entries()) { const nonExempt = await protectAction({ config: fixture.config, @@ -340,6 +478,16 @@ describe('Claude Code native lifecycle hooks', () => { assert.ok(nonExempt, command); assert.equal(nonExempt.event.metadata?.claudeBatchAgentGuardStatusOutputsSuppressed, undefined, command); } + + const failedOwnCall = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, + rawInput: { + hook_event_name: 'PostToolBatch', session_id: 'sess-failed-own-call', cwd: fixture.root, + tool_calls: [{ ...statusCall, + tool_response: 'Exit code 1\napi_key=sk-failed-own-secret-1234567890' }], + }, + }); + assert.equal(failedOwnCall?.decision.decision, 'block'); }); it('blocks a sensitive PostToolBatch before the next model call with bounded counts', async () => { diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 314a2bf..55a6a57 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -6,7 +6,9 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import type { AgentGuardConfig } from '../config.js'; import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; +import { protectAction } from '../runtime/protect.js'; const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); @@ -99,16 +101,17 @@ describe('CLI connect Agent JWT mode', () => { assert.match(result.stdout, /Registered local AgentGuard agent \(agt_cli_test\)/); assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=cli-test/); assert.equal(requests[0].url, '/api/agent/register'); - const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { - agentId?: string; - agentJwt?: string; - agentRegisterUrl?: string; - cloudUrl?: string; - }; + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as AgentGuardConfig; assert.equal(config.cloudUrl, cloudUrl); assert.equal(config.agentId, 'agt_cli_test'); assert.equal(config.agentJwt, 'agent.jwt.cli-test'); assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=cli-test'); + const native = await protectAction({ config, agentHost: 'codex', auditSafe: true, rawInput: { + hook_event_name: 'PostToolUse', session_id: 'sess-connect', cwd: home, + tool_name: 'functions.exec_command', tool_input: { cmd: `agentguard connect --url ${cloudUrl}` }, + tool_response: { output: result.stdout, exit_code: result.exitCode }, + } }); + assert.equal(native, null, 'real connect output and registration link remain untouched'); } finally { await new Promise((resolvePromise) => server.close(() => resolvePromise())); } diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index e65caad..1100114 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -6,7 +6,9 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import type { AgentGuardConfig } from '../config.js'; import type { Advisory } from '../feed/types.js'; +import { protectAction } from '../runtime/protect.js'; const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); @@ -335,10 +337,12 @@ describe('CLI subscribe command modes', () => { eventSpoolPath: join(home, 'events-spool.jsonl'), })); - const result = await runCliNoConfigWrite(['subscribe', '--json'], home); + const result = await runCliNoConfigWrite(['subscribe'], home); assert.equal(result.exitCode, 0); assert.equal(result.stderr, ''); + assert.match(result.stdout, /AgentGuard Cloud activation is ready:/); + assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=hermes-subscribe/); assert.deepEqual(requests.map((request) => `${request.method} ${request.url}`), [ 'POST /api/agent/register', 'POST /api/v1/feed/subscribe', @@ -357,6 +361,12 @@ describe('CLI subscribe command modes', () => { assert.equal(config.agentId, 'agt_hermes_subscribe'); assert.equal(config.agentJwt, 'agent.jwt.hermes-subscribe'); assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=hermes-subscribe'); + const native = await protectAction({ config: config as AgentGuardConfig, agentHost: 'codex', auditSafe: true, rawInput: { + hook_event_name: 'PostToolUse', session_id: 'sess-subscribe', cwd: home, + tool_name: 'functions.exec_command', tool_input: { cmd: 'agentguard subscribe' }, + tool_response: { output: result.stdout, exit_code: result.exitCode }, + } }); + assert.equal(native, null, 'real subscribe output and registration link remain untouched'); } finally { await new Promise((resolvePromise) => server.close(() => resolvePromise())); } diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 1951c08..6e01078 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -1,10 +1,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { installAgentTemplates } from '../installers.js'; +import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; interface HookRun { status: number | null; @@ -13,6 +14,16 @@ interface HookRun { } describe('installed Codex native hooks', () => { + it('recognizes the installed CLI symlink but rejects unrelated absolute executables', () => { + const root = mkdtempSync(join(tmpdir(), 'agentguard-self-command-')); + const installed = join(root, 'agentguard'); + symlinkSync(join(__dirname, '..', 'cli.js'), installed); + assert.equal(isStandaloneAgentGuardCliCommand(`${installed} status`), true); + assert.equal(isStandaloneAgentGuardCliCommand(`command ${installed} status`), true); + assert.equal(isStandaloneAgentGuardCliCommand('/tmp/agentguard status'), false); + assert.equal(isStandaloneAgentGuardCliCommand(`${installed} status; echo extra`), false); + }); + it('blocks sensitive user prompts before the model request without echoing secrets', () => { const fixture = installFixture(); const sensitivePrompts = [ @@ -191,7 +202,8 @@ describe('installed Codex native hooks', () => { assert.equal(JSON.parse(run.stdout).hookSpecificOutput.permissionDecision, 'deny', command); } - for (const command of ['agentguard status', "sh -c 'agentguard status'"]) { + for (const command of ['agentguard status', "sh -c 'agentguard status'", + 'agentguard scan ./approve', 'agentguard scan ./approve.json']) { const run = fixture.run('agentguard-pre-tool.sh', { hook_event_name: 'PreToolUse', session_id: 'sess_safe_self_command', @@ -304,12 +316,135 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0); - assert.equal(run.stdout.trim(), ''); + assert.equal(run.stdout.trim(), '', 'a public placeholder needs no output rewrite'); assert.equal(run.stderr.trim(), ''); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); - it('still blocks an unexpectedly unmasked secret in agentguard status output', () => { + it('keeps the actual pending activation link visible without blocking status', () => { + 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}` }); + 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/); + } + } + }); + + it('keeps real init and privacy status working with the public key example', () => { + const fixture = installFixture(); + const post = (cmd: string, output: string) => fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-self-hints', turn_id: 'turn-self-hints', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-hints', + tool_input: { cmd }, tool_response: { output, exit_code: 0 }, + }); + const init = fixture.runCli('init', '--agent', 'codex'); + assert.equal(init.status, 0); + assert.match(init.stdout, /TYPESAFE_API_KEY=/); + assert.equal(post('agentguard init --agent codex', init.stdout).stdout.trim(), ''); + + writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ + version: 1, level: 'balanced', agentHost: 'codex', privacy: { mode: 'jev' }, + policyCachePath: join(fixture.home, 'policy-cache.json'), + auditPath: join(fixture.home, 'audit.jsonl'), + eventSpoolPath: join(fixture.home, 'events-spool.jsonl'), + })); + const privacy = fixture.runCli('privacy', 'status'); + assert.equal(privacy.status, 0); + assert.match(privacy.stdout, /TYPESAFE_API_KEY=/); + assert.equal(post('agentguard privacy status', privacy.stdout).stdout.trim(), ''); + assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); + }); + + it('redacts all standalone CLI output but retains the saved JWT activation URL', () => { + 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); + } + 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', @@ -323,20 +458,16 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0); - assert.equal(JSON.parse(run.stdout).decision, 'block'); + 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('does not exempt wrapped, prefixed, alternate-path, or extended status output', () => { + it('keeps unverified executable paths on the ordinary guard', () => { const commands = [ - "sh -c 'agentguard status'", - 'env MODE=test agentguard status', - 'MODE=test agentguard status', - 'command agentguard status', - 'builtin agentguard status', '/tmp/agentguard status', 'agentguard-mcp status', - 'agentguard status --json', ]; for (const [index, command] of commands.entries()) { const fixture = installFixture(); @@ -361,8 +492,28 @@ describe('installed Codex native hooks', () => { } }); - it('keeps post-tool protection for non-status and compound AgentGuard commands', () => { - const commands = ['agentguard scan', 'agentguard status; printf unsafe']; + it('redacts simple wrappers without blocking their AgentGuard output', () => { + 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(); const run = fixture.run('agentguard-post-tool.sh', { @@ -377,7 +528,12 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0, command); - assert.equal(JSON.parse(run.stdout).decision, 'block', 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); } }); @@ -526,6 +682,9 @@ function installFixture(): { ...process.env, PATH: `${bin}:${process.env.PATH || ''}`, AGENTGUARD_HOME: home, + HOME: home, + DSH_HOME: '', + HERMES_HOME: '', TYPESAFE_API_KEY: '', }, }); diff --git a/src/tests/dsh-runtime.test.ts b/src/tests/dsh-runtime.test.ts index 41017a7..149c162 100644 --- a/src/tests/dsh-runtime.test.ts +++ b/src/tests/dsh-runtime.test.ts @@ -140,9 +140,7 @@ describe('DSH runtime Phase 2A observer', () => { }; const before = await protectDshToolCall(statusExec, dependencies); - assert.ok(before); - assert.equal(before.evaluation.decision.decision, 'allow'); - assert.equal(before.evaluation.decision.riskScore, 0); + assert.equal(before, null, 'self commands bypass the DSH policy evaluator'); const after = await protectDshToolResult(statusExec, { isError: false, @@ -151,6 +149,54 @@ 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 }; + 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` }] }; + 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 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.' }] }); + }); + it('attributes exact configured tool owners and preserves verified DSH call context', () => { const attribution = normalizeDshRuntimeAttribution({ toolOwners: { bash: '@deepseek-ai/dsh-tool-bash' }, diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 8103468..c59a8fb 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { evaluateHook } from '../adapters/engine.js'; @@ -178,13 +178,13 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { }); function createMockApi() { - const handlers: Record Promise> = {}; + const handlers: Record Promise | unknown> = {}; const api = { id: 'test-plugin', name: 'Test Plugin', source: '/tmp/test-plugin/index.ts', on(event: string, ...args: unknown[]) { - handlers[event] = args[args.length - 1] as (...args: unknown[]) => Promise; + handlers[event] = args[args.length - 1] as (...args: unknown[]) => Promise | unknown; }, }; return { api, handlers }; @@ -205,6 +205,7 @@ 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); @@ -339,6 +340,81 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.equal(result, undefined, 'Ordinary OpenClaw exec command should be allowed'); }); + it('keeps QClaw-compatible plugin registration working without a persistence hook', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + const originalOn = api.on; + api.on = (event: string, ...args: unknown[]) => { + if (event === 'tool_result_persist') throw new Error('unsupported hook'); + originalOn(event, ...args); + }; + 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 status' } }), undefined); + }); + + it('rewrites only correlated AgentGuard CLI results in OpenClaw-owned transcripts', 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; + } + }); + it('should allow AgentGuard CLI commands from OpenClaw args/cmd payloads', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi();