@geml/geml 1.4.2 → 1.4.4

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.
@@ -1,103 +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
- }
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
+ }