pi-opa-net 0.1.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.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: pi-opa-net
3
+ description: OPA-backed bash command guard for the pi ecosystem. Use when you need to evaluate shell commands against a safety policy and get structured JSON output (decision-output.v1 schema), wire a bash guard into a pi extension, or understand fail-open/fail-closed behavior. NOT for the rego policy authoring itself (edit policy/safety.rego directly).
4
+ ---
5
+
6
+ # pi-opa-net — OPA-backed bash guard
7
+
8
+ An **agent-agnostic** engine + CLI that evaluates shell commands against an [OPA](https://www.openpolicyagent.org/)/Rego policy and emits a strict, auditable `decision-output.v1` record. Designed as the decision backend for pi extensions, Claude Code hooks, and scripts.
9
+
10
+ ## When to use
11
+
12
+ - Wiring a bash command guard into a pi extension (the extension shells out to this CLI and parses the JSON).
13
+ - Evaluating a command programmatically and needing rule provenance (`reasons[].rule_id`).
14
+ - Replacing asymmetric allow-silent/deny-string output with a symmetric schema.
15
+ - Detecting rulebook drift via `metadata.rulebook_digest`.
16
+
17
+ ## When NOT to use
18
+
19
+ - You need the **pi extension tool_call hook itself** — that lives in a separate `pi-opa-net-ext` repo (OT5). This package is the engine + library, not the hook.
20
+ - You want OPA policy for non-bash domains (deploy-gating, k8s, API authz) — out of scope (LD3).
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ # CLI
26
+ bunx pi-opa-net eval "git stash pop" --json # exit 2 + JSON
27
+ bunx pi-opa-net eval "git stash list" # exit 0, empty stdout
28
+
29
+ # Programmatic
30
+ import { configFromEnv, CommandParserCoordinator, OpaCliEngine, DecisionBuilder, RULES, RuleRegistry } from 'pi-opa-net';
31
+ ```
32
+
33
+ ## Output shape
34
+
35
+ Every decision (allow AND deny) emits `decision-output.v1`:
36
+
37
+ - `decision`: `allow | deny`
38
+ - `source`: `opa | fail-open | fail-closed | cached`
39
+ - `reasons[]`: `{ rule_id, message, family, severity }` — empty on allow
40
+ - `input`: `{ raw, program, subcommand, args, parse_confidence }`
41
+ - `metadata`: `{ engine, opa_version, rulebook_digest, policy_path, hostname, session_id }`
42
+ - `decision_id` (uuid), `evaluated_at` (ISO-8601), `duration_ms`
43
+
44
+ Exit codes: `0 = allow`, `2 = deny` (Claude Code hook protocol compatible).
45
+
46
+ ## Fail-mode
47
+
48
+ - `PI_OPA_FAIL_MODE=open` (default) — allow when OPA unreachable, `source: "fail-open"`.
49
+ - `PI_OPA_FAIL_MODE=closed` — deny when OPA unreachable, `source: "fail-closed"`.
50
+
51
+ The `source` field makes whichever mode fires **observable** per-decision.
52
+
53
+ ## Adding a rule
54
+
55
+ 1. Add a `deny[msg] if { ... }` block to `policy/safety.rego`.
56
+ 2. Mirror it in `src/rules/catalog.ts` (same message string).
57
+ 3. The catalog↔rego parity test enforces zero drift.
58
+
59
+ ## References
60
+
61
+ - Schema: [`schemas/decision-output.v1.json`](https://github.com/buihongduc132/pi-opa-net/blob/main/schemas/decision-output.v1.json)
62
+ - Policy: [`policy/safety.rego`](https://github.com/buihongduc132/pi-opa-net/blob/main/policy/safety.rego)
63
+ - Decisions: [`docs/locked-decisions.yaml`](https://github.com/buihongduc132/pi-opa-net/blob/main/docs/locked-decisions.yaml), [`docs/open-threads.yaml`](https://github.com/buihongduc132/pi-opa-net/blob/main/docs/open-threads.yaml)
package/src/cli/run.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { resolve } from 'node:path';
2
+ import { configFromEnv } from '../config/Config.ts';
3
+ import { OpaCliEngine, probeOpaVersion } from '../engine/index.ts';
4
+ import { DecisionBuilder } from '../output/DecisionBuilder.ts';
5
+ import { OutputFormatter, validateDecision } from '../output/OutputFormatter.ts';
6
+ import { CommandParserCoordinator } from '../parser/index.ts';
7
+ import { RULES, RuleRegistry } from '../rules/index.ts';
8
+
9
+ export interface CliOptions {
10
+ /** Command string to evaluate. If omitted, read from stdin. */
11
+ readonly command?: string;
12
+ /** Output mode: json (full schema) | claude-code (suppress allow stdout). */
13
+ readonly mode: 'json' | 'claude-code';
14
+ /** Path to the .rego policy. */
15
+ readonly policyPath: string;
16
+ }
17
+
18
+ export interface CliResult {
19
+ readonly stdout: string;
20
+ readonly exitCode: number;
21
+ }
22
+
23
+ /**
24
+ * CLI entrypoint — wires parser → engine → builder → formatter.
25
+ *
26
+ * Returns {stdout, exitCode} instead of calling process.exit directly so it
27
+ * is unit-testable. The bin wrapper calls process.exit with the returned code.
28
+ */
29
+ export async function runCli(opts: CliOptions): Promise<CliResult> {
30
+ const raw = resolveRaw(opts);
31
+ if (raw === '') {
32
+ return { stdout: '', exitCode: 0 };
33
+ }
34
+
35
+ const config = configFromEnv(opts.policyPath);
36
+ const parser = new CommandParserCoordinator();
37
+ const parsed = parser.parse(raw);
38
+
39
+ const opaVersion = await probeOpaVersion(config.opaBinary ?? 'opa');
40
+ const engine = new OpaCliEngine(config, opaVersion);
41
+ const engineDecision = await engine.evaluate(parsed);
42
+
43
+ const builder = new DecisionBuilder({
44
+ config,
45
+ registry: new RuleRegistry(RULES),
46
+ digest: engine.rulebookDigest(),
47
+ });
48
+ const output = builder.build(parsed, engineDecision);
49
+
50
+ // Hard internal gate: the record MUST validate against the schema before emit.
51
+ validateDecision(output);
52
+
53
+ const formatter = new OutputFormatter();
54
+ const { stdout, exitCode } = formatter.format(output, opts.mode);
55
+ return { stdout, exitCode };
56
+ }
57
+
58
+ function resolveRaw(opts: CliOptions): string {
59
+ if (opts.command !== undefined && opts.command.length > 0) {
60
+ return opts.command;
61
+ }
62
+ // Read stdin synchronously when no command arg given.
63
+ try {
64
+ const fs = require('node:fs') as typeof import('node:fs');
65
+ return fs.readFileSync(0, 'utf8').trim();
66
+ } catch {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ /** Resolve the default policy path relative to package root. */
72
+ export function defaultPolicyPath(): string {
73
+ // import.meta.dir is available under Bun; fall back to cwd-relative for Node.
74
+ const here = (import.meta as { dir?: string }).dir ?? process.cwd();
75
+ return resolve(here, '../../policy/safety.rego');
76
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Fail-mode when the decision engine is unreachable [OT2 resolution].
3
+ * - `open`: allow the command through (default — matches pi-safety-net fork).
4
+ * - `closed`: block the command until engine responds.
5
+ */
6
+ export type FailMode = 'open' | 'closed';
7
+
8
+ /** Cache TTL in ms. 0 disables caching. */
9
+ export const DEFAULT_CACHE_TTL_MS = 0;
10
+
11
+ export interface EngineConfig {
12
+ /** Path to the OPA binary. If unset, auto-discovered via PATH + mise. */
13
+ readonly opaBinary?: string;
14
+ /** Path to the .rego policy bundle. */
15
+ readonly policyPath: string;
16
+ /** Fail-mode when OPA is unreachable [OT2]. */
17
+ readonly failMode: FailMode;
18
+ /** Milliseconds to wait for OPA before treating as unreachable. */
19
+ readonly timeoutMs: number;
20
+ /** Cache TTL for identical inputs. 0 = disabled. */
21
+ readonly cacheTtlMs: number;
22
+ /** Hostname for metadata. Defaults to os.hostname(). */
23
+ readonly hostname?: string;
24
+ /** Calling session ID for metadata (pi/claude session). Empty if none. */
25
+ readonly sessionId?: string;
26
+ }
27
+
28
+ const ENV = process.env;
29
+
30
+ /** Resolve the OPA binary path: explicit → PATH → mise install. */
31
+ export function resolveOpaBinary(explicit?: string): string {
32
+ if (explicit) return explicit;
33
+ if (ENV.PI_OPA_BINARY) return ENV.PI_OPA_BINARY;
34
+ // mise install path (LD2: OPA lazy-loaded on every dev box).
35
+ const misePath = `${process.env.HOME}/.local/share/mise/installs/opa`;
36
+ try {
37
+ const versions = readdirSafe(misePath);
38
+ // Prefer the most specific semver; fall back to 'latest'.
39
+ const pick =
40
+ versions
41
+ .filter((v) => /^\d+\.\d+\.\d+$/.test(v))
42
+ .sort()
43
+ .at(-1) ?? 'latest';
44
+ const candidate = `${misePath}/${pick}/opa`;
45
+ return candidate;
46
+ } catch {
47
+ return 'opa';
48
+ }
49
+ }
50
+
51
+ function readdirSafe(path: string): string[] {
52
+ try {
53
+ // Lazy require to avoid fs cost in non-Node runtimes.
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const fs = require('node:fs') as typeof import('node:fs');
56
+ return fs.readdirSync(path);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /** Build an EngineConfig from environment + defaults. */
63
+ export function configFromEnv(policyPath: string): EngineConfig {
64
+ const failMode: FailMode = (ENV.PI_OPA_FAIL_MODE as FailMode) === 'closed' ? 'closed' : 'open';
65
+ const timeoutMs = ENV.PI_OPA_TIMEOUT_MS ? Number.parseInt(ENV.PI_OPA_TIMEOUT_MS, 10) : 250;
66
+ const cacheTtlMs = ENV.PI_OPA_CACHE_TTL_MS
67
+ ? Number.parseInt(ENV.PI_OPA_CACHE_TTL_MS, 10)
68
+ : DEFAULT_CACHE_TTL_MS;
69
+ return {
70
+ opaBinary: resolveOpaBinary(),
71
+ policyPath,
72
+ failMode,
73
+ timeoutMs,
74
+ cacheTtlMs,
75
+ hostname: ENV.PI_OPA_HOSTNAME,
76
+ sessionId: ENV.PI_OPA_SESSION_ID,
77
+ };
78
+ }
@@ -0,0 +1,166 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import type { EngineConfig } from '../config/Config.ts';
7
+ import type { ParsedCommand } from '../parser/types.ts';
8
+ import { sha256Prefix } from '../util/digest.ts';
9
+ import type { DecisionEngine, EngineDecision, RawDeny } from './types.ts';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * OPA-backed decision engine via the `opa eval` CLI subprocess [LD1][LD2].
15
+ *
16
+ * - Spawns `opa eval -d <policy> -i <input.json> data.safety.allow + data.safety.deny`.
17
+ * - On timeout / non-zero exit / unreachable binary → applies [OT2] fail-mode.
18
+ * - fail-open: returns allow with source=fail-open.
19
+ * - fail-closed: returns deny with source=fail-closed.
20
+ *
21
+ * Rego returns `deny` as a set of message strings; we map each to a RawDeny.
22
+ */
23
+ export class OpaCliEngine implements DecisionEngine {
24
+ readonly name = 'opa-cli';
25
+ private readonly config: EngineConfig;
26
+ private readonly digest: string;
27
+ private readonly opaVersion: string;
28
+
29
+ constructor(config: EngineConfig, opaVersion = '') {
30
+ this.config = config;
31
+ this.digest = sha256Prefix(this.config.policyPath, this.readFile);
32
+ this.opaVersion = opaVersion;
33
+ }
34
+
35
+ async evaluate(parsed: ParsedCommand): Promise<EngineDecision> {
36
+ const input = {
37
+ program: parsed.program,
38
+ subcommand: parsed.subcommand,
39
+ args: parsed.args,
40
+ raw: parsed.raw,
41
+ };
42
+ const inputJson = JSON.stringify(input);
43
+ const query = '{"allow": data.safety.allow, "deny": data.safety.deny}';
44
+ const args = ['eval', '--format', 'json', '-d', this.config.policyPath];
45
+
46
+ const start = Date.now();
47
+ let tmpDir: string | null = null;
48
+ try {
49
+ tmpDir = mkdtempSync(join(tmpdir(), 'pi-opa-'));
50
+ const inFile = join(tmpDir, 'in.json');
51
+ writeFileSync(inFile, inputJson);
52
+ const fullArgs = [...args, '-i', inFile, query];
53
+ const { stdout } = await execFileAsync(this.resolveBinary(), fullArgs, {
54
+ timeout: this.config.timeoutMs,
55
+ maxBuffer: 4 * 1024 * 1024,
56
+ env: process.env,
57
+ });
58
+ const durationMs = Date.now() - start;
59
+ const parsedOut = parseOpaOutput(stdout);
60
+ const denies = extractDenies(parsedOut.deny);
61
+ const allow = parsedOut.allow === true;
62
+ return {
63
+ decision: allow ? 'allow' : 'deny',
64
+ source: 'opa',
65
+ reasons: allow ? [] : denies,
66
+ opaVersion: this.opaVersion,
67
+ durationMs,
68
+ };
69
+ } catch (err) {
70
+ const durationMs = Date.now() - start;
71
+ return this.failModeDecision(durationMs, err);
72
+ } finally {
73
+ if (tmpDir) {
74
+ try {
75
+ rmSync(tmpDir, { recursive: true, force: true });
76
+ } catch {
77
+ // best-effort cleanup
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ rulebookDigest(): string {
84
+ return this.digest;
85
+ }
86
+
87
+ private resolveBinary(): string {
88
+ return this.config.opaBinary ?? 'opa';
89
+ }
90
+
91
+ // Allow injecting a reader for testability without leaking fs into constructor.
92
+ private readFile = (path: string): string => {
93
+ try {
94
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
95
+ const fs = require('node:fs') as typeof import('node:fs');
96
+ return fs.readFileSync(path, 'utf8');
97
+ } catch {
98
+ return '';
99
+ }
100
+ };
101
+
102
+ private failModeDecision(durationMs: number, err: unknown): EngineDecision {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ if (this.config.failMode === 'closed') {
105
+ return {
106
+ decision: 'deny',
107
+ source: 'fail-closed',
108
+ reasons: [{ message: `OPA unreachable (${this.config.failMode} mode): ${message}` }],
109
+ opaVersion: '',
110
+ durationMs,
111
+ };
112
+ }
113
+ return {
114
+ decision: 'allow',
115
+ source: 'fail-open',
116
+ reasons: [],
117
+ opaVersion: '',
118
+ durationMs,
119
+ };
120
+ }
121
+ }
122
+
123
+ interface OpaResult {
124
+ allow?: unknown;
125
+ deny?: unknown;
126
+ }
127
+
128
+ function parseOpaOutput(stdout: string): OpaResult {
129
+ const doc = JSON.parse(stdout) as {
130
+ result?: Array<{ expressions: Array<{ value: unknown }> }>;
131
+ };
132
+ // Single object query → one expression whose value is {allow, deny}.
133
+ const value = doc.result?.[0]?.expressions?.[0]?.value;
134
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
135
+ const v = value as Record<string, unknown>;
136
+ return { allow: v.allow, deny: v.deny };
137
+ }
138
+ return {};
139
+ }
140
+
141
+ function extractDenies(denyValue: unknown): RawDeny[] {
142
+ if (!denyValue || typeof denyValue !== 'object') return [];
143
+ // When queried inside an object construction ({"deny": data.safety.deny}),
144
+ // a Rego set serializes as {message: true}. When queried directly it's an
145
+ // array. Handle both.
146
+ if (Array.isArray(denyValue)) {
147
+ return denyValue
148
+ .filter((m): m is string => typeof m === 'string')
149
+ .map((message) => ({ message }));
150
+ }
151
+ if (denyValue instanceof Object) {
152
+ return Object.keys(denyValue as Record<string, unknown>).map((message) => ({ message }));
153
+ }
154
+ return [];
155
+ }
156
+
157
+ /** Probe `opa version` once; returns the version string or ''. */
158
+ export async function probeOpaVersion(binary: string): Promise<string> {
159
+ try {
160
+ const { stdout } = await execFileAsync(binary, ['version'], { timeout: 2000 });
161
+ const match = /Version:\s*([0-9][^\s]*)/.exec(stdout);
162
+ return match ? match[1] : '';
163
+ } catch {
164
+ return '';
165
+ }
166
+ }
@@ -0,0 +1,2 @@
1
+ export { OpaCliEngine, probeOpaVersion } from './OpaCliEngine.ts';
2
+ export type { DecisionEngine, EngineDecision, RawDeny } from './types.ts';
@@ -0,0 +1,35 @@
1
+ import type { ParsedCommand } from '../parser/types.ts';
2
+
3
+ /**
4
+ * Raw deny reasons from the engine (before provenance enrichment).
5
+ * OPA returns a set of message strings; richer engines may return structured.
6
+ */
7
+ export interface RawDeny {
8
+ readonly message: string;
9
+ }
10
+
11
+ /** Outcome of an engine evaluation. */
12
+ export interface EngineDecision {
13
+ /** Verdict. */
14
+ readonly decision: 'allow' | 'deny';
15
+ /** Where the decision came from: opa | fail-open | fail-closed | cached. */
16
+ readonly source: 'opa' | 'fail-open' | 'fail-closed' | 'cached';
17
+ /** Fired deny reasons (empty on allow). */
18
+ readonly reasons: readonly RawDeny[];
19
+ /** OPA version string (empty when not from live OPA). */
20
+ readonly opaVersion: string;
21
+ /** Wall-clock duration in ms. */
22
+ readonly durationMs: number;
23
+ }
24
+
25
+ /**
26
+ * Decision engine interface [LD1: OPA].
27
+ * OpaCliEngine is the reference implementation; the interface allows fakes/mocks.
28
+ */
29
+ export interface DecisionEngine {
30
+ readonly name: string;
31
+ /** Evaluate the parsed command against the policy. */
32
+ evaluate(parsed: ParsedCommand): Promise<EngineDecision>;
33
+ /** SHA-256 prefix (12 hex) of the active policy bundle — for drift detection. */
34
+ rulebookDigest(): string;
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Public API for pi-opa-net — agent-agnostic engine + structured output.
2
+ //
3
+ // Consumers: pi extension (future pi-opa-net-ext), scripts, other agents.
4
+ // CLI entrypoint lives in src/cli/run.ts.
5
+
6
+ export { configFromEnv, resolveOpaBinary } from './config/Config.ts';
7
+ export type { EngineConfig, FailMode } from './config/Config.ts';
8
+
9
+ export { CommandParserCoordinator, RegexFallbackParser, ShellQuoteParser } from './parser/index.ts';
10
+ export type { CommandParser, ParseConfidence, ParsedCommand } from './parser/index.ts';
11
+
12
+ export { OpaCliEngine, probeOpaVersion } from './engine/index.ts';
13
+ export type { DecisionEngine, EngineDecision, RawDeny } from './engine/index.ts';
14
+
15
+ export { RULES, RuleRegistry, inferFamilyFromProgram } from './rules/index.ts';
16
+ export type { RuleFamily, RuleMeta } from './rules/index.ts';
17
+
18
+ export {
19
+ DecisionBuilder,
20
+ OutputFormatter,
21
+ isValidDecision,
22
+ validateDecision,
23
+ } from './output/index.ts';
24
+ export type {
25
+ DecisionMetadata,
26
+ DecisionOutput,
27
+ EvaluatedInput,
28
+ OutputMode,
29
+ Reason,
30
+ } from './output/index.ts';
31
+
32
+ export { sha256Prefix } from './util/digest.ts';
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { hostname as osHostname } from 'node:os';
3
+ import type { EngineConfig } from '../config/Config.ts';
4
+ import type { EngineDecision, RawDeny } from '../engine/types.ts';
5
+ import type { ParsedCommand } from '../parser/types.ts';
6
+ import { type RuleMeta, type RuleRegistry, inferFamilyFromProgram } from '../rules/index.ts';
7
+
8
+ /** The schema-compliant decision output (decision-output.v1). */
9
+ export interface DecisionOutput {
10
+ readonly schema_version: '1.0';
11
+ readonly decision: 'allow' | 'deny';
12
+ readonly action: 'allow' | 'block' | 'prompt_user' | 'log_only';
13
+ readonly source: 'opa' | 'fail-open' | 'fail-closed' | 'cached';
14
+ readonly reasons: readonly Reason[];
15
+ readonly input: EvaluatedInput;
16
+ readonly summary: string;
17
+ readonly suggestions: string[];
18
+ readonly metadata: DecisionMetadata;
19
+ readonly evaluated_at: string;
20
+ readonly decision_id: string;
21
+ readonly duration_ms: number;
22
+ }
23
+
24
+ export interface Reason {
25
+ readonly rule_id: string;
26
+ readonly message: string;
27
+ readonly family: string;
28
+ readonly severity: 'block' | 'warn' | 'info';
29
+ }
30
+
31
+ export interface EvaluatedInput {
32
+ readonly raw: string;
33
+ readonly program: string;
34
+ readonly subcommand: string;
35
+ readonly args: string[];
36
+ readonly parse_confidence: 'full' | 'partial' | 'regex-only' | 'failed';
37
+ }
38
+
39
+ export interface DecisionMetadata {
40
+ readonly engine: 'opa';
41
+ readonly opa_version: string;
42
+ readonly rulebook_digest: string;
43
+ readonly policy_path: string;
44
+ readonly hostname: string;
45
+ readonly session_id: string;
46
+ }
47
+
48
+ export interface DecisionBuilderDeps {
49
+ readonly config: EngineConfig;
50
+ readonly registry: RuleRegistry;
51
+ readonly digest: string;
52
+ /** Inject now() for deterministic tests. */
53
+ readonly now?: () => Date;
54
+ /** Inject uuid for deterministic tests. */
55
+ readonly uuid?: () => string;
56
+ }
57
+
58
+ /**
59
+ * Builds the decision-output.v1 record from engine result + parsed input.
60
+ *
61
+ * OOP: this class owns the schema-shape assembly. It does NOT decide — it
62
+ * translates an EngineDecision + ParsedCommand into the auditable record.
63
+ * DRY: provenance lookup (registry), summary formatting, and suggestion
64
+ * aggregation each happen in exactly one place.
65
+ */
66
+ export class DecisionBuilder {
67
+ private readonly deps: DecisionBuilderDeps;
68
+
69
+ constructor(deps: DecisionBuilderDeps) {
70
+ this.deps = deps;
71
+ }
72
+
73
+ build(parsed: ParsedCommand, engine: EngineDecision): DecisionOutput {
74
+ const reasons = engine.decision === 'deny' ? this.buildReasons(engine.reasons, parsed) : [];
75
+ const suggestions = this.collectSuggestions(reasons);
76
+ const action = engine.decision === 'allow' ? 'allow' : 'block';
77
+ return {
78
+ schema_version: '1.0',
79
+ decision: engine.decision,
80
+ action,
81
+ source: engine.source,
82
+ reasons,
83
+ input: {
84
+ raw: parsed.raw,
85
+ program: parsed.program,
86
+ subcommand: parsed.subcommand,
87
+ args: [...parsed.args],
88
+ parse_confidence: parsed.parseConfidence,
89
+ },
90
+ summary: this.summary(engine, parsed, reasons),
91
+ suggestions,
92
+ metadata: {
93
+ engine: 'opa',
94
+ opa_version: engine.opaVersion,
95
+ rulebook_digest: this.deps.digest,
96
+ policy_path: this.deps.config.policyPath,
97
+ hostname: this.deps.config.hostname ?? osHostname(),
98
+ session_id: this.deps.config.sessionId ?? '',
99
+ },
100
+ evaluated_at: (this.deps.now ?? (() => new Date()))().toISOString(),
101
+ decision_id: (this.deps.uuid ?? randomUUID)(),
102
+ duration_ms: engine.durationMs,
103
+ };
104
+ }
105
+
106
+ private buildReasons(raw: readonly RawDeny[], parsed: ParsedCommand): Reason[] {
107
+ return raw.map((d) => {
108
+ const meta = this.deps.registry.lookup(d);
109
+ return {
110
+ rule_id: meta.ruleId,
111
+ message: meta.message,
112
+ family: this.resolveFamily(meta, parsed),
113
+ severity: 'block' as const,
114
+ };
115
+ });
116
+ }
117
+
118
+ /** Use registered family; fall back to program-inferred family for sprintf rules. */
119
+ private resolveFamily(meta: RuleMeta, parsed: ParsedCommand): string {
120
+ if (meta.family !== 'custom') return meta.family;
121
+ const inferred = inferFamilyFromProgram(parsed.program);
122
+ return inferred;
123
+ }
124
+
125
+ private collectSuggestions(reasons: readonly Reason[]): string[] {
126
+ const out: string[] = [];
127
+ const seen = new Set<string>();
128
+ for (const r of reasons) {
129
+ const meta = this.findMetaByMessage(r.message);
130
+ if (meta?.suggestions) {
131
+ for (const s of meta.suggestions) {
132
+ if (!seen.has(s)) {
133
+ seen.add(s);
134
+ out.push(s);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ private findMetaByMessage(message: string): RuleMeta | undefined {
143
+ // Registry exposes lookup via RawDeny; reuse isKnown + synthesize-free path.
144
+ const synth = this.deps.registry.lookup({ message });
145
+ return synth.ruleId.startsWith('custom:') ? undefined : synth;
146
+ }
147
+
148
+ private summary(
149
+ engine: EngineDecision,
150
+ parsed: ParsedCommand,
151
+ reasons: readonly Reason[],
152
+ ): string {
153
+ if (engine.decision === 'allow') {
154
+ if (engine.source === 'fail-open' || engine.source === 'fail-closed') {
155
+ return `ALLOWED (${engine.source}: OPA unreachable for ${engine.durationMs}ms)`;
156
+ }
157
+ return '';
158
+ }
159
+ const first = reasons[0];
160
+ const rule = first ? ` (rule: ${first.rule_id})` : '';
161
+ return `BLOCKED: ${parsed.raw}${rule}`;
162
+ }
163
+ }
@@ -0,0 +1,43 @@
1
+ import addFormats from 'ajv-formats';
2
+ import Ajv2020 from 'ajv/dist/2020.js';
3
+ import schemaJson from '../../schemas/decision-output.v1.json' with { type: 'json' };
4
+ import type { DecisionOutput } from './DecisionBuilder.ts';
5
+
6
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
7
+ addFormats(ajv);
8
+ const validateFn = ajv.compile(schemaJson);
9
+
10
+ /** Validate a DecisionOutput against decision-output.v1. Throws on invalid. */
11
+ export function validateDecision(output: DecisionOutput): void {
12
+ if (!validateFn(output)) {
13
+ const errs =
14
+ validateFn.errors?.map((e) => `${e.instancePath}: ${e.message}`).join('; ') ?? 'unknown';
15
+ throw new Error(`decision-output schema violation: ${errs}`);
16
+ }
17
+ }
18
+
19
+ /** Returns true if valid, false otherwise (no throw). */
20
+ export function isValidDecision(output: DecisionOutput): boolean {
21
+ return validateFn(output) === true;
22
+ }
23
+
24
+ export type OutputMode = 'json' | 'claude-code';
25
+
26
+ /**
27
+ * Formats a decision for emission. Owns the stdout/exit-code strategy [D6][CA2].
28
+ *
29
+ * - `json` mode: always emit the full schema record on stdout.
30
+ * - `claude-code` mode: suppress stdout on allow (Claude Code hook protocol
31
+ * expects empty stdout for allowed commands); emit JSON only on deny.
32
+ *
33
+ * Exit codes: 0=allow, 2=deny (backward-compat with Claude Code hook protocol).
34
+ */
35
+ export class OutputFormatter {
36
+ format(output: DecisionOutput, mode: OutputMode): { stdout: string; exitCode: number } {
37
+ const exitCode = output.decision === 'allow' ? 0 : 2;
38
+ if (mode === 'claude-code' && output.decision === 'allow') {
39
+ return { stdout: '', exitCode };
40
+ }
41
+ return { stdout: JSON.stringify(output), exitCode };
42
+ }
43
+ }
@@ -0,0 +1,9 @@
1
+ export { DecisionBuilder } from './DecisionBuilder.ts';
2
+ export { OutputFormatter, isValidDecision, validateDecision } from './OutputFormatter.ts';
3
+ export type {
4
+ DecisionMetadata,
5
+ DecisionOutput,
6
+ EvaluatedInput,
7
+ Reason,
8
+ } from './DecisionBuilder.ts';
9
+ export type { OutputMode } from './OutputFormatter.ts';