Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/guard/harness-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
292 changes: 268 additions & 24 deletions src/codex_plugin_scanner/guard/adapters/pi_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<string, unknown>, 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<object>()): 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"
Comment thread
internet-dot marked this conversation as resolved.
" }\n"
" if (typeof value !== 'object') {\n"
" return { value: String(value), truncated: true };\n"
" }\n"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
" 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<string, unknown>;\n"
" const nextRecord: Record<string, unknown> = {};\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<object>(),\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<string, unknown>;\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<string, unknown>,\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"
Expand All @@ -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<string, string>();\n"
' pi.on("input", async (event, ctx) => {\n'
' if (event.source === "extension") return { action: "continue" };\n'
" const response = runGuard(\n"
Expand Down Expand Up @@ -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<string, unknown> }).input ??\n"
" (event as { toolInput?: Record<string, unknown> }).toolInput ??\n"
" (event as { arguments?: Record<string, unknown> }).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<string, unknown>;\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"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
" },\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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading