knosky 0.4.1 → 0.5.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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to KnoSky. Versions are git-tagged on this repo.
4
4
 
5
+ ## [0.5.0] - 2026-07-01 -- Route engine + PR-GPS (Protocol v1)
6
+
7
+ ### Added
8
+ - **`.knosky` protocol foundation:** versioned config (`.knosky/config.yml`), `route.json` and `intent-manifest` schemas (`knosky_protocol: "1.0"`).
9
+ - **Route engine (`kc_route`):** point at a destination -- a file, a folder, an import chain, a "district" (category) -- and get a ranked, advisory route through the repo with waypoints, alternates, confidence, and caveats. Structural only: file/folder/imports/dependency-chain, never semantic/code-meaning.
10
+ - **`kc_bundle`:** builds a shareable intent-manifest (paths + sha256 + edges) with a fail-closed secret scan, for agents that need to share a scoped, verifiable file set.
11
+ - **PR-GPS:** `knosky ci` generates an advisory navigation report for a pull request's changed files. A new GitHub Action posts/updates it as a single PR comment automatically -- advisory only, never blocks or gates a build.
12
+
13
+ ### Security
14
+ - Independent adversarial pass ahead of this release: fixed a symlink-escape read and an unreadable-file fail-open in the bundle engine, a git-ref option-injection in the CI report generator, plus two additional issues found during the pass. See the repo's PR history for detail.
15
+
16
+ ### Notes
17
+ - Everything above is **advisory-only, metadata-only, local, no telemetry** -- KnoSky reads structure, never uploads your code, and nothing here blocks or gates a build.
18
+
5
19
  ## [0.4.1] - 2026-06-29 — Security review fixes
6
20
 
7
21
  ### Security
package/bin/knosky.mjs CHANGED
@@ -18,6 +18,45 @@ const flags = new Set(argv.filter(a => a.startsWith('--')));
18
18
  const target = path.resolve(argv.find(a => !a.startsWith('--')) || '.');
19
19
  const NODE = process.execPath;
20
20
 
21
+ // ---------------------------------------------------------------------------
22
+ // ci subcommand: generate PR-GPS advisory artifacts (advisory, never breaks builds)
23
+ // ---------------------------------------------------------------------------
24
+ if (argv.find(a => !a.startsWith('--')) === 'ci') {
25
+ const { knoskyCi } = await import('../core/ci.mjs');
26
+
27
+ // Resolve a named flag's value from argv. Supports --flag=value and --flag value.
28
+ const getArgVal = (name) => {
29
+ const prefix = name + '=';
30
+ const eq = argv.find(a => a.startsWith(prefix));
31
+ if (eq !== undefined) return eq.slice(prefix.length);
32
+ const idx = argv.indexOf(name);
33
+ if (idx !== -1 && idx + 1 < argv.length && !argv[idx + 1].startsWith('--')) {
34
+ return argv[idx + 1];
35
+ }
36
+ return undefined;
37
+ };
38
+
39
+ const ciBase = getArgVal('--base');
40
+ const ciHead = getArgVal('--head');
41
+ const ciCity = getArgVal('--city');
42
+ const ciFailOnSecret = flags.has('--fail-on-secret');
43
+
44
+ const { exitCode, summaryMd, routeJson, safetyJson } = await knoskyCi({
45
+ root: process.cwd(),
46
+ base: ciBase,
47
+ head: ciHead,
48
+ cityPath: ciCity,
49
+ failOnSecret: ciFailOnSecret,
50
+ });
51
+
52
+ fs.writeFileSync('knosky-pr-summary.md', summaryMd, 'utf8');
53
+ fs.writeFileSync('knosky-pr-route.json', JSON.stringify(routeJson, null, 2) + '\n', 'utf8');
54
+ fs.writeFileSync('knosky-safety-report.json', JSON.stringify(safetyJson, null, 2) + '\n', 'utf8');
55
+
56
+ console.log(summaryMd);
57
+ process.exit(exitCode);
58
+ }
59
+
21
60
  if (!fs.existsSync(target)) { console.error('KnoSky: path not found: ' + target); process.exit(1); }
22
61
 
23
62
  const outDir = path.join(target, '.knosky');
@@ -0,0 +1,172 @@
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
+
9
+ // ---------------------------------------------------------------------------
10
+ // Internal helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
15
+ * @param {string|undefined} ref
16
+ * @returns {boolean}
17
+ */
18
+ function isSafeRef(ref) {
19
+ if (!ref || typeof ref !== 'string') return false;
20
+ if (ref.startsWith('/')) return false;
21
+ // Windows drive: C:\ or C:/
22
+ if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
23
+ if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
24
+ return true;
25
+ }
26
+
27
+ /**
28
+ * Resolve `ref` under `root` and read it — following symlinks via realpathSync,
29
+ * then verifying the REAL resolved path still lands inside `root` before the
30
+ * file is ever opened. `isSafeRef` above only validates the path STRING; it
31
+ * cannot see that a same-named file on disk is actually a symlink pointing
32
+ * outside root. This is the actual read boundary (belt-and-suspenders on top
33
+ * of fs-indexer's own symlink exclusion at index time — this is a separate
34
+ * read path with its own live filesystem access, so it re-checks independently).
35
+ *
36
+ * Never throws. Distinguishes "nothing to scan by design" (no root given —
37
+ * the caller explicitly opted out of content access) from "root given but the
38
+ * file could not be verified safe/readable" (fail-closed: caller must treat
39
+ * this as unscannable, NEVER as clean).
40
+ *
41
+ * @param {string|undefined} root
42
+ * @param {string} ref
43
+ * @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
44
+ */
45
+ function safeRead(root, ref) {
46
+ if (!root) return { noRoot: true };
47
+ const full = join(root, ref);
48
+ let real, rootReal;
49
+ try {
50
+ real = realpathSync(full);
51
+ rootReal = realpathSync(root);
52
+ } catch {
53
+ return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
54
+ }
55
+ const rel = relative(rootReal, real);
56
+ if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
57
+ return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
58
+ }
59
+ try {
60
+ return { buf: readFileSync(real) };
61
+ } catch {
62
+ return { unsafe: true };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
68
+ * @param {string|undefined} root Repo root directory, or undefined.
69
+ * @param {string} ref Repo-relative path.
70
+ * @returns {string}
71
+ */
72
+ function sha256OfFile(root, ref) {
73
+ const r = safeRead(root, ref);
74
+ if (!r.buf) return '';
75
+ return createHash('sha256').update(r.buf).digest('hex');
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // kcBundle — main export
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Build an intent-manifest for the given node ids.
84
+ *
85
+ * @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
86
+ * @param {string[]} ids — node ids to include
87
+ * @param {object} [opts]
88
+ * @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
89
+ * @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
90
+ * @returns {object} intent-manifest (passes validateIntentManifest)
91
+ */
92
+ export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
93
+ // Normalise ids to an array of strings
94
+ const idList = Array.isArray(ids) ? ids.map(String) : [];
95
+
96
+ // Build the set of included ids (only those with safe refs)
97
+ const includedSet = new Set();
98
+ const refByid = new Map(); // id -> safe ref string
99
+
100
+ for (const id of idList) {
101
+ const node = ctx.byId.get(id);
102
+ if (!node) continue; // missing node — skip
103
+ const ref = node.provenance && node.provenance.ref;
104
+ if (!isSafeRef(ref)) continue; // absolute or ".." — skip
105
+ includedSet.add(id);
106
+ refByid.set(id, ref);
107
+ }
108
+
109
+ // Build paths[] with sha256
110
+ const paths = [];
111
+ for (const id of idList) {
112
+ if (!includedSet.has(id)) continue;
113
+ const ref = refByid.get(id);
114
+ const sha256 = sha256OfFile(root, ref);
115
+ paths.push({ path: ref, sha256 });
116
+ }
117
+
118
+ // Build edges[] — out-edges within the bundled set
119
+ const edges = [];
120
+ for (const id of idList) {
121
+ if (!includedSet.has(id)) continue;
122
+ const node = ctx.byId.get(id);
123
+ const links = Array.isArray(node.links) ? node.links : [];
124
+ for (const target of links) {
125
+ if (includedSet.has(target)) {
126
+ edges.push({ from: id, to: target });
127
+ }
128
+ }
129
+ }
130
+
131
+ // Secret scan — FAIL-CLOSED. Three outcomes per included file:
132
+ // noRoot → caller opted out of content access entirely; nothing to scan by
133
+ // design (sha256 is also "" in this mode — a deliberate no-read mode,
134
+ // not a failure).
135
+ // unsafe → root WAS given but the file could not be verified safe-and-readable
136
+ // (missing, permission error, or a symlink resolving outside root).
137
+ // We can no longer prove it's secret-free, so we must NOT call it
138
+ // clean — treat it as blocked (the previous behavior silently
139
+ // skipped these and returned "clean", a fail-OPEN bug).
140
+ // text → scanned normally.
141
+ let totalMatches = 0;
142
+ let blocked = false;
143
+ let noRootMode = false;
144
+
145
+ for (const id of idList) {
146
+ if (!includedSet.has(id)) continue;
147
+ const ref = refByid.get(id);
148
+ const r = safeRead(root, ref);
149
+ if (r.noRoot) { noRootMode = true; continue; }
150
+ if (r.unsafe) { blocked = true; continue; }
151
+ const hits = findSecrets(r.buf.toString('utf8'));
152
+ if (hits.length > 0) {
153
+ blocked = true;
154
+ for (const [, count] of hits) totalMatches += count;
155
+ }
156
+ }
157
+ void noRootMode; // no-root is intentionally a no-op above; named for readability
158
+
159
+ const secret_scan = blocked
160
+ ? { status: 'blocked', count: totalMatches }
161
+ : { status: 'clean', count: 0 };
162
+
163
+ const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan });
164
+
165
+ // Invariant: manifest MUST pass validateIntentManifest
166
+ const validation = validateIntentManifest(manifest);
167
+ if (!validation.ok) {
168
+ throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
169
+ }
170
+
171
+ return manifest;
172
+ }
package/core/churn.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  // Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
2
2
  // No commit messages, diffs, hunks, authors, or line-level churn retained.
3
- import { execSync } from 'node:child_process';
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';
4
8
 
5
9
  export function gitChurn(root) {
6
10
  const counts = {}, last = {};
7
11
  try {
8
- const out = execSync('git log --since=90.days.ago --name-only --pretty=format:%ct -- .', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
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 });
9
13
  let ts = 0;
10
14
  for (const raw of out.split(/\r?\n/)) {
11
15
  const line = raw.trim();
package/core/ci.mjs ADDED
@@ -0,0 +1,268 @@
1
+ // KnoSky CI artifact generator (KSV2-CI1) — PR-GPS advisory artifact.
2
+ // Pure Node stdlib + node:child_process (git, args-array only) + internal imports.
3
+ // HARD RULE: exit 0 by default. Only --fail-on-secret can cause exit 1, and only
4
+ // when secrets are found in the emitted artifacts. Never throws, never breaks builds.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { load } from './retrieve.mjs';
7
+ import { kcRoute } from './route.mjs';
8
+ import { findSecrets } from './contract.mjs';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Path-safety guard (mirrors the one in route.mjs)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function isSafePath(p) {
15
+ if (!p || typeof p !== 'string') return false;
16
+ if (p.startsWith('/')) return false;
17
+ if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
18
+ if (p.split(/[/\\]/).some(seg => seg === '..')) return false;
19
+ return true;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Git helper — returns changed file list or [] on any error (advisory, never throws)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ // A conservative git-ref allowlist: SHAs, branch/tag names, and relative refs
27
+ // (HEAD, HEAD~1, HEAD^). Must NOT start with '-' — that's the belt-and-suspenders
28
+ // layer that stops a ref string looking like a CLI flag (e.g. "--output=...")
29
+ // from ever being handed to git, even before --end-of-options runs.
30
+ function isSafeGitRef(ref) {
31
+ if (!ref || typeof ref !== 'string') return false;
32
+ if (ref.length > 200) return false;
33
+ return /^[A-Za-z0-9][A-Za-z0-9._/\-~^:]*$/.test(ref);
34
+ }
35
+
36
+ function gitChangedFiles(root, base, head) {
37
+ // Refuse to shell out at all if either ref doesn't look like a ref (layer 1).
38
+ if (!isSafeGitRef(base) || !isSafeGitRef(head)) return [];
39
+ try {
40
+ // --end-of-options (layer 2, git >= 2.24) tells git's option parser that
41
+ // everything after is a revision, never an option — closes the residual
42
+ // "ref value that happens to start with '-'" injection even though
43
+ // execFileSync's args-array already rules out shell injection.
44
+ const output = execFileSync(
45
+ 'git',
46
+ ['-C', root, 'diff', '--name-only', '--end-of-options', base + '...' + head],
47
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
48
+ );
49
+ return output.split('\n').map(l => l.trim()).filter(Boolean);
50
+ } catch (_) {
51
+ // Any git error → advisory empty set, never break the build
52
+ return [];
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // summaryMd builder
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function buildSummaryMd(generatedAt, base, head, routes) {
61
+ const lines = [];
62
+ lines.push('## KnoSky PR-GPS — Advisory Navigation Report');
63
+ lines.push('');
64
+ lines.push('> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.');
65
+ lines.push('');
66
+ lines.push(`- Generated: ${generatedAt}`);
67
+ if (base) lines.push(`- Base: \`${base}\``);
68
+ if (head) lines.push(`- Head: \`${head}\``);
69
+ lines.push('');
70
+
71
+ if (routes.length === 0) {
72
+ lines.push('_No changed files detected in this PR._');
73
+ } else {
74
+ for (const { file, route: routeDoc } of routes) {
75
+ lines.push(`### \`${file}\``);
76
+ lines.push('');
77
+ const topWaypoints = (routeDoc.route || []).slice(0, 5);
78
+ if (topWaypoints.length === 0) {
79
+ lines.push('_No route waypoints found in the index for this file._');
80
+ } else {
81
+ lines.push('**Route waypoints:**');
82
+ lines.push('');
83
+ for (const wp of topWaypoints) {
84
+ const path = typeof wp === 'string' ? wp : (wp && wp.path) || '';
85
+ const reason = (wp && wp.reason) || '';
86
+ lines.push(`- \`${path}\`${reason ? ' — ' + reason : ''}`);
87
+ }
88
+ }
89
+ const confidence = typeof routeDoc.confidence === 'number'
90
+ ? Math.round(routeDoc.confidence * 100) + '%'
91
+ : 'n/a';
92
+ lines.push('');
93
+ lines.push(`**Confidence:** ${confidence}`);
94
+ const caveats = (routeDoc.caveats || []).filter(c =>
95
+ c.includes('recently changed') || c.includes('stale') || c.includes('coverage'),
96
+ );
97
+ if (caveats.length > 0) {
98
+ lines.push('');
99
+ lines.push('**Caveats:**');
100
+ for (const c of caveats) lines.push(`- ${c}`);
101
+ }
102
+ lines.push('');
103
+ }
104
+ }
105
+
106
+ lines.push('---');
107
+ lines.push('_This report is advisory. KnoSky reads metadata only — it does not read code meaning._');
108
+ return lines.join('\n');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // knoskyCi — main export
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Generate the KnoSky PR-GPS advisory artifacts.
117
+ *
118
+ * @param {object} [opts]
119
+ * @param {string} [opts.root='.'] Repo root for git commands.
120
+ * @param {string} [opts.base] Base ref for git diff.
121
+ * @param {string} [opts.head] Head ref for git diff.
122
+ * @param {string} [opts.cityPath] Path to city-data.json (city index).
123
+ * @param {boolean} [opts.failOnSecret=false] Exit 1 only if secrets found in artifacts.
124
+ * @param {string[]} [opts.changedFiles] Inject changed files directly (test seam — skips git).
125
+ * @returns {{ exitCode: number, summaryMd: string, routeJson: object, safetyJson: object }}
126
+ */
127
+ export async function knoskyCi({
128
+ root = '.',
129
+ base,
130
+ head,
131
+ cityPath,
132
+ failOnSecret = false,
133
+ changedFiles,
134
+ } = {}) {
135
+ const generatedAt = new Date().toISOString();
136
+
137
+ // 1. Resolve changed files
138
+ let files;
139
+ if (Array.isArray(changedFiles)) {
140
+ // Test seam — use injected list directly, no git needed
141
+ files = changedFiles;
142
+ } else {
143
+ // Resolve base/head from args, then environment, then fallback
144
+ const resolvedBase = base || process.env.GITHUB_BASE_REF || 'HEAD~1';
145
+ const resolvedHead = head || process.env.GITHUB_SHA || 'HEAD';
146
+ files = gitChangedFiles(root, resolvedBase, resolvedHead);
147
+ }
148
+
149
+ const resolvedBase = base || process.env.GITHUB_BASE_REF || null;
150
+ const resolvedHead = head || process.env.GITHUB_SHA || null;
151
+
152
+ // 2. Load the city index — if absent/unreadable, produce advisory summary and exit 0
153
+ let ctx;
154
+ if (!cityPath) {
155
+ const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
156
+ return {
157
+ exitCode: 0,
158
+ summaryMd,
159
+ routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
160
+ safetyJson: buildSafetyJson(generatedAt, 0),
161
+ };
162
+ }
163
+
164
+ try {
165
+ ctx = load(cityPath);
166
+ } catch (_) {
167
+ const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
168
+ return {
169
+ exitCode: 0,
170
+ summaryMd,
171
+ routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
172
+ safetyJson: buildSafetyJson(generatedAt, 0),
173
+ };
174
+ }
175
+
176
+ // 3. For each changed file, call kcRoute — metadata only, no file bodies
177
+ const routes = [];
178
+ for (const relpath of files) {
179
+ // Safety: skip any path that escapes the repo
180
+ if (!isSafePath(relpath)) continue;
181
+ let routeDoc;
182
+ try {
183
+ routeDoc = kcRoute(ctx, 'file:' + relpath);
184
+ } catch (_) {
185
+ // Route errors are advisory — skip this file, never break
186
+ continue;
187
+ }
188
+ // Drop any route entries with unsafe paths (defense in depth)
189
+ if (routeDoc && Array.isArray(routeDoc.route)) {
190
+ routeDoc.route = routeDoc.route.filter(wp => {
191
+ const p = typeof wp === 'string' ? wp : (wp && wp.path);
192
+ return !p || isSafePath(p);
193
+ });
194
+ }
195
+ if (routeDoc && Array.isArray(routeDoc.alternates)) {
196
+ routeDoc.alternates = routeDoc.alternates.filter(wp => {
197
+ const p = typeof wp === 'string' ? wp : (wp && wp.path);
198
+ return !p || isSafePath(p);
199
+ });
200
+ }
201
+ routes.push({ file: relpath, route: routeDoc });
202
+ }
203
+
204
+ // 4. Build routeJson
205
+ const routeJson = buildRouteJson(generatedAt, resolvedBase, resolvedHead, routes);
206
+
207
+ // 5. Build summaryMd
208
+ const summaryMd = buildSummaryMd(generatedAt, resolvedBase, resolvedHead, routes);
209
+
210
+ // 6. Scan ONLY the emitted artifacts for secrets (defense in depth)
211
+ const artifactText = summaryMd + '\n' + JSON.stringify(routeJson);
212
+ const secretHits = findSecrets(artifactText);
213
+ const secretsFound = secretHits.reduce((n, [, count]) => n + count, 0);
214
+
215
+ // 7. Build safetyJson
216
+ const safetyJson = buildSafetyJson(generatedAt, secretsFound);
217
+
218
+ // 8. exitCode: only failOnSecret+secrets_found > 0 can be non-zero
219
+ const exitCode = (failOnSecret && secretsFound > 0) ? 1 : 0;
220
+
221
+ return { exitCode, summaryMd, routeJson, safetyJson };
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Artifact builders
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function buildNoIndexSummary(generatedAt, base, head) {
229
+ const lines = [
230
+ '## KnoSky PR-GPS — Advisory Navigation Report',
231
+ '',
232
+ '> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.',
233
+ '',
234
+ `- Generated: ${generatedAt}`,
235
+ ];
236
+ if (base) lines.push(`- Base: \`${base}\``);
237
+ if (head) lines.push(`- Head: \`${head}\``);
238
+ lines.push('');
239
+ lines.push('_No index available — run `knosky <path>` to build the city index first._');
240
+ lines.push('');
241
+ lines.push('---');
242
+ lines.push('_This report is advisory. KnoSky reads metadata only._');
243
+ return lines.join('\n');
244
+ }
245
+
246
+ function buildRouteJson(generatedAt, base, head, routes) {
247
+ return {
248
+ knosky_protocol: '1.0',
249
+ artifact_type: 'pr-route',
250
+ advisory: true,
251
+ generated_at: generatedAt,
252
+ base: base || null,
253
+ head: head || null,
254
+ routes: routes.map(({ file, route }) => ({ file, route })),
255
+ };
256
+ }
257
+
258
+ function buildSafetyJson(generatedAt, secretsFound) {
259
+ return {
260
+ knosky_protocol: '1.0',
261
+ artifact_type: 'safety-report',
262
+ advisory: true,
263
+ generated_at: generatedAt,
264
+ absolute_paths: false,
265
+ secrets_found: secretsFound,
266
+ redaction: 'metadata-only; no file bodies; no absolute paths',
267
+ };
268
+ }