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
package/core/config.mjs
CHANGED
|
@@ -1,189 +1,189 @@
|
|
|
1
|
-
// KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
|
|
2
|
-
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// DEFAULTS — the canonical safe config for every knosky project.
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
/** @type {Readonly<KnoskyConfig>} */
|
|
11
|
-
export const DEFAULTS = Object.freeze({
|
|
12
|
-
knosky_protocol: '1.0',
|
|
13
|
-
telemetry: false,
|
|
14
|
-
absolute_paths: false,
|
|
15
|
-
fail_on_secret: true,
|
|
16
|
-
allow_excerpts: false,
|
|
17
|
-
max_excerpt_chars: 0,
|
|
18
|
-
categories: [],
|
|
19
|
-
ignore: [],
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// parseConfigYaml — minimal YAML-subset parser, no deps.
|
|
24
|
-
// Supports: `key: value` lines, booleans, integers, bare/quoted strings,
|
|
25
|
-
// block lists (`- item` lines following a `key:` line with no inline value).
|
|
26
|
-
// Ignores blank lines and # comments.
|
|
27
|
-
// Throws on any line it cannot interpret for a known key.
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Parse a minimal YAML subset used by `.knosky/config.yml`.
|
|
32
|
-
*
|
|
33
|
-
* @param {string} text Raw file contents.
|
|
34
|
-
* @returns {Record<string, unknown>} Parsed key/value map.
|
|
35
|
-
*/
|
|
36
|
-
export function parseConfigYaml(text) {
|
|
37
|
-
const result = {};
|
|
38
|
-
const lines = text.split(/\r?\n/);
|
|
39
|
-
let i = 0;
|
|
40
|
-
|
|
41
|
-
while (i < lines.length) {
|
|
42
|
-
const raw = lines[i];
|
|
43
|
-
// Strip inline comment and trim
|
|
44
|
-
const line = raw.replace(/#.*$/, '').trimEnd();
|
|
45
|
-
i++;
|
|
46
|
-
|
|
47
|
-
if (!line.trim()) continue; // blank / comment-only
|
|
48
|
-
|
|
49
|
-
// Must be a `key: [value]` line at the top level (no leading spaces)
|
|
50
|
-
const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
|
|
51
|
-
if (!kvMatch) {
|
|
52
|
-
throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
|
|
53
|
-
}
|
|
54
|
-
const key = kvMatch[1];
|
|
55
|
-
const rest = kvMatch[2].trim();
|
|
56
|
-
|
|
57
|
-
if (rest === '') {
|
|
58
|
-
// Possibly a block list — collect ` - item` lines that follow
|
|
59
|
-
const items = [];
|
|
60
|
-
while (i < lines.length) {
|
|
61
|
-
const next = lines[i].replace(/#.*$/, '');
|
|
62
|
-
if (/^\s*-\s/.test(next)) {
|
|
63
|
-
items.push(next.replace(/^\s*-\s*/, '').trim());
|
|
64
|
-
i++;
|
|
65
|
-
} else {
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
result[key] = items;
|
|
70
|
-
} else {
|
|
71
|
-
result[key] = parseScalar(rest, key);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return result;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
|
|
80
|
-
*
|
|
81
|
-
* @param {string} raw The raw value string (already trimmed).
|
|
82
|
-
* @param {string} key The key name, used for error messages.
|
|
83
|
-
* @returns {boolean|number|string}
|
|
84
|
-
*/
|
|
85
|
-
function parseScalar(raw, key) {
|
|
86
|
-
if (raw === 'true') return true;
|
|
87
|
-
if (raw === 'false') return false;
|
|
88
|
-
|
|
89
|
-
// Integer
|
|
90
|
-
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
91
|
-
|
|
92
|
-
// Double-quoted string
|
|
93
|
-
if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
|
|
94
|
-
|
|
95
|
-
// Single-quoted string
|
|
96
|
-
if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
97
|
-
|
|
98
|
-
// Bare string — must not contain characters that signal unhandled YAML
|
|
99
|
-
if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
|
|
100
|
-
|
|
101
|
-
throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
|
|
106
|
-
// Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
|
|
111
|
-
* Returns a plain object (not frozen). Throws if the file exists but is
|
|
112
|
-
* malformed.
|
|
113
|
-
*
|
|
114
|
-
* @param {string} root Absolute (or relative) project root.
|
|
115
|
-
* @returns {Record<string, unknown>} Merged config object.
|
|
116
|
-
*/
|
|
117
|
-
export function loadConfig(root) {
|
|
118
|
-
const cfgPath = path.join(root, '.knosky', 'config.yml');
|
|
119
|
-
|
|
120
|
-
let text;
|
|
121
|
-
try {
|
|
122
|
-
text = fs.readFileSync(cfgPath, 'utf8');
|
|
123
|
-
} catch (err) {
|
|
124
|
-
if (err.code === 'ENOENT') {
|
|
125
|
-
// File absent — return a safe copy of defaults
|
|
126
|
-
return { ...DEFAULTS, categories: [], ignore: [] };
|
|
127
|
-
}
|
|
128
|
-
throw err; // permission error, etc — still fail-closed
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// File exists: parse (throws on malformed — never silently fall back)
|
|
132
|
-
const parsed = parseConfigYaml(text);
|
|
133
|
-
|
|
134
|
-
// Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
|
|
135
|
-
return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
139
|
-
// validateConfig — collect all constraint violations, return { ok, errors }.
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
|
|
142
|
-
const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Validate a config object against the KnoSky protocol schema.
|
|
146
|
-
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
147
|
-
*
|
|
148
|
-
* @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
|
|
149
|
-
* @returns {{ ok: boolean, errors: string[] }}
|
|
150
|
-
*/
|
|
151
|
-
export function validateConfig(cfg) {
|
|
152
|
-
const errors = [];
|
|
153
|
-
const knownKeys = new Set(Object.keys(DEFAULTS));
|
|
154
|
-
|
|
155
|
-
// knosky_protocol must be the string "1.0"
|
|
156
|
-
if (cfg.knosky_protocol !== '1.0') {
|
|
157
|
-
errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Boolean fields
|
|
161
|
-
for (const k of BOOLEAN_KEYS) {
|
|
162
|
-
if (typeof cfg[k] !== 'boolean') {
|
|
163
|
-
errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// max_excerpt_chars must be integer >= 0
|
|
168
|
-
const mec = cfg.max_excerpt_chars;
|
|
169
|
-
if (!Number.isInteger(mec) || mec < 0) {
|
|
170
|
-
errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// categories and ignore must be arrays of strings
|
|
174
|
-
for (const k of ['categories', 'ignore']) {
|
|
175
|
-
const val = cfg[k];
|
|
176
|
-
if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
|
|
177
|
-
errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Unknown keys
|
|
182
|
-
for (const k of Object.keys(cfg)) {
|
|
183
|
-
if (!knownKeys.has(k)) {
|
|
184
|
-
errors.push(`unknown config key: ${k}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { ok: errors.length === 0, errors };
|
|
189
|
-
}
|
|
1
|
+
// KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
|
|
2
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// DEFAULTS — the canonical safe config for every knosky project.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** @type {Readonly<KnoskyConfig>} */
|
|
11
|
+
export const DEFAULTS = Object.freeze({
|
|
12
|
+
knosky_protocol: '1.0',
|
|
13
|
+
telemetry: false,
|
|
14
|
+
absolute_paths: false,
|
|
15
|
+
fail_on_secret: true,
|
|
16
|
+
allow_excerpts: false,
|
|
17
|
+
max_excerpt_chars: 0,
|
|
18
|
+
categories: [],
|
|
19
|
+
ignore: [],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// parseConfigYaml — minimal YAML-subset parser, no deps.
|
|
24
|
+
// Supports: `key: value` lines, booleans, integers, bare/quoted strings,
|
|
25
|
+
// block lists (`- item` lines following a `key:` line with no inline value).
|
|
26
|
+
// Ignores blank lines and # comments.
|
|
27
|
+
// Throws on any line it cannot interpret for a known key.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse a minimal YAML subset used by `.knosky/config.yml`.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text Raw file contents.
|
|
34
|
+
* @returns {Record<string, unknown>} Parsed key/value map.
|
|
35
|
+
*/
|
|
36
|
+
export function parseConfigYaml(text) {
|
|
37
|
+
const result = {};
|
|
38
|
+
const lines = text.split(/\r?\n/);
|
|
39
|
+
let i = 0;
|
|
40
|
+
|
|
41
|
+
while (i < lines.length) {
|
|
42
|
+
const raw = lines[i];
|
|
43
|
+
// Strip inline comment and trim
|
|
44
|
+
const line = raw.replace(/#.*$/, '').trimEnd();
|
|
45
|
+
i++;
|
|
46
|
+
|
|
47
|
+
if (!line.trim()) continue; // blank / comment-only
|
|
48
|
+
|
|
49
|
+
// Must be a `key: [value]` line at the top level (no leading spaces)
|
|
50
|
+
const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
|
|
51
|
+
if (!kvMatch) {
|
|
52
|
+
throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
|
|
53
|
+
}
|
|
54
|
+
const key = kvMatch[1];
|
|
55
|
+
const rest = kvMatch[2].trim();
|
|
56
|
+
|
|
57
|
+
if (rest === '') {
|
|
58
|
+
// Possibly a block list — collect ` - item` lines that follow
|
|
59
|
+
const items = [];
|
|
60
|
+
while (i < lines.length) {
|
|
61
|
+
const next = lines[i].replace(/#.*$/, '');
|
|
62
|
+
if (/^\s*-\s/.test(next)) {
|
|
63
|
+
items.push(next.replace(/^\s*-\s*/, '').trim());
|
|
64
|
+
i++;
|
|
65
|
+
} else {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
result[key] = items;
|
|
70
|
+
} else {
|
|
71
|
+
result[key] = parseScalar(rest, key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} raw The raw value string (already trimmed).
|
|
82
|
+
* @param {string} key The key name, used for error messages.
|
|
83
|
+
* @returns {boolean|number|string}
|
|
84
|
+
*/
|
|
85
|
+
function parseScalar(raw, key) {
|
|
86
|
+
if (raw === 'true') return true;
|
|
87
|
+
if (raw === 'false') return false;
|
|
88
|
+
|
|
89
|
+
// Integer
|
|
90
|
+
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
91
|
+
|
|
92
|
+
// Double-quoted string
|
|
93
|
+
if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
|
|
94
|
+
|
|
95
|
+
// Single-quoted string
|
|
96
|
+
if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
97
|
+
|
|
98
|
+
// Bare string — must not contain characters that signal unhandled YAML
|
|
99
|
+
if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
|
|
100
|
+
|
|
101
|
+
throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
|
|
106
|
+
// Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
|
|
111
|
+
* Returns a plain object (not frozen). Throws if the file exists but is
|
|
112
|
+
* malformed.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} root Absolute (or relative) project root.
|
|
115
|
+
* @returns {Record<string, unknown>} Merged config object.
|
|
116
|
+
*/
|
|
117
|
+
export function loadConfig(root) {
|
|
118
|
+
const cfgPath = path.join(root, '.knosky', 'config.yml');
|
|
119
|
+
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
text = fs.readFileSync(cfgPath, 'utf8');
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err.code === 'ENOENT') {
|
|
125
|
+
// File absent — return a safe copy of defaults
|
|
126
|
+
return { ...DEFAULTS, categories: [], ignore: [] };
|
|
127
|
+
}
|
|
128
|
+
throw err; // permission error, etc — still fail-closed
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// File exists: parse (throws on malformed — never silently fall back)
|
|
132
|
+
const parsed = parseConfigYaml(text);
|
|
133
|
+
|
|
134
|
+
// Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
|
|
135
|
+
return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// validateConfig — collect all constraint violations, return { ok, errors }.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate a config object against the KnoSky protocol schema.
|
|
146
|
+
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
147
|
+
*
|
|
148
|
+
* @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
|
|
149
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
150
|
+
*/
|
|
151
|
+
export function validateConfig(cfg) {
|
|
152
|
+
const errors = [];
|
|
153
|
+
const knownKeys = new Set(Object.keys(DEFAULTS));
|
|
154
|
+
|
|
155
|
+
// knosky_protocol must be the string "1.0"
|
|
156
|
+
if (cfg.knosky_protocol !== '1.0') {
|
|
157
|
+
errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Boolean fields
|
|
161
|
+
for (const k of BOOLEAN_KEYS) {
|
|
162
|
+
if (typeof cfg[k] !== 'boolean') {
|
|
163
|
+
errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// max_excerpt_chars must be integer >= 0
|
|
168
|
+
const mec = cfg.max_excerpt_chars;
|
|
169
|
+
if (!Number.isInteger(mec) || mec < 0) {
|
|
170
|
+
errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// categories and ignore must be arrays of strings
|
|
174
|
+
for (const k of ['categories', 'ignore']) {
|
|
175
|
+
const val = cfg[k];
|
|
176
|
+
if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
|
|
177
|
+
errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Unknown keys
|
|
182
|
+
for (const k of Object.keys(cfg)) {
|
|
183
|
+
if (!knownKeys.has(k)) {
|
|
184
|
+
errors.push(`unknown config key: ${k}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { ok: errors.length === 0, errors };
|
|
189
|
+
}
|
package/core/constants.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Shared, dependency-free constants used by more than one core module.
|
|
2
|
-
// Kept in its own file (no imports, no side effects) so any core/*.mjs can
|
|
3
|
-
// import from it without creating a circular dependency between modules.
|
|
4
|
-
|
|
5
|
-
// Upper bound for a plausible ledger sequence (git commit count). 1 billion
|
|
6
|
-
// is far beyond any real repo's commit count and far below
|
|
7
|
-
// Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
|
|
8
|
-
// supplied values (e.g. 1e100) with no precision-loss risk of its own.
|
|
9
|
-
// Enforced independently at two layers — core/freshness.mjs's
|
|
10
|
-
// extractLedgerSeq (structural validation) and core/ledger.mjs's
|
|
11
|
-
// checkAndAdvance (the persisted guard itself) — both import this single
|
|
12
|
-
// source of truth so the two layers cannot silently diverge.
|
|
13
|
-
export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
|
|
1
|
+
// Shared, dependency-free constants used by more than one core module.
|
|
2
|
+
// Kept in its own file (no imports, no side effects) so any core/*.mjs can
|
|
3
|
+
// import from it without creating a circular dependency between modules.
|
|
4
|
+
|
|
5
|
+
// Upper bound for a plausible ledger sequence (git commit count). 1 billion
|
|
6
|
+
// is far beyond any real repo's commit count and far below
|
|
7
|
+
// Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
|
|
8
|
+
// supplied values (e.g. 1e100) with no precision-loss risk of its own.
|
|
9
|
+
// Enforced independently at two layers — core/freshness.mjs's
|
|
10
|
+
// extractLedgerSeq (structural validation) and core/ledger.mjs's
|
|
11
|
+
// checkAndAdvance (the persisted guard itself) — both import this single
|
|
12
|
+
// source of truth so the two layers cannot silently diverge.
|
|
13
|
+
export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
|
package/core/contract.mjs
CHANGED
|
@@ -1,123 +1,123 @@
|
|
|
1
|
-
// KC city-data CONTRACT v2 — the shared spine.
|
|
2
|
-
// Generalizes the fixed-4-district v1 → N categories. Pointers + projections ONLY (D-146); never bodies.
|
|
3
|
-
// Council fixes baked in: serialization ALLOWLIST at the edge + best-effort DENYLIST scrub.
|
|
4
|
-
|
|
5
|
-
export const SCHEMA_VERSION = '2.0';
|
|
6
|
-
|
|
7
|
-
// The ONLY node fields that may ever be serialized into the index. Anything else is dropped/flagged.
|
|
8
|
-
export const NODE_FIELD_ALLOWLIST = [
|
|
9
|
-
'id', 'kind', 'title', 'summary', 'category', 'status',
|
|
10
|
-
'fact_date', 'tags', 'headings', 'links', 'churn', 'provenance', 'visibility', 'sensitive',
|
|
11
|
-
];
|
|
12
|
-
export const NODE_REQUIRED = ['id', 'kind', 'title', 'category', 'links', 'provenance'];
|
|
13
|
-
export const PROVENANCE_REQUIRED = ['store', 'ref'];
|
|
14
|
-
export const CATEGORY_REQUIRED = ['id', 'label', 'order'];
|
|
15
|
-
export const SUMMARY_MAX = 200;
|
|
16
|
-
|
|
17
|
-
// Detection patterns for the fail-closed safe-share audit (broader than the scrub list below).
|
|
18
|
-
export const SECRET_PATTERNS = [
|
|
19
|
-
[/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
|
|
20
|
-
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, 'private-key'],
|
|
21
|
-
[/\bgh[posru]_[A-Za-z0-9]{20,}\b/g, 'github-token'],
|
|
22
|
-
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github-pat'],
|
|
23
|
-
[/\bsk-[A-Za-z0-9]{20,}\b/g, 'openai-key'],
|
|
24
|
-
[/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, 'stripe-key'],
|
|
25
|
-
[/\bAIza[0-9A-Za-z_\-]{35}\b/g, 'google-api-key'],
|
|
26
|
-
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, 'slack-token'],
|
|
27
|
-
[/\bglpat-[A-Za-z0-9_\-]{20,}\b/g, 'gitlab-pat'],
|
|
28
|
-
[/\bnpm_[A-Za-z0-9]{36}\b/g, 'npm-token'],
|
|
29
|
-
[/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, 'jwt'],
|
|
30
|
-
];
|
|
31
|
-
export function findSecrets(text) {
|
|
32
|
-
const s = String(text); const hits = [];
|
|
33
|
-
for (const [re, kind] of SECRET_PATTERNS) { const m = s.match(re); if (m && m.length) hits.push([kind, m.length]); }
|
|
34
|
-
return hits;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Best-effort secret/PII denylist applied to emitted text projections. Backed by the indexer's
|
|
38
|
-
// fail-closed findSecrets() audit on the final artifact (defense in depth, not a single boundary).
|
|
39
|
-
const DENY = [
|
|
40
|
-
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // emails
|
|
41
|
-
/\b(?:api[_-]?key|secret|token|password|passwd|bearer)\b\s*[:=]\s*\S+/gi,
|
|
42
|
-
...SECRET_PATTERNS.map(([re]) => re),
|
|
43
|
-
];
|
|
44
|
-
// Optional project-specific sensitive terms (e.g. an employer/customer name) → redacted too.
|
|
45
|
-
let EXTRA_TERMS = [];
|
|
46
|
-
export function setRedactTerms(terms) {
|
|
47
|
-
EXTRA_TERMS = (terms || []).map(t => String(t).trim()).filter(Boolean)
|
|
48
|
-
.map(t => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'));
|
|
49
|
-
}
|
|
50
|
-
export function scrubText(s) {
|
|
51
|
-
if (typeof s !== 'string') return s;
|
|
52
|
-
let out = s;
|
|
53
|
-
for (const re of DENY) out = out.replace(re, '[REDACTED]');
|
|
54
|
-
for (const re of EXTRA_TERMS) out = out.replace(re, '[REDACTED]');
|
|
55
|
-
return out;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Default path/dir ignores (the indexer also honors the repo's .gitignore + a user .kcignore).
|
|
59
|
-
export const IGNORE_DEFAULTS = [
|
|
60
|
-
/(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)secrets?(\/|$)/i,
|
|
61
|
-
/(^|\/)keys?(\/|$)/i, /(^|\/)\.env/i, /(^|\/)dist(\/|$)/,
|
|
62
|
-
];
|
|
63
|
-
|
|
64
|
-
// Serialize one node: keep ONLY allowlisted fields, scrub text, clamp summary.
|
|
65
|
-
export function serializeNode(n) {
|
|
66
|
-
const o = {};
|
|
67
|
-
for (const k of NODE_FIELD_ALLOWLIST) if (n[k] !== undefined) o[k] = n[k];
|
|
68
|
-
if (typeof o.title === 'string') o.title = scrubText(o.title);
|
|
69
|
-
if (typeof o.summary === 'string') o.summary = scrubText(o.summary).slice(0, SUMMARY_MAX);
|
|
70
|
-
if (Array.isArray(o.headings)) o.headings = o.headings.map(scrubText);
|
|
71
|
-
if (Array.isArray(o.tags)) o.tags = o.tags.map(scrubText);
|
|
72
|
-
return o;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Validate a city envelope against the contract. Returns { ok, errors[] }.
|
|
76
|
-
export function validateCity(city) {
|
|
77
|
-
const errors = [];
|
|
78
|
-
if (!city || typeof city !== 'object') return { ok: false, errors: ['city is not an object'] };
|
|
79
|
-
if (city.schema_version !== SCHEMA_VERSION) errors.push(`schema_version != ${SCHEMA_VERSION}`);
|
|
80
|
-
if (!Array.isArray(city.categories)) errors.push('missing categories[] manifest');
|
|
81
|
-
if (!Array.isArray(city.nodes)) errors.push('missing nodes[]');
|
|
82
|
-
const catIds = new Set((city.categories || []).map(c => c.id));
|
|
83
|
-
(city.categories || []).forEach((c, i) => {
|
|
84
|
-
for (const k of CATEGORY_REQUIRED) if (c[k] === undefined) errors.push(`category[${i}] missing ${k}`);
|
|
85
|
-
});
|
|
86
|
-
const ids = new Set();
|
|
87
|
-
(city.nodes || []).forEach((n, i) => {
|
|
88
|
-
for (const k of NODE_REQUIRED) if (n[k] === undefined) errors.push(`node[${i}] missing ${k}`);
|
|
89
|
-
if (n.category && !catIds.has(n.category)) errors.push(`node[${i}] category '${n.category}' not in manifest`);
|
|
90
|
-
if (n.id) { if (ids.has(n.id)) errors.push(`duplicate id ${n.id}`); ids.add(n.id); }
|
|
91
|
-
if (n.provenance) for (const k of PROVENANCE_REQUIRED) if (n.provenance[k] === undefined) errors.push(`node[${i}] provenance missing ${k}`);
|
|
92
|
-
if (typeof n.summary === 'string' && n.summary.length > SUMMARY_MAX) errors.push(`node[${i}] summary > ${SUMMARY_MAX}`);
|
|
93
|
-
for (const k of Object.keys(n)) if (!NODE_FIELD_ALLOWLIST.includes(k)) errors.push(`node[${i}] non-allowlisted field '${k}'`);
|
|
94
|
-
});
|
|
95
|
-
if (city.node_count !== undefined && city.node_count !== (city.nodes || []).length) errors.push('node_count mismatch');
|
|
96
|
-
return { ok: errors.length === 0, errors };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const PALETTE = ['#4f8cff', '#34c759', '#ff9f0a', '#af52de', '#ff375f', '#5ac8fa', '#ffd60a', '#bf5af2', '#30d158', '#ff6482'];
|
|
100
|
-
|
|
101
|
-
// Build a categories[] manifest from a list of category ids (stable order + palette).
|
|
102
|
-
export function deriveCategories(ids) {
|
|
103
|
-
return ids.map((id, i) => ({
|
|
104
|
-
id, label: String(id).charAt(0).toUpperCase() + String(id).slice(1),
|
|
105
|
-
color: PALETTE[i % PALETTE.length], order: i,
|
|
106
|
-
}));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Legacy adapter: v1 (district, no categories[]) → v2 (category + categories[] manifest), via the allowlist.
|
|
110
|
-
export function adaptLegacy(cityV1) {
|
|
111
|
-
const ids = [...new Set((cityV1.nodes || []).map(n => n.district).filter(Boolean))];
|
|
112
|
-
const categories = deriveCategories(ids);
|
|
113
|
-
const nodes = (cityV1.nodes || []).map(n => {
|
|
114
|
-
const { district, ...rest } = n;
|
|
115
|
-
return serializeNode({ ...rest, category: district });
|
|
116
|
-
});
|
|
117
|
-
return {
|
|
118
|
-
schema_version: SCHEMA_VERSION,
|
|
119
|
-
generated_at: cityV1.generated_at || new Date().toISOString(),
|
|
120
|
-
source: { kind: 'legacy', ref: cityV1.source_rev || 'unknown', rev: cityV1.source_rev || 'unknown' },
|
|
121
|
-
categories, node_count: nodes.length, nodes,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
1
|
+
// KC city-data CONTRACT v2 — the shared spine.
|
|
2
|
+
// Generalizes the fixed-4-district v1 → N categories. Pointers + projections ONLY (D-146); never bodies.
|
|
3
|
+
// Council fixes baked in: serialization ALLOWLIST at the edge + best-effort DENYLIST scrub.
|
|
4
|
+
|
|
5
|
+
export const SCHEMA_VERSION = '2.0';
|
|
6
|
+
|
|
7
|
+
// The ONLY node fields that may ever be serialized into the index. Anything else is dropped/flagged.
|
|
8
|
+
export const NODE_FIELD_ALLOWLIST = [
|
|
9
|
+
'id', 'kind', 'title', 'summary', 'category', 'status',
|
|
10
|
+
'fact_date', 'tags', 'headings', 'links', 'churn', 'provenance', 'visibility', 'sensitive',
|
|
11
|
+
];
|
|
12
|
+
export const NODE_REQUIRED = ['id', 'kind', 'title', 'category', 'links', 'provenance'];
|
|
13
|
+
export const PROVENANCE_REQUIRED = ['store', 'ref'];
|
|
14
|
+
export const CATEGORY_REQUIRED = ['id', 'label', 'order'];
|
|
15
|
+
export const SUMMARY_MAX = 200;
|
|
16
|
+
|
|
17
|
+
// Detection patterns for the fail-closed safe-share audit (broader than the scrub list below).
|
|
18
|
+
export const SECRET_PATTERNS = [
|
|
19
|
+
[/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
|
|
20
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, 'private-key'],
|
|
21
|
+
[/\bgh[posru]_[A-Za-z0-9]{20,}\b/g, 'github-token'],
|
|
22
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github-pat'],
|
|
23
|
+
[/\bsk-[A-Za-z0-9]{20,}\b/g, 'openai-key'],
|
|
24
|
+
[/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, 'stripe-key'],
|
|
25
|
+
[/\bAIza[0-9A-Za-z_\-]{35}\b/g, 'google-api-key'],
|
|
26
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, 'slack-token'],
|
|
27
|
+
[/\bglpat-[A-Za-z0-9_\-]{20,}\b/g, 'gitlab-pat'],
|
|
28
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/g, 'npm-token'],
|
|
29
|
+
[/\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b/g, 'jwt'],
|
|
30
|
+
];
|
|
31
|
+
export function findSecrets(text) {
|
|
32
|
+
const s = String(text); const hits = [];
|
|
33
|
+
for (const [re, kind] of SECRET_PATTERNS) { const m = s.match(re); if (m && m.length) hits.push([kind, m.length]); }
|
|
34
|
+
return hits;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Best-effort secret/PII denylist applied to emitted text projections. Backed by the indexer's
|
|
38
|
+
// fail-closed findSecrets() audit on the final artifact (defense in depth, not a single boundary).
|
|
39
|
+
const DENY = [
|
|
40
|
+
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // emails
|
|
41
|
+
/\b(?:api[_-]?key|secret|token|password|passwd|bearer)\b\s*[:=]\s*\S+/gi,
|
|
42
|
+
...SECRET_PATTERNS.map(([re]) => re),
|
|
43
|
+
];
|
|
44
|
+
// Optional project-specific sensitive terms (e.g. an employer/customer name) → redacted too.
|
|
45
|
+
let EXTRA_TERMS = [];
|
|
46
|
+
export function setRedactTerms(terms) {
|
|
47
|
+
EXTRA_TERMS = (terms || []).map(t => String(t).trim()).filter(Boolean)
|
|
48
|
+
.map(t => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'));
|
|
49
|
+
}
|
|
50
|
+
export function scrubText(s) {
|
|
51
|
+
if (typeof s !== 'string') return s;
|
|
52
|
+
let out = s;
|
|
53
|
+
for (const re of DENY) out = out.replace(re, '[REDACTED]');
|
|
54
|
+
for (const re of EXTRA_TERMS) out = out.replace(re, '[REDACTED]');
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Default path/dir ignores (the indexer also honors the repo's .gitignore + a user .kcignore).
|
|
59
|
+
export const IGNORE_DEFAULTS = [
|
|
60
|
+
/(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)secrets?(\/|$)/i,
|
|
61
|
+
/(^|\/)keys?(\/|$)/i, /(^|\/)\.env/i, /(^|\/)dist(\/|$)/,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Serialize one node: keep ONLY allowlisted fields, scrub text, clamp summary.
|
|
65
|
+
export function serializeNode(n) {
|
|
66
|
+
const o = {};
|
|
67
|
+
for (const k of NODE_FIELD_ALLOWLIST) if (n[k] !== undefined) o[k] = n[k];
|
|
68
|
+
if (typeof o.title === 'string') o.title = scrubText(o.title);
|
|
69
|
+
if (typeof o.summary === 'string') o.summary = scrubText(o.summary).slice(0, SUMMARY_MAX);
|
|
70
|
+
if (Array.isArray(o.headings)) o.headings = o.headings.map(scrubText);
|
|
71
|
+
if (Array.isArray(o.tags)) o.tags = o.tags.map(scrubText);
|
|
72
|
+
return o;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate a city envelope against the contract. Returns { ok, errors[] }.
|
|
76
|
+
export function validateCity(city) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
if (!city || typeof city !== 'object') return { ok: false, errors: ['city is not an object'] };
|
|
79
|
+
if (city.schema_version !== SCHEMA_VERSION) errors.push(`schema_version != ${SCHEMA_VERSION}`);
|
|
80
|
+
if (!Array.isArray(city.categories)) errors.push('missing categories[] manifest');
|
|
81
|
+
if (!Array.isArray(city.nodes)) errors.push('missing nodes[]');
|
|
82
|
+
const catIds = new Set((city.categories || []).map(c => c.id));
|
|
83
|
+
(city.categories || []).forEach((c, i) => {
|
|
84
|
+
for (const k of CATEGORY_REQUIRED) if (c[k] === undefined) errors.push(`category[${i}] missing ${k}`);
|
|
85
|
+
});
|
|
86
|
+
const ids = new Set();
|
|
87
|
+
(city.nodes || []).forEach((n, i) => {
|
|
88
|
+
for (const k of NODE_REQUIRED) if (n[k] === undefined) errors.push(`node[${i}] missing ${k}`);
|
|
89
|
+
if (n.category && !catIds.has(n.category)) errors.push(`node[${i}] category '${n.category}' not in manifest`);
|
|
90
|
+
if (n.id) { if (ids.has(n.id)) errors.push(`duplicate id ${n.id}`); ids.add(n.id); }
|
|
91
|
+
if (n.provenance) for (const k of PROVENANCE_REQUIRED) if (n.provenance[k] === undefined) errors.push(`node[${i}] provenance missing ${k}`);
|
|
92
|
+
if (typeof n.summary === 'string' && n.summary.length > SUMMARY_MAX) errors.push(`node[${i}] summary > ${SUMMARY_MAX}`);
|
|
93
|
+
for (const k of Object.keys(n)) if (!NODE_FIELD_ALLOWLIST.includes(k)) errors.push(`node[${i}] non-allowlisted field '${k}'`);
|
|
94
|
+
});
|
|
95
|
+
if (city.node_count !== undefined && city.node_count !== (city.nodes || []).length) errors.push('node_count mismatch');
|
|
96
|
+
return { ok: errors.length === 0, errors };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const PALETTE = ['#4f8cff', '#34c759', '#ff9f0a', '#af52de', '#ff375f', '#5ac8fa', '#ffd60a', '#bf5af2', '#30d158', '#ff6482'];
|
|
100
|
+
|
|
101
|
+
// Build a categories[] manifest from a list of category ids (stable order + palette).
|
|
102
|
+
export function deriveCategories(ids) {
|
|
103
|
+
return ids.map((id, i) => ({
|
|
104
|
+
id, label: String(id).charAt(0).toUpperCase() + String(id).slice(1),
|
|
105
|
+
color: PALETTE[i % PALETTE.length], order: i,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Legacy adapter: v1 (district, no categories[]) → v2 (category + categories[] manifest), via the allowlist.
|
|
110
|
+
export function adaptLegacy(cityV1) {
|
|
111
|
+
const ids = [...new Set((cityV1.nodes || []).map(n => n.district).filter(Boolean))];
|
|
112
|
+
const categories = deriveCategories(ids);
|
|
113
|
+
const nodes = (cityV1.nodes || []).map(n => {
|
|
114
|
+
const { district, ...rest } = n;
|
|
115
|
+
return serializeNode({ ...rest, category: district });
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
schema_version: SCHEMA_VERSION,
|
|
119
|
+
generated_at: cityV1.generated_at || new Date().toISOString(),
|
|
120
|
+
source: { kind: 'legacy', ref: cityV1.source_rev || 'unknown', rev: cityV1.source_rev || 'unknown' },
|
|
121
|
+
categories, node_count: nodes.length, nodes,
|
|
122
|
+
};
|
|
123
|
+
}
|