@geml/geml 1.1.1 → 1.3.2
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/README.md +41 -11
- package/codemap/adapters/crg.mjs +11 -0
- package/codemap/adapters/scip.mjs +481 -52
- package/codemap/browser-stub.mjs +5 -0
- package/codemap/build.mjs +270 -45
- package/codemap/detect.mjs +230 -16
- package/codemap/emit.mjs +78 -7
- package/codemap/entries.mjs +129 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/mcp-server.mjs +44 -15
- package/codemap/normalize.mjs +0 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +158 -8
- package/codemap/serve.mjs +428 -228
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +20 -3
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +2 -1
- package/dist/geml.js +369 -97
- package/dist/history.js +102 -62
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +32 -2
- package/dist/render.js +308 -116
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +4 -0
- package/package.json +63 -58
package/codemap/find.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// geml codemap find <name> [codemap-dir]
|
|
3
|
+
//
|
|
4
|
+
// Locate a function/class by (substring, case-insensitive) name in a built
|
|
5
|
+
// codemap. Prints each candidate as <name> \t <doc>#<id> \t <src> — the
|
|
6
|
+
// document + block id to open, and the true source location. NO browser: pure
|
|
7
|
+
// stdout, so it pipes/greps. `dir` defaults to ./.geml-code-graph.
|
|
8
|
+
//
|
|
9
|
+
// Same index the MCP `resolve_name` tool and the viewer search box use
|
|
10
|
+
// (_index/name-lookup.json); a name with several rows is real ambiguity
|
|
11
|
+
// (overloads / same short name across classes) — every candidate is printed.
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// `find x | head` closes stdout after a few lines — that is normal pipe
|
|
16
|
+
// usage, not an error (POSIX would kill us silently with SIGPIPE; Windows
|
|
17
|
+
// node surfaces it as an EPIPE error event): exit quietly instead of
|
|
18
|
+
// crashing with an unhandled-error stack trace.
|
|
19
|
+
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") {
|
|
23
|
+
console.error("usage: geml codemap find <name> [codemap-dir] # locate a symbol by substring name (dir defaults to ./.geml-code-graph)");
|
|
24
|
+
process.exit(args.length ? 0 : 2);
|
|
25
|
+
}
|
|
26
|
+
const query = args[0];
|
|
27
|
+
const dir = args[1] || ".geml-code-graph";
|
|
28
|
+
const lookupPath = join(dir, "_index", "name-lookup.json");
|
|
29
|
+
if (!existsSync(lookupPath)) {
|
|
30
|
+
console.error(`no name-lookup at ${lookupPath} — build the codemap first (geml codemap build)`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
|
|
34
|
+
const q = query.toLowerCase();
|
|
35
|
+
const names = Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort();
|
|
36
|
+
if (!names.length) { console.error(`no symbol matching "${query}"`); process.exit(1); }
|
|
37
|
+
|
|
38
|
+
// src= lives on the block header line in the doc; read each doc once, index by id.
|
|
39
|
+
const docCache = new Map(); // doc -> Map(id -> src)
|
|
40
|
+
const srcOf = (doc, id) => {
|
|
41
|
+
if (!docCache.has(doc)) {
|
|
42
|
+
const map = new Map();
|
|
43
|
+
try {
|
|
44
|
+
const text = readFileSync(join(dir, doc), "utf8");
|
|
45
|
+
// src= may be quoted or a bare token (path#Lx-y, no spaces).
|
|
46
|
+
const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
|
|
49
|
+
} catch { /* doc unreadable — skip src */ }
|
|
50
|
+
docCache.set(doc, map);
|
|
51
|
+
}
|
|
52
|
+
return docCache.get(doc).get(id) || "";
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let n = 0;
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
for (const c of lookup[name]) {
|
|
58
|
+
const src = srcOf(c.doc, c.id);
|
|
59
|
+
process.stdout.write(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}\n`);
|
|
60
|
+
n++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.error(`\n${n} match(es) for "${query}" across ${names.length} name(s).`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// foldings.geml — the visible, human-owned config for codemap ceremony folding.
|
|
2
|
+
// GEML (dogfooded; the viewer renders it): a meta header, three bullet-list
|
|
3
|
+
// sections (fold-prefixes / source-roots / test-roots) and an options section.
|
|
4
|
+
// Read via the bundled parser; group-id shared-prefix stripping stays
|
|
5
|
+
// algorithmic (only its on/off toggle lives here).
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parse } from "../dist/geml.js";
|
|
9
|
+
import { deriveFoldLayers, DEFAULT_SOURCE_ROOTS, DEFAULT_TEST_ROOTS, LANG_FOLD_PREFIXES } from "./normalize.mjs";
|
|
10
|
+
|
|
11
|
+
const SECTIONS = { "fold-prefixes": "foldPrefixes", "source-roots": "sourceRoots", "test-roots": "testRoots", "module-roots": "moduleRoots" };
|
|
12
|
+
// Plain text of a bullet-list item. GEML list items carry `.text`; fall back to
|
|
13
|
+
// joining inline `.value`s if a build ever changes that shape.
|
|
14
|
+
const itemText = (it) => (typeof it.text === "string" ? it.text : (it.inlines ?? []).map((n) => n.value ?? "").join("")).trim();
|
|
15
|
+
|
|
16
|
+
export function parseFoldings(text) {
|
|
17
|
+
const doc = parse(text);
|
|
18
|
+
// A malformed hand-edit must fail LOUDLY, not silently drop every rule: the
|
|
19
|
+
// caller (loadOrSeedFoldings) catches this and falls back to the defaults.
|
|
20
|
+
// An intentionally empty file carries NO error diagnostics and yields empty
|
|
21
|
+
// sections — the "fold nothing" off-switch, a deliberately different outcome.
|
|
22
|
+
const errs = doc.diagnostics.filter((d) => d.severity === "error");
|
|
23
|
+
if (errs.length) throw new Error(`invalid GEML in foldings config: ${errs[0].message} (line ${errs[0].line})`);
|
|
24
|
+
const cfg = { foldPrefixes: [], sourceRoots: [], testRoots: [], moduleRoots: [], stripSharedPrefix: true };
|
|
25
|
+
let section = null;
|
|
26
|
+
for (const b of doc.children) {
|
|
27
|
+
if (b.kind === "heading") { section = b.text.trim().toLowerCase(); continue; }
|
|
28
|
+
if (b.kind !== "list") continue;
|
|
29
|
+
const items = b.items.map(itemText).filter(Boolean);
|
|
30
|
+
if (Object.hasOwn(SECTIONS, section)) cfg[SECTIONS[section]] = items;
|
|
31
|
+
else if (section === "options") {
|
|
32
|
+
for (const it of items) {
|
|
33
|
+
const m = it.match(/^strip-shared-prefix\s*:\s*(on|off|true|false)$/i);
|
|
34
|
+
if (m) cfg.stripSharedPrefix = /^(on|true)$/i.test(m[1]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return cfg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serializeFoldings(cfg) {
|
|
42
|
+
const list = (xs) => xs.map((x) => `- ${x}`).join("\n");
|
|
43
|
+
return [
|
|
44
|
+
"=== meta",
|
|
45
|
+
'title = "codemap foldings"',
|
|
46
|
+
"===",
|
|
47
|
+
"",
|
|
48
|
+
"Ceremony folded out of module display names. Seeded on first build; edit",
|
|
49
|
+
"freely — build never rewrites this. The shared package prefix (group-ids",
|
|
50
|
+
"like com/acme/app) is stripped automatically and needs no entry here; set",
|
|
51
|
+
"strip-shared-prefix to off under Options to disable it.",
|
|
52
|
+
"",
|
|
53
|
+
"Add a directory under module-roots to force it to display as its own",
|
|
54
|
+
"module — for a submodule a build tool declares centrally but whose folder",
|
|
55
|
+
"carries no manifest, or any layout the detector cannot see.",
|
|
56
|
+
"",
|
|
57
|
+
"## fold-prefixes",
|
|
58
|
+
"",
|
|
59
|
+
list(cfg.foldPrefixes),
|
|
60
|
+
"",
|
|
61
|
+
"## source-roots",
|
|
62
|
+
"",
|
|
63
|
+
list(cfg.sourceRoots),
|
|
64
|
+
"",
|
|
65
|
+
"## test-roots",
|
|
66
|
+
"",
|
|
67
|
+
list(cfg.testRoots),
|
|
68
|
+
"",
|
|
69
|
+
"## module-roots",
|
|
70
|
+
"",
|
|
71
|
+
list(cfg.moduleRoots ?? []),
|
|
72
|
+
"",
|
|
73
|
+
"## options",
|
|
74
|
+
"",
|
|
75
|
+
`- strip-shared-prefix: ${cfg.stripSharedPrefix ? "on" : "off"}`,
|
|
76
|
+
"",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Seeded config for a first build: structural fold-layers (above-root
|
|
81
|
+
// ceremony dirs, derived from the discovered module roots) unioned with
|
|
82
|
+
// known per-language ceremony (Cargo's `crates/`). Source/test roots always
|
|
83
|
+
// start from the same global defaults — the human edits foldings.geml from
|
|
84
|
+
// there; the build never rewrites it once it exists.
|
|
85
|
+
export function defaultFoldings({ moduleRoots, languages }) {
|
|
86
|
+
const langPrefixes = (languages ?? []).flatMap((l) => LANG_FOLD_PREFIXES[l] ?? []);
|
|
87
|
+
const foldPrefixes = [...new Set([...deriveFoldLayers(moduleRoots ?? []), ...langPrefixes])].sort();
|
|
88
|
+
return { foldPrefixes, sourceRoots: [...DEFAULT_SOURCE_ROOTS], testRoots: [...DEFAULT_TEST_ROOTS], moduleRoots: [], stripSharedPrefix: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Read <outDir>/_index/foldings.geml, or seed it on first build. Write-once:
|
|
92
|
+
// an existing file is the human's — we read it and never rewrite it (the
|
|
93
|
+
// refresh.json contract). A missing or unreadable file never crashes the
|
|
94
|
+
// build: seed/fall back to defaults and say so. Returns { config, seeded }.
|
|
95
|
+
export function loadOrSeedFoldings({ outDir, moduleRoots, languages }) {
|
|
96
|
+
const path = join(outDir, "_index", "foldings.geml");
|
|
97
|
+
if (existsSync(path)) {
|
|
98
|
+
try { return { config: parseFoldings(readFileSync(path, "utf8")), seeded: false }; }
|
|
99
|
+
catch (e) {
|
|
100
|
+
console.error(`warning: ignoring ${path} (${e.message}) — using default foldings; fix the file to re-enable your edits.`);
|
|
101
|
+
return { config: defaultFoldings({ moduleRoots, languages }), seeded: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const config = defaultFoldings({ moduleRoots, languages });
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(join(outDir, "_index"), { recursive: true });
|
|
107
|
+
writeFileSync(path, serializeFoldings(config));
|
|
108
|
+
} catch (e) { console.error(`warning: could not seed ${path} (${e.message}).`); }
|
|
109
|
+
return { config, seeded: true };
|
|
110
|
+
}
|
package/codemap/mcp-server.mjs
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
// claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
|
|
12
12
|
// -- geml codemap mcp
|
|
13
13
|
// The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
//
|
|
15
|
+
// The dispatch is exported (and the stdio wiring below is main-module guarded)
|
|
16
|
+
// so the test suite can drive it in-process; the CLI dispatcher always runs
|
|
17
|
+
// this file as a child's MAIN module, where nothing changes.
|
|
18
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
19
|
+
import { join, resolve, dirname, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
21
|
import { createInterface } from "node:readline";
|
|
18
22
|
|
|
19
23
|
// blockSpans from the reference parser (its CLI entry is guarded, so importing
|
|
@@ -26,18 +30,31 @@ if (!existsSync(parserPath)) {
|
|
|
26
30
|
const { blockSpans } = await import(`file://${parserPath.replace(/\\/g, "/")}`);
|
|
27
31
|
const splitLines = (s) => s.split(/(?<=\n)/);
|
|
28
32
|
|
|
29
|
-
const graphDirOf = (args) => resolve(args?.graph_dir ?? process.env.GEML_GRAPH_DIR ?? ".geml-code-graph");
|
|
33
|
+
export const graphDirOf = (args) => resolve(args?.graph_dir ?? process.env.GEML_GRAPH_DIR ?? ".geml-code-graph");
|
|
30
34
|
|
|
31
|
-
const readBlock = (graphDir, doc, id) => {
|
|
35
|
+
export const readBlock = (graphDir, doc, id) => {
|
|
32
36
|
const p = join(graphDir, doc);
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
// Confine `doc` to the graph dir. `doc` is client-supplied, so a value like
|
|
38
|
+
// ../../../etc/hosts joins OUT of the dir; realpathSync canonicalizes both
|
|
39
|
+
// sides (also defeating symlink escapes and normalizing Windows casing) and
|
|
40
|
+
// we verify the real doc path stays within the real graph dir. A missing
|
|
41
|
+
// file makes realpathSync throw — that is the normal "no such document"
|
|
42
|
+
// miss. (graph_dir itself is intentionally client-chosen — the server is
|
|
43
|
+
// pointed at a graph — so only the doc path is confined, to that dir.)
|
|
44
|
+
let realDir;
|
|
45
|
+
try { realDir = realpathSync(graphDir); } catch { realDir = resolve(graphDir); }
|
|
46
|
+
let realP;
|
|
47
|
+
try { realP = realpathSync(p); } catch { throw new Error(`no such document: ${doc} (graph dir: ${graphDir})`); }
|
|
48
|
+
if (realP !== realDir && !realP.startsWith(realDir + sep)) {
|
|
49
|
+
throw new Error(`document escapes the graph dir: ${doc} (graph dir: ${graphDir})`);
|
|
50
|
+
}
|
|
51
|
+
const source = readFileSync(realP, "utf8");
|
|
35
52
|
const span = blockSpans(source).get(id.replace(/^#/, ""));
|
|
36
53
|
if (!span) throw new Error(`no block with id \`${id}\` in ${doc}`);
|
|
37
54
|
return splitLines(source).slice(span.start, span.end).join("");
|
|
38
55
|
};
|
|
39
56
|
|
|
40
|
-
const TOOLS = [
|
|
57
|
+
export const TOOLS = [
|
|
41
58
|
{
|
|
42
59
|
name: "resolve_name",
|
|
43
60
|
description: "Find a function/class by name in the code graph. Returns candidate anchors with the document and block id to open. Multiple candidates = real ambiguity (overloads/same name) — inspect each, never assume.",
|
|
@@ -94,8 +111,14 @@ const TOOLS = [
|
|
|
94
111
|
}
|
|
95
112
|
if (!args.id) return table;
|
|
96
113
|
const id = args.id.replace(/^#/, "");
|
|
114
|
+
// `id` is client-supplied and goes straight into a RegExp: escape every
|
|
115
|
+
// regex metacharacter so it matches LITERALLY (an id like `.*` or a
|
|
116
|
+
// catastrophic-backtracking pattern can neither widen the match nor cause
|
|
117
|
+
// ReDoS — the pattern is a fixed string wrapped in `,\s*#…\s*,`).
|
|
118
|
+
const escId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
const re = new RegExp(`,\\s*#${escId}\\s*,`);
|
|
97
120
|
const lines = table.split("\n");
|
|
98
|
-
const hits = lines.filter((l, i) => i < 2 ||
|
|
121
|
+
const hits = lines.filter((l, i) => i < 2 || re.test(l));
|
|
99
122
|
return hits.length > 2 ? hits.join("\n")
|
|
100
123
|
: `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
|
|
101
124
|
},
|
|
@@ -103,11 +126,11 @@ const TOOLS = [
|
|
|
103
126
|
];
|
|
104
127
|
|
|
105
128
|
// ---- newline-delimited JSON-RPC 2.0 over stdio ----
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
129
|
+
// One frame in, zero or one frame out via `write` (stdout in production).
|
|
130
|
+
export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
131
|
+
const reply = (id, result) => write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
132
|
+
const replyError = (id, code, message) =>
|
|
133
|
+
write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
|
|
111
134
|
line = line.trim();
|
|
112
135
|
if (!line) return;
|
|
113
136
|
let msg;
|
|
@@ -140,4 +163,10 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
|
|
140
163
|
} catch (e) {
|
|
141
164
|
if (id !== undefined) replyError(id, -32603, String(e?.message ?? e));
|
|
142
165
|
}
|
|
143
|
-
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Auto-run only as a MAIN module (the CLI dispatcher spawns this file as a
|
|
169
|
+
// child's entry script) — an in-process `import` stays inert.
|
|
170
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
171
|
+
createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
|
|
172
|
+
}
|
package/codemap/normalize.mjs
CHANGED
|
Binary file
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Shared TRUST GATE for codemap recipes (security fix C2 — RCE).
|
|
2
|
+
//
|
|
3
|
+
// A codemap's _index/refresh.json is COMMITTED DATA whose `steps[]` are run
|
|
4
|
+
// through a shell by `geml codemap refresh` (spawnSync(step,{shell:true})).
|
|
5
|
+
// Cloning a hostile repo and running `geml codemap refresh` — which the
|
|
6
|
+
// geml-code-graph skill, `serve --watch`, and a PostToolUse hook all trigger —
|
|
7
|
+
// would otherwise execute arbitrary commands. The old "up-to-date" guard is
|
|
8
|
+
// bypassable and does not gate execution.
|
|
9
|
+
//
|
|
10
|
+
// The fix content-addresses each recipe (a stable fingerprint of its steps)
|
|
11
|
+
// and records which fingerprints the user has EXPLICITLY approved in a store
|
|
12
|
+
// kept OUTSIDE any repo (so a repo can never pre-approve itself). refresh
|
|
13
|
+
// refuses to execute a recipe whose fingerprint is not in the store; build
|
|
14
|
+
// auto-trusts the recipe it just authored (the user ran it locally).
|
|
15
|
+
//
|
|
16
|
+
// Trust gates WHO may run a recipe. Security fix R2-1 additionally changed HOW
|
|
17
|
+
// steps are stored: a step is now a STRUCTURED object { cwd?, env?, argv:[...] }
|
|
18
|
+
// so attacker-controllable paths are never interpolated into a shell string at
|
|
19
|
+
// rest, and refresh executes them without an attacker-influenced command line.
|
|
20
|
+
// The fingerprint below canonicalizes that structured form.
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join, dirname } from "node:path";
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
25
|
+
|
|
26
|
+
export const RECIPE_VERSION = 1; // on-disk step schema; bump ONLY on a real format change
|
|
27
|
+
|
|
28
|
+
// Canonicalize ONE recipe step for fingerprinting. Since security fix R2-1 a
|
|
29
|
+
// step is a STRUCTURED object { cwd?, env?, argv:[...] } (no shell string is
|
|
30
|
+
// ever stored). We emit a FIXED key order (cwd, env, argv), env keys SORTED so
|
|
31
|
+
// the fingerprint is independent of how the env map was built, and every value
|
|
32
|
+
// coerced to a string. Anything that is NOT a structured step (a legacy
|
|
33
|
+
// pre-R2-1 shell string, or a malformed entry) coerces to its String() form, so
|
|
34
|
+
// pre-existing string recipes keep the EXACT fingerprint they had before this
|
|
35
|
+
// change (backward compatible), while structured recipes get a stable identity.
|
|
36
|
+
function canonicalStep(step) {
|
|
37
|
+
if (step && typeof step === "object" && Array.isArray(step.argv)) {
|
|
38
|
+
const out = {};
|
|
39
|
+
if (step.cwd != null) out.cwd = String(step.cwd);
|
|
40
|
+
if (step.env && typeof step.env === "object") {
|
|
41
|
+
const env = {};
|
|
42
|
+
for (const k of Object.keys(step.env).sort()) env[k] = String(step.env[k]);
|
|
43
|
+
out.env = env;
|
|
44
|
+
}
|
|
45
|
+
out.argv = step.argv.map((a) => String(a));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
return String(step);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fingerprint = sha256 over a canonical JSON of {root, steps}. build (when it
|
|
52
|
+
// records refresh.json) and refresh (when it is about to run it) both call
|
|
53
|
+
// this on the same parsed recipe object, so they agree exactly. `root` is
|
|
54
|
+
// included because it is the base dir every step runs under — the same steps
|
|
55
|
+
// under a different root are a different execution and deserve a different
|
|
56
|
+
// identity. Deterministic: fixed key order, canonicalized steps, no timestamps.
|
|
57
|
+
export function recipeFingerprint(recipe) {
|
|
58
|
+
const steps = Array.isArray(recipe?.steps) ? recipe.steps.map(canonicalStep) : [];
|
|
59
|
+
const root = recipe?.root == null ? "" : String(recipe.root);
|
|
60
|
+
const canonical = JSON.stringify({ root, steps });
|
|
61
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Where the trust store lives — NEVER inside a repo. GEML_TRUST_STORE is an
|
|
65
|
+
// explicit override (test isolation / unusual homes). Otherwise it sits under
|
|
66
|
+
// the XDG config dir, falling back to ~/.config/geml, which is a sane
|
|
67
|
+
// cross-platform home (on Windows homedir() is C:\Users\<name>).
|
|
68
|
+
export function trustStorePath() {
|
|
69
|
+
if (process.env.GEML_TRUST_STORE) return process.env.GEML_TRUST_STORE;
|
|
70
|
+
const cfgHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
71
|
+
return join(cfgHome, "geml", "trusted-recipes.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Read the store DEFENSIVELY: a missing, unreadable, or malformed store means
|
|
75
|
+
// "nothing is trusted". A broken store must never silently trust a recipe.
|
|
76
|
+
export function readTrustStore() {
|
|
77
|
+
try {
|
|
78
|
+
const obj = JSON.parse(readFileSync(trustStorePath(), "utf8"));
|
|
79
|
+
if (obj && typeof obj === "object" && obj.recipes && typeof obj.recipes === "object") {
|
|
80
|
+
return { version: obj.version || 1, recipes: obj.recipes };
|
|
81
|
+
}
|
|
82
|
+
} catch { /* missing / unreadable / malformed: treat as empty */ }
|
|
83
|
+
return { version: 1, recipes: {} };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// True only when this exact recipe fingerprint has been approved.
|
|
87
|
+
export function isRecipeTrusted(fingerprint) {
|
|
88
|
+
const store = readTrustStore();
|
|
89
|
+
return Object.prototype.hasOwnProperty.call(store.recipes, fingerprint);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Record a fingerprint as trusted, MERGING into any existing store (never
|
|
93
|
+
// clobbering other approvals). Creates parent dirs. Returns the store path.
|
|
94
|
+
// THROWS on write failure: a caller that meant to trust must learn it did NOT,
|
|
95
|
+
// rather than proceed on the false belief that the recipe is now safe.
|
|
96
|
+
export function trustRecipe(fingerprint, graphDir) {
|
|
97
|
+
const store = readTrustStore();
|
|
98
|
+
store.recipes[fingerprint] = { graphDir: graphDir ? String(graphDir) : undefined, addedAt: Date.now() };
|
|
99
|
+
const p = trustStorePath();
|
|
100
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
101
|
+
writeFileSync(p, JSON.stringify(store, null, 2) + "\n");
|
|
102
|
+
return p;
|
|
103
|
+
}
|
package/codemap/refresh.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { readFileSync, existsSync, appendFileSync, openSync, closeSync } from "n
|
|
|
36
36
|
import { join, resolve, relative } from "node:path";
|
|
37
37
|
import { spawnSync, spawn } from "node:child_process";
|
|
38
38
|
import { isSourcePath } from "./detect.mjs";
|
|
39
|
+
import { recipeFingerprint, isRecipeTrusted, trustRecipe, trustStorePath, RECIPE_VERSION } from "./recipe-trust.mjs";
|
|
39
40
|
|
|
40
41
|
const args = process.argv.slice(2);
|
|
41
42
|
const hookMode = args.includes("--hook");
|
|
@@ -45,8 +46,11 @@ const background = args.includes("--background");
|
|
|
45
46
|
// naming, new emit shape) changes the OUTPUT for the same code.
|
|
46
47
|
const force = args.includes("--force");
|
|
47
48
|
const autoCommit = args.includes("--commit");
|
|
49
|
+
// --trust: approve THIS recipe (by fingerprint) so refresh will run it. The
|
|
50
|
+
// gate below refuses any recipe whose fingerprint is not in the trust store.
|
|
51
|
+
const trustFlag = args.includes("--trust");
|
|
48
52
|
if (args.includes("--help")) {
|
|
49
|
-
console.error("usage: geml codemap refresh [codemap-dir] [--force] [--commit] [--background|--hook] (dir defaults to ./.geml-code-graph)");
|
|
53
|
+
console.error("usage: geml codemap refresh [codemap-dir] [--trust] [--force] [--commit] [--background|--hook] (dir defaults to ./.geml-code-graph)");
|
|
50
54
|
process.exit(2);
|
|
51
55
|
}
|
|
52
56
|
const dir = args.find((a) => !a.startsWith("--")) || ".geml-code-graph";
|
|
@@ -60,6 +64,87 @@ if (!existsSync(cfgPath)) {
|
|
|
60
64
|
process.exit(1);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
// Parse the recipe UP FRONT: its fingerprint drives the TRUST GATE (security
|
|
68
|
+
// fix C2). refresh.json is committed data whose steps run through a shell, so
|
|
69
|
+
// an untrusted recipe must never reach the exec loop on ANY path — the
|
|
70
|
+
// foreground run, the --hook/--background re-spawn, or serve --watch (which
|
|
71
|
+
// spawns this script). See codemap/recipe-trust.mjs.
|
|
72
|
+
let cfg;
|
|
73
|
+
try { cfg = JSON.parse(readFileSync(cfgPath, "utf8")); }
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (hookMode) process.exit(0); // a broken recipe must not block a commit
|
|
76
|
+
console.error(`error: cannot parse recipe ${cfgPath}: ${e.message}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const steps = cfg.steps ?? [];
|
|
80
|
+
const fingerprint = recipeFingerprint(cfg);
|
|
81
|
+
let trusted = isRecipeTrusted(fingerprint);
|
|
82
|
+
|
|
83
|
+
// --- structured-step execution (security fix R2-1) --------------------------
|
|
84
|
+
// A recipe step is a structured object { cwd?, env?, argv:[...] }. We run argv
|
|
85
|
+
// WITHOUT ever concatenating an attacker-controllable value into a shell
|
|
86
|
+
// string (the R2-1 RCE was a recorded `cd <dir-name> && …` string run under a
|
|
87
|
+
// shell, where <dir-name> was attacker-chosen).
|
|
88
|
+
// POSIX: spawn the program directly (shell:false) — no shell, no injection.
|
|
89
|
+
// win32: npx.cmd / geml.cmd / rust-analyzer / joern.bat are .cmd/.bat shims
|
|
90
|
+
// that modern Node refuses to spawn with shell:false (EINVAL), so we go
|
|
91
|
+
// through cmd.exe. Node does NOT escape args under shell:true — it only
|
|
92
|
+
// concatenates them (DEP0190) — so we build the command line ourselves and
|
|
93
|
+
// quote EACH argv element: the program via q (a bare launcher name stays
|
|
94
|
+
// bare so its .cmd shim's %~dp0 resolves against the shim dir; a spaced
|
|
95
|
+
// full path is quoted), every argument via shq (ALWAYS double-quoted, so
|
|
96
|
+
// cmd.exe treats & | < > ( ) ^ and whitespace as literal). An injected
|
|
97
|
+
// metachar inside a dir-name argument is therefore inert.
|
|
98
|
+
const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
|
|
99
|
+
const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
|
|
100
|
+
// Human-readable render of a step for the log / refusal message — DISPLAY ONLY,
|
|
101
|
+
// never executed. Falls back to String() for a stale (non-structured) step.
|
|
102
|
+
const renderStep = (s) => {
|
|
103
|
+
if (!s || typeof s !== "object" || !Array.isArray(s.argv)) return String(s);
|
|
104
|
+
const parts = [];
|
|
105
|
+
if (s.cwd && s.cwd !== ".") parts.push(`cd ${s.cwd} &&`);
|
|
106
|
+
if (s.env) for (const [k, v] of Object.entries(s.env)) parts.push(`${k}=${v}`);
|
|
107
|
+
parts.push(...s.argv.map(String));
|
|
108
|
+
return parts.join(" ");
|
|
109
|
+
};
|
|
110
|
+
// A step is executable only when it is a structured object with a non-empty
|
|
111
|
+
// argv array. Anything else is a stale pre-R2-1 shell string (or malformed);
|
|
112
|
+
// it must be REFUSED, never run as a shell string.
|
|
113
|
+
const isStructuredStep = (s) => !!s && typeof s === "object" && Array.isArray(s.argv) && s.argv.length > 0;
|
|
114
|
+
|
|
115
|
+
// --trust: record this exact recipe as approved (content-addressed), then
|
|
116
|
+
// proceed. Recorded in the PARENT so every downstream path — including a
|
|
117
|
+
// detached --hook/--background child that re-runs this script — sees it as
|
|
118
|
+
// trusted via the persistent store. Fail LOUDLY if the store cannot be
|
|
119
|
+
// written: a caller that asked to trust must not be told it worked and then
|
|
120
|
+
// silently keep refusing.
|
|
121
|
+
if (trustFlag) {
|
|
122
|
+
if (trusted) {
|
|
123
|
+
console.error(`codemap refresh: recipe already trusted (${fingerprint.slice(0, 12)})`);
|
|
124
|
+
} else {
|
|
125
|
+
let where;
|
|
126
|
+
try { where = trustRecipe(fingerprint, cmDir); }
|
|
127
|
+
catch (e) {
|
|
128
|
+
console.error(`codemap refresh: FAILED to record trust in ${trustStorePath()}: ${e.message}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
console.error(`codemap refresh: recipe trusted (${fingerprint.slice(0, 12)}) — recorded in ${where}`);
|
|
132
|
+
trusted = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The refusal: show the exact steps that WOULD run so the user can review
|
|
137
|
+
// them, then how to approve. Non-zero exit so the skill/automation notices and
|
|
138
|
+
// surfaces it rather than silently doing nothing.
|
|
139
|
+
const refuseUntrusted = () => {
|
|
140
|
+
console.error(`codemap refresh: REFUSING to run an untrusted recipe (${cfgPath})`);
|
|
141
|
+
console.error(` fingerprint: ${fingerprint}`);
|
|
142
|
+
console.error(" steps that would run:");
|
|
143
|
+
for (const s of steps) console.error(` $ ${renderStep(s)}`);
|
|
144
|
+
console.error("this codemap recipe is not trusted; review the steps above and re-run with");
|
|
145
|
+
console.error("--trust to approve, or run `geml codemap build` to regenerate it.");
|
|
146
|
+
};
|
|
147
|
+
|
|
63
148
|
if (hookMode) {
|
|
64
149
|
// PostToolUse payload on stdin; only a git commit warrants a refresh.
|
|
65
150
|
let cmd = "";
|
|
@@ -68,13 +153,24 @@ if (hookMode) {
|
|
|
68
153
|
}
|
|
69
154
|
|
|
70
155
|
if (hookMode || background) {
|
|
156
|
+
// Never launch an exec child for an untrusted recipe. An empty recipe execs
|
|
157
|
+
// nothing, so it is not gated. --hook is automatic and must not block the
|
|
158
|
+
// commit: warn and no-op (exit 0). An explicit --background run surfaces the
|
|
159
|
+
// refusal with a non-zero exit.
|
|
160
|
+
if (steps.length && !trusted) {
|
|
161
|
+
if (hookMode) {
|
|
162
|
+
console.error(`codemap refresh: recipe not trusted — skipping (review it, then run \`geml codemap refresh ${dir} --trust\`)`);
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
refuseUntrusted();
|
|
166
|
+
process.exit(3);
|
|
167
|
+
}
|
|
71
168
|
const child = spawn(process.execPath, [process.argv[1], cmDir, ...(force ? ["--force"] : []), ...(autoCommit ? ["--commit"] : [])], { detached: true, stdio: "ignore" });
|
|
72
169
|
child.unref();
|
|
73
170
|
console.error(`codemap refresh: running in background (log: ${logPath})`);
|
|
74
171
|
process.exit(0);
|
|
75
172
|
}
|
|
76
173
|
|
|
77
|
-
const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
78
174
|
const root = resolve(cmDir, cfg.root ?? "..");
|
|
79
175
|
let head;
|
|
80
176
|
try {
|
|
@@ -112,25 +208,74 @@ if (!force && head && builtFrom) {
|
|
|
112
208
|
}
|
|
113
209
|
}
|
|
114
210
|
|
|
211
|
+
// TRUST GATE (foreground exec path). Reached only when the recipe is about to
|
|
212
|
+
// RUN its steps — after the up-to-date / no-source-change skips above, which
|
|
213
|
+
// never exec and so need no gate. This gate is INDEPENDENT of those checks, so
|
|
214
|
+
// forging index.geml's `commit` (or removing git) to force a rebuild cannot
|
|
215
|
+
// bypass it: it only changes which skip is taken, never whether an untrusted
|
|
216
|
+
// recipe may exec. An empty recipe execs nothing and is not gated.
|
|
217
|
+
if (steps.length && !trusted) {
|
|
218
|
+
refuseUntrusted();
|
|
219
|
+
process.exit(3);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// VERSION GATE. The on-disk step schema is versioned (RECIPE_VERSION): refuse a
|
|
223
|
+
// recipe recorded in any other format so a FUTURE format change is cleanly
|
|
224
|
+
// detected and the user is pointed at `geml codemap build` to regenerate it. A
|
|
225
|
+
// pre-versioning recipe (no `version` at all) is likewise refused. This judges
|
|
226
|
+
// the STANDALONE schema version, never the parser/`generator` version — the
|
|
227
|
+
// parser bumps every patch release, so using it here would force a full
|
|
228
|
+
// re-index of every project on each release. Reached only on the exec path
|
|
229
|
+
// (after the skips above, which never run steps).
|
|
230
|
+
if (cfg.version !== RECIPE_VERSION) {
|
|
231
|
+
console.error(`codemap refresh: REFUSING — recipe format out of date (${cfgPath})`);
|
|
232
|
+
console.error(` recorded format: v${cfg.version ?? "(pre-versioning)"}; this geml expects v${RECIPE_VERSION}`);
|
|
233
|
+
console.error("re-run `geml codemap build` to regenerate refresh.json.");
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
// STRUCTURE GUARD (the R2-1 invariant). Even a current-version recipe must not
|
|
237
|
+
// hand a non-structured step to the exec loop: a legacy shell STRING run through
|
|
238
|
+
// a shell is the exact RCE R2-1 closed. Refuse any step that is not a
|
|
239
|
+
// { argv: [...] } object rather than execute it.
|
|
240
|
+
const badIdx = steps.findIndex((s) => !isStructuredStep(s));
|
|
241
|
+
if (badIdx >= 0) {
|
|
242
|
+
console.error(`codemap refresh: REFUSING — step ${badIdx + 1} is not a { argv: [...] } step (${cfgPath})`);
|
|
243
|
+
console.error("re-run `geml codemap build` to regenerate refresh.json.");
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
115
247
|
appendFileSync(logPath, `\n[${new Date().toISOString()}] refresh @ ${head ?? "no-git"}\n`);
|
|
116
|
-
for (const step of
|
|
117
|
-
appendFileSync(logPath, `$ ${step}\n`);
|
|
248
|
+
for (const step of steps) {
|
|
249
|
+
appendFileSync(logPath, `$ ${renderStep(step)}\n`);
|
|
250
|
+
// Per-step cwd (relative to the project root, forward-slash) and env, merged
|
|
251
|
+
// over the current environment. Both may hold attacker-controlled dir names —
|
|
252
|
+
// they ride as a real cwd PATH / discrete argv elements, never shell syntax.
|
|
253
|
+
const stepCwd = resolve(root, step.cwd || ".");
|
|
254
|
+
const stepEnv = step.env ? { ...process.env, ...step.env } : process.env;
|
|
255
|
+
const argv = step.argv.map(String);
|
|
118
256
|
// Stream the step's output STRAIGHT into the log file. Capturing it in
|
|
119
257
|
// memory (spawnSync + encoding) hits the default 1MB maxBuffer — Joern's
|
|
120
258
|
// INFO firehose blew it and the child got killed mid-export (exit null).
|
|
121
259
|
// A file descriptor has no such limit, and the log tails live.
|
|
122
260
|
const fd = openSync(logPath, "a");
|
|
123
|
-
const r =
|
|
261
|
+
const r = process.platform === "win32"
|
|
262
|
+
// win32: build ONE pre-escaped command line (each element quoted so no arg
|
|
263
|
+
// can inject), run it through cmd.exe for the .cmd/.bat launchers.
|
|
264
|
+
? spawnSync([q(argv[0]), ...argv.slice(1).map(shq)].join(" "),
|
|
265
|
+
{ shell: true, cwd: stepCwd, env: stepEnv, stdio: ["ignore", fd, fd] })
|
|
266
|
+
// POSIX: exec the program directly with an args array — no shell involved.
|
|
267
|
+
: spawnSync(argv[0], argv.slice(1),
|
|
268
|
+
{ shell: false, cwd: stepCwd, env: stepEnv, stdio: ["ignore", fd, fd] });
|
|
124
269
|
closeSync(fd);
|
|
125
270
|
if (r.status !== 0) {
|
|
126
271
|
const why = r.status ?? r.signal ?? r.error?.message ?? "killed";
|
|
127
272
|
appendFileSync(logPath, `FAILED (exit ${why})\n`);
|
|
128
|
-
console.error(`codemap refresh: step failed (exit ${why}): ${step}\n log: ${logPath}`);
|
|
273
|
+
console.error(`codemap refresh: step failed (exit ${why}): ${renderStep(step)}\n log: ${logPath}`);
|
|
129
274
|
process.exit(1);
|
|
130
275
|
}
|
|
131
276
|
}
|
|
132
277
|
appendFileSync(logPath, "ok\n");
|
|
133
|
-
console.error(`codemap refresh: done${head ? ` @ ${head.slice(0, 10)}` : ""} (${
|
|
278
|
+
console.error(`codemap refresh: done${head ? ` @ ${head.slice(0, 10)}` : ""} (${steps.length} step(s))`);
|
|
134
279
|
|
|
135
280
|
// --commit: land the refreshed codemap as its own follow-up commit so it
|
|
136
281
|
// rides the next push with the code. Guards: HEAD must not have moved while
|
|
@@ -144,9 +289,14 @@ if (autoCommit && head) {
|
|
|
144
289
|
console.error(`codemap refresh: not auto-committing (${merging ? "merge in progress" : "HEAD moved during the refresh"}) — refreshed files left in the working tree`);
|
|
145
290
|
} else {
|
|
146
291
|
const rel = relative(root, cmDir).replace(/\\/g, "/") || ".";
|
|
292
|
+
// Exclude-pathspec prefix: empty when the codemap IS the repo root. A `./`
|
|
293
|
+
// prefix (what `${rel}/…` yields at rel=".") is rejected by some git
|
|
294
|
+
// versions inside `:(exclude)…`, silently un-excluding the logs or failing
|
|
295
|
+
// the commit — so build `_index/…` bare at root, `<rel>/_index/…` in a subdir.
|
|
296
|
+
const relPrefix = rel === "." ? "" : `${rel}/`;
|
|
147
297
|
// Runtime noise in _index (refresh/serve logs, serve.pid) never belongs in
|
|
148
298
|
// the commit — and this very run appends to refresh.log after committing.
|
|
149
|
-
const spec = ["--", rel, `:(exclude)${
|
|
299
|
+
const spec = ["--", rel, `:(exclude)${relPrefix}_index/refresh.log`, `:(exclude)${relPrefix}_index/serve.log`, `:(exclude)${relPrefix}_index/serve.pid`];
|
|
150
300
|
g("add", "-A", ...spec); // new pages need staging; pathspec keeps it surgical
|
|
151
301
|
const c = g("commit", "-m", `chore(codemap): refresh for ${head.slice(0, 7)}`, ...spec);
|
|
152
302
|
if (c.status === 0) {
|