pic-guard 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ {
2
+ "bridge_url": "http://127.0.0.1:7580",
3
+ "bridge_timeout_ms": 500,
4
+ "log_level": "info"
5
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * pic-audit — PIC post-execution audit trail for OpenClaw.
3
+ *
4
+ * Hook: tool_result_persist (priority 200)
5
+ *
6
+ * Fires after a tool call completes. Logs a structured audit record.
7
+ * This provides an audit trail for compliance and debugging.
8
+ *
9
+ * This hook is read-only — it never modifies the tool result or blocks
10
+ * execution. It runs at priority 200 (after all functional hooks).
11
+ *
12
+ * IMPORTANT: This hook is SYNCHRONOUS ONLY — async handlers are rejected
13
+ * by the OpenClaw hook runner.
14
+ *
15
+ * LIMITATION: The real tool_result_persist event contains
16
+ * { toolName?, toolCallId?, message, isSynthetic? } — it does NOT include
17
+ * params or __pic metadata (pic-gate strips __pic before execution, and
18
+ * the persist event receives the result message, not the original call).
19
+ */
20
+ /** Real shape of tool_result_persist event (from OpenClaw src/plugins/types.ts). */
21
+ interface ToolResultPersistEvent {
22
+ toolName?: string;
23
+ toolCallId?: string;
24
+ message: Record<string, unknown>;
25
+ isSynthetic?: boolean;
26
+ }
27
+ /** Real shape of tool_result_persist context. */
28
+ interface ToolResultPersistContext {
29
+ agentId?: string;
30
+ sessionKey?: string;
31
+ toolName?: string;
32
+ toolCallId?: string;
33
+ }
34
+ /**
35
+ * Factory: creates the tool_result_persist handler with captured plugin config.
36
+ */
37
+ export declare function createPicAuditHandler(pluginConfig: Record<string, unknown>): (event: ToolResultPersistEvent, ctx: ToolResultPersistContext) => void;
38
+ export {};
@@ -0,0 +1,60 @@
1
+ /**
2
+ * pic-audit — PIC post-execution audit trail for OpenClaw.
3
+ *
4
+ * Hook: tool_result_persist (priority 200)
5
+ *
6
+ * Fires after a tool call completes. Logs a structured audit record.
7
+ * This provides an audit trail for compliance and debugging.
8
+ *
9
+ * This hook is read-only — it never modifies the tool result or blocks
10
+ * execution. It runs at priority 200 (after all functional hooks).
11
+ *
12
+ * IMPORTANT: This hook is SYNCHRONOUS ONLY — async handlers are rejected
13
+ * by the OpenClaw hook runner.
14
+ *
15
+ * LIMITATION: The real tool_result_persist event contains
16
+ * { toolName?, toolCallId?, message, isSynthetic? } — it does NOT include
17
+ * params or __pic metadata (pic-gate strips __pic before execution, and
18
+ * the persist event receives the result message, not the original call).
19
+ */
20
+ import { DEFAULT_CONFIG } from "../../lib/types.js";
21
+ /**
22
+ * Resolve plugin config from captured pluginConfig (closure from register()).
23
+ */
24
+ function resolveConfig(pluginConfig) {
25
+ return {
26
+ bridge_url: typeof pluginConfig.bridge_url === "string"
27
+ ? pluginConfig.bridge_url
28
+ : DEFAULT_CONFIG.bridge_url,
29
+ bridge_timeout_ms: typeof pluginConfig.bridge_timeout_ms === "number"
30
+ ? pluginConfig.bridge_timeout_ms
31
+ : DEFAULT_CONFIG.bridge_timeout_ms,
32
+ log_level: pluginConfig.log_level === "debug" || pluginConfig.log_level === "info" || pluginConfig.log_level === "warn"
33
+ ? pluginConfig.log_level
34
+ : DEFAULT_CONFIG.log_level,
35
+ };
36
+ }
37
+ /**
38
+ * Factory: creates the tool_result_persist handler with captured plugin config.
39
+ */
40
+ export function createPicAuditHandler(pluginConfig) {
41
+ return function handler(event, ctx) {
42
+ const config = resolveConfig(pluginConfig);
43
+ const toolName = event.toolName ?? ctx.toolName ?? "unknown";
44
+ const entry = {
45
+ timestamp: new Date().toISOString(),
46
+ event: "tool_result_persist",
47
+ tool: toolName,
48
+ toolCallId: event.toolCallId ?? ctx.toolCallId,
49
+ isSynthetic: event.isSynthetic ?? false,
50
+ };
51
+ // ── Log ────────────────────────────────────────────────────────────
52
+ if (config.log_level === "debug") {
53
+ console.debug(`[pic-audit] ${JSON.stringify(entry)}`);
54
+ }
55
+ else if (config.log_level === "info") {
56
+ console.log(`[pic-audit] tool=${entry.tool} callId=${entry.toolCallId ?? "n/a"} ` +
57
+ `synthetic=${entry.isSynthetic}`);
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * pic-gate — PIC Standard pre-execution gate for OpenClaw.
3
+ *
4
+ * Hook: before_tool_call (priority 100)
5
+ *
6
+ * Sends the tool call (including any __pic proposal in params) to the PIC
7
+ * HTTP bridge for verification.
8
+ *
9
+ * - allowed → strips __pic from params, tool proceeds
10
+ * - blocked → returns { block: true, blockReason } — NEVER throws
11
+ * - bridge unreachable → blocked (fail-closed)
12
+ *
13
+ * IMPORTANT: Config comes from pluginConfig closure (captured in register()),
14
+ * NOT from ctx.pluginConfig (which doesn't exist in hook contexts).
15
+ */
16
+ /** Real shape of before_tool_call event (from OpenClaw src/plugins/types.ts). */
17
+ interface BeforeToolCallEvent {
18
+ toolName: string;
19
+ params: Record<string, unknown>;
20
+ }
21
+ /** Real return type for before_tool_call hook. */
22
+ type BeforeToolCallReturn = {
23
+ block: true;
24
+ blockReason: string;
25
+ } | {
26
+ params: Record<string, unknown>;
27
+ } | void;
28
+ /**
29
+ * Factory: creates the before_tool_call handler with captured plugin config.
30
+ */
31
+ export declare function createPicGateHandler(pluginConfig: Record<string, unknown>): (event: BeforeToolCallEvent, ctx: Record<string, unknown>) => Promise<BeforeToolCallReturn>;
32
+ export {};
@@ -0,0 +1,67 @@
1
+ /**
2
+ * pic-gate — PIC Standard pre-execution gate for OpenClaw.
3
+ *
4
+ * Hook: before_tool_call (priority 100)
5
+ *
6
+ * Sends the tool call (including any __pic proposal in params) to the PIC
7
+ * HTTP bridge for verification.
8
+ *
9
+ * - allowed → strips __pic from params, tool proceeds
10
+ * - blocked → returns { block: true, blockReason } — NEVER throws
11
+ * - bridge unreachable → blocked (fail-closed)
12
+ *
13
+ * IMPORTANT: Config comes from pluginConfig closure (captured in register()),
14
+ * NOT from ctx.pluginConfig (which doesn't exist in hook contexts).
15
+ */
16
+ import { verifyToolCall } from "../../lib/pic-client.js";
17
+ import { DEFAULT_CONFIG } from "../../lib/types.js";
18
+ /**
19
+ * Resolve plugin config from captured pluginConfig (closure from register()).
20
+ */
21
+ function resolveConfig(pluginConfig) {
22
+ return {
23
+ bridge_url: typeof pluginConfig.bridge_url === "string"
24
+ ? pluginConfig.bridge_url
25
+ : DEFAULT_CONFIG.bridge_url,
26
+ bridge_timeout_ms: typeof pluginConfig.bridge_timeout_ms === "number"
27
+ ? pluginConfig.bridge_timeout_ms
28
+ : DEFAULT_CONFIG.bridge_timeout_ms,
29
+ log_level: pluginConfig.log_level === "debug" || pluginConfig.log_level === "info" || pluginConfig.log_level === "warn"
30
+ ? pluginConfig.log_level
31
+ : DEFAULT_CONFIG.log_level,
32
+ };
33
+ }
34
+ /**
35
+ * Factory: creates the before_tool_call handler with captured plugin config.
36
+ */
37
+ export function createPicGateHandler(pluginConfig) {
38
+ return async function handler(event, _ctx) {
39
+ const config = resolveConfig(pluginConfig);
40
+ // Defensive: ensure params is an object (fail-closed if malformed event)
41
+ const params = event.params ?? {};
42
+ if (typeof params !== "object" || params === null) {
43
+ return { block: true, blockReason: "PIC gate: malformed event (params not an object)" };
44
+ }
45
+ // Defensive: ensure toolName is a non-empty string
46
+ const toolName = event.toolName;
47
+ if (typeof toolName !== "string" || toolName.trim() === "") {
48
+ return { block: true, blockReason: "PIC gate: malformed event (toolName missing or empty)" };
49
+ }
50
+ // ── Verify against PIC bridge ──────────────────────────────────────
51
+ const result = await verifyToolCall(toolName, params, config);
52
+ // ── Blocked ────────────────────────────────────────────────────────
53
+ if (!result.allowed) {
54
+ const reason = result.error?.message ?? "PIC contract violation (no details)";
55
+ if (config.log_level === "debug" || config.log_level === "info") {
56
+ console.log(`[pic-gate] BLOCKED tool=${toolName} reason="${reason}"`);
57
+ }
58
+ return { block: true, blockReason: reason };
59
+ }
60
+ // ── Allowed — strip __pic metadata before tool executes ────────────
61
+ const { __pic, __pic_request_id, ...cleanParams } = params;
62
+ if (config.log_level === "debug") {
63
+ console.debug(`[pic-gate] ALLOWED tool=${toolName} eval_ms=${result.eval_ms}`);
64
+ }
65
+ return { params: cleanParams };
66
+ };
67
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * pic-init — PIC awareness injection for OpenClaw.
3
+ *
4
+ * Hook: before_agent_start (priority 50)
5
+ *
6
+ * Returns a prependContext string that informs the agent about PIC governance
7
+ * requirements, so it includes __pic proposals in high-impact tool calls.
8
+ *
9
+ * Also performs an early health check against the PIC bridge to surface
10
+ * connectivity issues at session start rather than at first tool call.
11
+ */
12
+ /**
13
+ * Factory: creates the before_agent_start handler with captured plugin config.
14
+ */
15
+ export declare function createPicInitHandler(pluginConfig: Record<string, unknown>): (event: {
16
+ prompt: string;
17
+ messages?: unknown[];
18
+ }, ctx: Record<string, unknown>) => Promise<{
19
+ prependContext?: string;
20
+ }>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * pic-init — PIC awareness injection for OpenClaw.
3
+ *
4
+ * Hook: before_agent_start (priority 50)
5
+ *
6
+ * Returns a prependContext string that informs the agent about PIC governance
7
+ * requirements, so it includes __pic proposals in high-impact tool calls.
8
+ *
9
+ * Also performs an early health check against the PIC bridge to surface
10
+ * connectivity issues at session start rather than at first tool call.
11
+ */
12
+ import { DEFAULT_CONFIG } from "../../lib/types.js";
13
+ const PIC_AWARENESS_MESSAGE = `\
14
+ [PIC Standard] This session is governed by Provenance & Intent Contracts.
15
+
16
+ For high-impact tool calls (money transfers, data exports, irreversible
17
+ actions), you MUST include a __pic field in the tool parameters with:
18
+ - intent: why this action is needed (string)
19
+ - impact: impact class (e.g., "money", "privacy", "irreversible")
20
+ - provenance: array of { id, trust } identifying instruction origins
21
+ - claims: array of { text, evidence } — verifiable assertions
22
+ - action: { tool, args } binding the proposal to the specific call
23
+
24
+ Example __pic structure:
25
+ {
26
+ "intent": "Transfer funds for approved invoice",
27
+ "impact": "money",
28
+ "provenance": [{ "id": "user_request", "trust": "trusted" }],
29
+ "claims": [{ "text": "Invoice verified", "evidence": ["invoice_hash"] }],
30
+ "action": { "tool": "payments_send", "args": { "amount": 100 } }
31
+ }
32
+
33
+ Tool calls without valid __pic proposals will be BLOCKED for high-impact
34
+ operations. Low-impact tools may proceed without __pic.`;
35
+ /**
36
+ * Resolve plugin config from captured pluginConfig (closure from register()).
37
+ */
38
+ function resolveConfig(pluginConfig) {
39
+ return {
40
+ bridge_url: typeof pluginConfig.bridge_url === "string"
41
+ ? pluginConfig.bridge_url
42
+ : DEFAULT_CONFIG.bridge_url,
43
+ bridge_timeout_ms: typeof pluginConfig.bridge_timeout_ms === "number"
44
+ ? pluginConfig.bridge_timeout_ms
45
+ : DEFAULT_CONFIG.bridge_timeout_ms,
46
+ log_level: pluginConfig.log_level === "debug" || pluginConfig.log_level === "info" || pluginConfig.log_level === "warn"
47
+ ? pluginConfig.log_level
48
+ : DEFAULT_CONFIG.log_level,
49
+ };
50
+ }
51
+ /**
52
+ * Factory: creates the before_agent_start handler with captured plugin config.
53
+ */
54
+ export function createPicInitHandler(pluginConfig) {
55
+ return async function handler(_event, _ctx) {
56
+ const config = resolveConfig(pluginConfig);
57
+ if (config.log_level === "debug") {
58
+ console.debug("[pic-init] Injected awareness message:", PIC_AWARENESS_MESSAGE);
59
+ }
60
+ // ── Early health check (best-effort, never blocks) ─────────────────
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(() => controller.abort(), config.bridge_timeout_ms);
63
+ try {
64
+ const resp = await fetch(`${config.bridge_url}/health`, {
65
+ signal: controller.signal,
66
+ });
67
+ if (!resp.ok) {
68
+ console.warn(`[pic-init] PIC bridge health check failed: HTTP ${resp.status}`);
69
+ }
70
+ else if (config.log_level === "debug") {
71
+ console.debug("[pic-init] PIC bridge is healthy");
72
+ }
73
+ }
74
+ catch {
75
+ console.warn(`[pic-init] PIC bridge unreachable at ${config.bridge_url} — ` +
76
+ "tool calls will be blocked (fail-closed) until the bridge is started.");
77
+ }
78
+ finally {
79
+ clearTimeout(timeout);
80
+ }
81
+ if (config.log_level === "debug" || config.log_level === "info") {
82
+ console.log("[pic-init] PIC awareness injected into session");
83
+ }
84
+ return { prependContext: PIC_AWARENESS_MESSAGE };
85
+ };
86
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * PIC Standard plugin for OpenClaw.
3
+ *
4
+ * Entry point — registers lifecycle hooks using api.on() for typed hook system.
5
+ *
6
+ * IMPORTANT: Use api.on() for lifecycle hooks (before_tool_call, etc.), NOT api.registerHook().
7
+ * - api.registerHook() → internal hooks (old system, requires config flag)
8
+ * - api.on() → typed hooks (new system, always active, used by hook runner)
9
+ */
10
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
11
+ export default function register(api: OpenClawPluginApi): void;
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * PIC Standard plugin for OpenClaw.
3
+ *
4
+ * Entry point — registers lifecycle hooks using api.on() for typed hook system.
5
+ *
6
+ * IMPORTANT: Use api.on() for lifecycle hooks (before_tool_call, etc.), NOT api.registerHook().
7
+ * - api.registerHook() → internal hooks (old system, requires config flag)
8
+ * - api.on() → typed hooks (new system, always active, used by hook runner)
9
+ */
10
+ import { createPicAuditHandler } from "./hooks/pic-audit/handler.js";
11
+ import { createPicGateHandler } from "./hooks/pic-gate/handler.js";
12
+ import { createPicInitHandler } from "./hooks/pic-init/handler.js";
13
+ export default function register(api) {
14
+ const pluginConfig = (api.pluginConfig ?? {});
15
+ // pic-init: Inject PIC awareness at session start
16
+ api.on("before_agent_start", createPicInitHandler(pluginConfig), { priority: 50 });
17
+ // pic-gate: Verify tool calls before execution (fail-closed)
18
+ api.on("before_tool_call", createPicGateHandler(pluginConfig), { priority: 100 });
19
+ // pic-audit: Log verification outcomes after execution
20
+ api.on("tool_result_persist", createPicAuditHandler(pluginConfig), { priority: 200 });
21
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PIC HTTP Bridge client – fail-closed HTTP client for the PIC verifier.
3
+ *
4
+ * Calls POST /verify on the Python-side bridge and returns a typed response.
5
+ * On ANY failure (timeout, connection refused, malformed response) the client
6
+ * returns { allowed: false } — never throws.
7
+ */
8
+ import type { PICVerifyResponse, PICPluginConfig } from "./types.js";
9
+ /**
10
+ * Verify a tool call against the PIC HTTP bridge.
11
+ *
12
+ * @param toolName - The tool being invoked (e.g. "exec", "write_file").
13
+ * @param toolArgs - Full tool arguments (should include __pic if the agent provided one).
14
+ * @param config - Plugin configuration (bridge URL, timeout).
15
+ * @returns PICVerifyResponse — always resolves, never rejects.
16
+ */
17
+ export declare function verifyToolCall(toolName: string, toolArgs: Record<string, unknown>, config?: PICPluginConfig): Promise<PICVerifyResponse>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * PIC HTTP Bridge client – fail-closed HTTP client for the PIC verifier.
3
+ *
4
+ * Calls POST /verify on the Python-side bridge and returns a typed response.
5
+ * On ANY failure (timeout, connection refused, malformed response) the client
6
+ * returns { allowed: false } — never throws.
7
+ */
8
+ import { DEFAULT_CONFIG } from "./types.js";
9
+ /** Valid PIC error codes for runtime validation. */
10
+ const VALID_ERROR_CODES = [
11
+ "PIC_INVALID_REQUEST",
12
+ "PIC_LIMIT_EXCEEDED",
13
+ "PIC_SCHEMA_INVALID",
14
+ "PIC_VERIFIER_FAILED",
15
+ "PIC_TOOL_BINDING_MISMATCH",
16
+ "PIC_EVIDENCE_REQUIRED",
17
+ "PIC_EVIDENCE_FAILED",
18
+ "PIC_POLICY_VIOLATION",
19
+ "PIC_INTERNAL_ERROR",
20
+ "PIC_BRIDGE_UNREACHABLE",
21
+ ];
22
+ // TODO: Future telemetry hook point
23
+ // - count of failed bridge calls
24
+ // - average eval_ms
25
+ // - distribution of error.code values
26
+ /**
27
+ * Verify a tool call against the PIC HTTP bridge.
28
+ *
29
+ * @param toolName - The tool being invoked (e.g. "exec", "write_file").
30
+ * @param toolArgs - Full tool arguments (should include __pic if the agent provided one).
31
+ * @param config - Plugin configuration (bridge URL, timeout).
32
+ * @returns PICVerifyResponse — always resolves, never rejects.
33
+ */
34
+ export async function verifyToolCall(toolName, toolArgs, config = DEFAULT_CONFIG) {
35
+ const body = {
36
+ tool_name: toolName,
37
+ tool_args: toolArgs,
38
+ };
39
+ if (config.log_level === "debug") {
40
+ console.debug(`[pic-client] POST ${config.bridge_url}/verify tool=${toolName}`);
41
+ }
42
+ const controller = new AbortController();
43
+ const timeout = setTimeout(() => controller.abort(), config.bridge_timeout_ms);
44
+ try {
45
+ const resp = await fetch(`${config.bridge_url}/verify`, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify(body),
49
+ signal: controller.signal,
50
+ });
51
+ // Fail-closed on HTTP errors (5xx, 4xx, etc.)
52
+ if (!resp.ok) {
53
+ return failClosed(`Bridge returned HTTP ${resp.status}`);
54
+ }
55
+ const json = (await resp.json());
56
+ // Sanity-check the response shape
57
+ if (typeof json.allowed !== "boolean") {
58
+ console.warn(`[pic-client] Malformed response: ${JSON.stringify(json)}`);
59
+ return failClosed("Malformed bridge response: missing 'allowed'");
60
+ }
61
+ // Normalize eval_ms
62
+ const eval_ms = typeof json.eval_ms === "number" ? json.eval_ms : 0;
63
+ if (json.allowed === true) {
64
+ // Success case: allowed: true, error: null
65
+ if (config.log_level === "debug") {
66
+ console.debug(`[pic-client] result: allowed=true eval_ms=${eval_ms}`);
67
+ }
68
+ return { allowed: true, error: null, eval_ms };
69
+ }
70
+ // Denial case: allowed: false, error: PICError
71
+ const error = json.error;
72
+ if (!error || typeof error.code !== "string") {
73
+ console.warn(`[pic-client] Malformed denial response: ${JSON.stringify(json)}`);
74
+ return failClosed("Malformed bridge response: denial missing error code");
75
+ }
76
+ if (!VALID_ERROR_CODES.includes(error.code)) {
77
+ console.warn(`[pic-client] Unknown error code: ${error.code}`);
78
+ return failClosed(`Malformed bridge response: unknown error code '${error.code}'`);
79
+ }
80
+ const picError = {
81
+ code: error.code,
82
+ message: typeof error.message === "string" ? error.message : "Unknown error",
83
+ details: typeof error.details === "object" ? error.details : undefined,
84
+ };
85
+ if (config.log_level === "debug") {
86
+ console.debug(`[pic-client] result: allowed=false eval_ms=${eval_ms} code=${picError.code}`);
87
+ }
88
+ return { allowed: false, error: picError, eval_ms };
89
+ }
90
+ catch (err) {
91
+ const message = err instanceof Error ? err.message : "Unknown bridge error";
92
+ return failClosed(message);
93
+ }
94
+ finally {
95
+ clearTimeout(timeout);
96
+ }
97
+ }
98
+ /**
99
+ * Fail-closed helper: returns a block response when the bridge is unreachable
100
+ * or returns garbage. This ensures the plugin never silently allows a tool
101
+ * call just because the bridge is down.
102
+ */
103
+ function failClosed(reason) {
104
+ return {
105
+ allowed: false,
106
+ error: {
107
+ code: "PIC_BRIDGE_UNREACHABLE",
108
+ message: `PIC bridge error: ${reason}`,
109
+ },
110
+ eval_ms: 0,
111
+ };
112
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * PIC Standard – TypeScript types for the HTTP bridge protocol.
3
+ *
4
+ * These mirror the Python-side contracts defined in:
5
+ * sdk-python/pic_standard/integrations/http_bridge.py
6
+ * sdk-python/pic_standard/errors.py (PICErrorCode)
7
+ *
8
+ * Verification logic lives in sdk-python/pic_standard/pipeline.py
9
+ */
10
+ /**
11
+ * All possible PIC error codes.
12
+ * Mirrors PICErrorCode enum in sdk-python/pic_standard/errors.py.
13
+ */
14
+ export type PICErrorCode = "PIC_INVALID_REQUEST" | "PIC_LIMIT_EXCEEDED" | "PIC_SCHEMA_INVALID" | "PIC_VERIFIER_FAILED" | "PIC_TOOL_BINDING_MISMATCH" | "PIC_EVIDENCE_REQUIRED" | "PIC_EVIDENCE_FAILED" | "PIC_POLICY_VIOLATION" | "PIC_INTERNAL_ERROR" | "PIC_BRIDGE_UNREACHABLE";
15
+ /** Body sent to POST /verify on the PIC HTTP bridge. */
16
+ export interface PICVerifyRequest {
17
+ tool_name: string;
18
+ tool_args: Record<string, unknown>;
19
+ }
20
+ /** Structured error returned when allowed === false. */
21
+ export interface PICError {
22
+ code: PICErrorCode;
23
+ message: string;
24
+ details?: Record<string, unknown>;
25
+ }
26
+ /**
27
+ * Response from POST /verify. Always 200 — decision is in `allowed`.
28
+ *
29
+ * Modeled as a discriminated union to match the wire format:
30
+ * - allowed: true → error: null
31
+ * - allowed: false → error: PICError
32
+ */
33
+ export type PICVerifyResponse = {
34
+ allowed: true;
35
+ error: null;
36
+ eval_ms: number;
37
+ } | {
38
+ allowed: false;
39
+ error: PICError;
40
+ eval_ms: number;
41
+ };
42
+ /** Configuration for the pic-guard OpenClaw plugin. */
43
+ export interface PICPluginConfig {
44
+ /** URL of the PIC HTTP bridge (default: "http://127.0.0.1:7580"). */
45
+ bridge_url: string;
46
+ /** HTTP timeout in milliseconds (default: 500). */
47
+ bridge_timeout_ms: number;
48
+ /** Log level: "debug" | "info" | "warn" (default: "info"). */
49
+ log_level: "debug" | "info" | "warn";
50
+ }
51
+ /** Sensible defaults matching PICEvaluateLimits on the Python side. */
52
+ export declare const DEFAULT_CONFIG: PICPluginConfig;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * PIC Standard – TypeScript types for the HTTP bridge protocol.
3
+ *
4
+ * These mirror the Python-side contracts defined in:
5
+ * sdk-python/pic_standard/integrations/http_bridge.py
6
+ * sdk-python/pic_standard/errors.py (PICErrorCode)
7
+ *
8
+ * Verification logic lives in sdk-python/pic_standard/pipeline.py
9
+ */
10
+ /** Sensible defaults matching PICEvaluateLimits on the Python side. */
11
+ export const DEFAULT_CONFIG = {
12
+ bridge_url: "http://127.0.0.1:7580",
13
+ bridge_timeout_ms: 500,
14
+ log_level: "info",
15
+ };
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: pic-audit
3
+ description: PIC post-execution audit trail — logs verification outcomes after tool execution
4
+ metadata:
5
+ openclaw:
6
+ events: ["tool_result_persist"]
7
+ priority: 200
8
+ ---
9
+
10
+ # pic-audit — PIC Post-Execution Audit Trail
11
+
12
+ Fires after every tool execution via `tool_result_persist`. Logs a
13
+ structured audit record capturing the PIC verification outcome for
14
+ the tool call that just completed.
15
+
16
+ ## Behavior
17
+
18
+ - Logs structured JSON at debug level
19
+ - Logs summary line at info level
20
+ - Records: tool name, whether PIC was present, verification result
21
+ - Never modifies the tool result
22
+ - Never throws
@@ -0,0 +1,101 @@
1
+ /**
2
+ * pic-audit — PIC post-execution audit trail for OpenClaw.
3
+ *
4
+ * Hook: tool_result_persist (priority 200)
5
+ *
6
+ * Fires after a tool call completes. Logs a structured audit record.
7
+ * This provides an audit trail for compliance and debugging.
8
+ *
9
+ * This hook is read-only — it never modifies the tool result or blocks
10
+ * execution. It runs at priority 200 (after all functional hooks).
11
+ *
12
+ * IMPORTANT: This hook is SYNCHRONOUS ONLY — async handlers are rejected
13
+ * by the OpenClaw hook runner.
14
+ *
15
+ * LIMITATION: The real tool_result_persist event contains
16
+ * { toolName?, toolCallId?, message, isSynthetic? } — it does NOT include
17
+ * params or __pic metadata (pic-gate strips __pic before execution, and
18
+ * the persist event receives the result message, not the original call).
19
+ */
20
+
21
+ import type { PICPluginConfig } from "../../lib/types.js";
22
+ import { DEFAULT_CONFIG } from "../../lib/types.js";
23
+
24
+ /** Real shape of tool_result_persist event (from OpenClaw src/plugins/types.ts). */
25
+ interface ToolResultPersistEvent {
26
+ toolName?: string;
27
+ toolCallId?: string;
28
+ message: Record<string, unknown>;
29
+ isSynthetic?: boolean;
30
+ }
31
+
32
+ /** Real shape of tool_result_persist context. */
33
+ interface ToolResultPersistContext {
34
+ agentId?: string;
35
+ sessionKey?: string;
36
+ toolName?: string;
37
+ toolCallId?: string;
38
+ }
39
+
40
+ /** Structured audit entry written to console. */
41
+ interface PICAuditEntry {
42
+ timestamp: string;
43
+ event: "tool_result_persist";
44
+ tool: string;
45
+ toolCallId?: string;
46
+ isSynthetic: boolean;
47
+ }
48
+
49
+ /**
50
+ * Resolve plugin config from captured pluginConfig (closure from register()).
51
+ */
52
+ function resolveConfig(pluginConfig: Record<string, unknown>): PICPluginConfig {
53
+ return {
54
+ bridge_url:
55
+ typeof pluginConfig.bridge_url === "string"
56
+ ? pluginConfig.bridge_url
57
+ : DEFAULT_CONFIG.bridge_url,
58
+ bridge_timeout_ms:
59
+ typeof pluginConfig.bridge_timeout_ms === "number"
60
+ ? pluginConfig.bridge_timeout_ms
61
+ : DEFAULT_CONFIG.bridge_timeout_ms,
62
+ log_level:
63
+ pluginConfig.log_level === "debug" || pluginConfig.log_level === "info" || pluginConfig.log_level === "warn"
64
+ ? pluginConfig.log_level
65
+ : DEFAULT_CONFIG.log_level,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Factory: creates the tool_result_persist handler with captured plugin config.
71
+ */
72
+ export function createPicAuditHandler(
73
+ pluginConfig: Record<string, unknown>,
74
+ ): (event: ToolResultPersistEvent, ctx: ToolResultPersistContext) => void {
75
+ return function handler(
76
+ event: ToolResultPersistEvent,
77
+ ctx: ToolResultPersistContext,
78
+ ): void {
79
+ const config = resolveConfig(pluginConfig);
80
+
81
+ const toolName = event.toolName ?? ctx.toolName ?? "unknown";
82
+
83
+ const entry: PICAuditEntry = {
84
+ timestamp: new Date().toISOString(),
85
+ event: "tool_result_persist",
86
+ tool: toolName,
87
+ toolCallId: event.toolCallId ?? ctx.toolCallId,
88
+ isSynthetic: event.isSynthetic ?? false,
89
+ };
90
+
91
+ // ── Log ────────────────────────────────────────────────────────────
92
+ if (config.log_level === "debug") {
93
+ console.debug(`[pic-audit] ${JSON.stringify(entry)}`);
94
+ } else if (config.log_level === "info") {
95
+ console.log(
96
+ `[pic-audit] tool=${entry.tool} callId=${entry.toolCallId ?? "n/a"} ` +
97
+ `synthetic=${entry.isSynthetic}`,
98
+ );
99
+ }
100
+ };
101
+ }
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: pic-gate
3
+ description: PIC Standard pre-execution gate — verifies tool calls against the PIC bridge
4
+ metadata:
5
+ openclaw:
6
+ events: ["before_tool_call"]
7
+ priority: 100
8
+ ---
9
+
10
+ # pic-gate — PIC Standard Pre-Execution Gate
11
+
12
+ Intercepts every tool call via `before_tool_call` and verifies the agent's
13
+ PIC proposal against the PIC HTTP bridge before execution.
14
+
15
+ ## Behavior
16
+
17
+ |
18
+ Condition
19
+ |
20
+ Result
21
+ |
22
+ |
23
+ -----------
24
+ |
25
+ --------
26
+ |
27
+ |
28
+ Bridge returns
29
+ `allowed: true`
30
+ |
31
+ Tool executes;
32
+ `__pic`
33
+ stripped from params
34
+ |
35
+ |
36
+ Bridge returns
37
+ `allowed: false`
38
+ |
39
+ Tool blocked with
40
+ `blockReason`
41
+ |
42
+ |
43
+ Bridge unreachable / timeout
44
+ |
45
+ Tool blocked (fail-closed)
46
+ |
47
+
48
+ ## Requirements
49
+
50
+ - OpenClaw ≥ v2026.2.1 (before_tool_call hook support)
51
+ - PIC HTTP bridge running (`pic-cli serve` or programmatic `start_bridge()`)
52
+ - Python 3.10+ with `pic-standard` installed (for the bridge, not the hook)
53
+ - Default bridge URL: `http://127.0.0.1:7580`
54
+
55
+ ## Configuration
56
+
57
+ Set via `config/pic-plugin.example.json`:
58
+
59
+ ```json
60
+ {
61
+ "bridge_url": "http://127.0.0.1:7580",
62
+ "bridge_timeout_ms": 500,
63
+ "log_level": "info"
64
+ }
65
+ ```
@@ -0,0 +1,108 @@
1
+ /**
2
+ * pic-gate — PIC Standard pre-execution gate for OpenClaw.
3
+ *
4
+ * Hook: before_tool_call (priority 100)
5
+ *
6
+ * Sends the tool call (including any __pic proposal in params) to the PIC
7
+ * HTTP bridge for verification.
8
+ *
9
+ * - allowed → strips __pic from params, tool proceeds
10
+ * - blocked → returns { block: true, blockReason } — NEVER throws
11
+ * - bridge unreachable → blocked (fail-closed)
12
+ *
13
+ * IMPORTANT: Config comes from pluginConfig closure (captured in register()),
14
+ * NOT from ctx.pluginConfig (which doesn't exist in hook contexts).
15
+ */
16
+
17
+ import { verifyToolCall } from "../../lib/pic-client.js";
18
+ import type { PICPluginConfig } from "../../lib/types.js";
19
+ import { DEFAULT_CONFIG } from "../../lib/types.js";
20
+
21
+ /** Real shape of before_tool_call event (from OpenClaw src/plugins/types.ts). */
22
+ interface BeforeToolCallEvent {
23
+ toolName: string;
24
+ params: Record<string, unknown>;
25
+ }
26
+
27
+ /** Real return type for before_tool_call hook. */
28
+ type BeforeToolCallReturn =
29
+ | { block: true; blockReason: string }
30
+ | { params: Record<string, unknown> }
31
+ | void;
32
+
33
+ /**
34
+ * Resolve plugin config from captured pluginConfig (closure from register()).
35
+ */
36
+ function resolveConfig(pluginConfig: Record<string, unknown>): PICPluginConfig {
37
+ return {
38
+ bridge_url:
39
+ typeof pluginConfig.bridge_url === "string"
40
+ ? pluginConfig.bridge_url
41
+ : DEFAULT_CONFIG.bridge_url,
42
+ bridge_timeout_ms:
43
+ typeof pluginConfig.bridge_timeout_ms === "number"
44
+ ? pluginConfig.bridge_timeout_ms
45
+ : DEFAULT_CONFIG.bridge_timeout_ms,
46
+ log_level:
47
+ pluginConfig.log_level === "debug" || pluginConfig.log_level === "info" || pluginConfig.log_level === "warn"
48
+ ? pluginConfig.log_level
49
+ : DEFAULT_CONFIG.log_level,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Factory: creates the before_tool_call handler with captured plugin config.
55
+ */
56
+ export function createPicGateHandler(
57
+ pluginConfig: Record<string, unknown>,
58
+ ): (event: BeforeToolCallEvent, ctx: Record<string, unknown>) => Promise<BeforeToolCallReturn> {
59
+ return async function handler(
60
+ event: BeforeToolCallEvent,
61
+ _ctx: Record<string, unknown>,
62
+ ): Promise<BeforeToolCallReturn> {
63
+ const config = resolveConfig(pluginConfig);
64
+
65
+ // Defensive: ensure params is an object (fail-closed if malformed event)
66
+ const params = event.params ?? {};
67
+ if (typeof params !== "object" || params === null) {
68
+ return { block: true, blockReason: "PIC gate: malformed event (params not an object)" };
69
+ }
70
+
71
+ // Defensive: ensure toolName is a non-empty string
72
+ const toolName = event.toolName;
73
+ if (typeof toolName !== "string" || toolName.trim() === "") {
74
+ return { block: true, blockReason: "PIC gate: malformed event (toolName missing or empty)" };
75
+ }
76
+
77
+ // ── Verify against PIC bridge ──────────────────────────────────────
78
+ const result = await verifyToolCall(toolName, params, config);
79
+
80
+ // ── Blocked ────────────────────────────────────────────────────────
81
+ if (!result.allowed) {
82
+ const reason =
83
+ result.error?.message ?? "PIC contract violation (no details)";
84
+
85
+ if (config.log_level === "debug" || config.log_level === "info") {
86
+ console.log(
87
+ `[pic-gate] BLOCKED tool=${toolName} reason="${reason}"`,
88
+ );
89
+ }
90
+
91
+ return { block: true, blockReason: reason };
92
+ }
93
+
94
+ // ── Allowed — strip __pic metadata before tool executes ────────────
95
+ const { __pic, __pic_request_id, ...cleanParams } = params as Record<
96
+ string,
97
+ unknown
98
+ > & { __pic?: unknown; __pic_request_id?: unknown };
99
+
100
+ if (config.log_level === "debug") {
101
+ console.debug(
102
+ `[pic-gate] ALLOWED tool=${toolName} eval_ms=${result.eval_ms}`,
103
+ );
104
+ }
105
+
106
+ return { params: cleanParams };
107
+ };
108
+ }
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: pic-init
3
+ description: PIC awareness injection — informs the agent about PIC governance at session start
4
+ metadata:
5
+ openclaw:
6
+ events: ["before_agent_start"]
7
+ priority: 50
8
+ ---
9
+
10
+ # pic-init — PIC Awareness Injection
11
+
12
+ Fires once at session start via `before_agent_start`. Pushes a system
13
+ message that tells the agent about PIC governance so it knows to include
14
+ `__pic` proposals in tool calls.
15
+
16
+ ## Behavior
17
+
18
+ - Pushes a concise PIC awareness message into `event.messages`
19
+ - Checks bridge health to warn early if the bridge is unreachable
20
+ - Never blocks, never throws
@@ -0,0 +1,104 @@
1
+ /**
2
+ * pic-init — PIC awareness injection for OpenClaw.
3
+ *
4
+ * Hook: before_agent_start (priority 50)
5
+ *
6
+ * Returns a prependContext string that informs the agent about PIC governance
7
+ * requirements, so it includes __pic proposals in high-impact tool calls.
8
+ *
9
+ * Also performs an early health check against the PIC bridge to surface
10
+ * connectivity issues at session start rather than at first tool call.
11
+ */
12
+
13
+ import type { PICPluginConfig } from "../../lib/types.js";
14
+ import { DEFAULT_CONFIG } from "../../lib/types.js";
15
+
16
+ const PIC_AWARENESS_MESSAGE = `\
17
+ [PIC Standard] This session is governed by Provenance & Intent Contracts.
18
+
19
+ For high-impact tool calls (money transfers, data exports, irreversible
20
+ actions), you MUST include a __pic field in the tool parameters with:
21
+ - intent: why this action is needed (string)
22
+ - impact: impact class (e.g., "money", "privacy", "irreversible")
23
+ - provenance: array of { id, trust } identifying instruction origins
24
+ - claims: array of { text, evidence } — verifiable assertions
25
+ - action: { tool, args } binding the proposal to the specific call
26
+
27
+ Example __pic structure:
28
+ {
29
+ "intent": "Transfer funds for approved invoice",
30
+ "impact": "money",
31
+ "provenance": [{ "id": "user_request", "trust": "trusted" }],
32
+ "claims": [{ "text": "Invoice verified", "evidence": ["invoice_hash"] }],
33
+ "action": { "tool": "payments_send", "args": { "amount": 100 } }
34
+ }
35
+
36
+ Tool calls without valid __pic proposals will be BLOCKED for high-impact
37
+ operations. Low-impact tools may proceed without __pic.`;
38
+
39
+ /**
40
+ * Resolve plugin config from captured pluginConfig (closure from register()).
41
+ */
42
+ function resolveConfig(pluginConfig: Record<string, unknown>): PICPluginConfig {
43
+ return {
44
+ bridge_url:
45
+ typeof pluginConfig.bridge_url === "string"
46
+ ? pluginConfig.bridge_url
47
+ : DEFAULT_CONFIG.bridge_url,
48
+ bridge_timeout_ms:
49
+ typeof pluginConfig.bridge_timeout_ms === "number"
50
+ ? pluginConfig.bridge_timeout_ms
51
+ : DEFAULT_CONFIG.bridge_timeout_ms,
52
+ log_level:
53
+ pluginConfig.log_level === "debug" || pluginConfig.log_level === "info" || pluginConfig.log_level === "warn"
54
+ ? pluginConfig.log_level
55
+ : DEFAULT_CONFIG.log_level,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Factory: creates the before_agent_start handler with captured plugin config.
61
+ */
62
+ export function createPicInitHandler(
63
+ pluginConfig: Record<string, unknown>,
64
+ ): (event: { prompt: string; messages?: unknown[] }, ctx: Record<string, unknown>) => Promise<{ prependContext?: string }> {
65
+ return async function handler(
66
+ _event: { prompt: string; messages?: unknown[] },
67
+ _ctx: Record<string, unknown>,
68
+ ): Promise<{ prependContext?: string }> {
69
+ const config = resolveConfig(pluginConfig);
70
+
71
+ if (config.log_level === "debug") {
72
+ console.debug("[pic-init] Injected awareness message:", PIC_AWARENESS_MESSAGE);
73
+ }
74
+
75
+ // ── Early health check (best-effort, never blocks) ─────────────────
76
+ const controller = new AbortController();
77
+ const timeout = setTimeout(() => controller.abort(), config.bridge_timeout_ms);
78
+
79
+ try {
80
+ const resp = await fetch(`${config.bridge_url}/health`, {
81
+ signal: controller.signal,
82
+ });
83
+
84
+ if (!resp.ok) {
85
+ console.warn(`[pic-init] PIC bridge health check failed: HTTP ${resp.status}`);
86
+ } else if (config.log_level === "debug") {
87
+ console.debug("[pic-init] PIC bridge is healthy");
88
+ }
89
+ } catch {
90
+ console.warn(
91
+ `[pic-init] PIC bridge unreachable at ${config.bridge_url} — ` +
92
+ "tool calls will be blocked (fail-closed) until the bridge is started.",
93
+ );
94
+ } finally {
95
+ clearTimeout(timeout);
96
+ }
97
+
98
+ if (config.log_level === "debug" || config.log_level === "info") {
99
+ console.log("[pic-init] PIC awareness injected into session");
100
+ }
101
+
102
+ return { prependContext: PIC_AWARENESS_MESSAGE };
103
+ };
104
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "id": "pic-guard",
3
+ "name": "PIC Standard Guard",
4
+ "version": "0.6.1",
5
+ "description": "Provenance & Intent Contract verification for OpenClaw tool calls",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "bridge_url": {
10
+ "type": "string",
11
+ "default": "http://127.0.0.1:7580",
12
+ "description": "PIC HTTP bridge endpoint URL"
13
+ },
14
+ "bridge_timeout_ms": {
15
+ "type": "integer",
16
+ "default": 500,
17
+ "minimum": 100,
18
+ "maximum": 5000,
19
+ "description": "HTTP timeout for bridge verification calls (milliseconds)"
20
+ },
21
+ "log_level": {
22
+ "type": "string",
23
+ "enum": [
24
+ "debug",
25
+ "info",
26
+ "warn"
27
+ ],
28
+ "default": "info",
29
+ "description": "Logging verbosity level"
30
+ }
31
+ },
32
+ "additionalProperties": false
33
+ },
34
+ "uiHints": {
35
+ "bridge_url": {
36
+ "label": "Bridge URL",
37
+ "placeholder": "http://127.0.0.1:7580",
38
+ "description": "URL of the PIC HTTP bridge (pic-cli serve). Leave blank to use default localhost."
39
+ },
40
+ "bridge_timeout_ms": {
41
+ "label": "Timeout (ms)",
42
+ "inputType": "number",
43
+ "description": "Maximum time to wait for bridge response before blocking the tool call (fail-closed)."
44
+ },
45
+ "log_level": {
46
+ "label": "Log Level",
47
+ "description": "Controls verbosity: debug (all details), info (gate/audit events), warn (errors only)."
48
+ }
49
+ }
50
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pic-guard",
3
+ "version": "0.6.1",
4
+ "description": "PIC Standard governance plugin for OpenClaw",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "clean": "rm -rf dist",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "hooks",
21
+ "config",
22
+ "openclaw.plugin.json"
23
+ ],
24
+ "openclaw": {
25
+ "extensions": [
26
+ "./dist/index.js"
27
+ ]
28
+ },
29
+ "keywords": [
30
+ "pic",
31
+ "provenance",
32
+ "intent",
33
+ "contracts",
34
+ "openclaw",
35
+ "plugin",
36
+ "ai-safety",
37
+ "tool-governance"
38
+ ],
39
+ "author": "Fabio Marcello Salvadori",
40
+ "license": "Apache-2.0",
41
+ "homepage": "https://github.com/madeinplutofabio/pic-standard/tree/main/integrations/openclaw",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/madeinplutofabio/pic-standard.git",
45
+ "directory": "integrations/openclaw"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/madeinplutofabio/pic-standard/issues"
49
+ },
50
+ "peerDependencies": {
51
+ "openclaw": ">=2026.2.1"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.19.32",
55
+ "typescript": "^5.9.3",
56
+ "vitest": "^1.6.1"
57
+ }
58
+ }