diff --git a/docs/guard/harness-support.md b/docs/guard/harness-support.md index 8249779ff..37f73a610 100644 --- a/docs/guard/harness-support.md +++ b/docs/guard/harness-support.md @@ -150,8 +150,8 @@ Generated from `src/codex_plugin_scanner/guard/adapters/contracts.py`. | Harness | Install Aliases | Native Approval | Browser Fallback | Resume | Event Surfaces | |---------|-----------------|-----------------|------------------|--------|----------------| -| `codex` | `codex` | ✅ | ✅ | ✅ | shell, prompt, mcp_tool, file_read | -| `claude-code` | `claude-code`, `claude` | ✅ | ✅ | ✅ | shell, prompt, mcp_tool, file_read | +| `codex` | `codex` | ✅ | ✅ | ✅ | shell, prompt, mcp_tool, file_read, tool_result | +| `claude-code` | `claude-code`, `claude` | ✅ | ✅ | ✅ | shell, prompt, mcp_tool, file_read, tool_result | | `opencode` | `opencode` | ❌ | ✅ | ❌ | shell, mcp_tool | | `copilot` | `copilot` | ✅ | ✅ | ✅ | shell, prompt | | `cursor` | `cursor` | ❌ | ✅ | ❌ | shell, mcp_tool, file_read | @@ -161,5 +161,5 @@ Generated from `src/codex_plugin_scanner/guard/adapters/contracts.py`. | `antigravity` | `antigravity` | ❌ | ✅ | ❌ | mcp_tool, prompt | | `kimi` | `kimi`, `kimi-code`, `kimi-cli` | ❌ | ✅ | ❌ | shell, prompt | | `grok` | `grok`, `grok-build`, `grok-build-cli`, `xai-grok` | ❌ | ✅ | ❌ | shell, prompt, mcp_tool, file_read | -| `pi` | `pi`, `pi-agent`, `pi-coding-agent` | ✅ | ✅ | ❌ | shell, prompt, mcp_tool, file_read | +| `pi` | `pi`, `pi-agent`, `pi-coding-agent` | ✅ | ✅ | ❌ | shell, prompt, mcp_tool, file_read, tool_result | | `zcode` | `zcode`, `zai`, `z-code`, `zai-zcode` | ❌ | ✅ | ❌ | shell, prompt, mcp_tool, file_read | diff --git a/src/codex_plugin_scanner/guard/adapters/pi_support.py b/src/codex_plugin_scanner/guard/adapters/pi_support.py index fadd401ce..07a0cfeea 100644 --- a/src/codex_plugin_scanner/guard/adapters/pi_support.py +++ b/src/codex_plugin_scanner/guard/adapters/pi_support.py @@ -19,6 +19,11 @@ THEME_SUFFIXES = (".json", ".js", ".ts", ".yaml", ".yml") REMOTE_RESOURCE_PREFIXES = ("npm:", "git:", "http://", "https://", "ssh://") GUARD_HOOK_TIMEOUT_MS = 10_000 +GUARD_HOOK_TEXT_LIMIT_CHARS = 12_000 +GUARD_HOOK_CONTENT_ITEM_LIMIT = 24 +GUARD_HOOK_OBJECT_KEY_LIMIT = 24 +GUARD_HOOK_MAX_DEPTH = 24 +GUARD_HOOK_MAX_SERIALIZED_PAYLOAD_CHARS = 24_000 def append_found_path(found_paths: list[str], path: Path) -> None: @@ -129,19 +134,235 @@ def managed_extension_source(*, guard_home: Path, home_dir: Path, settings_path: f"const GUARD_ARGS = {guard_args_json};\n" f"const GUARD_CONFIG_PATH = {config_path_json};\n" f"const GUARD_TIMEOUT_MS = {GUARD_HOOK_TIMEOUT_MS};\n" + f"const GUARD_TEXT_LIMIT_CHARS = {GUARD_HOOK_TEXT_LIMIT_CHARS};\n" + f"const GUARD_CONTENT_ITEM_LIMIT = {GUARD_HOOK_CONTENT_ITEM_LIMIT};\n" + f"const GUARD_OBJECT_KEY_LIMIT = {GUARD_HOOK_OBJECT_KEY_LIMIT};\n" + f"const GUARD_MAX_DEPTH = {GUARD_HOOK_MAX_DEPTH};\n" + f"const GUARD_MAX_SERIALIZED_PAYLOAD_CHARS = {GUARD_HOOK_MAX_SERIALIZED_PAYLOAD_CHARS};\n" "\n" "type GuardResponse = { decision?: string; reason?: string };\n" + "type BoundedValue = { value: unknown; truncated: boolean };\n" + 'const OUTPUT_TEXT_KEYS = ["stdout", "stderr", "output", "content", "result", "message", "text"] as const;\n' "\n" - "function runGuard(payload: Record, cwd?: string): GuardResponse {\n" + "function truncateText(value: string, limit = GUARD_TEXT_LIMIT_CHARS): string {\n" + " if (value.length <= limit) return value;\n" + " return `${value.slice(0, Math.max(limit, 0))}\\n...[truncated by HOL Guard]...`;\n" + "}\n" + "\n" + "function boundValue(value: unknown, depth = 0, seen = new WeakSet()): BoundedValue {\n" + " if (typeof value === 'string') {\n" + " return value.length <= GUARD_TEXT_LIMIT_CHARS\n" + " ? { value, truncated: false }\n" + " : { value: truncateText(value), truncated: true };\n" + " }\n" + " if (value === undefined) return { value: undefined, truncated: false };\n" + " if (typeof value === 'bigint') return { value: value.toString(), truncated: false };\n" + " if (\n" + " value === null ||\n" + " typeof value === 'number' ||\n" + " typeof value === 'boolean'\n" + " ) {\n" + " return { value, truncated: false };\n" + " }\n" + " if (typeof value !== 'object') {\n" + " return { value: String(value), truncated: true };\n" + " }\n" + " const objectValue = value as object;\n" + " if (seen.has(objectValue)) {\n" + " return { value: '[cycle omitted by HOL Guard]', truncated: true };\n" + " }\n" + " if (depth > GUARD_MAX_DEPTH) {\n" + " return { value: '[deep object omitted by HOL Guard]', truncated: true };\n" + " }\n" + " seen.add(objectValue);\n" + " try {\n" + " if (Array.isArray(value)) {\n" + " const truncated = value.length > GUARD_CONTENT_ITEM_LIMIT;\n" + " const items = value.slice(0, GUARD_CONTENT_ITEM_LIMIT);\n" + " const nextItems: unknown[] = [];\n" + " let childTruncated = truncated;\n" + " for (const item of items) {\n" + " const next = boundValue(item, depth + 1, seen);\n" + " nextItems.push(next.value);\n" + " childTruncated = childTruncated || next.truncated;\n" + " }\n" + " return { value: nextItems, truncated: childTruncated };\n" + " }\n" + " const record = value as Record;\n" + " const nextRecord: Record = {};\n" + " let truncated = false;\n" + " let keyCount = 0;\n" + " for (const key in record) {\n" + " if (!Object.prototype.hasOwnProperty.call(record, key)) continue;\n" + " if (keyCount >= GUARD_OBJECT_KEY_LIMIT) {\n" + " truncated = true;\n" + " break;\n" + " }\n" + " keyCount += 1;\n" + " const entryValue = record[key];\n" + " const next = boundValue(entryValue, depth + 1, seen);\n" + " nextRecord[key] = next.value;\n" + " truncated = truncated || next.truncated;\n" + " }\n" + " return { value: nextRecord, truncated };\n" + " } finally {\n" + " seen.delete(objectValue);\n" + " }\n" + "}\n" + "\n" + "function appendBoundedText(accumulator: { text: string; truncated: boolean }, value: string): void {\n" + " if (accumulator.truncated || value.length === 0) return;\n" + ' const prefix = accumulator.text ? "\\n" : "";\n' + " const available = GUARD_TEXT_LIMIT_CHARS - accumulator.text.length - prefix.length;\n" + " if (available <= 0) {\n" + " accumulator.truncated = true;\n" + " return;\n" + " }\n" + " if (value.length > available) {\n" + " accumulator.text += `${prefix}${value.slice(0, available)}`;\n" + " accumulator.truncated = true;\n" + " return;\n" + " }\n" + " accumulator.text += `${prefix}${value}`;\n" + "}\n" + "\n" + "function collectOutputText(\n" + " value: unknown,\n" + " accumulator: { text: string; truncated: boolean; itemCount: number },\n" + " depth = 0,\n" + " seen = new WeakSet(),\n" + "): void {\n" + " if (accumulator.truncated) return;\n" + " if (typeof value === 'string') {\n" + " appendBoundedText(accumulator, value);\n" + " return;\n" + " }\n" + " if (typeof value === 'bigint') {\n" + " appendBoundedText(accumulator, value.toString());\n" + " return;\n" + " }\n" + " if (\n" + " value === undefined ||\n" + " value === null ||\n" + " typeof value === 'number' ||\n" + " typeof value === 'boolean'\n" + " ) {\n" + " return;\n" + " }\n" + " if (typeof value !== 'object') {\n" + " accumulator.truncated = true;\n" + " return;\n" + " }\n" + " const objectValue = value as object;\n" + " if (seen.has(objectValue) || depth > GUARD_MAX_DEPTH) {\n" + " accumulator.truncated = true;\n" + " return;\n" + " }\n" + " seen.add(objectValue);\n" + " try {\n" + " if (Array.isArray(value)) {\n" + " const arrayItems = value as unknown[];\n" + " for (const item of arrayItems) {\n" + " if (accumulator.itemCount >= GUARD_CONTENT_ITEM_LIMIT) {\n" + " accumulator.truncated = true;\n" + " break;\n" + " }\n" + " accumulator.itemCount += 1;\n" + " collectOutputText(item, accumulator, depth + 1, seen);\n" + " if (accumulator.truncated) break;\n" + " }\n" + " if (arrayItems.length > GUARD_CONTENT_ITEM_LIMIT) accumulator.truncated = true;\n" + " return;\n" + " }\n" + " const record = value as Record;\n" + " if (record.type === 'text' && typeof record.text === 'string') {\n" + " appendBoundedText(accumulator, record.text);\n" + " return;\n" + " }\n" + " for (const key of OUTPUT_TEXT_KEYS) {\n" + " if (!(key in record)) continue;\n" + " collectOutputText(record[key], accumulator, depth + 1, seen);\n" + " if (accumulator.truncated) break;\n" + " }\n" + " } finally {\n" + " seen.delete(objectValue);\n" + " }\n" + "}\n" + "\n" + "function boundedOutputText(value: unknown): BoundedValue {\n" + " const accumulator = { text: '', truncated: false, itemCount: 0 };\n" + " collectOutputText(value, accumulator);\n" + " return { value: accumulator.text, truncated: accumulator.truncated };\n" + "}\n" + "\n" + "function toolCallIdKey(value: unknown): string | null {\n" + " if (typeof value !== 'string') return null;\n" + " const trimmed = value.trim();\n" + " return trimmed.length > 0 ? trimmed : null;\n" + "}\n" + "\n" + "function runGuard(\n" + " payload: Record,\n" + " cwd?: string,\n" + " options?: { enforceSizeCap?: boolean },\n" + "): GuardResponse {\n" " const args = [...GUARD_ARGS];\n" ' const workspace = typeof cwd === "string" && cwd ? cwd : process.cwd();\n' ' if (workspace) args.push("--workspace", workspace);\n' + " let payloadToSend = payload;\n" + " let serializedPayload = '';\n" + " try {\n" + " serializedPayload = JSON.stringify(payloadToSend);\n" + " } catch (error) {\n" + " return {\n" + ' decision: "deny",\n' + " reason: `HOL Guard could not serialize Pi hook payload: ${\n" + " error instanceof Error ? error.message : String(error)\n" + " }`,\n" + " };\n" + " }\n" + " if (\n" + " options?.enforceSizeCap === true &&\n" + " serializedPayload.length > GUARD_MAX_SERIALIZED_PAYLOAD_CHARS &&\n" + ' payloadToSend.hook_event_name === "PostToolUse" &&\n' + ' typeof payloadToSend.stdout === "string"\n' + " ) {\n" + " const reducedPayload = { ...payloadToSend };\n" + " delete reducedPayload.stdout;\n" + " try {\n" + " const reducedSerializedPayload = JSON.stringify(reducedPayload);\n" + " if (reducedSerializedPayload.length <= GUARD_MAX_SERIALIZED_PAYLOAD_CHARS) {\n" + " payloadToSend = reducedPayload;\n" + " serializedPayload = reducedSerializedPayload;\n" + " }\n" + " } catch {}\n" + " }\n" + " if (\n" + " options?.enforceSizeCap === true &&\n" + " serializedPayload.length > GUARD_MAX_SERIALIZED_PAYLOAD_CHARS\n" + " ) {\n" + " return {\n" + ' decision: "deny",\n' + ' reason: "HOL Guard blocked this Pi hook payload before review because it exceeded "\n' + ' + "the safe size limit.",\n' + " };\n" + " }\n" ' const result = spawnSync("hol-guard", args, {\n' - " input: `${JSON.stringify(payload)}\\n`,\n" + " input: `${serializedPayload}\\n`,\n" ' encoding: "utf-8",\n' " timeout: GUARD_TIMEOUT_MS,\n" " });\n" - ' if (result.error) return { decision: "allow" };\n' + " if (result.error) {\n" + " const resultError = result.error as (Error & { code?: unknown }) | undefined;\n" + " const errorMessage = resultError instanceof Error ? resultError.message : String(result.error);\n" + " const errorCode = typeof resultError?.code === 'string' ? resultError.code : '';\n" + " return {\n" + ' decision: "deny",\n' + " reason: errorCode === 'ETIMEDOUT' || result.error?.name === 'TimeoutError'\n" + " ? `HOL Guard Pi hook timed out after ${GUARD_TIMEOUT_MS}ms while reviewing this action.`\n" + " : `HOL Guard Pi hook failed before completing review: ${errorMessage}`,\n" + " };\n" + " }\n" ' const lines = (result.stdout ?? "").split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean);\n' " const lastLine = lines.length > 0 ? lines[lines.length - 1] : null;\n" " if (lastLine) {\n" @@ -159,21 +380,16 @@ def managed_extension_source(*, guard_home: Path, home_dir: Path, settings_path: ' return { decision: "allow" };\n' "}\n" "\n" - "function contentText(content: unknown): string {\n" - ' if (typeof content === "string") return content;\n' - ' if (!Array.isArray(content)) return "";\n' - " return content\n" - " .map((item) => {\n" - ' if (!item || typeof item !== "object") return "";\n' - " const type = (item as { type?: unknown }).type;\n" - " const text = (item as { text?: unknown }).text;\n" - ' return type === "text" && typeof text === "string" ? text : "";\n' - " })\n" - " .filter(Boolean)\n" - ' .join("\\n");\n' + "function blockedToolResult(reason: string, details: unknown) {\n" + " return {\n" + ' content: [{ type: "text", text: reason }],\n' + " details,\n" + " isError: true,\n" + " };\n" "}\n" "\n" "export default function (pi: ExtensionAPI) {\n" + " const blockedToolResults = new Map();\n" ' pi.on("input", async (event, ctx) => {\n' ' if (event.source === "extension") return { action: "continue" };\n' " const response = runGuard(\n" @@ -207,33 +423,61 @@ def managed_extension_source(*, guard_home: Path, home_dir: Path, settings_path: " }\n" " return undefined;\n" " });\n" + ' pi.on("message_end", async (event) => {\n' + ' if (event.message.role !== "toolResult") return;\n' + " const toolCallId = toolCallIdKey(event.message.toolCallId);\n" + " if (!toolCallId) return;\n" + " const reason = blockedToolResults.get(toolCallId);\n" + " if (!reason) return;\n" + " blockedToolResults.delete(toolCallId);\n" + " return {\n" + " message: {\n" + " ...event.message,\n" + ' content: [{ type: "text", text: reason }],\n' + " isError: true,\n" + " },\n" + " };\n" + " });\n" ' pi.on("tool_result", async (event, ctx) => {\n' - " const toolOutput = contentText(event.content);\n" - " const toolInput =\n" + " const boundedToolInput = boundValue(\n" " (event as { input?: Record }).input ??\n" " (event as { toolInput?: Record }).toolInput ??\n" " (event as { arguments?: Record }).arguments ??\n" - " {};\n" + " {},\n" + " );\n" + " const boundedContent = boundValue(event.content);\n" + " const boundedStdout = boundedOutputText(event.content);\n" + " if (boundedToolInput.truncated || boundedContent.truncated || boundedStdout.truncated) {\n" + ' const reason = "HOL Guard blocked oversized Pi tool output before review because "\n' + ' + "the result exceeded the safe hook limit.";\n' + " const toolCallId = toolCallIdKey(event.toolCallId);\n" + " if (toolCallId) blockedToolResults.set(toolCallId, reason);\n" + ' ctx.ui.notify(reason, "warning");\n' + " return blockedToolResult(reason, event.details);\n" + " }\n" + " const toolInput =\n" + " boundedToolInput.value as Record;\n" + " const limitedContent = boundedContent.value;\n" + " const toolOutput = boundedStdout.value as string;\n" " const response = runGuard(\n" " {\n" ' hook_event_name: "PostToolUse",\n' " config_path: GUARD_CONFIG_PATH,\n" " tool_name: event.toolName,\n" " tool_input: toolInput,\n" - " tool_response: event.content,\n" + " tool_response: limitedContent,\n" " stdout: toolOutput,\n" " is_error: event.isError === true,\n" " },\n" " ctx.cwd,\n" + " { enforceSizeCap: true },\n" " );\n" ' if (response.decision === "deny") {\n' ' const reason = response.reason ?? "Blocked by HOL Guard.";\n' + " const toolCallId = toolCallIdKey(event.toolCallId);\n" + " if (toolCallId) blockedToolResults.set(toolCallId, reason);\n" ' ctx.ui.notify(reason, "warning");\n' - " return {\n" - ' content: [{ type: "text", text: reason }],\n' - " details: event.details,\n" - " isError: true,\n" - " };\n" + " return blockedToolResult(reason, event.details);\n" " }\n" " return undefined;\n" " });\n" diff --git a/src/codex_plugin_scanner/guard/cli/commands_support_runtime_artifacts.py b/src/codex_plugin_scanner/guard/cli/commands_support_runtime_artifacts.py index 7ea6738e9..b58a25472 100644 --- a/src/codex_plugin_scanner/guard/cli/commands_support_runtime_artifacts.py +++ b/src/codex_plugin_scanner/guard/cli/commands_support_runtime_artifacts.py @@ -339,7 +339,7 @@ def _codex_post_tool_output_artifact( canonical_harness = _canonical_harness_name(harness) harness_label = "Pi" if canonical_harness == "pi" else "Codex" response_text = _collect_codex_tool_response_text(payload.get("tool_response")) - stdout_text = _coalesce_string(payload.get("stdout")) + stdout_text = _optional_string(payload.get("stdout")) if stdout_text: response_text = f"{response_text}\n{stdout_text}".strip() if response_text else stdout_text tool_name = _coalesce_string(payload.get("tool_name"), "Bash") diff --git a/tests/test_pi_adapter.py b/tests/test_pi_adapter.py index 2eb338352..439fafa96 100644 --- a/tests/test_pi_adapter.py +++ b/tests/test_pi_adapter.py @@ -192,7 +192,7 @@ def test_install_writes_managed_extension(self, tmp_path: Path, monkeypatch) -> assert 'pi.on("tool_result"' in text assert 'pi.on("input"' in text assert 'hook_event_name: "PostToolUse"' in text - assert "tool_response: event.content" in text + assert "tool_response: limitedContent" in text assert "const GUARD_CONFIG_PATH =" in text assert "config_path: GUARD_CONFIG_PATH" in text assert '"--harness", "pi"' in text @@ -203,6 +203,71 @@ def test_install_writes_managed_extension(self, tmp_path: Path, monkeypatch) -> assert omp_extension_path.is_file() assert str(omp_extension_path) in json.loads(omp_settings_path.read_text(encoding="utf-8"))["extensions"] + def test_install_writes_managed_extension_that_denies_on_hook_errors(self, tmp_path: Path, monkeypatch) -> None: + ctx = _ctx(tmp_path) + monkeypatch.setattr( + "codex_plugin_scanner.guard.adapters.pi.install_guard_shim", + lambda *args, **kwargs: {"shim_path": str(ctx.guard_home / "bin" / "guard-pi"), "notes": []}, + ) + + manifest = get_adapter("pi").install(ctx) + + text = Path(str(manifest["config_path"])).read_text(encoding="utf-8") + assert "serializedPayload = JSON.stringify(payloadToSend);" in text + assert "serializedPayload.length > GUARD_MAX_SERIALIZED_PAYLOAD_CHARS" in text + assert "if (result.error) {" in text + assert "const resultError =" in text + assert "const errorCode =" in text + assert 'decision: "deny"' in text + assert "errorCode === 'ETIMEDOUT'" in text + assert "HOL Guard Pi hook timed out after" in text + assert "HOL Guard Pi hook failed before completing review" in text + + def test_install_writes_managed_extension_that_truncates_post_tool_payloads( + self, + tmp_path: Path, + monkeypatch, + ) -> None: + ctx = _ctx(tmp_path) + monkeypatch.setattr( + "codex_plugin_scanner.guard.adapters.pi.install_guard_shim", + lambda *args, **kwargs: {"shim_path": str(ctx.guard_home / "bin" / "guard-pi"), "notes": []}, + ) + + manifest = get_adapter("pi").install(ctx) + + text = Path(str(manifest["config_path"])).read_text(encoding="utf-8") + assert "const GUARD_TEXT_LIMIT_CHARS =" in text + assert "const GUARD_CONTENT_ITEM_LIMIT =" in text + assert "const GUARD_OBJECT_KEY_LIMIT =" in text + assert "const GUARD_MAX_DEPTH =" in text + assert "const GUARD_MAX_SERIALIZED_PAYLOAD_CHARS =" in text + assert "function truncateText(" in text + assert "function boundValue(" in text + assert "function boundedOutputText(" in text + assert "function toolCallIdKey(" in text + assert "if (value === undefined) return { value: undefined, truncated: false };" in text + assert "typeof value === 'bigint'" in text + assert "value.toString()" in text + assert "new WeakSet()" in text + assert "[deep object omitted by HOL Guard]" in text + assert "const boundedContent = boundValue(event.content);" in text + assert "const boundedStdout = boundedOutputText(event.content);" in text + assert "boundedToolInput.truncated || boundedContent.truncated || boundedStdout.truncated" in text + assert "const blockedToolResults = new Map();" in text + assert 'pi.on("message_end"' in text + assert "const toolCallId = toolCallIdKey(event.toolCallId);" in text + assert "if (toolCallId) blockedToolResults.set(toolCallId, reason);" in text + assert "blockedToolResults.delete(toolCallId);" in text + assert "HOL Guard blocked oversized Pi tool output before review" in text + assert "return blockedToolResult(reason, event.details);" in text + assert "tool_response: limitedContent" in text + assert "stdout: toolOutput" in text + assert "contentText(event.content)" not in text + assert "options?.enforceSizeCap === true" in text + assert 'payloadToSend.hook_event_name === "PostToolUse"' in text + assert "delete reducedPayload.stdout;" in text + def test_uninstall_removes_managed_extension(self, tmp_path: Path, monkeypatch) -> None: ctx = _ctx(tmp_path) monkeypatch.setattr(