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.
- package/config/pic-plugin.example.json +5 -0
- package/dist/hooks/pic-audit/handler.d.ts +38 -0
- package/dist/hooks/pic-audit/handler.js +60 -0
- package/dist/hooks/pic-gate/handler.d.ts +32 -0
- package/dist/hooks/pic-gate/handler.js +67 -0
- package/dist/hooks/pic-init/handler.d.ts +20 -0
- package/dist/hooks/pic-init/handler.js +86 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +21 -0
- package/dist/lib/pic-client.d.ts +17 -0
- package/dist/lib/pic-client.js +112 -0
- package/dist/lib/types.d.ts +52 -0
- package/dist/lib/types.js +15 -0
- package/hooks/pic-audit/HOOK.md +22 -0
- package/hooks/pic-audit/handler.ts +101 -0
- package/hooks/pic-gate/HOOK.md +65 -0
- package/hooks/pic-gate/handler.ts +108 -0
- package/hooks/pic-init/HOOK.md +20 -0
- package/hooks/pic-init/handler.ts +104 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +58 -0
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|