@swfte/nexus-sdk 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,224 @@
1
+ 'use strict';
2
+ /**
3
+ * The enforcement seam — the one thing in this product that can say *no*.
4
+ *
5
+ * Capture of coding agents is now free: OpenLIT is Apache-2.0, `opentelemetry-hooks` is MIT, and
6
+ * ddtrace, OpenLLMetry and OpenInference all read the same provider call sites we do. **None of
7
+ * them can stop a call.** Every agent-observability vendor observes; the differentiator is not what
8
+ * we see, it is that we can refuse. That is what is implemented here.
9
+ *
10
+ * import * as nexus from '@swfte/nexus-sdk';
11
+ * import * as policy from '@swfte/nexus-sdk/policy';
12
+ *
13
+ * policy.install(signedEnvelope); // verified before a single rule is read
14
+ *
15
+ * const d = policy.decide('tool_action', { tool: 'db.write', target: 'prod-orders' });
16
+ * if (d.denied) { … }
17
+ *
18
+ * policy.gate('tool_action', { tool: 'db.write', target: 'prod-orders' }, () => {
19
+ * writeTheRows(); // never runs if an enforcing rule denies
20
+ * });
21
+ *
22
+ * ── Where enforcement actually happens, and where it cannot ──────────────────────────────────
23
+ *
24
+ * **`run.action(...)` is gated.** It is the only call site in this SDK that is, and that is not an
25
+ * oversight — it is the only one where the customer owns the call site and a decision can be taken
26
+ * before the effect.
27
+ *
28
+ * **An application on the Vercel AI SDK seam cannot be gated in-process, now or later.** That
29
+ * seam's callbacks return `void`: there is no channel to refuse on, and a throw from
30
+ * `onToolExecutionStart` neither stops the tool nor reaches the application — measured, repeatedly.
31
+ * There is deliberately no policy hook on `telemetry()` and there will not be one, because a hook
32
+ * there would be a capability that appears to work and silently does not. Enforcement in Node means
33
+ * the explicit API, a gateway in front of the provider, or nothing.
34
+ *
35
+ * ── What raises ──────────────────────────────────────────────────────────────────────────────
36
+ *
37
+ * `Denied` is the single exception this SDK will ever put in a host stack trace, and the host opted
38
+ * into it twice: the rule carried `enforce: true` and the call site did not decline. With no policy
39
+ * installed — the default — nothing here can throw, so adding this import changes no behaviour
40
+ * until a signed envelope arrives.
41
+ */
42
+
43
+ const core = require('../core.cjs');
44
+ const { Engine, Denied, ALERTS, ALLOW, DENY } = require('./engine.cjs');
45
+ const rules = require('./rules.cjs');
46
+ const envelope = require('./envelope.cjs');
47
+
48
+ let _engine = null;
49
+
50
+ /**
51
+ * The process-wide engine.
52
+ *
53
+ * Alerts are routed into the ledger as `policy_alert` events, which is the point of having them:
54
+ * an integrity failure that only exists as a counter is a failure nobody is looking at. The alert
55
+ * path is guarded and never re-enters the engine, because an alert raised while deciding must not
56
+ * be able to make deciding fail.
57
+ */
58
+ function engine() {
59
+ if (_engine === null) {
60
+ _engine = new Engine({
61
+ settings: settingsFromEnv(),
62
+ onAlert: (kind, message, extra) => emitAlert(kind, message, extra),
63
+ });
64
+ }
65
+ return _engine;
66
+ }
67
+
68
+ function settingsFromEnv() {
69
+ const flag = (name, dflt) => {
70
+ const v = process.env[name];
71
+ if (v === undefined || String(v).trim() === '') return dflt;
72
+ return !['0', 'false', 'no', 'off'].includes(String(v).trim().toLowerCase());
73
+ };
74
+ const num = (name, dflt) => {
75
+ const v = parseFloat(process.env[name]);
76
+ return Number.isFinite(v) && v > 0 ? v : dflt;
77
+ };
78
+ return {
79
+ enforcementEnabled: flag('NEXUS_POLICY_ENABLED', true),
80
+ failClosed: flag('NEXUS_POLICY_FAIL_CLOSED', false),
81
+ decisionBudgetMs: num('NEXUS_POLICY_BUDGET_MS', undefined),
82
+ pubkeyHex: (process.env.NEXUS_POLICY_PUBKEY || '').trim() || null,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * In-process alert state, deduplicated by kind.
88
+ *
89
+ * A ring rather than a stream, because the alerts that matter are the ones that keep happening: a
90
+ * fleet whose policy will not verify raises `bad_signature` on every install attempt, and a
91
+ * thousand identical events answer no question a `count` does not answer better. Bounded, because
92
+ * the kinds are a closed set but a bug that invented one must not become unbounded memory.
93
+ */
94
+ const _alerts = new Map();
95
+ const MAX_ALERTS = 64;
96
+
97
+ /**
98
+ * Emit one `policy_alert`, deduplicated.
99
+ *
100
+ * **Only the fields `contract/events.v1.json` declares for this type**: `kind`, `detail`, `count`,
101
+ * `first_seen`, `last_seen`. An earlier draft of this function invented `alert`, `reason`,
102
+ * `rule_id`, `subject_kind` and `detected_by` — all of which the schema would have accepted, since
103
+ * `additionalProperties` is true, and none of which any reader could validate. That is the same
104
+ * drift as `data_expectation`, one level down: a producer writing fields the ledger has no model
105
+ * for. The rule-level detail rides inside `detail`, where a human can read it, rather than in
106
+ * invented columns nothing will ever query.
107
+ *
108
+ * `behavior_trace`, matching Python: the class answers *how we know this record exists*, and the
109
+ * alert being raised is something this process observed itself doing.
110
+ *
111
+ * The event pipeline is the **least** important destination for an alert. If reporting a broken
112
+ * policy required a working collector, the case where both are broken — the case that matters —
113
+ * would be the silent one. So this is best-effort and wrapped whole; the counters are the
114
+ * destination that cannot fail.
115
+ */
116
+ function emitAlert(kind, message, extra) {
117
+ core.guard('policy.alert', () => {
118
+ const nowS = Date.now() / 1000;
119
+ let entry = _alerts.get(kind);
120
+ if (entry === undefined) {
121
+ if (_alerts.size >= MAX_ALERTS) {
122
+ // A full ring means something is very wrong; drop the oldest by first_seen so the newest
123
+ // signal is the one that survives.
124
+ let oldestKey = null;
125
+ let oldest = Infinity;
126
+ for (const [k, v] of _alerts) if (v.firstSeen < oldest) { oldest = v.firstSeen; oldestKey = k; }
127
+ if (oldestKey !== null) _alerts.delete(oldestKey);
128
+ }
129
+ entry = { count: 0, firstSeen: nowS, lastSeen: nowS };
130
+ _alerts.set(kind, entry);
131
+ }
132
+ entry.count += 1;
133
+ entry.lastSeen = nowS;
134
+
135
+ const client = core.ensureClient();
136
+ if (!client) return;
137
+
138
+ // The detail is authored by this SDK, never by a customer or a model, so it is a label rather
139
+ // than content and does not go through the tier ladder. Bounded all the same.
140
+ let detail = String(message || '');
141
+ if (extra && extra.rule_id) detail += ' [rule ' + String(extra.rule_id) + ']';
142
+ if (extra && extra.subject_kind) detail += ' [kind ' + String(extra.subject_kind) + ']';
143
+
144
+ client.emit(core.base('policy_alert', client.sessionId, client.cfg, 'behavior_trace', {
145
+ kind: String(kind).slice(0, 64),
146
+ detail: detail.slice(0, 256),
147
+ count: entry.count,
148
+ first_seen: new Date(entry.firstSeen * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
149
+ last_seen: new Date(entry.lastSeen * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
150
+ }));
151
+ });
152
+ }
153
+
154
+ /** Every alert this process has raised, with its count and window. */
155
+ function alerts() {
156
+ return core.guard('policy.alerts', () => Array.from(_alerts.entries())
157
+ .map(([kind, a]) => ({ kind, count: a.count, firstSeen: a.firstSeen, lastSeen: a.lastSeen })), []);
158
+ }
159
+
160
+ /**
161
+ * Verify and install a signed policy envelope.
162
+ *
163
+ * Failure is *no policy*, which allows — never a denial. A control plane that pushed a bad envelope
164
+ * must not take a customer's service down as the punishment.
165
+ */
166
+ function install(raw, pubkeyHex) {
167
+ return core.guard('policy.install', () => engine().install(raw, pubkeyHex),
168
+ { installed: false, problem: 'install raised', state: null, rules: 0, quarantined: [] });
169
+ }
170
+
171
+ /** Adjust settings at runtime. Returns the effective settings. */
172
+ function configure(patch) {
173
+ return core.guard('policy.configure', () => engine().configure(patch), null);
174
+ }
175
+
176
+ /** Evaluate a subject. **Never throws.** */
177
+ function decide(kind, subject, opts) {
178
+ // The fallback is a real allow-shaped decision, not null: every caller reads `.denied`, and a
179
+ // null here would turn a contained guard failure into a TypeError one frame later — which is the
180
+ // exact shape `guard` exists to prevent.
181
+ return core.guard('policy.decide', () => engine().decide(kind, subject, opts),
182
+ { action: ALLOW, enforced: false, denied: false, ruleId: null, reason: '',
183
+ source: 'error', stale: false, timedOut: false, latencyMs: 0 });
184
+ }
185
+
186
+ /** Decide, and throw `Denied` if an enforcing rule refuses. */
187
+ function check(kind, subject, opts) {
188
+ const d = decide(kind, subject, opts);
189
+ if (d && d.denied) throw new Denied(d);
190
+ return d;
191
+ }
192
+
193
+ /** Guard a function: decides **first**, then calls it. Throws `Denied` instead of running it. */
194
+ function gate(kind, subject, fn, opts) {
195
+ const d = check(kind, subject, opts);
196
+ return fn(d);
197
+ }
198
+
199
+ /** What the engine has done: evaluations, denials, budget overruns, alerts by kind. */
200
+ function counters() {
201
+ return core.guard('policy.counters', () => Object.assign({}, engine().counters), {});
202
+ }
203
+
204
+ /** Forget the installed policy and every counter. Chiefly for tests. */
205
+ function reset() {
206
+ if (_engine !== null) _engine.reset();
207
+ _engine = null;
208
+ _alerts.clear();
209
+ }
210
+
211
+ // ── Arm the one gated call site ──────────────────────────────────────────────────────────────
212
+ //
213
+ // Importing this module is what gates `run.action()`. That is deliberate and it is the whole
214
+ // opt-in: a customer who never imports `@swfte/nexus-sdk/policy` has no policy code on their
215
+ // request path at all, and one who does has said, in an import, that they want their actions
216
+ // subject to a signed policy.
217
+ //
218
+ // `check` rather than `decide`: the gate has to be able to throw, or it is not a gate.
219
+ core.setGateHook((subject) => check(subject.kind, subject));
220
+
221
+ module.exports = {
222
+ install, configure, decide, check, gate, counters, alerts, reset, engine,
223
+ Denied, ALERTS, ALLOW, DENY, rules, envelope,
224
+ };
@@ -0,0 +1,442 @@
1
+ 'use strict';
2
+ /**
3
+ * Rules: the shape, the matcher, and the three asymmetries that make the matcher safe.
4
+ *
5
+ * A port of `nexus/policy/rules.py`. **Unshipped** — see `test/policy-envelope.test.mjs` and
6
+ * PARITY.md §6. Nothing in this SDK is gated by these rules yet; they exist so the engine can be
7
+ * built on top of a matcher that is already correct.
8
+ *
9
+ * ── Asymmetry one: `enforce` is a property of the rule, not of the caller ────────────────────
10
+ *
11
+ * A rule denies for real only if it carries `"enforce": true`. Everything else evaluates, records,
12
+ * and lets the call through. One flag rather than two, because a mode that could be inferred from
13
+ * context is a mode somebody will infer wrongly at 3am. A call site cannot promote an unmarked rule
14
+ * to enforcing; it can only decline to enforce a marked one.
15
+ *
16
+ * ── Asymmetry two: unknown fields ────────────────────────────────────────────────────────────
17
+ *
18
+ * **An unknown field never satisfies an `allow` clause, but does not save a subject from a `deny`
19
+ * clause.** `branch_not: ["main"]` against a subject with no branch must not read as *"well, it
20
+ * isn't main"*. That step, from "I don't know" to "allow", is the one this package exists to
21
+ * refuse. The mirror holds too: refusing under uncertainty is never the unsafe direction, so a
22
+ * `deny` rule whose clause references a missing field still denies. One predicate, two polarities,
23
+ * decided by the rule's own action.
24
+ *
25
+ * ── Asymmetry three: normalisation and glob strictness follow the same polarity ──────────────
26
+ *
27
+ * An exactly case-sensitive `fnmatch` matcher gives an attacker two free evasions:
28
+ * `target_contains: ["prod"]` misses `PROD`, and because `*` crosses every character including
29
+ * `/` and `?`, the *allow* rule `target_glob: "https://*.safe.com/*"` matches
30
+ * `https://evil.com/a?z=.safe.com/b`. Both are fixed, and each fix moves only in the direction
31
+ * that is safe for its rule's own polarity:
32
+ *
33
+ * - For `deny` / `require_approval`, both sides are NFKC-normalised and case-folded. That can only
34
+ * make a refusing rule match *more*.
35
+ * - For `allow`, both sides stay case-sensitive *and* `*`/`?` stop crossing the structural
36
+ * delimiters `/ ? # @`. Both changes can only make a permitting rule match *less*.
37
+ *
38
+ * They are opposite rather than uniform because "casefold everything" would widen allow rules, and
39
+ * a widened allow is an authorisation bug. `branch_not` inverts again, because it is a negative
40
+ * clause, and it is handled where it lives.
41
+ *
42
+ * ── Quarantine ──────────────────────────────────────────────────────────────────────────────
43
+ *
44
+ * Malformed rules are quarantined **individually**, never fatal to the envelope. Rejecting a whole
45
+ * rule set over one typo means a single bad character in a control-plane deploy disarms every
46
+ * control at once — or, if we failed closed on it, denies everything at once. The cost is honest
47
+ * and worth naming: a malformed `enforce` rule is an enforcement gap, so `quarantined` is
48
+ * *reported* rather than merely counted.
49
+ *
50
+ * Glob clauses are `fnmatch`, never regex. There is no alternation: `"git (status|diff)*"` matches
51
+ * that literal text and nothing else. The list form is how you spell alternation, which is why the
52
+ * matcher needs no pattern language of its own.
53
+ */
54
+
55
+ const ALLOW = 'allow';
56
+ const DENY = 'deny';
57
+ const REQUIRE_APPROVAL = 'require_approval';
58
+ const ACTIONS = [ALLOW, DENY, REQUIRE_APPROVAL];
59
+
60
+ const RISK = { low: 0, medium: 1, high: 2 };
61
+
62
+ const CLAUSES = new Set(['kind', 'tool', 'tool_glob', 'target', 'target_glob', 'target_contains',
63
+ 'command_glob', 'repos', 'branch_not', 'max_risk', 'field_equals']);
64
+
65
+ /** Distinct from `undefined`, because a subject may legitimately carry `undefined`. */
66
+ const MISSING = Symbol('missing');
67
+ const UNCACHED = Symbol('uncached');
68
+
69
+ /**
70
+ * Structural delimiters an `allow` rule's `*` may not cross. Chosen as the characters that change
71
+ * what a URL or path *means*: authority (`@`), path (`/`), query (`?`) and fragment (`#`).
72
+ *
73
+ * Space is deliberately not one — `command_glob: "git status*"` must keep matching
74
+ * `git status -s`, and a space does not reinterpret the rest of the string.
75
+ */
76
+ const SEPARATORS = '/?#@';
77
+ const SEP_SPLIT = /([/?#@])/;
78
+
79
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
80
+ // glob
81
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * `fnmatch.fnmatchcase`, which JavaScript has no equivalent of.
85
+ *
86
+ * Translated to a regular expression rather than hand-matched, because the translation is small and
87
+ * total: `*` is any run, `?` is one character, `[...]` is a class (with `!` as its negation, which
88
+ * is fnmatch's spelling of `^`), and every other character is a literal. Written out because
89
+ * reaching for a glob library here would put a third-party pattern engine inside a security
90
+ * control — the one place a transitive dependency is least welcome.
91
+ */
92
+ function globToRegExp(pattern) {
93
+ let out = '';
94
+ let i = 0;
95
+ while (i < pattern.length) {
96
+ const c = pattern[i];
97
+ if (c === '*') { out += '[\\s\\S]*'; i += 1; continue; }
98
+ if (c === '?') { out += '[\\s\\S]'; i += 1; continue; }
99
+ if (c === '[') {
100
+ const close = pattern.indexOf(']', i + (pattern[i + 1] === '!' || pattern[i + 1] === ']' ? 2 : 1));
101
+ if (close === -1) { out += '\\['; i += 1; continue; } // unterminated class is a literal
102
+ let body = pattern.slice(i + 1, close);
103
+ if (body[0] === '!') body = '^' + body.slice(1);
104
+ // Escape a backslash inside the class so it cannot introduce an escape of its own.
105
+ out += '[' + body.replace(/\\/g, '\\\\') + ']';
106
+ i = close + 1;
107
+ continue;
108
+ }
109
+ out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+ i += 1;
111
+ }
112
+ return new RegExp('^' + out + '$', 's');
113
+ }
114
+
115
+ const globCache = new Map();
116
+ function fnmatchcase(text, pattern) {
117
+ let re = globCache.get(pattern);
118
+ if (re === undefined) {
119
+ re = globToRegExp(pattern);
120
+ // Bounded, because patterns come from a signed envelope but the cache still must not grow
121
+ // without limit across many installs in one process.
122
+ if (globCache.size > 4096) globCache.clear();
123
+ globCache.set(pattern, re);
124
+ }
125
+ return re.test(text);
126
+ }
127
+
128
+ /**
129
+ * `fnmatch`, except `*` and `?` do not cross `/`, `?`, `#` or `@`.
130
+ *
131
+ * Implemented by splitting both sides on the delimiters and matching pairwise — the same thing as
132
+ * compiling a stricter wildcard, without hand-writing a glob-to-regex translator with different
133
+ * semantics inside a security control. A differing number of pieces is an immediate no, which is
134
+ * what stops `https://*.safe.com/*` matching `https://evil.com/a?z=.safe.com/b`: eleven pieces
135
+ * against seven.
136
+ *
137
+ * `**` anywhere in the pattern opts back into plain `fnmatch`. An author who wants a wildcard that
138
+ * crosses delimiters writes it in two characters and has said so on purpose, which is a different
139
+ * thing from getting one by accident.
140
+ */
141
+ function globStrict(text, pattern) {
142
+ if (pattern.includes('**')) return fnmatchcase(text, pattern);
143
+ const pat = pattern.split(SEP_SPLIT);
144
+ const txt = String(text).split(SEP_SPLIT);
145
+ if (txt.length !== pat.length) return false;
146
+ for (let i = 0; i < txt.length; i += 1) {
147
+ // `split` with a capturing group alternates piece, delimiter, piece, … so odd indices are
148
+ // delimiters and must be identical rather than matched.
149
+ if (i % 2 === 1) { if (txt[i] !== pat[i]) return false; } else if (!fnmatchcase(txt[i], pat[i])) return false;
150
+ }
151
+ return true;
152
+ }
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
155
+ // normalisation
156
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * NFKC-normalise and case-fold. Applied only where matching *more* is the safe direction.
160
+ *
161
+ * `toLowerCase()` rather than a true Unicode case-fold, which JavaScript does not expose. The
162
+ * difference is confined to a handful of characters (fi, ß, ς) and lands on the permissive side of
163
+ * a *refusing* rule — the same direction the whole asymmetry moves in — so it is a narrower
164
+ * approximation than it looks. Named rather than left as an implicit "close enough".
165
+ */
166
+ function fold(s) {
167
+ return String(s).normalize('NFKC').toLowerCase();
168
+ }
169
+
170
+ const foldCache = new Map();
171
+ function foldPattern(p) {
172
+ const key = String(p);
173
+ let hit = foldCache.get(key);
174
+ if (hit === undefined) {
175
+ hit = fold(key);
176
+ if (foldCache.size > 4096) foldCache.clear();
177
+ foldCache.set(key, hit);
178
+ }
179
+ return hit;
180
+ }
181
+
182
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
183
+ // parsing
184
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
185
+
186
+ const EMPTY = Object.freeze({ rules: [], quarantined: [] });
187
+
188
+ /** True when this rule can actually stop something. */
189
+ function enforcing(rule) {
190
+ return Boolean(rule.enforce) && (rule.action === DENY || rule.action === REQUIRE_APPROVAL);
191
+ }
192
+
193
+ function hasEnforcing(ruleset) {
194
+ return ruleset.rules.some(enforcing);
195
+ }
196
+
197
+ function parseOne(raw) {
198
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [null, 'rule is not an object'];
199
+
200
+ const id = raw.id;
201
+ if (typeof id !== 'string' || !id.trim()) return [null, 'rule has no id'];
202
+
203
+ const action = raw.action;
204
+ if (!ACTIONS.includes(action)) {
205
+ return [null, 'rule ' + JSON.stringify(id) + ' unknown action ' + JSON.stringify(action)];
206
+ }
207
+
208
+ const match = raw.match === undefined ? {} : raw.match;
209
+ if (!match || typeof match !== 'object' || Array.isArray(match)) {
210
+ return [null, 'rule ' + JSON.stringify(id) + ' non-object match'];
211
+ }
212
+ const unknown = Object.keys(match).filter((k) => !CLAUSES.has(k)).sort();
213
+ if (unknown.length) {
214
+ return [null, 'rule ' + JSON.stringify(id) + ' unknown clauses ' + JSON.stringify(unknown)];
215
+ }
216
+
217
+ const mr = match.max_risk;
218
+ if (mr !== undefined && mr !== null && !(mr in RISK)) {
219
+ return [null, 'rule ' + JSON.stringify(id) + ' unknown max_risk ' + JSON.stringify(mr)];
220
+ }
221
+ const fe = match.field_equals;
222
+ if (fe !== undefined && fe !== null && (typeof fe !== 'object' || Array.isArray(fe))) {
223
+ return [null, 'rule ' + JSON.stringify(id) + ' non-object field_equals'];
224
+ }
225
+
226
+ // `=== true` rather than truthy: `"enforce": "no"` is a string, and a string is truthy. An
227
+ // envelope that meant to disarm a rule and spelled it wrongly must not arm it instead.
228
+ const enforce = raw.enforce === true;
229
+
230
+ const approval = raw.approval === undefined || raw.approval === null ? {} : raw.approval;
231
+ if (typeof approval !== 'object' || Array.isArray(approval)) {
232
+ return [null, 'rule ' + JSON.stringify(id) + ' non-object approval block'];
233
+ }
234
+ const timeout = approval.timeout_s;
235
+ if (timeout !== undefined && timeout !== null
236
+ && (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0)) {
237
+ return [null, 'rule ' + JSON.stringify(id) + ' non-positive approval timeout'];
238
+ }
239
+ let onTimeout = approval.on_timeout;
240
+ if (onTimeout === undefined || onTimeout === null) {
241
+ // An approval gate that allows when nobody answers is not a gate; an *advisory* one that
242
+ // denied when nobody answered blocks calls the customer never asked us to block. So the
243
+ // default follows the marking rather than being a constant.
244
+ onTimeout = enforce ? DENY : ALLOW;
245
+ }
246
+ if (onTimeout !== ALLOW && onTimeout !== DENY) {
247
+ return [null, 'rule ' + JSON.stringify(id) + ' unknown on_timeout ' + JSON.stringify(onTimeout)];
248
+ }
249
+
250
+ return [Object.freeze({
251
+ id,
252
+ action,
253
+ enforce,
254
+ reason: typeof raw.reason === 'string' ? raw.reason : '',
255
+ match,
256
+ approvalTimeoutS: typeof timeout === 'number' ? timeout : null,
257
+ onTimeout,
258
+ }), null];
259
+ }
260
+
261
+ /** Build a rule set. Never throws; anything unusable becomes a quarantine entry. */
262
+ function parse(rawRules, maxRules) {
263
+ const cap = maxRules === undefined ? 1000 : maxRules;
264
+ if (!Array.isArray(rawRules)) return EMPTY;
265
+ const good = [];
266
+ const bad = [];
267
+ const slice = rawRules.slice(0, cap);
268
+ for (let i = 0; i < slice.length; i += 1) {
269
+ let rule = null;
270
+ let why = null;
271
+ try {
272
+ [rule, why] = parseOne(slice[i]);
273
+ } catch (err) {
274
+ rule = null;
275
+ why = (err && err.name ? err.name : 'Error') + ': ' + (err && err.message);
276
+ }
277
+ if (rule === null) bad.push([i, why || 'unusable']);
278
+ else good.push(rule);
279
+ }
280
+ return Object.freeze({ rules: good, quarantined: bad });
281
+ }
282
+
283
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
284
+ // matching
285
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
286
+
287
+ function get(subject, name) {
288
+ const v = subject === null || subject === undefined ? undefined : subject[name];
289
+ if (v === undefined || v === null || v === '') return MISSING;
290
+ return v;
291
+ }
292
+
293
+ function asList(v) {
294
+ return Array.isArray(v) ? v : [v];
295
+ }
296
+
297
+ /**
298
+ * A subject field as a comparison string, normalised unless the rule is an `allow`.
299
+ *
300
+ * The cache is keyed by field name and lives for one decision, so a 50-rule set that reads `target`
301
+ * in every rule normalises it once rather than fifty times. This is on the request path under a
302
+ * latency budget, so that matters.
303
+ */
304
+ function field(subject, name, allowing, folded) {
305
+ if (allowing) {
306
+ const v = get(subject, name);
307
+ return v === MISSING ? MISSING : String(v);
308
+ }
309
+ let hit = folded[name];
310
+ if (hit === undefined) hit = UNCACHED;
311
+ if (hit === UNCACHED) {
312
+ const v = get(subject, name);
313
+ hit = v === MISSING ? MISSING : fold(String(v));
314
+ folded[name] = hit;
315
+ }
316
+ return hit;
317
+ }
318
+
319
+ const pat = (x, allowing) => (allowing ? String(x) : foldPattern(String(x)));
320
+
321
+ function exact(got, want, allowing) {
322
+ if (got === MISSING) return !allowing;
323
+ return asList(want).some((x) => pat(x, allowing) === got);
324
+ }
325
+
326
+ function glob(got, want, allowing) {
327
+ if (got === MISSING) return !allowing;
328
+ if (allowing) return asList(want).some((p) => globStrict(got, String(p)));
329
+ return asList(want).some((p) => fnmatchcase(got, pat(p, false)));
330
+ }
331
+
332
+ function contains(got, want, allowing) {
333
+ if (got === MISSING) return !allowing;
334
+ return asList(want).some((p) => got.includes(pat(p, allowing)));
335
+ }
336
+
337
+ function clauseOk(clause, want, subject, allowing, folded) {
338
+ switch (clause) {
339
+ case 'kind': return exact(field(subject, 'kind', allowing, folded), want, allowing);
340
+ case 'tool': return exact(field(subject, 'tool', allowing, folded), want, allowing);
341
+ case 'target': return exact(field(subject, 'target', allowing, folded), want, allowing);
342
+ case 'tool_glob': return glob(field(subject, 'tool', allowing, folded), want, allowing);
343
+ case 'target_glob': return glob(field(subject, 'target', allowing, folded), want, allowing);
344
+ case 'command_glob': return glob(field(subject, 'command', allowing, folded), want, allowing);
345
+ case 'repos': return glob(field(subject, 'repo', allowing, folded), want, allowing);
346
+ case 'target_contains':
347
+ return contains(field(subject, 'target', allowing, folded), want, allowing);
348
+
349
+ case 'branch_not': {
350
+ // The polarity inverts here, because the clause is negative. `branch_not: ["main"]` in a
351
+ // *deny* rule exempts main, so folding would *widen the exemption* and weaken the deny; in an
352
+ // *allow* rule it restricts the allow, so folding narrows it. The safe direction is therefore
353
+ // the opposite of everywhere else: normalise for `allow`, not for `deny`.
354
+ const branch = field(subject, 'branch', !allowing, folded);
355
+ // A subject with no branch does not satisfy `branch_not`. "It isn't main because I couldn't
356
+ // tell" is the smuggling route.
357
+ if (branch === MISSING) return !allowing;
358
+ return !asList(want).some((x) => pat(x, !allowing) === branch);
359
+ }
360
+
361
+ case 'max_risk': {
362
+ const risk = field(subject, 'risk', allowing, folded);
363
+ if (risk === MISSING) return !allowing; // no risk stated == worst case
364
+ // An unknown risk is 99, i.e. worse than anything named. Folding only means `"HIGH"` is
365
+ // recognised as high, which makes a `deny` with `max_risk: "high"` fire where it previously
366
+ // fell through, and leaves an `allow` case-sensitive as above.
367
+ const got = RISK[risk] === undefined ? 99 : RISK[risk];
368
+ const limit = RISK[String(want)] === undefined ? -1 : RISK[String(want)];
369
+ return got <= limit;
370
+ }
371
+
372
+ case 'field_equals': {
373
+ for (const [name, expected] of Object.entries(want || {})) {
374
+ const got = field(subject, name, allowing, folded);
375
+ if (got === MISSING) {
376
+ if (allowing) return false;
377
+ continue;
378
+ }
379
+ if (!asList(expected).some((x) => pat(x, allowing) === got)) return false;
380
+ }
381
+ return true;
382
+ }
383
+
384
+ default: return false; // unreachable: parse rejects unknown clauses
385
+ }
386
+ }
387
+
388
+ /**
389
+ * True iff `rule` covers `subject`. Clauses are ANDed; a list matches if any entry does.
390
+ *
391
+ * `folded` is a per-decision scratch object so a subject value is NFKC-normalised once rather than
392
+ * once per rule. Passing nothing is correct and simply means no sharing.
393
+ */
394
+ function matches(rule, subject, folded) {
395
+ const allowing = rule.action === ALLOW;
396
+ const scratch = folded || {};
397
+ const m = rule.match;
398
+
399
+ if (!m || Object.keys(m).length === 0) {
400
+ // A rule with no clauses is unconditional. For a deny that is a legitimate thing to ship —
401
+ // `{action: 'deny', enforce: true}` is the kill switch, and the control plane is entitled to
402
+ // send it. For an *allow* it is almost always a mistake (a match block that failed to
403
+ // serialise, a template that rendered empty), and the mistake authorises everything. So
404
+ // unconditional means "everything" in the refusing direction only.
405
+ return rule.action !== ALLOW;
406
+ }
407
+
408
+ for (const [clause, want] of Object.entries(m)) {
409
+ if (!clauseOk(clause, want, subject, allowing, scratch)) return false;
410
+ }
411
+ return true;
412
+ }
413
+
414
+ /**
415
+ * First matching rule wins.
416
+ *
417
+ * `deadline` is polled between rules — cooperative rather than pre-emptive, because JavaScript
418
+ * cannot interrupt a running frame any more than Python can. The honest statement of the guarantee
419
+ * is that it is bounded per-rule, not per-decision.
420
+ *
421
+ * `onError` is called for a matcher that throws, which is a malformed rule that got past parsing.
422
+ * The rule is skipped and evaluation continues: one bad rule must not stop the rules after it from
423
+ * applying, which is the same argument as quarantining at parse time.
424
+ */
425
+ function firstMatch(ruleset, subject, deadline, onError) {
426
+ const folded = {};
427
+ for (const rule of ruleset.rules) {
428
+ if (deadline && deadline()) return null;
429
+ try {
430
+ if (matches(rule, subject, folded)) return rule;
431
+ } catch (err) {
432
+ if (onError) onError(rule, err);
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+
438
+ module.exports = {
439
+ ALLOW, DENY, REQUIRE_APPROVAL, ACTIONS, CLAUSES, RISK, EMPTY, SEPARATORS, MISSING,
440
+ parse, parseOne, matches, firstMatch, enforcing, hasEnforcing,
441
+ fold, foldPattern, globStrict, fnmatchcase, globToRegExp,
442
+ };