pi-opa-net 0.3.1 → 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,12 @@ 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
+
8
14
  ## [0.3.1] - 2026-07-23
9
15
 
10
16
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opa-net",
3
- "version": "0.3.1",
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",
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 {