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,347 @@
|
|
|
1
|
+
// Local trust domain state for Mode B + early L3 identity (DEC-109/106).
|
|
2
|
+
// Loads/creates .knosky agents, leases, and optional policy rules.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
openSync,
|
|
11
|
+
fsyncSync,
|
|
12
|
+
closeSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
chmodSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { DENY, ALLOW, NOT_APPLICABLE } from './policy-lattice.mjs';
|
|
19
|
+
import { DEFAULT_CLASS, loadClass, CLASS_BLOCKED, CLASS_RESTRICTED, CLASS_CONFIDENTIAL } from './district-classification.mjs';
|
|
20
|
+
import {
|
|
21
|
+
assertOperator,
|
|
22
|
+
assertOperatorQuorum,
|
|
23
|
+
operatorCount,
|
|
24
|
+
sanitizeClasses,
|
|
25
|
+
ELEVATED,
|
|
26
|
+
} from './operator-auth.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve domain root (.knosky dir) from city path or explicit env.
|
|
30
|
+
* @param {string} [cityPath]
|
|
31
|
+
* @param {string} [explicitDomain]
|
|
32
|
+
*/
|
|
33
|
+
export function resolveDomainRoot(cityPath, explicitDomain) {
|
|
34
|
+
if (explicitDomain) return explicitDomain;
|
|
35
|
+
if (process.env.KC_DOMAIN) return process.env.KC_DOMAIN;
|
|
36
|
+
if (cityPath) {
|
|
37
|
+
// city often lives at <repo>/.knosky/city-data.json
|
|
38
|
+
const d = dirname(cityPath);
|
|
39
|
+
if (d.endsWith('.knosky') || d.replace(/\\/g, '/').endsWith('/.knosky')) return d;
|
|
40
|
+
return join(d, '.knosky');
|
|
41
|
+
}
|
|
42
|
+
return join(process.cwd(), '.knosky');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function atomicWrite(path, obj) {
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
47
|
+
const tmp = path + '.tmp';
|
|
48
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
49
|
+
try {
|
|
50
|
+
const fd = openSync(tmp, 'r');
|
|
51
|
+
try {
|
|
52
|
+
fsyncSync(fd);
|
|
53
|
+
} finally {
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
renameSync(tmp, path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readJson(path, fallback) {
|
|
63
|
+
if (!existsSync(path)) return fallback;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`corrupt domain file: ${path}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} domainRoot
|
|
73
|
+
*/
|
|
74
|
+
export function ensureDomainLayout(domainRoot) {
|
|
75
|
+
mkdirSync(domainRoot, { recursive: true });
|
|
76
|
+
// Best-effort owner-only domain dir (Unix). Windows ACLs are out of process scope;
|
|
77
|
+
// local disk write capability is the trust boundary for policy.json / leases.
|
|
78
|
+
try {
|
|
79
|
+
chmodSync(domainRoot, 0o700);
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore — platform may not support POSIX mode bits */
|
|
82
|
+
}
|
|
83
|
+
mkdirSync(join(domainRoot, 'audit'), { recursive: true });
|
|
84
|
+
const agentsPath = join(domainRoot, 'agents.json');
|
|
85
|
+
const leasesPath = join(domainRoot, 'leases.json');
|
|
86
|
+
const policyPath = join(domainRoot, 'policy.json');
|
|
87
|
+
|
|
88
|
+
if (!existsSync(agentsPath)) {
|
|
89
|
+
atomicWrite(agentsPath, {
|
|
90
|
+
v: 1,
|
|
91
|
+
agents: {
|
|
92
|
+
// bootstrapped solo agent for local Meta — encoded as optional
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (!existsSync(leasesPath)) {
|
|
97
|
+
atomicWrite(leasesPath, { v: 1, leases: {} });
|
|
98
|
+
}
|
|
99
|
+
if (!existsSync(policyPath)) {
|
|
100
|
+
// Default: Team-ish open for public/internal, deny restricted+ without allowlist
|
|
101
|
+
atomicWrite(policyPath, {
|
|
102
|
+
v: 1,
|
|
103
|
+
profile: 'team',
|
|
104
|
+
require_identity: true,
|
|
105
|
+
default_class_effect: {
|
|
106
|
+
public: 'ALLOW',
|
|
107
|
+
internal: 'ALLOW',
|
|
108
|
+
restricted: 'DENY',
|
|
109
|
+
confidential: 'DENY',
|
|
110
|
+
blocked: 'DENY',
|
|
111
|
+
},
|
|
112
|
+
// agentId -> allowed classes
|
|
113
|
+
agent_class_allow: {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return { agentsPath, leasesPath, policyPath };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load domain into memory structures.
|
|
121
|
+
* @param {string} domainRoot
|
|
122
|
+
*/
|
|
123
|
+
export function loadDomain(domainRoot) {
|
|
124
|
+
const paths = ensureDomainLayout(domainRoot);
|
|
125
|
+
const agentsDoc = readJson(paths.agentsPath, { v: 1, agents: {} });
|
|
126
|
+
const leasesDoc = readJson(paths.leasesPath, { v: 1, leases: {} });
|
|
127
|
+
const policy = readJson(paths.policyPath, { v: 1, require_identity: true });
|
|
128
|
+
|
|
129
|
+
/** @type {Map<string, any>} */
|
|
130
|
+
const leaseStore = new Map();
|
|
131
|
+
for (const [leaseId, rec] of Object.entries(leasesDoc.leases || {})) {
|
|
132
|
+
leaseStore.set(leaseId, { leaseId, ...rec });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
domainRoot,
|
|
137
|
+
paths,
|
|
138
|
+
agents: agentsDoc.agents || {},
|
|
139
|
+
leaseStore,
|
|
140
|
+
leasesDoc,
|
|
141
|
+
policy,
|
|
142
|
+
saveLeases() {
|
|
143
|
+
const leases = {};
|
|
144
|
+
for (const [id, rec] of leaseStore.entries()) {
|
|
145
|
+
const { leaseId, ...rest } = rec;
|
|
146
|
+
leases[id] = rest;
|
|
147
|
+
}
|
|
148
|
+
atomicWrite(paths.leasesPath, { v: 1, leases });
|
|
149
|
+
},
|
|
150
|
+
saveAgents() {
|
|
151
|
+
atomicWrite(paths.agentsPath, { v: 1, agents: this.agents });
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Register an agent and mint an active lease (local trust domain).
|
|
158
|
+
*
|
|
159
|
+
* Authorization (Rule 3 — security-critical policy mutation):
|
|
160
|
+
* - Elevated classes (restricted/confidential) require **two distinct operators**
|
|
161
|
+
* (operatorToken + operatorToken2). Single-op cannot unilaterally elevate.
|
|
162
|
+
* - Once any operator exists: plain public/internal join still needs **one**
|
|
163
|
+
* operator token (domain is secured).
|
|
164
|
+
* - Open first-run solo (no operators): only public/internal may self-register.
|
|
165
|
+
*
|
|
166
|
+
* @param {ReturnType<typeof loadDomain>} domain
|
|
167
|
+
* @param {{ agentId: string, role?: string, classes?: string[] }} agent
|
|
168
|
+
* @param {{ operatorToken?: string, operatorToken2?: string }} [opts]
|
|
169
|
+
* @returns {{ ok:true, agentId:string, leaseId:string }
|
|
170
|
+
* |{ ok:false, reason:string, next_action?:string }}
|
|
171
|
+
*/
|
|
172
|
+
export function registerAgentWithLease(domain, agent, opts = {}) {
|
|
173
|
+
const agentId = agent?.agentId;
|
|
174
|
+
if (!agentId || typeof agentId !== 'string') {
|
|
175
|
+
return { ok: false, reason: 'agentId_required' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const requested = Array.isArray(agent.classes) ? agent.classes.map(String) : ['public', 'internal'];
|
|
179
|
+
const elevatedWanted = requested.some((c) => ELEVATED.includes(c));
|
|
180
|
+
const secured = operatorCount(domain.domainRoot) > 0;
|
|
181
|
+
const token = opts.operatorToken || process.env.KC_OPERATOR_TOKEN;
|
|
182
|
+
const token2 = opts.operatorToken2 || process.env.KC_OPERATOR_TOKEN_2;
|
|
183
|
+
let allowElevated = false;
|
|
184
|
+
|
|
185
|
+
if (elevatedWanted) {
|
|
186
|
+
// Quorum of 2 distinct operators — single bootstrap token cannot elevate.
|
|
187
|
+
const q = assertOperatorQuorum(domain.domainRoot, token, token2);
|
|
188
|
+
if (!q.ok) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: q.reason || 'operator_quorum_required_for_elevated_classes',
|
|
192
|
+
next_action:
|
|
193
|
+
q.next_action ||
|
|
194
|
+
'Elevated classes require two distinct operator tokens (--operator-token and --operator-token-2)',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
allowElevated = true;
|
|
198
|
+
} else if (secured) {
|
|
199
|
+
const auth = assertOperator(domain.domainRoot, token);
|
|
200
|
+
if (!auth.ok) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: 'operator_required_domain_secured',
|
|
204
|
+
next_action:
|
|
205
|
+
auth.reason === 'missing_operator_token'
|
|
206
|
+
? 'Pass operatorToken / KC_OPERATOR_TOKEN (bootstrap via knosky agent-register --bootstrap-operator)'
|
|
207
|
+
: `Operator auth failed: ${auth.reason}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const classes = sanitizeClasses(requested, { allowElevated });
|
|
213
|
+
if (elevatedWanted && !allowElevated) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
reason: 'operator_quorum_required_for_elevated_classes',
|
|
217
|
+
next_action: 'Elevated district classes require two distinct operator tokens',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
domain.agents[agentId] = {
|
|
222
|
+
agentId,
|
|
223
|
+
role: agent.role || 'coder',
|
|
224
|
+
classes,
|
|
225
|
+
created_at: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
domain.saveAgents();
|
|
228
|
+
|
|
229
|
+
if (!domain.policy.agent_class_allow) domain.policy.agent_class_allow = {};
|
|
230
|
+
domain.policy.agent_class_allow[agentId] = classes;
|
|
231
|
+
try {
|
|
232
|
+
atomicWrite(domain.paths.policyPath, domain.policy);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { ok: false, reason: `policy_persist_failed: ${err.message || err}` };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const leaseId = `lease_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
|
|
238
|
+
const rec = {
|
|
239
|
+
leaseId,
|
|
240
|
+
agentId,
|
|
241
|
+
status: 'active',
|
|
242
|
+
created_at: new Date().toISOString(),
|
|
243
|
+
expires_at: null,
|
|
244
|
+
};
|
|
245
|
+
domain.leaseStore.set(leaseId, rec);
|
|
246
|
+
domain.saveLeases();
|
|
247
|
+
return { ok: true, agentId, leaseId };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Build lattice rule functions from domain policy for a subject.
|
|
252
|
+
* Subject: { agentId, node?, class?, tool }
|
|
253
|
+
*/
|
|
254
|
+
export function policyRulesFromDomain(policy) {
|
|
255
|
+
const requireId = policy.require_identity !== false;
|
|
256
|
+
const effects = policy.default_class_effect || {};
|
|
257
|
+
const allowMap = policy.agent_class_allow || {};
|
|
258
|
+
|
|
259
|
+
return [
|
|
260
|
+
// identity present?
|
|
261
|
+
(subject) => {
|
|
262
|
+
if (!requireId) return NOT_APPLICABLE;
|
|
263
|
+
if (!subject || !subject.agentId) return DENY;
|
|
264
|
+
return ALLOW;
|
|
265
|
+
},
|
|
266
|
+
// class effect — DENY is sticky for elevated classes; non-elevated allowlist may only grant
|
|
267
|
+
// classes that default is not DENY OR agent was listed AND class is public|internal.
|
|
268
|
+
(subject) => {
|
|
269
|
+
const cls = subject?.class || DEFAULT_CLASS;
|
|
270
|
+
const eff = effects[cls];
|
|
271
|
+
if (eff === 'DENY') {
|
|
272
|
+
// agent_class_allow must NOT override DENY for restricted/confidential without those classes
|
|
273
|
+
// already granted at registration time via quorum. At evaluation, listed classes can pass
|
|
274
|
+
// only if the allowlist includes that exact class (set only under quorum at register time).
|
|
275
|
+
const allowed = subject?.agentId ? allowMap[subject.agentId] : null;
|
|
276
|
+
if (Array.isArray(allowed) && allowed.includes(cls) && !ELEVATED.includes(cls)) {
|
|
277
|
+
return ALLOW;
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(allowed) && allowed.includes(cls) && ELEVATED.includes(cls)) {
|
|
280
|
+
// Elevated classes listed at register time (quorum-minted) may ALLOW
|
|
281
|
+
return ALLOW;
|
|
282
|
+
}
|
|
283
|
+
return DENY;
|
|
284
|
+
}
|
|
285
|
+
if (eff === 'ALLOW') return ALLOW;
|
|
286
|
+
return NOT_APPLICABLE;
|
|
287
|
+
},
|
|
288
|
+
// per-agent allowlist can expand public/internal when default is N/A
|
|
289
|
+
(subject) => {
|
|
290
|
+
if (!subject?.agentId) return NOT_APPLICABLE;
|
|
291
|
+
const allowed = allowMap[subject.agentId];
|
|
292
|
+
if (!Array.isArray(allowed)) return NOT_APPLICABLE;
|
|
293
|
+
const cls = subject.class || DEFAULT_CLASS;
|
|
294
|
+
if (allowed.includes(cls)) return ALLOW;
|
|
295
|
+
return NOT_APPLICABLE;
|
|
296
|
+
},
|
|
297
|
+
// blocked class always deny
|
|
298
|
+
(subject) => {
|
|
299
|
+
const cls = subject?.class || DEFAULT_CLASS;
|
|
300
|
+
if (cls === CLASS_BLOCKED) return DENY;
|
|
301
|
+
return NOT_APPLICABLE;
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Infer a representative class for a route payload (best/most open among hits).
|
|
308
|
+
* Unclassified nodes default to `internal` for Team profile routing (policy can still deny),
|
|
309
|
+
* so greenfield indexes without district_class metadata remain usable under Mode B.
|
|
310
|
+
*/
|
|
311
|
+
export function inferRouteClass(cityCtx, routeDoc) {
|
|
312
|
+
try {
|
|
313
|
+
const entries = [].concat(routeDoc?.route || [], routeDoc?.alternates || []);
|
|
314
|
+
if (!entries.length) return CLASS_INTERNAL_FALLBACK();
|
|
315
|
+
|
|
316
|
+
let best = CLASS_BLOCKED;
|
|
317
|
+
let saw = false;
|
|
318
|
+
for (const e of entries) {
|
|
319
|
+
const id = e.id || (e.path ? `fs:${e.path}` : null);
|
|
320
|
+
if (!id || !cityCtx?.byId) continue;
|
|
321
|
+
const node = typeof cityCtx.byId.get === 'function' ? cityCtx.byId.get(id) : cityCtx.byId[id];
|
|
322
|
+
if (!node) continue;
|
|
323
|
+
saw = true;
|
|
324
|
+
const cls = loadClass(node);
|
|
325
|
+
// prefer least restrictive for "representative open class" of the destination set
|
|
326
|
+
if (rank(cls) < rank(best)) best = cls;
|
|
327
|
+
}
|
|
328
|
+
if (!saw) return CLASS_INTERNAL_FALLBACK();
|
|
329
|
+
// If everything unresolved stays blocked, treat as internal for team usability
|
|
330
|
+
if (best === CLASS_BLOCKED) return CLASS_INTERNAL_FALLBACK();
|
|
331
|
+
return best;
|
|
332
|
+
} catch {
|
|
333
|
+
return CLASS_INTERNAL_FALLBACK();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function CLASS_INTERNAL_FALLBACK() {
|
|
338
|
+
return 'internal';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function rank(cls) {
|
|
342
|
+
const order = ['public', 'internal', 'restricted', 'confidential', 'blocked'];
|
|
343
|
+
const i = order.indexOf(cls);
|
|
344
|
+
return i < 0 ? 99 : i;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export { CLASS_RESTRICTED, CLASS_CONFIDENTIAL, DEFAULT_CLASS };
|
package/core/edges.mjs
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
// File-level import edges (D-155): file-to-file ONLY. Import specifiers are read transiently
|
|
2
|
-
// and discarded after resolution; nothing but the resolved file-to-file edge is ever stored.
|
|
3
|
-
// No symbol names, no raw import lines, no body text. Reviewer rule: only file paths survive.
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
|
|
7
|
-
const PATTERNS = [
|
|
8
|
-
/^\s*import\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: import x from 'y'
|
|
9
|
-
/^\s*import\s*['"]([^'"]+)['"]/, // js/ts: import 'y'
|
|
10
|
-
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/, // js: require('y')
|
|
11
|
-
/^\s*export\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: export ... from 'y'
|
|
12
|
-
/^\s*from\s+([.\w/]+)\s+import\b/, // python: from x import
|
|
13
|
-
/^\s*#include\s*["<]([^">]+)[">]/, // c/c++: #include "y"
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
// Read up to 8KB / 150 lines, scan for import specifiers, then drop the buffer. Returns specifier strings only.
|
|
17
|
-
export function extractImportSpecifiers(filePath) {
|
|
18
|
-
let txt = '';
|
|
19
|
-
try { const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(8192); const n = fs.readSync(fd, buf, 0, 8192, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
|
|
20
|
-
catch { return []; }
|
|
21
|
-
const lines = txt.split(/\r?\n/);
|
|
22
|
-
txt = '';
|
|
23
|
-
const specs = new Set();
|
|
24
|
-
for (let i = 0; i < lines.length && i < 150; i++) {
|
|
25
|
-
for (const re of PATTERNS) { const m = lines[i].match(re); if (m && m[1]) { specs.add(m[1]); break; } }
|
|
26
|
-
}
|
|
27
|
-
return [...specs];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.h', '.cpp', '.hpp', '.cs', '.kt', '.swift'];
|
|
31
|
-
|
|
32
|
-
// Resolve a relative specifier to an indexed repo file (rel path). Returns the target rel or null.
|
|
33
|
-
// Only relative/internal specifiers resolve; bare package names return null (external = no edge).
|
|
34
|
-
export function resolveSpec(fileRel, spec, relSet) {
|
|
35
|
-
if (!spec || !(spec.startsWith('.') || spec.startsWith('/'))) return null;
|
|
36
|
-
const baseDir = path.posix.dirname(fileRel);
|
|
37
|
-
const target = path.posix.normalize(path.posix.join(spec.startsWith('/') ? '.' : baseDir, spec)).replace(/^\.\//, '');
|
|
38
|
-
for (const e of EXTS) {
|
|
39
|
-
if (relSet.has(target + e)) return target + e;
|
|
40
|
-
const idx = path.posix.join(target, 'index' + (e || '.js'));
|
|
41
|
-
if (relSet.has(idx)) return idx;
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
1
|
+
// File-level import edges (D-155): file-to-file ONLY. Import specifiers are read transiently
|
|
2
|
+
// and discarded after resolution; nothing but the resolved file-to-file edge is ever stored.
|
|
3
|
+
// No symbol names, no raw import lines, no body text. Reviewer rule: only file paths survive.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const PATTERNS = [
|
|
8
|
+
/^\s*import\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: import x from 'y'
|
|
9
|
+
/^\s*import\s*['"]([^'"]+)['"]/, // js/ts: import 'y'
|
|
10
|
+
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/, // js: require('y')
|
|
11
|
+
/^\s*export\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: export ... from 'y'
|
|
12
|
+
/^\s*from\s+([.\w/]+)\s+import\b/, // python: from x import
|
|
13
|
+
/^\s*#include\s*["<]([^">]+)[">]/, // c/c++: #include "y"
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Read up to 8KB / 150 lines, scan for import specifiers, then drop the buffer. Returns specifier strings only.
|
|
17
|
+
export function extractImportSpecifiers(filePath) {
|
|
18
|
+
let txt = '';
|
|
19
|
+
try { const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(8192); const n = fs.readSync(fd, buf, 0, 8192, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
|
|
20
|
+
catch { return []; }
|
|
21
|
+
const lines = txt.split(/\r?\n/);
|
|
22
|
+
txt = '';
|
|
23
|
+
const specs = new Set();
|
|
24
|
+
for (let i = 0; i < lines.length && i < 150; i++) {
|
|
25
|
+
for (const re of PATTERNS) { const m = lines[i].match(re); if (m && m[1]) { specs.add(m[1]); break; } }
|
|
26
|
+
}
|
|
27
|
+
return [...specs];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.h', '.cpp', '.hpp', '.cs', '.kt', '.swift'];
|
|
31
|
+
|
|
32
|
+
// Resolve a relative specifier to an indexed repo file (rel path). Returns the target rel or null.
|
|
33
|
+
// Only relative/internal specifiers resolve; bare package names return null (external = no edge).
|
|
34
|
+
export function resolveSpec(fileRel, spec, relSet) {
|
|
35
|
+
if (!spec || !(spec.startsWith('.') || spec.startsWith('/'))) return null;
|
|
36
|
+
const baseDir = path.posix.dirname(fileRel);
|
|
37
|
+
const target = path.posix.normalize(path.posix.join(spec.startsWith('/') ? '.' : baseDir, spec)).replace(/^\.\//, '');
|
|
38
|
+
for (const e of EXTS) {
|
|
39
|
+
if (relSet.has(target + e)) return target + e;
|
|
40
|
+
const idx = path.posix.join(target, 'index' + (e || '.js'));
|
|
41
|
+
if (relSet.has(idx)) return idx;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
44
|
}
|
package/core/escalate.mjs
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
// KnoSky HITL escalation gate (D-166 / SAT-467) — any P0/critical or ambiguous
|
|
2
|
-
// review result must surface to Paul, never proceed silently.
|
|
3
|
-
// D-166 (SAT-466): zero-P0/critical + all reviewers succeeded → auto-proceed-to-publish.
|
|
4
|
-
// Pure function: no I/O, no external dependencies, safe to import anywhere.
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Decide whether a review result must be escalated to Paul, or may auto-publish.
|
|
8
|
-
*
|
|
9
|
-
* Rules (D-166 hitl gate, SAT-467 / SAT-466):
|
|
10
|
-
* - One or more CRITICAL findings → P0, escalate immediately.
|
|
11
|
-
* - All reviewers failed → ambiguous (no result at all), escalate.
|
|
12
|
-
* - Any reviewer failed (degraded) → ambiguous (result is incomplete), escalate.
|
|
13
|
-
* - Zero criticals + zero failures + at least one reviewer ran
|
|
14
|
-
* → canAutoPublish (D-166 pre-authorized path).
|
|
15
|
-
*
|
|
16
|
-
* @param {object} opts
|
|
17
|
-
* @param {object[]} [opts.criticals] CRITICAL findings ({ sev, file, hint, role }).
|
|
18
|
-
* @param {object[]} [opts.failed] Failed reviewer records ({ role, err, ok: false }).
|
|
19
|
-
* @param {number} [opts.total] Total reviewer count dispatched this run.
|
|
20
|
-
* @returns {{ escalate: boolean, reasons: string[], paulMessage: string, canAutoPublish: boolean }}
|
|
21
|
-
* escalate — true iff the result must block and surface to Paul.
|
|
22
|
-
* reasons — human-readable list explaining why escalation was triggered
|
|
23
|
-
* (empty array when escalate === false).
|
|
24
|
-
* paulMessage — the block line to embed in the PR review body;
|
|
25
|
-
* non-empty iff escalate === true.
|
|
26
|
-
* canAutoPublish — DATA ONLY, not an authorization by itself (D-173): true iff a clean
|
|
27
|
-
* review (zero criticals, zero reviewer failures, ≥1 reviewer ran)
|
|
28
|
-
* would satisfy D-166's pre-authorized-publish condition. Callers must
|
|
29
|
-
* NOT treat this as license to take an autonomous action (e.g. approving
|
|
30
|
-
* a PR, publishing a release) on its own -- it exists so a separately
|
|
31
|
-
* gated, narrowly-scoped release workflow can consume it for the actual
|
|
32
|
-
* SAT-437/D-166 publish decision. The general per-PR review script
|
|
33
|
-
* deliberately does NOT act on it -- see that script's own comments.
|
|
34
|
-
*/
|
|
35
|
-
export function shouldEscalateToPaul({ criticals = [], failed = [], total = 0 } = {}) {
|
|
36
|
-
const reasons = [];
|
|
37
|
-
|
|
38
|
-
// P0 / critical findings — must never pass silently
|
|
39
|
-
if (criticals.length > 0) {
|
|
40
|
-
reasons.push(`${criticals.length} CRITICAL finding(s)`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const allFailed = total > 0 && failed.length === total;
|
|
44
|
-
|
|
45
|
-
if (allFailed) {
|
|
46
|
-
// No reviewer returned a result — outcome is unknowable (ambiguous)
|
|
47
|
-
reasons.push('all reviewers failed (no result — ambiguous)');
|
|
48
|
-
} else if (failed.length > 0) {
|
|
49
|
-
// At least one reviewer failed — remaining results are incomplete (ambiguous)
|
|
50
|
-
reasons.push(`${failed.length} of ${total} reviewer(s) failed (degraded — ambiguous)`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const escalate = reasons.length > 0;
|
|
54
|
-
|
|
55
|
-
// D-166 (SAT-466): pre-authorized auto-publish path — zero criticals, all reviewers
|
|
56
|
-
// succeeded, and at least one reviewer actually ran. Do NOT auto-publish when total
|
|
57
|
-
// is 0 (no-reviewer synthetic run) — that would be a silent pass with no evidence.
|
|
58
|
-
const canAutoPublish = !escalate && total > 0 && failed.length === 0;
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
escalate,
|
|
62
|
-
reasons,
|
|
63
|
-
paulMessage: escalate
|
|
64
|
-
? 'Merge blocked until CRITICAL items are resolved or dismissed by Paul.'
|
|
65
|
-
: '',
|
|
66
|
-
canAutoPublish,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
1
|
+
// KnoSky HITL escalation gate (D-166 / SAT-467) — any P0/critical or ambiguous
|
|
2
|
+
// review result must surface to Paul, never proceed silently.
|
|
3
|
+
// D-166 (SAT-466): zero-P0/critical + all reviewers succeeded → auto-proceed-to-publish.
|
|
4
|
+
// Pure function: no I/O, no external dependencies, safe to import anywhere.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decide whether a review result must be escalated to Paul, or may auto-publish.
|
|
8
|
+
*
|
|
9
|
+
* Rules (D-166 hitl gate, SAT-467 / SAT-466):
|
|
10
|
+
* - One or more CRITICAL findings → P0, escalate immediately.
|
|
11
|
+
* - All reviewers failed → ambiguous (no result at all), escalate.
|
|
12
|
+
* - Any reviewer failed (degraded) → ambiguous (result is incomplete), escalate.
|
|
13
|
+
* - Zero criticals + zero failures + at least one reviewer ran
|
|
14
|
+
* → canAutoPublish (D-166 pre-authorized path).
|
|
15
|
+
*
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {object[]} [opts.criticals] CRITICAL findings ({ sev, file, hint, role }).
|
|
18
|
+
* @param {object[]} [opts.failed] Failed reviewer records ({ role, err, ok: false }).
|
|
19
|
+
* @param {number} [opts.total] Total reviewer count dispatched this run.
|
|
20
|
+
* @returns {{ escalate: boolean, reasons: string[], paulMessage: string, canAutoPublish: boolean }}
|
|
21
|
+
* escalate — true iff the result must block and surface to Paul.
|
|
22
|
+
* reasons — human-readable list explaining why escalation was triggered
|
|
23
|
+
* (empty array when escalate === false).
|
|
24
|
+
* paulMessage — the block line to embed in the PR review body;
|
|
25
|
+
* non-empty iff escalate === true.
|
|
26
|
+
* canAutoPublish — DATA ONLY, not an authorization by itself (D-173): true iff a clean
|
|
27
|
+
* review (zero criticals, zero reviewer failures, ≥1 reviewer ran)
|
|
28
|
+
* would satisfy D-166's pre-authorized-publish condition. Callers must
|
|
29
|
+
* NOT treat this as license to take an autonomous action (e.g. approving
|
|
30
|
+
* a PR, publishing a release) on its own -- it exists so a separately
|
|
31
|
+
* gated, narrowly-scoped release workflow can consume it for the actual
|
|
32
|
+
* SAT-437/D-166 publish decision. The general per-PR review script
|
|
33
|
+
* deliberately does NOT act on it -- see that script's own comments.
|
|
34
|
+
*/
|
|
35
|
+
export function shouldEscalateToPaul({ criticals = [], failed = [], total = 0 } = {}) {
|
|
36
|
+
const reasons = [];
|
|
37
|
+
|
|
38
|
+
// P0 / critical findings — must never pass silently
|
|
39
|
+
if (criticals.length > 0) {
|
|
40
|
+
reasons.push(`${criticals.length} CRITICAL finding(s)`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const allFailed = total > 0 && failed.length === total;
|
|
44
|
+
|
|
45
|
+
if (allFailed) {
|
|
46
|
+
// No reviewer returned a result — outcome is unknowable (ambiguous)
|
|
47
|
+
reasons.push('all reviewers failed (no result — ambiguous)');
|
|
48
|
+
} else if (failed.length > 0) {
|
|
49
|
+
// At least one reviewer failed — remaining results are incomplete (ambiguous)
|
|
50
|
+
reasons.push(`${failed.length} of ${total} reviewer(s) failed (degraded — ambiguous)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const escalate = reasons.length > 0;
|
|
54
|
+
|
|
55
|
+
// D-166 (SAT-466): pre-authorized auto-publish path — zero criticals, all reviewers
|
|
56
|
+
// succeeded, and at least one reviewer actually ran. Do NOT auto-publish when total
|
|
57
|
+
// is 0 (no-reviewer synthetic run) — that would be a silent pass with no evidence.
|
|
58
|
+
const canAutoPublish = !escalate && total > 0 && failed.length === 0;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
escalate,
|
|
62
|
+
reasons,
|
|
63
|
+
paulMessage: escalate
|
|
64
|
+
? 'Merge blocked until CRITICAL items are resolved or dismissed by Paul.'
|
|
65
|
+
: '',
|
|
66
|
+
canAutoPublish,
|
|
67
|
+
};
|
|
68
|
+
}
|