gitnexus 1.6.8-rc.21 → 1.6.8-rc.23

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.
Files changed (37) hide show
  1. package/dist/core/incremental/subgraph-extract.js +12 -1
  2. package/dist/core/ingestion/pipeline-phases/index.d.ts +1 -0
  3. package/dist/core/ingestion/pipeline-phases/index.js +1 -0
  4. package/dist/core/ingestion/pipeline-phases/taint-summaries.d.ts +30 -0
  5. package/dist/core/ingestion/pipeline-phases/taint-summaries.js +81 -0
  6. package/dist/core/ingestion/pipeline.d.ts +13 -0
  7. package/dist/core/ingestion/pipeline.js +5 -1
  8. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +7 -0
  9. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +15 -0
  10. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +17 -0
  11. package/dist/core/ingestion/scope-resolution/pipeline/run.js +37 -1
  12. package/dist/core/ingestion/taint/interproc-emit.d.ts +50 -0
  13. package/dist/core/ingestion/taint/interproc-emit.js +88 -0
  14. package/dist/core/ingestion/taint/interproc-solver.d.ts +110 -0
  15. package/dist/core/ingestion/taint/interproc-solver.js +312 -0
  16. package/dist/core/ingestion/taint/propagate.d.ts +1 -1
  17. package/dist/core/ingestion/taint/propagate.js +4 -9
  18. package/dist/core/ingestion/taint/source-sink-config.d.ts +13 -0
  19. package/dist/core/ingestion/taint/source-sink-config.js +24 -1
  20. package/dist/core/ingestion/taint/summary-harvest-driver.d.ts +56 -0
  21. package/dist/core/ingestion/taint/summary-harvest-driver.js +118 -0
  22. package/dist/core/ingestion/taint/summary-harvest.d.ts +89 -0
  23. package/dist/core/ingestion/taint/summary-harvest.js +537 -0
  24. package/dist/core/ingestion/taint/summary-model.d.ts +200 -0
  25. package/dist/core/ingestion/taint/summary-model.js +86 -0
  26. package/dist/core/lbug/lbug-adapter.d.ts +18 -0
  27. package/dist/core/lbug/lbug-adapter.js +55 -0
  28. package/dist/core/run-analyze.d.ts +7 -1
  29. package/dist/core/run-analyze.js +21 -1
  30. package/dist/mcp/local/local-backend.js +93 -10
  31. package/dist/mcp/tools.js +7 -6
  32. package/dist/storage/repo-manager.d.ts +10 -0
  33. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +89 -3
  34. package/hooks/claude/gitnexus-hook.cjs +88 -4
  35. package/hooks/claude/hook-db-lock-probe.cjs +53 -21
  36. package/package.json +1 -1
  37. package/skills/gitnexus-taint-analysis.md +178 -0
@@ -24,7 +24,10 @@ const fs = require('fs');
24
24
  const path = require('path');
25
25
  const { spawnSync } = require('child_process');
26
26
  const { acquireHookSlot } = require('./hook-lock.cjs');
27
- const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
27
+ const {
28
+ hasGitNexusDbLockedByGitNexusServer,
29
+ resolveUnixGuardTimeout,
30
+ } = require('./hook-db-lock-probe.cjs');
28
31
  const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
29
32
 
30
33
  function readInput() {
@@ -198,10 +201,73 @@ function resolveCliPath() {
198
201
  return cliPath;
199
202
  }
200
203
 
204
+ // Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
205
+ // at most one line per (short-lived) hook process, even if a future change
206
+ // runs the CLI more than once.
207
+ let unguardedCliWarned = false;
208
+
209
+ /**
210
+ * Unix orphan containment (#2163 follow-up): the augment CLI is the
211
+ * longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
212
+ * npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
213
+ * wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
214
+ * seconds — STRICTLY greater than the inner spawnSync timeout, so on the
215
+ * supervised path Node's SIGTERM always fires first and the existing
216
+ * error/status contract is untouched. Once the hook itself has been
217
+ * SIGKILLed (exactly the orphan case the wrapper exists for), the guard
218
+ * semantics differ per branch:
219
+ * - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
220
+ * SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
221
+ * before the `-k` SIGKILL escalation reaps it.
222
+ * - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
223
+ * budget expiry SIGKILLs the whole process group outright. TERM-first
224
+ * would kill only the obedient npx parent, making `timeout` reap it and
225
+ * return before the `-k` escalation ever fires, stranding a
226
+ * SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
227
+ * 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
228
+ * `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
229
+ * on this branch, both bounded by "no worse than pre-fix" (where the
230
+ * grandchild received no signal at all): the group-wide SIGKILL is
231
+ * coreutils semantics — a busybox `timeout` passes the self-test (it
232
+ * has `-k` and propagates exit status) but signals only its direct
233
+ * child, so a busybox guard cannot reach the grandchild; and on the
234
+ * SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
235
+ * guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
236
+ * and the guard exits before any KILL fires — so a SIGTERM-immune CLI
237
+ * grandchild still escapes in those two cases.
238
+ * If the sibling probe predates the resolveUnixGuardTimeout export (version
239
+ * skew), the adapter degrades to the unwrapped invocation instead of
240
+ * throwing. Windows is deliberately NOT wrapped — there is no coreutils
241
+ * timeout to resolve there and the resolver's self-test spawns /bin/sh — so
242
+ * on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
243
+ * macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
244
+ * the argv stays byte-identical to the pre-wrap invocation.
245
+ */
201
246
  function runGitNexusCli(cliPath, args, cwd, timeout) {
202
247
  const isWin = process.platform === 'win32';
248
+ // Version-skew guard (#2163 follow-up review): an older sibling probe
249
+ // without the resolveUnixGuardTimeout export must degrade to the unwrapped
250
+ // invocation — a TypeError here would be swallowed by the caller's catch
251
+ // and silently kill the augment.
252
+ const guard =
253
+ isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
254
+ if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
255
+ // Diagnose the "stays unwrapped" Unix paths once per hook process: no
256
+ // usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
257
+ // coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
258
+ unguardedCliWarned = true;
259
+ process.stderr.write(
260
+ '[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
261
+ );
262
+ }
203
263
  if (cliPath) {
204
- return spawnSync(process.execPath, [cliPath, ...args], {
264
+ const [cmd, cmdArgs] = guard
265
+ ? [
266
+ guard,
267
+ ['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
268
+ ]
269
+ : [process.execPath, [cliPath, ...args]];
270
+ return spawnSync(cmd, cmdArgs, {
205
271
  encoding: 'utf-8',
206
272
  timeout,
207
273
  cwd,
@@ -209,7 +275,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
209
275
  windowsHide: true,
210
276
  });
211
277
  }
212
- return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
278
+ // A non-null guard implies non-Windows, so the wrapped arm can hardcode
279
+ // plain `npx`. The wrapped arm leads with `-s KILL` (NOT TERM-first like
280
+ // the direct branch above): the CLI here is a grandchild behind npx — see
281
+ // the docblock.
282
+ const [cmd, cmdArgs] = guard
283
+ ? [
284
+ guard,
285
+ [
286
+ '-s',
287
+ 'KILL',
288
+ '-k',
289
+ '1',
290
+ String(Math.ceil((timeout + 5000) / 1000) + 1),
291
+ 'npx',
292
+ '-y',
293
+ 'gitnexus',
294
+ ...args,
295
+ ],
296
+ ]
297
+ : [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
298
+ return spawnSync(cmd, cmdArgs, {
213
299
  encoding: 'utf-8',
214
300
  timeout: timeout + 5000,
215
301
  cwd,
@@ -15,7 +15,10 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { spawnSync } = require('child_process');
17
17
  const { acquireHookSlot } = require('./hook-lock.cjs');
18
- const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
18
+ const {
19
+ hasGitNexusDbLockedByGitNexusServer,
20
+ resolveUnixGuardTimeout,
21
+ } = require('./hook-db-lock-probe.cjs');
19
22
  const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
20
23
 
21
24
  /**
@@ -218,14 +221,76 @@ function resolveCliPath() {
218
221
  return cliPath;
219
222
  }
220
223
 
224
+ // Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
225
+ // at most one line per (short-lived) hook process, even if a future change
226
+ // runs the CLI more than once.
227
+ let unguardedCliWarned = false;
228
+
221
229
  /**
222
230
  * Spawn a gitnexus CLI command synchronously.
223
231
  * Returns the stderr output (KuzuDB captures stdout at OS level).
232
+ *
233
+ * Unix orphan containment (#2163 follow-up): the augment CLI is the
234
+ * longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
235
+ * npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
236
+ * wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
237
+ * seconds — STRICTLY greater than the inner spawnSync timeout, so on the
238
+ * supervised path Node's SIGTERM always fires first and the existing
239
+ * error/status contract is untouched. Once the hook itself has been
240
+ * SIGKILLed (exactly the orphan case the wrapper exists for), the guard
241
+ * semantics differ per branch:
242
+ * - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
243
+ * SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
244
+ * before the `-k` SIGKILL escalation reaps it.
245
+ * - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
246
+ * budget expiry SIGKILLs the whole process group outright. TERM-first
247
+ * would kill only the obedient npx parent, making `timeout` reap it and
248
+ * return before the `-k` escalation ever fires, stranding a
249
+ * SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
250
+ * 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
251
+ * `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
252
+ * on this branch, both bounded by "no worse than pre-fix" (where the
253
+ * grandchild received no signal at all): the group-wide SIGKILL is
254
+ * coreutils semantics — a busybox `timeout` passes the self-test (it
255
+ * has `-k` and propagates exit status) but signals only its direct
256
+ * child, so a busybox guard cannot reach the grandchild; and on the
257
+ * SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
258
+ * guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
259
+ * and the guard exits before any KILL fires — so a SIGTERM-immune CLI
260
+ * grandchild still escapes in those two cases.
261
+ * If the sibling probe predates the resolveUnixGuardTimeout export (version
262
+ * skew), the adapter degrades to the unwrapped invocation instead of
263
+ * throwing. Windows is deliberately NOT wrapped — there is no coreutils
264
+ * timeout to resolve there and the resolver's self-test spawns /bin/sh — so
265
+ * on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
266
+ * macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
267
+ * the argv stays byte-identical to the pre-wrap invocation.
224
268
  */
225
269
  function runGitNexusCli(cliPath, args, cwd, timeout) {
226
270
  const isWin = process.platform === 'win32';
271
+ // Version-skew guard (#2163 follow-up review): an older sibling probe
272
+ // without the resolveUnixGuardTimeout export must degrade to the unwrapped
273
+ // invocation — a TypeError here would be swallowed by the caller's catch
274
+ // and silently kill the augment.
275
+ const guard =
276
+ isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
277
+ if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
278
+ // Diagnose the "stays unwrapped" Unix paths once per hook process: no
279
+ // usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
280
+ // coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
281
+ unguardedCliWarned = true;
282
+ process.stderr.write(
283
+ '[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
284
+ );
285
+ }
227
286
  if (cliPath) {
228
- return spawnSync(process.execPath, [cliPath, ...args], {
287
+ const [cmd, cmdArgs] = guard
288
+ ? [
289
+ guard,
290
+ ['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
291
+ ]
292
+ : [process.execPath, [cliPath, ...args]];
293
+ return spawnSync(cmd, cmdArgs, {
229
294
  encoding: 'utf-8',
230
295
  timeout,
231
296
  cwd,
@@ -233,8 +298,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
233
298
  windowsHide: true,
234
299
  });
235
300
  }
236
- // On Windows, invoke npx.cmd directly (no shell needed)
237
- return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
301
+ // On Windows, invoke npx.cmd directly (no shell needed). A non-null guard
302
+ // implies non-Windows, so the wrapped arm can hardcode plain `npx`. The
303
+ // wrapped arm leads with `-s KILL` (NOT TERM-first like the direct branch
304
+ // above): the CLI here is a grandchild behind npx — see the docblock.
305
+ const [cmd, cmdArgs] = guard
306
+ ? [
307
+ guard,
308
+ [
309
+ '-s',
310
+ 'KILL',
311
+ '-k',
312
+ '1',
313
+ String(Math.ceil((timeout + 5000) / 1000) + 1),
314
+ 'npx',
315
+ '-y',
316
+ 'gitnexus',
317
+ ...args,
318
+ ],
319
+ ]
320
+ : [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
321
+ return spawnSync(cmd, cmdArgs, {
238
322
  encoding: 'utf-8',
239
323
  timeout: timeout + 5000,
240
324
  cwd,
@@ -20,13 +20,18 @@
20
20
  * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
21
21
  * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
22
22
  * wrapper off deterministically; any other value is adopted only when it
23
- * exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
24
- * THROUGH to the built-in candidate list (first self-test pass wins), so
25
- * no malformed value of any shape can silently disable orphan containment.
23
+ * exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
24
+ * resolution FALLS THROUGH to the built-in candidate list (first self-test
25
+ * pass wins), so no malformed value of any shape can silently disable
26
+ * orphan containment.
26
27
  * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
27
28
  * ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
28
29
  * A probe before that first query is therefore always false — a known,
29
30
  * pre-existing race, not a bug in this probe.
31
+ * - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
32
+ * `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
33
+ * local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
34
+ * in the adapters (#2163 follow-up).
30
35
  */
31
36
 
32
37
  const fs = require('fs');
@@ -70,38 +75,53 @@ let unixGuardTimeoutCache;
70
75
 
71
76
  /**
72
77
  * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
73
- * (#2163). Dead code on Windows (the win32 dispatch returns earlier).
78
+ * (#2163). Unix-only by contract: the probe's win32 dispatch returns before
79
+ * reaching it, and the exported callers (the adapters' runGitNexusCli,
80
+ * #2163 follow-up) must check the platform first — the self-test below
81
+ * spawns /bin/sh. The memoized result is module-wide, so probe and adapter
82
+ * share one lazy self-test per hook process.
74
83
  *
75
84
  * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
76
85
  * wrapper off; any other value is only a CANDIDATE — an existing file path
77
- * is tried first, but it must pass the `-k` self-test to be adopted. On any
78
- * failure (non-existent path, directory, non-executable file, wrapper
79
- * without `-k` support, …) resolution falls through to the built-in
80
- * candidates below, tried in order, first self-test pass wins. This is
81
- * strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
82
- * GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
83
- * value of ANY shape can silently disable orphan containment.
86
+ * is tried first, but it must pass the `-k` exit-propagation self-test to
87
+ * be adopted. On any failure (non-existent path, directory, non-executable
88
+ * file, wrapper without `-k` support, always-exit-0 stub, …) resolution
89
+ * falls through to the built-in candidates below, tried in order, first
90
+ * self-test pass wins. This is strictly stronger than the sibling
91
+ * GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
92
+ * check existence): no bad env value of ANY shape can silently disable
93
+ * orphan containment.
84
94
  *
85
95
  * Lazy self-test: candidates are probed only when the lsof/ps fallback is
86
96
  * first reached, and the result is memoized. A candidate is adopted only
87
- * when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
88
- * not support the coreutils `-k` flag busybox <1.34, toybox, broken
89
- * symlinks which would otherwise exit with a usage error without ever
90
- * running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
91
- * into fail-open (#1492 regression). Only when EVERY candidate fails does
92
- * the probe fall back to the unwrapped status quo (memoized null).
93
- * busybox ≥1.34 passes the test and is fully usable (capability, not
94
- * identity, decides).
97
+ * when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 i.e. it must RUN
98
+ * the wrapped command AND PROPAGATE its exit status. This rejects two
99
+ * failure shapes: wrappers without the coreutils `-k` flag busybox <1.34,
100
+ * toybox, broken symlinks which would exit with a usage error without
101
+ * ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
102
+ * contract into fail-open (#1492 regression); and always-exit-0 stubs
103
+ * (/bin/true shapes), which would otherwise be adopted and "succeed" every
104
+ * wrapped spawn instantly without running it — a constant no-owner probe
105
+ * answer and, worse, a silently dead augment (status 0, empty stderr passes
106
+ * the adapters' success check with no context; #2163 follow-up review).
107
+ * Only when EVERY candidate fails does the probe fall back to the unwrapped
108
+ * status quo (memoized null). busybox ≥1.34 passes the test and is fully
109
+ * usable for everything THIS file spawns (lsof/ps are the guard's direct
110
+ * children) and for the adapters' direct-exec arm. The adapters' npx arm
111
+ * additionally relies on coreutils' process-GROUP signalling for its
112
+ * `-s KILL` grandchild reaping; busybox signals only its direct child, and
113
+ * this self-test deliberately does not probe that capability — see the
114
+ * adapter docblocks for the residual-gap statement.
95
115
  */
96
116
  function passesGuardSelfTest(guard) {
97
117
  try {
98
- const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
118
+ const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
99
119
  encoding: 'utf-8',
100
120
  timeout: 3000,
101
121
  stdio: ['ignore', 'ignore', 'ignore'],
102
122
  windowsHide: true,
103
123
  });
104
- return !selfTest.error && selfTest.status === 0;
124
+ return !selfTest.error && selfTest.status === 42;
105
125
  } catch {
106
126
  return false;
107
127
  }
@@ -359,4 +379,16 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
359
379
 
360
380
  module.exports = {
361
381
  hasGitNexusDbLockedByGitNexusServer,
382
+ // #2163 follow-up: the hook adapters wrap the augment CLI in the same
383
+ // guard. Returns a self-tested wrapper path — the built-in candidates are
384
+ // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
385
+ // exact string that passed the self-test. Same string is also the same
386
+ // RESOLUTION for absolute paths and for slashless names (PATH lookup is
387
+ // cwd-independent); a slash-containing RELATIVE override, however, is
388
+ // existsSync-checked and self-tested against this process's cwd while the
389
+ // adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
390
+ // a value can pass here yet ENOENT at the augment call site — set the
391
+ // override to an absolute path. Returns null when the wrapper is
392
+ // disabled/unavailable. Never call on win32 (see its JSDoc).
393
+ resolveUnixGuardTimeout,
362
394
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.21",
3
+ "version": "1.6.8-rc.23",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -0,0 +1,178 @@
1
+ ---
2
+ name: gitnexus-taint-analysis
3
+ description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
4
+ ---
5
+
6
+ # CFG & Taint Analysis with GitNexus
7
+
8
+ Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
9
+ graphs, reaching definitions, and intra- + inter-procedural taint. Read this
10
+ before touching `gitnexus/src/core/ingestion/cfg/**` or
11
+ `gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
12
+
13
+ ## When to Use
14
+
15
+ - "How does the taint engine work / why is this flow (not) reported?"
16
+ - Adding a source, sink, or sanitizer to the model.
17
+ - Extending or reviewing the CFG / reaching-defs / taint / summary code.
18
+ - Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
19
+ - Debugging a false positive or false negative in `--pdg` output.
20
+
21
+ ## The layered substrate (build order)
22
+
23
+ Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
24
+ and a default `analyze` run is **byte-identical** (the golden parity gate is the
25
+ hard floor for every change here).
26
+
27
+ ```
28
+ L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
29
+ L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
30
+ L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
31
+ L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
32
+ ```
33
+
34
+ - **Worker-built, main-thread-solved.** The parse worker builds each function's
35
+ CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
36
+ (plain, structured-clone-safe data — never AST nodes). The main thread runs
37
+ the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
38
+ OOM).
39
+ - **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
40
+ pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
41
+ because the disk-backed ParsedFile store is cleared when that phase ends — a
42
+ standalone post-`mro` phase would read empty data. The cross-function fixpoint
43
+ (L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
44
+ AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
45
+ small plain summary data threaded out via `ScopeResolutionOutput`.
46
+ - **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
47
+ `harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
48
+ (no graph, no I/O, no logger; sorted outputs). Snapshot tests and
49
+ content-derived edge ids depend on it.
50
+
51
+ ## Intra-procedural taint (L3)
52
+
53
+ Forward reachability over RD facts from matched **sources** to matched **sinks**,
54
+ killed by **sanitizers**. Key design points worth internalizing:
55
+
56
+ - **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
57
+ `exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
58
+ call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
59
+ precise.
60
+ - **Kind-set sanitizer model.** A taint carries a set of *neutralized*
61
+ `SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
62
+ suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
63
+ kill would be a suppressed live injection (the forbidden FN direction).
64
+ `path.basename(t)` neutralizes path-traversal only, not command-injection.
65
+ - **Statement-level finding identity.** NOT block-pair (block conflation drops
66
+ distinct findings; `exec(req.body, req.query)` is two findings).
67
+ - Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
68
+ `reason` column via the shared versioned codec (`taint/path-codec.ts`).
69
+
70
+ ## Interprocedural taint (L4) — the functional/summary method
71
+
72
+ The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
73
+ Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
74
+ reduced to a compact **summary**, and summaries are composed over the already-
75
+ resolved `CALLS` graph.
76
+
77
+ **Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
78
+
79
+ | Edge | Meaning | Analogue |
80
+ |------|---------|----------|
81
+ | `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
82
+ | `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
83
+ | `param→sink` | a param reaches a modelled sink | partial/triggered sink |
84
+ | `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
85
+ | `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
86
+ | `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
87
+
88
+ **The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
89
+ parameter, source)`. Seed from `source→callee-arg`, propagate via
90
+ `param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
91
+
92
+ - **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
93
+ lattice (`fn × param × source`), so the worklist converges — a recursive call
94
+ just re-proposes an already-visited entry. SCC condensation would only refine
95
+ processing order; correctness/termination don't require it.
96
+ - **Source-discriminated state (load-bearing).** Key the state by the SOURCE
97
+ too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
98
+ tainted by source A is marked visited and a later flow from source B is dropped
99
+ before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
100
+ - **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
101
+ CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
102
+ site) is fragile; the callee identity is exact and context-insensitivity
103
+ taints the callee's param identically at every call site.
104
+ - Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
105
+ in `reason` via the same codec; confidence < the intra-procedural 1.0.
106
+
107
+ **Context-insensitivity** is the accepted trade-off at this tier: one summary
108
+ per function, return/call-site merging accepted (security-conservative). Expect
109
+ some FP from merging; the bigger FN sources are unmodeled features (below).
110
+
111
+ ## Known false-negative classes (documented, deferred)
112
+
113
+ The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
114
+ into a callback is dropped without per-library models (true of CodeQL's JS libs
115
+ too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
116
+ field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
117
+ flows, promise/async-await threading, and **destructured/rest params before a
118
+ tainted simple param** (the summary port index is the binding ordinal, not the
119
+ formal arg position — needs a formal-param index threaded from the worker
120
+ `BindingEntry`). The interprocedural join is also context-insensitive: when one
121
+ caller invokes two distinct **same-named callees**, a flow into one
122
+ over-attributes to both (sound — over-report, never a missed flow). Absence of a
123
+ finding is NOT proof of safety.
124
+
125
+ ## GitNexus-specific gotchas
126
+
127
+ - **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
128
+ `Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
129
+ have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
130
+ ambiguous → drop (the summary driver counts `unresolved`) rather than
131
+ cross-wire.
132
+ - **No rel-property index (S1).** Kuzu has no secondary index on relationship
133
+ properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
134
+ TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
135
+ traversed live; `explain` reads it source-anchored + LIMIT-guarded.
136
+ - **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
137
+ deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
138
+ schema (pinned in `security.test.ts`). `explain` enumerates both layers
139
+ (cross-function findings carry `interprocedural: true`).
140
+ - **One shared codec.** Both the emit path and `explain` import
141
+ `taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
142
+ fork it. New metadata extends the format WITHIN the version when writer +
143
+ reader ship together.
144
+ - **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
145
+ NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
146
+ Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
147
+ full writeback. Model content rides `taintModelVersion`.
148
+
149
+ ## Adding a source / sink / sanitizer
150
+
151
+ Edit the language model in `taint/typescript-model.ts` (registered via the
152
+ explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
153
+ spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
154
+ EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
155
+ finding (or its absence) in `test/unit/taint/` (real-source harness:
156
+ `test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
157
+ `test/integration/cfg/`.
158
+
159
+ ## Validation checklist for any `--pdg` change
160
+
161
+ ```
162
+ 1. tsc clean (schema additions are exhaustiveness-checked; watch the
163
+ api.ts getNodeQuery runtime read-path if a node label is added).
164
+ 2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
165
+ test/integration/cfg) — verify by ISOLATION, not full-suite exit
166
+ (known load-flakes). `node scripts/build.js` before worker/integration runs.
167
+ 3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
168
+ 4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
169
+ 5. detect_changes() before commit; impact({direction:'upstream'}) before
170
+ editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
171
+ ```
172
+
173
+ ## Prior art (for deeper design questions)
174
+
175
+ Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
176
+ FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
177
+ propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
178
+ notation, incl. callback ports); Infer (content-keyed incremental summaries).