@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.
- package/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +414 -0
- package/ai.d.ts +84 -0
- package/index.d.ts +433 -0
- package/otel.d.ts +80 -0
- package/package.json +93 -0
- package/policy.d.ts +141 -0
- package/src/ai.cjs +334 -0
- package/src/ai.js +39 -0
- package/src/core.cjs +2411 -0
- package/src/health.cjs +172 -0
- package/src/index.cjs +53 -0
- package/src/index.js +151 -0
- package/src/otel/bridge.cjs +257 -0
- package/src/otel/classify.cjs +166 -0
- package/src/otel/index.cjs +84 -0
- package/src/otel/index.js +39 -0
- package/src/otel/semconv.cjs +650 -0
- package/src/policy/engine.cjs +368 -0
- package/src/policy/envelope.cjs +256 -0
- package/src/policy/index.cjs +224 -0
- package/src/policy/rules.cjs +442 -0
- package/src/pricing.cjs +188 -0
- package/src/provenance.cjs +304 -0
- package/src/redact.cjs +734 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The decision engine. A port of `nexus/policy/engine.py`'s `decide`.
|
|
4
|
+
*
|
|
5
|
+
* **Unshipped, and nothing in this SDK calls it.** `src/policy/` stays out of `files` and out of
|
|
6
|
+
* the guard allowlist; `gate()` is not yet wired into `run.action()`. The order is deliberate — the
|
|
7
|
+
* engine becomes real first, in its own right, and only then does anything become gated. A `gate()`
|
|
8
|
+
* that matches rules but cannot refuse is the same failure as the AI seam swallowing a throw.
|
|
9
|
+
*
|
|
10
|
+
* ── Failure semantics, in one paragraph, because this is the part that must be exactly right ──
|
|
11
|
+
*
|
|
12
|
+
* Policy that is **missing, unverifiable or unparseable is *no policy***: allow, and raise an
|
|
13
|
+
* integrity alert so the gap is visible rather than merely survived. Policy that is **verified but
|
|
14
|
+
* stale keeps enforcing its `enforce`-marked rules** — a cached deny never decays, or disconnecting
|
|
15
|
+
* from the control plane would be the documented bypass — while its unmarked rules degrade to
|
|
16
|
+
* advice. Evaluation is **capped by a latency budget; exceeding it allows** and records the
|
|
17
|
+
* timeout. A rule that requires a human is bounded by a mandatory per-rule timeout, and times out
|
|
18
|
+
* **to deny when the rule is enforce-marked and to allow when it is not**.
|
|
19
|
+
*
|
|
20
|
+
* **Denying because we could not reach something is never a default anywhere in this module.** The
|
|
21
|
+
* one exception is opt-in and says so in its own reason string: `failClosed` is a deployment that
|
|
22
|
+
* has asked, in so many words, for governance over availability.
|
|
23
|
+
*
|
|
24
|
+
* ── What raises ──────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* `Denied` is the single exception this SDK will ever put in a host stack trace, and the host opted
|
|
27
|
+
* into it twice: the rule carried `enforce: true` and the call site did not decline. Everything
|
|
28
|
+
* else is caught — a bug in policy evaluation degrades to allow plus an integrity alert, never to a
|
|
29
|
+
* 500 in a customer's service. `decide()` never throws at all; only `check()` and `gate()` do, and
|
|
30
|
+
* only on a real denial.
|
|
31
|
+
*
|
|
32
|
+
* ── The human-approval channel is NOT ported, and the gap is load-bearing ────────────────────
|
|
33
|
+
*
|
|
34
|
+
* Python parks the calling *thread* on `approval.request` while a human answers. Node has one event
|
|
35
|
+
* loop and cannot park it — a synchronous wait here would freeze the whole process, which is a
|
|
36
|
+
* worse outcome than any policy decision.
|
|
37
|
+
*
|
|
38
|
+
* So `require_approval` resolves immediately to the rule's own `onTimeout`, stamped
|
|
39
|
+
* `source: 'approval-unavailable'` so the record says which branch it took and why. That is the
|
|
40
|
+
* *same* value Python reaches when nobody answers in time, arrived at sooner: **deny** for an
|
|
41
|
+
* enforce-marked rule, **allow** for an advisory one. Fail-safe by construction rather than by
|
|
42
|
+
* luck. An asynchronous approval channel is a real piece of work and is named in PARITY.md rather
|
|
43
|
+
* than approximated here.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const rulesMod = require('./rules.cjs');
|
|
47
|
+
const envelopeMod = require('./envelope.cjs');
|
|
48
|
+
|
|
49
|
+
const ALLOW = 'allow';
|
|
50
|
+
const DENY = 'deny';
|
|
51
|
+
|
|
52
|
+
/** Floors and ceilings that no caller and no rule may cross. */
|
|
53
|
+
const MIN_DECISION_BUDGET_MS = 1.0;
|
|
54
|
+
const DEFAULT_DECISION_BUDGET_MS = 25.0;
|
|
55
|
+
const SOFT_STALE_S = 900; // 15 minutes
|
|
56
|
+
const HARD_STALE_S = 86400; // 24 hours
|
|
57
|
+
const EXPIRE_S = 604800; // 7 days
|
|
58
|
+
|
|
59
|
+
/** Alert kinds, spelled exactly as the Python module spells them. */
|
|
60
|
+
const ALERTS = Object.freeze({
|
|
61
|
+
NO_POLICY: 'policy.no_policy',
|
|
62
|
+
DISARMED: 'policy.disarmed',
|
|
63
|
+
BUDGET_EXCEEDED: 'policy.budget_exceeded',
|
|
64
|
+
MALFORMED_RULE: 'policy.malformed_rule',
|
|
65
|
+
INTERNAL_ERROR: 'policy.internal_error',
|
|
66
|
+
STALE: 'policy.stale',
|
|
67
|
+
NO_PUBKEY: 'policy.no_pubkey',
|
|
68
|
+
BAD_SIGNATURE: 'policy.bad_signature',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The exception a real denial produces. The only one this SDK puts in a host stack trace. */
|
|
72
|
+
class Denied extends Error {
|
|
73
|
+
constructor(decision) {
|
|
74
|
+
super(decision.reason || 'denied by policy');
|
|
75
|
+
this.name = 'Denied';
|
|
76
|
+
this.decision = decision;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function defaultSettings() {
|
|
81
|
+
return {
|
|
82
|
+
enforcementEnabled: true,
|
|
83
|
+
failClosed: false,
|
|
84
|
+
decisionBudgetMs: DEFAULT_DECISION_BUDGET_MS,
|
|
85
|
+
softStaleS: SOFT_STALE_S,
|
|
86
|
+
hardStaleS: HARD_STALE_S,
|
|
87
|
+
expireS: EXPIRE_S,
|
|
88
|
+
pubkeyHex: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A decision, with everything a reader needs to tell what happened and why. */
|
|
93
|
+
function decision(o) {
|
|
94
|
+
return {
|
|
95
|
+
action: o.action || ALLOW,
|
|
96
|
+
/** True only when this decision actually stopped something. */
|
|
97
|
+
enforced: Boolean(o.enforced),
|
|
98
|
+
get denied() { return this.action === DENY && this.enforced; },
|
|
99
|
+
ruleId: o.ruleId || null,
|
|
100
|
+
reason: o.reason || '',
|
|
101
|
+
/** Where the verdict came from: `policy`, `no-match`, `no-policy`, `stale-advisory`,
|
|
102
|
+
* `budget-exceeded`, `approval-unavailable`, `error`. */
|
|
103
|
+
source: o.source || 'no-policy',
|
|
104
|
+
stale: Boolean(o.stale),
|
|
105
|
+
timedOut: Boolean(o.timedOut),
|
|
106
|
+
latencyMs: o.latencyMs === undefined ? 0 : o.latencyMs,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ALLOW_NO_POLICY = { action: ALLOW, source: 'no-policy' };
|
|
111
|
+
|
|
112
|
+
class Engine {
|
|
113
|
+
/**
|
|
114
|
+
* @param {{onAlert?: (kind: string, message: string, extra?: object) => void,
|
|
115
|
+
* settings?: object, now?: () => number}} [opts]
|
|
116
|
+
*/
|
|
117
|
+
constructor(opts) {
|
|
118
|
+
const o = opts || {};
|
|
119
|
+
this.settings = Object.assign(defaultSettings(), o.settings || {});
|
|
120
|
+
this.onAlert = o.onAlert || null;
|
|
121
|
+
this.now = o.now || (() => Date.now());
|
|
122
|
+
this.clock = o.clock || (() => Number(process.hrtime.bigint() / 1000n) / 1000);
|
|
123
|
+
this.counters = Object.create(null);
|
|
124
|
+
this.snapshot = { present: false, ruleset: rulesMod.EMPTY, env: envelopeMod.ABSENT };
|
|
125
|
+
this._noPolicyAlerted = false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
incr(name, n) {
|
|
129
|
+
this.counters[name] = (this.counters[name] || 0) + (n === undefined ? 1 : n);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
alert(kind, message, extra) {
|
|
133
|
+
this.incr('alert.' + kind);
|
|
134
|
+
if (this.onAlert) {
|
|
135
|
+
try { this.onAlert(kind, message, extra || {}); } catch (_err) { this.incr('alert_failed'); }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
configure(patch) {
|
|
140
|
+
Object.assign(this.settings, patch || {});
|
|
141
|
+
return this.settings;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Verify and install an envelope. Returns `{ installed, problem, state, rules, quarantined }`.
|
|
146
|
+
*
|
|
147
|
+
* A failure to install is *no policy*, which allows — never a denial. A control plane that
|
|
148
|
+
* pushed a bad envelope must not take a customer's service down as the punishment.
|
|
149
|
+
*/
|
|
150
|
+
install(raw, pubkeyHex) {
|
|
151
|
+
const key = pubkeyHex === undefined ? this.settings.pubkeyHex : pubkeyHex;
|
|
152
|
+
const { payload, problem } = envelopeMod.verify(raw, key);
|
|
153
|
+
if (payload === null) {
|
|
154
|
+
this.incr('install_rejected');
|
|
155
|
+
this.alert(problem === envelopeMod.NO_KEY ? ALERTS.NO_PUBKEY : ALERTS.BAD_SIGNATURE, problem);
|
|
156
|
+
// The previous snapshot is deliberately left in place. An envelope that fails to verify is
|
|
157
|
+
// not evidence that the last good one has stopped being true, and discarding it would make a
|
|
158
|
+
// malformed control-plane push a way to disarm every control at once.
|
|
159
|
+
return { installed: false, problem, state: null, rules: 0, quarantined: [] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ruleset = rulesMod.parse(payload.rules);
|
|
163
|
+
if (ruleset.quarantined.length) {
|
|
164
|
+
// Reported, not merely counted: a quarantined `enforce` rule is an enforcement gap, and a
|
|
165
|
+
// count alone cannot tell an operator which control is missing.
|
|
166
|
+
this.alert(ALERTS.MALFORMED_RULE,
|
|
167
|
+
ruleset.quarantined.length + ' rule(s) quarantined',
|
|
168
|
+
{ quarantined: ruleset.quarantined });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const issued = envelopeMod.issuedAt(payload);
|
|
172
|
+
const state = envelopeMod.freshness(issued, this.now(),
|
|
173
|
+
this.settings.softStaleS, this.settings.hardStaleS, this.settings.expireS);
|
|
174
|
+
|
|
175
|
+
this.snapshot = {
|
|
176
|
+
present: true,
|
|
177
|
+
ruleset,
|
|
178
|
+
env: { payload, verified: true, state, problem: null, issuedAt: issued },
|
|
179
|
+
};
|
|
180
|
+
this._noPolicyAlerted = false;
|
|
181
|
+
this.incr('installed');
|
|
182
|
+
return {
|
|
183
|
+
installed: true, problem: null, state,
|
|
184
|
+
rules: ruleset.rules.length, quarantined: ruleset.quarantined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Forget the installed policy. After this, every decision is `no-policy`. */
|
|
189
|
+
reset() {
|
|
190
|
+
this.snapshot = { present: false, ruleset: rulesMod.EMPTY, env: envelopeMod.ABSENT };
|
|
191
|
+
this._noPolicyAlerted = false;
|
|
192
|
+
this.counters = Object.create(null);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Evaluate a subject against the installed policy. **Never throws.**
|
|
197
|
+
*
|
|
198
|
+
* @param {string} kind the subject kind, e.g. `tool_action`
|
|
199
|
+
* @param {object} subject the fields rules match on
|
|
200
|
+
* @param {{enforce?: boolean, budgetMs?: number}} [opts]
|
|
201
|
+
* `enforce: false` forces advise for this call site regardless of rule markings — the dry-run
|
|
202
|
+
* switch. `true` or omitted defers to the rule, which is the only thing that can make a denial
|
|
203
|
+
* real: a call site can decline to enforce, never promote.
|
|
204
|
+
*/
|
|
205
|
+
decide(kind, subject, opts) {
|
|
206
|
+
const t0 = this.clock();
|
|
207
|
+
try {
|
|
208
|
+
return this._decide(kind, subject, opts || {}, t0);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// A bug in policy degrades to allow plus an alert. The alternative — a policy bug becoming a
|
|
211
|
+
// 500 in a customer's service — is the failure that gets an SDK removed rather than patched.
|
|
212
|
+
this.incr('errors');
|
|
213
|
+
this.alert(ALERTS.INTERNAL_ERROR, (err && err.name) + ': ' + (err && err.message));
|
|
214
|
+
return decision({ ...ALLOW_NO_POLICY, source: 'error', latencyMs: this.clock() - t0 });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_decide(kind, subject, opts, t0) {
|
|
219
|
+
const s = this.settings;
|
|
220
|
+
this.incr('evaluated');
|
|
221
|
+
|
|
222
|
+
const subj = Object.assign({}, subject || {});
|
|
223
|
+
if (subj.kind === undefined) subj.kind = kind;
|
|
224
|
+
|
|
225
|
+
// A caller may lower the budget for its own latency reasons — a checkout handler and a batch
|
|
226
|
+
// job do not owe each other the same patience — but not below the floor the operator's own
|
|
227
|
+
// budget is held to. Unfloored, `budgetMs: 0` expires on the first check and every deny becomes
|
|
228
|
+
// `budget-exceeded` with no rule named: not a bypass, but an erasure shaped like infrastructure.
|
|
229
|
+
const requested = opts.budgetMs === undefined ? s.decisionBudgetMs : opts.budgetMs;
|
|
230
|
+
const budgetMs = Math.max(MIN_DECISION_BUDGET_MS, requested);
|
|
231
|
+
let expired = false;
|
|
232
|
+
const pastDeadline = () => {
|
|
233
|
+
if ((this.clock() - t0) > budgetMs) { expired = true; return true; }
|
|
234
|
+
return false;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const finish = (d) => decision({ ...d, latencyMs: this.clock() - t0 });
|
|
238
|
+
|
|
239
|
+
if (!this.snapshot.present) {
|
|
240
|
+
// The fail-open half. No envelope, or one that would not verify. Alerted once per process: a
|
|
241
|
+
// fleet with no policy configured must not emit one alert per request.
|
|
242
|
+
if (!this._noPolicyAlerted) {
|
|
243
|
+
this._noPolicyAlerted = true;
|
|
244
|
+
this.alert(ALERTS.NO_POLICY, this.snapshot.env.problem || 'no policy installed');
|
|
245
|
+
}
|
|
246
|
+
this.incr('no_policy');
|
|
247
|
+
if (s.failClosed && s.enforcementEnabled && opts.enforce !== false) {
|
|
248
|
+
// The other half of that argument, and it is opt-in. The customer asked for governance over
|
|
249
|
+
// availability in so many words, and the deny says so in its reason rather than leaving
|
|
250
|
+
// them to work out why their service started refusing. This covers only "we have no
|
|
251
|
+
// policy" — not the budget overrun below, and not the error path above.
|
|
252
|
+
this.incr('denied');
|
|
253
|
+
return finish({
|
|
254
|
+
action: DENY, source: 'no-policy', enforced: true,
|
|
255
|
+
reason: 'no verified policy is installed and this deployment is configured to fail closed',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return finish(ALLOW_NO_POLICY);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Re-classify freshness on every decision rather than at install: an envelope goes stale by the
|
|
262
|
+
// passage of time, not by an event, so a snapshot classified once would report `fresh` forever.
|
|
263
|
+
const state = envelopeMod.freshness(this.snapshot.env.issuedAt, this.now(),
|
|
264
|
+
s.softStaleS, s.hardStaleS, s.expireS);
|
|
265
|
+
const stale = state === envelopeMod.HARD_STALE || state === envelopeMod.EXPIRED;
|
|
266
|
+
|
|
267
|
+
const rule = rulesMod.firstMatch(this.snapshot.ruleset, subj, pastDeadline,
|
|
268
|
+
(r, err) => this.alert(ALERTS.MALFORMED_RULE, 'matcher raised for ' + r.id,
|
|
269
|
+
{ error: err && err.name }));
|
|
270
|
+
|
|
271
|
+
if (expired) {
|
|
272
|
+
// Abandon and allow. Recording the timeout is what keeps this from being a silent hole: a
|
|
273
|
+
// rule set that consistently blows the budget is a control-plane bug that shows up as a
|
|
274
|
+
// counter rather than as an unexplained gap in the enforcement record.
|
|
275
|
+
this.incr('budget_exceeded');
|
|
276
|
+
this.alert(ALERTS.BUDGET_EXCEEDED, 'decision abandoned after ' + Math.round(budgetMs) + 'ms',
|
|
277
|
+
{ subject_kind: String(kind) });
|
|
278
|
+
return finish({ ...ALLOW_NO_POLICY, source: 'budget-exceeded', timedOut: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (rule === null) return finish({ source: 'no-match', stale });
|
|
282
|
+
|
|
283
|
+
// Both flags must agree. The rule marking is necessary; the call site can only decline.
|
|
284
|
+
const mayEnforce = rulesMod.enforcing(rule) && s.enforcementEnabled && opts.enforce !== false;
|
|
285
|
+
|
|
286
|
+
if (stale && !rulesMod.enforcing(rule)) {
|
|
287
|
+
// A verified-but-stale envelope keeps advising, but an unmarked rule stops mattering. The
|
|
288
|
+
// marked ones do not decay — that is the branch below, and its absence here is the point.
|
|
289
|
+
this.alert(ALERTS.STALE, 'policy is ' + state + '; unmarked rules are advisory');
|
|
290
|
+
return finish({
|
|
291
|
+
action: rule.action === rulesMod.ALLOW ? ALLOW : DENY,
|
|
292
|
+
ruleId: rule.id, reason: rule.reason, source: 'stale-advisory', enforced: false, stale: true,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (rule.action === rulesMod.ALLOW) {
|
|
297
|
+
return finish({ action: ALLOW, ruleId: rule.id, reason: rule.reason, source: 'policy', stale });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (rule.action === rulesMod.DENY) {
|
|
301
|
+
if (mayEnforce) this.incr('denied');
|
|
302
|
+
else {
|
|
303
|
+
this.incr('advised_deny');
|
|
304
|
+
if (rulesMod.enforcing(rule) && !s.enforcementEnabled) {
|
|
305
|
+
// The moment disarming actually costs something. Raised here rather than at startup,
|
|
306
|
+
// because "enforcement is off" is only a finding once a rule that would have blocked
|
|
307
|
+
// something does not.
|
|
308
|
+
this.alert(ALERTS.DISARMED,
|
|
309
|
+
'enforcement disabled: an enforce-marked deny became advice');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return finish({
|
|
313
|
+
action: DENY, ruleId: rule.id, reason: rule.reason || 'denied by policy',
|
|
314
|
+
source: 'policy', enforced: mayEnforce, stale,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// require_approval, with no human channel to reach. See the module header: this resolves to the
|
|
319
|
+
// rule's own `onTimeout`, which is the same value Python reaches when nobody answers — deny for
|
|
320
|
+
// an enforce-marked rule, allow for an advisory one — arrived at sooner and stamped so the
|
|
321
|
+
// record says which branch it took.
|
|
322
|
+
if (rulesMod.enforcing(rule) && !s.enforcementEnabled) {
|
|
323
|
+
this.alert(ALERTS.DISARMED, 'enforcement disabled: an enforce-marked approval gate became advice');
|
|
324
|
+
}
|
|
325
|
+
this.incr('approval_unavailable');
|
|
326
|
+
const timedOutTo = rule.onTimeout === DENY ? DENY : ALLOW;
|
|
327
|
+
if (timedOutTo === DENY && mayEnforce) this.incr('denied');
|
|
328
|
+
return finish({
|
|
329
|
+
action: timedOutTo,
|
|
330
|
+
ruleId: rule.id,
|
|
331
|
+
reason: rule.reason || 'approval required and no approver channel is configured',
|
|
332
|
+
source: 'approval-unavailable',
|
|
333
|
+
enforced: timedOutTo === DENY && mayEnforce,
|
|
334
|
+
stale,
|
|
335
|
+
timedOut: true,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Decide, and throw `Denied` if an enforcing rule refuses. Returns the decision otherwise.
|
|
341
|
+
*
|
|
342
|
+
* The raising counterpart to `decide`. Use it where the effect is the next statement and there is
|
|
343
|
+
* no block to wrap.
|
|
344
|
+
*/
|
|
345
|
+
check(kind, subject, opts) {
|
|
346
|
+
const d = this.decide(kind, subject, opts);
|
|
347
|
+
if (d.denied) throw new Denied(d);
|
|
348
|
+
return d;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Guard a function. **Decides first, then calls it.**
|
|
353
|
+
*
|
|
354
|
+
* That order is the acceptance criterion for the whole subsystem: a denial raised after the write
|
|
355
|
+
* already went out is not enforcement, it is journalism. On a denial the body never executes and
|
|
356
|
+
* `Denied` propagates. On an allow the decision is passed to the body, so a caller in advise mode
|
|
357
|
+
* can see that policy *would* have refused — which is the whole value of a shadow deployment.
|
|
358
|
+
*/
|
|
359
|
+
gate(kind, subject, fn, opts) {
|
|
360
|
+
const d = this.check(kind, subject, opts);
|
|
361
|
+
return fn(d);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
ALLOW, DENY, ALERTS, Denied, Engine, decision, defaultSettings,
|
|
367
|
+
MIN_DECISION_BUDGET_MS, DEFAULT_DECISION_BUDGET_MS, SOFT_STALE_S, HARD_STALE_S, EXPIRE_S,
|
|
368
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Signed policy envelopes. A port of `nexus/policy/envelope.py`.
|
|
4
|
+
*
|
|
5
|
+
* A policy that anything on the machine can edit is not a control, so an envelope is Ed25519-signed
|
|
6
|
+
* by the control plane and verified here before a single rule is read. Node's `crypto` does Ed25519
|
|
7
|
+
* natively, so unlike the Python SDK — which vendors a pure-Python curve implementation to keep its
|
|
8
|
+
* core ring dependency-free — this needs nothing beyond a builtin.
|
|
9
|
+
*
|
|
10
|
+
* ── Freshness is four states, not two ────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* A verified envelope that has stopped being refreshed is not the same as no envelope, and
|
|
13
|
+
* collapsing the two is how disconnecting from the control plane becomes the documented bypass.
|
|
14
|
+
* `FRESH` enforces everything; `SOFT_STALE` and `HARD_STALE` keep enforcing rules marked
|
|
15
|
+
* `enforce` while unmarked rules degrade to advice; `EXPIRED` is treated as no policy at all.
|
|
16
|
+
* **A cached deny never decays.**
|
|
17
|
+
*
|
|
18
|
+
* ── The canonical form, and the trap in porting it ───────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Both sides must agree byte-for-byte or every signature fails, so the canonicalisation is
|
|
21
|
+
* deliberately the most boring available: sorted keys, no insignificant whitespace. Python spells
|
|
22
|
+
* that `json.dumps(payload, sort_keys=True, separators=(",", ":"))`.
|
|
23
|
+
*
|
|
24
|
+
* `JSON.stringify` is **not** that function, in two ways that both matter:
|
|
25
|
+
*
|
|
26
|
+
* 1. It does not sort keys. Insertion order is whatever the parser produced.
|
|
27
|
+
* 2. Python's `json.dumps` defaults to `ensure_ascii=True`, so `é` is signed as the six bytes
|
|
28
|
+
* `é` while `JSON.stringify` emits the two UTF-8 bytes of the character itself. Any policy
|
|
29
|
+
* containing a non-ASCII character — a reason string in French, a repo name with an umlaut —
|
|
30
|
+
* would verify in Python and fail here, with a "signature does not verify" that points at the
|
|
31
|
+
* key rather than at the encoder.
|
|
32
|
+
*
|
|
33
|
+
* Both are handled below, and `scripts/policy-parity.mjs` diffs the produced bytes against Python's
|
|
34
|
+
* over a corpus including non-ASCII, nesting and key-ordering cases.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
|
|
39
|
+
const FRESH = 'fresh';
|
|
40
|
+
const SOFT_STALE = 'soft-stale';
|
|
41
|
+
const HARD_STALE = 'hard-stale';
|
|
42
|
+
const EXPIRED = 'expired';
|
|
43
|
+
|
|
44
|
+
const NO_KEY = 'no policy public key is configured';
|
|
45
|
+
const NO_ISSUED_AT = 'envelope carries no signed issued_at';
|
|
46
|
+
const ROLLED_BACK = 'envelope is older than one already accepted';
|
|
47
|
+
const UNCANONICALISABLE = 'envelope contains a number this SDK cannot canonicalise byte-identically';
|
|
48
|
+
|
|
49
|
+
/** What an absent policy looks like. Not a null: a caller must be able to ask it questions. */
|
|
50
|
+
const ABSENT = Object.freeze({
|
|
51
|
+
payload: {}, verified: false, state: EXPIRED, problem: 'absent', issuedAt: null,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Escape non-ASCII exactly as Python's `ensure_ascii=True` does, including surrogate pairs.
|
|
56
|
+
*
|
|
57
|
+
* Python emits `\uXXXX` per UTF-16 code unit — so an astral character becomes a surrogate pair of
|
|
58
|
+
* two escapes, which is what `JSON.stringify` would have produced had it escaped at all. Iterating
|
|
59
|
+
* code *units* rather than code points is therefore correct rather than sloppy.
|
|
60
|
+
*/
|
|
61
|
+
function escapeNonAscii(s) {
|
|
62
|
+
let out = '';
|
|
63
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
64
|
+
const c = s.charCodeAt(i);
|
|
65
|
+
out += c > 0x7f ? '\\u' + c.toString(16).padStart(4, '0') : s[i];
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Numbers Python and JavaScript are guaranteed to render identically.
|
|
72
|
+
*
|
|
73
|
+
* **This is a real limit, and it is enforced rather than hoped for.** Python's float repr and
|
|
74
|
+
* `JSON.stringify` disagree in several ranges at once, measured rather than assumed:
|
|
75
|
+
*
|
|
76
|
+
* value Python JavaScript
|
|
77
|
+
* 30.0 30.0 30 (Python keeps a float's decimal point)
|
|
78
|
+
* 1e-7 1e-07 1e-7 (exponent padding)
|
|
79
|
+
* 1e-6 1e-06 0.000001 (different threshold for exponential form)
|
|
80
|
+
* 1e16 1e+16 10000000000000000
|
|
81
|
+
* -0.0 -0.0 0
|
|
82
|
+
*
|
|
83
|
+
* Worse, the first row is not fixable from this side: after `JSON.parse`, JavaScript has lost
|
|
84
|
+
* whether the signed text said `30` or `30.0`, and Python renders those differently. So a
|
|
85
|
+
* general-purpose emulator cannot be written here even in principle — it would need bytes that no
|
|
86
|
+
* longer exist by the time `verify` is called.
|
|
87
|
+
*
|
|
88
|
+
* Since this is the byte-for-byte input to a signature check, guessing is the one thing that must
|
|
89
|
+
* not happen: an encoder that is subtly wrong turns valid policies into "signature does not verify",
|
|
90
|
+
* which reads as a key-distribution problem and gets diagnosed as one for a long time.
|
|
91
|
+
*
|
|
92
|
+
* So a payload containing a number outside the safe-integer range — or any non-integer — is
|
|
93
|
+
* **refused with a specific problem string**, which the engine treats as *no policy*: allow, plus an
|
|
94
|
+
* integrity alert. Loud and diagnosable, rather than a mysterious signature failure. Integers are
|
|
95
|
+
* the realistic case for a policy payload anyway (`version`, `issued_at`, `approval_timeout_s`), and
|
|
96
|
+
* a control plane needing fractional seconds should sign milliseconds.
|
|
97
|
+
*/
|
|
98
|
+
function uncanonicalisableNumber(v) {
|
|
99
|
+
// Returns `{ value }` or `null`, never the bare number. An earlier draft returned the value and
|
|
100
|
+
// the recursion tested it for truthiness — which meant `-0`, the one number whose rendering
|
|
101
|
+
// differs between the two languages *and* is falsy, walked straight through the check written to
|
|
102
|
+
// catch it. Caught by the parity script asserting the refusal rather than by reading the code.
|
|
103
|
+
if (Array.isArray(v)) {
|
|
104
|
+
for (const i of v) { const bad = uncanonicalisableNumber(i); if (bad !== null) return bad; }
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (v && typeof v === 'object') {
|
|
108
|
+
for (const k of Object.keys(v)) {
|
|
109
|
+
const bad = uncanonicalisableNumber(v[k]);
|
|
110
|
+
if (bad !== null) return bad;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (typeof v === 'number') {
|
|
115
|
+
if (!Number.isInteger(v) || !Number.isSafeInteger(v) || Object.is(v, -0)) return { value: v };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Recursively order object keys by code point, as Python's `sort_keys=True` does. */
|
|
121
|
+
function sortKeys(v) {
|
|
122
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
123
|
+
if (v && typeof v === 'object') {
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
return v;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The exact bytes the control plane signed.
|
|
133
|
+
*
|
|
134
|
+
* Anything clever here is a future interoperability bug with a cryptographic failure mode, so this
|
|
135
|
+
* is as dull as it can be made.
|
|
136
|
+
*/
|
|
137
|
+
function canonical(payload) {
|
|
138
|
+
return Buffer.from(escapeNonAscii(JSON.stringify(sortKeys(payload))), 'latin1');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* True iff `keyHex` is a key at all.
|
|
143
|
+
*
|
|
144
|
+
* Absent, blank, wrong length, not hex, or all-zero are all *no key*. The all-zero case is the one
|
|
145
|
+
* that mattered upstream: it was once a shipped default on the theory that a nonsense key verifies
|
|
146
|
+
* nothing, and it verified 23.5% of forgeries because 32 zero bytes decompress to an order-4 point.
|
|
147
|
+
* This is also the *honest* check — a deployment that set a policy file and forgot the key should
|
|
148
|
+
* be told it has no key rather than be told its control plane's signature is bad.
|
|
149
|
+
*/
|
|
150
|
+
function keyIsConfigured(keyHex) {
|
|
151
|
+
const k = String(keyHex || '').trim();
|
|
152
|
+
if (!k || !/^[0-9a-fA-F]+$/.test(k)) return false;
|
|
153
|
+
const raw = Buffer.from(k, 'hex');
|
|
154
|
+
return raw.length === 32 && raw.some((b) => b !== 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The 12-byte SPKI prefix for an Ed25519 public key.
|
|
159
|
+
*
|
|
160
|
+
* Node's `crypto.createPublicKey` takes DER, not a raw 32-byte curve point, so a raw key has to be
|
|
161
|
+
* wrapped. The header is a constant: SEQUENCE, SEQUENCE, OID 1.3.101.112 (Ed25519), BIT STRING.
|
|
162
|
+
* Written as a literal rather than assembled, because a DER encoder for one fixed structure is more
|
|
163
|
+
* code and more risk than the twelve bytes it would produce.
|
|
164
|
+
*/
|
|
165
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
166
|
+
|
|
167
|
+
function publicKeyFromHex(keyHex) {
|
|
168
|
+
return crypto.createPublicKey({
|
|
169
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(keyHex, 'hex')]),
|
|
170
|
+
format: 'der',
|
|
171
|
+
type: 'spki',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Return `{ payload, problem }`. `payload` is non-null only for a valid signature.
|
|
177
|
+
*
|
|
178
|
+
* Every malformed input resolves to a problem string rather than an exception. Callers are all
|
|
179
|
+
* going to treat an exception as "do not trust this policy" anyway, and a verifier that can throw
|
|
180
|
+
* invites exactly one bare catch too many.
|
|
181
|
+
*/
|
|
182
|
+
function verify(raw, pubkeyHex) {
|
|
183
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
184
|
+
return { payload: null, problem: 'envelope is not an object' };
|
|
185
|
+
}
|
|
186
|
+
const payload = raw.policy;
|
|
187
|
+
const sigHex = raw.signature;
|
|
188
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
189
|
+
return { payload: null, problem: 'envelope has no policy object' };
|
|
190
|
+
}
|
|
191
|
+
if (typeof sigHex !== 'string' || !sigHex) {
|
|
192
|
+
return { payload: null, problem: 'envelope is unsigned' };
|
|
193
|
+
}
|
|
194
|
+
if (!keyIsConfigured(pubkeyHex)) return { payload: null, problem: NO_KEY };
|
|
195
|
+
// Before any curve arithmetic: if the payload cannot be canonicalised byte-identically with the
|
|
196
|
+
// signer, no verdict from this function would mean anything. Refusing here produces a specific,
|
|
197
|
+
// diagnosable problem instead of a 'signature does not verify' that points at the key.
|
|
198
|
+
const badNumber = uncanonicalisableNumber(payload);
|
|
199
|
+
if (badNumber !== null) {
|
|
200
|
+
return { payload: null, problem: UNCANONICALISABLE + ': ' + String(badNumber.value) };
|
|
201
|
+
}
|
|
202
|
+
if (!/^[0-9a-fA-F]+$/.test(sigHex)) {
|
|
203
|
+
return { payload: null, problem: 'signature or public key is not hex' };
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const ok = crypto.verify(null, canonical(payload),
|
|
207
|
+
publicKeyFromHex(String(pubkeyHex).trim()), Buffer.from(sigHex, 'hex'));
|
|
208
|
+
if (!ok) return { payload: null, problem: 'signature does not verify' };
|
|
209
|
+
} catch (_err) {
|
|
210
|
+
// A verifier that throws must still mean "do not trust".
|
|
211
|
+
return { payload: null, problem: 'verification raised' };
|
|
212
|
+
}
|
|
213
|
+
return { payload, problem: null };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The signed issue time, or `null` if the envelope does not state one.
|
|
218
|
+
*
|
|
219
|
+
* `issued_at` is the name; `iat` is accepted because JWT-shaped tooling spells it that way and one
|
|
220
|
+
* signer serves both attach points. Booleans are excluded explicitly — `issued_at: true` becoming
|
|
221
|
+
* `1` would be a 1970 timestamp that reads as maximally stale rather than as the malformed envelope
|
|
222
|
+
* it is. NaN, infinities and non-positive values are refused for the same reason: a freshness input
|
|
223
|
+
* we cannot compare is not a freshness input.
|
|
224
|
+
*/
|
|
225
|
+
function issuedAt(payload) {
|
|
226
|
+
for (const name of ['issued_at', 'iat']) {
|
|
227
|
+
const v = payload[name];
|
|
228
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue;
|
|
229
|
+
return v;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Classify freshness against the configured windows.
|
|
236
|
+
*
|
|
237
|
+
* The gap between soft and hard is not decoration. A soft-stale envelope is one the control plane
|
|
238
|
+
* has merely not refreshed recently; a hard-stale one has been out of contact long enough that an
|
|
239
|
+
* operator should be told. Neither stops an `enforce` rule working, because a cached deny that
|
|
240
|
+
* decayed would make disconnecting the bypass.
|
|
241
|
+
*/
|
|
242
|
+
function freshness(issued, now, softStaleS, hardStaleS, expireS) {
|
|
243
|
+
if (issued === null) return HARD_STALE; // signed but undated: trusted, never called fresh
|
|
244
|
+
const ageS = (now - issued * 1000) / 1000;
|
|
245
|
+
if (ageS <= softStaleS) return FRESH;
|
|
246
|
+
if (ageS <= hardStaleS) return SOFT_STALE;
|
|
247
|
+
if (expireS && ageS > expireS) return EXPIRED;
|
|
248
|
+
return HARD_STALE;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
FRESH, SOFT_STALE, HARD_STALE, EXPIRED, ABSENT,
|
|
253
|
+
NO_KEY, NO_ISSUED_AT, ROLLED_BACK, UNCANONICALISABLE,
|
|
254
|
+
canonical, keyIsConfigured, verify, issuedAt, freshness, uncanonicalisableNumber,
|
|
255
|
+
escapeNonAscii, sortKeys, publicKeyFromHex,
|
|
256
|
+
};
|