pi-opa-net 0.1.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.
@@ -23,6 +23,12 @@ export interface EngineConfig {
23
23
  readonly hostname?: string;
24
24
  /** Calling session ID for metadata (pi/claude session). Empty if none. */
25
25
  readonly sessionId?: string;
26
+ /** Unlock keys from PIOPANET_UNLOCK_KEYS / --unlock (comma-separated env). */
27
+ readonly unlockKeys?: readonly string[];
28
+ /** Path to the deploy-local salt file (or PIOPANET_UNLOCK_SALT override). */
29
+ readonly unlockSaltPath?: string;
30
+ /** Agent ID for unlock audit metadata (PIOPANET_AGENT_ID). */
31
+ readonly unlockAgentId?: string;
26
32
  }
27
33
 
28
34
  const ENV = process.env;
@@ -63,9 +69,25 @@ function readdirSafe(path: string): string[] {
63
69
  export function configFromEnv(policyPath: string): EngineConfig {
64
70
  const failMode: FailMode = (ENV.PI_OPA_FAIL_MODE as FailMode) === 'closed' ? 'closed' : 'open';
65
71
  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
72
+ const baseCacheTtlMs = ENV.PI_OPA_CACHE_TTL_MS
67
73
  ? Number.parseInt(ENV.PI_OPA_CACHE_TTL_MS, 10)
68
74
  : DEFAULT_CACHE_TTL_MS;
75
+
76
+ // Unlock keys: PIOPANET_UNLOCK_KEYS (comma-separated, trimmed).
77
+ const unlockKeys = (ENV.PIOPANET_UNLOCK_KEYS ?? '')
78
+ .split(',')
79
+ .map((k) => k.trim())
80
+ .filter((k) => k.length > 0);
81
+
82
+ // LD-G3: force cacheTtlMs=0 when unlock keys are present (cache poisoning guard).
83
+ const cacheTtlMs = unlockKeys.length > 0 ? 0 : baseCacheTtlMs;
84
+
85
+ // Salt path: PIOPANET_UNLOCK_SALT or PIOPANET_UNLOCK_SALT_FILE → default ~/.pi-opa-net/salt.
86
+ const unlockSaltPath =
87
+ ENV.PIOPANET_UNLOCK_SALT ?? ENV.PIOPANET_UNLOCK_SALT_FILE ?? defaultSaltPath();
88
+
89
+ const unlockAgentId = ENV.PIOPANET_AGENT_ID;
90
+
69
91
  return {
70
92
  opaBinary: resolveOpaBinary(),
71
93
  policyPath,
@@ -74,5 +96,14 @@ export function configFromEnv(policyPath: string): EngineConfig {
74
96
  cacheTtlMs,
75
97
  hostname: ENV.PI_OPA_HOSTNAME,
76
98
  sessionId: ENV.PI_OPA_SESSION_ID,
99
+ unlockKeys,
100
+ unlockSaltPath,
101
+ unlockAgentId,
77
102
  };
78
103
  }
104
+
105
+ /** Default salt file path: ~/.pi-opa-net/salt. */
106
+ function defaultSaltPath(): string {
107
+ const home = ENV.HOME ?? process.env.HOME ?? '';
108
+ return `${home}/.pi-opa-net/salt`;
109
+ }
@@ -4,13 +4,21 @@ import type { EngineConfig } from '../config/Config.ts';
4
4
  import type { EngineDecision, RawDeny } from '../engine/types.ts';
5
5
  import type { ParsedCommand } from '../parser/types.ts';
6
6
  import { type RuleMeta, type RuleRegistry, inferFamilyFromProgram } from '../rules/index.ts';
7
+ import type { UnlockResult } from '../unlock/types.ts';
7
8
 
8
9
  /** The schema-compliant decision output (decision-output.v1). */
9
10
  export interface DecisionOutput {
10
11
  readonly schema_version: '1.0';
11
12
  readonly decision: 'allow' | 'deny';
12
13
  readonly action: 'allow' | 'block' | 'prompt_user' | 'log_only';
13
- readonly source: 'opa' | 'fail-open' | 'fail-closed' | 'cached';
14
+ readonly source:
15
+ | 'opa'
16
+ | 'fail-open'
17
+ | 'fail-closed'
18
+ | 'cached'
19
+ | 'opa-unlocked'
20
+ | 'fail-open-keyless'
21
+ | 'unlock-filter-error';
14
22
  readonly reasons: readonly Reason[];
15
23
  readonly input: EvaluatedInput;
16
24
  readonly summary: string;
@@ -26,6 +34,11 @@ export interface Reason {
26
34
  readonly message: string;
27
35
  readonly family: string;
28
36
  readonly severity: 'block' | 'warn' | 'info';
37
+ readonly bypassed?: boolean;
38
+ readonly unlock_key_id?: string;
39
+ readonly unlock_key_type?: 'll' | 'ttl';
40
+ readonly unlock_expires_at?: string;
41
+ readonly unlock_status?: 'valid' | 'expired';
29
42
  }
30
43
 
31
44
  export interface EvaluatedInput {
@@ -43,6 +56,9 @@ export interface DecisionMetadata {
43
56
  readonly policy_path: string;
44
57
  readonly hostname: string;
45
58
  readonly session_id: string;
59
+ readonly unlock_count?: number;
60
+ readonly unlock_blocked_count?: number;
61
+ readonly unlock_agent?: string;
46
62
  }
47
63
 
48
64
  export interface DecisionBuilderDeps {
@@ -70,15 +86,49 @@ export class DecisionBuilder {
70
86
  this.deps = deps;
71
87
  }
72
88
 
73
- build(parsed: ParsedCommand, engine: EngineDecision): DecisionOutput {
74
- const reasons = engine.decision === 'deny' ? this.buildReasons(engine.reasons, parsed) : [];
89
+ build(
90
+ parsed: ParsedCommand,
91
+ engine: EngineDecision,
92
+ opts?: { unlockResult?: UnlockResult },
93
+ ): DecisionOutput {
94
+ const unlockResult = opts?.unlockResult;
95
+ const reasons =
96
+ engine.decision === 'deny' ? this.buildReasons(engine.reasons, parsed, unlockResult) : [];
75
97
  const suggestions = this.collectSuggestions(reasons);
76
- const action = engine.decision === 'allow' ? 'allow' : 'block';
98
+
99
+ // Determine source and decision based on unlock result.
100
+ let source: DecisionOutput['source'] = engine.source;
101
+ let decision = engine.decision;
102
+ if (unlockResult && unlockResult.bypassedCount > 0 && unlockResult.blockedCount === 0) {
103
+ source = 'opa-unlocked';
104
+ decision = 'allow';
105
+ }
106
+
107
+ const action = decision === 'allow' ? 'allow' : 'block';
108
+
109
+ // Build metadata with optional unlock fields.
110
+ const metadata: DecisionMetadata = {
111
+ engine: 'opa',
112
+ opa_version: engine.opaVersion,
113
+ rulebook_digest: this.deps.digest,
114
+ policy_path: this.deps.config.policyPath,
115
+ hostname: this.deps.config.hostname ?? osHostname(),
116
+ session_id: this.deps.config.sessionId ?? '',
117
+ };
118
+ if (unlockResult) {
119
+ const meta = metadata as unknown as Record<string, unknown>;
120
+ meta.unlock_count = unlockResult.bypassedCount;
121
+ meta.unlock_blocked_count = unlockResult.blockedCount;
122
+ if (this.deps.config.unlockAgentId) {
123
+ meta.unlock_agent = this.deps.config.unlockAgentId;
124
+ }
125
+ }
126
+
77
127
  return {
78
128
  schema_version: '1.0',
79
- decision: engine.decision,
129
+ decision,
80
130
  action,
81
- source: engine.source,
131
+ source,
82
132
  reasons,
83
133
  input: {
84
134
  raw: parsed.raw,
@@ -89,29 +139,50 @@ export class DecisionBuilder {
89
139
  },
90
140
  summary: this.summary(engine, parsed, reasons),
91
141
  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
- },
142
+ metadata,
100
143
  evaluated_at: (this.deps.now ?? (() => new Date()))().toISOString(),
101
144
  decision_id: (this.deps.uuid ?? randomUUID)(),
102
145
  duration_ms: engine.durationMs,
103
146
  };
104
147
  }
105
148
 
106
- private buildReasons(raw: readonly RawDeny[], parsed: ParsedCommand): Reason[] {
107
- return raw.map((d) => {
108
- const meta = this.deps.registry.lookup(d);
109
- return {
149
+ private buildReasons(
150
+ raw: readonly RawDeny[],
151
+ parsed: ParsedCommand,
152
+ unlockResult?: UnlockResult,
153
+ ): Reason[] {
154
+ return raw.map((d, i) => {
155
+ const familyHint = inferFamilyFromProgram(parsed.program);
156
+ const meta = this.deps.registry.lookup(d, familyHint, parsed.args);
157
+ const reason: Record<string, unknown> = {
110
158
  rule_id: meta.ruleId,
111
159
  message: meta.message,
112
160
  family: this.resolveFamily(meta, parsed),
113
- severity: 'block' as const,
161
+ severity: 'block',
114
162
  };
163
+
164
+ // Merge unlock info if available.
165
+ if (unlockResult && i < unlockResult.reasons.length) {
166
+ const info = unlockResult.reasons[i];
167
+ if (info.bypassed) {
168
+ reason.bypassed = true;
169
+ reason.severity = 'info'; // Demote bypassed reason severity.
170
+ }
171
+ const keyId = info.unlockKeyId ?? info.unlock_key_id;
172
+ if (keyId) reason.unlock_key_id = keyId;
173
+ const keyType = info.keyType ?? info.unlock_key_type;
174
+ if (keyType) reason.unlock_key_type = keyType;
175
+ const exp = info.expiresAt ?? info.unlock_expires_at;
176
+ if (typeof exp === 'number') {
177
+ reason.unlock_expires_at = new Date(exp * 1000).toISOString();
178
+ } else if (typeof exp === 'string') {
179
+ reason.unlock_expires_at = exp;
180
+ }
181
+ const status = info.unlockStatus ?? info.unlock_status;
182
+ if (status) reason.unlock_status = status;
183
+ }
184
+
185
+ return reason as unknown as Reason;
115
186
  });
116
187
  }
117
188
 
@@ -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
+ }
@@ -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
+ }
@@ -10,7 +10,10 @@ export type RuleFamily =
10
10
  | 'glab'
11
11
  | 'bd'
12
12
  | 'builtin'
13
- | 'custom';
13
+ | 'custom'
14
+ | 'tmux'
15
+ | 'pkill'
16
+ | 'killall';
14
17
 
15
18
  export interface RuleMeta {
16
19
  readonly ruleId: string;
@@ -18,6 +21,15 @@ export interface RuleMeta {
18
21
  readonly message: string;
19
22
  /** Safe alternatives for the "did you mean?" UX. Optional. */
20
23
  readonly suggestions?: readonly string[];
24
+ /**
25
+ * Disambiguation tokens used ONLY when multiple registered rules share an
26
+ * identical message AND family (collision). If any of these tokens appears
27
+ * in the parsed command's args, this candidate wins. Only needed for rules
28
+ * whose reason text is byte-identical to a sibling rule's (e.g.
29
+ * block-tmux-kill-server vs block-tmux-kill-session, which share the same
30
+ * verbatim reason and family 'tmux').
31
+ */
32
+ readonly matchArgs?: readonly string[];
21
33
  }
22
34
 
23
35
  /**
@@ -28,20 +40,51 @@ export interface RuleMeta {
28
40
  * so audits trace decision → rule → source line. Messages not in the registry
29
41
  * fall back to a synthesized `custom:<hash>` id with family=custom.
30
42
  *
43
+ * Multiple rules MAY share an identical message (e.g. the four
44
+ * tmux/pkill/killall session-kill rules all carry the same verbatim reason
45
+ * text). Such collisions are stored as a list and resolved by family hint
46
+ * (inferred from the parsed program) when one is supplied to lookup().
47
+ *
31
48
  * Keeping this in TS (not rego) is intentional: rego is the policy, this is the
32
49
  * provenance metadata layer. DRY — one canonical list, consumed by the builder.
33
50
  */
34
51
  export class RuleRegistry {
35
- private readonly byMessage: Map<string, RuleMeta>;
52
+ private readonly byMessage: Map<string, RuleMeta[]>;
36
53
 
37
54
  constructor(rules: readonly RuleMeta[]) {
38
- this.byMessage = new Map(rules.map((r) => [r.message, r]));
55
+ this.byMessage = new Map<string, RuleMeta[]>();
56
+ for (const r of rules) {
57
+ const arr = this.byMessage.get(r.message) ?? [];
58
+ arr.push(r);
59
+ this.byMessage.set(r.message, arr);
60
+ }
39
61
  }
40
62
 
41
- /** Look up metadata for a deny message; synthesizes a custom entry if unknown. */
42
- lookup(deny: RawDeny): RuleMeta {
43
- const found = this.byMessage.get(deny.message);
44
- if (found) return found;
63
+ /**
64
+ * Look up metadata for a deny message; synthesizes a custom entry if unknown.
65
+ *
66
+ * Collision resolution order when several registered rules share the same
67
+ * message:
68
+ * 1. `familyHint` (inferred from the parsed program) narrows to that family.
69
+ * 2. `parsedArgs` narrows further to a candidate whose `matchArgs` intersects
70
+ * the parsed args (for rules that share message AND family, e.g. the two
71
+ * tmux kill-* rules).
72
+ * 3. Otherwise the first remaining candidate is returned.
73
+ */
74
+ lookup(deny: RawDeny, familyHint?: string, parsedArgs?: readonly string[]): RuleMeta {
75
+ const candidates = this.byMessage.get(deny.message);
76
+ if (candidates && candidates.length > 0) {
77
+ let pool = candidates;
78
+ if (familyHint) {
79
+ const byFamily = pool.filter((c) => c.family === familyHint);
80
+ if (byFamily.length > 0) pool = byFamily;
81
+ }
82
+ if (parsedArgs && pool.length > 1) {
83
+ const byArgs = pool.filter((c) => c.matchArgs?.some((t) => parsedArgs.includes(t)));
84
+ if (byArgs.length > 0) pool = byArgs;
85
+ }
86
+ return pool[0];
87
+ }
45
88
  return {
46
89
  ruleId: `custom:${hashMessage(deny.message)}`,
47
90
  family: 'custom',
@@ -1,4 +1,4 @@
1
- import type { RuleMeta } from './RuleRegistry.ts';
1
+ import type { RuleFamily, RuleMeta } from './RuleRegistry.ts';
2
2
 
3
3
  /**
4
4
  * Canonical rule catalog — mirrors policy/safety.rego message-for-message.
@@ -206,15 +206,51 @@ export const RULES: readonly RuleMeta[] = [
206
206
  family: 'glab',
207
207
  message: 'Public GitLab repository creation is blocked by default.',
208
208
  },
209
+ // ── GROUP G: tmux / pkill / killall session protection (cc-safety-net parity) ──
210
+ {
211
+ ruleId: 'block-tmux-kill-server',
212
+ family: 'tmux',
213
+ message:
214
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
215
+ matchArgs: ['kill-server'],
216
+ },
217
+ {
218
+ ruleId: 'block-tmux-kill-session',
219
+ family: 'tmux',
220
+ message:
221
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
222
+ matchArgs: ['kill-session'],
223
+ },
224
+ {
225
+ ruleId: 'block-pkill-tmux-wezterm',
226
+ family: 'pkill',
227
+ message:
228
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
229
+ },
230
+ {
231
+ ruleId: 'block-killall-tmux-wezterm',
232
+ family: 'killall',
233
+ message:
234
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
235
+ },
209
236
  ];
210
237
 
211
- /** gcloud/bq produce sprintf messages — family inferred from program. */
212
- export function inferFamilyFromProgram(program: string): 'gcloud' | 'bq' | 'custom' {
238
+ /** gcloud/bq produce sprintf messages — family inferred from program.
239
+ * tmux/pkill/killall rules share identical reason text (the four session-kill
240
+ * rules), so their family is also inferred from the program to disambiguate
241
+ * the message-keyed registry. */
242
+ export function inferFamilyFromProgram(program: string): RuleFamily {
213
243
  switch (program) {
214
244
  case 'gcloud':
215
245
  return 'gcloud';
216
246
  case 'bq':
217
247
  return 'bq';
248
+ case 'tmux':
249
+ return 'tmux';
250
+ case 'pkill':
251
+ return 'pkill';
252
+ case 'killall':
253
+ return 'killall';
218
254
  default:
219
255
  return 'custom';
220
256
  }