@geml/geml 1.1.1 → 1.4.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/LICENSE +21 -21
- package/README.md +155 -79
- package/codemap/adapters/crg.mjs +120 -109
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -229
- package/codemap/browser-stub.mjs +29 -24
- package/codemap/build.mjs +609 -354
- package/codemap/cross-stack.mjs +303 -0
- package/codemap/detect.mjs +399 -185
- package/codemap/emit.mjs +480 -361
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -143
- package/codemap/normalize.mjs +0 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -160
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -378
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +148 -126
- package/dist/block-edit.d.ts +1 -0
- package/dist/block-edit.js +112 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +2 -1
- package/dist/geml.js +1103 -233
- 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 +33 -3
- package/dist/render.js +558 -283
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +4 -0
- package/package.json +62 -58
package/codemap/mcp-server.mjs
CHANGED
|
@@ -1,143 +1,172 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
|
|
3
|
-
// Three navigation tools over a built graph/ directory, each "give an
|
|
4
|
-
// identifier, get readable text back" (the original proposal's 2.6):
|
|
5
|
-
// resolve_name name -> candidate anchors (doc + block id)
|
|
6
|
-
// open_symbol doc + id -> that symbol's block, verbatim
|
|
7
|
-
// get_backlinks doc + id -> the symbol's backlink block (who calls it)
|
|
8
|
-
//
|
|
9
|
-
// Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
|
|
10
|
-
// transport). Register e.g.:
|
|
11
|
-
// claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
|
|
12
|
-
// -- geml codemap mcp
|
|
13
|
-
// The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
|
|
3
|
+
// Three navigation tools over a built graph/ directory, each "give an
|
|
4
|
+
// identifier, get readable text back" (the original proposal's 2.6):
|
|
5
|
+
// resolve_name name -> candidate anchors (doc + block id)
|
|
6
|
+
// open_symbol doc + id -> that symbol's block, verbatim
|
|
7
|
+
// get_backlinks doc + id -> the symbol's backlink block (who calls it)
|
|
8
|
+
//
|
|
9
|
+
// Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
|
|
10
|
+
// transport). Register e.g.:
|
|
11
|
+
// claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
|
|
12
|
+
// -- geml codemap mcp
|
|
13
|
+
// The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
|
|
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";
|
|
21
|
+
import { createInterface } from "node:readline";
|
|
22
|
+
|
|
23
|
+
// blockSpans from the reference parser (its CLI entry is guarded, so importing
|
|
24
|
+
// is side-effect free). Falls back with a clear error if the parser isn't built.
|
|
25
|
+
const parserPath = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
|
|
26
|
+
if (!existsSync(parserPath)) {
|
|
27
|
+
console.error("geml-code-graph mcp: build the parser first (cd geml-parser && npm install && npm run build)");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const { blockSpans } = await import(`file://${parserPath.replace(/\\/g, "/")}`);
|
|
31
|
+
const splitLines = (s) => s.split(/(?<=\n)/);
|
|
32
|
+
|
|
33
|
+
export const graphDirOf = (args) => resolve(args?.graph_dir ?? process.env.GEML_GRAPH_DIR ?? ".geml-code-graph");
|
|
34
|
+
|
|
35
|
+
export const readBlock = (graphDir, doc, id) => {
|
|
36
|
+
const p = join(graphDir, doc);
|
|
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");
|
|
52
|
+
const span = blockSpans(source).get(id.replace(/^#/, ""));
|
|
53
|
+
if (!span) throw new Error(`no block with id \`${id}\` in ${doc}`);
|
|
54
|
+
return splitLines(source).slice(span.start, span.end).join("");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const TOOLS = [
|
|
58
|
+
{
|
|
59
|
+
name: "resolve_name",
|
|
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.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
name: { type: "string", description: "Exact symbol name (function/class short name)" },
|
|
65
|
+
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
|
|
66
|
+
},
|
|
67
|
+
required: ["name"],
|
|
68
|
+
},
|
|
69
|
+
run: (args) => {
|
|
70
|
+
const lookupPath = join(graphDirOf(args), "_index/name-lookup.json");
|
|
71
|
+
if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
|
|
72
|
+
const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
|
|
73
|
+
const hits = lookup[args.name];
|
|
74
|
+
if (!hits?.length) return `no symbol named \`${args.name}\` in the graph`;
|
|
75
|
+
return JSON.stringify(hits, null, 1);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "open_symbol",
|
|
80
|
+
description: "Open ONE symbol's block from the code graph (its callees as checked references, confidence annotations, called-by pointer). Equivalent to following a link. Get doc+id from resolve_name.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
doc: { type: "string", description: "Document path relative to the codemap dir, e.g. hashtable.c.geml" },
|
|
85
|
+
id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by for the edge tables)" },
|
|
86
|
+
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
|
|
87
|
+
},
|
|
88
|
+
required: ["doc", "id"],
|
|
89
|
+
},
|
|
90
|
+
run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "get_backlinks",
|
|
94
|
+
description: "Who calls this symbol: opens its backlink block (callers with file:line sites, each a followable reference). Absence means no RESOLVED callers — never proof of none.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
|
|
99
|
+
id: { type: "string", description: "The symbol's block id (e.g. hashtableFind); omit to get the whole #called-by table" },
|
|
100
|
+
graph_dir: { type: "string", description: "Codemap directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
|
|
101
|
+
},
|
|
102
|
+
required: ["doc"],
|
|
103
|
+
},
|
|
104
|
+
run: (args) => {
|
|
105
|
+
// codemap profile: in-edges live in the SAME document's #called-by table.
|
|
106
|
+
let table;
|
|
107
|
+
try {
|
|
108
|
+
table = readBlock(graphDirOf(args), args.doc, "called-by");
|
|
109
|
+
} catch {
|
|
110
|
+
return `no #called-by table in ${args.doc} — no resolved callers recorded (under heuristic extraction this is a blind spot, not proof of none)`;
|
|
111
|
+
}
|
|
112
|
+
if (!args.id) return table;
|
|
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*,`);
|
|
120
|
+
const lines = table.split("\n");
|
|
121
|
+
const hits = lines.filter((l, i) => i < 2 || re.test(l));
|
|
122
|
+
return hits.length > 2 ? hits.join("\n")
|
|
123
|
+
: `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
// ---- newline-delimited JSON-RPC 2.0 over stdio ----
|
|
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");
|
|
134
|
+
line = line.trim();
|
|
135
|
+
if (!line) return;
|
|
136
|
+
let msg;
|
|
137
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
138
|
+
const { id, method, params } = msg;
|
|
139
|
+
try {
|
|
140
|
+
if (method === "initialize") {
|
|
141
|
+
reply(id, {
|
|
142
|
+
protocolVersion: params?.protocolVersion ?? "2024-11-05",
|
|
143
|
+
capabilities: { tools: {} },
|
|
144
|
+
serverInfo: { name: "geml-code-graph", version: "0.2.0" },
|
|
145
|
+
});
|
|
146
|
+
} else if (method === "notifications/initialized" || method?.startsWith("notifications/")) {
|
|
147
|
+
// notifications get no response
|
|
148
|
+
} else if (method === "ping") {
|
|
149
|
+
reply(id, {});
|
|
150
|
+
} else if (method === "tools/list") {
|
|
151
|
+
reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
152
|
+
} else if (method === "tools/call") {
|
|
153
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
154
|
+
if (!tool) { replyError(id, -32602, `unknown tool: ${params?.name}`); return; }
|
|
155
|
+
try {
|
|
156
|
+
reply(id, { content: [{ type: "text", text: tool.run(params?.arguments ?? {}) }] });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
reply(id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true });
|
|
159
|
+
}
|
|
160
|
+
} else if (id !== undefined) {
|
|
161
|
+
replyError(id, -32601, `method not found: ${method}`);
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (id !== undefined) replyError(id, -32603, String(e?.message ?? e));
|
|
165
|
+
}
|
|
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
|
+
}
|