pi-opa-net 0.2.0 → 0.3.0
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/package.json +4 -2
- package/src/pi/audit.ts +81 -0
- package/src/pi/index.ts +33 -0
- package/src/pi/tool-call.ts +193 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-opa-net",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "OPA-backed bash command guard for the pi ecosystem — structured decision-output.v1 JSON, fail-open default, Claude Code hook protocol compatible. Agent-agnostic engine + CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
"unlock-keys"
|
|
46
46
|
],
|
|
47
47
|
"pi": {
|
|
48
|
-
"skills": [
|
|
48
|
+
"skills": [
|
|
49
|
+
"./skills"
|
|
50
|
+
]
|
|
49
51
|
},
|
|
50
52
|
"dependencies": {
|
|
51
53
|
"ajv": "^8.17.1",
|
package/src/pi/audit.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { DecisionOutput } from '../output/DecisionBuilder.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Redact common secret patterns from a command string before audit.
|
|
7
|
+
* - Bearer tokens / Authorization headers
|
|
8
|
+
* - API keys (sk-... long hex/alnum)
|
|
9
|
+
*/
|
|
10
|
+
export function redactSecrets(text: string): string {
|
|
11
|
+
let out = text;
|
|
12
|
+
// Authorization: Bearer <token>
|
|
13
|
+
out = out.replace(/(Bearer\s+)([A-Za-z0-9._\-]+)/gi, '$1[REDACTED]');
|
|
14
|
+
// Generic sk-<token> (Stripe/Anthropic/etc API keys)
|
|
15
|
+
out = out.replace(/\bsk-[A-Za-z0-9_\-]{6,}/g, 'sk-[REDACTED]');
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Audit sink bridge — writes one JSONL line per decision.
|
|
21
|
+
*
|
|
22
|
+
* Test/unit injectable: tests pass an `auditSink` with a captured `write`.
|
|
23
|
+
* Production wires this to a real filesystem sink (JSONL append).
|
|
24
|
+
*/
|
|
25
|
+
export interface AuditSink {
|
|
26
|
+
write: (entry: unknown) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WriteAuditEntryInput {
|
|
30
|
+
sessionId: string | undefined;
|
|
31
|
+
decision: Pick<
|
|
32
|
+
DecisionOutput,
|
|
33
|
+
'decision' | 'source' | 'reasons' | 'input' | 'evaluated_at' | 'decision_id'
|
|
34
|
+
>;
|
|
35
|
+
auditSink: AuditSink;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AuditEntry {
|
|
39
|
+
decision_id: string;
|
|
40
|
+
decision: string;
|
|
41
|
+
source: string;
|
|
42
|
+
command: string;
|
|
43
|
+
rule_ids: string[];
|
|
44
|
+
evaluated_at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeAuditEntry(input: WriteAuditEntryInput): Promise<void> {
|
|
48
|
+
if (!input.sessionId) return;
|
|
49
|
+
|
|
50
|
+
const entry: AuditEntry = {
|
|
51
|
+
decision_id: input.decision.decision_id,
|
|
52
|
+
decision: input.decision.decision,
|
|
53
|
+
source: input.decision.source,
|
|
54
|
+
command: redactSecrets(input.decision.input.raw),
|
|
55
|
+
rule_ids: input.decision.reasons.map((r) => r.rule_id),
|
|
56
|
+
evaluated_at: input.decision.evaluated_at,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
await input.auditSink.write(entry);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Default filesystem audit sink — appends JSONL to `${cwd}/.pi-opa-net/audit/${decision_id}.jsonl`.
|
|
64
|
+
* Production wires this when ctx.auditSink is not injected.
|
|
65
|
+
*/
|
|
66
|
+
export function createFilesystemAuditSink(cwd: string): AuditSink {
|
|
67
|
+
const auditDir = resolve(cwd, '.pi-opa-net', 'audit');
|
|
68
|
+
return {
|
|
69
|
+
write: async (entry: unknown) => {
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(auditDir, { recursive: true });
|
|
72
|
+
const decisionId = (entry as { decision_id?: string }).decision_id ?? 'unknown';
|
|
73
|
+
const logPath = resolve(auditDir, `${decisionId}.jsonl`);
|
|
74
|
+
appendFileSync(logPath, `${JSON.stringify(entry)}\n`);
|
|
75
|
+
} catch {
|
|
76
|
+
// Audit write failure is non-fatal — log to stderr and continue.
|
|
77
|
+
console.error('[pi-opa-net] audit write failed, continuing without audit');
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/pi/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { registerToolCallEvent } from './tool-call.ts';
|
|
4
|
+
|
|
5
|
+
type PiExtensionApi = Parameters<typeof registerToolCallEvent>[0];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-discover the rule/policy config shipped alongside the pi agent.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors pi-safety-net/src/pi/index.ts: derives agent dir from PI_CODING_AGENT_DIR
|
|
11
|
+
* or PI_SESSION_FILE (sessions/ parent), checks for a `<agentDir>/pi-opa-net/rules/`
|
|
12
|
+
* candidate, and sets PIOPANET_HOME so the engine picks it up zero-config.
|
|
13
|
+
*/
|
|
14
|
+
function autoDiscoverOpaNetHome(): void {
|
|
15
|
+
if (process.env.PIOPANET_HOME) return;
|
|
16
|
+
const agentDir =
|
|
17
|
+
process.env.PI_CODING_AGENT_DIR ||
|
|
18
|
+
(process.env.PI_SESSION_FILE && dirname(dirname(process.env.PI_SESSION_FILE)));
|
|
19
|
+
if (!agentDir) return;
|
|
20
|
+
const candidate = join(agentDir, 'pi-opa-net');
|
|
21
|
+
if (existsSync(join(candidate, 'rules'))) {
|
|
22
|
+
process.env.PIOPANET_HOME = candidate;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function piOpaNetExtension(pi: PiExtensionApi): void {
|
|
27
|
+
autoDiscoverOpaNetHome();
|
|
28
|
+
registerToolCallEvent(pi);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Re-export for direct unit-test access.
|
|
32
|
+
export { handlePiToolCall, registerToolCallEvent } from './tool-call.ts';
|
|
33
|
+
export type { PiToolCallContext, PiToolCallResult } from './tool-call.ts';
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { DecisionOutput } from '../output/DecisionBuilder.ts';
|
|
5
|
+
import { type AuditSink, createFilesystemAuditSink, writeAuditEntry } from './audit.ts';
|
|
6
|
+
|
|
7
|
+
/** Fail-closed block reason — mirrors pi-safety-net's REASON_SAFETY_NET_FAILED_CLOSED. */
|
|
8
|
+
export const REASON_OPA_NET_FAILED_CLOSED =
|
|
9
|
+
'OPA-Net fail-closed: command evaluation failed unexpectedly.';
|
|
10
|
+
|
|
11
|
+
/** Reason prefix for malformed shell tool input. */
|
|
12
|
+
const REASON_MALFORMED_SHELL_INPUT =
|
|
13
|
+
'OPA-Net fail-closed: shell tool call input missing required command field.';
|
|
14
|
+
|
|
15
|
+
type PiApi = {
|
|
16
|
+
on: (
|
|
17
|
+
event: 'tool_call',
|
|
18
|
+
handler: (event: unknown, ctx: PiToolCallContext) => Promise<PiToolCallResult>,
|
|
19
|
+
) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type EvalOpts = { cwd?: string; env?: NodeJS.ProcessEnv };
|
|
23
|
+
|
|
24
|
+
export type PiToolCallContext = {
|
|
25
|
+
cwd: string;
|
|
26
|
+
sessionManager: {
|
|
27
|
+
getSessionFile: () => string | undefined;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Inject-able eval function. Unit tests pass a stub. Production wires this
|
|
31
|
+
* to the real `bin/pi-opa-net.js eval` subprocess.
|
|
32
|
+
*/
|
|
33
|
+
opaNetEvalCommand?: (command: string, opts?: EvalOpts) => Promise<DecisionOutput>;
|
|
34
|
+
/** Optional audit sink for test capture. Production wires filesystem sink. */
|
|
35
|
+
auditSink?: AuditSink;
|
|
36
|
+
/** Reserved for future config passthrough. */
|
|
37
|
+
opaNetConfigOptions?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PiToolCallResult = { block: true; reason: string } | undefined;
|
|
41
|
+
|
|
42
|
+
type PiToolCallEvent = {
|
|
43
|
+
type?: string;
|
|
44
|
+
toolName?: string;
|
|
45
|
+
input?: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type PiShellToolAdapter = {
|
|
49
|
+
commandField: string;
|
|
50
|
+
cwdField?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PI_SHELL_TOOL_ADAPTERS: Partial<Record<string, PiShellToolAdapter>> = {
|
|
54
|
+
bash: { commandField: 'command' },
|
|
55
|
+
Shell: { commandField: 'command', cwdField: 'working_directory' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type PiShellToolCall = { command: string; cwd: string } | { malformed: true };
|
|
59
|
+
|
|
60
|
+
export function registerToolCallEvent(pi: PiApi): void {
|
|
61
|
+
pi.on('tool_call', handlePiToolCall);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isStrictMode(): boolean {
|
|
65
|
+
return process.env.PIOPANET_STRICT === '1';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default subprocess eval — spawns `bin/pi-opa-net.js eval "<cmd>" --json`
|
|
70
|
+
* and parses stdout as DecisionOutput. This is the production bridge:
|
|
71
|
+
* without this, the handler has no way to evaluate commands.
|
|
72
|
+
*
|
|
73
|
+
* Mirrors pi-safety-net's `?? analyzeCommand` default.
|
|
74
|
+
*/
|
|
75
|
+
async function defaultEvalCommand(command: string, opts?: EvalOpts): Promise<DecisionOutput> {
|
|
76
|
+
const binPath = resolve(fileURLToPath(import.meta.url), '../../../bin/pi-opa-net.js');
|
|
77
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
78
|
+
|
|
79
|
+
return new Promise((accept, reject) => {
|
|
80
|
+
const child = spawn('bun', [binPath, 'eval', command, '--json'], {
|
|
81
|
+
cwd,
|
|
82
|
+
env: opts?.env ?? process.env,
|
|
83
|
+
});
|
|
84
|
+
let stdout = '';
|
|
85
|
+
let stderr = '';
|
|
86
|
+
child.stdout.on('data', (d: Buffer) => {
|
|
87
|
+
stdout += d.toString();
|
|
88
|
+
});
|
|
89
|
+
child.stderr.on('data', (d: Buffer) => {
|
|
90
|
+
stderr += d.toString();
|
|
91
|
+
});
|
|
92
|
+
child.on('error', reject);
|
|
93
|
+
child.on('close', (code: number | null) => {
|
|
94
|
+
if (code !== 0 && code !== 2) {
|
|
95
|
+
// Exit code 0 = allow, 2 = deny. Anything else = subprocess error.
|
|
96
|
+
reject(new Error(`pi-opa-net eval exited ${code}: ${stderr}`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const decision = JSON.parse(stdout.trim()) as DecisionOutput;
|
|
101
|
+
accept(decision);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
reject(
|
|
104
|
+
new Error(`pi-opa-net eval non-JSON stdout: ${stdout.slice(0, 200)}\nstderr: ${stderr}`),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal — exported for test coverage */
|
|
112
|
+
export async function handlePiToolCall(
|
|
113
|
+
event: unknown,
|
|
114
|
+
ctx: PiToolCallContext,
|
|
115
|
+
): Promise<PiToolCallResult> {
|
|
116
|
+
const shellToolCall = getPiShellToolCall(event, ctx);
|
|
117
|
+
if (!shellToolCall) return undefined;
|
|
118
|
+
|
|
119
|
+
if ('malformed' in shellToolCall) {
|
|
120
|
+
return blockPiToolCall(REASON_MALFORMED_SHELL_INPUT);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { command, cwd } = shellToolCall;
|
|
124
|
+
|
|
125
|
+
// Use injected eval OR default subprocess eval.
|
|
126
|
+
const evalCommand = ctx.opaNetEvalCommand ?? defaultEvalCommand;
|
|
127
|
+
|
|
128
|
+
let decision: DecisionOutput;
|
|
129
|
+
try {
|
|
130
|
+
decision = await evalCommand(command, { cwd });
|
|
131
|
+
} catch {
|
|
132
|
+
// Fork fail-open default: do not brick the agent on eval errors.
|
|
133
|
+
if (isStrictMode()) {
|
|
134
|
+
return blockPiToolCall(REASON_OPA_NET_FAILED_CLOSED, command);
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Allow + log_only: proceed (with audit).
|
|
140
|
+
// opa-unlocked: engine already filtered — plugin trusts it.
|
|
141
|
+
// fail-open / cached: degraded paths — proceed silently.
|
|
142
|
+
if (
|
|
143
|
+
decision.decision === 'allow' ||
|
|
144
|
+
decision.action === 'allow' ||
|
|
145
|
+
decision.action === 'log_only'
|
|
146
|
+
) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Deny + block: translate + audit + block.
|
|
151
|
+
if (decision.decision === 'deny' && decision.action === 'block') {
|
|
152
|
+
const sessionId = ctx.sessionManager.getSessionFile();
|
|
153
|
+
if (sessionId) {
|
|
154
|
+
const auditSink = ctx.auditSink ?? createFilesystemAuditSink(cwd);
|
|
155
|
+
await writeAuditEntry({
|
|
156
|
+
sessionId,
|
|
157
|
+
decision,
|
|
158
|
+
auditSink,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return blockPiToolCall(formatBlockReason(decision), command);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// prompt_user (reserved v2) — treat as allow for now.
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getPiShellToolCall(event: unknown, ctx: PiToolCallContext): PiShellToolCall | undefined {
|
|
169
|
+
if (!event || typeof event !== 'object') return undefined;
|
|
170
|
+
const toolCall = event as PiToolCallEvent;
|
|
171
|
+
if (typeof toolCall.toolName !== 'string') return undefined;
|
|
172
|
+
|
|
173
|
+
const adapter = PI_SHELL_TOOL_ADAPTERS[toolCall.toolName];
|
|
174
|
+
if (!adapter) return undefined;
|
|
175
|
+
if (!toolCall.input || typeof toolCall.input !== 'object') return { malformed: true };
|
|
176
|
+
|
|
177
|
+
const command = toolCall.input[adapter.commandField];
|
|
178
|
+
if (typeof command !== 'string') return { malformed: true };
|
|
179
|
+
|
|
180
|
+
const cwdInput = adapter.cwdField ? toolCall.input[adapter.cwdField] : undefined;
|
|
181
|
+
const cwd = typeof cwdInput === 'string' ? resolve(ctx.cwd, cwdInput) : ctx.cwd;
|
|
182
|
+
return { command, cwd };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatBlockReason(decision: DecisionOutput): string {
|
|
186
|
+
const reasons = decision.reasons.map((r) => `${r.rule_id}: ${r.message}`).join('; ');
|
|
187
|
+
const header = decision.summary || 'BLOCKED by OPA-Net';
|
|
188
|
+
return `${header}\nReason: ${reasons}\nRule family: ${decision.reasons[0]?.family ?? 'unknown'}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function blockPiToolCall(reason: string, _command?: string): PiToolCallResult {
|
|
192
|
+
return { block: true, reason };
|
|
193
|
+
}
|