knosky 0.6.2 → 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 -824
  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
package/core/bundle.mjs CHANGED
@@ -1,178 +1,178 @@
1
- // KnoSky bundle engine (KSV2-R4) — intent-manifest builder with fail-closed secret scan.
2
- // Pure Node stdlib + internal imports only. ESM. No new deps.
3
- import { createHash } from 'node:crypto';
4
- import { readFileSync, realpathSync } from 'node:fs';
5
- import { join, relative, isAbsolute, sep } from 'node:path';
6
- import { findSecrets } from './contract.mjs';
7
- import { makeIntentManifest, validateIntentManifest } from './schema.mjs';
8
- import { extractLedgerSeq } from './freshness.mjs';
9
-
10
- // ---------------------------------------------------------------------------
11
- // Internal helpers
12
- // ---------------------------------------------------------------------------
13
-
14
- /**
15
- * Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
16
- * @param {string|undefined} ref
17
- * @returns {boolean}
18
- */
19
- function isSafeRef(ref) {
20
- if (!ref || typeof ref !== 'string') return false;
21
- if (ref.startsWith('/')) return false;
22
- // Windows drive: C:\ or C:/
23
- if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
24
- if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
25
- return true;
26
- }
27
-
28
- /**
29
- * Resolve `ref` under `root` and read it — following symlinks via realpathSync,
30
- * then verifying the REAL resolved path still lands inside `root` before the
31
- * file is ever opened. `isSafeRef` above only validates the path STRING; it
32
- * cannot see that a same-named file on disk is actually a symlink pointing
33
- * outside root. This is the actual read boundary (belt-and-suspenders on top
34
- * of fs-indexer's own symlink exclusion at index time — this is a separate
35
- * read path with its own live filesystem access, so it re-checks independently).
36
- *
37
- * Never throws. Distinguishes "nothing to scan by design" (no root given —
38
- * the caller explicitly opted out of content access) from "root given but the
39
- * file could not be verified safe/readable" (fail-closed: caller must treat
40
- * this as unscannable, NEVER as clean).
41
- *
42
- * @param {string|undefined} root
43
- * @param {string} ref
44
- * @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
45
- */
46
- function safeRead(root, ref) {
47
- if (!root) return { noRoot: true };
48
- const full = join(root, ref);
49
- let real, rootReal;
50
- try {
51
- real = realpathSync(full);
52
- rootReal = realpathSync(root);
53
- } catch {
54
- return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
55
- }
56
- const rel = relative(rootReal, real);
57
- if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
58
- return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
59
- }
60
- try {
61
- return { buf: readFileSync(real) };
62
- } catch {
63
- return { unsafe: true };
64
- }
65
- }
66
-
67
- /**
68
- * Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
69
- * @param {string|undefined} root Repo root directory, or undefined.
70
- * @param {string} ref Repo-relative path.
71
- * @returns {string}
72
- */
73
- function sha256OfFile(root, ref) {
74
- const r = safeRead(root, ref);
75
- if (!r.buf) return '';
76
- return createHash('sha256').update(r.buf).digest('hex');
77
- }
78
-
79
- // ---------------------------------------------------------------------------
80
- // kcBundle — main export
81
- // ---------------------------------------------------------------------------
82
-
83
- /**
84
- * Build an intent-manifest for the given node ids.
85
- *
86
- * @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
87
- * @param {string[]} ids — node ids to include
88
- * @param {object} [opts]
89
- * @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
90
- * @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
91
- * @returns {object} intent-manifest (passes validateIntentManifest)
92
- */
93
- export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
94
- // Normalise ids to an array of strings
95
- const idList = Array.isArray(ids) ? ids.map(String) : [];
96
-
97
- // Build the set of included ids (only those with safe refs)
98
- const includedSet = new Set();
99
- const refByid = new Map(); // id -> safe ref string
100
-
101
- for (const id of idList) {
102
- const node = ctx.byId.get(id);
103
- if (!node) continue; // missing node — skip
104
- const ref = node.provenance && node.provenance.ref;
105
- if (!isSafeRef(ref)) continue; // absolute or ".." — skip
106
- includedSet.add(id);
107
- refByid.set(id, ref);
108
- }
109
-
110
- // Build paths[] with sha256
111
- const paths = [];
112
- for (const id of idList) {
113
- if (!includedSet.has(id)) continue;
114
- const ref = refByid.get(id);
115
- const sha256 = sha256OfFile(root, ref);
116
- paths.push({ path: ref, sha256 });
117
- }
118
-
119
- // Build edges[] — out-edges within the bundled set
120
- const edges = [];
121
- for (const id of idList) {
122
- if (!includedSet.has(id)) continue;
123
- const node = ctx.byId.get(id);
124
- const links = Array.isArray(node.links) ? node.links : [];
125
- for (const target of links) {
126
- if (includedSet.has(target)) {
127
- edges.push({ from: id, to: target });
128
- }
129
- }
130
- }
131
-
132
- // Secret scan — FAIL-CLOSED. Three outcomes per included file:
133
- // noRoot → caller opted out of content access entirely; nothing to scan by
134
- // design (sha256 is also "" in this mode — a deliberate no-read mode,
135
- // not a failure).
136
- // unsafe → root WAS given but the file could not be verified safe-and-readable
137
- // (missing, permission error, or a symlink resolving outside root).
138
- // We can no longer prove it's secret-free, so we must NOT call it
139
- // clean — treat it as blocked (the previous behavior silently
140
- // skipped these and returned "clean", a fail-OPEN bug).
141
- // text → scanned normally.
142
- let totalMatches = 0;
143
- let blocked = false;
144
- let noRootMode = false;
145
-
146
- for (const id of idList) {
147
- if (!includedSet.has(id)) continue;
148
- const ref = refByid.get(id);
149
- const r = safeRead(root, ref);
150
- if (r.noRoot) { noRootMode = true; continue; }
151
- if (r.unsafe) { blocked = true; continue; }
152
- const hits = findSecrets(r.buf.toString('utf8'));
153
- if (hits.length > 0) {
154
- blocked = true;
155
- for (const [, count] of hits) totalMatches += count;
156
- }
157
- }
158
- void noRootMode; // no-root is intentionally a no-op above; named for readability
159
-
160
- const secret_scan = blocked
161
- ? { status: 'blocked', count: totalMatches }
162
- : { status: 'clean', count: 0 };
163
-
164
- // Ledger-anchored freshness (SAT-444): propagate the monotone commit count
165
- // from the city envelope into the manifest so consumers can apply the V13
166
- // high-water-mark guard without needing the original city object.
167
- const ledger_seq = extractLedgerSeq(ctx.city);
168
-
169
- const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan, ledger_seq });
170
-
171
- // Invariant: manifest MUST pass validateIntentManifest
172
- const validation = validateIntentManifest(manifest);
173
- if (!validation.ok) {
174
- throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
175
- }
176
-
177
- return manifest;
178
- }
1
+ // KnoSky bundle engine (KSV2-R4) — intent-manifest builder with fail-closed secret scan.
2
+ // Pure Node stdlib + internal imports only. ESM. No new deps.
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync, realpathSync } from 'node:fs';
5
+ import { join, relative, isAbsolute, sep } from 'node:path';
6
+ import { findSecrets } from './contract.mjs';
7
+ import { makeIntentManifest, validateIntentManifest } from './schema.mjs';
8
+ import { extractLedgerSeq } from './freshness.mjs';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Internal helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
16
+ * @param {string|undefined} ref
17
+ * @returns {boolean}
18
+ */
19
+ function isSafeRef(ref) {
20
+ if (!ref || typeof ref !== 'string') return false;
21
+ if (ref.startsWith('/')) return false;
22
+ // Windows drive: C:\ or C:/
23
+ if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
24
+ if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Resolve `ref` under `root` and read it — following symlinks via realpathSync,
30
+ * then verifying the REAL resolved path still lands inside `root` before the
31
+ * file is ever opened. `isSafeRef` above only validates the path STRING; it
32
+ * cannot see that a same-named file on disk is actually a symlink pointing
33
+ * outside root. This is the actual read boundary (belt-and-suspenders on top
34
+ * of fs-indexer's own symlink exclusion at index time — this is a separate
35
+ * read path with its own live filesystem access, so it re-checks independently).
36
+ *
37
+ * Never throws. Distinguishes "nothing to scan by design" (no root given —
38
+ * the caller explicitly opted out of content access) from "root given but the
39
+ * file could not be verified safe/readable" (fail-closed: caller must treat
40
+ * this as unscannable, NEVER as clean).
41
+ *
42
+ * @param {string|undefined} root
43
+ * @param {string} ref
44
+ * @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
45
+ */
46
+ function safeRead(root, ref) {
47
+ if (!root) return { noRoot: true };
48
+ const full = join(root, ref);
49
+ let real, rootReal;
50
+ try {
51
+ real = realpathSync(full);
52
+ rootReal = realpathSync(root);
53
+ } catch {
54
+ return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
55
+ }
56
+ const rel = relative(rootReal, real);
57
+ if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
58
+ return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
59
+ }
60
+ try {
61
+ return { buf: readFileSync(real) };
62
+ } catch {
63
+ return { unsafe: true };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
69
+ * @param {string|undefined} root Repo root directory, or undefined.
70
+ * @param {string} ref Repo-relative path.
71
+ * @returns {string}
72
+ */
73
+ function sha256OfFile(root, ref) {
74
+ const r = safeRead(root, ref);
75
+ if (!r.buf) return '';
76
+ return createHash('sha256').update(r.buf).digest('hex');
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // kcBundle — main export
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Build an intent-manifest for the given node ids.
85
+ *
86
+ * @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
87
+ * @param {string[]} ids — node ids to include
88
+ * @param {object} [opts]
89
+ * @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
90
+ * @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
91
+ * @returns {object} intent-manifest (passes validateIntentManifest)
92
+ */
93
+ export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
94
+ // Normalise ids to an array of strings
95
+ const idList = Array.isArray(ids) ? ids.map(String) : [];
96
+
97
+ // Build the set of included ids (only those with safe refs)
98
+ const includedSet = new Set();
99
+ const refByid = new Map(); // id -> safe ref string
100
+
101
+ for (const id of idList) {
102
+ const node = ctx.byId.get(id);
103
+ if (!node) continue; // missing node — skip
104
+ const ref = node.provenance && node.provenance.ref;
105
+ if (!isSafeRef(ref)) continue; // absolute or ".." — skip
106
+ includedSet.add(id);
107
+ refByid.set(id, ref);
108
+ }
109
+
110
+ // Build paths[] with sha256
111
+ const paths = [];
112
+ for (const id of idList) {
113
+ if (!includedSet.has(id)) continue;
114
+ const ref = refByid.get(id);
115
+ const sha256 = sha256OfFile(root, ref);
116
+ paths.push({ path: ref, sha256 });
117
+ }
118
+
119
+ // Build edges[] — out-edges within the bundled set
120
+ const edges = [];
121
+ for (const id of idList) {
122
+ if (!includedSet.has(id)) continue;
123
+ const node = ctx.byId.get(id);
124
+ const links = Array.isArray(node.links) ? node.links : [];
125
+ for (const target of links) {
126
+ if (includedSet.has(target)) {
127
+ edges.push({ from: id, to: target });
128
+ }
129
+ }
130
+ }
131
+
132
+ // Secret scan — FAIL-CLOSED. Three outcomes per included file:
133
+ // noRoot → caller opted out of content access entirely; nothing to scan by
134
+ // design (sha256 is also "" in this mode — a deliberate no-read mode,
135
+ // not a failure).
136
+ // unsafe → root WAS given but the file could not be verified safe-and-readable
137
+ // (missing, permission error, or a symlink resolving outside root).
138
+ // We can no longer prove it's secret-free, so we must NOT call it
139
+ // clean — treat it as blocked (the previous behavior silently
140
+ // skipped these and returned "clean", a fail-OPEN bug).
141
+ // text → scanned normally.
142
+ let totalMatches = 0;
143
+ let blocked = false;
144
+ let noRootMode = false;
145
+
146
+ for (const id of idList) {
147
+ if (!includedSet.has(id)) continue;
148
+ const ref = refByid.get(id);
149
+ const r = safeRead(root, ref);
150
+ if (r.noRoot) { noRootMode = true; continue; }
151
+ if (r.unsafe) { blocked = true; continue; }
152
+ const hits = findSecrets(r.buf.toString('utf8'));
153
+ if (hits.length > 0) {
154
+ blocked = true;
155
+ for (const [, count] of hits) totalMatches += count;
156
+ }
157
+ }
158
+ void noRootMode; // no-root is intentionally a no-op above; named for readability
159
+
160
+ const secret_scan = blocked
161
+ ? { status: 'blocked', count: totalMatches }
162
+ : { status: 'clean', count: 0 };
163
+
164
+ // Ledger-anchored freshness (SAT-444): propagate the monotone commit count
165
+ // from the city envelope into the manifest so consumers can apply the V13
166
+ // high-water-mark guard without needing the original city object.
167
+ const ledger_seq = extractLedgerSeq(ctx.city);
168
+
169
+ const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan, ledger_seq });
170
+
171
+ // Invariant: manifest MUST pass validateIntentManifest
172
+ const validation = validateIntentManifest(manifest);
173
+ if (!validation.ok) {
174
+ throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
175
+ }
176
+
177
+ return manifest;
178
+ }
package/core/churn.mjs CHANGED
@@ -1,24 +1,24 @@
1
- // Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
2
- // No commit messages, diffs, hunks, authors, or line-level churn retained.
3
- // execFileSync + an args array (not execSync + a shell string) — no argument here is
4
- // externally controlled today, but this matches the args-array-only discipline used by
5
- // every other git invocation in this codebase (ci.mjs) and removes the shell entirely,
6
- // so a future edit that adds a dynamic path/ref here can't reintroduce an injection class.
7
- import { execFileSync } from 'node:child_process';
8
-
9
- export function gitChurn(root) {
10
- const counts = {}, last = {};
11
- try {
12
- const out = execFileSync('git', ['log', '--since=90.days.ago', '--name-only', '--pretty=format:%ct', '--', '.'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
13
- let ts = 0;
14
- for (const raw of out.split(/\r?\n/)) {
15
- const line = raw.trim();
16
- if (!line) continue;
17
- if (/^\d+$/.test(line)) { ts = parseInt(line, 10); continue; }
18
- const rel = line.replace(/\\/g, '/');
19
- counts[rel] = (counts[rel] || 0) + 1;
20
- if (!last[rel] || ts > last[rel]) last[rel] = ts;
21
- }
22
- } catch { /* no git / no history -> empty */ }
23
- return { counts, last };
1
+ // Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
2
+ // No commit messages, diffs, hunks, authors, or line-level churn retained.
3
+ // execFileSync + an args array (not execSync + a shell string) — no argument here is
4
+ // externally controlled today, but this matches the args-array-only discipline used by
5
+ // every other git invocation in this codebase (ci.mjs) and removes the shell entirely,
6
+ // so a future edit that adds a dynamic path/ref here can't reintroduce an injection class.
7
+ import { execFileSync } from 'node:child_process';
8
+
9
+ export function gitChurn(root) {
10
+ const counts = {}, last = {};
11
+ try {
12
+ const out = execFileSync('git', ['log', '--since=90.days.ago', '--name-only', '--pretty=format:%ct', '--', '.'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
13
+ let ts = 0;
14
+ for (const raw of out.split(/\r?\n/)) {
15
+ const line = raw.trim();
16
+ if (!line) continue;
17
+ if (/^\d+$/.test(line)) { ts = parseInt(line, 10); continue; }
18
+ const rel = line.replace(/\\/g, '/');
19
+ counts[rel] = (counts[rel] || 0) + 1;
20
+ if (!last[rel] || ts > last[rel]) last[rel] = ts;
21
+ }
22
+ } catch { /* no git / no history -> empty */ }
23
+ return { counts, last };
24
24
  }