@holmes-lab/holmes-kit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +15 -9
- package/dist/.build-id +1 -1
- package/dist/holmes/cpg/foundation/cfg.js +487 -4
- package/dist/holmes/cpg/foundation/ddg.js +251 -5
- package/dist/holmes/cpg/foundation/language-matrix.d.ts +17 -0
- package/dist/holmes/cpg/foundation/language-matrix.js +52 -4
- package/dist/holmes/cpg/language-capability.js +15 -3
- package/dist/holmes/cpg/language-parser-walk.js +63 -5
- package/dist/holmes/mcp/handlers.js +40 -28
- package/dist/holmes/rtm/flow-sensitive-taint.js +18 -2
- package/dist/holmes/rtm/rtm-builder.d.ts +6 -14
- package/dist/holmes/rtm/rtm-builder.js +117 -0
- package/dist/holmes/rtm/taint-vocabulary.d.ts +1 -1
- package/dist/holmes/rtm/taint-vocabulary.js +46 -0
- package/package.json +1 -1
|
@@ -6,7 +6,19 @@ const sink_matching_1 = require("./sink-matching");
|
|
|
6
6
|
const lineOf = (source, offset) => source.slice(0, offset).split('\n').length;
|
|
7
7
|
/** The callee's bare last-segment name for a call node, or null when it is not a plain call. */
|
|
8
8
|
function calleeNameOf(ast, kids, call, source) {
|
|
9
|
-
const
|
|
9
|
+
const named = kids.of(call).filter((k) => ast.nodes[k].named);
|
|
10
|
+
// @implements A-SPEC-524.1 — Java's method_invocation is [receiver?, NAME, argument_list]:
|
|
11
|
+
// the first named child is the RECEIVER when present, so taking it named `runtime` where the
|
|
12
|
+
// sink is `exec` — measured before sealing. The name is the identifier just before the
|
|
13
|
+
// argument list.
|
|
14
|
+
let fnNode;
|
|
15
|
+
if (ast.nodes[call].type === 'method_invocation') {
|
|
16
|
+
const argsAt = named.findIndex((k) => ast.nodes[k].type === 'argument_list');
|
|
17
|
+
fnNode = argsAt > 0 ? named[argsAt - 1] : named[0];
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
fnNode = named[0];
|
|
21
|
+
}
|
|
10
22
|
if (fnNode === undefined)
|
|
11
23
|
return null;
|
|
12
24
|
const text = source.slice(ast.nodes[fnNode].start, ast.nodes[fnNode].end);
|
|
@@ -31,7 +43,11 @@ function flowSensitiveTaint(input) {
|
|
|
31
43
|
const ty = ast.nodes[n].type;
|
|
32
44
|
if (n !== stmt && stmtOfBlock.has(n))
|
|
33
45
|
return;
|
|
34
|
-
|
|
46
|
+
// @implements A-SPEC-524.1 — the call-node set, measured per grammar: Java spells calls
|
|
47
|
+
// method_invocation and constructors object_creation_expression (where ProcessBuilder
|
|
48
|
+
// lives), C# spells them invocation_expression.
|
|
49
|
+
if (ty === 'call_expression' || ty === 'call' || ty === 'method_invocation'
|
|
50
|
+
|| ty === 'invocation_expression' || ty === 'object_creation_expression')
|
|
35
51
|
out.push(n);
|
|
36
52
|
for (const c of kids.of(n))
|
|
37
53
|
walk(c);
|
|
@@ -29,6 +29,12 @@ export interface ResolutionReport {
|
|
|
29
29
|
export interface BuildRtmOptions {
|
|
30
30
|
/** Resolve a spec id to its source file path (e.g. its .md file) for provenance tagging. */
|
|
31
31
|
specSourcePath?: (id: string) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* @implements A-SPEC-525.1 — the Go module path (go.mod's `module` line). Supplied by the
|
|
34
|
+
* caller that can read go.mod; without it every Go import is external and resolves to nothing,
|
|
35
|
+
* which is the honest default (no prefix to strip, no guess to make).
|
|
36
|
+
*/
|
|
37
|
+
goModule?: string;
|
|
32
38
|
/**
|
|
33
39
|
* @implements A-SPEC-281
|
|
34
40
|
* The observation context every fact in this build shares: which commit it was read at, which
|
|
@@ -170,20 +176,6 @@ export interface CommitRecord {
|
|
|
170
176
|
* scanned are still recorded as nodes — they happened.
|
|
171
177
|
*/
|
|
172
178
|
export declare function addCommitHistory(history: readonly CommitRecord[], scanned: readonly ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): void;
|
|
173
|
-
/**
|
|
174
|
-
* @implements A-SPEC-289
|
|
175
|
-
* FILE nodes and resolved `imports` edges.
|
|
176
|
-
*
|
|
177
|
-
* Import relations were extracted and then dropped, because their endpoints are a file and a module
|
|
178
|
-
* path while the graph knew only symbols and specs — measured on this repository, 1,892 extracted
|
|
179
|
-
* and 0 in the graph. `File` is a node kind Goal Phase 3 names, so it becomes one.
|
|
180
|
-
*
|
|
181
|
-
* ONLY relative specifiers are resolved, and only to a file the scan actually contains. A bare
|
|
182
|
-
* specifier (`node:fs`, `js-yaml`) is an external package with no node to point at, and inventing
|
|
183
|
-
* one would be a guess — the same precision-over-recall rule call resolution follows. Measured: 441
|
|
184
|
-
* of 1,892 specifiers (23%) resolve inside the repository, and the rest failing to resolve is a
|
|
185
|
-
* fact about the imports, not a defect in the resolver.
|
|
186
|
-
*/
|
|
187
179
|
export declare function addImportEdges(scanned: readonly ScannedFile[], graph: RtmGraph, opts?: BuildRtmOptions): void;
|
|
188
180
|
/** @implements A-SPEC-293 — one architecture decision as its record states it. */
|
|
189
181
|
export interface DecisionRecord {
|
|
@@ -43,6 +43,7 @@ exports.addCommitHistory = addCommitHistory;
|
|
|
43
43
|
exports.addImportEdges = addImportEdges;
|
|
44
44
|
exports.addDecisionEdges = addDecisionEdges;
|
|
45
45
|
const path = __importStar(require("node:path"));
|
|
46
|
+
const fs = __importStar(require("node:fs"));
|
|
46
47
|
const language_capability_1 = require("../cpg/language-capability");
|
|
47
48
|
/**
|
|
48
49
|
* @implements A-SPEC-281
|
|
@@ -341,12 +342,117 @@ function addCommitHistory(history, scanned, graph, opts) {
|
|
|
341
342
|
* of 1,892 specifiers (23%) resolve inside the repository, and the rest failing to resolve is a
|
|
342
343
|
* fact about the imports, not a defect in the resolver.
|
|
343
344
|
*/
|
|
345
|
+
function fsReadGoMod(p) {
|
|
346
|
+
try {
|
|
347
|
+
return fs.readFileSync(p, 'utf8').match(/^module\s+(\S+)/m)?.[1] ?? null;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
344
353
|
function addImportEdges(scanned, graph, opts) {
|
|
345
354
|
const known = new Set(scanned.map((f) => f.sourcePath));
|
|
346
355
|
const EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx', '.py',
|
|
347
356
|
'/index.ts', '/index.js', '/__init__.py'];
|
|
348
357
|
const firstKnown = (base) => EXTENSIONS.map((ext) => `${base}${ext}`).find((candidate) => known.has(candidate)) ?? null;
|
|
358
|
+
// @implements A-SPEC-525.1 — the five languages' resolution rules, selected by the IMPORTING
|
|
359
|
+
// file's extension because resolution IS per-language semantics. The founding rule is unchanged
|
|
360
|
+
// everywhere: resolve only to files the scan actually contains; the ambiguous resolve to none.
|
|
361
|
+
const knownList = [...known];
|
|
362
|
+
const uniqueSuffix = (suffix) => {
|
|
363
|
+
const hits = knownList.filter((k) => k === suffix || k.endsWith(`/${suffix}`));
|
|
364
|
+
return hits.length === 1 ? hits[0] : null; // two files may not become a guess
|
|
365
|
+
};
|
|
366
|
+
// go.mod discovery: injected via opts when the caller knows it; otherwise walked up from the
|
|
367
|
+
// first Go file's ABSOLUTE path (bounded), so production builds resolve without extra wiring.
|
|
368
|
+
let goModCache;
|
|
369
|
+
const goModuleOf = () => {
|
|
370
|
+
if (opts?.goModule)
|
|
371
|
+
return opts.goModule;
|
|
372
|
+
if (goModCache !== undefined)
|
|
373
|
+
return goModCache ?? undefined;
|
|
374
|
+
goModCache = null;
|
|
375
|
+
const anyGo = scanned.find((f) => /\.go$/.test(f.sourcePath) && f.path);
|
|
376
|
+
if (anyGo) {
|
|
377
|
+
let dir = path.dirname(anyGo.path);
|
|
378
|
+
for (let hops = 0; hops < 12; hops++) {
|
|
379
|
+
try {
|
|
380
|
+
const txt = fsReadGoMod(path.join(dir, 'go.mod'));
|
|
381
|
+
if (txt !== null) {
|
|
382
|
+
goModCache = txt;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch { /* keep walking */ }
|
|
387
|
+
const up = path.dirname(dir);
|
|
388
|
+
if (up === dir)
|
|
389
|
+
break;
|
|
390
|
+
dir = up;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return goModCache ?? undefined;
|
|
394
|
+
};
|
|
395
|
+
const goPackage = (spec) => {
|
|
396
|
+
const mod = goModuleOf();
|
|
397
|
+
if (!mod || !(spec === mod || spec.startsWith(`${mod}/`)))
|
|
398
|
+
return []; // external, honestly
|
|
399
|
+
const dir = spec === mod ? '' : spec.slice(mod.length + 1);
|
|
400
|
+
const prefix = dir === '' ? '' : `${dir}/`;
|
|
401
|
+
return knownList.filter((k) => k.startsWith(prefix) && k.endsWith('.go')
|
|
402
|
+
&& !k.endsWith('_test.go') && !k.slice(prefix.length).includes('/'));
|
|
403
|
+
};
|
|
404
|
+
const rustResolve = (fromFile, spec) => {
|
|
405
|
+
const segs = spec.split('::');
|
|
406
|
+
const head = segs.shift();
|
|
407
|
+
let baseDir;
|
|
408
|
+
if (head === 'crate') {
|
|
409
|
+
// the crate root is the src/ directory nearest above the importing file
|
|
410
|
+
const m = fromFile.match(/^(.*?src)\//);
|
|
411
|
+
baseDir = m ? m[1] : 'src';
|
|
412
|
+
}
|
|
413
|
+
else if (head === 'super') {
|
|
414
|
+
baseDir = path.posix.dirname(path.posix.dirname(fromFile));
|
|
415
|
+
while (segs[0] === 'super') {
|
|
416
|
+
segs.shift();
|
|
417
|
+
baseDir = path.posix.dirname(baseDir);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (head === 'self') {
|
|
421
|
+
baseDir = path.posix.dirname(fromFile);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
return null; // external crate
|
|
425
|
+
}
|
|
426
|
+
const tryPath = (parts) => {
|
|
427
|
+
if (parts.length === 0)
|
|
428
|
+
return null;
|
|
429
|
+
const base = path.posix.normalize(path.posix.join(baseDir, ...parts));
|
|
430
|
+
if (known.has(`${base}.rs`))
|
|
431
|
+
return `${base}.rs`;
|
|
432
|
+
if (known.has(`${base}/mod.rs`))
|
|
433
|
+
return `${base}/mod.rs`;
|
|
434
|
+
return null;
|
|
435
|
+
};
|
|
436
|
+
// the last segment may be an ITEM, not a module — drop it once and retry
|
|
437
|
+
return tryPath(segs) ?? tryPath(segs.slice(0, -1));
|
|
438
|
+
};
|
|
349
439
|
const resolve = (fromFile, spec) => {
|
|
440
|
+
// @implements A-SPEC-525.1 — language branches BEFORE the JS/Python-shaped fallthrough.
|
|
441
|
+
if (/\.go$/.test(fromFile))
|
|
442
|
+
return null; // Go fans out separately (a package is its files)
|
|
443
|
+
if (/\.rs$/.test(fromFile))
|
|
444
|
+
return rustResolve(fromFile, spec);
|
|
445
|
+
if (/\.java$/.test(fromFile))
|
|
446
|
+
return uniqueSuffix(`${spec.split('.').join('/')}.java`);
|
|
447
|
+
if (/\.cs$/.test(fromFile))
|
|
448
|
+
return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
|
|
449
|
+
if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
|
|
450
|
+
// extension preserved: the dotted split below would butcher `util/env.h`
|
|
451
|
+
const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
|
|
452
|
+
if (known.has(relative))
|
|
453
|
+
return relative;
|
|
454
|
+
return known.has(spec) ? spec : null; // repo-root-relative include
|
|
455
|
+
}
|
|
350
456
|
// @implements A-SPEC-406
|
|
351
457
|
// A specifier without a leading dot used to be refused outright, on the ground that it names an
|
|
352
458
|
// external package. That holds for `node:fs` and `js-yaml`; it does NOT hold for Python, which
|
|
@@ -381,6 +487,17 @@ function addImportEdges(scanned, graph, opts) {
|
|
|
381
487
|
for (const e of f.edges ?? []) {
|
|
382
488
|
if (e.rel !== 'imports')
|
|
383
489
|
continue;
|
|
490
|
+
// @implements A-SPEC-525.1 — Go: importing a package imports ALL its files (that is the
|
|
491
|
+
// language's semantics, not an invention), so one specifier fans out to each scanned
|
|
492
|
+
// file of the package directory.
|
|
493
|
+
if (/\.go$/.test(f.sourcePath)) {
|
|
494
|
+
for (const target of goPackage(e.to)) {
|
|
495
|
+
graph.addNode(`FILE:${f.sourcePath}`, 'FILE', f.sourcePath, fact(opts, f.sourcePath, 'ast-scan', 1));
|
|
496
|
+
graph.addNode(`FILE:${target}`, 'FILE', target, fact(opts, target, 'ast-scan', 1));
|
|
497
|
+
graph.addEdge(`FILE:${f.sourcePath}`, `FILE:${target}`, 'imports', f.sourcePath, fact(opts, `${f.sourcePath} -> ${e.to}`, 'import-resolution', 1));
|
|
498
|
+
}
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
384
501
|
const target = resolve(f.sourcePath, e.to);
|
|
385
502
|
if (!target)
|
|
386
503
|
continue;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* positives.
|
|
14
14
|
*/
|
|
15
15
|
import type { DataFlowTaintConfig } from './dataflow-taint';
|
|
16
|
-
export declare const TAINT_VOCABULARY: Record<'typescript' | 'python', DataFlowTaintConfig>;
|
|
16
|
+
export declare const TAINT_VOCABULARY: Record<'typescript' | 'python' | 'go' | 'java' | 'csharp' | 'rust' | 'cpp', DataFlowTaintConfig>;
|
|
17
17
|
/**
|
|
18
18
|
* The vocabulary this file should be judged with, or null when the file is outside the analysable
|
|
19
19
|
* set. Null is deliberate: degrading an unknown language to the TypeScript list would produce
|
|
@@ -35,9 +35,44 @@ const PYTHON_VOCABULARY = {
|
|
|
35
35
|
],
|
|
36
36
|
sanitizers: ['quote', 'escape', 'shlex.quote', 're.escape'],
|
|
37
37
|
};
|
|
38
|
+
// @implements A-SPEC-524.1 — five more rows. Same table discipline: sinks are matched on the
|
|
39
|
+
// callee's LAST segment, sources against expression TEXT, and the vocabularies are SELECTED by
|
|
40
|
+
// file extension, never unioned. Rust's `Command::new` is deliberately absent — its bare last
|
|
41
|
+
// segment is 'new', far too general to be a sink; the EXECUTING methods (spawn/output/status)
|
|
42
|
+
// are the sinks instead (sealed in REQ-524).
|
|
43
|
+
const GO_VOCABULARY = {
|
|
44
|
+
sources: ['os.Getenv', 'os.Environ', 'os.Args', '.FormValue', 'URL.Query', 'PostForm'],
|
|
45
|
+
sinks: ['Command', 'CommandContext', 'StartProcess', 'Exec', 'Query'],
|
|
46
|
+
sanitizers: ['Quote', 'QuoteToASCII', 'EscapeString', 'QueryEscape'],
|
|
47
|
+
};
|
|
48
|
+
const JAVA_VOCABULARY = {
|
|
49
|
+
sources: ['System.getenv', 'System.getProperty', 'getParameter', 'getHeader', 'readLine'],
|
|
50
|
+
sinks: ['exec', 'ProcessBuilder', 'executeQuery', 'executeUpdate', 'eval', 'loadLibrary'],
|
|
51
|
+
sanitizers: ['escapeHtml', 'quoteReplacement', 'encode'],
|
|
52
|
+
};
|
|
53
|
+
const CSHARP_VOCABULARY = {
|
|
54
|
+
sources: ['GetEnvironmentVariable', 'ReadLine', 'QueryString', 'Form'],
|
|
55
|
+
sinks: ['Start', 'ExecuteReader', 'ExecuteNonQuery', 'ExecuteScalar', 'Deserialize'],
|
|
56
|
+
sanitizers: ['HtmlEncode', 'UrlEncode', 'EscapeDataString'],
|
|
57
|
+
};
|
|
58
|
+
const RUST_VOCABULARY = {
|
|
59
|
+
sources: ['env::var', 'env::args', 'read_to_string', 'stdin'],
|
|
60
|
+
sinks: ['spawn', 'output', 'status', 'exec', 'query'],
|
|
61
|
+
sanitizers: ['escape', 'quote'],
|
|
62
|
+
};
|
|
63
|
+
const CPP_VOCABULARY = {
|
|
64
|
+
sources: ['getenv', 'argv', 'cin', 'fgets'],
|
|
65
|
+
sinks: ['system', 'popen', 'exec', 'execl', 'execlp', 'execle', 'execv', 'execvp', 'ShellExecute'],
|
|
66
|
+
sanitizers: ['escape', 'quote'],
|
|
67
|
+
};
|
|
38
68
|
exports.TAINT_VOCABULARY = {
|
|
39
69
|
typescript: TYPESCRIPT_VOCABULARY,
|
|
40
70
|
python: PYTHON_VOCABULARY,
|
|
71
|
+
go: GO_VOCABULARY,
|
|
72
|
+
java: JAVA_VOCABULARY,
|
|
73
|
+
csharp: CSHARP_VOCABULARY,
|
|
74
|
+
rust: RUST_VOCABULARY,
|
|
75
|
+
cpp: CPP_VOCABULARY,
|
|
41
76
|
};
|
|
42
77
|
const TS_EXT = /\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/i;
|
|
43
78
|
const PY_EXT = /\.py$/i;
|
|
@@ -52,6 +87,17 @@ function vocabularyFor(relPath) {
|
|
|
52
87
|
return exports.TAINT_VOCABULARY.typescript;
|
|
53
88
|
if (PY_EXT.test(relPath))
|
|
54
89
|
return exports.TAINT_VOCABULARY.python;
|
|
90
|
+
// @implements A-SPEC-524.1 — the five languages whose CFG/DDG landed in P2.
|
|
91
|
+
if (/\.go$/i.test(relPath))
|
|
92
|
+
return exports.TAINT_VOCABULARY.go;
|
|
93
|
+
if (/\.rs$/i.test(relPath))
|
|
94
|
+
return exports.TAINT_VOCABULARY.rust;
|
|
95
|
+
if (/\.java$/i.test(relPath))
|
|
96
|
+
return exports.TAINT_VOCABULARY.java;
|
|
97
|
+
if (/\.cs$/i.test(relPath))
|
|
98
|
+
return exports.TAINT_VOCABULARY.csharp;
|
|
99
|
+
if (/\.(cpp|cc|cxx|hpp|h)$/i.test(relPath))
|
|
100
|
+
return exports.TAINT_VOCABULARY.cpp;
|
|
55
101
|
return null;
|
|
56
102
|
}
|
|
57
103
|
/** Languages this vocabulary table covers — the matrix derives its taint row from this. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@implements A-SPEC-209",
|
|
3
3
|
"name": "@holmes-lab/holmes-kit",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|