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.
- package/CHANGELOG.md +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- 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
|
+
}
|