@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.
@@ -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
+ }
@@ -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
- import { readFileSync, existsSync } from "node:fs";
15
- import { join, resolve, dirname } from "node:path";
16
- import { fileURLToPath } from "node:url";
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
- if (!existsSync(p)) throw new Error(`no such document: ${doc} (graph dir: ${graphDir})`);
34
- const source = readFileSync(p, "utf8");
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 || new RegExp(`,\\s*#${id}\\s*,`).test(l));
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
- const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
107
- const replyError = (id, code, message) =>
108
- process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
109
-
110
- createInterface({ input: process.stdin }).on("line", (line) => {
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
+ }
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
+ }
@@ -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 cfg.steps ?? []) {
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 = spawnSync(step, { shell: true, cwd: root, stdio: ["ignore", fd, fd] });
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)}` : ""} (${(cfg.steps ?? []).length} step(s))`);
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)${rel}/_index/refresh.log`, `:(exclude)${rel}/_index/serve.log`, `:(exclude)${rel}/_index/serve.pid`];
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) {