User-supplied dir argument resolved via path.resolve
28 │ * guardlink tui [dir] Interactive TUI with slash commands + AI chat 29 │ * guardlink gal Display GAL annotation language quick reference 30 │ * guardlink link-project <repos...> Link repos into a shared workspace 31 │ * guardlink merge <files...> Merge repo reports into unified dashboard 32 │ * 33 │ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output"
resolve() canonicalizes paths; cwd-relative by design
29 │ * guardlink gal Display GAL annotation language quick reference 30 │ * guardlink link-project <repos...> Link repos into a shared workspace 31 │ * guardlink merge <files...> Merge repo reports into unified dashboard 32 │ * 33 │ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes"
init/report/sarif/dashboard write files to user-specified paths
30 │ * guardlink link-project <repos...> Link repos into a shared workspace 31 │ * guardlink merge <files...> Merge repo reports into unified dashboard 32 │ * 33 │ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args"
Output paths resolved relative to project root
31 │ * guardlink merge <files...> Merge repo reports into unified dashboard 32 │ * 33 │ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path"
API keys handled in config set/show commands
32 │ * 33 │ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path"
maskKey() redacts keys in show output
33 │ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing"
Agent launcher spawns child processes
34 │ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" 35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing" 44 │ * @handles secrets on #cli -- "Processes API keys via config commands"
Child process spawning delegated to agents/launcher.ts with explicit args
35 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "init/report/sarif/dashboard write files to user-specified paths" 36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing" 44 │ * @handles secrets on #cli -- "Processes API keys via config commands" 45 │ */
CLI argument input path
36 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing" 44 │ * @handles secrets on #cli -- "Processes API keys via config commands" 45 │ */ 46 │
Report/config output path
37 │ * @exposes #cli to #api-key-exposure [high] cwe:CWE-798 -- "API keys handled in config set/show commands" 38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing" 44 │ * @handles secrets on #cli -- "Processes API keys via config commands" 45 │ */ 46 │ 47 │ import { Command } from 'commander';
Trust boundary at CLI argument parsing
38 │ * @mitigates #cli against #api-key-exposure using #key-redaction -- "maskKey() redacts keys in show output" 39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing" 44 │ * @handles secrets on #cli -- "Processes API keys via config commands" 45 │ */ 46 │ 47 │ import { Command } from 'commander'; 48 │ import { resolve, basename, join, isAbsolute, relative } from 'node:path';
Processes API keys via config commands
39 │ * @exposes #cli to #cmd-injection [critical] cwe:CWE-78 -- "Agent launcher spawns child processes" 40 │ * @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args" 41 │ * @flows UserArgs -> #cli via process.argv -- "CLI argument input path" 42 │ * @flows #cli -> FileSystem via writeFile -- "Report/config output path" 43 │ * @boundary #cli and UserInput (#cli-input-boundary) -- "Trust boundary at CLI argument parsing" 44 │ * @handles secrets on #cli -- "Processes API keys via config commands" 45 │ */ 46 │ 47 │ import { Command } from 'commander'; 48 │ import { resolve, basename, join, isAbsolute, relative } from 'node:path'; 49 │ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
Opt-in attribution; without --blame the JSON is byte-identical to before
269 │ const { model, diagnostics } = await parseProject({ root, project: opts.project ?? readConfiguredProject(root) ?? undefined }); 270 │ 271 │ // Print diagnostics to stderr 272 │ printDiagnostics(diagnostics); 273 │ 274 │ // @flows GitRepo -> #cli via attachBlame -- "Opt-in attribution; without --blame the JSON is byte-identical to before" 275 │ if (opts.blame) attachBlame(root, model); 276 │ 277 │ // R10: stamp the export with the annotations it was cut from. `parse -o 278 │ // .guardlink/report.json` is how the model reaches the graph and everything 279 │ // downstream of it, and until now that file said nothing about which
status reports verified/stale/unverified claim counts alongside annotation coverage
323 │ model = filterByFeature(model, featureNames); 324 │ console.log(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}\n`); 325 │ } 326 │ 327 │ printDiagnostics(diagnostics); 328 │ // @flows LedgerFile -> #cli via readLedger -- "status reports verified/stale/unverified claim counts alongside annotation coverage" 329 │ // @flows LedgerFile -> #cli via readHypotheses -- "status reports how many exposures were tested, and with what outcome" 330 │ printStatus(model, classifyClaims(model, readLedger(root)), classifyHypotheses(model, readHypotheses(root)), checkHandoff(root, model)); 331 │ // @flows GitRepo -> #cli via computeBlame -- "status --blame reads attribution without touching the model" 332 │ if (opts.blame) printBlameSummary(buildBlamePayload(model, computeBlame(root, model), root)); 333 │
status reports how many exposures were tested, and with what outcome
324 │ console.log(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}\n`); 325 │ } 326 │ 327 │ printDiagnostics(diagnostics); 328 │ // @flows LedgerFile -> #cli via readLedger -- "status reports verified/stale/unverified claim counts alongside annotation coverage" 329 │ // @flows LedgerFile -> #cli via readHypotheses -- "status reports how many exposures were tested, and with what outcome" 330 │ printStatus(model, classifyClaims(model, readLedger(root)), classifyHypotheses(model, readHypotheses(root)), checkHandoff(root, model)); 331 │ // @flows GitRepo -> #cli via computeBlame -- "status --blame reads attribution without touching the model" 332 │ if (opts.blame) printBlameSummary(buildBlamePayload(model, computeBlame(root, model), root)); 333 │ 334 │ if (opts.notAnnotated) {
status --blame reads attribution without touching the model
326 │ 327 │ printDiagnostics(diagnostics); 328 │ // @flows LedgerFile -> #cli via readLedger -- "status reports verified/stale/unverified claim counts alongside annotation coverage" 329 │ // @flows LedgerFile -> #cli via readHypotheses -- "status reports how many exposures were tested, and with what outcome" 330 │ printStatus(model, classifyClaims(model, readLedger(root)), classifyHypotheses(model, readHypotheses(root)), checkHandoff(root, model)); 331 │ // @flows GitRepo -> #cli via computeBlame -- "status --blame reads attribution without touching the model" 332 │ if (opts.blame) printBlameSummary(buildBlamePayload(model, computeBlame(root, model), root)); 333 │ 334 │ if (opts.notAnnotated) { 335 │ printUnannotatedFiles(model); 336 │ }
Read-only; a corrupt ledger becomes a validate error (ledger-corrupt) instead of being silently treated as absent
399 │ // the file still parsed and every annotation in it counted. 400 │ const galConventionDiags = findOffConventionGalFiles(model); 401 │ 402 │ // A ledger that exists but cannot be read is an error: every surface that 403 │ // reads it is silently treating it as absent until someone fixes it. 404 │ // @flows LedgerFile -> #cli via readLedger -- "Read-only; a corrupt ledger becomes a validate error (ledger-corrupt) instead of being silently treated as absent" 405 │ // @comment -- "Closes the same blind spot ci's third check already closed (src/ci/index.ts) for the validate/CI-gate surface" 406 │ const ledgerRead = readLedger(root); 407 │ const ledgerDiags = ledgerRead.diagnostic ? [ledgerRead.diagnostic] : []; 408 │ 409 │ // Opt-in, so validate without the flag never consults a graph and prints what
Closes the same blind spot ci's third check already closed (src/ci/index.ts) for the validate/CI-gate surface
400 │ const galConventionDiags = findOffConventionGalFiles(model); 401 │ 402 │ // A ledger that exists but cannot be read is an error: every surface that 403 │ // reads it is silently treating it as absent until someone fixes it. 404 │ // @flows LedgerFile -> #cli via readLedger -- "Read-only; a corrupt ledger becomes a validate error (ledger-corrupt) instead of being silently treated as absent" 405 │ // @comment -- "Closes the same blind spot ci's third check already closed (src/ci/index.ts) for the validate/CI-gate surface" 406 │ const ledgerRead = readLedger(root); 407 │ const ledgerDiags = ledgerRead.diagnostic ? [ledgerRead.diagnostic] : []; 408 │ 409 │ // Opt-in, so validate without the flag never consults a graph and prints what 410 │ // it always printed. The checks are warnings: the graph's measurement
validate --code-graph: project root; ranked entry points with route access, or a status
409 │ // Opt-in, so validate without the flag never consults a graph and prints what 410 │ // it always printed. The checks are warnings: the graph's measurement 411 │ // disagrees with a claim a human wrote, and a human decides which is wrong. 412 │ let boundaryDiags: ParseDiagnostic[] = []; 413 │ if (opts.codeGraph) { 414 │ // @flows #cli -> #codegraph via loadWorklist -- "validate --code-graph: project root; ranked entry points with route access, or a status" 415 │ const worklist = await loadWorklist(root, model); 416 │ boundaryDiags = checkBoundaries(model, worklist); 417 │ console.error(boundaryCheckSummaryLine(worklist, boundaryDiags)); 418 │ } 419 │
The one command that writes .guardlink/verified.json
554 │ }); 555 │ 556 │ // ─── verify ────────────────────────────────────────────────────────── 557 │ 558 │ /** 559 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "The one command that writes .guardlink/verified.json" 560 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Path is the constant LEDGER_FILE under a root that must already carry .guardlink/; targets only select claims, they are never written to" 561 │ * @flows UserInput -> #cli via verify -- "Targets, --by and mode flags" 562 │ * @comment -- "Re-locking a stale claim is an assertion that the control still holds, so the default form never does it: --stale, --all or a named target is required, and each says so in its output" 563 │ */ 564 │ program
Path is the constant LEDGER_FILE under a root that must already carry .guardlink/; targets only select claims, they are never written to
555 │ 556 │ // ─── verify ────────────────────────────────────────────────────────── 557 │ 558 │ /** 559 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "The one command that writes .guardlink/verified.json" 560 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Path is the constant LEDGER_FILE under a root that must already carry .guardlink/; targets only select claims, they are never written to" 561 │ * @flows UserInput -> #cli via verify -- "Targets, --by and mode flags" 562 │ * @comment -- "Re-locking a stale claim is an assertion that the control still holds, so the default form never does it: --stale, --all or a named target is required, and each says so in its output" 563 │ */ 564 │ program 565 │ .command('verify')
Targets, --by and mode flags
556 │ // ─── verify ────────────────────────────────────────────────────────── 557 │ 558 │ /** 559 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "The one command that writes .guardlink/verified.json" 560 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Path is the constant LEDGER_FILE under a root that must already carry .guardlink/; targets only select claims, they are never written to" 561 │ * @flows UserInput -> #cli via verify -- "Targets, --by and mode flags" 562 │ * @comment -- "Re-locking a stale claim is an assertion that the control still holds, so the default form never does it: --stale, --all or a named target is required, and each says so in its output" 563 │ */ 564 │ program 565 │ .command('verify') 566 │ .description('Record that the code beneath each claim was checked — writes .guardlink/verified.json and nothing else')
Re-locking a stale claim is an assertion that the control still holds, so the default form never does it: --stale, --all or a named target is required, and each says so in its output
557 │ 558 │ /** 559 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "The one command that writes .guardlink/verified.json" 560 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Path is the constant LEDGER_FILE under a root that must already carry .guardlink/; targets only select claims, they are never written to" 561 │ * @flows UserInput -> #cli via verify -- "Targets, --by and mode flags" 562 │ * @comment -- "Re-locking a stale claim is an assertion that the control still holds, so the default form never does it: --stale, --all or a named target is required, and each says so in its output" 563 │ */ 564 │ program 565 │ .command('verify') 566 │ .description('Record that the code beneath each claim was checked — writes .guardlink/verified.json and nothing else') 567 │ .argument('[dir]', 'Project directory (default .). A file or file:line here is taken as a target.', '.')
Attribution read from blame, log -L and commit trailers
673 │ /** 674 │ * Who introduced the code beneath each claim, who declared the claim, who 675 │ * declared its fix, and which AI tool each of those commits credited — read 676 │ * from git history at run time. Nothing is written, not even a trailer. 677 │ * 678 │ * @flows GitRepo -> #cli via computeBlame -- "Attribution read from blame, log -L and commit trailers" 679 │ * @handles pii on #cli -- "Author identities printed or emitted as JSON; --identity hash (or blame.identity in config.json) redacts emails" 680 │ * @comment -- "Always exits 0 once the model parsed: outside a git checkout every claim reads as unattributed, which is an answer, not a failure. Only an unknown --identity is an error" 681 │ */ 682 │ program 683 │ .command('blame')
Author identities printed or emitted as JSON; --identity hash (or blame.identity in config.json) redacts emails
674 │ * Who introduced the code beneath each claim, who declared the claim, who 675 │ * declared its fix, and which AI tool each of those commits credited — read 676 │ * from git history at run time. Nothing is written, not even a trailer. 677 │ * 678 │ * @flows GitRepo -> #cli via computeBlame -- "Attribution read from blame, log -L and commit trailers" 679 │ * @handles pii on #cli -- "Author identities printed or emitted as JSON; --identity hash (or blame.identity in config.json) redacts emails" 680 │ * @comment -- "Always exits 0 once the model parsed: outside a git checkout every claim reads as unattributed, which is an answer, not a failure. Only an unknown --identity is an error" 681 │ */ 682 │ program 683 │ .command('blame') 684 │ .description('Who introduced, declared and fixed each claim, and which AI tool co-authored it — read from git, nothing written')
Always exits 0 once the model parsed: outside a git checkout every claim reads as unattributed, which is an answer, not a failure. Only an unknown --identity is an error
675 │ * declared its fix, and which AI tool each of those commits credited — read 676 │ * from git history at run time. Nothing is written, not even a trailer. 677 │ * 678 │ * @flows GitRepo -> #cli via computeBlame -- "Attribution read from blame, log -L and commit trailers" 679 │ * @handles pii on #cli -- "Author identities printed or emitted as JSON; --identity hash (or blame.identity in config.json) redacts emails" 680 │ * @comment -- "Always exits 0 once the model parsed: outside a git checkout every claim reads as unattributed, which is an answer, not a failure. Only an unknown --identity is an error" 681 │ */ 682 │ program 683 │ .command('blame') 684 │ .description('Who introduced, declared and fixed each claim, and which AI tool co-authored it — read from git, nothing written') 685 │ .argument('[dir]', 'Project directory to scan', '.')
report --blame attaches attribution before the metadata spread, so the shared records carry it
867 │ // Show errors if any. Per-annotation errors don't block the report — 868 │ // affected annotations are skipped, the rest of the model still renders. 869 │ const errors = diagnostics.filter(d => d.level === 'error'); 870 │ if (errors.length > 0) printDiagnostics(errors); 871 │ 872 │ // @flows GitRepo -> #cli via attachBlame -- "report --blame attaches attribution before the metadata spread, so the shared records carry it" 873 │ if (opts.blame) attachBlame(root, model); 874 │ 875 │ // Enrich with provenance metadata (git SHA, branch, workspace, schema version) 876 │ const enrichedModel = populateMetadata(model, root, diagnostics); 877 │
guardlink lookup is read-only: it parses the project as parse and validate do and answers with the same pure lookup() the guardlink_lookup MCP tool uses, so it adds no input surface beyond the query text, which lookup's anchored patterns already bound
1240 │ if (opts.failOnFound && findings.length > 0) process.exit(1);1241 │ });1242 │ 1243 │ // ─── lookup ──────────────────────────────────────────────────────────1244 │ 1245 │ // @comment -- "guardlink lookup is read-only: it parses the project as parse and validate do and answers with the same pure lookup() the guardlink_lookup MCP tool uses, so it adds no input surface beyond the query text, which lookup's anchored patterns already bound"1246 │ program1247 │ .command('lookup')1248 │ .description('Query the threat model with the guardlink_lookup forms, e.g. "unentitled reaches", "agents", "effects for #db", "cwe:CWE-89". Prints JSON; an unrecognised form lists the supported ones')1249 │ .argument('<query...>', 'Query form')1250 │ .option('-d, --dir <dir>', 'Project directory', '.')
--baseline reads a file the caller names, relative to the cwd like any CLI path argument; it is parsed as JSON and only compared, never written or executed
1280 │ const pentest = opts.profile === 'pentest';1281 │ if (opts.baseline && !pentest) {1282 │ console.error('--baseline needs --profile pentest: the github profile never carries baselineState');1283 │ process.exit(2);1284 │ }1285 │ // @comment -- "--baseline reads a file the caller names, relative to the cwd like any CLI path argument; it is parsed as JSON and only compared, never written or executed"1286 │ let baseline: unknown;1287 │ if (opts.baseline) {1288 │ try {1289 │ baseline = JSON.parse(readFileSync(resolve(opts.baseline), 'utf-8'));1290 │ } catch (e) {
pentest profile: each claim's tested state goes into the SARIF
1295 │ const { model, diagnostics } = await parseProject({ root, project: opts.project ?? readConfiguredProject(root) ?? undefined });1296 │ 1297 │ // Compute dangling refs (reuse validate logic)1298 │ const danglingDiags = findDanglingRefs(model);1299 │ 1300 │ // @flows LedgerFile -> #cli via readHypotheses -- "pentest profile: each claim's tested state goes into the SARIF"1301 │ const hypotheses = pentest ? readHypotheses(root) : undefined;1302 │ if (hypotheses?.status === 'corrupt') {1303 │ console.error(`⚠ ${HYPOTHESES_FILE} is corrupt (${hypotheses.error}); no hypothesis state is exported`);1304 │ }1305 │
Project root; the graph answers with ranked entry points or a status
1656 │ } catch { /* no model yet — that's fine */ }1657 │ 1658 │ // The optional code graph: a ranked entry-point worklist when one is installed1659 │ // and built for this repo, nothing otherwise. Announced on stderr either way,1660 │ // so --stdout still pipes only the prompt.1661 │ // @flows #cli -> #codegraph via loadWorklist -- "Project root; the graph answers with ranked entry points or a status"1662 │ const worklist = await loadWorklist(root, baseline, { enabled: opts.codeGraph !== false });1663 │ const graphLine = worklistSummaryLine(worklist);1664 │ if (graphLine) console.error(graphLine);1665 │ 1666 │ // Build prompt
The model before and after the agent
1703 │ if (opts.gate === false) { console.log(' Gate skipped (--no-gate). Run: guardlink lint . --since HEAD'); return; }1704 │ 1705 │ // The gate: what did the run add, and does it meet the evidence bar?1706 │ // Re-prompt with the violations, then strip what still fails so a bad1707 │ // claim never lands in the tree as if it had been checked.1708 │ // @flows #cli -> #gate via runGate -- "The model before and after the agent"1709 │ const parseNow = async (): Promise<ThreatModel> => {1710 │ const m = (await parseProject({ root, project })).model;1711 │ attachHypotheses(m, classifyHypotheses(m, readHypotheses(root)));1712 │ return m;1713 │ };
Which assets sit on an undefended path, for the ranking
1838 │ .option('-n, --count <n>', 'How many to list', '10')1839 │ .option('--intake', 'Print the queue as a brief for bugb intake')1840 │ .option('--json', 'Machine-readable output (guardlink.hypotheses-next/v1)')1841 │ .action(async (dir: string, opts: { project?: string; count?: string; intake?: boolean; json?: boolean }) => {1842 │ const { root, project, model, c } = await hypothesisContext(dir, opts.project);1843 │ // @flows ThreatModel -> #cli via findUnmitigatedPaths -- "Which assets sit on an undefended path, for the ranking"1844 │ const pathAssets = new Set<string>();1845 │ for (const f of findUnmitigatedPaths(model)) for (const a of f.assetsOnPath) pathAssets.add(a);1846 │ const n = Math.max(1, parseInt(opts.count ?? '10', 10) || 10);1847 │ // @comment -- "ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one"1848 │ const ranked = rankUntested(c.records, model, pathAssets);
ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one
1842 │ const { root, project, model, c } = await hypothesisContext(dir, opts.project);1843 │ // @flows ThreatModel -> #cli via findUnmitigatedPaths -- "Which assets sit on an undefended path, for the ranking"1844 │ const pathAssets = new Set<string>();1845 │ for (const f of findUnmitigatedPaths(model)) for (const a of f.assetsOnPath) pathAssets.add(a);1846 │ const n = Math.max(1, parseInt(opts.count ?? '10', 10) || 10);1847 │ // @comment -- "ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one"1848 │ const ranked = rankUntested(c.records, model, pathAssets);1849 │ const queue = ranked.slice(0, n);1850 │ // @comment -- "`key` is the claim key, the same one hypotheses-list/v1 carries and `hypothesis confirm`/`--from-scan` join on, so a consumer can address a queued claim instead of joining back to `hypothesis list` on (asset, threat, file, line). That join is positional and GAP-58 proved it unsafe: this population collides at the coarser (asset, threat, file) tier. The schema stays at v1 deliberately — the field is purely additive, nothing v1 carried is renamed or removed, and a consumer tells whether a build has it by the field's presence; bumping would strand consumers pinned to the v1 string for a change that breaks none of them"1851 │ // @comment -- "`total` is the queue length before the bound, carried into all three renderers because a bounded queue that does not say it is bounded reads as the whole of what there is to test — the failure this guards is an operator handing bugb a 10-item plan off a 142-claim queue believing nothing else is outstanding. It is additive exactly as `key` is, so the schema stays at v1: nothing v1 carried is renamed or removed, and a consumer holding a page tells it apart from a whole queue by the field's presence"1852 │ if (opts.json) { console.log(JSON.stringify({ schema: 'guardlink.hypotheses-next/v1', root, total: ranked.length, queue: queue.map(r => ({ rank: r.rank, key: r.key, state: r.state, asset: r.asset, threat: r.threat, severity: r.severity, file: r.file, line: r.line, onPath: r.onPath, unowned: r.unowned, claim: r.claim })) }, null, 2)); return; }
`key` is the claim key, the same one hypotheses-list/v1 carries and `hypothesis confirm`/`--from-scan` join on, so a consumer can address a queued claim instead of joining back to `hypothesis list` on (asset, threat, file, line). That join is positional and GAP-58 proved it unsafe: this population collides at the coarser (asset, threat, file) tier. The schema stays at v1 deliberately — the field is purely additive, nothing v1 carried is renamed or removed, and a consumer tells whether a build has it by the field's presence; bumping would strand consumers pinned to the v1 string for a change that breaks none of them
1845 │ for (const f of findUnmitigatedPaths(model)) for (const a of f.assetsOnPath) pathAssets.add(a);1846 │ const n = Math.max(1, parseInt(opts.count ?? '10', 10) || 10);1847 │ // @comment -- "ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one"1848 │ const ranked = rankUntested(c.records, model, pathAssets);1849 │ const queue = ranked.slice(0, n);1850 │ // @comment -- "`key` is the claim key, the same one hypotheses-list/v1 carries and `hypothesis confirm`/`--from-scan` join on, so a consumer can address a queued claim instead of joining back to `hypothesis list` on (asset, threat, file, line). That join is positional and GAP-58 proved it unsafe: this population collides at the coarser (asset, threat, file) tier. The schema stays at v1 deliberately — the field is purely additive, nothing v1 carried is renamed or removed, and a consumer tells whether a build has it by the field's presence; bumping would strand consumers pinned to the v1 string for a change that breaks none of them"1851 │ // @comment -- "`total` is the queue length before the bound, carried into all three renderers because a bounded queue that does not say it is bounded reads as the whole of what there is to test — the failure this guards is an operator handing bugb a 10-item plan off a 142-claim queue believing nothing else is outstanding. It is additive exactly as `key` is, so the schema stays at v1: nothing v1 carried is renamed or removed, and a consumer holding a page tells it apart from a whole queue by the field's presence"1852 │ if (opts.json) { console.log(JSON.stringify({ schema: 'guardlink.hypotheses-next/v1', root, total: ranked.length, queue: queue.map(r => ({ rank: r.rank, key: r.key, state: r.state, asset: r.asset, threat: r.threat, severity: r.severity, file: r.file, line: r.line, onPath: r.onPath, unowned: r.unowned, claim: r.claim })) }, null, 2)); return; }1853 │ console.log(opts.intake ? formatIntake(queue, project, ranked.length) : formatQueue(queue, ranked.length));1854 │ });1855 │
`total` is the queue length before the bound, carried into all three renderers because a bounded queue that does not say it is bounded reads as the whole of what there is to test — the failure this guards is an operator handing bugb a 10-item plan off a 142-claim queue believing nothing else is outstanding. It is additive exactly as `key` is, so the schema stays at v1: nothing v1 carried is renamed or removed, and a consumer holding a page tells it apart from a whole queue by the field's presence
1846 │ const n = Math.max(1, parseInt(opts.count ?? '10', 10) || 10);1847 │ // @comment -- "ONE bound, applied before any renderer runs. `--intake` used to take the whole queue while --json and the table took -n, so the renderer built for the downstream consumer was the one that could not be asked for a bounded brief — `-n 3 --intake` printed all 142 on temporal. A per-renderer slice is the shape that drifts; a renderer added later inherits this one"1848 │ const ranked = rankUntested(c.records, model, pathAssets);1849 │ const queue = ranked.slice(0, n);1850 │ // @comment -- "`key` is the claim key, the same one hypotheses-list/v1 carries and `hypothesis confirm`/`--from-scan` join on, so a consumer can address a queued claim instead of joining back to `hypothesis list` on (asset, threat, file, line). That join is positional and GAP-58 proved it unsafe: this population collides at the coarser (asset, threat, file) tier. The schema stays at v1 deliberately — the field is purely additive, nothing v1 carried is renamed or removed, and a consumer tells whether a build has it by the field's presence; bumping would strand consumers pinned to the v1 string for a change that breaks none of them"1851 │ // @comment -- "`total` is the queue length before the bound, carried into all three renderers because a bounded queue that does not say it is bounded reads as the whole of what there is to test — the failure this guards is an operator handing bugb a 10-item plan off a 142-claim queue believing nothing else is outstanding. It is additive exactly as `key` is, so the schema stays at v1: nothing v1 carried is renamed or removed, and a consumer holding a page tells it apart from a whole queue by the field's presence"1852 │ if (opts.json) { console.log(JSON.stringify({ schema: 'guardlink.hypotheses-next/v1', root, total: ranked.length, queue: queue.map(r => ({ rank: r.rank, key: r.key, state: r.state, asset: r.asset, threat: r.threat, severity: r.severity, file: r.file, line: r.line, onPath: r.onPath, unowned: r.unowned, claim: r.claim })) }, null, 2)); return; }1853 │ console.log(opts.intake ? formatIntake(queue, project, ranked.length) : formatQueue(queue, ranked.length));1854 │ });1855 │ 1856 │ const outcomeAction = (outcome: 'refuted' | 'confirmed') => async (target: string | undefined, dir: string, opts: { project?: string; evidence?: string; by?: string; write?: boolean; fromScan?: string }) => {
`defaultVerifier` already returns `human:<name>` (src/parser/verify.ts:172), so prefixing it here recorded `human:human:<name>` — the form the ledger schema and this command's own --by help both say it is not. Do NOT 'repair' older entries that carry the doubled form: `by` is display and provenance metadata, never a join key — upsert and every reader match on the claim key — so pre-fix and post-fix entries coexist in one ledger and nothing stops matching. An explicit --by is passed through verbatim on this path, which is how `--by cxg:login-sqli` names its scheme
1853 │ console.log(opts.intake ? formatIntake(queue, project, ranked.length) : formatQueue(queue, ranked.length));1854 │ });1855 │ 1856 │ const outcomeAction = (outcome: 'refuted' | 'confirmed') => async (target: string | undefined, dir: string, opts: { project?: string; evidence?: string; by?: string; write?: boolean; fromScan?: string }) => {1857 │ const { root, model } = await hypothesisContext(dir, opts.project);1858 │ // @comment -- "`defaultVerifier` already returns `human:<name>` (src/parser/verify.ts:172), so prefixing it here recorded `human:human:<name>` — the form the ledger schema and this command's own --by help both say it is not. Do NOT 'repair' older entries that carry the doubled form: `by` is display and provenance metadata, never a join key — upsert and every reader match on the claim key — so pre-fix and post-fix entries coexist in one ledger and nothing stops matching. An explicit --by is passed through verbatim on this path, which is how `--by cxg:login-sqli` names its scheme"1859 │ const by = opts.by || defaultVerifier(root);1860 │ const at = nowIso();1861 │ try {1862 │ if (outcome === 'confirmed' && opts.fromScan) {1863 │ // @flows ScanReport -> #cli via importScan -- "cxg findings recorded as confirmations, joined to claims"
cxg findings recorded as confirmations, joined to claims
1858 │ // @comment -- "`defaultVerifier` already returns `human:<name>` (src/parser/verify.ts:172), so prefixing it here recorded `human:human:<name>` — the form the ledger schema and this command's own --by help both say it is not. Do NOT 'repair' older entries that carry the doubled form: `by` is display and provenance metadata, never a join key — upsert and every reader match on the claim key — so pre-fix and post-fix entries coexist in one ledger and nothing stops matching. An explicit --by is passed through verbatim on this path, which is how `--by cxg:login-sqli` names its scheme"1859 │ const by = opts.by || defaultVerifier(root);1860 │ const at = nowIso();1861 │ try {1862 │ if (outcome === 'confirmed' && opts.fromScan) {1863 │ // @flows ScanReport -> #cli via importScan -- "cxg findings recorded as confirmations, joined to claims"1864 │ const r = importScan(root, model, opts.fromScan, { by: opts.by || 'cxg', at });1865 │ console.log(formatImport(r));1866 │ // @comment -- "Withheld is a fact about a CLAIM, not about a finding. Several findings can join to one claim — a stamped one beside an unstamped one during a cxg stamp rollout, or two templates probing the same exposure — so a per-finding list printed one claim's remediation command twice and, worse, could report a claim as skipped in the very run that wrote it. Keyed by record.key on the same rule `writes` uses, last occurrence kept because that is the entry the ledger ends up holding"1867 │ const coarse = [...new Map(r.confirmed.filter(c => c.joinedBy !== 'claim-key').map(c => [c.record.key, c])).values()];1868 │ let withheld = coarse;
Withheld is a fact about a CLAIM, not about a finding. Several findings can join to one claim — a stamped one beside an unstamped one during a cxg stamp rollout, or two templates probing the same exposure — so a per-finding list printed one claim's remediation command twice and, worse, could report a claim as skipped in the very run that wrote it. Keyed by record.key on the same rule `writes` uses, last occurrence kept because that is the entry the ledger ends up holding
1861 │ try {1862 │ if (outcome === 'confirmed' && opts.fromScan) {1863 │ // @flows ScanReport -> #cli via importScan -- "cxg findings recorded as confirmations, joined to claims"1864 │ const r = importScan(root, model, opts.fromScan, { by: opts.by || 'cxg', at });1865 │ console.log(formatImport(r));1866 │ // @comment -- "Withheld is a fact about a CLAIM, not about a finding. Several findings can join to one claim — a stamped one beside an unstamped one during a cxg stamp rollout, or two templates probing the same exposure — so a per-finding list printed one claim's remediation command twice and, worse, could report a claim as skipped in the very run that wrote it. Keyed by record.key on the same rule `writes` uses, last occurrence kept because that is the entry the ledger ends up holding"1867 │ const coarse = [...new Map(r.confirmed.filter(c => c.joinedBy !== 'claim-key').map(c => [c.record.key, c])).values()];1868 │ let withheld = coarse;1869 │ // @comment -- "THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice"1870 │ const done: { file: string; line: number }[] = [];1871 │ const afterWrites = (file: string, pre: number) => pre + done.filter(o => o.file === file && o.line <= pre).length;
THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice
1864 │ const r = importScan(root, model, opts.fromScan, { by: opts.by || 'cxg', at });1865 │ console.log(formatImport(r));1866 │ // @comment -- "Withheld is a fact about a CLAIM, not about a finding. Several findings can join to one claim — a stamped one beside an unstamped one during a cxg stamp rollout, or two templates probing the same exposure — so a per-finding list printed one claim's remediation command twice and, worse, could report a claim as skipped in the very run that wrote it. Keyed by record.key on the same rule `writes` uses, last occurrence kept because that is the entry the ledger ends up holding"1867 │ const coarse = [...new Map(r.confirmed.filter(c => c.joinedBy !== 'claim-key').map(c => [c.record.key, c])).values()];1868 │ let withheld = coarse;1869 │ // @comment -- "THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice"1870 │ const done: { file: string; line: number }[] = [];1871 │ const afterWrites = (file: string, pre: number) => pre + done.filter(o => o.file === file && o.line <= pre).length;1872 │ if (opts.write) {1873 │ // @comment -- "Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet"1874 │ // @comment -- "ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds"
Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet
1868 │ let withheld = coarse;1869 │ // @comment -- "THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice"1870 │ const done: { file: string; line: number }[] = [];1871 │ const afterWrites = (file: string, pre: number) => pre + done.filter(o => o.file === file && o.line <= pre).length;1872 │ if (opts.write) {1873 │ // @comment -- "Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet"1874 │ // @comment -- "ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds"1875 │ const writes = [...new Map(r.confirmed.filter(c => c.joinedBy === 'claim-key').map(c => [c.record.key, c])).values()]1876 │ .sort((a, b) => (a.record.file < b.record.file ? -1 : a.record.file > b.record.file ? 1 : b.record.line - a.record.line));1877 │ // @comment -- "An exit code is a claim about what happened, and it has to be true in BOTH directions: honest about an under-write and honest about a success. A confirmation already in the source is the state we were asked to reach — from an earlier run, or a re-import of the same report — so it is separated from a real failure by the TYPE writeConfirmedLine returns, never by matching the wording of an error. Every remaining throw is a failure and still exits non-zero"1878 │ const present: { file: string; line: number }[] = [];
ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds
1869 │ // @comment -- "THE one calculation of where a line sits once the writes are done, used by every coordinate this command hands a user: the confirmations that landed, the ones already present, and the by-hand commands for both the withheld and the ambiguous. Each done.line is a PRE-write coordinate — descending order guarantees nothing at or above a target had been spliced when it was written — so an insertion at or at a lower number than a pre-write position pushes that position down by one. Only SUCCESSFUL writes are in `done`, so a write that threw shifts nothing, and with no --write it is empty and this is the identity. Two sites computing this independently is the shape that disagrees silently, and this command has already produced that defect twice"1870 │ const done: { file: string; line: number }[] = [];1871 │ const afterWrites = (file: string, pre: number) => pre + done.filter(o => o.file === file && o.line <= pre).length;1872 │ if (opts.write) {1873 │ // @comment -- "Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet"1874 │ // @comment -- "ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds"1875 │ const writes = [...new Map(r.confirmed.filter(c => c.joinedBy === 'claim-key').map(c => [c.record.key, c])).values()]1876 │ .sort((a, b) => (a.record.file < b.record.file ? -1 : a.record.file > b.record.file ? 1 : b.record.line - a.record.line));1877 │ // @comment -- "An exit code is a claim about what happened, and it has to be true in BOTH directions: honest about an under-write and honest about a success. A confirmation already in the source is the state we were asked to reach — from an earlier run, or a re-import of the same report — so it is separated from a real failure by the TYPE writeConfirmedLine returns, never by matching the wording of an error. Every remaining throw is a failure and still exits non-zero"1878 │ const present: { file: string; line: number }[] = [];1879 │ const reached = new Set<string>();
An exit code is a claim about what happened, and it has to be true in BOTH directions: honest about an under-write and honest about a success. A confirmation already in the source is the state we were asked to reach — from an earlier run, or a re-import of the same report — so it is separated from a real failure by the TYPE writeConfirmedLine returns, never by matching the wording of an error. Every remaining throw is a failure and still exits non-zero
1872 │ if (opts.write) {1873 │ // @comment -- "Writes are applied per file in DESCENDING line order. Every record.line was resolved from the PRE-write model and writeConfirmedLine splices at line + 1, so an ascending pass shifts each later target in that file down by one: with consecutive @exposes lines the second write lands beneath the wrong claim's @exposes and its guards all pass, writing a confirmation against a claim the probe never tested. Inserting below a line never moves a line above it, so descending order cannot shift a target it has not written yet"1874 │ // @comment -- "ONE CLAIM, ONE WRITE. Two findings can carry the same claim key — the ordinary shape when two cxg templates probe one exposure — and both resolve to the same record and fold into ONE ledger entry, so writing per finding attempted the same insertion twice and the second was refused as a failure. Keyed by record.key, keeping the LAST occurrence: upsert replaces the entry object on every call, so only the final one is the entry the ledger actually holds"1875 │ const writes = [...new Map(r.confirmed.filter(c => c.joinedBy === 'claim-key').map(c => [c.record.key, c])).values()]1876 │ .sort((a, b) => (a.record.file < b.record.file ? -1 : a.record.file > b.record.file ? 1 : b.record.line - a.record.line));1877 │ // @comment -- "An exit code is a claim about what happened, and it has to be true in BOTH directions: honest about an under-write and honest about a success. A confirmation already in the source is the state we were asked to reach — from an earlier run, or a re-import of the same report — so it is separated from a real failure by the TYPE writeConfirmedLine returns, never by matching the wording of an error. Every remaining throw is a failure and still exits non-zero"1878 │ const present: { file: string; line: number }[] = [];1879 │ const reached = new Set<string>();1880 │ for (const c of writes) {1881 │ try {1882 │ const w = writeConfirmedLine(root, c.record, confirmedLine(c.record, c.entry));
Reconciled against what the writes actually did. A claim whose confirmation IS in the source — inserted just now or already there — was not withheld, whatever else joined to it coarsely, so it is neither told to the operator as skipped nor counted toward the exit status. The rule it serves is the one the exit check states: the code is a claim about what happened and has to be true in BOTH directions, and reporting a claim as withheld in the run that wrote it is the half that was still false
1883 │ (w.outcome === 'inserted' ? done : present).push({ file: w.file, line: w.line });1884 │ reached.add(c.record.key);1885 │ }1886 │ catch (e) { console.error(` ! ${(e as Error).message}`); process.exitCode = 1; }1887 │ }1888 │ // @comment -- "Reconciled against what the writes actually did. A claim whose confirmation IS in the source — inserted just now or already there — was not withheld, whatever else joined to it coarsely, so it is neither told to the operator as skipped nor counted toward the exit status. The rule it serves is the one the exit check states: the code is a claim about what happened and has to be true in BOTH directions, and reporting a claim as withheld in the run that wrote it is the half that was still false"1889 │ withheld = coarse.filter(c => !reached.has(c.record.key));1890 │ // @comment -- "An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in"1891 │ const landed = done.map(w => ({ file: w.file, line: afterWrites(w.file, w.line) - 1 }));1892 │ for (const w of landed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` wrote ${w.file}:${w.line}`);1893 │ // @comment -- "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites"
An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in
1885 │ }1886 │ catch (e) { console.error(` ! ${(e as Error).message}`); process.exitCode = 1; }1887 │ }1888 │ // @comment -- "Reconciled against what the writes actually did. A claim whose confirmation IS in the source — inserted just now or already there — was not withheld, whatever else joined to it coarsely, so it is neither told to the operator as skipped nor counted toward the exit status. The rule it serves is the one the exit check states: the code is a claim about what happened and has to be true in BOTH directions, and reporting a claim as withheld in the run that wrote it is the half that was still false"1889 │ withheld = coarse.filter(c => !reached.has(c.record.key));1890 │ // @comment -- "An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in"1891 │ const landed = done.map(w => ({ file: w.file, line: afterWrites(w.file, w.line) - 1 }));1892 │ for (const w of landed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` wrote ${w.file}:${w.line}`);1893 │ // @comment -- "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites"1894 │ for (const w of present.map(p => ({ file: p.file, line: afterWrites(p.file, p.line) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` already confirmed ${w.file}:${w.line}`);1895 │ // @comment -- "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong"
Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites
1888 │ // @comment -- "Reconciled against what the writes actually did. A claim whose confirmation IS in the source — inserted just now or already there — was not withheld, whatever else joined to it coarsely, so it is neither told to the operator as skipped nor counted toward the exit status. The rule it serves is the one the exit check states: the code is a claim about what happened and has to be true in BOTH directions, and reporting a claim as withheld in the run that wrote it is the half that was still false"1889 │ withheld = coarse.filter(c => !reached.has(c.record.key));1890 │ // @comment -- "An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in"1891 │ const landed = done.map(w => ({ file: w.file, line: afterWrites(w.file, w.line) - 1 }));1892 │ for (const w of landed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` wrote ${w.file}:${w.line}`);1893 │ // @comment -- "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites"1894 │ for (const w of present.map(p => ({ file: p.file, line: afterWrites(p.file, p.line) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` already confirmed ${w.file}:${w.line}`);1895 │ // @comment -- "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong"1896 │ // @comment -- "Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it"1897 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here"1898 │ for (const c of withheld) {
--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong
1890 │ // @comment -- "An inserted @confirmed comes to rest directly above the line it displaced, so it is that line's new position less one — derived from afterWrites rather than counted a second way. Printed in reading order, not in the order the writes went in"1891 │ const landed = done.map(w => ({ file: w.file, line: afterWrites(w.file, w.line) - 1 }));1892 │ for (const w of landed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` wrote ${w.file}:${w.line}`);1893 │ // @comment -- "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites"1894 │ for (const w of present.map(p => ({ file: p.file, line: afterWrites(p.file, p.line) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` already confirmed ${w.file}:${w.line}`);1895 │ // @comment -- "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong"1896 │ // @comment -- "Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it"1897 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here"1898 │ for (const c of withheld) {1899 │ const why = c.joinedBy === 'claim-key-contested'1900 │ ? 'the report carried more than one claim key naming different claims, so this one won on precedence rather than agreement — which exposure was tested is in doubt'
Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it
1891 │ const landed = done.map(w => ({ file: w.file, line: afterWrites(w.file, w.line) - 1 }));1892 │ for (const w of landed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` wrote ${w.file}:${w.line}`);1893 │ // @comment -- "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites"1894 │ for (const w of present.map(p => ({ file: p.file, line: afterWrites(p.file, p.line) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` already confirmed ${w.file}:${w.line}`);1895 │ // @comment -- "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong"1896 │ // @comment -- "Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it"1897 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here"1898 │ for (const c of withheld) {1899 │ const why = c.joinedBy === 'claim-key-contested'1900 │ ? 'the report carried more than one claim key naming different claims, so this one won on precedence rather than agreement — which exposure was tested is in doubt'1901 │ : `joined by ${c.joinedBy}, not key-verified, so it may be about a different exposure than the probe tested`;
GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here
1892 │ for (const w of landed.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` wrote ${w.file}:${w.line}`);1893 │ // @comment -- "Said in terms of what is true rather than as an error: the confirmation is there. The line an already-present @confirmed was found at is a pre-write coordinate like any other — the scan that found it stops at the next @exposes, and descending order means every insertion so far sits below that — so it goes through the same afterWrites"1894 │ for (const w of present.map(p => ({ file: p.file, line: afterWrites(p.file, p.line) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line))) console.log(` already confirmed ${w.file}:${w.line}`);1895 │ // @comment -- "--write only inserts an UNCONTESTED key-verified confirmation. An @confirmed in source is a claim in this repository that the exposure was tested and proven — later scans, reviewers and the SARIF export all read it, and unlike a ledger entry it never expires. A location- or CWE-joined confirmation may be about a different exposure than the one probed (GAP-58), and a contested one came from a report that named two different claims at once, so identity is in doubt in both cases. Losing that throughput is the point: those are exactly the joins that can be wrong"1896 │ // @comment -- "Reported AFTER the writes, at the line the withheld claim occupies once they have landed. Printing pre-write lines here handed the operator a command naming a line that this same run then moved: with consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator writes a confirmation against an exposure the probe never tested — GAP-58 one layer out, in a human's fingers instead of ours. Documenting the hazard one block down was not guarding against it"1897 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so every command printed here is position-dependent and is correct AT THE MOMENT OF PRINTING and no longer — a later write, or an edit, moves the target again. Tracked there, not widened here"1898 │ for (const c of withheld) {1899 │ const why = c.joinedBy === 'claim-key-contested'1900 │ ? 'the report carried more than one claim key naming different claims, so this one won on precedence rather than agreement — which exposure was tested is in doubt'1901 │ : `joined by ${c.joinedBy}, not key-verified, so it may be about a different exposure than the probe tested`;1902 │ const at = `${c.record.file}:${afterWrites(c.record.file, c.record.line)}`;
The candidates for an ambiguous finding are by-hand targets like the withheld ones, so they are emitted here rather than inside formatImport: that formatter runs BEFORE the writes, and a key-verified write higher up the same file moves an ambiguous candidate exactly as it moves a withheld claim. With consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator records a confirmation against an exposure the probe never tested — GAP-58 through a human's fingers, on our own instruction. Stays on stdout, where it was
1904 │ console.error(` guardlink hypothesis confirm ${at} --evidence "…" --write`);1905 │ }1906 │ } else if (r.confirmed.some(c => c.joinedBy === 'claim-key')) {1907 │ console.log('\nadd --write to insert an @confirmed line beneath each key-verified @exposes');1908 │ }1909 │ // @comment -- "The candidates for an ambiguous finding are by-hand targets like the withheld ones, so they are emitted here rather than inside formatImport: that formatter runs BEFORE the writes, and a key-verified write higher up the same file moves an ambiguous candidate exactly as it moves a withheld claim. With consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator records a confirmation against an exposure the probe never tested — GAP-58 through a human's fingers, on our own instruction. Stays on stdout, where it was"1910 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so these are correct AT THE MOMENT OF PRINTING and no longer. That residue is the addressing scheme and is tracked there; a command this run itself invalidated is a different problem and is fixed here"1911 │ for (const a of r.ambiguous) {1912 │ console.log(`\nAmbiguous ${a.finding.id} (${a.finding.template_id}) fits ${a.candidates.length} claims — pick one and record it by hand:`);1913 │ for (const c of a.candidates) console.log(` guardlink hypothesis confirm ${c.file}:${afterWrites(c.file, c.line)} --evidence "…"`);1914 │ }
GAP-77: `hypothesis confirm` addresses a claim only as file:line, so these are correct AT THE MOMENT OF PRINTING and no longer. That residue is the addressing scheme and is tracked there; a command this run itself invalidated is a different problem and is fixed here
1905 │ }1906 │ } else if (r.confirmed.some(c => c.joinedBy === 'claim-key')) {1907 │ console.log('\nadd --write to insert an @confirmed line beneath each key-verified @exposes');1908 │ }1909 │ // @comment -- "The candidates for an ambiguous finding are by-hand targets like the withheld ones, so they are emitted here rather than inside formatImport: that formatter runs BEFORE the writes, and a key-verified write higher up the same file moves an ambiguous candidate exactly as it moves a withheld claim. With consecutive @exposes the shifted line holds a DIFFERENT claim, resolveTarget finds exactly one @exposes there, and the operator records a confirmation against an exposure the probe never tested — GAP-58 through a human's fingers, on our own instruction. Stays on stdout, where it was"1910 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so these are correct AT THE MOMENT OF PRINTING and no longer. That residue is the addressing scheme and is tracked there; a command this run itself invalidated is a different problem and is fixed here"1911 │ for (const a of r.ambiguous) {1912 │ console.log(`\nAmbiguous ${a.finding.id} (${a.finding.template_id}) fits ${a.candidates.length} claims — pick one and record it by hand:`);1913 │ for (const c of a.candidates) console.log(` guardlink hypothesis confirm ${c.file}:${afterWrites(c.file, c.line)} --evidence "…"`);1914 │ }1915 │ // @comment -- "A scan whose findings were not all recorded exits non-zero: ambiguous, stale (the stamped claim is not in the model any more), malformed (a stamp arrived that is not a claim key) and unmatched each need a human, and a silent 0 would read as 'all imported'. A --write that withheld any confirmation is the same fact about the same run, so it exits non-zero too — PARTIALLY written included, not only the all-withheld case. The reader of this exit code is bravos, which orchestrates this loop and never sees the `! skipped` lines on stderr: give 0 two meanings and a half-written run is indistinguishable from a complete one, and the orchestrator proceeds believing the corpus carries confirmations that are not in it"
A scan whose findings were not all recorded exits non-zero: ambiguous, stale (the stamped claim is not in the model any more), malformed (a stamp arrived that is not a claim key) and unmatched each need a human, and a silent 0 would read as 'all imported'. A --write that withheld any confirmation is the same fact about the same run, so it exits non-zero too — PARTIALLY written included, not only the all-withheld case. The reader of this exit code is bravos, which orchestrates this loop and never sees the `! skipped` lines on stderr: give 0 two meanings and a half-written run is indistinguishable from a complete one, and the orchestrator proceeds believing the corpus carries confirmations that are not in it
1910 │ // @comment -- "GAP-77: `hypothesis confirm` addresses a claim only as file:line, so these are correct AT THE MOMENT OF PRINTING and no longer. That residue is the addressing scheme and is tracked there; a command this run itself invalidated is a different problem and is fixed here"1911 │ for (const a of r.ambiguous) {1912 │ console.log(`\nAmbiguous ${a.finding.id} (${a.finding.template_id}) fits ${a.candidates.length} claims — pick one and record it by hand:`);1913 │ for (const c of a.candidates) console.log(` guardlink hypothesis confirm ${c.file}:${afterWrites(c.file, c.line)} --evidence "…"`);1914 │ }1915 │ // @comment -- "A scan whose findings were not all recorded exits non-zero: ambiguous, stale (the stamped claim is not in the model any more), malformed (a stamp arrived that is not a claim key) and unmatched each need a human, and a silent 0 would read as 'all imported'. A --write that withheld any confirmation is the same fact about the same run, so it exits non-zero too — PARTIALLY written included, not only the all-withheld case. The reader of this exit code is bravos, which orchestrates this loop and never sees the `! skipped` lines on stderr: give 0 two meanings and a half-written run is indistinguishable from a complete one, and the orchestrator proceeds believing the corpus carries confirmations that are not in it"1916 │ if (r.ambiguous.length > 0 || r.stale.length > 0 || r.malformed.length > 0 || r.unmatched.length > 01917 │ || (opts.write && withheld.length > 0)) process.exitCode = 1;1918 │ return;1919 │ }1920 │ if (!target) { console.error('Name the exposure as file:line, or pass --from-scan <report.json>.'); process.exitCode = 1; return; }
The by-hand path reads the same typed outcome as --from-scan. A claim whose source already carries this confirmation is in the state that was asked for, so it is reported and exits 0; it used to throw into the ✗ handler and exit non-zero on a run that changed nothing and needed to change nothing
1921 │ if (!opts.evidence) { console.error(`--evidence is required: say what was tried and what came back. A ${outcome === 'refuted' ? 'refutation' : 'confirmation'} without it is a guess.`); process.exitCode = 1; return; }1922 │ const { record, entry } = recordOutcome(root, model, target, outcome, { evidence: opts.evidence, by, at });1923 │ const offered = outcome === 'confirmed' ? confirmedLine(record, entry) : undefined;1924 │ console.log(formatOutcome(record, entry, opts.write ? undefined : offered));1925 │ if (outcome === 'confirmed' && opts.write) {1926 │ // @comment -- "The by-hand path reads the same typed outcome as --from-scan. A claim whose source already carries this confirmation is in the state that was asked for, so it is reported and exits 0; it used to throw into the ✗ handler and exit non-zero on a run that changed nothing and needed to change nothing"1927 │ const w = writeConfirmedLine(root, record, offered!);1928 │ console.log(w.outcome === 'already-present' ? `\n${w.file}:${w.line} already carries this confirmation` : `\nWrote ${w.file}:${w.line}`);1929 │ }1930 │ } catch (e) {1931 │ console.error(`✗ ${(e as Error).message}`);
The accepted names and containers are INTERPOLATED from the one shared definition, not written out here. A hand-written copy drifts benignly when the definition widens (the help under-advertises) and harmfully when it narrows: the help then advertises a name the reader refuses, which is the one thing this definition exists to make impossible. The no-stamp banner in format.ts interpolates for the same reason, and both are pinned by tests that take the names out of the text and require the reader to accept them
1948 │ .description('Record that an exposure was tested and is exploitable, from evidence in hand or a cxg scan report')1949 │ .argument('[target]', 'The exposure as file:line (omit with --from-scan)')1950 │ .argument('[dir]', 'Project directory', '.')1951 │ .option('-p, --project <n>', 'Project name (default: the name in .guardlink/config.json)')1952 │ .option('--evidence <text>', 'The request and response, the reproduction, or the scan proof (required without --from-scan)')1953 │ // @comment -- "The accepted names and containers are INTERPOLATED from the one shared definition, not written out here. A hand-written copy drifts benignly when the definition widens (the help under-advertises) and harmfully when it narrows: the help then advertises a name the reader refuses, which is the one thing this definition exists to make impossible. The no-stamp banner in format.ts interpolates for the same reason, and both are pinned by tests that take the names out of the text and require the reader to accept them"1954 │ .option('--from-scan <file>', `A cxg scan report (JSON). A finding carrying the claim key (${CLAIM_KEY_NAMES.join(', ')} — at the finding's top level or in its annotation, spread or as a forwarded ${CLAIM_KEY_SURFACES.map(s => s.container).join('/')} map) resolves to the claim with that key, or is reported stale; only an unstamped finding falls to the weaker joins — location, then asset and threat, then CWE. A key that names a @boundary, or one under ${BOUNDARY_CLAIM_KEY_NAMES.join('/')} beside the exposure's, records that boundary contradicted (supported when the finding's boundary_outcome says so), in the ledger only`)1955 │ .option('--by <name>', 'Who tested it (default: human:<git user.name>; cxg for --from-scan)')1956 │ // @comment -- "ONE string stating BOTH behaviours, because --write sits on `confirm` and genuinely serves both paths. An author writing against this flag reads --help, not our runtime output: 'inserts the @confirmed line' alone over-promises on the one surface that writes into someone else's repository, and an operator who believes every joined confirmation landed stops checking the ones that did not — which is exactly the knowledge the withholding rule exists to give them. Two per-path wordings were rejected for the reason this branch has already corrected on names, containers, grammars and positions: two descriptions of one thing can disagree, and one string cannot contradict itself"1957 │ .option('--write', 'Also insert the @confirmed line into the source. On the manual confirm <file:line> path it goes beneath that @exposes; under --from-scan only uncontested key-verified confirmations are written — the rest stay in the ledger alone and are reported with the command to record them by hand')1958 │ .action(outcomeAction('confirmed'));
ONE string stating BOTH behaviours, because --write sits on `confirm` and genuinely serves both paths. An author writing against this flag reads --help, not our runtime output: 'inserts the @confirmed line' alone over-promises on the one surface that writes into someone else's repository, and an operator who believes every joined confirmation landed stops checking the ones that did not — which is exactly the knowledge the withholding rule exists to give them. Two per-path wordings were rejected for the reason this branch has already corrected on names, containers, grammars and positions: two descriptions of one thing can disagree, and one string cannot contradict itself
1951 │ .option('-p, --project <n>', 'Project name (default: the name in .guardlink/config.json)')1952 │ .option('--evidence <text>', 'The request and response, the reproduction, or the scan proof (required without --from-scan)')1953 │ // @comment -- "The accepted names and containers are INTERPOLATED from the one shared definition, not written out here. A hand-written copy drifts benignly when the definition widens (the help under-advertises) and harmfully when it narrows: the help then advertises a name the reader refuses, which is the one thing this definition exists to make impossible. The no-stamp banner in format.ts interpolates for the same reason, and both are pinned by tests that take the names out of the text and require the reader to accept them"1954 │ .option('--from-scan <file>', `A cxg scan report (JSON). A finding carrying the claim key (${CLAIM_KEY_NAMES.join(', ')} — at the finding's top level or in its annotation, spread or as a forwarded ${CLAIM_KEY_SURFACES.map(s => s.container).join('/')} map) resolves to the claim with that key, or is reported stale; only an unstamped finding falls to the weaker joins — location, then asset and threat, then CWE. A key that names a @boundary, or one under ${BOUNDARY_CLAIM_KEY_NAMES.join('/')} beside the exposure's, records that boundary contradicted (supported when the finding's boundary_outcome says so), in the ledger only`)1955 │ .option('--by <name>', 'Who tested it (default: human:<git user.name>; cxg for --from-scan)')1956 │ // @comment -- "ONE string stating BOTH behaviours, because --write sits on `confirm` and genuinely serves both paths. An author writing against this flag reads --help, not our runtime output: 'inserts the @confirmed line' alone over-promises on the one surface that writes into someone else's repository, and an operator who believes every joined confirmation landed stops checking the ones that did not — which is exactly the knowledge the withholding rule exists to give them. Two per-path wordings were rejected for the reason this branch has already corrected on names, containers, grammars and positions: two descriptions of one thing can disagree, and one string cannot contradict itself"1957 │ .option('--write', 'Also insert the @confirmed line into the source. On the manual confirm <file:line> path it goes beneath that @exposes; under --from-scan only uncontested key-verified confirmations are written — the rest stay in the ledger alone and are reported with the command to record them by hand')1958 │ .action(outcomeAction('confirmed'));1959 │ 1960 │ hypothesis1961 │ .command('boundaries')
Boundary outcomes read back by claim key
1965 │ .option('--state <state>', 'Only this state: unverified, supported, contradicted, retest')1966 │ .option('--json', 'Machine-readable output (guardlink.boundary-claims/v1)')1967 │ .action(async (dir: string, opts: { project?: string; state?: string; json?: boolean }) => {1968 │ const { root, model } = await hypothesisContext(dir, opts.project);1969 │ if (opts.state && !['unverified', 'supported', 'contradicted', 'retest'].includes(opts.state)) { console.error(`Unknown state ${opts.state}`); process.exitCode = 1; return; }1970 │ // @flows LedgerFile -> #cli via classifyBoundaryClaims -- "Boundary outcomes read back by claim key"1971 │ const c = classifyBoundaryClaims(model, readHypotheses(root));1972 │ if (opts.json) {1973 │ const records = (opts.state ? c.records.filter(r => r.state === opts.state) : c.records).map(r => ({1974 │ key: r.key, claim: r.claim, id: r.id || null, asset_a: r.asset_a, asset_b: r.asset_b, directed: r.directed, description: r.description, file: r.file, line: r.line, state: r.state, expired: r.expired,1975 │ outcome: r.entry ? { outcome: r.entry.outcome, evidence: r.entry.evidence, by: r.entry.by, at: r.entry.at, source: r.entry.source, history: r.entry.history.length } : null,
dashboard --blame; identities land in the page, so blame.identity=hash is the setting for a shared dashboard
3073 │ if (model.annotations_parsed === 0 && !opts.feature) {3074 │ console.error('No annotations found. Add GuardLink annotations first.');3075 │ process.exit(1);3076 │ }3077 │ 3078 │ // @flows GitRepo -> #cli via attachBlame -- "dashboard --blame; identities land in the page, so blame.identity=hash is the setting for a shared dashboard"3079 │ if (opts.blame) attachBlame(root, model);3080 │ 3081 │ // @flows GitRepo -> #cli via loadSince -- "dashboard --since <ref>: the model at the ref, diffed against this one"3082 │ let since: SinceInput | undefined;3083 │ if (opts.since) {
dashboard --since <ref>: the model at the ref, diffed against this one
3076 │ }3077 │ 3078 │ // @flows GitRepo -> #cli via attachBlame -- "dashboard --blame; identities land in the page, so blame.identity=hash is the setting for a shared dashboard"3079 │ if (opts.blame) attachBlame(root, model);3080 │ 3081 │ // @flows GitRepo -> #cli via loadSince -- "dashboard --since <ref>: the model at the ref, diffed against this one"3082 │ let since: SinceInput | undefined;3083 │ if (opts.since) {3084 │ try {3085 │ since = await loadSince(root, opts.since, project, model);3086 │ console.error(`Compared against ${opts.since}: ${since.diff.summary.newUnmitigated} new unmitigated, ${since.diff.summary.resolvedUnmitigated} resolved`);
Displays ledger state (absent, corrupt, or counts) computed by the caller; this function performs no I/O of its own
3873 │ // one, where this line is the difference between a model and a model nothing3874 │ // can read. Computed by the caller — see the no-I/O note below.3875 │ if (handoff && handoff.verdict !== 'not-needed') {3876 │ console.log(`Consumers read: ${describeHandoff(handoff)}`);3877 │ }3878 │ // @comment -- "Displays ledger state (absent, corrupt, or counts) computed by the caller; this function performs no I/O of its own"3879 │ if (verification) {3880 │ const s = verification.summary;3881 │ if (verification.ledger === 'absent') {3882 │ console.log('Verified claims: none recorded (run guardlink verify --all)');3883 │ } else if (verification.ledger === 'corrupt') {
Identity strings printed to the terminal
3950 │ 3951 │ /**3952 │ * The short form of `guardlink blame` for `status --blame`: the top five3953 │ * people and AI tools by exposures introduced.3954 │ *3955 │ * @handles pii on #cli -- "Identity strings printed to the terminal"3956 │ * @comment -- "Prints only what the payload already holds; no I/O here"3957 │ */3958 │ function printBlameSummary(payload: BlamePayload): void {3959 │ console.log(`${'─'.repeat(40)}`);3960 │ if (payload.status === 'no-git') {
Prints only what the payload already holds; no I/O here
3951 │ /**3952 │ * The short form of `guardlink blame` for `status --blame`: the top five3953 │ * people and AI tools by exposures introduced.3954 │ *3955 │ * @handles pii on #cli -- "Identity strings printed to the terminal"3956 │ * @comment -- "Prints only what the payload already holds; no I/O here"3957 │ */3958 │ function printBlameSummary(payload: BlamePayload): void {3959 │ console.log(`${'─'.repeat(40)}`);3960 │ if (payload.status === 'no-git') {3961 │ console.log('Attribution: not a git checkout — nothing to attribute');