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,32 @@
1
+ import { RegexFallbackParser } from './RegexFallbackParser.ts';
2
+ import { ShellQuoteParser } from './ShellQuoteParser.ts';
3
+ import type { CommandParser, ParsedCommand } from './types.ts';
4
+
5
+ /**
6
+ * Hybrid parser coordinator [OT1 resolution].
7
+ *
8
+ * Strategy: AST primary (ShellQuoteParser), regex fallback when AST reports
9
+ * `failed`. `partial` and `regex-only` results are returned as-is — the
10
+ * `parseConfidence` field surfaces which path fired per-decision.
11
+ */
12
+ export class CommandParserCoordinator implements CommandParser {
13
+ readonly name = 'hybrid';
14
+ private readonly primary: CommandParser;
15
+ private readonly fallback: CommandParser;
16
+
17
+ constructor(
18
+ primary: CommandParser = new ShellQuoteParser(),
19
+ fallback: CommandParser = new RegexFallbackParser(),
20
+ ) {
21
+ this.primary = primary;
22
+ this.fallback = fallback;
23
+ }
24
+
25
+ parse(raw: string): ParsedCommand {
26
+ const result = this.primary.parse(raw);
27
+ if (result.parseConfidence === 'failed') {
28
+ return this.fallback.parse(raw);
29
+ }
30
+ return result;
31
+ }
32
+ }
@@ -0,0 +1,31 @@
1
+ import type { CommandParser, ParsedCommand } from './types.ts';
2
+
3
+ /** Programs with a subcommand shape — mirror of ShellQuoteParser's set. */
4
+ const SUBCOMMAND_PROGRAMS = new Set(['git', 'docker', 'gh', 'glab']);
5
+
6
+ /**
7
+ * Regex-only fallback parser [OT1 resolution: fallback path].
8
+ *
9
+ * Used when the AST parser returns `failed`. Splits on whitespace, lowercases,
10
+ * and applies the same program-aware subcommand classification as ShellQuoteParser.
11
+ * Confidence is always `regex-only` (or `failed` on empty input).
12
+ */
13
+ export class RegexFallbackParser implements CommandParser {
14
+ readonly name = 'regex';
15
+
16
+ parse(raw: string): ParsedCommand {
17
+ const trimmed = raw.trim();
18
+ if (trimmed === '') {
19
+ return { raw, program: '', subcommand: '', args: [], parseConfidence: 'failed' };
20
+ }
21
+
22
+ const tokens = trimmed.split(/\s+/);
23
+ const [program, ...rest] = tokens.map((t) => t.toLowerCase());
24
+
25
+ if (SUBCOMMAND_PROGRAMS.has(program) && rest.length > 0 && !rest[0].startsWith('-')) {
26
+ const [sub, ...args] = rest;
27
+ return { raw, program, subcommand: sub, args, parseConfidence: 'regex-only' };
28
+ }
29
+ return { raw, program, subcommand: '', args: rest, parseConfidence: 'regex-only' };
30
+ }
31
+ }
@@ -0,0 +1,72 @@
1
+ import { parse as shellQuoteParse } from 'shell-quote';
2
+ import type { CommandParser, ParsedCommand } from './types.ts';
3
+
4
+ /**
5
+ * Programs that use a `[program, subcommand, args...]` shape.
6
+ * For programs NOT in this set (rm, bd, gcloud, bq, ls, ...), every token
7
+ * after the program is an arg and subcommand is "". This matches how the
8
+ * rego policy queries each family (git/docker/gh/glab check subcommand;
9
+ * rm/bd/gcloud/bq check args).
10
+ */
11
+ const SUBCOMMAND_PROGRAMS = new Set(['git', 'docker', 'gh', 'glab']);
12
+
13
+ /**
14
+ * AST-based parser using shell-quote [OT1 resolution: primary path].
15
+ *
16
+ * Produces `parseConfidence: full` when shell-quote tokenizes cleanly into
17
+ * a simple [program, subcommand?, args...] shape. Falls back to `partial`
18
+ * when redirects/pipelines/subshells are detected (structured tokens still
19
+ * extracted but the command is flagged as not a plain invocation).
20
+ */
21
+ export class ShellQuoteParser implements CommandParser {
22
+ readonly name = 'shell-quote';
23
+
24
+ parse(raw: string): ParsedCommand {
25
+ const trimmed = raw.trim();
26
+ if (trimmed === '') {
27
+ return emptyParsed(raw, 'failed');
28
+ }
29
+
30
+ let tokens: unknown[];
31
+ try {
32
+ tokens = shellQuoteParse(trimmed);
33
+ } catch {
34
+ // shell-quote throws on unbalanced quotes etc. — defer to regex fallback.
35
+ return emptyParsed(raw, 'failed');
36
+ }
37
+
38
+ const hasMeta = tokens.some(isMetaToken);
39
+ const strings = tokens.filter((t): t is string => typeof t === 'string');
40
+
41
+ if (strings.length === 0) {
42
+ return emptyParsed(raw, hasMeta ? 'partial' : 'failed');
43
+ }
44
+
45
+ return classify(strings, raw, hasMeta);
46
+ }
47
+ }
48
+
49
+ /** Apply program-aware subcommand classification. */
50
+ function classify(strings: string[], raw: string, hasMeta: boolean): ParsedCommand {
51
+ const [programRaw, ...rest] = strings;
52
+ const program = programRaw.toLowerCase();
53
+ const confidence = hasMeta ? 'partial' : 'full';
54
+
55
+ // Subcommand-style programs: tokens[1] is the subcommand (unless it's a flag).
56
+ if (SUBCOMMAND_PROGRAMS.has(program) && rest.length > 0 && !rest[0].startsWith('-')) {
57
+ const [sub, ...args] = rest;
58
+ return { raw, program, subcommand: sub.toLowerCase(), args, parseConfidence: confidence };
59
+ }
60
+
61
+ // Non-subcommand programs: everything after program is an arg.
62
+ return { raw, program, subcommand: '', args: rest, parseConfidence: confidence };
63
+ }
64
+
65
+ /** shell-quote emits objects ({op, ...}) for redirects/pipelines/subshells. */
66
+ function isMetaToken(token: unknown): boolean {
67
+ return typeof token === 'object' && token !== null;
68
+ }
69
+
70
+ function emptyParsed(raw: string, confidence: ParsedCommand['parseConfidence']): ParsedCommand {
71
+ return { raw, program: '', subcommand: '', args: [], parseConfidence: confidence };
72
+ }
@@ -0,0 +1,4 @@
1
+ export { CommandParserCoordinator } from './CommandParser.ts';
2
+ export { RegexFallbackParser } from './RegexFallbackParser.ts';
3
+ export { ShellQuoteParser } from './ShellQuoteParser.ts';
4
+ export type { CommandParser, ParseConfidence, ParsedCommand } from './types.ts';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Normalized command structure — the "decide half" input to OPA.
3
+ * Matches the `EvaluatedInput` schema (minus `raw` which is added at output).
4
+ */
5
+ export interface ParsedCommand {
6
+ /** Original command string verbatim. */
7
+ readonly raw: string;
8
+ /** Normalized program name (lowercase). Empty string if unparseable. */
9
+ readonly program: string;
10
+ /** Normalized subcommand. Empty string if none (bare-default case [OT3]). */
11
+ readonly subcommand: string;
12
+ /** Normalized arg tokens. Empty array if none. */
13
+ readonly args: readonly string[];
14
+ /** Parser fidelity — surfaces how well the command was parsed [OT1]. */
15
+ readonly parseConfidence: ParseConfidence;
16
+ }
17
+
18
+ export type ParseConfidence = 'full' | 'partial' | 'regex-only' | 'failed';
19
+
20
+ /**
21
+ * Strategy interface for command parsing [OT1 resolution: hybrid].
22
+ * Implementations: ShellQuoteParser (AST primary), RegexFallbackParser (fallback).
23
+ */
24
+ export interface CommandParser {
25
+ readonly name: string;
26
+ parse(raw: string): ParsedCommand;
27
+ }
@@ -0,0 +1,65 @@
1
+ import type { RawDeny } from '../engine/types.ts';
2
+
3
+ export type RuleFamily =
4
+ | 'git'
5
+ | 'docker'
6
+ | 'rm'
7
+ | 'gcloud'
8
+ | 'bq'
9
+ | 'gh'
10
+ | 'glab'
11
+ | 'bd'
12
+ | 'builtin'
13
+ | 'custom';
14
+
15
+ export interface RuleMeta {
16
+ readonly ruleId: string;
17
+ readonly family: RuleFamily;
18
+ readonly message: string;
19
+ /** Safe alternatives for the "did you mean?" UX. Optional. */
20
+ readonly suggestions?: readonly string[];
21
+ }
22
+
23
+ /**
24
+ * Registry mapping deny messages to stable rule provenance [D3].
25
+ *
26
+ * OPA returns deny as a set of message strings (the rego rule bodies).
27
+ * This registry is the single source of truth for rule_id + family + severity,
28
+ * so audits trace decision → rule → source line. Messages not in the registry
29
+ * fall back to a synthesized `custom:<hash>` id with family=custom.
30
+ *
31
+ * Keeping this in TS (not rego) is intentional: rego is the policy, this is the
32
+ * provenance metadata layer. DRY — one canonical list, consumed by the builder.
33
+ */
34
+ export class RuleRegistry {
35
+ private readonly byMessage: Map<string, RuleMeta>;
36
+
37
+ constructor(rules: readonly RuleMeta[]) {
38
+ this.byMessage = new Map(rules.map((r) => [r.message, r]));
39
+ }
40
+
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;
45
+ return {
46
+ ruleId: `custom:${hashMessage(deny.message)}`,
47
+ family: 'custom',
48
+ message: deny.message,
49
+ };
50
+ }
51
+
52
+ /** True if the message is a known registered rule. */
53
+ isKnown(message: string): boolean {
54
+ return this.byMessage.has(message);
55
+ }
56
+ }
57
+
58
+ function hashMessage(msg: string): string {
59
+ // Simple stable hash for synthesis — not cryptographic.
60
+ let h = 0;
61
+ for (let i = 0; i < msg.length; i++) {
62
+ h = (Math.imul(31, h) + msg.charCodeAt(i)) | 0;
63
+ }
64
+ return (h >>> 0).toString(16).padStart(8, '0');
65
+ }
@@ -0,0 +1,221 @@
1
+ import type { RuleMeta } from './RuleRegistry.ts';
2
+
3
+ /**
4
+ * Canonical rule catalog — mirrors policy/safety.rego message-for-message.
5
+ *
6
+ * Single source of truth for rule_id + family + suggestions. When you add a
7
+ * deny rule to safety.rego, add its message here too. The registry test
8
+ * (`rule-catalog-parity.test.ts`) fails if the rego and this list drift.
9
+ */
10
+ export const RULES: readonly RuleMeta[] = [
11
+ // ── GROUP A: git ──
12
+ {
13
+ ruleId: 'block-git-commit-am',
14
+ family: 'git',
15
+ message:
16
+ 'git commit -am stages ALL tracked modifications indiscriminately. Use explicit paths.',
17
+ suggestions: ['git commit -m "msg" <paths>'],
18
+ },
19
+ {
20
+ ruleId: 'block-git-commit-no-verify',
21
+ family: 'git',
22
+ message: 'ALWAYS run pre-commit hooks. Bypassing hooks risks shipping broken changes.',
23
+ },
24
+ {
25
+ ruleId: 'block-git-stash-mutations',
26
+ family: 'git',
27
+ message: 'Do not mutate stashes in shared work. Others may be relying on them.',
28
+ suggestions: ['git stash list', 'git stash show'],
29
+ },
30
+ {
31
+ ruleId: 'builtin:bare-stash-default',
32
+ family: 'builtin',
33
+ message: 'Bare `git stash` defaults to push. Use `git stash list/show` explicitly.',
34
+ suggestions: ['git stash list', 'git stash show', 'git stash branch <name>'],
35
+ },
36
+ {
37
+ ruleId: 'block-git-reset-hard',
38
+ family: 'git',
39
+ message: "Hard reset discards local work and can remove others' uncommitted changes.",
40
+ },
41
+ {
42
+ ruleId: 'block-git-reset-mixed',
43
+ family: 'git',
44
+ message: 'Mixed reset rewrites index state and can disrupt shared work.',
45
+ },
46
+ {
47
+ ruleId: 'block-git-reset-modes',
48
+ family: 'git',
49
+ message: 'Reset modes can unexpectedly alter local changes in shared work.',
50
+ },
51
+ {
52
+ ruleId: 'block-git-clean-force',
53
+ family: 'git',
54
+ message: 'git clean can permanently remove untracked files from the working tree.',
55
+ },
56
+ {
57
+ ruleId: 'block-git-checkout-discard',
58
+ family: 'git',
59
+ message: "checkout -- discards local file changes and may destroy others' work.",
60
+ },
61
+ {
62
+ ruleId: 'block-git-checkout-B',
63
+ family: 'git',
64
+ message: 'git checkout -B force-resets branch refs and can trash shared branches.',
65
+ },
66
+ {
67
+ ruleId: 'block-git-restore',
68
+ family: 'git',
69
+ message: 'git restore can discard tracked modifications in shared work.',
70
+ },
71
+ {
72
+ ruleId: 'block-git-add-all',
73
+ family: 'git',
74
+ message: 'git add -A / -a stages ALL changed files indiscriminately. Use explicit paths.',
75
+ suggestions: ['git add <explicit-paths>'],
76
+ },
77
+ {
78
+ ruleId: 'block-git-add-dot',
79
+ family: 'git',
80
+ message: 'git add . stages ALL files in the current directory indiscriminately.',
81
+ suggestions: ['git add <explicit-paths>'],
82
+ },
83
+ {
84
+ ruleId: 'block-git-switch-C',
85
+ family: 'git',
86
+ message: 'git switch -C force-resets branch refs and can rewrite shared history.',
87
+ },
88
+ {
89
+ ruleId: 'block-git-branch-force',
90
+ family: 'git',
91
+ message: 'Forced branch moves or renames can rewrite refs and disrupt shared work.',
92
+ },
93
+ {
94
+ ruleId: 'block-git-rebase',
95
+ family: 'git',
96
+ message: 'Rebase rewrites commit history and is blocked in this environment.',
97
+ },
98
+ {
99
+ ruleId: 'block-git-rebase-lifecycle',
100
+ family: 'git',
101
+ message: 'git rebase --continue/--skip/--abort should be run only with explicit approval.',
102
+ },
103
+ // ── GROUP B: docker subcommands ──
104
+ {
105
+ ruleId: 'block-docker-stop',
106
+ family: 'docker',
107
+ message: 'Direct container stop is blocked to protect services managed by Nomad.',
108
+ },
109
+ {
110
+ ruleId: 'block-docker-kill',
111
+ family: 'docker',
112
+ message: 'Direct container kill is blocked. Abrupt termination risks data loss.',
113
+ },
114
+ {
115
+ ruleId: 'block-docker-rm',
116
+ family: 'docker',
117
+ message: 'Direct container removal is blocked. Re-deploying via Nomad is safer.',
118
+ },
119
+ {
120
+ ruleId: 'block-docker-restart',
121
+ family: 'docker',
122
+ message: 'NEVER restart containers directly. This bypasses scheduling safety.',
123
+ },
124
+ {
125
+ ruleId: 'block-docker-exec',
126
+ family: 'docker',
127
+ message: 'Direct exec into containers is blocked for security.',
128
+ },
129
+ {
130
+ ruleId: 'block-docker-update',
131
+ family: 'docker',
132
+ message: 'Direct resource updates are blocked. Use Nomad job specification.',
133
+ },
134
+ {
135
+ ruleId: 'block-docker-rename',
136
+ family: 'docker',
137
+ message: 'Container renaming is blocked to prevent breaking service discovery.',
138
+ },
139
+ {
140
+ ruleId: 'block-docker-volume-rm-prune',
141
+ family: 'docker',
142
+ message: 'Direct volume removal is strictly blocked to prevent data loss.',
143
+ },
144
+ {
145
+ ruleId: 'block-docker-volume-create',
146
+ family: 'docker',
147
+ message: 'Manual volume creation is blocked to maintain infra-as-code parity.',
148
+ },
149
+ // ── GROUP C: docker compose carve-outs ──
150
+ {
151
+ ruleId: 'block-docker-compose-down-litellm',
152
+ family: 'docker',
153
+ message: 'NEVER bring down litellm/litellm-local/omniroute via docker compose.',
154
+ },
155
+ {
156
+ ruleId: 'block-docker-compose-rm-litellm',
157
+ family: 'docker',
158
+ message: 'NEVER remove litellm/litellm-local/omniroute containers via docker compose.',
159
+ },
160
+ {
161
+ ruleId: 'block-docker-compose-target-litellm',
162
+ family: 'docker',
163
+ message: 'NEVER stop litellm/litellm-local/omniroute via docker compose --target.',
164
+ },
165
+ // ── GROUP D: command-level ──
166
+ {
167
+ ruleId: 'block-bd-notes',
168
+ family: 'bd',
169
+ message: 'Use --append-notes instead to preserve existing notes.',
170
+ suggestions: ['bd --append-notes'],
171
+ },
172
+ // ── GROUP E: rm ──
173
+ {
174
+ ruleId: 'block-rm-bd-sub-skills',
175
+ family: 'rm',
176
+ message: "Removing deprecated bd sub-skill directories is blocked (rule is misnamed 'allow').",
177
+ },
178
+ {
179
+ ruleId: 'block-rm-beads-subdirs',
180
+ family: 'rm',
181
+ message: "Removing symlink subdirs in beads/ skill is blocked (rule is misnamed 'allow').",
182
+ },
183
+ // ── GROUP F: gh / glab ──
184
+ {
185
+ ruleId: 'block-gh-repo-delete-archive',
186
+ family: 'gh',
187
+ message: 'Destructive GitHub repository lifecycle actions are blocked by default.',
188
+ },
189
+ {
190
+ ruleId: 'block-gh-repo-public',
191
+ family: 'gh',
192
+ message: 'Public GitHub repository creation is blocked by default.',
193
+ },
194
+ {
195
+ ruleId: 'block-gh-repo-visibility',
196
+ family: 'gh',
197
+ message: 'GitHub repository visibility changes are blocked by default.',
198
+ },
199
+ {
200
+ ruleId: 'block-glab-repo-delete-archive',
201
+ family: 'glab',
202
+ message: 'Destructive GitLab repository lifecycle actions are blocked by default.',
203
+ },
204
+ {
205
+ ruleId: 'block-glab-repo-public',
206
+ family: 'glab',
207
+ message: 'Public GitLab repository creation is blocked by default.',
208
+ },
209
+ ];
210
+
211
+ /** gcloud/bq produce sprintf messages — family inferred from program. */
212
+ export function inferFamilyFromProgram(program: string): 'gcloud' | 'bq' | 'custom' {
213
+ switch (program) {
214
+ case 'gcloud':
215
+ return 'gcloud';
216
+ case 'bq':
217
+ return 'bq';
218
+ default:
219
+ return 'custom';
220
+ }
221
+ }
@@ -0,0 +1,3 @@
1
+ export { RuleRegistry } from './RuleRegistry.ts';
2
+ export { RULES, inferFamilyFromProgram } from './catalog.ts';
3
+ export type { RuleFamily, RuleMeta } from './RuleRegistry.ts';
@@ -0,0 +1,18 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ /**
5
+ * Compute a SHA-256 hex prefix of file content for rulebook drift detection.
6
+ * @param path file path to hash
7
+ * @param reader optional reader override (testability)
8
+ * @returns first 12 hex chars, or 12 zeros if unreadable
9
+ */
10
+ export function sha256Prefix(path: string, reader?: (p: string) => string): string {
11
+ const read = reader ?? ((p: string) => readFileSync(p, 'utf8'));
12
+ try {
13
+ const content = read(path);
14
+ return createHash('sha256').update(content).digest('hex').slice(0, 12);
15
+ } catch {
16
+ return '0'.repeat(12);
17
+ }
18
+ }