knosky 0.6.3 → 0.7.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
@@ -0,0 +1,317 @@
1
+ // Operator authorization for trust-domain security-critical mutations (DEC-106/109).
2
+ // Local-only: operator tokens under .knosky/operators.json (SHA-256 hashes only).
3
+ // Dual-operator default: elevated policy / class elevation needs TWO distinct operators.
4
+ // Bootstrap never prints both raw tokens in one stdout payload.
5
+
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ renameSync,
12
+ chmodSync,
13
+ } from 'node:fs';
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const OPS_FILE = 'operators.json';
18
+ export const DEFAULT_SOLO_CLASSES = Object.freeze(['public', 'internal']);
19
+ export const ELEVATED = Object.freeze(['restricted', 'confidential']);
20
+
21
+ function atomicWriteSoft(path, obj) {
22
+ mkdirSync(dirname(path), { recursive: true });
23
+ const tmp = path + '.tmp';
24
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
25
+ renameSync(tmp, path);
26
+ }
27
+
28
+ export function operatorsPath(domainRoot) {
29
+ return join(domainRoot, OPS_FILE);
30
+ }
31
+
32
+ export function operatorTokenDir(domainRoot) {
33
+ return join(domainRoot, 'operator-tokens');
34
+ }
35
+
36
+ export function hashOperatorToken(token) {
37
+ return createHash('sha256').update(String(token), 'utf8').digest('hex');
38
+ }
39
+
40
+ export function fingerprintToken(token) {
41
+ return hashOperatorToken(token).slice(0, 12);
42
+ }
43
+
44
+ export function mintOperatorToken() {
45
+ return `opk_${randomBytes(24).toString('hex')}`;
46
+ }
47
+
48
+ /**
49
+ * @param {string} domainRoot
50
+ */
51
+ export function loadOperators(domainRoot) {
52
+ const path = operatorsPath(domainRoot);
53
+ if (!existsSync(path)) {
54
+ return { v: 1, operators: {}, bootstrap_complete: false, path };
55
+ }
56
+ try {
57
+ const doc = JSON.parse(readFileSync(path, 'utf8'));
58
+ return {
59
+ v: 1,
60
+ operators: doc.operators || {},
61
+ bootstrap_complete: !!doc.bootstrap_complete,
62
+ path,
63
+ };
64
+ } catch {
65
+ throw new Error(`corrupt operators file: ${path}`);
66
+ }
67
+ }
68
+
69
+ function saveOperators(domainRoot, doc) {
70
+ atomicWriteSoft(operatorsPath(domainRoot), {
71
+ v: 1,
72
+ operators: doc.operators || {},
73
+ bootstrap_complete: !!doc.bootstrap_complete,
74
+ });
75
+ }
76
+
77
+ export function operatorCount(domainRoot) {
78
+ const ops = loadOperators(domainRoot).operators || {};
79
+ return Object.values(ops).filter((o) => o && !o.revoked).length;
80
+ }
81
+
82
+ function addOperatorRecord(doc, operatorId) {
83
+ const token = mintOperatorToken();
84
+ const id = operatorId || `operator-${Object.keys(doc.operators).length + 1}`;
85
+ if (doc.operators[id] && !doc.operators[id].revoked) {
86
+ return { ok: false, reason: 'operator_id_exists' };
87
+ }
88
+ doc.operators[id] = {
89
+ id,
90
+ role: 'operator',
91
+ token_hash: hashOperatorToken(token),
92
+ created_at: new Date().toISOString(),
93
+ };
94
+ return { ok: true, operatorId: id, operatorToken: token, fingerprint: fingerprintToken(token) };
95
+ }
96
+
97
+ function writeTokenFile(domainRoot, operatorId, token) {
98
+ const dir = operatorTokenDir(domainRoot);
99
+ mkdirSync(dir, { recursive: true });
100
+ const path = join(dir, `${operatorId}.token`);
101
+ writeFileSync(path, token + '\n', { encoding: 'utf8', mode: 0o600 });
102
+ try {
103
+ chmodSync(path, 0o600);
104
+ } catch {
105
+ /* windows may ignore */
106
+ }
107
+ return path;
108
+ }
109
+
110
+ /**
111
+ * Bootstrap operators when none exist.
112
+ * ALWAYS creates two operators for a healthy domain.
113
+ * allowSingleOperator is rejected for production domains; only for multi-op fail,
114
+ * single is never enough for elevated class elevation later (quorum still needs 2).
115
+ *
116
+ * Token delivery (dual separation):
117
+ * - operator-a token printed once to stdout (reveal_a)
118
+ * - operator-b token written ONLY to a 0600 file; stdout gets path + fingerprint, not the raw second token
119
+ *
120
+ * @param {string} domainRoot
121
+ * @param {{ operatorId?: string, operatorId2?: string, allowSingleOperator?: boolean }} [opts]
122
+ */
123
+ export function bootstrapOperator(domainRoot, opts = {}) {
124
+ mkdirSync(domainRoot, { recursive: true });
125
+ const doc = loadOperators(domainRoot);
126
+ if (Object.values(doc.operators || {}).some((o) => o && !o.revoked)) {
127
+ return { ok: false, reason: 'operators_already_exist' };
128
+ }
129
+
130
+ // Dual is mandatory default. Single operator is an explicit escape hatch that
131
+ // can ONLY mint one operator record — and elevated policy still needs TWO
132
+ // distinct tokens (impossible with one → elevated remains blocked until second
133
+ // operator is added via addOperator).
134
+ const single = opts.allowSingleOperator === true;
135
+
136
+ const a = addOperatorRecord(doc, opts.operatorId || 'operator-a');
137
+ if (!a.ok) return a;
138
+
139
+ /** @type {any} */
140
+ const out = {
141
+ ok: true,
142
+ mode: single ? 'single' : 'dual',
143
+ operatorId: a.operatorId,
144
+ // Only ONE raw token on the wire for dual mode.
145
+ operatorToken: a.operatorToken,
146
+ fingerprint: a.fingerprint,
147
+ warning:
148
+ 'Store operator-a token offline. Dual mode embeds operator-b only on disk at tokenFileB (0600). ' +
149
+ 'Elevated policy changes require TWO distinct operator tokens. Losing all tokens locks admin actions.',
150
+ };
151
+
152
+ if (!single) {
153
+ const b = addOperatorRecord(doc, opts.operatorId2 || 'operator-b');
154
+ if (!b.ok) return b;
155
+ const tokenFileB = writeTokenFile(domainRoot, b.operatorId, b.operatorToken);
156
+ out.operatorId2 = b.operatorId;
157
+ out.fingerprint2 = b.fingerprint;
158
+ out.tokenFileB = tokenFileB;
159
+ // Do NOT set operatorToken2 in the bootstrap return used for stdout.
160
+ out.tokenB_delivery =
161
+ 'operator-b raw token written only to tokenFileB — not printed alongside operator-a';
162
+ } else {
163
+ out.warning +=
164
+ ' SINGLE-OPERATOR manual escape: elevated class elevation stays blocked until a second operator is added (addOperator).';
165
+ }
166
+
167
+ doc.bootstrap_complete = true;
168
+ saveOperators(domainRoot, doc);
169
+ return out;
170
+ }
171
+
172
+ /**
173
+ * Add a second/later operator — requires an existing different operator token.
174
+ */
175
+ export function addOperator(domainRoot, opts = {}) {
176
+ const caller = assertOperator(domainRoot, opts.callerOperatorToken);
177
+ if (!caller.ok) return { ok: false, reason: caller.reason };
178
+
179
+ const doc = loadOperators(domainRoot);
180
+ const added = addOperatorRecord(doc, opts.operatorId || `operator-${Date.now()}`);
181
+ if (!added.ok) return added;
182
+ saveOperators(domainRoot, doc);
183
+ const tokenFile = writeTokenFile(domainRoot, added.operatorId, added.operatorToken);
184
+ return {
185
+ ok: true,
186
+ operatorId: added.operatorId,
187
+ fingerprint: added.fingerprint,
188
+ tokenFile,
189
+ // Raw token returned only to caller over this API so CLI can choose delivery
190
+ operatorToken: added.operatorToken,
191
+ added_by: caller.operatorId,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * @returns {{ ok:true, operatorId:string } | { ok:false, reason:string }}
197
+ */
198
+ export function assertOperator(domainRoot, operatorToken) {
199
+ if (!operatorToken || typeof operatorToken !== 'string') {
200
+ return { ok: false, reason: 'missing_operator_token' };
201
+ }
202
+ const doc = loadOperators(domainRoot);
203
+ const h = hashOperatorToken(operatorToken);
204
+ for (const [id, rec] of Object.entries(doc.operators || {})) {
205
+ if (rec && rec.token_hash === h) {
206
+ if (rec.revoked) return { ok: false, reason: 'operator_revoked' };
207
+ return { ok: true, operatorId: id };
208
+ }
209
+ }
210
+ return { ok: false, reason: 'invalid_operator_token' };
211
+ }
212
+
213
+ /**
214
+ * Require TWO distinct valid operators (quorum for elevated / policy class elevation).
215
+ * @param {string} domainRoot
216
+ * @param {string} [tokenA]
217
+ * @param {string} [tokenB]
218
+ */
219
+ export function assertOperatorQuorum(domainRoot, tokenA, tokenB) {
220
+ const a = assertOperator(domainRoot, tokenA);
221
+ if (!a.ok) return { ok: false, reason: a.reason || 'operator_a_invalid', need: 2 };
222
+ const b = assertOperator(domainRoot, tokenB);
223
+ if (!b.ok) {
224
+ return {
225
+ ok: false,
226
+ reason: b.reason || 'operator_b_invalid',
227
+ need: 2,
228
+ next_action: 'Provide a second distinct operator token (--operator-token-2 / KC_OPERATOR_TOKEN_2)',
229
+ };
230
+ }
231
+ if (a.operatorId === b.operatorId) {
232
+ return {
233
+ ok: false,
234
+ reason: 'operator_tokens_not_distinct',
235
+ need: 2,
236
+ next_action: 'Quorum requires two different operators',
237
+ };
238
+ }
239
+ return { ok: true, operatorIds: [a.operatorId, b.operatorId] };
240
+ }
241
+
242
+ /**
243
+ * Revoke an operator. Caller must be a DIFFERENT active operator.
244
+ * Last operator cannot be revoked.
245
+ */
246
+ export function revokeOperator(domainRoot, opts = {}) {
247
+ const caller = assertOperator(domainRoot, opts.callerOperatorToken);
248
+ if (!caller.ok) return { ok: false, reason: caller.reason };
249
+
250
+ const targetId = opts.targetOperatorId;
251
+ if (!targetId || typeof targetId !== 'string') {
252
+ return { ok: false, reason: 'targetOperatorId_required' };
253
+ }
254
+ if (caller.operatorId === targetId) {
255
+ return {
256
+ ok: false,
257
+ reason: 'cannot_self_revoke_operator',
258
+ next_action: 'A second active operator must revoke this operator',
259
+ };
260
+ }
261
+
262
+ const doc = loadOperators(domainRoot);
263
+ const target = doc.operators[targetId];
264
+ if (!target || target.revoked) {
265
+ return { ok: false, reason: 'target_not_found_or_already_revoked' };
266
+ }
267
+
268
+ const active = Object.values(doc.operators).filter((o) => o && !o.revoked);
269
+ if (active.length <= 1) {
270
+ return { ok: false, reason: 'cannot_revoke_last_operator' };
271
+ }
272
+
273
+ target.revoked = true;
274
+ target.revoked_at = new Date().toISOString();
275
+ target.revoked_by = caller.operatorId;
276
+ doc.operators[targetId] = target;
277
+ saveOperators(domainRoot, doc);
278
+ return { ok: true, targetOperatorId: targetId, revoked_by: caller.operatorId };
279
+ }
280
+
281
+ export function sanitizeClasses(classes, { allowElevated = false } = {}) {
282
+ const raw = Array.isArray(classes) ? classes.map(String) : DEFAULT_SOLO_CLASSES.slice();
283
+ const out = [];
284
+ for (const c of raw) {
285
+ if (!c) continue;
286
+ if (ELEVATED.includes(c) && !allowElevated) continue;
287
+ if (['public', 'internal', 'restricted', 'confidential', 'blocked'].includes(c)) {
288
+ if (!out.includes(c)) out.push(c);
289
+ }
290
+ }
291
+ if (!out.length) return DEFAULT_SOLO_CLASSES.slice();
292
+ return out.filter((c) => c !== 'blocked');
293
+ }
294
+
295
+ /**
296
+ * @param {string} domainRoot
297
+ * @param {{ operatorToken?: string, action?: string, allowEmptyBootstrap?: boolean }} opts
298
+ */
299
+ export function authorizeMutation(domainRoot, opts = {}) {
300
+ const action = opts.action || 'mutate';
301
+ const auth = assertOperator(domainRoot, opts.operatorToken);
302
+ if (auth.ok) {
303
+ return { ok: true, mode: 'operator', operatorId: auth.operatorId, action };
304
+ }
305
+ if (opts.allowEmptyBootstrap && operatorCount(domainRoot) === 0 && action === 'bootstrap_context') {
306
+ return { ok: true, mode: 'bootstrap_pending', action };
307
+ }
308
+ return {
309
+ ok: false,
310
+ reason: auth.reason || 'operator_required',
311
+ action,
312
+ next_action:
313
+ operatorCount(domainRoot) === 0
314
+ ? 'Run: knosky agent-register --bootstrap-operator (dual operators; elevated needs both tokens)'
315
+ : 'Pass operatorToken / KC_OPERATOR_TOKEN for admin actions; elevated needs a second token too',
316
+ };
317
+ }
package/core/overlays.mjs CHANGED
@@ -1,45 +1,45 @@
1
- // Operational-overlay metadata ingest (D-155): file-level ONLY.
2
- // Reads existing local test/coverage artifacts under `root` — never executes tests.
3
- // Returns a map { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }.
4
- // Key: relpath is always forward-slash, relative to root, no leading './'.
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
-
8
- // Parse Istanbul coverage-summary.json. Returns partial overlay map.
9
- function readIstanbul(root) {
10
- const fp = path.join(root, 'coverage', 'coverage-summary.json');
11
- let raw;
12
- try { raw = fs.readFileSync(fp, 'utf8'); } catch { return {}; }
13
- let json;
14
- try { json = JSON.parse(raw); } catch { return {}; }
15
- const out = {};
16
- for (const [key, val] of Object.entries(json)) {
17
- if (key === 'total') continue; // skip the aggregate row
18
- if (!val || typeof val !== 'object') continue;
19
- const pct = val.lines?.pct;
20
- if (typeof pct !== 'number') continue;
21
- const rel = key.replace(/\\/g, '/').replace(/^\.\//, '');
22
- out[rel] = { coverage: Math.min(100, Math.max(0, pct)) };
23
- }
24
- return out;
25
- }
26
-
27
- // Merge source into dest (dest wins on conflict).
28
- function merge(dest, src) {
29
- for (const [k, v] of Object.entries(src)) {
30
- dest[k] = dest[k] ? { ...v, ...dest[k] } : v;
31
- }
32
- }
33
-
34
- /**
35
- * Read all recognised local test/coverage artifacts under `root` and return
36
- * a file-level overlay map. Never executes tests or modifies files.
37
- *
38
- * @param {string} root Absolute or relative path to the project root.
39
- * @returns {{ [relpath: string]: { coverage?: number, test?: 'pass'|'fail' } }}
40
- */
41
- export function readOverlays(root) {
42
- const out = {};
43
- merge(out, readIstanbul(root));
44
- return out;
45
- }
1
+ // Operational-overlay metadata ingest (D-155): file-level ONLY.
2
+ // Reads existing local test/coverage artifacts under `root` — never executes tests.
3
+ // Returns a map { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }.
4
+ // Key: relpath is always forward-slash, relative to root, no leading './'.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ // Parse Istanbul coverage-summary.json. Returns partial overlay map.
9
+ function readIstanbul(root) {
10
+ const fp = path.join(root, 'coverage', 'coverage-summary.json');
11
+ let raw;
12
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return {}; }
13
+ let json;
14
+ try { json = JSON.parse(raw); } catch { return {}; }
15
+ const out = {};
16
+ for (const [key, val] of Object.entries(json)) {
17
+ if (key === 'total') continue; // skip the aggregate row
18
+ if (!val || typeof val !== 'object') continue;
19
+ const pct = val.lines?.pct;
20
+ if (typeof pct !== 'number') continue;
21
+ const rel = key.replace(/\\/g, '/').replace(/^\.\//, '');
22
+ out[rel] = { coverage: Math.min(100, Math.max(0, pct)) };
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // Merge source into dest (dest wins on conflict).
28
+ function merge(dest, src) {
29
+ for (const [k, v] of Object.entries(src)) {
30
+ dest[k] = dest[k] ? { ...v, ...dest[k] } : v;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Read all recognised local test/coverage artifacts under `root` and return
36
+ * a file-level overlay map. Never executes tests or modifies files.
37
+ *
38
+ * @param {string} root Absolute or relative path to the project root.
39
+ * @returns {{ [relpath: string]: { coverage?: number, test?: 'pass'|'fail' } }}
40
+ */
41
+ export function readOverlays(root) {
42
+ const out = {};
43
+ merge(out, readIstanbul(root));
44
+ return out;
45
+ }
@@ -0,0 +1,142 @@
1
+ // KS2-F1-4 deny-overrides-allow lattice evaluator (SAT-507 / D-187).
2
+ // Hand-rolled: zero external dependencies, no egress.
3
+ //
4
+ // Lattice model — three decision values form a total order under
5
+ // deny-overrides-allow combination semantics:
6
+ //
7
+ // NOT_APPLICABLE < ALLOW < DENY
8
+ //
9
+ // DENY dominates: any DENY anywhere in a decision set makes the combined
10
+ // result DENY, regardless of how many ALLOW or NOT_APPLICABLE values accompany
11
+ // it. ALLOW dominates NOT_APPLICABLE: at least one ALLOW with no DENY → ALLOW.
12
+ // All NOT_APPLICABLE (or empty set) → NOT_APPLICABLE.
13
+ //
14
+ // Fail-closed: a rule that throws, or a non-function rule entry, is treated
15
+ // as DENY. Unknown return values are treated as NOT_APPLICABLE (neutral).
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Decision constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Lattice decision: access is explicitly denied. Dominates ALLOW. */
22
+ export const DENY = 'DENY';
23
+
24
+ /** Lattice decision: access is explicitly allowed. */
25
+ export const ALLOW = 'ALLOW';
26
+
27
+ /** Lattice decision: this rule has no opinion on the subject. */
28
+ export const NOT_APPLICABLE = 'NOT_APPLICABLE';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Internal helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ // Numeric weights implement the lattice order.
35
+ // DENY (2) beats ALLOW (1) beats NOT_APPLICABLE (0).
36
+ const WEIGHT = { [DENY]: 2, [ALLOW]: 1, [NOT_APPLICABLE]: 0 };
37
+
38
+ /**
39
+ * Return true when d is a recognised lattice constant.
40
+ * @param {unknown} d
41
+ * @returns {boolean}
42
+ */
43
+ function isKnown(d) {
44
+ return d === DENY || d === ALLOW || d === NOT_APPLICABLE;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // combine — join an array of decisions into one
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Combine an array of lattice decisions using deny-overrides-allow semantics.
53
+ *
54
+ * - Any DENY → DENY (short-circuits: remaining decisions are skipped).
55
+ * - No DENY, at least one ALLOW → ALLOW.
56
+ * - All NOT_APPLICABLE, or empty array → NOT_APPLICABLE.
57
+ * - Non-array, null, undefined → NOT_APPLICABLE.
58
+ * - Unrecognised (unknown) values are skipped — they are neutral.
59
+ *
60
+ * @param {unknown[]} decisions
61
+ * @returns {'DENY'|'ALLOW'|'NOT_APPLICABLE'}
62
+ */
63
+ export function combine(decisions) {
64
+ if (!Array.isArray(decisions) || decisions.length === 0) {
65
+ return NOT_APPLICABLE;
66
+ }
67
+
68
+ let best = NOT_APPLICABLE;
69
+
70
+ for (const d of decisions) {
71
+ if (!isKnown(d)) continue; // unknown values are neutral
72
+ if (WEIGHT[d] > WEIGHT[best]) {
73
+ best = d;
74
+ }
75
+ if (best === DENY) break; // DENY is the maximum — short-circuit
76
+ }
77
+
78
+ return best;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // evaluate — run rule functions against a subject, then combine
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Evaluate a policy (array of rule functions) against a subject.
87
+ *
88
+ * Each rule is called as `rule(subject)` and must return one of the three
89
+ * lattice constants. Rules are evaluated left-to-right.
90
+ *
91
+ * Fail-closed behaviour:
92
+ * - A rule that throws → treated as DENY.
93
+ * - A non-function rule entry → treated as DENY (structural error).
94
+ * - A rule returning an unknown value → treated as NOT_APPLICABLE (neutral).
95
+ *
96
+ * The individual decisions are combined with {@link combine}.
97
+ *
98
+ * @param {Array<(subject: unknown) => string>} rules
99
+ * @param {unknown} subject Passed verbatim to every rule.
100
+ * @returns {{ decision: string, reasons: string[] }}
101
+ * decision — combined lattice value (DENY | ALLOW | NOT_APPLICABLE).
102
+ * reasons — explanatory strings for errors or unknown-value situations.
103
+ */
104
+ export function evaluate(rules, subject) {
105
+ if (!Array.isArray(rules) || rules.length === 0) {
106
+ return { decision: NOT_APPLICABLE, reasons: [] };
107
+ }
108
+
109
+ const decisions = [];
110
+ const reasons = [];
111
+
112
+ for (let i = 0; i < rules.length; i++) {
113
+ const rule = rules[i];
114
+
115
+ if (typeof rule !== 'function') {
116
+ decisions.push(DENY);
117
+ reasons.push(`rule[${i}] is not a function — treated as DENY`);
118
+ continue;
119
+ }
120
+
121
+ let d;
122
+ try {
123
+ d = rule(subject);
124
+ } catch (err) {
125
+ // Fail-closed: a rule that throws becomes DENY.
126
+ decisions.push(DENY);
127
+ reasons.push(`rule[${i}] threw: ${err && err.message ? err.message : String(err)}`);
128
+ continue;
129
+ }
130
+
131
+ if (!isKnown(d)) {
132
+ // Unknown return value → neutral (NOT_APPLICABLE).
133
+ decisions.push(NOT_APPLICABLE);
134
+ reasons.push(`rule[${i}] returned unknown value ${JSON.stringify(d)} — treated as NOT_APPLICABLE`);
135
+ continue;
136
+ }
137
+
138
+ decisions.push(d);
139
+ }
140
+
141
+ return { decision: combine(decisions), reasons };
142
+ }