pi-opa-net 0.3.0 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.2] - 2026-07-23
9
+
10
+ ### Fixed
11
+
12
+ - CRITICAL: compound commands (e.g. `export FOO=bar; git stash pop`) now have EACH segment evaluated against the OPA policy. Previously the parser only saw the first command (`export`), which is always allowed, so dangerous commands after `;` were silently evaluated. This was the root cause of pi-opa-net appearing to never block commands in live pi sessions (pi-bash-guard prepends env exports to every command).
13
+
14
+ ## [0.3.1] - 2026-07-23
15
+
16
+ ### Fixed
17
+
18
+ - CRITICAL: declared `pi.extensions` in package.json so pi-coding-agent's extension loader can discover the `tool_call` hook. Without this, the entire safety guard was a silent no-op (extension loaded but hook never registered).
19
+
8
20
  ## [0.2.0] - 2026-07-20
9
21
 
10
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opa-net",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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,6 +45,9 @@
45
45
  "unlock-keys"
46
46
  ],
47
47
  "pi": {
48
+ "extensions": [
49
+ "./src/pi/index.ts"
50
+ ],
48
51
  "skills": [
49
52
  "./skills"
50
53
  ]
package/src/cli/run.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { resolve } from 'node:path';
2
- import { configFromEnv } from '../config/Config.ts';
3
- import { OpaCliEngine, probeOpaVersion } from '../engine/index.ts';
2
+ import { type EngineConfig, configFromEnv } from '../config/Config.ts';
3
+ import {
4
+ type DecisionEngine,
5
+ type EngineDecision,
6
+ OpaCliEngine,
7
+ probeOpaVersion,
8
+ } from '../engine/index.ts';
4
9
  import { DecisionBuilder, type DecisionOutput } from '../output/DecisionBuilder.ts';
5
10
  import { OutputFormatter, validateDecision } from '../output/OutputFormatter.ts';
11
+ import type { CommandParser, ParsedCommand } from '../parser/index.ts';
6
12
  import { CommandParserCoordinator } from '../parser/index.ts';
7
13
  import { RULES, RuleRegistry } from '../rules/index.ts';
8
14
  import { SaltResolver } from '../unlock/SaltResolver.ts';
@@ -63,11 +69,8 @@ export async function runCli(opts: CliOptions): Promise<CliResult> {
63
69
  const hasKeys = unlockKeys.length > 0;
64
70
 
65
71
  const parser = new CommandParserCoordinator();
66
- const parsed = parser.parse(raw);
67
-
68
72
  const opaVersion = await probeOpaVersion(config.opaBinary ?? 'opa');
69
73
  const engine = new OpaCliEngine(config, opaVersion);
70
- const engineDecision = await engine.evaluate(parsed);
71
74
 
72
75
  const builder = new DecisionBuilder({
73
76
  config,
@@ -75,6 +78,98 @@ export async function runCli(opts: CliOptions): Promise<CliResult> {
75
78
  digest: engine.rulebookDigest(),
76
79
  });
77
80
 
81
+ // Compound commands (joined by ';'): split and evaluate EACH segment.
82
+ // If ANY segment is denied, the whole command is denied. This catches
83
+ // env-prefixed commands like `export FOO=bar; git stash pop` where
84
+ // pi-bash-guard prepends env exports to every bash invocation.
85
+ const output = await evaluatePossiblyCompound(raw, {
86
+ parser,
87
+ engine,
88
+ config,
89
+ builder,
90
+ unlockKeys,
91
+ hasKeys,
92
+ });
93
+
94
+ // Hard internal gate: the record MUST validate against the schema before emit.
95
+ validateDecision(output);
96
+
97
+ const formatter = new OutputFormatter();
98
+ const { stdout, exitCode } = formatter.format(output, opts.mode);
99
+ return { stdout, exitCode };
100
+ }
101
+
102
+ /**
103
+ * Evaluate a possibly-compound raw command string. If it contains ';',
104
+ * evaluate each segment and return deny if ANY segment is denied.
105
+ * Single commands (no ';') take the existing fast path unchanged.
106
+ */
107
+ async function evaluatePossiblyCompound(
108
+ raw: string,
109
+ deps: {
110
+ parser: CommandParser;
111
+ engine: DecisionEngine;
112
+ config: EngineConfig;
113
+ builder: DecisionBuilder;
114
+ unlockKeys: readonly string[];
115
+ hasKeys: boolean;
116
+ },
117
+ ): Promise<DecisionOutput> {
118
+ const { parser, engine, config, builder, unlockKeys, hasKeys } = deps;
119
+
120
+ // Split on ';' but only treat as compound if more than one non-empty segment.
121
+ const segments = raw
122
+ .split(';')
123
+ .map((s) => s.trim())
124
+ .filter((s) => s.length > 0);
125
+
126
+ if (segments.length <= 1) {
127
+ // Single command path — unchanged behavior.
128
+ const parsed = parser.parse(raw);
129
+ const engineDecision = await engine.evaluate(parsed);
130
+ return buildDecision(parsed, engineDecision, { config, builder, unlockKeys, hasKeys });
131
+ }
132
+
133
+ // Compound path: evaluate each segment, deny wins.
134
+ let denyOutput: DecisionOutput | undefined;
135
+ for (const segment of segments) {
136
+ const parsed = parser.parse(segment);
137
+ const engineDecision = await engine.evaluate(parsed);
138
+ const output = buildDecision(parsed, engineDecision, { config, builder, unlockKeys, hasKeys });
139
+ if (output.decision === 'deny' && output.action === 'block') {
140
+ denyOutput = output;
141
+ break; // first deny wins
142
+ }
143
+ }
144
+
145
+ if (denyOutput) {
146
+ return denyOutput;
147
+ }
148
+
149
+ // All segments allowed — return an allow decision based on the first segment.
150
+ const firstParsed = parser.parse(segments[0] ?? '');
151
+ const firstEngineDecision = await engine.evaluate(firstParsed);
152
+ return buildDecision(firstParsed, firstEngineDecision, {
153
+ config,
154
+ builder,
155
+ unlockKeys,
156
+ hasKeys,
157
+ });
158
+ }
159
+
160
+ /** Build a DecisionOutput from a parsed command + engine decision, applying unlock keys. */
161
+ function buildDecision(
162
+ parsed: ParsedCommand,
163
+ engineDecision: EngineDecision,
164
+ deps: {
165
+ config: EngineConfig;
166
+ builder: DecisionBuilder;
167
+ unlockKeys: readonly string[];
168
+ hasKeys: boolean;
169
+ },
170
+ ): DecisionOutput {
171
+ const { config, builder, unlockKeys, hasKeys } = deps;
172
+
78
173
  let output: DecisionOutput;
79
174
 
80
175
  if (engineDecision.source === 'fail-open' && hasKeys) {
@@ -111,12 +206,7 @@ export async function runCli(opts: CliOptions): Promise<CliResult> {
111
206
  }
112
207
  }
113
208
 
114
- // Hard internal gate: the record MUST validate against the schema before emit.
115
- validateDecision(output);
116
-
117
- const formatter = new OutputFormatter();
118
- const { stdout, exitCode } = formatter.format(output, opts.mode);
119
- return { stdout, exitCode };
209
+ return output;
120
210
  }
121
211
 
122
212
  function resolveRaw(opts: CliOptions): string {
@@ -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}/.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, '.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('[opa-net] audit write failed, continuing without audit');
78
+ }
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,46 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { registerPreToolCallHook } from './tool-call.ts';
4
+
5
+ type HermesPluginCtx = {
6
+ register_hook: (hook: string, callback: (kwargs: Record<string, unknown>) => unknown) => void;
7
+ };
8
+
9
+ /**
10
+ * Auto-discover the rule/policy config for the Hermes agent.
11
+ *
12
+ * Priority:
13
+ * 1. HERMES_OPA_NET_HOME env alias → PIOPANET_HOME (always honored if set)
14
+ * 2. HERMES_HOME env → check for <HERMES_HOME>/opa-net/rules/ existence
15
+ * → set PIOPANET_HOME only if candidate exists (never bogus)
16
+ *
17
+ * Mirrors src/pi/index.ts autoDiscoverOpaNetHome but adapted for Hermes.
18
+ */
19
+ function autoDiscoverOpaNetHome(): void {
20
+ // Honor explicit alias first (no existence check — user explicitly set it).
21
+ const alias = process.env.HERMES_OPA_NET_HOME;
22
+ if (alias) {
23
+ process.env.PIOPANET_HOME = alias;
24
+ return;
25
+ }
26
+
27
+ if (process.env.PIOPANET_HOME) return;
28
+
29
+ // Auto-discover from HERMES_HOME.
30
+ const hermesHome = process.env.HERMES_HOME;
31
+ if (!hermesHome) return;
32
+
33
+ const candidate = join(hermesHome, 'opa-net');
34
+ if (existsSync(join(candidate, 'rules'))) {
35
+ process.env.PIOPANET_HOME = candidate;
36
+ }
37
+ }
38
+
39
+ export default function hermesOpaNetExtension(ctx: HermesPluginCtx): void {
40
+ autoDiscoverOpaNetHome();
41
+ registerPreToolCallHook(ctx);
42
+ }
43
+
44
+ // Re-export for direct unit-test access.
45
+ export { handleHermesToolCall, registerPreToolCallHook } from './tool-call.ts';
46
+ export type { HermesToolCallContext, HermesToolCallResult } from './tool-call.ts';
@@ -0,0 +1,222 @@
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 HermesApi = {
16
+ register_hook: (
17
+ hook: 'pre_tool_call',
18
+ handler: (kwargs: Record<string, unknown>) => Promise<HermesToolCallResult>,
19
+ ) => void;
20
+ };
21
+
22
+ export type EvalOpts = { cwd?: string; env?: NodeJS.ProcessEnv };
23
+
24
+ export type HermesToolCallContext = {
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
+ /**
41
+ * Hermes-canonical block directive. Hermes plugins.py @2101-2175 expects
42
+ * `{action: 'block', message: string}` to veto a tool call.
43
+ */
44
+ export type HermesToolCallResult = { action: 'block'; message: string } | undefined;
45
+
46
+ type HermesToolCallEvent = {
47
+ tool_name?: string;
48
+ tool_call_id?: string;
49
+ args?: Record<string, unknown>;
50
+ };
51
+
52
+ type HermesShellToolAdapter = {
53
+ commandField: string;
54
+ cwdField?: string;
55
+ };
56
+
57
+ /**
58
+ * Hermes shell tool adapters. Hermes uses 'terminal' as the canonical shell
59
+ * tool name, but also accepts 'bash' for backward compat. The command lives
60
+ * in args.command, cwd in args.cwd (or args.working_directory for Grok compat).
61
+ */
62
+ const HERMES_SHELL_TOOL_ADAPTERS: Partial<Record<string, HermesShellToolAdapter>> = {
63
+ terminal: { commandField: 'command', cwdField: 'cwd' },
64
+ bash: { commandField: 'command', cwdField: 'cwd' },
65
+ shell: { commandField: 'command', cwdField: 'working_directory' },
66
+ };
67
+
68
+ type HermesShellToolCall = { command: string; cwd: string } | { malformed: true };
69
+
70
+ /**
71
+ * Register the pre_tool_call hook on a Hermes plugin ctx.
72
+ *
73
+ * Hermes's dispatcher invokes the callback with a SINGLE kwargs object
74
+ * ({tool_name, args, tool_call_id, session_id, ...}) — NOT (event, ctx).
75
+ * This wrapper bridges that to handleHermesToolCall(event, ctx) by building
76
+ * the context from process state (cwd, HERMES_SESSION_FILE env).
77
+ */
78
+ export function registerPreToolCallHook(api: HermesApi): void {
79
+ const wrapped = (kwargs: Record<string, unknown>): Promise<HermesToolCallResult> => {
80
+ const ctx: HermesToolCallContext = {
81
+ cwd: process.cwd(),
82
+ sessionManager: {
83
+ getSessionFile: () => process.env.HERMES_SESSION_FILE,
84
+ },
85
+ };
86
+ return handleHermesToolCall(kwargs, ctx);
87
+ };
88
+ api.register_hook('pre_tool_call', wrapped);
89
+ }
90
+
91
+ function isStrictMode(): boolean {
92
+ return process.env.PIOPANET_STRICT === '1';
93
+ }
94
+
95
+ /**
96
+ * Default subprocess eval — spawns `bin/pi-opa-net.js eval "<cmd>" --json`
97
+ * and parses stdout as DecisionOutput. This is the production bridge:
98
+ * without this, the handler has no way to evaluate commands.
99
+ *
100
+ * Mirrors pi-safety-net's `?? analyzeCommand` default.
101
+ */
102
+ async function defaultEvalCommand(command: string, opts?: EvalOpts): Promise<DecisionOutput> {
103
+ const binPath = resolve(fileURLToPath(import.meta.url), '../../../bin/pi-opa-net.js');
104
+ const cwd = opts?.cwd ?? process.cwd();
105
+
106
+ return new Promise((accept, reject) => {
107
+ const child = spawn('bun', [binPath, 'eval', command, '--json'], {
108
+ cwd,
109
+ env: opts?.env ?? process.env,
110
+ });
111
+ let stdout = '';
112
+ let stderr = '';
113
+ child.stdout.on('data', (d: Buffer) => {
114
+ stdout += d.toString();
115
+ });
116
+ child.stderr.on('data', (d: Buffer) => {
117
+ stderr += d.toString();
118
+ });
119
+ child.on('error', reject);
120
+ child.on('close', (code: number | null) => {
121
+ if (code !== 0 && code !== 2) {
122
+ // Exit code 0 = allow, 2 = deny. Anything else = subprocess error.
123
+ reject(new Error(`pi-opa-net eval exited ${code}: ${stderr}`));
124
+ return;
125
+ }
126
+ try {
127
+ const decision = JSON.parse(stdout.trim()) as DecisionOutput;
128
+ accept(decision);
129
+ } catch (err) {
130
+ reject(
131
+ new Error(`pi-opa-net eval non-JSON stdout: ${stdout.slice(0, 200)}\nstderr: ${stderr}`),
132
+ );
133
+ }
134
+ });
135
+ });
136
+ }
137
+
138
+ /** @internal — exported for test coverage */
139
+ export async function handleHermesToolCall(
140
+ event: Record<string, unknown>,
141
+ ctx: HermesToolCallContext,
142
+ ): Promise<HermesToolCallResult> {
143
+ const shellToolCall = getHermesShellToolCall(event, ctx);
144
+ if (!shellToolCall) return undefined;
145
+
146
+ if ('malformed' in shellToolCall) {
147
+ return blockHermesToolCall(REASON_MALFORMED_SHELL_INPUT);
148
+ }
149
+
150
+ const { command, cwd } = shellToolCall;
151
+
152
+ // Use injected eval OR default subprocess eval.
153
+ const evalCommand = ctx.opaNetEvalCommand ?? defaultEvalCommand;
154
+
155
+ let decision: DecisionOutput;
156
+ try {
157
+ decision = await evalCommand(command, { cwd });
158
+ } catch {
159
+ // Fork fail-open default: do not brick the agent on eval errors.
160
+ if (isStrictMode()) {
161
+ return blockHermesToolCall(REASON_OPA_NET_FAILED_CLOSED, command);
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ // Allow + log_only: proceed (with audit).
167
+ // opa-unlocked: engine already filtered — plugin trusts it.
168
+ // fail-open / cached: degraded paths — proceed silently.
169
+ if (
170
+ decision.decision === 'allow' ||
171
+ decision.action === 'allow' ||
172
+ decision.action === 'log_only'
173
+ ) {
174
+ return undefined;
175
+ }
176
+
177
+ // Deny + block: translate + audit + block.
178
+ if (decision.decision === 'deny' && decision.action === 'block') {
179
+ const sessionId = ctx.sessionManager.getSessionFile();
180
+ if (sessionId) {
181
+ const auditSink = ctx.auditSink ?? createFilesystemAuditSink(cwd);
182
+ await writeAuditEntry({
183
+ sessionId,
184
+ decision,
185
+ auditSink,
186
+ });
187
+ }
188
+ return blockHermesToolCall(formatBlockMessage(decision), command);
189
+ }
190
+
191
+ // prompt_user (reserved v2) — treat as allow for now.
192
+ return undefined;
193
+ }
194
+
195
+ function getHermesShellToolCall(
196
+ event: Record<string, unknown>,
197
+ ctx: HermesToolCallContext,
198
+ ): HermesShellToolCall | undefined {
199
+ const toolCall = event as HermesToolCallEvent;
200
+ if (typeof toolCall.tool_name !== 'string') return undefined;
201
+
202
+ const adapter = HERMES_SHELL_TOOL_ADAPTERS[toolCall.tool_name];
203
+ if (!adapter) return undefined;
204
+ if (!toolCall.args || typeof toolCall.args !== 'object') return { malformed: true };
205
+
206
+ const command = toolCall.args[adapter.commandField];
207
+ if (typeof command !== 'string') return { malformed: true };
208
+
209
+ const cwdInput = adapter.cwdField ? toolCall.args[adapter.cwdField] : undefined;
210
+ const cwd = typeof cwdInput === 'string' ? resolve(ctx.cwd, cwdInput) : ctx.cwd;
211
+ return { command, cwd };
212
+ }
213
+
214
+ function formatBlockMessage(decision: DecisionOutput): string {
215
+ const reasons = decision.reasons.map((r) => `${r.rule_id}: ${r.message}`).join('; ');
216
+ const header = decision.summary || 'BLOCKED by OPA-Net';
217
+ return `${header}\nReason: ${reasons}\nRule family: ${decision.reasons[0]?.family ?? 'unknown'}`;
218
+ }
219
+
220
+ function blockHermesToolCall(message: string, _command?: string): HermesToolCallResult {
221
+ return { action: 'block', message };
222
+ }
@@ -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}/.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, '.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('[opa-net] audit write failed, continuing without audit');
78
+ }
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { type ZcodeToolCallContext, handleZcodeToolCall } from './tool-call.ts';
4
+
5
+ /**
6
+ * Auto-discover the rule/policy config for the ZCode agent.
7
+ *
8
+ * Priority:
9
+ * 1. ZCODE_OPA_NET_HOME env alias → PIOPANET_HOME (always honored if set)
10
+ * 2. ZCODE_HOME env → check for <ZCODE_HOME>/opa-net/rules/ existence
11
+ * → set PIOPANET_HOME only if candidate exists (never bogus)
12
+ *
13
+ * Mirrors src/hermes/index.ts autoDiscoverOpaNetHome but adapted for ZCode.
14
+ */
15
+ export function autoDiscoverOpaNetHome(): void {
16
+ // Honor explicit alias first (no existence check — user explicitly set it).
17
+ const alias = process.env.ZCODE_OPA_NET_HOME;
18
+ if (alias) {
19
+ process.env.PIOPANET_HOME = alias;
20
+ return;
21
+ }
22
+
23
+ if (process.env.PIOPANET_HOME) return;
24
+
25
+ // Auto-discover from ZCODE_HOME.
26
+ const zcodeHome = process.env.ZCODE_HOME;
27
+ if (!zcodeHome) return;
28
+
29
+ const candidate = join(zcodeHome, 'opa-net');
30
+ if (existsSync(join(candidate, 'rules'))) {
31
+ process.env.PIOPANET_HOME = candidate;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Build the ZCode tool-call context from process state.
37
+ * cwd from process.cwd(); sessionManager reads ZCODE_SESSION_FILE env.
38
+ */
39
+ function buildContext(): ZcodeToolCallContext {
40
+ return {
41
+ cwd: process.cwd(),
42
+ sessionManager: {
43
+ getSessionFile: () => process.env.ZCODE_SESSION_FILE,
44
+ },
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Run the ZCode PreToolUse hook script.
50
+ *
51
+ * ZCode dispatches command-type hooks by spawning the hook command and piping
52
+ * a JSON payload on stdin. This function:
53
+ * 1. Reads stdin (the PreToolUse payload)
54
+ * 2. Calls handleZcodeToolCall(payload, ctx)
55
+ * 3. Writes the result JSON to stdout (or {} on allow / error)
56
+ *
57
+ * Failure isolation: on ANY error, emit {} (never block the turn, never emit
58
+ * invalid JSON). Mirrors ZCode's own hook conventions.
59
+ */
60
+ export async function runHookScript(): Promise<void> {
61
+ autoDiscoverOpaNetHome();
62
+
63
+ let payload: Record<string, unknown> = {};
64
+ try {
65
+ const chunks: Buffer[] = [];
66
+ for await (const chunk of process.stdin) {
67
+ chunks.push(chunk as Buffer);
68
+ }
69
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
70
+ if (raw) {
71
+ payload = JSON.parse(raw);
72
+ }
73
+ } catch {
74
+ // Malformed stdin — emit {} and let the tool call proceed.
75
+ process.stdout.write('{}');
76
+ return;
77
+ }
78
+
79
+ try {
80
+ const result = await handleZcodeToolCall(payload, buildContext());
81
+ process.stdout.write(result ? JSON.stringify(result) : '{}');
82
+ } catch {
83
+ // Failure isolation — never break the agent turn.
84
+ process.stdout.write('{}');
85
+ }
86
+ }
87
+
88
+ // Default export: object (ZCode uses command hooks, not callback registration).
89
+ export default {
90
+ runHookScript,
91
+ autoDiscoverOpaNetHome,
92
+ };
93
+
94
+ // Re-export for direct unit-test access.
95
+ export { handleZcodeToolCall } from './tool-call.ts';
96
+ export type { ZcodeToolCallContext, ZcodeToolCallResult } from './tool-call.ts';
@@ -0,0 +1,195 @@
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
+ export type EvalOpts = { cwd?: string; env?: NodeJS.ProcessEnv };
16
+
17
+ export type ZcodeToolCallContext = {
18
+ cwd: string;
19
+ sessionManager: {
20
+ getSessionFile: () => string | undefined;
21
+ };
22
+ /**
23
+ * Inject-able eval function. Unit tests pass a stub. Production wires this
24
+ * to the real `bin/pi-opa-net.js eval` subprocess (shared engine CLI).
25
+ */
26
+ opaNetEvalCommand?: (command: string, opts?: EvalOpts) => Promise<DecisionOutput>;
27
+ /** Optional audit sink for test capture. Production wires filesystem sink. */
28
+ auditSink?: AuditSink;
29
+ /** Reserved for future config passthrough. */
30
+ opaNetConfigOptions?: Record<string, unknown>;
31
+ };
32
+
33
+ /**
34
+ * ZCode-canonical block directive (verified against zcode.cjs bundle).
35
+ * permissionDecision enum: "allow" | "ask" | "deny".
36
+ */
37
+ export type ZcodeToolCallResult =
38
+ | {
39
+ hookSpecificOutput: {
40
+ hookEventName: 'PreToolUse';
41
+ permissionDecision: 'deny';
42
+ permissionDecisionReason: string;
43
+ };
44
+ }
45
+ | undefined;
46
+
47
+ type ZcodePreToolUsePayload = {
48
+ hookEventName?: string;
49
+ toolName?: string;
50
+ tool_name?: string;
51
+ toolInput?: Record<string, unknown>;
52
+ tool_input?: Record<string, unknown>;
53
+ };
54
+
55
+ /**
56
+ * ZCode shell tool names. ZCode is Claude-Code-compatible, so the canonical
57
+ * shell tool is "Bash" (capitalized). "terminal" is an alias.
58
+ */
59
+ const ZCODE_SHELL_TOOL_NAMES = new Set(['Bash', 'bash', 'terminal', 'shell', 'Shell']);
60
+
61
+ type ZcodeShellToolCall = { command: string } | { malformed: true };
62
+
63
+ function isStrictMode(): boolean {
64
+ return process.env.PIOPANET_STRICT === '1';
65
+ }
66
+
67
+ /**
68
+ * Default subprocess eval — spawns `bin/pi-opa-net.js eval "<cmd>" --json`
69
+ * (shared engine CLI) and parses stdout as DecisionOutput. This is the
70
+ * production bridge.
71
+ */
72
+ async function defaultEvalCommand(command: string, opts?: EvalOpts): Promise<DecisionOutput> {
73
+ const binPath = resolve(fileURLToPath(import.meta.url), '../../../bin/pi-opa-net.js');
74
+ const cwd = opts?.cwd ?? process.cwd();
75
+
76
+ return new Promise((accept, reject) => {
77
+ const child = spawn('bun', [binPath, 'eval', command, '--json'], {
78
+ cwd,
79
+ env: opts?.env ?? process.env,
80
+ });
81
+ let stdout = '';
82
+ let stderr = '';
83
+ child.stdout.on('data', (d: Buffer) => {
84
+ stdout += d.toString();
85
+ });
86
+ child.stderr.on('data', (d: Buffer) => {
87
+ stderr += d.toString();
88
+ });
89
+ child.on('error', reject);
90
+ child.on('close', (code: number | null) => {
91
+ if (code !== 0 && code !== 2) {
92
+ // Exit code 0 = allow, 2 = deny. Anything else = subprocess error.
93
+ reject(new Error(`pi-opa-net eval exited ${code}: ${stderr}`));
94
+ return;
95
+ }
96
+ try {
97
+ const decision = JSON.parse(stdout.trim()) as DecisionOutput;
98
+ accept(decision);
99
+ } catch (err) {
100
+ reject(
101
+ new Error(`pi-opa-net eval non-JSON stdout: ${stdout.slice(0, 200)}\nstderr: ${stderr}`),
102
+ );
103
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ /** @internal — exported for test coverage */
109
+ export async function handleZcodeToolCall(
110
+ payload: Record<string, unknown>,
111
+ ctx: ZcodeToolCallContext,
112
+ ): Promise<ZcodeToolCallResult> {
113
+ const shellToolCall = getZcodeShellToolCall(payload);
114
+ if (!shellToolCall) return undefined;
115
+
116
+ if ('malformed' in shellToolCall) {
117
+ return blockZcodeToolCall(REASON_MALFORMED_SHELL_INPUT);
118
+ }
119
+
120
+ const { command } = shellToolCall;
121
+
122
+ // Use injected eval OR default subprocess eval.
123
+ const evalCommand = ctx.opaNetEvalCommand ?? defaultEvalCommand;
124
+
125
+ let decision: DecisionOutput;
126
+ try {
127
+ decision = await evalCommand(command, { cwd: ctx.cwd });
128
+ } catch {
129
+ // Fail-open default: do not brick the agent on eval errors.
130
+ if (isStrictMode()) {
131
+ return blockZcodeToolCall(REASON_OPA_NET_FAILED_CLOSED);
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ // Allow + log_only: proceed (with audit).
137
+ // opa-unlocked: engine already filtered — plugin trusts it.
138
+ // fail-open / cached: degraded paths — proceed silently.
139
+ if (
140
+ decision.decision === 'allow' ||
141
+ decision.action === 'allow' ||
142
+ decision.action === 'log_only'
143
+ ) {
144
+ return undefined;
145
+ }
146
+
147
+ // Deny + block: translate + audit + block.
148
+ if (decision.decision === 'deny' && decision.action === 'block') {
149
+ const sessionId = ctx.sessionManager.getSessionFile();
150
+ if (sessionId) {
151
+ const auditSink = ctx.auditSink ?? createFilesystemAuditSink(ctx.cwd);
152
+ await writeAuditEntry({
153
+ sessionId,
154
+ decision,
155
+ auditSink,
156
+ });
157
+ }
158
+ return blockZcodeToolCall(formatBlockMessage(decision));
159
+ }
160
+
161
+ // prompt_user (reserved v2) — treat as allow for now.
162
+ return undefined;
163
+ }
164
+
165
+ function getZcodeShellToolCall(payload: Record<string, unknown>): ZcodeShellToolCall | undefined {
166
+ const event = payload as ZcodePreToolUsePayload;
167
+ const toolName = event.toolName ?? event.tool_name;
168
+ if (typeof toolName !== 'string') return undefined;
169
+
170
+ if (!ZCODE_SHELL_TOOL_NAMES.has(toolName)) return undefined;
171
+
172
+ const toolInput = event.toolInput ?? event.tool_input;
173
+ if (!toolInput || typeof toolInput !== 'object') return { malformed: true };
174
+
175
+ const command = toolInput.command;
176
+ if (typeof command !== 'string') return { malformed: true };
177
+
178
+ return { command };
179
+ }
180
+
181
+ function formatBlockMessage(decision: DecisionOutput): string {
182
+ const reasons = decision.reasons.map((r) => `${r.rule_id}: ${r.message}`).join('; ');
183
+ const header = decision.summary || 'BLOCKED by OPA-Net';
184
+ return `${header}\nReason: ${reasons}\nRule family: ${decision.reasons[0]?.family ?? 'unknown'}`;
185
+ }
186
+
187
+ function blockZcodeToolCall(reason: string): ZcodeToolCallResult {
188
+ return {
189
+ hookSpecificOutput: {
190
+ hookEventName: 'PreToolUse',
191
+ permissionDecision: 'deny',
192
+ permissionDecisionReason: reason,
193
+ },
194
+ };
195
+ }