guardlinkthreat model
Assets 20
Open 16
Controls 14
Coverage 86%
/

16 of 126 exposures are open

3 critical or high. Mitigated means a @mitigates covers the same asset and threat; accepted means an @accepts in code signs the risk off. Acceptances shown here are read from @accepts annotations in this repository, not from the server decision log. An annotation is a proposal signed in a commit; the signer is free text and is not verified.

D risk grade
What to do next4 items
0 critical, 3 high. Each needs a @mitigates with a real control, or a human @accepts with a reason.
guardlink review .
Show
The code beneath them changed since they were verified; 36 of them are mitigations or acceptances that may no longer hold. Re-check the code, then re-lock.
guardlink verify --stale
Show
#agent-launcher, #mcp, #tui, #codegraph and 5 more carry open exposures but no @owns, so nobody is accountable for closing them. Name the team in the definitions file.
Each @audit marks a risk with no control yet. Review it and either add a control or record a decision.
Worst open exposures8 of 16
SeverityAsset → threatState
high
#agent-launcher → #prompt-injection
User prompt concatenated into agent instruction text
src/agents/prompts.ts:6
open
high
#mcp → #cmd-injection
Accepts tool calls from external MCP clients
src/mcp/index.ts:6
open
high
#tui → #cmd-injection
/annotate and /threat-report spawn child processes
src/tui/commands.ts:11
open
medium
#agent-launcher → #prompt-injection
User prompt passed to agent CLI as argument
src/agents/launcher.ts:13
open
medium
#agent-launcher → #config-tamper
Translate prompt may read CXG reference paths from environment overrides
src/agents/prompts.ts:10
open
medium
#llm-client → #prompt-injection
User prompts sent to LLM API
src/analyze/llm.ts:17
open
medium
#codegraph → #config-tamper
GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes
src/codegraph/index.ts:57
open
medium
#codegraph → #prompt-injection
Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt
src/codegraph/index.ts:61
open
See all 16 open exposures →
Model inventoryeach tile opens its page

Exposures

Every @exposes in the model, as a matrix of asset against threat and as rows. A cell is warm while something in it is open (in its worst open severity), a mint wash once everything in it is mitigated or refuted, hollow when accepted. Rows run in model order; columns by how many exposures each threat has. Click a cell to pin it — the table narrows to that pair. Click a row for detail; type / to search.

126 exposures
Status Severity Claim
Asset × threat
#arbitrary-write#path-traversal#dos#cmd-injection#data-exposure#api-key-exposure#insecure-deser#redos#prompt-injection#config-tamper#child-proc-injection#info-disclosure#ssrf#vacuous-pass#xssGUARDLINKWORKSPACE#parser#cli#tui#mcp#llm-client#dashboard#init#agent-launcher#diff#sarif#suggest#blame#codegraph#gate#estate-view#merge-engine#report-metadata3663211222322222322322223332
Exposures16 open · 110 mitigated · 0 accepted

One table, every exposure, open first. Open rows have no covering control.

Description
Openstale
high
#agent-launcher#prompt-injection
User prompt concatenated into agent instruction text
prompts.ts:6src/agents
Openverified
high
#mcp#cmd-injection
Accepts tool calls from external MCP clients
index.ts:6src/mcp
Openstale
high
#tui#cmd-injection
/annotate and /threat-report spawn child processes
commands.ts:11src/tui
Openverified
medium
#agent-launcher#prompt-injection
User prompt passed to agent CLI as argument
launcher.ts:13src/agents
Openstale
medium
#agent-launcher#config-tamper
Translate prompt may read CXG reference paths from environment overrides
prompts.ts:10src/agents
Openverified
medium
#llm-client#prompt-injection
User prompts sent to LLM API
llm.ts:17src/analyze
Openunverified
medium
#codegraph#config-tamper
GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes
index.ts:57src/codegraph
Openunverified
medium
#codegraph#prompt-injection
Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt
index.ts:61src/codegraph
Openstale
medium
#mcp#prompt-injection
annotate and threat_report tools pass user prompts to LLM
server.ts:39src/mcp
Openstale
medium
#mcp#data-exposure
Resources expose full threat model to MCP clients
server.ts:43src/mcp
Openstale
medium
#tui#prompt-injection
Freeform chat sends user text to LLM
commands.ts:15src/tui
Openstale
low
#llm-client#data-exposure
Serializes full threat model and code snippets for LLM
index.ts:12src/analyze
Openstale
low
#sarif#data-exposure
Exposes threat model findings to SARIF consumers
sarif.ts:48src/analyzer
Openstale
low
#init#data-exposure
Writes API key config to .guardlink/config.json
index.ts:12src/init
Openverified
low
#suggest#dos
Large files loaded into memory for pattern scanning
suggest.ts:16src/mcp
Openverified
low
#parser#data-exposure
Reads every annotated source file into memory
migrate-mode.ts:26src/parser
Mitigatedverified
critical
#agent-launcher#child-proc-injection
spawn/spawnSync execute external binaries
launcher.ts:10src/agents
Mitigatedstale
critical
#cli#cmd-injection
Agent launcher spawns child processes
index.ts:39src/cli
Mitigatedverified
high
#agent-launcher#api-key-exposure
API keys loaded from env vars, files; stored in config.json
config.ts:13src/agents
Mitigatedverified
high
#llm-client#api-key-exposure
API keys passed in Authorization headers
llm.ts:15src/analyze
Mitigatedstale
high
#cli#path-traversal
User-supplied dir argument resolved via path.resolve
index.ts:33src/cli
Mitigatedstale
high
#cli#arbitrary-write
init/report/sarif/dashboard write files to user-specified paths
index.ts:35src/cli
Mitigatedstale
high
#cli#api-key-exposure
API keys handled in config set/show commands
index.ts:37src/cli
Mitigatedunverified
high
#codegraph#child-proc-injection
Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP
index.ts:55src/codegraph
Mitigatedunverified
high
#dashboard#xss
generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants
generate.ts:28src/dashboard
Mitigatedstale
high
#dashboard#xss
Generates HTML with threat model data
index.ts:4src/dashboard
Mitigatedverified
high
#diff#cmd-injection
execSync runs git commands with ref argument
git.ts:6src/diff
Mitigatedverified
high
#diff#cmd-injection
ref is interpolated into an execSync git command
git.ts:92src/diff
Mitigatedverified
high
#diff#cmd-injection
git.ts uses execSync with ref argument
index.ts:4src/diff
Mitigatedunverified
high
#cli#arbitrary-write
scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from
commands.ts:24src/hypothesis
Mitigatedstale
high
#init#arbitrary-write
Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc.
index.ts:8src/init
Mitigatedverified
high
#init#arbitrary-write
Creates directories for agent-file writes
index.ts:517src/init
Mitigatedverified
high
#init#arbitrary-write
Decides whether init may overwrite an existing definitions/config file
preserve.ts:16src/init
Mitigatedstale
high
#mcp#path-traversal
Tool arguments include 'root' directory path from external client
server.ts:35src/mcp
Mitigatedstale
high
#mcp#arbitrary-write
report, dashboard, sarif tools write files
server.ts:37src/mcp
Mitigatedverified
high
#suggest#path-traversal
File path from MCP client joined with root
suggest.ts:12src/mcp
Mitigatedverified
high
#parser#arbitrary-write
Writes annotation sidecars from tool input
apply-annotations.ts:21src/parser
Mitigatedstale
high
#parser#arbitrary-write
Writes modified content back to discovered files
clear.ts:8src/parser
Mitigatedstale
high
#parser#path-traversal
Glob patterns determine which files are modified
clear.ts:9src/parser
Mitigatedverified
high
#parser#arbitrary-write
Rewrites source files and creates sidecars across the project
migrate-mode.ts:24src/parser
Mitigatedstale
high
#parser#path-traversal
File path from caller read via readFile; no validation here
parse-file.ts:6src/parser
Mitigatedstale
high
#parser#path-traversal
Glob patterns could escape root directory
parse-project.ts:5src/parser
Mitigatedstale
high
#cli#arbitrary-write
Accepting a proposal writes an @entitles line into a source file named by the proposal
entitlements.ts:27src/review
Mitigatedstale
high
#cli#arbitrary-write
Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation
entitlements.ts:32src/review
Mitigatedunverified
high
#cli#arbitrary-write
Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation
index.ts:30src/review
Mitigatedverified
high
#cli#arbitrary-write
Writes annotation lines into a caller-supplied file path
index.ts:413src/review
Mitigatedstale
high
#tui#path-traversal
File paths from user args in /view, /sarif -o
commands.ts:7src/tui
Mitigatedstale
high
#tui#arbitrary-write
/report, /sarif, /dashboard write files
commands.ts:9src/tui
Mitigatedstale
high
#tui#api-key-exposure
/model handles API key input and storage
commands.ts:13src/tui
Mitigatedverified
high
#tui#api-key-exposure
API keys loaded from and saved to config files
config.ts:7src/tui
Mitigatedstale
high
#tui#path-traversal
User-supplied dir argument resolved via path.resolve
index.ts:9src/tui
Mitigatedunverified
high
#estate-view#vacuous-pass
Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health
estate.ts:57src/workspace
Mitigatedunverified
high
#merge-engine#vacuous-pass
A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate
merge.ts:48src/workspace
Mitigatedverified
medium
#agent-launcher#path-traversal
Config paths resolved from root and homedir
config.ts:15src/agents
Mitigatedverified
medium
#agent-launcher#arbitrary-write
saveProjectConfig writes to .guardlink/config.json
config.ts:17src/agents
Mitigatedstale
medium
#agent-launcher#path-traversal
Reads reference docs from root-relative paths
prompts.ts:8src/agents
Mitigatedstale
medium
#llm-client#path-traversal
buildProjectContext reads files from root-relative paths
index.ts:8src/analyze
Mitigatedstale
medium
#llm-client#arbitrary-write
writeFileSync saves threat reports to .guardlink/
index.ts:10src/analyze
Mitigatedverified
medium
#llm-client#ssrf
fetch() calls external LLM API endpoints
llm.ts:13src/analyze
Mitigatedverified
medium
#llm-client#ssrf
lookupCve fetches from NVD API with user-controlled CVE ID
tools.ts:9src/analyze
Mitigatedverified
medium
#llm-client#path-traversal
searchCodebase reads files from project root
tools.ts:11src/analyze
Mitigatedstale
medium
#dashboard#arbitrary-write
Writes .mmd, model.json and MANIFEST.json under a caller-supplied root
emit.ts:27src/artifacts
Mitigatedunverified
medium
#blame#data-exposure
identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses
trailers.ts:19src/blame
Mitigatedunverified
medium
#dashboard#path-traversal
readFileSync reads the file each annotation's location names, for the code context
annotations.ts:5src/dashboard
Mitigatedstale
medium
#dashboard#path-traversal
readFileSync reads code files for annotation context
generate.ts:30src/dashboard
Mitigatedverified
medium
#diff#arbitrary-write
writeFileSync creates files in temp directory
git.ts:8src/diff
Mitigatedverified
medium
#diff#path-traversal
git show extracts files based on ls-tree output
git.ts:10src/diff
Mitigatedunverified
medium
#gate#arbitrary-write
stripViolations rewrites source files named by the model's locations
gate.ts:11src/gate
Mitigatedunverified
medium
#cli#arbitrary-write
writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed
commands.ts:10src/hypothesis
Mitigatedstale
medium
#init#path-traversal
Reads/writes files based on root argument
index.ts:10src/init
Mitigatedstale
medium
#mcp#path-traversal
Caller-supplied file path is resolved against the project root
context.ts:17src/mcp
Mitigatedstale
medium
#mcp#api-key-exposure
threat_report tool uses API keys from environment
server.ts:41src/mcp
Mitigatedstale
medium
#mcp#arbitrary-write
guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json
server.ts:45src/mcp
Mitigatedverified
medium
#suggest#redos
Complex regex patterns applied to source code
suggest.ts:14src/mcp
Mitigatedunverified
medium
#parser#config-tamper
readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear
acceptance.ts:47src/parser
Mitigatedunverified
medium
#parser#redos
Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source
comment-strip.ts:8src/parser
Mitigatedstale
medium
#parser#dos
Large files loaded entirely into memory
parse-file.ts:7src/parser
Mitigatedstale
medium
#parser#redos
Complex regex patterns applied to annotation text
parse-line.ts:5src/parser
Mitigatedstale
medium
#parser#dos
Large projects with many files could exhaust memory
parse-project.ts:7src/parser
Mitigatedstale
medium
#cli#insecure-deser
JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written
entitlements.ts:29src/review
Mitigatedstale
medium
#cli#arbitrary-write
Writes @accepts/@audit annotations into source files
index.ts:28src/review
Mitigatedstale
medium
#tui#api-key-exposure
API keys displayed in banner via resolveLLMConfig
index.ts:11src/tui
Mitigatedunverified
medium
#estate-view#insecure-deser
Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact
estate.ts:62src/workspace
Mitigatedverified
low
#agent-launcher#dos
No timeout on foreground spawn; agent controls duration
launcher.ts:15src/agents
Mitigatedverified
low
#agent-launcher#dos
Prompt size now scales with model size: uncapped flow and exposure rows mean a large repo assembles a large prompt
prompts.ts:69src/agents
Mitigatedunverified
low
#llm-client#insecure-deser
JSON.parse over a block the model wrote
findings.ts:8src/analyze
Mitigatedverified
low
#llm-client#dos
searchCodebase reads many files; the LLM now sets max_results itself
tools.ts:13src/analyze
Mitigatedunverified
low
#sarif#dos
Enumerating upstream @flows chains is combinatorial in a dense flow graph
sarif-context.ts:43src/analyzer
Mitigatedunverified
low
#blame#path-traversal
safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git
compute.ts:23src/blame
Mitigatedunverified
low
#blame#dos
computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns
compute.ts:25src/blame
Mitigatedunverified
low
#blame#redos
readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history
config.ts:22src/blame
Mitigatedunverified
low
#blame#path-traversal
ignore_revs names a file git will open with --ignore-revs-file
config.ts:24src/blame
Mitigatedunverified
low
#blame#cmd-injection
gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record
git.ts:13src/blame
Mitigatedunverified
low
#blame#path-traversal
blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names
git.ts:15src/blame
Mitigatedunverified
low
#blame#dos
git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD
git.ts:17src/blame
Mitigatedunverified
low
#blame#dos
listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost
git.ts:201src/blame
Mitigatedunverified
low
#blame#redos
attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history
trailers.ts:21src/blame
Mitigatedverified
low
#cli#arbitrary-write
The one command that writes .guardlink/verified.json
index.ts:559src/cli
Mitigatedunverified
low
#codegraph#dos
A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output
index.ts:59src/codegraph
Mitigatedunverified
low
#dashboard#path-traversal
detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path
links.ts:10src/dashboard
Mitigatedunverified
low
#dashboard#data-exposure
detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML
links.ts:12src/dashboard
Mitigatedunverified
low
#dashboard#dos
Measures caller-supplied diagram text with regular expressions
render-budget.ts:57src/dashboard
Mitigatedunverified
low
#dashboard#cmd-injection
loadSince() passes the --since ref the user typed to parseAtRef() and into the git log and git rev-list argv
since.ts:6src/dashboard
Mitigatedunverified
low
#dashboard#dos
measureLegibility scans caller-supplied diagram text with regular expressions, once per line
legibility.ts:90src/graph
Mitigatedunverified
low
#cli#insecure-deser
importScan() JSON.parses a scan report
commands.ts:12src/hypothesis
Mitigatedunverified
low
#cli#arbitrary-write
writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved
ledger.ts:18src/hypothesis
Mitigatedverified
low
#init#path-traversal
Reads package.json, pyproject.toml, etc. from root
detect.ts:5src/init
Mitigatedverified
low
#mcp#info-disclosure
Envelope discloses the absolute project root and git SHA to the connected client
freshness.ts:17src/mcp
Mitigatedstale
low
#mcp#redos
Regex patterns applied to query strings
lookup.ts:24src/mcp
Mitigatedstale
low
#mcp#dos
Unbounded depth on a dense graph expands the frontier
subgraph.ts:42src/mcp
Mitigatedstale
low
#mcp#info-disclosure
D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing
subgraph.ts:44src/mcp
Mitigatedunverified
low
#parser#insecure-deser
JSON.parse of a repository file
acceptance.ts:51src/parser
Mitigatedverified
low
#parser#insecure-deser
Annotation lines arrive as caller-supplied text
apply-annotations.ts:23src/parser
Mitigatedverified
low
#parser#dos
Fingerprint globs the project tree on every call
fingerprint.ts:20src/parser
Mitigatedunverified
low
#parser#dos
Widening the scan set grows the file count a single parse walks; every added extension is another glob fast-glob expands and another file read into memory
languages.ts:23src/parser
Mitigatedverified
low
#parser#insecure-deser
JSON.parse on a committed file under .guardlink/
ledger.ts:18src/parser
Mitigatedverified
low
#cli#arbitrary-write
writeLedger writes one fixed path under root
ledger.ts:20src/parser
Mitigatedunverified
low
#parser#path-traversal
`@source file:` is author-controlled text and is joined onto the project root to test existence
parse-project.ts:143src/parser
Mitigatedverified
low
#parser#path-traversal
Reads source files named by @source blocks
reanchor.ts:19src/parser
Mitigatedverified
low
#cli#cmd-injection
git is spawned for the verifier name and HEAD
verify.ts:13src/parser
Mitigatedunverified
low
#cli#redos
selectAnnotatePlaybook() runs each Playbook.triggers RegExp against the prompt text the user typed
select.ts:6src/playbooks
Mitigatedverified
low
#parser#path-traversal
Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact
attach.ts:10src/structure
Mitigatedverified
low
#parser#dos
One structure parse per annotated file
attach.ts:12src/structure
Mitigatedverified
low
#parser#dos
Grammar WASM is loaded into memory per language; a pathological source file costs one parse
runtime.ts:16src/structure
Mitigatedverified
low
#tui#dos
Rapid keystrokes could consume CPU in raw mode
input.ts:15src/tui
Mitigatedunverified
low
#report-metadata#data-exposure
remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning
metadata.ts:11src/workspace

Breakdowns

The same rows, cut by owner, data class, severity and status, threat class, control and file. Pick a feature in the top bar and every breakdown recomputes for it.

Who owns the open risk0 teams · 9 unowned

Open exposures rolled up to the team named by @owns on the asset. An exposed asset with no owner has nobody accountable for closing it.

No @owns in the model, so no team is accountable for anything here.

Sensitive data under open exposure3 classifications

Assets recorded with @handles, and how many of them carry an open exposure. Click a class for its rows; each pill is one exposed asset.

internal 86110high
secrets 4370high
pii 6250high
Severity × status126 exposures

Open critical and high cells are the ones to close first; a large mitigated column with a small open one is a model in good shape. Stronger ink is more.

Threats by frequencyexposures per threat class

How often each threat class appears across the model; the hint says how many of those are still open.

#arbitrary-write
25 all covered
#path-traversal
25 all covered
#dos
19 1 open
#cmd-injection
9 2 open
#data-exposure
8 5 open
#api-key-exposure
7 all covered
#insecure-deser
7 all covered
#redos
7 all covered
#config-tamper
3 2 open
#child-proc-injection
2 all covered
#info-disclosure
2 all covered
Control coverage13 used · 1 unused

What each declared control actually mitigates. A control declared in the definitions that no @mitigates names is doing nothing for the model — either it is missing annotations or it is not real.

#path-validation
42 2 threats · 12 assets
#output-encoding
27 2 threats · 2 assets
#resource-limits
17 3 threats · 9 assets
#regex-anchoring
11 2 threats · 6 assets
#config-validation
9 4 threats · 5 assets
#input-sanitize
7 4 threats · 4 assets
#key-redaction
7 2 threats · 6 assets
#param-commands
7 2 threats · 5 assets
#glob-filtering
4 1 threat · 3 assets
#fail-closed
2 1 threat · 2 assets
#prefix-ownership
2 1 threat · 2 assets
#identity-redaction
1 1 threat · 1 asset
1 unused #process-sandbox
Files with the most open exposurestop 10

Where the open exposure concentrates in the tree. Click to see that file's open rows.

Generate with guardlink dashboard --blame to add the people × time and AI tool × severity grids.

Diagrams

Five views of one model. Nothing is left out — each footer says what is drawn.

Assets → threats → controls. One line per exposure: warm open, mint mitigated, dashed accepted. Hover to trace it; click to list its exposures.

ASSETSTHREATSCONTROLS#parser#cli#tui#mcp#llm-client#dashboard#init#agent-launcher#diff#sarif#suggest#blame#codegraph#gate#estate-view#merge-engine#report-metadata#api-key-exposure#path-traversal25 exp · 0 open · 26 mit#arbitrary-write25 exp · 0 open · 24 mit#child-proc-injection#prompt-injection#dos19 exp · 1 open · 17 mit#config-tamper#insecure-deser#data-exposure#ssrf#redos#cmd-injection#xss2 exp · 0 open · 26 mit#info-disclosure#vacuous-pass#tag-collision#key-redaction#path-validation#param-commands#resource-limits#config-validation#input-sanitize#glob-filtering#regex-anchoring#identity-redaction#output-encoding#prefix-ownership#fail-closed#yaml-validation
126 of 126 exposures and 137 of 137 mitigations drawn17 assets · 16 threats · 13 controls
open, by severitymitigated or refutedacceptedmitigation → control

Assets

Every asset, riskiest first: what is open on it, one tick per exposure, what controls and flows it has, the trust boundaries it sits on and the data it handles. Click a header to sort by risk, exposures or name; click a row for its exposures and the way onto the diagrams. The picture of the same assets is Diagrams › Attack surface.

20 assets
Handles
#mcp
GuardLink · Model Context Protocol server, accepts tool calls from AI agents over stdio
3 · high 4 10 in · 5 out ⊢ 2 internal pii
#agent-launcher
GuardLink · Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)
3 · high 4 10 in · 3 out ⊢ 1 internal secrets
#tui
GuardLink · Interactive terminal interface with readline input and command dispatch
2 · high 3 5 in · 6 out ⊢ 1 pii secrets
#llm-client
GuardLink · Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)
2 · medium 6 10 in · 3 out ⊢ 2 internal secrets
#codegraph
GuardLink · Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON
2 · medium 2 6 in · 3 out ⊢ 1
#parser
GuardLink · Reads source files from disk, extracts security annotations using regex patterns
1 · low 7 26 in · 6 out ⊢ 1 internal
#init
GuardLink · Initializes projects, writes config files and agent instruction files to disk
1 · low 2 4 in · 2 out — internal
#suggest
GuardLink · Analyzes code patterns to suggest appropriate security annotations
1 · low 2 1 in · 1 out —
#sarif
GuardLink · Exports findings as SARIF 2.1.0 JSON for security tooling
1 · low 1 6 in · 1 out — internal
#cli
GuardLink · Command-line interface, handles user arguments, invokes subcommands
none open 7 34 in · 13 out ⊢ 1 internal pii secrets
#dashboard
GuardLink · Generates interactive HTML threat model dashboard from ThreatModel data
none open 4 20 in · 3 out — internal pii
#blame
GuardLink · Maps annotated code spans to git history: authors, commits, AI co-author trailers
none open 5 5 in · 5 out — pii
#diff
GuardLink · Compares threat models across git commits, invokes git commands
none open 3 4 in · 3 out ⊢ 1
#estate-view
Workspace · Estate-wide open-risk read-back over a merged report
none open 2 2 in · 1 out —
#gate
GuardLink · Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails
none open 1 3 in · 2 out —
#merge-engine
Workspace · Cross-repo threat model unification
none open 2 3 in · 2 out —
#report-metadata
Workspace · Report provenance data
none open 1 2 in · 1 out —
#report
GuardLink · Generates markdown threat model reports with Mermaid diagrams
— nothing declared 1 6 in · 1 out — pii
#workspace-link
Workspace · Multi-repo workspace linking setup
— nothing declared 0 1 in · 1 out —
#workspace-config
Workspace · Multi-repo workspace definition
— nothing declared 1 0 in · 0 out —
Validations8
Description
#resource-limits #codegraph tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run
index.ts:68src/codegraph
#param-commands #codegraph tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell
index.ts:69src/codegraph
#config-validation #cli
tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes
commands.ts:21src/hypothesis
#input-sanitize #cli
tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see
commands.ts:26src/hypothesis
#resource-limits #mcp tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it
subgraph.ts:46src/mcp
#regex-anchoring #parser tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null
comment-strip.ts:12src/parser
#glob-filtering #parser
tests/scanned-languages.test.ts writes an annotation in every language named here and asserts the model read it, and asserts the scan set and the marker table are the same list
languages.ts:26src/parser
#input-sanitize #parser tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics
parse-file.ts:13src/parser
Audit items27

Each @audit marks a risk that has no control yet and needs a human decision.

Description
#agent-launcher Prompt content is opaque to agent binary; injection risk depends on agent implementation
launcher.ts:14src/agents
#agent-launcher Timeout intentionally omitted for interactive sessions; inline mode has implicit control
launcher.ts:16src/agents
#agent-launcher Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context
prompts.ts:7src/agents
#agent-launcher Environment override paths are optional convenience; verify trusted local paths in CI
prompts.ts:11src/agents
#llm-client Threat model data intentionally sent to LLM for analysis
index.ts:13src/analyze
#llm-client Prompt injection mitigated by LLM provider safety; local code is read-only
llm.ts:18src/analyze
#sarif SARIF output intentionally reveals security findings for CI/CD integration
sarif.ts:49src/analyzer
#cli Child process spawning delegated to agents/launcher.ts with explicit args
index.ts:40src/cli
#codegraph The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP
index.ts:58src/codegraph
#codegraph
Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt
index.ts:62src/codegraph
#diff Git commands use execSync; ref is validated with rev-parse before use
index.ts:5src/diff
#cli
The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either
commands.ts:27src/hypothesis
#init Config file may contain API keys; .gitignore entry added automatically
index.ts:13src/init
#mcp Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model.
freshness.ts:18src/mcp
#mcp All tool calls validated by server.ts before execution
index.ts:7src/mcp
#mcp User prompts passed to LLM; model context is read-only
server.ts:40src/mcp
#mcp Threat model data intentionally exposed to connected agents
server.ts:44src/mcp
#suggest File size is bounded by project scope; production use involves reasonable file sizes
suggest.ts:17src/mcp
#parser
The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit
acceptance.ts:50src/parser
#parser Destructive operation requires explicit user confirmation via dryRun flag
clear.ts:11src/parser
#parser
Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control
migrate-mode.ts:29src/parser
#parser Path validation delegated to callers (CLI/MCP validate root)
parse-file.ts:8src/parser
#cli
Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it
entitlements.ts:39src/review
#cli Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it
index.ts:32src/review
#tui Child process spawning delegated to agents/launcher.ts
commands.ts:12src/tui
#tui User freeform text passed to LLM via cmdChat; model context is read-only
commands.ts:16src/tui
#tui API keys masked via maskKey() in banner display
index.ts:12src/tui
Assumptions2

Unverified assumptions that should be periodically reviewed.

Assumption
#parser location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)
annotation-hash.ts:30src/parser
#parser
`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance
handoff.ts:49src/parser

Code

Every file with GuardLink annotations, riskiest first. Click a file to expand it and any annotation to see details; type / to search by path, kind or asset.

153 files
L6 exposes #agent-launcher → #prompt-injection
User prompt concatenated into agent instruction text
1 │ /** 2 │ * GuardLink Agents — Prompt builders for annotation and analysis. 3 │ * 4 │ * Extracted from tui/commands.ts for shared use across CLI, TUI, MCP. 5 │ * 6 │ * @exposes #agent-launcher to #prompt-injection [high] cwe:CWE-77 -- "User prompt concatenated into agent instruction text" 7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI"
L7 audit Audit: #agent-launcher
Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context
2 │ * GuardLink Agents — Prompt builders for annotation and analysis. 3 │ * 4 │ * Extracted from tui/commands.ts for shared use across CLI, TUI, MCP. 5 │ * 6 │ * @exposes #agent-launcher to #prompt-injection [high] cwe:CWE-77 -- "User prompt concatenated into agent instruction text" 7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input"
L8 exposes #agent-launcher → #path-traversal
Reads reference docs from root-relative paths
3 │ * 4 │ * Extracted from tui/commands.ts for shared use across CLI, TUI, MCP. 5 │ * 6 │ * @exposes #agent-launcher to #prompt-injection [high] cwe:CWE-77 -- "User prompt concatenated into agent instruction text" 7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input"
L9 mitigates #path-validation mitigates #path-traversal
resolve() with root constrains file access
4 │ * Extracted from tui/commands.ts for shared use across CLI, TUI, MCP. 5 │ * 6 │ * @exposes #agent-launcher to #prompt-injection [high] cwe:CWE-77 -- "User prompt concatenated into agent instruction text" 7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input"
L10 exposes #agent-launcher → #config-tamper
Translate prompt may read CXG reference paths from environment overrides
5 │ * 6 │ * @exposes #agent-launcher to #prompt-injection [high] cwe:CWE-77 -- "User prompt concatenated into agent instruction text" 7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection"
L11 audit Audit: #agent-launcher
Environment override paths are optional convenience; verify trusted local paths in CI
6 │ * @exposes #agent-launcher to #prompt-injection [high] cwe:CWE-77 -- "User prompt concatenated into agent instruction text" 7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output"
L12 flow UserPrompt → #agent-launcher
User instruction input
7 │ * @audit #agent-launcher -- "Prompt injection mitigated by agent's own safety measures; GuardLink prompt is read-only context" 8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt"
L13 flow UserPrompt → #agent-launcher
Template translation instruction input
8 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Reads reference docs from root-relative paths" 9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt" 18 │ * @comment -- "The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)"
L14 flow UserPrompt → #agent-launcher
Threat model question input
9 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "resolve() with root constrains file access" 10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt" 18 │ * @comment -- "The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)" 19 │ */
L15 flow ThreatModel → #agent-launcher
Model context injection
10 │ * @exposes #agent-launcher to #config-tamper [medium] cwe:CWE-15 -- "Translate prompt may read CXG reference paths from environment overrides" 11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt" 18 │ * @comment -- "The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)" 19 │ */ 20 │
L16 flow #agent-launcher → AgentPrompt
Assembled prompt output
11 │ * @audit #agent-launcher -- "Environment override paths are optional convenience; verify trusted local paths in CI" 12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt" 18 │ * @comment -- "The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)" 19 │ */ 20 │ 21 │ import { existsSync, readFileSync } from 'node:fs';
L17 handles #agent-launcher: internal
Serializes threat model IDs and flows into prompt
12 │ * @flows UserPrompt -> #agent-launcher via buildAnnotatePrompt -- "User instruction input" 13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt" 18 │ * @comment -- "The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)" 19 │ */ 20 │ 21 │ import { existsSync, readFileSync } from 'node:fs'; 22 │ import { resolve } from 'node:path';
L18 comment The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)
The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)
13 │ * @flows UserPrompt -> #agent-launcher via buildTranslatePrompt -- "Template translation instruction input" 14 │ * @flows UserPrompt -> #agent-launcher via buildAskPrompt -- "Threat model question input" 15 │ * @flows ThreatModel -> #agent-launcher via model -- "Model context injection" 16 │ * @flows #agent-launcher -> AgentPrompt via return -- "Assembled prompt output" 17 │ * @handles internal on #agent-launcher -- "Serializes threat model IDs and flows into prompt" 18 │ * @comment -- "The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)" 19 │ */ 20 │ 21 │ import { existsSync, readFileSync } from 'node:fs'; 22 │ import { resolve } from 'node:path'; 23 │ import { homedir } from 'node:os';
L69 exposes #agent-launcher → #dos
Prompt size now scales with model size: uncapped flow and exposure rows mean a large repo assembles a large prompt
64 │ * arbitrary row count. On a repo large enough for that budget to bite, the fix 65 │ * is to scope the run (`--feature`, or a subgraph) rather than to hand the agent 66 │ * an arbitrary prefix of the graph and let it believe it saw everything. 67 │ */ 68 │ /** 69 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "Prompt size now scales with model size: uncapped flow and exposure rows mean a large repo assembles a large prompt" 70 │ * @mitigates #agent-launcher against #dos using #resource-limits -- "renderRows caps the assembled context at PROMPT_CONTEXT_BUDGET_CHARS and reports how many rows it dropped" 71 │ * @comment -- "Row counts are uncapped on purpose and the byte budget is the only ceiling: a truncated flow graph made the agent duplicate edges it was never shown, and a missing hop reads as findPath's 'no directed path' — a false clean rather than a coverage gap" 72 │ */ 73 │ const PROMPT_CONTEXT_BUDGET_CHARS = 120_000; 74 │
L70 mitigates #resource-limits mitigates #dos
renderRows caps the assembled context at PROMPT_CONTEXT_BUDGET_CHARS and reports how many rows it dropped
65 │ * is to scope the run (`--feature`, or a subgraph) rather than to hand the agent 66 │ * an arbitrary prefix of the graph and let it believe it saw everything. 67 │ */ 68 │ /** 69 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "Prompt size now scales with model size: uncapped flow and exposure rows mean a large repo assembles a large prompt" 70 │ * @mitigates #agent-launcher against #dos using #resource-limits -- "renderRows caps the assembled context at PROMPT_CONTEXT_BUDGET_CHARS and reports how many rows it dropped" 71 │ * @comment -- "Row counts are uncapped on purpose and the byte budget is the only ceiling: a truncated flow graph made the agent duplicate edges it was never shown, and a missing hop reads as findPath's 'no directed path' — a false clean rather than a coverage gap" 72 │ */ 73 │ const PROMPT_CONTEXT_BUDGET_CHARS = 120_000; 74 │ 75 │ /** Render rows, keeping every one that fits the budget and saying so when some do not. */
L71 comment Row counts are uncapped on purpose and the byte budget is the only ceiling: a truncated flow graph made the agent duplicate edges it was never shown, and a missing hop reads as findPath's 'no directed path' — a false clean rather than a coverage gap
Row counts are uncapped on purpose and the byte budget is the only ceiling: a truncated flow graph made the agent duplicate edges it was never shown, and a missing hop reads as findPath's 'no directed path' — a false clean rather than a coverage gap
66 │ * an arbitrary prefix of the graph and let it believe it saw everything. 67 │ */ 68 │ /** 69 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "Prompt size now scales with model size: uncapped flow and exposure rows mean a large repo assembles a large prompt" 70 │ * @mitigates #agent-launcher against #dos using #resource-limits -- "renderRows caps the assembled context at PROMPT_CONTEXT_BUDGET_CHARS and reports how many rows it dropped" 71 │ * @comment -- "Row counts are uncapped on purpose and the byte budget is the only ceiling: a truncated flow graph made the agent duplicate edges it was never shown, and a missing hop reads as findPath's 'no directed path' — a false clean rather than a coverage gap" 72 │ */ 73 │ const PROMPT_CONTEXT_BUDGET_CHARS = 120_000; 74 │ 75 │ /** Render rows, keeping every one that fits the budget and saying so when some do not. */ 76 │ function renderRows(rows: string[], budget = PROMPT_CONTEXT_BUDGET_CHARS): { text: string; omitted: number } {
L145 comment The method sits above the generic rules on purpose: the agent reads it first, and the evidence bar it carries is what the gate later checks
The method sits above the generic rules on purpose: the agent reads it first, and the evidence bar it carries is what the gate later checks
140 │ * the user's text supplies scope and intent. When no playbook is given one is 141 │ * selected from the text by rule, so a vague prompt still runs a full method. 142 │ * Then the reference doc, the current model, the placement mode and the 143 │ * syntax rules. 144 │ * 145 │ * @comment -- "The method sits above the generic rules on purpose: the agent reads it first, and the evidence bar it carries is what the gate later checks" 146 │ * @flows #codegraph -> #agent-launcher via worklist -- "Optional code-graph worklist, placed between the scope and the method; absent, the prompt is unchanged" 147 │ */ 148 │ export function buildAnnotatePrompt( 149 │ userPrompt: string, 150 │ root: string,
L146 flow #codegraph → #agent-launcher
Optional code-graph worklist, placed between the scope and the method; absent, the prompt is unchanged
141 │ * selected from the text by rule, so a vague prompt still runs a full method. 142 │ * Then the reference doc, the current model, the placement mode and the 143 │ * syntax rules. 144 │ * 145 │ * @comment -- "The method sits above the generic rules on purpose: the agent reads it first, and the evidence bar it carries is what the gate later checks" 146 │ * @flows #codegraph -> #agent-launcher via worklist -- "Optional code-graph worklist, placed between the scope and the method; absent, the prompt is unchanged" 147 │ */ 148 │ export function buildAnnotatePrompt( 149 │ userPrompt: string, 150 │ root: string, 151 │ model: ThreatModel | null,
L297 shield Example annotation block for reference, excluded from parsing
292 │ 293 │ ### Always Couple Annotations Together 294 │ A file's doc-block should paint the full security picture of that module. Group annotations logically: 295 │ 296 │ \`\`\` 297 │ // @shield:begin -- "Example annotation block for reference, excluded from parsing" 298 │ // 299 │ // GOOD — Complete story at a single code location: 300 │ // @exposes #auth-api to #sqli [P1] cwe:CWE-89 -- "User-supplied email passed to findUser() query builder" 301 │ // @mitigates #auth-api against #sqli using #input-validation -- "Zod schema validates email format before query" 302 │ // @flows User_Input -> #auth-api via POST./login -- "Login form submits credentials"
L310 shield Shielded region
305 │ // @comment -- "Password comparison uses bcrypt.compare with timing-safe equality" 306 │ // 307 │ // BAD — Isolated annotation with no context: 308 │ // @exposes #auth-api to #sqli -- "SQL injection possible" 309 │ // 310 │ // @shield:end 311 │ \`\`\` 312 │ 313 │ ### Description Style — Reference Actual Code 314 │ Descriptions must reference the real code: function names, variable names, libraries, mechanisms. 315 │
L317 shield Description examples, excluded from parsing
312 │ 313 │ ### Description Style — Reference Actual Code 314 │ Descriptions must reference the real code: function names, variable names, libraries, mechanisms. 315 │ 316 │ \`\`\` 317 │ // @shield:begin -- "Description examples, excluded from parsing" 318 │ // 319 │ // GOOD: -- "req.body.token passed to jwt.verify() without audience check" 320 │ // GOOD: -- "bcrypt rounds set to 12 via BCRYPT_COST env var" 321 │ // GOOD: -- "Rate limiter uses express-rate-limit at 100req/15min on /api/*" 322 │ //
L327 shield Shielded region
322 │ // 323 │ // BAD: -- "Input not validated" (too vague — WHICH input? WHERE?) 324 │ // BAD: -- "Uses encryption" (WHAT encryption? On WHAT data?) 325 │ // BAD: -- "Security vulnerability exists" (meaningless — be specific) 326 │ // 327 │ // @shield:end 328 │ \`\`\` 329 │ 330 │ ### \`@flows\` — Stitch the Complete Data Path 331 │ @flows is the backbone of the threat model. Trace data movement accurately: 332 │
L334 shield Flow examples, excluded from parsing
329 │ 330 │ ### \`@flows\` — Stitch the Complete Data Path 331 │ @flows is the backbone of the threat model. Trace data movement accurately: 332 │ 333 │ \`\`\` 334 │ // @shield:begin -- "Flow examples, excluded from parsing" 335 │ // 336 │ // Trace a request through the full stack: 337 │ // @flows User_Browser -> #api-gateway via HTTPS -- "Client sends auth request" 338 │ // @flows #api-gateway -> #auth-service via internal.gRPC -- "Gateway forwards to auth microservice" 339 │ // @flows #auth-service -> #user-db via pg.query -- "Looks up user record by email"
L343 shield Shielded region
338 │ // @flows #api-gateway -> #auth-service via internal.gRPC -- "Gateway forwards to auth microservice" 339 │ // @flows #auth-service -> #user-db via pg.query -- "Looks up user record by email" 340 │ // @flows #auth-service -> #session-store via redis.set -- "Stores session token with TTL" 341 │ // @flows #auth-service -> User_Browser via Set-Cookie -- "Returns session cookie to client" 342 │ // 343 │ // @shield:end 344 │ \`\`\` 345 │ 346 │ ### \`@boundary\` — Mark Every Trust Zone Crossing 347 │ Place @boundary annotations where trust level changes between two components. 348 │ When you know which side is less trusted, write the directed form \`@boundary from <outer> to <inner>\`: the outer side is where untrusted input comes from, the inner side is what the boundary protects. Testers probe the inner side from the outer one, and without a direction they cannot tell which is which when both sides are declared assets. Write \`between\` only when the code does not show the direction:
L351 shield Boundary examples, excluded from parsing
346 │ ### \`@boundary\` — Mark Every Trust Zone Crossing 347 │ Place @boundary annotations where trust level changes between two components. 348 │ When you know which side is less trusted, write the directed form \`@boundary from <outer> to <inner>\`: the outer side is where untrusted input comes from, the inner side is what the boundary protects. Testers probe the inner side from the outer one, and without a direction they cannot tell which is which when both sides are declared assets. Write \`between\` only when the code does not show the direction: 349 │ 350 │ \`\`\` 351 │ // @shield:begin -- "Boundary examples, excluded from parsing" 352 │ // 353 │ // @boundary from External_Internet to #api-gateway (#public-boundary) -- "TLS termination, rate limiting at edge" 354 │ // @boundary from #backend to #database (#data-boundary) -- "Application to persistence layer, connection pooling via pgBouncer" 355 │ // @boundary between #app and #payment-provider (#vendor-boundary) -- "PCI-DSS scope boundary, tokenized card data only" 356 │ //
L357 shield Shielded region
352 │ // 353 │ // @boundary from External_Internet to #api-gateway (#public-boundary) -- "TLS termination, rate limiting at edge" 354 │ // @boundary from #backend to #database (#data-boundary) -- "Application to persistence layer, connection pooling via pgBouncer" 355 │ // @boundary between #app and #payment-provider (#vendor-boundary) -- "PCI-DSS scope boundary, tokenized card data only" 356 │ // 357 │ // @shield:end 358 │ \`\`\` 359 │ 360 │ ### Where to Place Annotations 361 │ ${annotationMode === 'external' 362 │ ? 'Annotations go in associated `.gal` files, grouped by `@source` blocks that point at the real code location:'
L418 shield Actor example, excluded from parsing
413 │ 4. **Under-grant, don't over-grant.** Naming too narrow an actor costs noise; naming too broad an actor can close a real privilege escalation as by-design. When unsure which role the code actually requires, write @comment describing what you saw and let a human decide. 414 │ 5. **Declare the actor first.** \`@actor <Name> (#id)\` belongs in the definitions file next to @asset/@threat/@control; an @entitles naming an undeclared actor is a validation error. 415 │ 416 │ \`\`\` 417 │ // In the definitions file — declaring the principal is yours to do: 418 │ // @shield:begin -- "Actor example, excluded from parsing" 419 │ // @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" 420 │ // @shield:end 421 │ \`\`\` 422 │ 423 │ Then propose the entitlement instead of writing it:
L420 shield Shielded region
415 │ 416 │ \`\`\` 417 │ // In the definitions file — declaring the principal is yours to do: 418 │ // @shield:begin -- "Actor example, excluded from parsing" 419 │ // @actor Namespace_Admin (#ns-admin) -- "Administers one namespace's configuration" 420 │ // @shield:end 421 │ \`\`\` 422 │ 423 │ Then propose the entitlement instead of writing it: 424 │ 425 │ \`\`\`
L437 shield Entitlement example, excluded from parsing
432 │ \`\`\` 433 │ 434 │ Report what you proposed at the end of your run, and tell the user to review it with \`guardlink entitle\`. If they accept, the annotation below is what lands — written by the accept step, with their name on it: 435 │ 436 │ \`\`\` 437 │ // @shield:begin -- "Entitlement example, excluded from parsing" 438 │ // @entitles #ns-admin to configure-archival-destination on #archival-fs 439 │ // -- "By design: the archival URI is namespace configuration. Authz: ScopeCluster/AccessAdmin at common/api/metadata.go:189" 440 │ // @shield:end 441 │ \`\`\` 442 │
L440 shield Shielded region
435 │ 436 │ \`\`\` 437 │ // @shield:begin -- "Entitlement example, excluded from parsing" 438 │ // @entitles #ns-admin to configure-archival-destination on #archival-fs 439 │ // -- "By design: the archival URI is namespace configuration. Authz: ScopeCluster/AccessAdmin at common/api/metadata.go:189" 440 │ // @shield:end 441 │ \`\`\` 442 │ 443 │ ### \`@agents\`, \`@reaches\`, \`@effects\`, \`@gates\` — What an Embedded Agent Can Reach 444 │ When the code embeds an LLM agent, its harness hands the agent capabilities: tool definitions, MCP servers, file and shell access, database clients, internal methods. Declare them, so a reviewer can see the agent's blast radius: 445 │
L454 shield Agent reach example, excluded from parsing
449 │ - \`@gates <Asset> by <#approver> [for <capability>]\` — on an approval step that blocks until the named principal decides. A gate suppresses nothing; only write one where the code really waits. 450 │ 451 │ You may write all four: they record what the code *can* do. Use the capability token an \`@entitles\` would use (\`run-sql\`, \`issue-refund\`), because "can minus may" — \`guardlink_lookup("unentitled reaches")\` — joins on it. For a capability with no entitlement, propose one only if the code shows it is meant to be there; a debug tool left registered is an \`@exposes\` + \`@audit\`, not an entitlement. 452 │ 453 │ \`\`\` 454 │ // @shield:begin -- "Agent reach example, excluded from parsing" 455 │ // @agents #support-agent to run-sql on #tool-surface -- "run_sql registered in tools.ts, still on in production" 456 │ // @effects write on #users-db as #db-service -- "db.raw(sql) runs the model-written SQL verbatim" 457 │ // @gates #payments by #support-human for issue-refund -- "requireApproval() parks the refund until a human approves" 458 │ // @shield:end 459 │ \`\`\`
L458 shield Shielded region
453 │ \`\`\` 454 │ // @shield:begin -- "Agent reach example, excluded from parsing" 455 │ // @agents #support-agent to run-sql on #tool-surface -- "run_sql registered in tools.ts, still on in production" 456 │ // @effects write on #users-db as #db-service -- "db.raw(sql) runs the model-written SQL verbatim" 457 │ // @gates #payments by #support-human for issue-refund -- "requireApproval() parks the refund until a human approves" 458 │ // @shield:end 459 │ \`\`\` 460 │ 461 │ ### \`@accepts\` — NEVER USE (Human-Only Decision) 462 │ @accepts marks a risk as intentionally unmitigated. This is a **human-only governance decision** — it requires conscious risk ownership by a person or team. 463 │ As an AI agent, you MUST NEVER write @accepts annotations. You cannot accept risk on behalf of humans.
L473 shield @accepts alternative examples, excluded from parsing
468 │ 3. Add @comment explaining what controls COULD be added 469 │ 4. Optionally add @assumes to document any assumptions the code makes 470 │ 471 │ Example — what to do when no mitigation exists: 472 │ \`\`\` 473 │ // @shield:begin -- "@accepts alternative examples, excluded from parsing" 474 │ // 475 │ // WRONG (AI rubber-stamping risk): 476 │ // @accepts #prompt-injection on #ai-endpoint -- "Relying on model safety filters" 477 │ // 478 │ // RIGHT (flag for human review):
L483 shield Shielded region
478 │ // RIGHT (flag for human review): 479 │ // @exposes #ai-endpoint to #prompt-injection [P1] cwe:CWE-77 -- "User prompt passed directly to LLM API without sanitization" 480 │ // @audit #ai-endpoint -- "No prompt sanitization — needs human review to decide: add input filter or accept risk" 481 │ // @comment -- "Potential controls: #prompt-filter (input sanitization), #output-validator (response filtering)" 482 │ // 483 │ // @shield:end 484 │ \`\`\` 485 │ 486 │ Leaving exposures unmitigated is HONEST. The dashboard and reports will surface them as open risks for humans to triage. 487 │ 488 │ ### Pentest-Confirmable vs Governance-Only Gaps
L510 shield Definition syntax examples, excluded from parsing
505 │ 506 │ Definitions go in .guardlink/definitions.{ts,js,py,rs}. Relationship annotations can live in source comments or standalone .gal files. 507 │ 508 │ ### Definitions (in .guardlink/definitions file) 509 │ \`\`\` 510 │ // @shield:begin -- "Definition syntax examples, excluded from parsing" 511 │ // @asset Server.Auth (#auth) -- "Authentication service handling login and session management" 512 │ // @threat SQL_Injection (#sqli) [P0] cwe:CWE-89 -- "Unsanitized input reaches SQL query builder" 513 │ // @control Prepared_Statements (#prepared-stmts) -- "Parameterized queries via ORM or driver placeholders" 514 │ // @shield:end 515 │ \`\`\`
L514 shield Shielded region
509 │ \`\`\` 510 │ // @shield:begin -- "Definition syntax examples, excluded from parsing" 511 │ // @asset Server.Auth (#auth) -- "Authentication service handling login and session management" 512 │ // @threat SQL_Injection (#sqli) [P0] cwe:CWE-89 -- "Unsanitized input reaches SQL query builder" 513 │ // @control Prepared_Statements (#prepared-stmts) -- "Parameterized queries via ORM or driver placeholders" 514 │ // @shield:end 515 │ \`\`\` 516 │ 517 │ ### Relationships (in source files) 518 │ \`\`\` 519 │ // @shield:begin -- "Relationship syntax examples, excluded from parsing"
L519 shield Relationship syntax examples, excluded from parsing
514 │ // @shield:end 515 │ \`\`\` 516 │ 517 │ ### Relationships (in source files) 518 │ \`\`\` 519 │ // @shield:begin -- "Relationship syntax examples, excluded from parsing" 520 │ // @exposes #auth to #sqli [P0] cwe:CWE-89 owasp:A03:2021 -- "User input concatenated into query" 521 │ // @confirmed #sqli on #auth [critical] cwe:CWE-89 -- "Pentest 2026-04: time-based blind SQLi on /login confirmed" 522 │ // @mitigates #auth against #sqli using #prepared-stmts -- "Uses parameterized queries via sqlx" 523 │ // @audit #auth -- "Timing attack risk — needs human review to decide if bcrypt constant-time comparison is sufficient" 524 │ // @transfers #ddos from #api to #cdn -- "Cloudflare handles L7 DDoS mitigation"
L535 shield Shielded region
530 │ // @assumes #auth -- "Upstream API gateway has already validated TLS and rate-limited requests" 531 │ // @owns security-team for #auth -- "Security team reviews all auth PRs" 532 │ // @entitles #tenant-admin to rotate-signing-key on #auth -- "By design: key rotation is tenant admin scope. Authz: src/authz/scopes.ts:88" 533 │ // @feature "SSO Login" -- "Single sign-on authentication flow" 534 │ // @comment -- "Password hashing uses bcrypt with cost factor 12, migration from SHA256 completed in v2.1" 535 │ // @shield:end 536 │ \`\`\` 537 │ 538 │ ### Relationships (in standalone .gal files) 539 │ \`\`\` 540 │ // @shield:begin -- "Externalized relationship examples, excluded from parsing"
L540 shield Externalized relationship examples, excluded from parsing
535 │ // @shield:end 536 │ \`\`\` 537 │ 538 │ ### Relationships (in standalone .gal files) 539 │ \`\`\` 540 │ // @shield:begin -- "Externalized relationship examples, excluded from parsing" 541 │ @source file:src/auth/login.ts line:42 symbol:authenticate 542 │ @exposes #auth to #sqli [P0] cwe:CWE-89 owasp:A03:2021 -- "User input concatenated into query" 543 │ @mitigates #auth against #sqli using #prepared-stmts -- "Uses parameterized queries via sqlx" 544 │ @audit #auth -- "Timing attack risk — needs human review" 545 │ // @shield:end
L545 shield Shielded region
540 │ // @shield:begin -- "Externalized relationship examples, excluded from parsing" 541 │ @source file:src/auth/login.ts line:42 symbol:authenticate 542 │ @exposes #auth to #sqli [P0] cwe:CWE-89 owasp:A03:2021 -- "User input concatenated into query" 543 │ @mitigates #auth against #sqli using #prepared-stmts -- "Uses parameterized queries via sqlx" 544 │ @audit #auth -- "Timing attack risk — needs human review" 545 │ // @shield:end 546 │ \`\`\` 547 │ 548 │ ## CRITICAL SYNTAX RULES (violations cause parse errors) 549 │ 550 │ 1. **@boundary requires TWO assets**: \`@boundary from #outer to #inner\` when you know which side is less trusted, otherwise \`@boundary between #A and #B\` or \`@boundary #A | #B\`.
L7 exposes #tui → #path-traversal
File paths from user args in /view, /sarif -o
2 │ * GuardLink TUI — Command implementations. 3 │ * 4 │ * Each command function takes (args, ctx) and prints output directly. 5 │ * Returns void. Throws on fatal errors. 6 │ * 7 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o" 8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts"
L8 mitigates #path-validation mitigates #path-traversal
resolve() with ctx.root constrains file access
3 │ * 4 │ * Each command function takes (args, ctx) and prints output directly. 5 │ * Returns void. Throws on fatal errors. 6 │ * 7 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o" 8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage"
L9 exposes #tui → #arbitrary-write
/report, /sarif, /dashboard write files
4 │ * Each command function takes (args, ctx) and prints output directly. 5 │ * Returns void. Throws on fatal errors. 6 │ * 7 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o" 8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output"
L10 mitigates #path-validation mitigates #arbitrary-write
Output paths resolved relative to project root
5 │ * Returns void. Throws on fatal errors. 6 │ * 7 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o" 8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM"
L11 exposes #tui → #cmd-injection
/annotate and /threat-report spawn child processes
6 │ * 7 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o" 8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only"
L12 audit Audit: #tui
Child process spawning delegated to agents/launcher.ts
7 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "File paths from user args in /view, /sarif -o" 8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input"
L13 exposes #tui → #api-key-exposure
/model handles API key input and storage
8 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() with ctx.root constrains file access" 9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output"
L14 mitigates #key-redaction mitigates #api-key-exposure
API keys masked in /model show output
9 │ * @exposes #tui to #arbitrary-write [high] cwe:CWE-73 -- "/report, /sarif, /dashboard write files" 10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path"
L15 exposes #tui → #prompt-injection
Freeform chat sends user text to LLM
10 │ * @mitigates #tui against #arbitrary-write using #path-validation -- "Output paths resolved relative to project root" 11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path"
L16 audit Audit: #tui
User freeform text passed to LLM via cmdChat; model context is read-only
11 │ * @exposes #tui to #cmd-injection [high] cwe:CWE-78 -- "/annotate and /threat-report spawn child processes" 12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path" 21 │ * @handles secrets on #tui -- "Processes and stores API keys via /model"
L17 flow UserArgs → #tui
Command argument input
12 │ * @audit #tui -- "Child process spawning delegated to agents/launcher.ts" 13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path" 21 │ * @handles secrets on #tui -- "Processes and stores API keys via /model" 22 │ */
L18 flow #tui → FileSystem
Report/config output
13 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "/model handles API key input and storage" 14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path" 21 │ * @handles secrets on #tui -- "Processes and stores API keys via /model" 22 │ */ 23 │
L19 flow #tui → #agent-launcher
Agent spawn path
14 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "API keys masked in /model show output" 15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path" 21 │ * @handles secrets on #tui -- "Processes and stores API keys via /model" 22 │ */ 23 │ 24 │ import { resolve, basename } from 'node:path';
L20 flow #tui → #llm-client
LLM API call path
15 │ * @exposes #tui to #prompt-injection [medium] cwe:CWE-77 -- "Freeform chat sends user text to LLM" 16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path" 21 │ * @handles secrets on #tui -- "Processes and stores API keys via /model" 22 │ */ 23 │ 24 │ import { resolve, basename } from 'node:path'; 25 │ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
L21 handles #tui: secrets
Processes and stores API keys via /model
16 │ * @audit #tui -- "User freeform text passed to LLM via cmdChat; model context is read-only" 17 │ * @flows UserArgs -> #tui via args -- "Command argument input" 18 │ * @flows #tui -> FileSystem via writeFile -- "Report/config output" 19 │ * @flows #tui -> #agent-launcher via launchAgent -- "Agent spawn path" 20 │ * @flows #tui -> #llm-client via chatCompletion -- "LLM API call path" 21 │ * @handles secrets on #tui -- "Processes and stores API keys via /model" 22 │ */ 23 │ 24 │ import { resolve, basename } from 'node:path'; 25 │ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; 26 │ import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, findUndeclaredActors, findAgentReachConflicts, findInertEntitlements, findImpreciseEntitlements, findUnresolvedBoundarySides, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries } from '../parser/index.js';
L1006 flow GitRepo → #tui
Attribution read from blame, log -L and commit trailers
1001 │ 1002 │ /**1003 │ * Attribution for the current model, read from git. Prints the same text as1004 │ * `guardlink blame`, indented.1005 │ *1006 │ * @flows GitRepo -> #tui via computeBlame -- "Attribution read from blame, log -L and commit trailers"1007 │ * @handles pii on #tui -- "Author identities printed to the terminal in the configured identity mode"1008 │ * @comment -- "Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked"1009 │ */1010 │ export async function cmdBlame(args: string, ctx: TuiContext): Promise<void> {1011 │ if (!ctx.model) await refreshModel(ctx);
L1007 handles #tui: pii
Author identities printed to the terminal in the configured identity mode
1002 │ /**1003 │ * Attribution for the current model, read from git. Prints the same text as1004 │ * `guardlink blame`, indented.1005 │ *1006 │ * @flows GitRepo -> #tui via computeBlame -- "Attribution read from blame, log -L and commit trailers"1007 │ * @handles pii on #tui -- "Author identities printed to the terminal in the configured identity mode"1008 │ * @comment -- "Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked"1009 │ */1010 │ export async function cmdBlame(args: string, ctx: TuiContext): Promise<void> {1011 │ if (!ctx.model) await refreshModel(ctx);1012 │ const file = args.trim() || undefined;
L1008 comment Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked
Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked
1003 │ * Attribution for the current model, read from git. Prints the same text as1004 │ * `guardlink blame`, indented.1005 │ *1006 │ * @flows GitRepo -> #tui via computeBlame -- "Attribution read from blame, log -L and commit trailers"1007 │ * @handles pii on #tui -- "Author identities printed to the terminal in the configured identity mode"1008 │ * @comment -- "Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked"1009 │ */1010 │ export async function cmdBlame(args: string, ctx: TuiContext): Promise<void> {1011 │ if (!ctx.model) await refreshModel(ctx);1012 │ const file = args.trim() || undefined;1013 │ console.log(C.dim(` Reading git history${file ? ` for ${file}` : ''}...`));
L1081 comment Derived query over @flows and @mitigates; every hop it prints is an existing annotation location, so a finding here cannot cite a line that was never written
Derived query over @flows and @mitigates; every hop it prints is an existing annotation location, so a finding here cannot cite a line that was never written
1076 │ *1077 │ * Reads the parsed model only — no file writes, no agent, no network — which is1078 │ * why this handler has no @exposes of its own while its neighbours in this file1079 │ * do. `--all` widens the result to routes a control already covers.1080 │ *1081 │ * @comment -- "Derived query over @flows and @mitigates; every hop it prints is an existing annotation location, so a finding here cannot cite a line that was never written"1082 │ */1083 │ export async function cmdPaths(args: string, ctx: TuiContext): Promise<void> {1084 │ const includeMitigated = /(?:^|\s)--all(?=\s|$)/.test(args);1085 │ const boundaryOnly = /(?:^|\s)--boundary-only(?=\s|$)/.test(args);1086 │
L6 exposes #mcp → #cmd-injection
Accepts tool calls from external MCP clients
1 │ #!/usr/bin/env node 2 │ 3 │ /** 4 │ * GuardLink MCP Server — exports and stdio entry point. 5 │ * 6 │ * @exposes #mcp to #cmd-injection [high] cwe:CWE-78 -- "Accepts tool calls from external MCP clients" 7 │ * @audit #mcp -- "All tool calls validated by server.ts before execution" 8 │ * @flows MCPClient -> #mcp via stdio -- "MCP protocol transport" 9 │ * @boundary #mcp and MCPClient (#mcp-boundary) -- "Trust boundary at MCP protocol" 10 │ * @comment -- "D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport." 11 │ */
L7 audit Audit: #mcp
All tool calls validated by server.ts before execution
2 │ 3 │ /** 4 │ * GuardLink MCP Server — exports and stdio entry point. 5 │ * 6 │ * @exposes #mcp to #cmd-injection [high] cwe:CWE-78 -- "Accepts tool calls from external MCP clients" 7 │ * @audit #mcp -- "All tool calls validated by server.ts before execution" 8 │ * @flows MCPClient -> #mcp via stdio -- "MCP protocol transport" 9 │ * @boundary #mcp and MCPClient (#mcp-boundary) -- "Trust boundary at MCP protocol" 10 │ * @comment -- "D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport." 11 │ */ 12 │
L8 flow MCPClient → #mcp
MCP protocol transport
3 │ /** 4 │ * GuardLink MCP Server — exports and stdio entry point. 5 │ * 6 │ * @exposes #mcp to #cmd-injection [high] cwe:CWE-78 -- "Accepts tool calls from external MCP clients" 7 │ * @audit #mcp -- "All tool calls validated by server.ts before execution" 8 │ * @flows MCPClient -> #mcp via stdio -- "MCP protocol transport" 9 │ * @boundary #mcp and MCPClient (#mcp-boundary) -- "Trust boundary at MCP protocol" 10 │ * @comment -- "D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport." 11 │ */ 12 │ 13 │ export { createServer } from './server.js';
L9 boundary #mcp ↔ MCPClient
Trust boundary at MCP protocol
4 │ * GuardLink MCP Server — exports and stdio entry point. 5 │ * 6 │ * @exposes #mcp to #cmd-injection [high] cwe:CWE-78 -- "Accepts tool calls from external MCP clients" 7 │ * @audit #mcp -- "All tool calls validated by server.ts before execution" 8 │ * @flows MCPClient -> #mcp via stdio -- "MCP protocol transport" 9 │ * @boundary #mcp and MCPClient (#mcp-boundary) -- "Trust boundary at MCP protocol" 10 │ * @comment -- "D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport." 11 │ */ 12 │ 13 │ export { createServer } from './server.js'; 14 │ export { lookup, type LookupResult } from './lookup.js';
L10 comment D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport.
D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport.
5 │ * 6 │ * @exposes #mcp to #cmd-injection [high] cwe:CWE-78 -- "Accepts tool calls from external MCP clients" 7 │ * @audit #mcp -- "All tool calls validated by server.ts before execution" 8 │ * @flows MCPClient -> #mcp via stdio -- "MCP protocol transport" 9 │ * @boundary #mcp and MCPClient (#mcp-boundary) -- "Trust boundary at MCP protocol" 10 │ * @comment -- "D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport." 11 │ */ 12 │ 13 │ export { createServer } from './server.js'; 14 │ export { lookup, type LookupResult } from './lookup.js'; 15 │ export { suggestAnnotations, type Suggestion, type SuggestOptions } from './suggest.js';
L55 exposes #codegraph → #child-proc-injection
Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP
50 │ * Access levels have a gate of their own: they are carried only when the 51 │ * auth-boundary verdict is `classified`. Under any other verdict the levels are 52 │ * withheld and the graph's note is carried instead, and an `unknown` level is 53 │ * always rendered as unknown with its reason, never as public. 54 │ * 55 │ * @exposes #codegraph to #child-proc-injection [high] cwe:CWE-78 -- "Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP" 56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit"
L56 mitigates #param-commands mitigates #child-proc-injection
spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element
51 │ * auth-boundary verdict is `classified`. Under any other verdict the levels are 52 │ * withheld and the graph's note is carried instead, and an `unknown` level is 53 │ * always rendered as unknown with its reason, never as public. 54 │ * 55 │ * @exposes #codegraph to #child-proc-injection [high] cwe:CWE-78 -- "Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP" 56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt"
L57 exposes #codegraph → #config-tamper
GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes
52 │ * withheld and the graph's note is carried instead, and an `unknown` level is 53 │ * always rendered as unknown with its reason, never as public. 54 │ * 55 │ * @exposes #codegraph to #child-proc-injection [high] cwe:CWE-78 -- "Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP" 56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt"
L58 audit Audit: #codegraph
The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP
53 │ * always rendered as unknown with its reason, never as public. 54 │ * 55 │ * @exposes #codegraph to #child-proc-injection [high] cwe:CWE-78 -- "Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP" 56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach"
L59 exposes #codegraph → #dos
A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output
54 │ * 55 │ * @exposes #codegraph to #child-proc-injection [high] cwe:CWE-78 -- "Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP" 56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict"
L60 mitigates #resource-limits mitigates #dos
Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit
55 │ * @exposes #codegraph to #child-proc-injection [high] cwe:CWE-78 -- "Spawns codegraph-mcp or bravos found on PATH, or the binary named by GUARDLINK_CODEGRAPH_MCP" 56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction"
L61 exposes #codegraph → #prompt-injection
Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt
56 │ * @mitigates #codegraph against #child-proc-injection using #param-commands -- "spawn/execFile with an argv array and shell:false; the only caller-derived arguments are the project root and a symbol address, each passed as one argv element" 57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments"
L62 audit Audit: #codegraph
Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt
57 │ * @exposes #codegraph to #config-tamper [medium] cwe:CWE-15 -- "GUARDLINK_CODEGRAPH_MCP names the binary to run; whoever controls the environment chooses what executes" 58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed"
L63 flow CodeGraphServer → #codegraph
JSON answers to routes, attack_surface, reachable_sinks, entry_reach
58 │ * @audit #codegraph -- "The override is an operator convenience, as PATH itself is; confirm CI environments do not let untrusted input set GUARDLINK_CODEGRAPH_MCP" 59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run"
L64 flow CodeGraphServer → #codegraph
Route access levels with the guard text and file:line each rests on; read only under the classified verdict
59 │ * @exposes #codegraph to #dos [low] cwe:CWE-400 -- "A wedged or runaway graph server could hold the annotate command or an MCP call open, or stream unbounded output" 60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell"
L65 comment Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction
Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction
60 │ * @mitigates #codegraph against #dos using #resource-limits -- "Every query has a timeout (QUERY_TIMEOUT_MS) and an output cap (MAX_OUTPUT_BYTES); the process is killed when either is hit" 61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell" 70 │ * @comment -- "Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream"
L66 flow #codegraph → CodeGraphServer
Project root and symbol address as argv / tools/call arguments
61 │ * @exposes #codegraph to #prompt-injection [medium] cwe:CWE-77 -- "Route paths, symbol names and sink call text come from the repository's own source and are rendered into the annotate prompt" 62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell" 70 │ * @comment -- "Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream" 71 │ */
L67 boundary #codegraph ↔ CodeGraphServer
Process boundary: an external binary's JSON is parsed defensively and never executed
62 │ * @audit #codegraph -- "Same trust as the source files the agent is told to read; the worklist adds no content the agent could not read itself, but a crafted route string is prose in the prompt" 63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell" 70 │ * @comment -- "Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream" 71 │ */ 72 │
L68 validates #resource-limits validates #codegraph
tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run
63 │ * @flows CodeGraphServer -> #codegraph via stdout -- "JSON answers to routes, attack_surface, reachable_sinks, entry_reach" 64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell" 70 │ * @comment -- "Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream" 71 │ */ 72 │ 73 │ import { spawn, type ChildProcess } from 'node:child_process';
L69 validates #param-commands validates #codegraph
tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell
64 │ * @flows CodeGraphServer -> #codegraph via codegraph_auth_boundary -- "Route access levels with the guard text and file:line each rests on; read only under the classified verdict" 65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell" 70 │ * @comment -- "Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream" 71 │ */ 72 │ 73 │ import { spawn, type ChildProcess } from 'node:child_process'; 74 │ import { accessSync, constants as fsConstants } from 'node:fs';
L70 comment Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream
Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream
65 │ * @comment -- "Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction" 66 │ * @flows #codegraph -> CodeGraphServer via spawn -- "Project root and symbol address as argv / tools/call arguments" 67 │ * @boundary between #codegraph and CodeGraphServer (#codegraph-boundary) -- "Process boundary: an external binary's JSON is parsed defensively and never executed" 68 │ * @validates #resource-limits for #codegraph -- "tests/codegraph.test.ts: a stand-in server that never answers costs the timeout and yields status error, not a hung run" 69 │ * @validates #param-commands for #codegraph -- "tests/codegraph.test.ts drives both transports against stand-in executables through argv, with no shell" 70 │ * @comment -- "Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream" 71 │ */ 72 │ 73 │ import { spawn, type ChildProcess } from 'node:child_process'; 74 │ import { accessSync, constants as fsConstants } from 'node:fs'; 75 │ import { delimiter, join } from 'node:path';
L35 exposes #mcp → #path-traversal
Tool arguments include 'root' directory path from external client
30 │ * guardlink://definitions — Assets, threats, controls 31 │ * guardlink://unmitigated — Unmitigated exposures list 32 │ * 33 │ * Transport: stdio (for Claude Code .mcp.json, Cursor, etc.) 34 │ * 35 │ * @exposes #mcp to #path-traversal [high] cwe:CWE-22 -- "Tool arguments include 'root' directory path from external client" 36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only"
L36 mitigates #path-validation mitigates #path-traversal
Zod schema validates root; resolve() canonicalizes
31 │ * guardlink://unmitigated — Unmitigated exposures list 32 │ * 33 │ * Transport: stdio (for Claude Code .mcp.json, Cursor, etc.) 34 │ * 35 │ * @exposes #mcp to #path-traversal [high] cwe:CWE-22 -- "Tool arguments include 'root' directory path from external client" 36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment"
L37 exposes #mcp → #arbitrary-write
report, dashboard, sarif tools write files
32 │ * 33 │ * Transport: stdio (for Claude Code .mcp.json, Cursor, etc.) 34 │ * 35 │ * @exposes #mcp to #path-traversal [high] cwe:CWE-22 -- "Tool arguments include 'root' directory path from external client" 36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned"
L38 mitigates #path-validation mitigates #arbitrary-write
Output paths resolved relative to validated root
33 │ * Transport: stdio (for Claude Code .mcp.json, Cursor, etc.) 34 │ * 35 │ * @exposes #mcp to #path-traversal [high] cwe:CWE-22 -- "Tool arguments include 'root' directory path from external client" 36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients"
L39 exposes #mcp → #prompt-injection
annotate and threat_report tools pass user prompts to LLM
34 │ * 35 │ * @exposes #mcp to #path-traversal [high] cwe:CWE-22 -- "Tool arguments include 'root' directory path from external client" 36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents"
L40 audit Audit: #mcp
User prompts passed to LLM; model context is read-only
35 │ * @exposes #mcp to #path-traversal [high] cwe:CWE-22 -- "Tool arguments include 'root' directory path from external client" 36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json"
L41 exposes #mcp → #api-key-exposure
threat_report tool uses API keys from environment
36 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Zod schema validates root; resolve() canonicalizes" 37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source"
L42 mitigates #key-redaction mitigates #api-key-exposure
Keys from env only; never logged or returned
37 │ * @exposes #mcp to #arbitrary-write [high] cwe:CWE-73 -- "report, dashboard, sarif tools write files" 38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)"
L43 exposes #mcp → #data-exposure
Resources expose full threat model to MCP clients
38 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "Output paths resolved relative to validated root" 39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input"
L44 audit Audit: #mcp
Threat model data intentionally exposed to connected agents
39 │ * @exposes #mcp to #prompt-injection [medium] cwe:CWE-77 -- "annotate and threat_report tools pass user prompts to LLM" 40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output"
L45 exposes #mcp → #arbitrary-write
guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json
40 │ * @audit #mcp -- "User prompts passed to LLM; model context is read-only" 41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path"
L46 mitigates #path-validation mitigates #arbitrary-write
The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source
41 │ * @exposes #mcp to #api-key-exposure [medium] cwe:CWE-798 -- "threat_report tool uses API keys from environment" 42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output"
L47 comment No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)
No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)
42 │ * @mitigates #mcp against #api-key-exposure using #key-redaction -- "Keys from env only; never logged or returned" 43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed"
L48 flow MCPClient → #mcp
Tool invocation input
43 │ * @exposes #mcp to #data-exposure [medium] cwe:CWE-200 -- "Resources expose full threat model to MCP clients" 44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument"
L49 flow #mcp → FileSystem
Report/dashboard output
44 │ * @audit #mcp -- "Threat model data intentionally exposed to connected agents" 45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing"
L50 flow #mcp → #llm-client
LLM API call path
45 │ * @exposes #mcp to #arbitrary-write [medium] cwe:CWE-73 -- "guardlink_entitlement_propose writes a client-supplied claim into .guardlink/entitlement-proposals.json" 46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data"
L51 flow #mcp → MCPClient
Threat model data output
46 │ * @mitigates #mcp against #arbitrary-write using #path-validation -- "The proposal artifact path is fixed, and its target file must resolve inside the project root — a proposal never writes to source" 47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling"
L52 flow #mcp → #codegraph
Ranked entry points for an annotating agent, when a code graph is installed
47 │ * @comment -- "No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)" 48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling" 57 │ * @entitles #mcp-agent to read-threat-model on #mcp against #data-exposure -- "By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23"
L53 flow #mcp → #codegraph
Client-supplied symbol address, passed to the graph as one argument
48 │ * @flows MCPClient -> #mcp via tool_call -- "Tool invocation input" 49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling" 57 │ * @entitles #mcp-agent to read-threat-model on #mcp against #data-exposure -- "By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23" 58 │ * @comment -- "Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure)."
L54 boundary #mcp ↔ MCPClient
Trust boundary at tool argument parsing
49 │ * @flows #mcp -> FileSystem via writeFile -- "Report/dashboard output" 50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling" 57 │ * @entitles #mcp-agent to read-threat-model on #mcp against #data-exposure -- "By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23" 58 │ * @comment -- "Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure)." 59 │ */
L55 handles #mcp: internal
Processes project annotations and threat model data
50 │ * @flows #mcp -> #llm-client via generateThreatReport -- "LLM API call path" 51 │ * @flows #mcp -> MCPClient via resource -- "Threat model data output" 52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling" 57 │ * @entitles #mcp-agent to read-threat-model on #mcp against #data-exposure -- "By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23" 58 │ * @comment -- "Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure)." 59 │ */ 60 │
L57 entitles #mcp-agent entitled to read-threat-model
By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23
52 │ * @flows #mcp -> #codegraph via guardlink_worklist -- "Ranked entry points for an annotating agent, when a code graph is installed" 53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling" 57 │ * @entitles #mcp-agent to read-threat-model on #mcp against #data-exposure -- "By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23" 58 │ * @comment -- "Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure)." 59 │ */ 60 │ 61 │ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 62 │ import { resolve } from 'node:path';
L58 comment Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure).
Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure).
53 │ * @flows #mcp -> #codegraph via guardlink_reach -- "Client-supplied symbol address, passed to the graph as one argument" 54 │ * @boundary #mcp and MCPClient (#mcp-tool-boundary) -- "Trust boundary at tool argument parsing" 55 │ * @handles internal on #mcp -- "Processes project annotations and threat model data" 56 │ * @feature "MCP Integration" -- "Model Context Protocol server for AI agent tooling" 57 │ * @entitles #mcp-agent to read-threat-model on #mcp against #data-exposure -- "By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23" 58 │ * @comment -- "Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure)." 59 │ */ 60 │ 61 │ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 62 │ import { resolve } from 'node:path'; 63 │ import { z } from 'zod';
L1221 flow GitRepo → #mcp
Attribution read from blame, log -L and commit trailers for the connected agent
1216 │ },1217 │ );1218 │ 1219 │ // ── Tool: guardlink_blame ──1220 │ /**1221 │ * @flows GitRepo -> #mcp via computeBlame -- "Attribution read from blame, log -L and commit trailers for the connected agent"1222 │ * @handles pii on #mcp -- "Author identities, in the configured identity mode (blame.identity in config.json), reach the MCP client"1223 │ * @comment -- "Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository"1224 │ */1225 │ registerTool(1226 │ server, cache,
L1222 handles #mcp: pii
Author identities, in the configured identity mode (blame.identity in config.json), reach the MCP client
1217 │ );1218 │ 1219 │ // ── Tool: guardlink_blame ──1220 │ /**1221 │ * @flows GitRepo -> #mcp via computeBlame -- "Attribution read from blame, log -L and commit trailers for the connected agent"1222 │ * @handles pii on #mcp -- "Author identities, in the configured identity mode (blame.identity in config.json), reach the MCP client"1223 │ * @comment -- "Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository"1224 │ */1225 │ registerTool(1226 │ server, cache,1227 │ 'guardlink_blame',
L1223 comment Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository
Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository
1218 │ 1219 │ // ── Tool: guardlink_blame ──1220 │ /**1221 │ * @flows GitRepo -> #mcp via computeBlame -- "Attribution read from blame, log -L and commit trailers for the connected agent"1222 │ * @handles pii on #mcp -- "Author identities, in the configured identity mode (blame.identity in config.json), reach the MCP client"1223 │ * @comment -- "Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository"1224 │ */1225 │ registerTool(1226 │ server, cache,1227 │ 'guardlink_blame',1228 │ 'Who introduced the code beneath each claim, who declared it, who declared its fix, and which AI tool co-authored those commits — read from git (blame, log -L, Co-Authored-By / Assisted-by trailers); nothing is written. AI credit is DECLARED by commit trailers, never detected from code: a commit with no trailer stays human. Pass `file` to narrow to one file. Entries carry the ledger claim key, so they join to .guardlink/verified.json. Outside a git checkout every entry is status no-git; uncommitted means the line has changes git has not seen; shallow means every introduction is a lower bound.',
L10 exposes #agent-launcher → #child-proc-injection
spawn/spawnSync execute external binaries
5 │ * 1. Foreground spawn (CLI + TUI): takes over terminal, returns on exit 6 │ * 2. IDE launch: opens GUI app with project directory 7 │ * 8 │ * Clipboard copy is always performed first regardless of agent type. 9 │ * 10 │ * @exposes #agent-launcher to #child-proc-injection [critical] cwe:CWE-78 -- "spawn/spawnSync execute external binaries" 11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration"
L11 mitigates #param-commands mitigates #child-proc-injection
Binary names from hardcoded AGENTS registry; no shell interpolation
6 │ * 2. IDE launch: opens GUI app with project directory 7 │ * 8 │ * Clipboard copy is always performed first regardless of agent type. 9 │ * 10 │ * @exposes #agent-launcher to #child-proc-injection [critical] cwe:CWE-78 -- "spawn/spawnSync execute external binaries" 11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control"
L12 mitigates #param-commands mitigates #cmd-injection
Arguments passed as array, not shell string
7 │ * 8 │ * Clipboard copy is always performed first regardless of agent type. 9 │ * 10 │ * @exposes #agent-launcher to #child-proc-injection [critical] cwe:CWE-78 -- "spawn/spawnSync execute external binaries" 11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path"
L13 exposes #agent-launcher → #prompt-injection
User prompt passed to agent CLI as argument
8 │ * Clipboard copy is always performed first regardless of agent type. 9 │ * 10 │ * @exposes #agent-launcher to #child-proc-injection [critical] cwe:CWE-78 -- "spawn/spawnSync execute external binaries" 11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path"
L14 audit Audit: #agent-launcher
Prompt content is opaque to agent binary; injection risk depends on agent implementation
9 │ * 10 │ * @exposes #agent-launcher to #child-proc-injection [critical] cwe:CWE-78 -- "spawn/spawnSync execute external binaries" 11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture"
L15 exposes #agent-launcher → #dos
No timeout on foreground spawn; agent controls duration
10 │ * @exposes #agent-launcher to #child-proc-injection [critical] cwe:CWE-78 -- "spawn/spawnSync execute external binaries" 11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture" 20 │ * @boundary #agent-launcher and AgentProcess (#agent-boundary) -- "Trust boundary at process spawn"
L16 audit Audit: #agent-launcher
Timeout intentionally omitted for interactive sessions; inline mode has implicit control
11 │ * @mitigates #agent-launcher against #child-proc-injection using #param-commands -- "Binary names from hardcoded AGENTS registry; no shell interpolation" 12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture" 20 │ * @boundary #agent-launcher and AgentProcess (#agent-boundary) -- "Trust boundary at process spawn" 21 │ */
L17 flow UserPrompt → #agent-launcher
Prompt input path
12 │ * @mitigates #agent-launcher against #cmd-injection using #param-commands -- "Arguments passed as array, not shell string" 13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture" 20 │ * @boundary #agent-launcher and AgentProcess (#agent-boundary) -- "Trust boundary at process spawn" 21 │ */ 22 │
L18 flow #agent-launcher → AgentProcess
Process spawn path
13 │ * @exposes #agent-launcher to #prompt-injection [medium] cwe:CWE-77 -- "User prompt passed to agent CLI as argument" 14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture" 20 │ * @boundary #agent-launcher and AgentProcess (#agent-boundary) -- "Trust boundary at process spawn" 21 │ */ 22 │ 23 │ import { spawnSync, spawn } from 'node:child_process';
L19 flow AgentProcess → #agent-launcher
Agent output capture
14 │ * @audit #agent-launcher -- "Prompt content is opaque to agent binary; injection risk depends on agent implementation" 15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture" 20 │ * @boundary #agent-launcher and AgentProcess (#agent-boundary) -- "Trust boundary at process spawn" 21 │ */ 22 │ 23 │ import { spawnSync, spawn } from 'node:child_process'; 24 │ import { platform } from 'node:os';
L20 boundary #agent-launcher ↔ AgentProcess
Trust boundary at process spawn
15 │ * @exposes #agent-launcher to #dos [low] cwe:CWE-400 -- "No timeout on foreground spawn; agent controls duration" 16 │ * @audit #agent-launcher -- "Timeout intentionally omitted for interactive sessions; inline mode has implicit control" 17 │ * @flows UserPrompt -> #agent-launcher via launchAgent -- "Prompt input path" 18 │ * @flows #agent-launcher -> AgentProcess via spawn -- "Process spawn path" 19 │ * @flows AgentProcess -> #agent-launcher via stdout -- "Agent output capture" 20 │ * @boundary #agent-launcher and AgentProcess (#agent-boundary) -- "Trust boundary at process spawn" 21 │ */ 22 │ 23 │ import { spawnSync, spawn } from 'node:child_process'; 24 │ import { platform } from 'node:os'; 25 │ import { mkdtempSync, readFileSync, unlinkSync, existsSync } from 'node:fs';
L13 exposes #llm-client → #ssrf
fetch() calls external LLM API endpoints
8 │ * - OpenAI-compatible Chat Completions (DeepSeek, OpenRouter, Ollama) 9 │ * - DeepSeek reasoning mode (deepseek-reasoner) 10 │ * 11 │ * Zero dependencies — uses Node 20+ built-in fetch. 12 │ * 13 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints" 14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only"
L14 mitigates #config-validation mitigates #ssrf
BASE_URLS are hardcoded; baseUrl override is optional config
9 │ * - DeepSeek reasoning mode (deepseek-reasoner) 10 │ * 11 │ * Zero dependencies — uses Node 20+ built-in fetch. 12 │ * 13 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints" 14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input"
L15 exposes #llm-client → #api-key-exposure
API keys passed in Authorization headers
10 │ * 11 │ * Zero dependencies — uses Node 20+ built-in fetch. 12 │ * 13 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints" 14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output"
L16 mitigates #key-redaction mitigates #api-key-exposure
Keys never logged; passed directly to API
11 │ * Zero dependencies — uses Node 20+ built-in fetch. 12 │ * 13 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints" 14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input"
L17 exposes #llm-client → #prompt-injection
User prompts sent to LLM API
12 │ * 13 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints" 14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call"
L18 audit Audit: #llm-client
Prompt injection mitigated by LLM provider safety; local code is read-only
13 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "fetch() calls external LLM API endpoints" 14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call" 23 │ * @handles secrets on #llm-client -- "Processes API keys for authentication"
L19 flow LLMConfig → #llm-client
Config and prompt input
14 │ * @mitigates #llm-client against #ssrf using #config-validation -- "BASE_URLS are hardcoded; baseUrl override is optional config" 15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call" 23 │ * @handles secrets on #llm-client -- "Processes API keys for authentication" 24 │ */
L20 flow #llm-client → LLMProvider
API request output
15 │ * @exposes #llm-client to #api-key-exposure [high] cwe:CWE-798 -- "API keys passed in Authorization headers" 16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call" 23 │ * @handles secrets on #llm-client -- "Processes API keys for authentication" 24 │ */ 25 │
L21 flow LLMProvider → #llm-client
API response input
16 │ * @mitigates #llm-client against #api-key-exposure using #key-redaction -- "Keys never logged; passed directly to API" 17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call" 23 │ * @handles secrets on #llm-client -- "Processes API keys for authentication" 24 │ */ 25 │ 26 │ export type LLMProvider = 'anthropic' | 'openai' | 'google' | 'openrouter' | 'deepseek' | 'ollama';
L22 boundary #llm-client ↔ LLMProvider
Trust boundary at external API call
17 │ * @exposes #llm-client to #prompt-injection [medium] cwe:CWE-77 -- "User prompts sent to LLM API" 18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call" 23 │ * @handles secrets on #llm-client -- "Processes API keys for authentication" 24 │ */ 25 │ 26 │ export type LLMProvider = 'anthropic' | 'openai' | 'google' | 'openrouter' | 'deepseek' | 'ollama'; 27 │
L23 handles #llm-client: secrets
Processes API keys for authentication
18 │ * @audit #llm-client -- "Prompt injection mitigated by LLM provider safety; local code is read-only" 19 │ * @flows LLMConfig -> #llm-client via chatCompletion -- "Config and prompt input" 20 │ * @flows #llm-client -> LLMProvider via fetch -- "API request output" 21 │ * @flows LLMProvider -> #llm-client via response -- "API response input" 22 │ * @boundary #llm-client and LLMProvider (#llm-api-boundary) -- "Trust boundary at external API call" 23 │ * @handles secrets on #llm-client -- "Processes API keys for authentication" 24 │ */ 25 │ 26 │ export type LLMProvider = 'anthropic' | 'openai' | 'google' | 'openrouter' | 'deepseek' | 'ollama'; 27 │ 28 │ // ─── Tool definitions ────────────────────────────────────────────────
L8 exposes #llm-client → #path-traversal
buildProjectContext reads files from root-relative paths
3 │ * 4 │ * Serializes the threat model, sends it to an LLM with a framework- 5 │ * specific prompt, streams the response, and saves timestamped results 6 │ * to .guardlink/threat-reports/. 7 │ * 8 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "buildProjectContext reads files from root-relative paths" 9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis"
L9 mitigates #path-validation mitigates #path-traversal
join() with root constrains file access
4 │ * Serializes the threat model, sends it to an LLM with a framework- 5 │ * specific prompt, streams the response, and saves timestamped results 6 │ * to .guardlink/threat-reports/. 7 │ * 8 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "buildProjectContext reads files from root-relative paths" 9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input"
L10 exposes #llm-client → #arbitrary-write
writeFileSync saves threat reports to .guardlink/
5 │ * specific prompt, streams the response, and saves timestamped results 6 │ * to .guardlink/threat-reports/. 7 │ * 8 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "buildProjectContext reads files from root-relative paths" 9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read"
L11 mitigates #path-validation mitigates #arbitrary-write
Output path is fixed to .guardlink/threat-reports/
6 │ * to .guardlink/threat-reports/. 7 │ * 8 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "buildProjectContext reads files from root-relative paths" 9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output"
L12 exposes #llm-client → #data-exposure
Serializes full threat model and code snippets for LLM
7 │ * 8 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "buildProjectContext reads files from root-relative paths" 9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output" 17 │ * @handles internal on #llm-client -- "Processes project dependencies, env examples, code snippets"
L13 audit Audit: #llm-client
Threat model data intentionally sent to LLM for analysis
8 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "buildProjectContext reads files from root-relative paths" 9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output" 17 │ * @handles internal on #llm-client -- "Processes project dependencies, env examples, code snippets" 18 │ */
L14 flow ThreatModel → #llm-client
Model serialization input
9 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() with root constrains file access" 10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output" 17 │ * @handles internal on #llm-client -- "Processes project dependencies, env examples, code snippets" 18 │ */ 19 │
L15 flow ProjectFiles → #llm-client
Project context read
10 │ * @exposes #llm-client to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync saves threat reports to .guardlink/" 11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output" 17 │ * @handles internal on #llm-client -- "Processes project dependencies, env examples, code snippets" 18 │ */ 19 │ 20 │ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
L16 flow #llm-client → ReportFile
Report output
11 │ * @mitigates #llm-client against #arbitrary-write using #path-validation -- "Output path is fixed to .guardlink/threat-reports/" 12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output" 17 │ * @handles internal on #llm-client -- "Processes project dependencies, env examples, code snippets" 18 │ */ 19 │ 20 │ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; 21 │ import { join, relative } from 'node:path';
L17 handles #llm-client: internal
Processes project dependencies, env examples, code snippets
12 │ * @exposes #llm-client to #data-exposure [low] cwe:CWE-200 -- "Serializes full threat model and code snippets for LLM" 13 │ * @audit #llm-client -- "Threat model data intentionally sent to LLM for analysis" 14 │ * @flows ThreatModel -> #llm-client via serializeModel -- "Model serialization input" 15 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Project context read" 16 │ * @flows #llm-client -> ReportFile via writeFileSync -- "Report output" 17 │ * @handles internal on #llm-client -- "Processes project dependencies, env examples, code snippets" 18 │ */ 19 │ 20 │ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; 21 │ import { join, relative } from 'node:path'; 22 │ import type { ThreatModel } from '../types/index.js';
L766 flow PentestFindings → #llm-client
Reads CXG scan results for dashboard and report context
761 │ } 762 │ 763 │ // ─── Pentest findings loader ───────────────────────────────────────── 764 │ 765 │ /** 766 │ * @flows PentestFindings -> #llm-client via readFileSync -- "Reads CXG scan results for dashboard and report context" 767 │ * @handles internal on #llm-client -- "Processes pentest scan output (JSON/SARIF)" 768 │ */ 769 │ 770 │ export interface PentestFinding { 771 │ id: string;
L767 handles #llm-client: internal
Processes pentest scan output (JSON/SARIF)
762 │ 763 │ // ─── Pentest findings loader ───────────────────────────────────────── 764 │ 765 │ /** 766 │ * @flows PentestFindings -> #llm-client via readFileSync -- "Reads CXG scan results for dashboard and report context" 767 │ * @handles internal on #llm-client -- "Processes pentest scan output (JSON/SARIF)" 768 │ */ 769 │ 770 │ export interface PentestFinding { 771 │ id: string; 772 │ target: string;
L839 mitigates #path-validation mitigates #path-traversal
join() constrains reads to .guardlink/
834 │ 835 │ /** 836 │ * Load pentest findings from .guardlink/pentest-findings/ and template 837 │ * metadata from .guardlink/cxg-templates/. 838 │ * 839 │ * @mitigates #llm-client against #path-traversal using #path-validation -- "join() constrains reads to .guardlink/" 840 │ */ 841 │ export function loadPentestData(root: string): PentestData { 842 │ const data: PentestData = { scans: [], templates: [], totalFindings: 0, findingsBySeverity: {} }; 843 │ 844 │ // Load scan results (JSON files)
L48 exposes #sarif → #data-exposure
Exposes threat model findings to SARIF consumers
43 │ * been rewritten as unchanged, which is the silent all-clear §2 exists to 44 │ * prevent — the same reason the annotation hash was taught about @entitles in 45 │ * v2. `results` and `tool` remain byte-identical, and the test asserts exactly 46 │ * that rather than document equality. 47 │ * 48 │ * @exposes #sarif to #data-exposure [low] cwe:CWE-200 -- "Exposes threat model findings to SARIF consumers" 49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)"
L49 audit Audit: #sarif
SARIF output intentionally reveals security findings for CI/CD integration
44 │ * prevent — the same reason the annotation hash was taught about @entitles in 45 │ * v2. `results` and `tool` remain byte-identical, and the test asserts exactly 46 │ * that rather than document equality. 47 │ * 48 │ * @exposes #sarif to #data-exposure [low] cwe:CWE-200 -- "Exposes threat model findings to SARIF consumers" 49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route"
L50 comment Pure function: transforms ThreatModel to SARIF JSON; no I/O
Pure function: transforms ThreatModel to SARIF JSON; no I/O
45 │ * v2. `results` and `tool` remain byte-identical, and the test asserts exactly 46 │ * that rather than document equality. 47 │ * 48 │ * @exposes #sarif to #data-exposure [low] cwe:CWE-200 -- "Exposes threat model findings to SARIF consumers" 49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it"
L51 comment runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on
runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on
46 │ * that rather than document equality. 47 │ * 48 │ * @exposes #sarif to #data-exposure [low] cwe:CWE-200 -- "Exposes threat model findings to SARIF consumers" 49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used"
L52 comment runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested
runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested
47 │ * 48 │ * @exposes #sarif to #data-exposure [low] cwe:CWE-200 -- "Exposes threat model findings to SARIF consumers" 49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed"
L53 comment @entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)
@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)
48 │ * @exposes #sarif to #data-exposure [low] cwe:CWE-200 -- "Exposes threat model findings to SARIF consumers" 49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported"
L54 comment Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route
Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route
49 │ * @audit #sarif -- "SARIF output intentionally reveals security findings for CI/CD integration" 50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input"
L55 comment EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it
EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it
50 │ * @comment -- "Pure function: transforms ThreatModel to SARIF JSON; no I/O" 51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input" 60 │ * @flows #sarif -> SarifLog via return -- "SARIF output"
L56 comment CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used
CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used
51 │ * @comment -- "runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on" 52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input" 60 │ * @flows #sarif -> SarifLog via return -- "SARIF output" 61 │ */
L57 comment Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed
Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed
52 │ * @comment -- "runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested" 53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input" 60 │ * @flows #sarif -> SarifLog via return -- "SARIF output" 61 │ */ 62 │
L58 comment --min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported
--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported
53 │ * @comment -- "@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)" 54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input" 60 │ * @flows #sarif -> SarifLog via return -- "SARIF output" 61 │ */ 62 │ 63 │ import { createHash } from 'node:crypto';
L59 flow ThreatModel → #sarif
Model input
54 │ * @comment -- "Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route" 55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input" 60 │ * @flows #sarif -> SarifLog via return -- "SARIF output" 61 │ */ 62 │ 63 │ import { createHash } from 'node:crypto'; 64 │
L60 flow #sarif → SarifLog
SARIF output
55 │ * @comment -- "EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it" 56 │ * @comment -- "CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used" 57 │ * @comment -- "Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed" 58 │ * @comment -- "--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported" 59 │ * @flows ThreatModel -> #sarif via generateSarif -- "Model input" 60 │ * @flows #sarif -> SarifLog via return -- "SARIF output" 61 │ */ 62 │ 63 │ import { createHash } from 'node:crypto'; 64 │ 65 │ import type { ThreatModel, ThreatModelExposure, ParseDiagnostic, Severity, SourceLocation } from '../types/index.js';
L8 exposes #init → #arbitrary-write
Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc.
3 │ * 4 │ * Detects project language and existing agent files, creates .guardlink/ 5 │ * directory with shared definitions, and injects GuardLink instructions 6 │ * into agent instruction files (CLAUDE.md, .cursorrules, etc.). 7 │ * 8 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc." 9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically"
L9 mitigates #path-validation mitigates #arbitrary-write
All paths are relative to root; join() constrains
4 │ * Detects project language and existing agent files, creates .guardlink/ 5 │ * directory with shared definitions, and injects GuardLink instructions 6 │ * into agent instruction files (CLAUDE.md, .cursorrules, etc.). 7 │ * 8 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc." 9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input"
L10 exposes #init → #path-traversal
Reads/writes files based on root argument
5 │ * directory with shared definitions, and injects GuardLink instructions 6 │ * into agent instruction files (CLAUDE.md, .cursorrules, etc.). 7 │ * 8 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc." 9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes"
L11 mitigates #path-validation mitigates #path-traversal
join() with explicit root constrains file access
6 │ * into agent instruction files (CLAUDE.md, .cursorrules, etc.). 7 │ * 8 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc." 9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write"
L12 exposes #init → #data-exposure
Writes API key config to .guardlink/config.json
7 │ * 8 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc." 9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write" 17 │ * @handles internal on #init -- "Generates definitions and agent instruction content"
L13 audit Audit: #init
Config file may contain API keys; .gitignore entry added automatically
8 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates/modifies files: .guardlink/, CLAUDE.md, .cursorrules, etc." 9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write" 17 │ * @handles internal on #init -- "Generates definitions and agent instruction content" 18 │ */
L14 flow ProjectRoot → #init
Project root input
9 │ * @mitigates #init against #arbitrary-write using #path-validation -- "All paths are relative to root; join() constrains" 10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write" 17 │ * @handles internal on #init -- "Generates definitions and agent instruction content" 18 │ */ 19 │
L15 flow #init → AgentFiles
Agent instruction file writes
10 │ * @exposes #init to #path-traversal [medium] cwe:CWE-22 -- "Reads/writes files based on root argument" 11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write" 17 │ * @handles internal on #init -- "Generates definitions and agent instruction content" 18 │ */ 19 │ 20 │ import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs';
L16 flow #init → ConfigFile
Config file write
11 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with explicit root constrains file access" 12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write" 17 │ * @handles internal on #init -- "Generates definitions and agent instruction content" 18 │ */ 19 │ 20 │ import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs'; 21 │ import { join, dirname } from 'node:path';
L17 handles #init: internal
Generates definitions and agent instruction content
12 │ * @exposes #init to #data-exposure [low] cwe:CWE-200 -- "Writes API key config to .guardlink/config.json" 13 │ * @audit #init -- "Config file may contain API keys; .gitignore entry added automatically" 14 │ * @flows ProjectRoot -> #init via options.root -- "Project root input" 15 │ * @flows #init -> AgentFiles via writeFileSync -- "Agent instruction file writes" 16 │ * @flows #init -> ConfigFile via writeFileSync -- "Config file write" 17 │ * @handles internal on #init -- "Generates definitions and agent instruction content" 18 │ */ 19 │ 20 │ import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs'; 21 │ import { join, dirname } from 'node:path'; 22 │ import { detectProject, type ProjectInfo } from './detect.js';
L517 exposes #init → #arbitrary-write
Creates directories for agent-file writes
512 │ } 513 │ 514 │ /** 515 │ * Ensure a directory exists, creating it if needed. 516 │ * 517 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates directories for agent-file writes" 518 │ * @mitigates #init against #arbitrary-write using #path-validation -- "callers pass join(root, ...) constrained paths" 519 │ * 520 │ * Throws GuardLinkPathConflictError if the path already exists but is a FILE, not a 521 │ * directory. This happens when a project ships an older single-file agent config (e.g. 522 │ * a `.cursor/rules` file) where GuardLink expects the newer directory layout
L518 mitigates #path-validation mitigates #arbitrary-write
callers pass join(root, ...) constrained paths
513 │ 514 │ /** 515 │ * Ensure a directory exists, creating it if needed. 516 │ * 517 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Creates directories for agent-file writes" 518 │ * @mitigates #init against #arbitrary-write using #path-validation -- "callers pass join(root, ...) constrained paths" 519 │ * 520 │ * Throws GuardLinkPathConflictError if the path already exists but is a FILE, not a 521 │ * directory. This happens when a project ships an older single-file agent config (e.g. 522 │ * a `.cursor/rules` file) where GuardLink expects the newer directory layout 523 │ * (`.cursor/rules/`). Without this guard, mkdirSync no-ops on the existing file and the
L12 exposes #suggest → #path-traversal
File path from MCP client joined with root
7 │ * - HTTP handlers, auth checks, input parsing 8 │ * - Missing annotations on files that handle sensitive data 9 │ * 10 │ * Designed for both file-based and diff-based analysis (§8.2). 11 │ * 12 │ * @exposes #suggest to #path-traversal [high] cwe:CWE-22 -- "File path from MCP client joined with root" 13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes"
L13 mitigates #path-validation mitigates #path-traversal
join() with validated root constrains access
8 │ * - Missing annotations on files that handle sensitive data 9 │ * 10 │ * Designed for both file-based and diff-based analysis (§8.2). 11 │ * 12 │ * @exposes #suggest to #path-traversal [high] cwe:CWE-22 -- "File path from MCP client joined with root" 13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path"
L14 exposes #suggest → #redos
Complex regex patterns applied to source code
9 │ * 10 │ * Designed for both file-based and diff-based analysis (§8.2). 11 │ * 12 │ * @exposes #suggest to #path-traversal [high] cwe:CWE-22 -- "File path from MCP client joined with root" 13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output"
L15 mitigates #regex-anchoring mitigates #redos
Patterns designed with bounded quantifiers
10 │ * Designed for both file-based and diff-based analysis (§8.2). 11 │ * 12 │ * @exposes #suggest to #path-traversal [high] cwe:CWE-22 -- "File path from MCP client joined with root" 13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output" 20 │ * @comment -- "Skips node_modules and .guardlink directories"
L16 exposes #suggest → #dos
Large files loaded into memory for pattern scanning
11 │ * 12 │ * @exposes #suggest to #path-traversal [high] cwe:CWE-22 -- "File path from MCP client joined with root" 13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output" 20 │ * @comment -- "Skips node_modules and .guardlink directories" 21 │ */
L17 audit Audit: #suggest
File size is bounded by project scope; production use involves reasonable file sizes
12 │ * @exposes #suggest to #path-traversal [high] cwe:CWE-22 -- "File path from MCP client joined with root" 13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output" 20 │ * @comment -- "Skips node_modules and .guardlink directories" 21 │ */ 22 │
L18 flow FilePath → #suggest
File read path
13 │ * @mitigates #suggest against #path-traversal using #path-validation -- "join() with validated root constrains access" 14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output" 20 │ * @comment -- "Skips node_modules and .guardlink directories" 21 │ */ 22 │ 23 │ import { readFileSync, existsSync } from 'node:fs';
L19 flow #suggest → Suggestions
Suggestion output
14 │ * @exposes #suggest to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to source code" 15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output" 20 │ * @comment -- "Skips node_modules and .guardlink directories" 21 │ */ 22 │ 23 │ import { readFileSync, existsSync } from 'node:fs'; 24 │ import { join } from 'node:path';
L20 comment Skips node_modules and .guardlink directories
Skips node_modules and .guardlink directories
15 │ * @mitigates #suggest against #redos using #regex-anchoring -- "Patterns designed with bounded quantifiers" 16 │ * @exposes #suggest to #dos [low] cwe:CWE-400 -- "Large files loaded into memory for pattern scanning" 17 │ * @audit #suggest -- "File size is bounded by project scope; production use involves reasonable file sizes" 18 │ * @flows FilePath -> #suggest via readFileSync -- "File read path" 19 │ * @flows #suggest -> Suggestions via suggestAnnotations -- "Suggestion output" 20 │ * @comment -- "Skips node_modules and .guardlink directories" 21 │ */ 22 │ 23 │ import { readFileSync, existsSync } from 'node:fs'; 24 │ import { join } from 'node:path'; 25 │ import type { ThreatModel } from '../types/index.js';
L24 exposes #parser → #arbitrary-write
Rewrites source files and creates sidecars across the project
19 │ * 20 │ * Only annotation lines are removed from source; surrounding comment structure 21 │ * is left exactly as it was. That is what makes the round trip reproduce the 22 │ * original file rather than an equivalent one. 23 │ * 24 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Rewrites source files and creates sidecars across the project" 25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control"
L25 mitigates #path-validation mitigates #arbitrary-write
Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input
20 │ * Only annotation lines are removed from source; surrounding comment structure 21 │ * is left exactly as it was. That is what makes the round trip reproduce the 22 │ * original file rather than an equivalent one. 23 │ * 24 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Rewrites source files and creates sidecars across the project" 25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control" 30 │ * @comment -- "Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model"
L26 exposes #parser → #data-exposure
Reads every annotated source file into memory
21 │ * is left exactly as it was. That is what makes the round trip reproduce the 22 │ * original file rather than an equivalent one. 23 │ * 24 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Rewrites source files and creates sidecars across the project" 25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control" 30 │ * @comment -- "Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model" 31 │ */
L27 flow ThreatModel → #parser
Model drives which files are rewritten
22 │ * original file rather than an equivalent one. 23 │ * 24 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Rewrites source files and creates sidecars across the project" 25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control" 30 │ * @comment -- "Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model" 31 │ */ 32 │
L28 flow #parser → FileSystem
Source rewrite and sidecar creation
23 │ * 24 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Rewrites source files and creates sidecars across the project" 25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control" 30 │ * @comment -- "Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model" 31 │ */ 32 │ 33 │ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, rmdirSync, statSync } from 'node:fs';
L29 audit Audit: #parser
Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control
24 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Rewrites source files and creates sidecars across the project" 25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control" 30 │ * @comment -- "Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model" 31 │ */ 32 │ 33 │ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, rmdirSync, statSync } from 'node:fs'; 34 │ import { dirname, extname, join } from 'node:path';
L30 comment Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model
Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model
25 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Only files already in the parsed model are touched; sidecar targets come from resolveGalPath, never from input" 26 │ * @exposes #parser to #data-exposure [low] -- "Reads every annotated source file into memory" 27 │ * @flows ThreatModel -> #parser via migrateAnnotationMode -- "Model drives which files are rewritten" 28 │ * @flows #parser -> FileSystem via writeFileSync -- "Source rewrite and sidecar creation" 29 │ * @audit #parser -- "Migration is destructive by nature — the dry-run path and the hash check are the safety net, and both should be exercised before this is trusted on a repo without clean version control" 30 │ * @comment -- "Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model" 31 │ */ 32 │ 33 │ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, rmdirSync, statSync } from 'node:fs'; 34 │ import { dirname, extname, join } from 'node:path'; 35 │ import { stripCommentPrefix, commentStyleForExt } from './comment-strip.js';
L15 asset GuardLink.Parser
Reads source files from disk, extracts security annotations using regex patterns
10 │ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 11 │ 12 │ // ─── ASSETS ─────────────────────────────────────────────────────────── 13 │ // Components that process data, handle user input, or interact with external systems 14 │ 15 │ // @asset GuardLink.Parser (#parser) -- "Reads source files from disk, extracts security annotations using regex patterns" 16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data"
L16 asset GuardLink.CLI
Command-line interface, handles user arguments, invokes subcommands
11 │ 12 │ // ─── ASSETS ─────────────────────────────────────────────────────────── 13 │ // Components that process data, handle user input, or interact with external systems 14 │ 15 │ // @asset GuardLink.Parser (#parser) -- "Reads source files from disk, extracts security annotations using regex patterns" 16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk"
L17 asset GuardLink.TUI
Interactive terminal interface with readline input and command dispatch
12 │ // ─── ASSETS ─────────────────────────────────────────────────────────── 13 │ // Components that process data, handle user input, or interact with external systems 14 │ 15 │ // @asset GuardLink.Parser (#parser) -- "Reads source files from disk, extracts security annotations using regex patterns" 16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)"
L18 asset GuardLink.MCP
Model Context Protocol server, accepts tool calls from AI agents over stdio
13 │ // Components that process data, handle user input, or interact with external systems 14 │ 15 │ // @asset GuardLink.Parser (#parser) -- "Reads source files from disk, extracts security annotations using regex patterns" 16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands"
L19 asset GuardLink.LLM_Client
Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)
14 │ 15 │ // @asset GuardLink.Parser (#parser) -- "Reads source files from disk, extracts security annotations using regex patterns" 16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams"
L20 asset GuardLink.Dashboard
Generates interactive HTML threat model dashboard from ThreatModel data
15 │ // @asset GuardLink.Parser (#parser) -- "Reads source files from disk, extracts security annotations using regex patterns" 16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling"
L21 asset GuardLink.Init
Initializes projects, writes config files and agent instruction files to disk
16 │ // @asset GuardLink.CLI (#cli) -- "Command-line interface, handles user arguments, invokes subcommands" 17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations"
L22 asset GuardLink.Agent_Launcher
Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)
17 │ // @asset GuardLink.TUI (#tui) -- "Interactive terminal interface with readline input and command dispatch" 18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers"
L23 asset GuardLink.Diff
Compares threat models across git commits, invokes git commands
18 │ // @asset GuardLink.MCP (#mcp) -- "Model Context Protocol server, accepts tool calls from AI agents over stdio" 19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers" 28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON"
L24 asset GuardLink.Report
Generates markdown threat model reports with Mermaid diagrams
19 │ // @asset GuardLink.LLM_Client (#llm-client) -- "Makes HTTP requests to external AI providers (Anthropic, OpenAI, DeepSeek, OpenRouter)" 20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers" 28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON" 29 │
L25 asset GuardLink.SARIF
Exports findings as SARIF 2.1.0 JSON for security tooling
20 │ // @asset GuardLink.Dashboard (#dashboard) -- "Generates interactive HTML threat model dashboard from ThreatModel data" 21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers" 28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON" 29 │ 30 │ // ─── THREATS ──────────────────────────────────────────────────────────
L26 asset GuardLink.Suggest
Analyzes code patterns to suggest appropriate security annotations
21 │ // @asset GuardLink.Init (#init) -- "Initializes projects, writes config files and agent instruction files to disk" 22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers" 28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON" 29 │ 30 │ // ─── THREATS ────────────────────────────────────────────────────────── 31 │ // Security threats that can impact the application
L27 asset GuardLink.Blame
Maps annotated code spans to git history: authors, commits, AI co-author trailers
22 │ // @asset GuardLink.Agent_Launcher (#agent-launcher) -- "Spawns child processes for AI coding agents (Claude Code, Cursor, Codex)" 23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers" 28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON" 29 │ 30 │ // ─── THREATS ────────────────────────────────────────────────────────── 31 │ // Security threats that can impact the application 32 │
L28 asset GuardLink.CodeGraph
Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON
23 │ // @asset GuardLink.Diff (#diff) -- "Compares threat models across git commits, invokes git commands" 24 │ // @asset GuardLink.Report (#report) -- "Generates markdown threat model reports with Mermaid diagrams" 25 │ // @asset GuardLink.SARIF (#sarif) -- "Exports findings as SARIF 2.1.0 JSON for security tooling" 26 │ // @asset GuardLink.Suggest (#suggest) -- "Analyzes code patterns to suggest appropriate security annotations" 27 │ // @asset GuardLink.Blame (#blame) -- "Maps annotated code spans to git history: authors, commits, AI co-author trailers" 28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON" 29 │ 30 │ // ─── THREATS ────────────────────────────────────────────────────────── 31 │ // Security threats that can impact the application 32 │ 33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths"
L33 threat Path_Traversal
File read/write operations outside intended project directory via ../ sequences or absolute paths
28 │ // @asset GuardLink.CodeGraph (#codegraph) -- "Optional adapter that reads an installed code graph (codegraph-mcp or bravos) for ranked entry points and per-function reach; spawns the graph binary and parses its JSON" 29 │ 30 │ // ─── THREATS ────────────────────────────────────────────────────────── 31 │ // Security threats that can impact the application 32 │ 33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths" 34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content"
L34 threat Command_Injection
Shell command execution with unsanitized user input
29 │ 30 │ // ─── THREATS ────────────────────────────────────────────────────────── 31 │ // Security threats that can impact the application 32 │ 33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths" 34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project"
L35 threat Cross_Site_Scripting
Injection of malicious scripts into generated HTML output
30 │ // ─── THREATS ────────────────────────────────────────────────────────── 31 │ // Security threats that can impact the application 32 │ 33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths" 34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts"
L36 threat API_Key_Exposure
API keys leaked in logs, error messages, or unintended output
31 │ // Security threats that can impact the application 32 │ 33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths" 34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees"
L37 threat Server_Side_Request_Forgery
LLM API requests to attacker-controlled URLs via config override
32 │ 33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths" 34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties"
L38 threat ReDoS
Regular expression denial of service from crafted annotation content
33 │ // @threat Path_Traversal (#path-traversal) [high] cwe:CWE-22 -- "File read/write operations outside intended project directory via ../ sequences or absolute paths" 34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files"
L39 threat Arbitrary_File_Write
Writing files to attacker-controlled paths outside project
34 │ // @threat Command_Injection (#cmd-injection) [critical] cwe:CWE-78 -- "Shell command execution with unsanitized user input" 35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn"
L40 threat Prompt_Injection
Malicious content in annotations injected into LLM prompts
35 │ // @threat Cross_Site_Scripting (#xss) [high] cwe:CWE-79 -- "Injection of malicious scripts into generated HTML output" 36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details"
L41 threat Denial_of_Service
Resource exhaustion from processing large files or deep directory trees
36 │ // @threat API_Key_Exposure (#api-key-exposure) [high] cwe:CWE-798 -- "API keys leaked in logs, error messages, or unintended output" 37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details" 46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean"
L42 threat Sensitive_Data_Exposure
Threat model details exposed to unauthorized parties
37 │ // @threat Server_Side_Request_Forgery (#ssrf) [medium] cwe:CWE-918 -- "LLM API requests to attacker-controlled URLs via config override" 38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details" 46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean" 47 │
L43 threat Insecure_Deserialization
Unsafe parsing of JSON/YAML configuration files
38 │ // @threat ReDoS (#redos) [medium] cwe:CWE-1333 -- "Regular expression denial of service from crafted annotation content" 39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details" 46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean" 47 │ 48 │ // ─── CONTROLS ─────────────────────────────────────────────────────────
L44 threat Child_Process_Injection
Agent launcher executing attacker-controlled commands via process spawn
39 │ // @threat Arbitrary_File_Write (#arbitrary-write) [high] cwe:CWE-73 -- "Writing files to attacker-controlled paths outside project" 40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details" 46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean" 47 │ 48 │ // ─── CONTROLS ───────────────────────────────────────────────────────── 49 │ // Security controls that mitigate threats
L45 threat Information_Disclosure
Unintended exposure of internal paths, structure, or implementation details
40 │ // @threat Prompt_Injection (#prompt-injection) [medium] cwe:CWE-77 -- "Malicious content in annotations injected into LLM prompts" 41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details" 46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean" 47 │ 48 │ // ─── CONTROLS ───────────────────────────────────────────────────────── 49 │ // Security controls that mitigate threats 50 │
L46 threat Vacuous_Pass
A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean
41 │ // @threat Denial_of_Service (#dos) [medium] cwe:CWE-400 -- "Resource exhaustion from processing large files or deep directory trees" 42 │ // @threat Sensitive_Data_Exposure (#data-exposure) [medium] cwe:CWE-200 -- "Threat model details exposed to unauthorized parties" 43 │ // @threat Insecure_Deserialization (#insecure-deser) [medium] cwe:CWE-502 -- "Unsafe parsing of JSON/YAML configuration files" 44 │ // @threat Child_Process_Injection (#child-proc-injection) [high] cwe:CWE-78 -- "Agent launcher executing attacker-controlled commands via process spawn" 45 │ // @threat Information_Disclosure (#info-disclosure) [low] cwe:CWE-200 -- "Unintended exposure of internal paths, structure, or implementation details" 46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean" 47 │ 48 │ // ─── CONTROLS ───────────────────────────────────────────────────────── 49 │ // Security controls that mitigate threats 50 │ 51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories"
L51 control Path_Validation
Validates file paths using resolve() + startsWith() to ensure access within allowed directories
46 │ // @threat Vacuous_Pass (#vacuous-pass) [high] cwe:CWE-754 -- "A check reports success without having read what it was asked to check: zero repositories loaded, a glob that matched nothing, annotations the parser dropped. An input nobody read contributes to no count, so every number above it looks healthier for its absence, and the exit code says the estate is clean" 47 │ 48 │ // ─── CONTROLS ───────────────────────────────────────────────────────── 49 │ // Security controls that mitigate threats 50 │ 51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories" 52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use"
L52 control Input_Sanitization
Input validation with anchored regex patterns and length limits
47 │ 48 │ // ─── CONTROLS ───────────────────────────────────────────────────────── 49 │ // Security controls that mitigate threats 50 │ 51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories" 52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints"
L53 control Output_Encoding
HTML entity encoding for untrusted data in generated output
48 │ // ─── CONTROLS ───────────────────────────────────────────────────────── 49 │ // Security controls that mitigate threats 50 │ 51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories" 52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation"
L54 control Key_Redaction
Masking API keys in logs and error messages
49 │ // Security controls that mitigate threats 50 │ 51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories" 52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes"
L55 control Process_Sandboxing
Controlled child process spawning with explicit args array, no shell
50 │ 51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories" 52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking"
L56 control Config_Validation
Schema validation for configuration files before use
51 │ // @control Path_Validation (#path-validation) -- "Validates file paths using resolve() + startsWith() to ensure access within allowed directories" 52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions"
L57 control Resource_Limits
File size limits, recursion depth limits, timeout constraints
52 │ // @control Input_Sanitization (#input-sanitize) -- "Input validation with anchored regex patterns and length limits" 53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files"
L58 control Parameterized_Commands
Using spawn with args array instead of shell string interpolation
53 │ // @control Output_Encoding (#output-encoding) -- "HTML entity encoding for untrusted data in generated output" 54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine"
L59 control Glob_Pattern_Filtering
Filtering files using glob patterns with explicit excludes
54 │ // @control Key_Redaction (#key-redaction) -- "Masking API keys in logs and error messages" 55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag"
L60 control Regex_Anchoring
Using anchored regex patterns (^...$) to prevent backtracking
55 │ // @control Process_Sandboxing (#process-sandbox) -- "Controlled child process spawning with explicit args array, no shell" 56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag" 65 │ // @asset GuardLink.Gate (#gate) -- "Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails"
L61 control Prefix_Ownership
Tag prefix determines owning repo, preventing cross-repo tag collisions
56 │ // @control Config_Validation (#config-validation) -- "Schema validation for configuration files before use" 57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag" 65 │ // @asset GuardLink.Gate (#gate) -- "Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails" 66 │
L62 control YAML_Validation
Schema validation for workspace.yaml configuration files
57 │ // @control Resource_Limits (#resource-limits) -- "File size limits, recursion depth limits, timeout constraints" 58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag" 65 │ // @asset GuardLink.Gate (#gate) -- "Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails" 66 │ 67 │ // ─── ACTORS ───────────────────────────────────────────────────────────
L63 control Identity_Redaction
Hashes or drops author emails before attribution leaves the machine
58 │ // @control Parameterized_Commands (#param-commands) -- "Using spawn with args array instead of shell string interpolation" 59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag" 65 │ // @asset GuardLink.Gate (#gate) -- "Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails" 66 │ 67 │ // ─── ACTORS ─────────────────────────────────────────────────────────── 68 │ // Principals in guardlink's own authorization model — roles, not people.
L64 control Fail_Closed
A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag
59 │ // @control Glob_Pattern_Filtering (#glob-filtering) -- "Filtering files using glob patterns with explicit excludes" 60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag" 65 │ // @asset GuardLink.Gate (#gate) -- "Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails" 66 │ 67 │ // ─── ACTORS ─────────────────────────────────────────────────────────── 68 │ // Principals in guardlink's own authorization model — roles, not people. 69 │ // The actor verb is a definition (docs/prd/actor-entitlement-design.md §3.1), so
L65 asset GuardLink.Gate
Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails
60 │ // @control Regex_Anchoring (#regex-anchoring) -- "Using anchored regex patterns (^...$) to prevent backtracking" 61 │ // @control Prefix_Ownership (#prefix-ownership) -- "Tag prefix determines owning repo, preventing cross-repo tag collisions" 62 │ // @control YAML_Validation (#yaml-validation) -- "Schema validation for workspace.yaml configuration files" 63 │ // @control Identity_Redaction (#identity-redaction) -- "Hashes or drops author emails before attribution leaves the machine" 64 │ // @control Fail_Closed (#fail-closed) -- "A command that could not read what it was asked to read exits non-zero and names what it could not read, rather than reporting the empty result as a clean one. Distinct from a gate: this fires on the absence of input, not on a finding, so it is not behind an opt-in flag" 65 │ // @asset GuardLink.Gate (#gate) -- "Checks what an annotating agent added against the evidence bar: lints, re-prompts, strips what still fails" 66 │ 67 │ // ─── ACTORS ─────────────────────────────────────────────────────────── 68 │ // Principals in guardlink's own authorization model — roles, not people. 69 │ // The actor verb is a definition (docs/prd/actor-entitlement-design.md §3.1), so 70 │ // it lives here beside @asset/@threat/@control. Declaring an actor grants nothing;
L75 actor Actor: Local_Developer
Runs the guardlink binary from a shell on the project; already holds write access to the same working tree guardlink edits, and has an interactive TTY that can answer a confirmation prompt
70 │ // it lives here beside @asset/@threat/@control. Declaring an actor grants nothing; 71 │ // only an entitlement in a source file grants, and only when it cites authz code 72 │ // (§3.4). NOTE: never begin a comment line with @actor or @entitles followed by 73 │ // prose — the parser reads it as a real annotation and reports it malformed. 74 │ 75 │ // @actor Local_Developer (#local-dev) -- "Runs the guardlink binary from a shell on the project; already holds write access to the same working tree guardlink edits, and has an interactive TTY that can answer a confirmation prompt" 76 │ // @actor MCP_Agent (#mcp-agent) -- "AI coding agent calling guardlink tools over stdio through the MCP server; acts only with the tool set src/mcp/server.ts exposes, not with the operator's shell" 77 │ // @actor CI_Runner (#ci-runner) -- "GitHub Actions job invoking the built CLI (see .github/workflows/ci.yml); has no TTY, so it cannot answer a confirmation prompt, and is deliberately granted no entitlement" 78 │
L76 actor Actor: MCP_Agent
AI coding agent calling guardlink tools over stdio through the MCP server; acts only with the tool set src/mcp/server.ts exposes, not with the operator's shell
71 │ // only an entitlement in a source file grants, and only when it cites authz code 72 │ // (§3.4). NOTE: never begin a comment line with @actor or @entitles followed by 73 │ // prose — the parser reads it as a real annotation and reports it malformed. 74 │ 75 │ // @actor Local_Developer (#local-dev) -- "Runs the guardlink binary from a shell on the project; already holds write access to the same working tree guardlink edits, and has an interactive TTY that can answer a confirmation prompt" 76 │ // @actor MCP_Agent (#mcp-agent) -- "AI coding agent calling guardlink tools over stdio through the MCP server; acts only with the tool set src/mcp/server.ts exposes, not with the operator's shell" 77 │ // @actor CI_Runner (#ci-runner) -- "GitHub Actions job invoking the built CLI (see .github/workflows/ci.yml); has no TTY, so it cannot answer a confirmation prompt, and is deliberately granted no entitlement" 78 │
L77 actor Actor: CI_Runner
GitHub Actions job invoking the built CLI (see .github/workflows/ci.yml); has no TTY, so it cannot answer a confirmation prompt, and is deliberately granted no entitlement
72 │ // (§3.4). NOTE: never begin a comment line with @actor or @entitles followed by 73 │ // prose — the parser reads it as a real annotation and reports it malformed. 74 │ 75 │ // @actor Local_Developer (#local-dev) -- "Runs the guardlink binary from a shell on the project; already holds write access to the same working tree guardlink edits, and has an interactive TTY that can answer a confirmation prompt" 76 │ // @actor MCP_Agent (#mcp-agent) -- "AI coding agent calling guardlink tools over stdio through the MCP server; acts only with the tool set src/mcp/server.ts exposes, not with the operator's shell" 77 │ // @actor CI_Runner (#ci-runner) -- "GitHub Actions job invoking the built CLI (see .github/workflows/ci.yml); has no TTY, so it cannot answer a confirmation prompt, and is deliberately granted no entitlement" 78 │
L13 exposes #agent-launcher → #api-key-exposure
API keys loaded from env vars, files; stored in config.json
8 │ * 4. Project config: .guardlink/config.json 9 │ * 5. Global config: ~/.config/guardlink/config.json 10 │ * 11 │ * Replaces the fragmented tui-config.json / CLI flag / env var resolution. 12 │ * 13 │ * @exposes #agent-launcher to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from env vars, files; stored in config.json" 14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root"
L14 mitigates #key-redaction mitigates #api-key-exposure
maskKey() redacts keys for display; keys never logged
9 │ * 5. Global config: ~/.config/guardlink/config.json 10 │ * 11 │ * Replaces the fragmented tui-config.json / CLI flag / env var resolution. 12 │ * 13 │ * @exposes #agent-launcher to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from env vars, files; stored in config.json" 14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input"
L15 exposes #agent-launcher → #path-traversal
Config paths resolved from root and homedir
10 │ * 11 │ * Replaces the fragmented tui-config.json / CLI flag / env var resolution. 12 │ * 13 │ * @exposes #agent-launcher to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from env vars, files; stored in config.json" 14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read"
L16 mitigates #path-validation mitigates #path-traversal
join() with known base dirs constrains paths
11 │ * Replaces the fragmented tui-config.json / CLI flag / env var resolution. 12 │ * 13 │ * @exposes #agent-launcher to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from env vars, files; stored in config.json" 14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write"
L17 exposes #agent-launcher → #arbitrary-write
saveProjectConfig writes to .guardlink/config.json
12 │ * 13 │ * @exposes #agent-launcher to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from env vars, files; stored in config.json" 14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write" 22 │ * @handles secrets on #agent-launcher -- "Processes and stores LLM API keys"
L18 mitigates #path-validation mitigates #arbitrary-write
Output path is fixed relative to project root
13 │ * @exposes #agent-launcher to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from env vars, files; stored in config.json" 14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write" 22 │ * @handles secrets on #agent-launcher -- "Processes and stores LLM API keys" 23 │ */
L19 flow EnvVars → #agent-launcher
Environment variable input
14 │ * @mitigates #agent-launcher against #api-key-exposure using #key-redaction -- "maskKey() redacts keys for display; keys never logged" 15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write" 22 │ * @handles secrets on #agent-launcher -- "Processes and stores LLM API keys" 23 │ */ 24 │
L20 flow ConfigFile → #agent-launcher
Config file read
15 │ * @exposes #agent-launcher to #path-traversal [medium] cwe:CWE-22 -- "Config paths resolved from root and homedir" 16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write" 22 │ * @handles secrets on #agent-launcher -- "Processes and stores LLM API keys" 23 │ */ 24 │ 25 │ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
L21 flow #agent-launcher → ConfigFile
Config file write
16 │ * @mitigates #agent-launcher against #path-traversal using #path-validation -- "join() with known base dirs constrains paths" 17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write" 22 │ * @handles secrets on #agent-launcher -- "Processes and stores LLM API keys" 23 │ */ 24 │ 25 │ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 26 │ import { join } from 'node:path';
L22 handles #agent-launcher: secrets
Processes and stores LLM API keys
17 │ * @exposes #agent-launcher to #arbitrary-write [medium] cwe:CWE-73 -- "saveProjectConfig writes to .guardlink/config.json" 18 │ * @mitigates #agent-launcher against #arbitrary-write using #path-validation -- "Output path is fixed relative to project root" 19 │ * @flows EnvVars -> #agent-launcher via process.env -- "Environment variable input" 20 │ * @flows ConfigFile -> #agent-launcher via readFileSync -- "Config file read" 21 │ * @flows #agent-launcher -> ConfigFile via writeFileSync -- "Config file write" 22 │ * @handles secrets on #agent-launcher -- "Processes and stores LLM API keys" 23 │ */ 24 │ 25 │ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 26 │ import { join } from 'node:path'; 27 │ import { homedir } from 'node:os';
L7 comment Agent binaries are hardcoded; no user-controlled binary names
Agent binaries are hardcoded; no user-controlled binary names
2 │ * GuardLink Agents — Shared agent registry. 3 │ * 4 │ * Used by CLI, TUI, and MCP to identify and resolve coding agents 5 │ * (Claude Code, Codex, Cursor, Windsurf, Gemini, clipboard). 6 │ * 7 │ * @comment -- "Agent binaries are hardcoded; no user-controlled binary names" 8 │ * @comment -- "parseAgentFlag extracts flags from args; no injection risk" 9 │ */ 10 │ 11 │ // ─── Agent registry ────────────────────────────────────────────────── 12 │
L8 comment parseAgentFlag extracts flags from args; no injection risk
parseAgentFlag extracts flags from args; no injection risk
3 │ * 4 │ * Used by CLI, TUI, and MCP to identify and resolve coding agents 5 │ * (Claude Code, Codex, Cursor, Windsurf, Gemini, clipboard). 6 │ * 7 │ * @comment -- "Agent binaries are hardcoded; no user-controlled binary names" 8 │ * @comment -- "parseAgentFlag extracts flags from args; no injection risk" 9 │ */ 10 │ 11 │ // ─── Agent registry ────────────────────────────────────────────────── 12 │ 13 │ export interface AgentEntry {
L8 exposes #llm-client → #insecure-deser
JSON.parse over a block the model wrote
3 │ * 4 │ * Every threat report ends with a fenced `guardlink-findings` JSON block. This 5 │ * module parses it out of the markdown, validates the ids it names against the 6 │ * model, renders a table from it, and strips it from the prose for display. 7 │ * 8 │ * @exposes #llm-client to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse over a block the model wrote" 9 │ * @mitigates #llm-client against #insecure-deser using #config-validation -- "Only plain data is read out of the parsed object; every field is coerced to a string, a number or one of a fixed set, and anything else is dropped" 10 │ * @flows SavedReport -> #llm-client via parseFindingsBlock -- "Findings out of a saved report" 11 │ * @comment -- "The last block wins when a report carries more than one, because a model that restated its findings restated them last" 12 │ */ 13 │ import type { ThreatModel } from '../types/index.js';
L9 mitigates #config-validation mitigates #insecure-deser
Only plain data is read out of the parsed object; every field is coerced to a string, a number or one of a fixed set, and anything else is dropped
4 │ * Every threat report ends with a fenced `guardlink-findings` JSON block. This 5 │ * module parses it out of the markdown, validates the ids it names against the 6 │ * model, renders a table from it, and strips it from the prose for display. 7 │ * 8 │ * @exposes #llm-client to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse over a block the model wrote" 9 │ * @mitigates #llm-client against #insecure-deser using #config-validation -- "Only plain data is read out of the parsed object; every field is coerced to a string, a number or one of a fixed set, and anything else is dropped" 10 │ * @flows SavedReport -> #llm-client via parseFindingsBlock -- "Findings out of a saved report" 11 │ * @comment -- "The last block wins when a report carries more than one, because a model that restated its findings restated them last" 12 │ */ 13 │ import type { ThreatModel } from '../types/index.js'; 14 │
L10 flow SavedReport → #llm-client
Findings out of a saved report
5 │ * module parses it out of the markdown, validates the ids it names against the 6 │ * model, renders a table from it, and strips it from the prose for display. 7 │ * 8 │ * @exposes #llm-client to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse over a block the model wrote" 9 │ * @mitigates #llm-client against #insecure-deser using #config-validation -- "Only plain data is read out of the parsed object; every field is coerced to a string, a number or one of a fixed set, and anything else is dropped" 10 │ * @flows SavedReport -> #llm-client via parseFindingsBlock -- "Findings out of a saved report" 11 │ * @comment -- "The last block wins when a report carries more than one, because a model that restated its findings restated them last" 12 │ */ 13 │ import type { ThreatModel } from '../types/index.js'; 14 │ 15 │ export const FINDINGS_SCHEMA = 'guardlink.findings/v1';
L11 comment The last block wins when a report carries more than one, because a model that restated its findings restated them last
The last block wins when a report carries more than one, because a model that restated its findings restated them last
6 │ * model, renders a table from it, and strips it from the prose for display. 7 │ * 8 │ * @exposes #llm-client to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse over a block the model wrote" 9 │ * @mitigates #llm-client against #insecure-deser using #config-validation -- "Only plain data is read out of the parsed object; every field is coerced to a string, a number or one of a fixed set, and anything else is dropped" 10 │ * @flows SavedReport -> #llm-client via parseFindingsBlock -- "Findings out of a saved report" 11 │ * @comment -- "The last block wins when a report carries more than one, because a model that restated its findings restated them last" 12 │ */ 13 │ import type { ThreatModel } from '../types/index.js'; 14 │ 15 │ export const FINDINGS_SCHEMA = 'guardlink.findings/v1'; 16 │
L7 comment Prompt templates are static; no user input interpolation in system prompts
Prompt templates are static; no user input interpolation in system prompts
2 │ * GuardLink Threat Reports — Framework-specific analysis prompts. 3 │ * 4 │ * Each framework produces a structured security analysis from the 5 │ * serialized threat model. The LLM acts as a senior security architect. 6 │ * 7 │ * @comment -- "Prompt templates are static; no user input interpolation in system prompts" 8 │ * @comment -- "customPrompt is appended to user message, not system prompt — bounded injection risk" 9 │ */ 10 │ 11 │ import { getPlaybook, type ReportShapeId } from '../playbooks/index.js'; 12 │
L8 comment customPrompt is appended to user message, not system prompt — bounded injection risk
customPrompt is appended to user message, not system prompt — bounded injection risk
3 │ * 4 │ * Each framework produces a structured security analysis from the 5 │ * serialized threat model. The LLM acts as a senior security architect. 6 │ * 7 │ * @comment -- "Prompt templates are static; no user input interpolation in system prompts" 8 │ * @comment -- "customPrompt is appended to user message, not system prompt — bounded injection risk" 9 │ */ 10 │ 11 │ import { getPlaybook, type ReportShapeId } from '../playbooks/index.js'; 12 │ 13 │ export type AnalysisFramework = 'stride' | 'dread' | 'pasta' | 'attacker' | 'rapid' | 'general';
L9 exposes #llm-client → #ssrf
lookupCve fetches from NVD API with user-controlled CVE ID
4 │ * Defines tools that the LLM can invoke during threat analysis: 5 │ * - lookup_cve: Search for CVE details (via web fetch) 6 │ * - validate_finding: Cross-reference a finding against the parsed model 7 │ * - search_codebase: Search project files for patterns 8 │ * 9 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "lookupCve fetches from NVD API with user-controlled CVE ID" 10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter"
L10 mitigates #input-sanitize mitigates #ssrf
CVE ID validated with strict regex; URL hardcoded to NVD
5 │ * - lookup_cve: Search for CVE details (via web fetch) 6 │ * - validate_finding: Cross-reference a finding against the parsed model 7 │ * - search_codebase: Search project files for patterns 8 │ * 9 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "lookupCve fetches from NVD API with user-controlled CVE ID" 10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit"
L11 exposes #llm-client → #path-traversal
searchCodebase reads files from project root
6 │ * - validate_finding: Cross-reference a finding against the parsed model 7 │ * - search_codebase: Search project files for patterns 8 │ * 9 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "lookupCve fetches from NVD API with user-controlled CVE ID" 10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input"
L12 mitigates #glob-filtering mitigates #path-traversal
skipDirs excludes sensitive directories; relative() bounds output
7 │ * - search_codebase: Search project files for patterns 8 │ * 9 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "lookupCve fetches from NVD API with user-controlled CVE ID" 10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call"
L13 exposes #llm-client → #dos
searchCodebase reads many files; the LLM now sets max_results itself
8 │ * 9 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "lookupCve fetches from NVD API with user-controlled CVE ID" 10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads"
L14 mitigates #resource-limits mitigates #dos
clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter
9 │ * @exposes #llm-client to #ssrf [medium] cwe:CWE-918 -- "lookupCve fetches from NVD API with user-controlled CVE ID" 10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads" 19 │ * @boundary #llm-client and NVD (#nvd-api-boundary) -- "Trust boundary at external API"
L15 comment The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit
The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit
10 │ * @mitigates #llm-client against #ssrf using #input-sanitize -- "CVE ID validated with strict regex; URL hardcoded to NVD" 11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads" 19 │ * @boundary #llm-client and NVD (#nvd-api-boundary) -- "Trust boundary at external API" 20 │ */
L16 flow LLMToolCall → #llm-client
Tool invocation input
11 │ * @exposes #llm-client to #path-traversal [medium] cwe:CWE-22 -- "searchCodebase reads files from project root" 12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads" 19 │ * @boundary #llm-client and NVD (#nvd-api-boundary) -- "Trust boundary at external API" 20 │ */ 21 │
L17 flow #llm-client → NVD
CVE lookup API call
12 │ * @mitigates #llm-client against #path-traversal using #glob-filtering -- "skipDirs excludes sensitive directories; relative() bounds output" 13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads" 19 │ * @boundary #llm-client and NVD (#nvd-api-boundary) -- "Trust boundary at external API" 20 │ */ 21 │ 22 │ import { readFileSync, readdirSync, statSync } from 'node:fs';
L18 flow ProjectFiles → #llm-client
Codebase search reads
13 │ * @exposes #llm-client to #dos [low] cwe:CWE-400 -- "searchCodebase reads many files; the LLM now sets max_results itself" 14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads" 19 │ * @boundary #llm-client and NVD (#nvd-api-boundary) -- "Trust boundary at external API" 20 │ */ 21 │ 22 │ import { readFileSync, readdirSync, statSync } from 'node:fs'; 23 │ import { join, relative } from 'node:path';
L19 boundary #llm-client ↔ NVD
Trust boundary at external API
14 │ * @mitigates #llm-client against #dos using #resource-limits -- "clampMaxResults bounds the caller's limit to [1, HARD_MAX_RESULTS] and turns a non-numeric argument into the default rather than NaN; stat.size < 500KB filter" 15 │ * @comment -- "The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit" 16 │ * @flows LLMToolCall -> #llm-client via createToolExecutor -- "Tool invocation input" 17 │ * @flows #llm-client -> NVD via fetch -- "CVE lookup API call" 18 │ * @flows ProjectFiles -> #llm-client via readFileSync -- "Codebase search reads" 19 │ * @boundary #llm-client and NVD (#nvd-api-boundary) -- "Trust boundary at external API" 20 │ */ 21 │ 22 │ import { readFileSync, readdirSync, statSync } from 'node:fs'; 23 │ import { join, relative } from 'node:path'; 24 │ import type { ToolDefinition, ToolExecutor } from './llm.js';
L4 comment SARIF generation is pure transformation; no I/O in this module
SARIF generation is pure transformation; no I/O in this module
1 │ /** 2 │ * GuardLink Analyzer — exports. 3 │ * 4 │ * @comment -- "SARIF generation is pure transformation; no I/O in this module" 5 │ * @comment -- "File writes handled by CLI/MCP callers" 6 │ */ 7 │ 8 │ export { generateSarif, type SarifOptions, type SarifVersionControl } from './sarif.js'; 9 │ export { SARIF_PROFILES, isSarifProfile, MITIGATED_RULE_ID, BOUNDARY_CLAIM_RULE_ID, AGENT_REACH_RULE_ID, type SarifProfile } from './sarif-pentest.js';
L5 comment File writes handled by CLI/MCP callers
File writes handled by CLI/MCP callers
1 │ /** 2 │ * GuardLink Analyzer — exports. 3 │ * 4 │ * @comment -- "SARIF generation is pure transformation; no I/O in this module" 5 │ * @comment -- "File writes handled by CLI/MCP callers" 6 │ */ 7 │ 8 │ export { generateSarif, type SarifOptions, type SarifVersionControl } from './sarif.js'; 9 │ export { SARIF_PROFILES, isSarifProfile, MITIGATED_RULE_ID, BOUNDARY_CLAIM_RULE_ID, AGENT_REACH_RULE_ID, type SarifProfile } from './sarif-pentest.js'; 10 │
L42 flow ThreatModel → #sarif
Flows, boundaries, assets and context annotations read from the parsed model
37 │ * SPEC §6.1: an entitlement changes no byte of `results` or `tool`. Nothing in 38 │ * this module reads `model.entitlements` or `model.actors`, and the claim-key 39 │ * lookup skips the `entitles` verb, so neither a relatedLocation nor a graph 40 │ * node can come from one. 41 │ * 42 │ * @flows ThreatModel -> #sarif via buildSarifContext -- "Flows, boundaries, assets and context annotations read from the parsed model" 43 │ * @exposes #sarif to #dos [low] cwe:CWE-400 -- "Enumerating upstream @flows chains is combinatorial in a dense flow graph" 44 │ * @mitigates #sarif against #dos using #resource-limits -- "Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result" 45 │ * @comment -- "Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)" 46 │ */ 47 │
L43 exposes #sarif → #dos
Enumerating upstream @flows chains is combinatorial in a dense flow graph
38 │ * this module reads `model.entitlements` or `model.actors`, and the claim-key 39 │ * lookup skips the `entitles` verb, so neither a relatedLocation nor a graph 40 │ * node can come from one. 41 │ * 42 │ * @flows ThreatModel -> #sarif via buildSarifContext -- "Flows, boundaries, assets and context annotations read from the parsed model" 43 │ * @exposes #sarif to #dos [low] cwe:CWE-400 -- "Enumerating upstream @flows chains is combinatorial in a dense flow graph" 44 │ * @mitigates #sarif against #dos using #resource-limits -- "Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result" 45 │ * @comment -- "Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)" 46 │ */ 47 │ 48 │ import type {
L44 mitigates #resource-limits mitigates #dos
Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result
39 │ * lookup skips the `entitles` verb, so neither a relatedLocation nor a graph 40 │ * node can come from one. 41 │ * 42 │ * @flows ThreatModel -> #sarif via buildSarifContext -- "Flows, boundaries, assets and context annotations read from the parsed model" 43 │ * @exposes #sarif to #dos [low] cwe:CWE-400 -- "Enumerating upstream @flows chains is combinatorial in a dense flow graph" 44 │ * @mitigates #sarif against #dos using #resource-limits -- "Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result" 45 │ * @comment -- "Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)" 46 │ */ 47 │ 48 │ import type { 49 │ ThreatModel, SourceLocation, ThreatModelFlow, ThreatModelBoundary,
L45 comment Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)
Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)
40 │ * node can come from one. 41 │ * 42 │ * @flows ThreatModel -> #sarif via buildSarifContext -- "Flows, boundaries, assets and context annotations read from the parsed model" 43 │ * @exposes #sarif to #dos [low] cwe:CWE-400 -- "Enumerating upstream @flows chains is combinatorial in a dense flow graph" 44 │ * @mitigates #sarif against #dos using #resource-limits -- "Chain walk is capped by depth, by chains collected and by a step budget; at most three chains are emitted per result" 45 │ * @comment -- "Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)" 46 │ */ 47 │ 48 │ import type { 49 │ ThreatModel, SourceLocation, ThreatModelFlow, ThreatModelBoundary, 50 │ } from '../types/index.js';
L57 flow ThreatModel → #sarif
Covered exposures, boundaries and flows read from the parsed model
52 │ * (§3.2.1), so it cannot remove an effect — a gated effect is the gate a probe 53 │ * should try to get past. Whether an effect is gated is a property of its 54 │ * result; whether a reach is entitled is not, and a consumer reads it from the 55 │ * model export's `reach_analysis` (§5.5), joined on the claim key. 56 │ * 57 │ * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" 58 │ * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" 59 │ * @flows BaselineSarif -> #sarif via applyBaseline -- "A previous export compared result by result" 60 │ * @handles internal on #sarif -- "Ledger evidence strings are exported with the state they support" 61 │ * @flows ThreatModel -> #sarif via agentReachResults -- "Reaches, effects and gates read from the parsed model" 62 │ * @comment -- "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written"
L58 flow LedgerFile → #sarif
Ledger states stamped onto results; the front end reads the file, this module does not
53 │ * should try to get past. Whether an effect is gated is a property of its 54 │ * result; whether a reach is entitled is not, and a consumer reads it from the 55 │ * model export's `reach_analysis` (§5.5), joined on the claim key. 56 │ * 57 │ * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" 58 │ * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" 59 │ * @flows BaselineSarif -> #sarif via applyBaseline -- "A previous export compared result by result" 60 │ * @handles internal on #sarif -- "Ledger evidence strings are exported with the state they support" 61 │ * @flows ThreatModel -> #sarif via agentReachResults -- "Reaches, effects and gates read from the parsed model" 62 │ * @comment -- "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written" 63 │ */
L59 flow BaselineSarif → #sarif
A previous export compared result by result
54 │ * result; whether a reach is entitled is not, and a consumer reads it from the 55 │ * model export's `reach_analysis` (§5.5), joined on the claim key. 56 │ * 57 │ * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" 58 │ * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" 59 │ * @flows BaselineSarif -> #sarif via applyBaseline -- "A previous export compared result by result" 60 │ * @handles internal on #sarif -- "Ledger evidence strings are exported with the state they support" 61 │ * @flows ThreatModel -> #sarif via agentReachResults -- "Reaches, effects and gates read from the parsed model" 62 │ * @comment -- "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written" 63 │ */ 64 │
L60 handles #sarif: internal
Ledger evidence strings are exported with the state they support
55 │ * model export's `reach_analysis` (§5.5), joined on the claim key. 56 │ * 57 │ * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" 58 │ * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" 59 │ * @flows BaselineSarif -> #sarif via applyBaseline -- "A previous export compared result by result" 60 │ * @handles internal on #sarif -- "Ledger evidence strings are exported with the state they support" 61 │ * @flows ThreatModel -> #sarif via agentReachResults -- "Reaches, effects and gates read from the parsed model" 62 │ * @comment -- "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written" 63 │ */ 64 │ 65 │ import type {
L61 flow ThreatModel → #sarif
Reaches, effects and gates read from the parsed model
56 │ * 57 │ * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" 58 │ * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" 59 │ * @flows BaselineSarif -> #sarif via applyBaseline -- "A previous export compared result by result" 60 │ * @handles internal on #sarif -- "Ledger evidence strings are exported with the state they support" 61 │ * @flows ThreatModel -> #sarif via agentReachResults -- "Reaches, effects and gates read from the parsed model" 62 │ * @comment -- "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written" 63 │ */ 64 │ 65 │ import type { 66 │ ThreatModel, ThreatModelExposure, ThreatModelReach, ThreatModelEffect, ThreatModelGate, SourceLocation,
L62 comment Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written
Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written
57 │ * @flows ThreatModel -> #sarif via buildPentestAppendix -- "Covered exposures, boundaries and flows read from the parsed model" 58 │ * @flows LedgerFile -> #sarif via HypothesesRead -- "Ledger states stamped onto results; the front end reads the file, this module does not" 59 │ * @flows BaselineSarif -> #sarif via applyBaseline -- "A previous export compared result by result" 60 │ * @handles internal on #sarif -- "Ledger evidence strings are exported with the state they support" 61 │ * @flows ThreatModel -> #sarif via agentReachResults -- "Reaches, effects and gates read from the parsed model" 62 │ * @comment -- "Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written" 63 │ */ 64 │ 65 │ import type { 66 │ ThreatModel, ThreatModelExposure, ThreatModelReach, ThreatModelEffect, ThreatModelGate, SourceLocation, 67 │ } from '../types/index.js';
L27 exposes #dashboard → #arbitrary-write
Writes .mmd, model.json and MANIFEST.json under a caller-supplied root
22 │ * across processes (fast-glob completion order, D23), so without it every 23 │ * regeneration would be a diff and a real change would be indistinguishable from 24 │ * a re-run — which would make the staleness signal useless in exactly the files 25 │ * that exist to carry it. 26 │ * 27 │ * @exposes #dashboard to #arbitrary-write [medium] cwe:CWE-73 -- "Writes .mmd, model.json and MANIFEST.json under a caller-supplied root" 28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things"
L28 mitigates #path-validation mitigates #arbitrary-write
Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames
23 │ * regeneration would be a diff and a real change would be indistinguishable from 24 │ * a re-run — which would make the staleness signal useless in exactly the files 25 │ * that exist to carry it. 26 │ * 27 │ * @exposes #dashboard to #arbitrary-write [medium] cwe:CWE-73 -- "Writes .mmd, model.json and MANIFEST.json under a caller-supplied root" 28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things" 33 │ * @comment -- "annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact"
L29 flow ThreatModel → #dashboard
Model rendered to disk artifacts
24 │ * a re-run — which would make the staleness signal useless in exactly the files 25 │ * that exist to carry it. 26 │ * 27 │ * @exposes #dashboard to #arbitrary-write [medium] cwe:CWE-73 -- "Writes .mmd, model.json and MANIFEST.json under a caller-supplied root" 28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things" 33 │ * @comment -- "annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact" 34 │ */
L30 flow #dashboard → FileSystem
Artifact write path
25 │ * that exist to carry it. 26 │ * 27 │ * @exposes #dashboard to #arbitrary-write [medium] cwe:CWE-73 -- "Writes .mmd, model.json and MANIFEST.json under a caller-supplied root" 28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things" 33 │ * @comment -- "annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact" 34 │ */ 35 │
L31 comment Artifacts carry a provenance header so a stale one is detectable rather than merely wrong
Artifacts carry a provenance header so a stale one is detectable rather than merely wrong
26 │ * 27 │ * @exposes #dashboard to #arbitrary-write [medium] cwe:CWE-73 -- "Writes .mmd, model.json and MANIFEST.json under a caller-supplied root" 28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things" 33 │ * @comment -- "annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact" 34 │ */ 35 │ 36 │ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
L32 comment by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things
by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things
27 │ * @exposes #dashboard to #arbitrary-write [medium] cwe:CWE-73 -- "Writes .mmd, model.json and MANIFEST.json under a caller-supplied root" 28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things" 33 │ * @comment -- "annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact" 34 │ */ 35 │ 36 │ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; 37 │ import { join } from 'node:path';
L33 comment annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact
annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact
28 │ * @mitigates #dashboard against #arbitrary-write using #path-validation -- "Every write is join(root, '.guardlink', …); feature names are slugified before use as filenames" 29 │ * @flows ThreatModel -> #dashboard via emitArtifacts -- "Model rendered to disk artifacts" 30 │ * @flows #dashboard -> FileSystem via writeFileSync -- "Artifact write path" 31 │ * @comment -- "Artifacts carry a provenance header so a stale one is detectable rather than merely wrong" 32 │ * @comment -- "by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things" 33 │ * @comment -- "annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact" 34 │ */ 35 │ 36 │ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; 37 │ import { join } from 'node:path'; 38 │ import { generateThreatGraph, generateDataFlowDiagram, generateAttackSurface } from '../dashboard/index.js';
L867 shield graph/index example content, excluded from parsing
862 │ '.guardlink/README.md', 863 │ ]; 864 │ 865 │ // ─── graph/index.md ────────────────────────────────────────────────── 866 │ 867 │ // @shield:begin -- "graph/index example content, excluded from parsing" 868 │ /** 869 │ * A table of slices and the question each one answers. 870 │ * 871 │ * This is the file to open first, and it is deliberately Markdown rather than a 872 │ * diagram. It renders natively on GitHub and on a docs site at any model size,
L973 shield Shielded region
968 │ Generated by ${p.generator} from annotation hash \`${p.annotation_hash}\`. If that 969 │ differs from what \`guardlink status .\` reports, everything here is stale — 970 │ regenerate rather than trusting it. 971 │ `; 972 │ } 973 │ // @shield:end 974 │ 975 │ // ─── graph/README.md ───────────────────────────────────────────────── 976 │ 977 │ // @shield:begin -- "graph/README example content, excluded from parsing" 978 │ function graphReadme(p: ArtifactProvenance, features: string[]): string {
L977 shield graph/README example content, excluded from parsing
972 │ } 973 │ // @shield:end 974 │ 975 │ // ─── graph/README.md ───────────────────────────────────────────────── 976 │ 977 │ // @shield:begin -- "graph/README example content, excluded from parsing" 978 │ function graphReadme(p: ArtifactProvenance, features: string[]): string { 979 │ return `# .guardlink/graph/ — generated diagrams 980 │ 981 │ **Every file here is generated. Do not edit any of them.** Regenerate with: 982 │
L1069 shield Shielded region
1064 │ 1065 │ Generated by ${p.generator}. For when, ask git — a timestamp here would1066 │ rewrite this file on every commit and tell you less than \`git log\` does.1067 │ `;1068 │ }1069 │ // @shield:end1070 │
L8 flow #blame → ThreatModel
record.blame on exposures, confirmed and mitigations
3 │ * 4 │ * The one place `record.blame` is set. Only the CLI `--blame` paths call this; 5 │ * the MCP server and the TUI use `computeBlame` and keep their cached model 6 │ * clean, so `--blame` stays opt-in even inside a long-lived process. 7 │ * 8 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame on exposures, confirmed and mitigations" 9 │ * @comment -- "The field is invisible to the annotation hash (a whitelist of claim fields) and stripped from the committed model.json, so attaching it changes no tracked artifact; the same goes for blame_context, the commit counts the dashboard's rates need" 10 │ */ 11 │ import type { ThreatModel } from '../types/index.js'; 12 │ import { computeBlame, type ComputeBlameOptions } from './compute.js'; 13 │ import type { BlameComputation, RecordBlame } from './types.js';
L9 comment The field is invisible to the annotation hash (a whitelist of claim fields) and stripped from the committed model.json, so attaching it changes no tracked artifact; the same goes for blame_context, the commit counts the dashboard's rates need
The field is invisible to the annotation hash (a whitelist of claim fields) and stripped from the committed model.json, so attaching it changes no tracked artifact; the same goes for blame_context, the commit counts the dashboard's rates need
4 │ * The one place `record.blame` is set. Only the CLI `--blame` paths call this; 5 │ * the MCP server and the TUI use `computeBlame` and keep their cached model 6 │ * clean, so `--blame` stays opt-in even inside a long-lived process. 7 │ * 8 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame on exposures, confirmed and mitigations" 9 │ * @comment -- "The field is invisible to the annotation hash (a whitelist of claim fields) and stripped from the committed model.json, so attaching it changes no tracked artifact; the same goes for blame_context, the commit counts the dashboard's rates need" 10 │ */ 11 │ import type { ThreatModel } from '../types/index.js'; 12 │ import { computeBlame, type ComputeBlameOptions } from './compute.js'; 13 │ import type { BlameComputation, RecordBlame } from './types.js'; 14 │
L23 exposes #blame → #path-traversal
safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git
18 │ * 19 │ * Every degradation is per record and explicit in `status`; nothing here throws 20 │ * for one bad file, because one pathological file must not take down `blame` 21 │ * for the whole repository. 22 │ * 23 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git" 24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths"
L24 mitigates #path-validation mitigates #path-traversal
safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it
19 │ * Every degradation is per record and explicit in `status`; nothing here throws 20 │ * for one bad file, because one pathological file must not take down `blame` 21 │ * for the whole repository. 22 │ * 23 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git" 24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates"
L25 exposes #blame → #dos
computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns
20 │ * for one bad file, because one pathological file must not take down `blame` 21 │ * for the whole repository. 22 │ * 23 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git" 24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared"
L26 mitigates #resource-limits mitigates #dos
Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it
21 │ * for the whole repository. 22 │ * 23 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git" 24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared" 31 │ * @comment -- "as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone"
L27 flow ThreatModel → #blame
Record locations and anchors
22 │ * 23 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git" 24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared" 31 │ * @comment -- "as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone" 32 │ */
L28 flow #blame → ThreatModel
record.blame, only on the CLI --blame paths
23 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "safeRelPath() receives location.file from each parsed record; a sidecar @source path is author text that can carry ../ into the argv computeBlame() hands to git" 24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared" 31 │ * @comment -- "as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone" 32 │ */ 33 │ import { existsSync } from 'node:fs';
L29 handles #blame: pii
Author identities attached to each record, and every identity in the history counted for the per-100-commits rates
24 │ * @mitigates #blame against #path-traversal using #path-validation -- "safeRelPath resolves each path against root and keeps it only when it is root or lies under root + sep; anything else yields status error and git never sees it" 25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared" 31 │ * @comment -- "as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone" 32 │ */ 33 │ import { existsSync } from 'node:fs'; 34 │ import { join, resolve, sep } from 'node:path';
L30 comment Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared
Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared
25 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "computeBlame() spawns git blame once per annotated file, git log -L once per distinct symbol span and listCommits() once over the whole history; a large model multiplies the spawns" 26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared" 31 │ * @comment -- "as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone" 32 │ */ 33 │ import { existsSync } from 'node:fs'; 34 │ import { join, resolve, sep } from 'node:path'; 35 │ import type {
L31 comment as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone
as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone
26 │ * @mitigates #blame against #dos using #resource-limits -- "Files are blamed once and memoised; -L is memoised per span and skipped for dirty files and file-wide anchors; commits resolve in one batched log call; the history walk is a single call and history: false skips it" 27 │ * @flows ThreatModel -> #blame via computeBlame -- "Record locations and anchors" 28 │ * @flows #blame -> ThreatModel via attachBlame -- "record.blame, only on the CLI --blame paths" 29 │ * @handles pii on #blame -- "Author identities attached to each record, and every identity in the history counted for the per-100-commits rates" 30 │ * @comment -- "Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared" 31 │ * @comment -- "as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone" 32 │ */ 33 │ import { existsSync } from 'node:fs'; 34 │ import { join, resolve, sep } from 'node:path'; 35 │ import type { 36 │ SourceLocation, ThreatModel, ThreatModelConfirmed, ThreatModelExposure, ThreatModelMitigation,
L264 handles #blame: pii
Every author and human co-author identity in the history, counted per person
259 │ * mode as the records so a key here is the string a summary row carries. On a 260 │ * shallow clone the history is truncated and every count is a lower bound, 261 │ * which `status: 'shallow'` already says. Keys are sorted so the JSON is the 262 │ * same bytes on every run. 263 │ * 264 │ * @handles pii on #blame -- "Every author and human co-author identity in the history, counted per person" 265 │ * @comment -- "A commit credits its author and each human co-author once; a bot-authored commit with no human co-author has an agent: author and credits no person. An AI tool is credited once per commit however many trailers name it" 266 │ */ 267 │ function countCommits(history: RawCommit[], rules: readonly CompiledRule[], mode: IdentityMode): CommitCounts { 268 │ const byHuman = new Map<string, number>(); 269 │ const byAgent = new Map<string, { tool: string; model: string | null; commits: number }>();
L265 comment A commit credits its author and each human co-author once; a bot-authored commit with no human co-author has an agent: author and credits no person. An AI tool is credited once per commit however many trailers name it
A commit credits its author and each human co-author once; a bot-authored commit with no human co-author has an agent: author and credits no person. An AI tool is credited once per commit however many trailers name it
260 │ * shallow clone the history is truncated and every count is a lower bound, 261 │ * which `status: 'shallow'` already says. Keys are sorted so the JSON is the 262 │ * same bytes on every run. 263 │ * 264 │ * @handles pii on #blame -- "Every author and human co-author identity in the history, counted per person" 265 │ * @comment -- "A commit credits its author and each human co-author once; a bot-authored commit with no human co-author has an agent: author and credits no person. An AI tool is credited once per commit however many trailers name it" 266 │ */ 267 │ function countCommits(history: RawCommit[], rules: readonly CompiledRule[], mode: IdentityMode): CommitCounts { 268 │ const byHuman = new Map<string, number>(); 269 │ const byAgent = new Map<string, { tool: string; model: string | null; commits: number }>(); 270 │ let ai_assisted = 0;
L22 exposes #blame → #redos
readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history
17 │ * name its own bot or override how a vendor's trailer is read without a code 18 │ * change. Same shape as `readDisabledDiagnostics` in the parser: absent or 19 │ * unreadable config returns the defaults, deliberately — a broken config must 20 │ * not silently change who gets attributed. 21 │ * 22 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history" 23 │ * @mitigates #blame against #redos using #regex-anchoring -- "Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching" 24 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "ignore_revs names a file git will open with --ignore-revs-file" 25 │ * @mitigates #blame against #path-traversal using #path-validation -- "The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it" 26 │ * @flows ConfigFile -> #blame via readBlameConfig -- "Attribution settings" 27 │ * @comment -- "The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists"
L23 mitigates #regex-anchoring mitigates #redos
Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching
18 │ * change. Same shape as `readDisabledDiagnostics` in the parser: absent or 19 │ * unreadable config returns the defaults, deliberately — a broken config must 20 │ * not silently change who gets attributed. 21 │ * 22 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history" 23 │ * @mitigates #blame against #redos using #regex-anchoring -- "Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching" 24 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "ignore_revs names a file git will open with --ignore-revs-file" 25 │ * @mitigates #blame against #path-traversal using #path-validation -- "The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it" 26 │ * @flows ConfigFile -> #blame via readBlameConfig -- "Attribution settings" 27 │ * @comment -- "The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists" 28 │ */
L24 exposes #blame → #path-traversal
ignore_revs names a file git will open with --ignore-revs-file
19 │ * unreadable config returns the defaults, deliberately — a broken config must 20 │ * not silently change who gets attributed. 21 │ * 22 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history" 23 │ * @mitigates #blame against #redos using #regex-anchoring -- "Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching" 24 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "ignore_revs names a file git will open with --ignore-revs-file" 25 │ * @mitigates #blame against #path-traversal using #path-validation -- "The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it" 26 │ * @flows ConfigFile -> #blame via readBlameConfig -- "Attribution settings" 27 │ * @comment -- "The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists" 28 │ */ 29 │ import { readFileSync } from 'node:fs';
L25 mitigates #path-validation mitigates #path-traversal
The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it
20 │ * not silently change who gets attributed. 21 │ * 22 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history" 23 │ * @mitigates #blame against #redos using #regex-anchoring -- "Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching" 24 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "ignore_revs names a file git will open with --ignore-revs-file" 25 │ * @mitigates #blame against #path-traversal using #path-validation -- "The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it" 26 │ * @flows ConfigFile -> #blame via readBlameConfig -- "Attribution settings" 27 │ * @comment -- "The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists" 28 │ */ 29 │ import { readFileSync } from 'node:fs'; 30 │ import { join, resolve, sep } from 'node:path';
L26 flow ConfigFile → #blame
Attribution settings
21 │ * 22 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history" 23 │ * @mitigates #blame against #redos using #regex-anchoring -- "Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching" 24 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "ignore_revs names a file git will open with --ignore-revs-file" 25 │ * @mitigates #blame against #path-traversal using #path-validation -- "The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it" 26 │ * @flows ConfigFile -> #blame via readBlameConfig -- "Attribution settings" 27 │ * @comment -- "The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists" 28 │ */ 29 │ import { readFileSync } from 'node:fs'; 30 │ import { join, resolve, sep } from 'node:path'; 31 │ import type { BlameConfig, CompiledRule, IdentityMode, ModelRule, ToolRule } from './types.js';
L27 comment The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists
The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists
22 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "readBlameConfig() compiles blame.tools[].match from .guardlink/config.json into RegExp objects that attributeCommit() runs against every author and trailer in the history" 23 │ * @mitigates #blame against #redos using #regex-anchoring -- "Every pattern is anchored (^…$), capped at 256 characters and compiled once; a pattern that fails to compile drops its whole rule rather than half-matching" 24 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "ignore_revs names a file git will open with --ignore-revs-file" 25 │ * @mitigates #blame against #path-traversal using #path-validation -- "The value is resolved against root and kept only when it is root or lies under root + sep; anything else becomes null and git is never told about it" 26 │ * @flows ConfigFile -> #blame via readBlameConfig -- "Attribution settings" 27 │ * @comment -- "The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists" 28 │ */ 29 │ import { readFileSync } from 'node:fs'; 30 │ import { join, resolve, sep } from 'node:path'; 31 │ import type { BlameConfig, CompiledRule, IdentityMode, ModelRule, ToolRule } from './types.js'; 32 │
L8 handles #blame: pii
Identity strings printed to the terminal
3 │ * 4 │ * One group per file, one block per claim, then the two summary tables and 5 │ * the quarterly trend. Plain `padEnd` columns and no colour, so the output 6 │ * pipes cleanly and the TUI can indent it. 7 │ * 8 │ * @handles pii on #blame -- "Identity strings printed to the terminal" 9 │ * @flows #blame -> #cli via formatBlameText -- "Human-readable attribution" 10 │ * @comment -- "Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one" 11 │ * @comment -- "A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one" 12 │ */ 13 │ import type { AgentSummaryRow, BlameEntry, BlamePayload, CommitRef, HumanSummaryRow, TrendBucket } from './types.js';
L9 flow #blame → #cli
Human-readable attribution
4 │ * One group per file, one block per claim, then the two summary tables and 5 │ * the quarterly trend. Plain `padEnd` columns and no colour, so the output 6 │ * pipes cleanly and the TUI can indent it. 7 │ * 8 │ * @handles pii on #blame -- "Identity strings printed to the terminal" 9 │ * @flows #blame -> #cli via formatBlameText -- "Human-readable attribution" 10 │ * @comment -- "Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one" 11 │ * @comment -- "A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one" 12 │ */ 13 │ import type { AgentSummaryRow, BlameEntry, BlamePayload, CommitRef, HumanSummaryRow, TrendBucket } from './types.js'; 14 │
L10 comment Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one
Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one
5 │ * the quarterly trend. Plain `padEnd` columns and no colour, so the output 6 │ * pipes cleanly and the TUI can indent it. 7 │ * 8 │ * @handles pii on #blame -- "Identity strings printed to the terminal" 9 │ * @flows #blame -> #cli via formatBlameText -- "Human-readable attribution" 10 │ * @comment -- "Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one" 11 │ * @comment -- "A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one" 12 │ */ 13 │ import type { AgentSummaryRow, BlameEntry, BlamePayload, CommitRef, HumanSummaryRow, TrendBucket } from './types.js'; 14 │ 15 │ function who(ref: CommitRef | null): string {
L11 comment A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one
A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one
6 │ * pipes cleanly and the TUI can indent it. 7 │ * 8 │ * @handles pii on #blame -- "Identity strings printed to the terminal" 9 │ * @flows #blame -> #cli via formatBlameText -- "Human-readable attribution" 10 │ * @comment -- "Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one" 11 │ * @comment -- "A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one" 12 │ */ 13 │ import type { AgentSummaryRow, BlameEntry, BlamePayload, CommitRef, HumanSummaryRow, TrendBucket } from './types.js'; 14 │ 15 │ function who(ref: CommitRef | null): string { 16 │ if (!ref) return '—';
L13 exposes #blame → #cmd-injection
gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record
8 │ * 9 │ * The parsers (`parseBlamePorcelain`, `parseLogRecords`) are pure and exported 10 │ * so they can be pinned on captured output; the commands take an injectable 11 │ * `exec` so batching can be asserted without spawning. 12 │ * 13 │ * @exposes #blame to #cmd-injection [low] cwe:CWE-78 -- "gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record" 14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip"
L14 mitigates #param-commands mitigates #cmd-injection
execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings
9 │ * The parsers (`parseBlamePorcelain`, `parseLogRecords`) are pure and exported 10 │ * so they can be pinned on captured output; the commands take an injectable 11 │ * `exec` so batching can be asserted without spawning. 12 │ * 13 │ * @exposes #blame to #cmd-injection [low] cwe:CWE-78 -- "gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record" 14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output"
L15 exposes #blame → #path-traversal
blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names
10 │ * so they can be pinned on captured output; the commands take an injectable 11 │ * `exec` so batching can be asserted without spawning. 12 │ * 13 │ * @exposes #blame to #cmd-injection [low] cwe:CWE-78 -- "gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record" 14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log"
L16 mitigates #path-validation mitigates #path-traversal
compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree
11 │ * `exec` so batching can be asserted without spawning. 12 │ * 13 │ * @exposes #blame to #cmd-injection [low] cwe:CWE-78 -- "gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record" 14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log" 21 │ * @comment -- "Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled"
L17 exposes #blame → #dos
git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD
12 │ * 13 │ * @exposes #blame to #cmd-injection [low] cwe:CWE-78 -- "gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record" 14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log" 21 │ * @comment -- "Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled" 22 │ */
L18 mitigates #resource-limits mitigates #dos
30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip
13 │ * @exposes #blame to #cmd-injection [low] cwe:CWE-78 -- "gitExec() builds the argv it spawns from location.file and the start/end line numbers of each parsed record" 14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log" 21 │ * @comment -- "Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled" 22 │ */ 23 │ import { execFileSync } from 'node:child_process';
L19 flow GitRepo → #blame
blame, log, ls-files, status and rev-parse output
14 │ * @mitigates #blame against #cmd-injection using #param-commands -- "execFileSync with an argv array and no shell; paths follow a literal -- and line ranges are integers formatted here, never caller strings" 15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log" 21 │ * @comment -- "Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled" 22 │ */ 23 │ import { execFileSync } from 'node:child_process'; 24 │ import { existsSync } from 'node:fs';
L20 handles #blame: pii
Author names, emails and dates from git log
15 │ * @exposes #blame to #path-traversal [low] cwe:CWE-22 -- "blameFile(), spanOldestCommit() and fileAddCommit() pass location.file to git blame, git log and git ls-files, which read whatever path it names" 16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log" 21 │ * @comment -- "Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled" 22 │ */ 23 │ import { execFileSync } from 'node:child_process'; 24 │ import { existsSync } from 'node:fs'; 25 │ import { join } from 'node:path';
L21 comment Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled
Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled
16 │ * @mitigates #blame against #path-traversal using #path-validation -- "compute.ts resolves every path against root and drops any that escapes it before this module sees it, and git itself refuses paths outside the work tree" 17 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "git log -L walks history once per distinct span, blame reads every annotated file, and listCommits reads the whole history reachable from HEAD" 18 │ * @mitigates #blame against #dos using #resource-limits -- "30 s timeout and 64 MiB output cap per call; one blame per file; sha resolution and path queries batched; -L only for symbol/block spans on clean files; the history walk is one call a caller can skip" 19 │ * @flows GitRepo -> #blame via execFileSync -- "blame, log, ls-files, status and rev-parse output" 20 │ * @handles pii on #blame -- "Author names, emails and dates from git log" 21 │ * @comment -- "Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled" 22 │ */ 23 │ import { execFileSync } from 'node:child_process'; 24 │ import { existsSync } from 'node:fs'; 25 │ import { join } from 'node:path'; 26 │ import type { RawCommit } from './types.js';
L201 exposes #blame → #dos
listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost
196 │ * `resolveCommits` reads — the denominator of every "per 100 commits" rate 197 │ * and the source of `as_of`. `HEAD --` pins the argument as a revision even 198 │ * when a file is named HEAD. An empty repository (no HEAD yet) or a plain 199 │ * directory is an empty history, not an error. 200 │ * 201 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost" 202 │ * @mitigates #blame against #dos using #resource-limits -- "A single log call under the same 30 s timeout and 64 MiB output cap; nothing is spawned per commit, and compute.ts lets a caller skip the walk with history: false" 203 │ * @handles pii on #blame -- "Author names, emails and co-author trailers of every commit in the history" 204 │ * @flows GitRepo -> #blame via listCommits -- "The reachable history, for commit counts and the HEAD author date" 205 │ * @comment -- "Reachable from HEAD only, never --all: what other branches carry is not this checkout's history" 206 │ */
L202 mitigates #resource-limits mitigates #dos
A single log call under the same 30 s timeout and 64 MiB output cap; nothing is spawned per commit, and compute.ts lets a caller skip the walk with history: false
197 │ * and the source of `as_of`. `HEAD --` pins the argument as a revision even 198 │ * when a file is named HEAD. An empty repository (no HEAD yet) or a plain 199 │ * directory is an empty history, not an error. 200 │ * 201 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost" 202 │ * @mitigates #blame against #dos using #resource-limits -- "A single log call under the same 30 s timeout and 64 MiB output cap; nothing is spawned per commit, and compute.ts lets a caller skip the walk with history: false" 203 │ * @handles pii on #blame -- "Author names, emails and co-author trailers of every commit in the history" 204 │ * @flows GitRepo -> #blame via listCommits -- "The reachable history, for commit counts and the HEAD author date" 205 │ * @comment -- "Reachable from HEAD only, never --all: what other branches carry is not this checkout's history" 206 │ */ 207 │ export function listCommits(root: string, exec: GitExec = gitExec): RawCommit[] {
L203 handles #blame: pii
Author names, emails and co-author trailers of every commit in the history
198 │ * when a file is named HEAD. An empty repository (no HEAD yet) or a plain 199 │ * directory is an empty history, not an error. 200 │ * 201 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost" 202 │ * @mitigates #blame against #dos using #resource-limits -- "A single log call under the same 30 s timeout and 64 MiB output cap; nothing is spawned per commit, and compute.ts lets a caller skip the walk with history: false" 203 │ * @handles pii on #blame -- "Author names, emails and co-author trailers of every commit in the history" 204 │ * @flows GitRepo -> #blame via listCommits -- "The reachable history, for commit counts and the HEAD author date" 205 │ * @comment -- "Reachable from HEAD only, never --all: what other branches carry is not this checkout's history" 206 │ */ 207 │ export function listCommits(root: string, exec: GitExec = gitExec): RawCommit[] { 208 │ const out = tryExec(root, ['log', LOG_FORMAT, 'HEAD', '--'], exec);
L204 flow GitRepo → #blame
The reachable history, for commit counts and the HEAD author date
199 │ * directory is an empty history, not an error. 200 │ * 201 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost" 202 │ * @mitigates #blame against #dos using #resource-limits -- "A single log call under the same 30 s timeout and 64 MiB output cap; nothing is spawned per commit, and compute.ts lets a caller skip the walk with history: false" 203 │ * @handles pii on #blame -- "Author names, emails and co-author trailers of every commit in the history" 204 │ * @flows GitRepo -> #blame via listCommits -- "The reachable history, for commit counts and the HEAD author date" 205 │ * @comment -- "Reachable from HEAD only, never --all: what other branches carry is not this checkout's history" 206 │ */ 207 │ export function listCommits(root: string, exec: GitExec = gitExec): RawCommit[] { 208 │ const out = tryExec(root, ['log', LOG_FORMAT, 'HEAD', '--'], exec); 209 │ return out === null ? [] : parseLogRecords(out);
L205 comment Reachable from HEAD only, never --all: what other branches carry is not this checkout's history
Reachable from HEAD only, never --all: what other branches carry is not this checkout's history
200 │ * 201 │ * @exposes #blame to #dos [low] cwe:CWE-400 -- "listCommits() runs git log over every commit reachable from HEAD and parseLogRecords() parses each trailer block; history size, not model size, sets the cost" 202 │ * @mitigates #blame against #dos using #resource-limits -- "A single log call under the same 30 s timeout and 64 MiB output cap; nothing is spawned per commit, and compute.ts lets a caller skip the walk with history: false" 203 │ * @handles pii on #blame -- "Author names, emails and co-author trailers of every commit in the history" 204 │ * @flows GitRepo -> #blame via listCommits -- "The reachable history, for commit counts and the HEAD author date" 205 │ * @comment -- "Reachable from HEAD only, never --all: what other branches carry is not this checkout's history" 206 │ */ 207 │ export function listCommits(root: string, exec: GitExec = gitExec): RawCommit[] { 208 │ const out = tryExec(root, ['log', LOG_FORMAT, 'HEAD', '--'], exec); 209 │ return out === null ? [] : parseLogRecords(out); 210 │ }
L9 comment Public surface of src/blame: computeBlame (non-mutating), attachBlame (CLI --blame), the payload builders, the config reader and listCommits, the one history walk behind the commit counts
Public surface of src/blame: computeBlame (non-mutating), attachBlame (CLI --blame), the payload builders, the config reader and listCommits, the one history walk behind the commit counts
4 │ * Who introduced the code beneath an annotation, who declared it, who declared 5 │ * the fix, and which AI tool a commit credited — computed from git at run 6 │ * time, never written into source. See `docs/GUARDLINK_REFERENCE.md`, 7 │ * "Attribution". 8 │ * 9 │ * @comment -- "Public surface of src/blame: computeBlame (non-mutating), attachBlame (CLI --blame), the payload builders, the config reader and listCommits, the one history walk behind the commit counts" 10 │ */ 11 │ export * from './types.js'; 12 │ export { listCommits } from './git.js'; 13 │ export { readBlameConfig, compileRules, DEFAULT_TOOL_RULES, DEFAULT_IGNORE_REVS } from './config.js'; 14 │ export { attributeCommit, parseTrailerBlock, parseAssistedBy, splitPerson, identityFor, classifyPerson } from './trailers.js';
L23 handles #blame: pii
Identity strings aggregated per person
18 │ * the hot files — are the same fold over the same entries plus the commit 19 │ * counts `compute.ts` read from history. "Now" is never the wall clock: every 20 │ * age is measured against `as_of`, the HEAD commit's author date, so two runs 21 │ * on the same HEAD print the same bytes. 22 │ * 23 │ * @handles pii on #blame -- "Identity strings aggregated per person" 24 │ * @flows #blame -> #cli via buildBlamePayload -- "guardlink.blame/v1 payload for --json, the MCP tool and the TUI" 25 │ * @flows #blame -> #dashboard via summarise -- "Per-person and per-tool rows, the quarterly trend, the cohort comparison and the hot files the Attribution page renders" 26 │ * @comment -- "Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it" 27 │ * @comment -- "Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count" 28 │ */
L24 flow #blame → #cli
guardlink.blame/v1 payload for --json, the MCP tool and the TUI
19 │ * counts `compute.ts` read from history. "Now" is never the wall clock: every 20 │ * age is measured against `as_of`, the HEAD commit's author date, so two runs 21 │ * on the same HEAD print the same bytes. 22 │ * 23 │ * @handles pii on #blame -- "Identity strings aggregated per person" 24 │ * @flows #blame -> #cli via buildBlamePayload -- "guardlink.blame/v1 payload for --json, the MCP tool and the TUI" 25 │ * @flows #blame -> #dashboard via summarise -- "Per-person and per-tool rows, the quarterly trend, the cohort comparison and the hot files the Attribution page renders" 26 │ * @comment -- "Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it" 27 │ * @comment -- "Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count" 28 │ */ 29 │ import { relationRecords } from '../parser/claim-key.js';
L25 flow #blame → #dashboard
Per-person and per-tool rows, the quarterly trend, the cohort comparison and the hot files the Attribution page renders
20 │ * age is measured against `as_of`, the HEAD commit's author date, so two runs 21 │ * on the same HEAD print the same bytes. 22 │ * 23 │ * @handles pii on #blame -- "Identity strings aggregated per person" 24 │ * @flows #blame -> #cli via buildBlamePayload -- "guardlink.blame/v1 payload for --json, the MCP tool and the TUI" 25 │ * @flows #blame -> #dashboard via summarise -- "Per-person and per-tool rows, the quarterly trend, the cohort comparison and the hot files the Attribution page renders" 26 │ * @comment -- "Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it" 27 │ * @comment -- "Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count" 28 │ */ 29 │ import { relationRecords } from '../parser/claim-key.js'; 30 │ import type { ThreatModel } from '../types/index.js';
L26 comment Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it
Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it
21 │ * on the same HEAD print the same bytes. 22 │ * 23 │ * @handles pii on #blame -- "Identity strings aggregated per person" 24 │ * @flows #blame -> #cli via buildBlamePayload -- "guardlink.blame/v1 payload for --json, the MCP tool and the TUI" 25 │ * @flows #blame -> #dashboard via summarise -- "Per-person and per-tool rows, the quarterly trend, the cohort comparison and the hot files the Attribution page renders" 26 │ * @comment -- "Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it" 27 │ * @comment -- "Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count" 28 │ */ 29 │ import { relationRecords } from '../parser/claim-key.js'; 30 │ import type { ThreatModel } from '../types/index.js'; 31 │ import {
L27 comment Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count
Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count
22 │ * 23 │ * @handles pii on #blame -- "Identity strings aggregated per person" 24 │ * @flows #blame -> #cli via buildBlamePayload -- "guardlink.blame/v1 payload for --json, the MCP tool and the TUI" 25 │ * @flows #blame -> #dashboard via summarise -- "Per-person and per-tool rows, the quarterly trend, the cohort comparison and the hot files the Attribution page renders" 26 │ * @comment -- "Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it" 27 │ * @comment -- "Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count" 28 │ */ 29 │ import { relationRecords } from '../parser/claim-key.js'; 30 │ import type { ThreatModel } from '../types/index.js'; 31 │ import { 32 │ BLAME_SCHEMA,
L19 exposes #blame → #data-exposure
identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses
14 │ * Bot-as-author (GitHub Copilot's coding agent) is handled by swapping: the 15 │ * first human co-author becomes the author and the bot lands in 16 │ * `assisted_by`. With no human co-author the author is `agent:<tool>` — the 17 │ * honest answer; nothing here invents a person. 18 │ * 19 │ * @exposes #blame to #data-exposure [medium] cwe:CWE-200 -- "identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses" 20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values"
L20 mitigates #identity-redaction mitigates #data-exposure
identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address
15 │ * first human co-author becomes the author and the bot lands in 16 │ * `assisted_by`. With no human co-author the author is `agent:<tool>` — the 17 │ * honest answer; nothing here invents a person. 18 │ * 19 │ * @exposes #blame to #data-exposure [medium] cwe:CWE-200 -- "identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses" 20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values" 25 │ * @comment -- "Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses"
L21 exposes #blame → #redos
attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history
16 │ * `assisted_by`. With no human co-author the author is `agent:<tool>` — the 17 │ * honest answer; nothing here invents a person. 18 │ * 19 │ * @exposes #blame to #data-exposure [medium] cwe:CWE-200 -- "identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses" 20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values" 25 │ * @comment -- "Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses" 26 │ */
L22 mitigates #regex-anchoring mitigates #redos
Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input
17 │ * honest answer; nothing here invents a person. 18 │ * 19 │ * @exposes #blame to #data-exposure [medium] cwe:CWE-200 -- "identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses" 20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values" 25 │ * @comment -- "Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses" 26 │ */ 27 │ import { createHash } from 'node:crypto';
L23 handles #blame: pii
Git author names and email addresses
18 │ * 19 │ * @exposes #blame to #data-exposure [medium] cwe:CWE-200 -- "identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses" 20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values" 25 │ * @comment -- "Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses" 26 │ */ 27 │ import { createHash } from 'node:crypto'; 28 │ import type { AiAttribution, CommitRef, CompiledRule, IdentityMode, RawCommit } from './types.js';
L24 flow CommitTrailers → #blame
Co-authored-by and Assisted-by values
19 │ * @exposes #blame to #data-exposure [medium] cwe:CWE-200 -- "identityFor() turns author.name and author.email from git history into identity strings that buildBlamePayload() writes into reports, dashboards and MCP responses" 20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values" 25 │ * @comment -- "Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses" 26 │ */ 27 │ import { createHash } from 'node:crypto'; 28 │ import type { AiAttribution, CommitRef, CompiledRule, IdentityMode, RawCommit } from './types.js'; 29 │
L25 comment Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses
Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses
20 │ * @mitigates #blame against #data-exposure using #identity-redaction -- "identityFor renders the configured mode: name by default, and hash replaces the email with 12 hex of its sha256 so a shared artifact carries no address" 21 │ * @exposes #blame to #redos [low] cwe:CWE-1333 -- "attributeCommit() runs every compiled ToolRule.match against each author and Co-Authored-By trailer in the history" 22 │ * @mitigates #blame against #redos using #regex-anchoring -- "Rules arrive compiled, anchored and length-capped from config.ts; the fixed regexes here are linear in the input" 23 │ * @handles pii on #blame -- "Git author names and email addresses" 24 │ * @flows CommitTrailers -> #blame via parseTrailerBlock -- "Co-authored-by and Assisted-by values" 25 │ * @comment -- "Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses" 26 │ */ 27 │ import { createHash } from 'node:crypto'; 28 │ import type { AiAttribution, CommitRef, CompiledRule, IdentityMode, RawCommit } from './types.js'; 29 │ 30 │ export interface RawPerson {
L14 comment Pure type declarations for the blame module; no I/O. The optional `blame` field these describe is invisible to the annotation hash and stripped from the committed model.json, exactly like `anchor`
Pure type declarations for the blame module; no I/O. The optional `blame` field these describe is invisible to the annotation hash and stripped from the committed model.json, exactly like `anchor`
9 │ * - `CommitRef.author` is an identity string in the ledger's scheme — 10 │ * `human:<name>` or `agent:<tool>` — so a gate can key on the prefix. 11 │ * - `assisted_by[]` is structured (`tool`, `model`) because "which model" 12 │ * is the question the dashboard groups by. 13 │ * 14 │ * @comment -- "Pure type declarations for the blame module; no I/O. The optional `blame` field these describe is invisible to the annotation hash and stripped from the committed model.json, exactly like `anchor`" 15 │ */ 16 │ 17 │ export const BLAME_SCHEMA = 'guardlink.blame/v1'; 18 │ 19 │ /** What an identity string shows for a human: git name, email, or a 12-hex hash of the email. */
L179 flow ThreatModel → #cli
Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances
174 │ * 175 │ * Read-only. `applyReanchor` is deliberately not called from here: rewriting an 176 │ * anchor inside a CI run would move an annotation onto code nobody chose for 177 │ * it, on a machine where nobody is watching. 178 │ * 179 │ * @flows ThreatModel -> #cli via runCiChecks -- "Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances" 180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on"
L180 flow ParseDiagnostics → #cli
Diagnostics from the parse are reported, never recomputed
175 │ * Read-only. `applyReanchor` is deliberately not called from here: rewriting an 176 │ * anchor inside a CI run would move an annotation onto code nobody chose for 177 │ * it, on a machine where nobody is watching. 178 │ * 179 │ * @flows ThreatModel -> #cli via runCiChecks -- "Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances" 180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up"
L181 flow SourceFiles → #cli
Recorded anchors compared against current source
176 │ * anchor inside a CI run would move an annotation onto code nobody chose for 177 │ * it, on a machine where nobody is watching. 178 │ * 179 │ * @flows ThreatModel -> #cli via runCiChecks -- "Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances" 180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run"
L182 flow LedgerFile → #cli
Recorded claim hashes, read only
177 │ * it, on a machine where nobody is watching. 178 │ * 179 │ * @flows ThreatModel -> #cli via runCiChecks -- "Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances" 180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on"
L183 comment Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got
Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got
178 │ * 179 │ * @flows ThreatModel -> #cli via runCiChecks -- "Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances" 180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports"
L184 flow ThreatModel → #cli
Qualifying acceptances read for how much of their horizon is left; reported, never gated on
179 │ * @flows ThreatModel -> #cli via runCiChecks -- "Parsed model checked for uncovered exposures, confirmed exploits and unqualified acceptances" 180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent"
L185 comment The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up
The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up
180 │ * @flows ParseDiagnostics -> #cli via runCiChecks -- "Diagnostics from the parse are reported, never recomputed" 181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here"
L186 comment --min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run
--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run
181 │ * @flows SourceFiles -> #cli via findAnchorDrift -- "Recorded anchors compared against current source" 182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here" 191 │ * @comment -- "--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them"
L187 comment A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on
A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on
182 │ * @flows LedgerFile -> #cli via readLedger -- "Recorded claim hashes, read only" 183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here" 191 │ * @comment -- "--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them" 192 │ */
L188 comment Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports
Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports
183 │ * @comment -- "Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got" 184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here" 191 │ * @comment -- "--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them" 192 │ */ 193 │
L189 comment The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent
The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent
184 │ * @flows ThreatModel -> #cli via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left; reported, never gated on" 185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here" 191 │ * @comment -- "--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them" 192 │ */ 193 │ 194 │ import type {
L190 comment @confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here
@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here
185 │ * @comment -- "The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up" 186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here" 191 │ * @comment -- "--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them" 192 │ */ 193 │ 194 │ import type { 195 │ ThreatModel, ThreatModelExposure, ThreatModelConfirmed, Severity, ParseDiagnostic, DiagnosticCode,
L191 comment --severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them
--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them
186 │ * @comment -- "--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run" 187 │ * @comment -- "A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on" 188 │ * @comment -- "Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports" 189 │ * @comment -- "The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent" 190 │ * @comment -- "@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here" 191 │ * @comment -- "--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them" 192 │ */ 193 │ 194 │ import type { 195 │ ThreatModel, ThreatModelExposure, ThreatModelConfirmed, Severity, ParseDiagnostic, DiagnosticCode, 196 │ } from '../types/index.js';
L33 exposes #cli → #path-traversal
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"
L34 mitigates #path-validation mitigates #path-traversal
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"
L35 exposes #cli → #arbitrary-write
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"
L36 mitigates #path-validation mitigates #arbitrary-write
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"
L37 exposes #cli → #api-key-exposure
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"
L38 mitigates #key-redaction mitigates #api-key-exposure
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"
L39 exposes #cli → #cmd-injection
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"
L40 audit Audit: #cli
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 │ */
L41 flow UserArgs → #cli
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 │
L42 flow #cli → FileSystem
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';
L43 boundary #cli ↔ UserInput
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';
L44 handles #cli: secrets
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';
L274 flow GitRepo → #cli
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
L328 flow LedgerFile → #cli
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 │
L329 flow LedgerFile → #cli
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) {
L331 flow GitRepo → #cli
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 │ }
L404 flow LedgerFile → #cli
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
L405 comment Closes the same blind spot ci's third check already closed (src/ci/index.ts) for the validate/CI-gate surface
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
L414 flow #cli → #codegraph
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 │
L559 exposes #cli → #arbitrary-write
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
L560 mitigates #path-validation mitigates #arbitrary-write
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')
L561 flow UserInput → #cli
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')
L562 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
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.', '.')
L678 flow GitRepo → #cli
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')
L679 handles #cli: pii
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')
L680 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
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', '.')
L872 flow GitRepo → #cli
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 │
L1245 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
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', '.')
L1285 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
--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) {
L1300 flow LedgerFile → #cli
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 │
L1661 flow #cli → #codegraph
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
L1708 flow #cli → #gate
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 │ };
L1843 flow ThreatModel → #cli
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);
L1847 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
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; }
L1850 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
`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 │
L1851 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
`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 }) => {
L1858 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
`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"
L1863 flow ScanReport → #cli
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;
L1866 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
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;
L1869 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
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"
L1873 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
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 }[] = [];
L1874 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
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>();
L1877 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
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));
L1888 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
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"
L1890 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
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"
L1893 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
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) {
L1895 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
--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'
L1896 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
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`;
L1897 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
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)}`;
L1909 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
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 │ }
L1910 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
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"
L1915 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; }
L1926 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
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}`);
L1953 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
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'));
L1956 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
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')
L1970 flow LedgerFile → #cli
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,
L3078 flow GitRepo → #cli
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) {
L3081 flow GitRepo → #cli
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`);
L3878 comment Displays ledger state (absent, corrupt, or counts) computed by the caller; this function performs no I/O of its own
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') {
L3955 handles #cli: pii
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') {
L3956 comment Prints only what the payload already holds; no I/O here
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');
L45 flow #codegraph → #cli
Boundary diagnostics from the worklist and the parsed model, for validate --code-graph and guardlink_validate
40 │ * THE GRAPH IS OPTIONAL 41 │ * With no usable worklist this returns nothing, so a caller that runs it without a 42 │ * graph prints exactly what it printed before. The access checks additionally 43 │ * need the graph's `classified` auth verdict. 44 │ * 45 │ * @flows #codegraph -> #cli via checkBoundaries -- "Boundary diagnostics from the worklist and the parsed model, for validate --code-graph and guardlink_validate" 46 │ * @comment -- "Pure over a parsed model and an already-loaded worklist: no process, no file, no network. The description is matched for a stated access level and quoted back, so a reader sees the exact word the check read" 47 │ */ 48 │ import type { ThreatModel, ThreatModelBoundary, ParseDiagnostic } from '../types/index.js'; 49 │ import { canonicaliser } from '../parser/canonical-ref.js'; 50 │ import { accessUsable, worklistUsable, type Worklist, type WorklistEntry, type RouteAccess } from './index.js';
L46 comment Pure over a parsed model and an already-loaded worklist: no process, no file, no network. The description is matched for a stated access level and quoted back, so a reader sees the exact word the check read
Pure over a parsed model and an already-loaded worklist: no process, no file, no network. The description is matched for a stated access level and quoted back, so a reader sees the exact word the check read
41 │ * With no usable worklist this returns nothing, so a caller that runs it without a 42 │ * graph prints exactly what it printed before. The access checks additionally 43 │ * need the graph's `classified` auth verdict. 44 │ * 45 │ * @flows #codegraph -> #cli via checkBoundaries -- "Boundary diagnostics from the worklist and the parsed model, for validate --code-graph and guardlink_validate" 46 │ * @comment -- "Pure over a parsed model and an already-loaded worklist: no process, no file, no network. The description is matched for a stated access level and quoted back, so a reader sees the exact word the check read" 47 │ */ 48 │ import type { ThreatModel, ThreatModelBoundary, ParseDiagnostic } from '../types/index.js'; 49 │ import { canonicaliser } from '../parser/canonical-ref.js'; 50 │ import { accessUsable, worklistUsable, type Worklist, type WorklistEntry, type RouteAccess } from './index.js'; 51 │
L9 handles #dashboard: pii
Introducer identities per asset and per period, in the configured identity mode
4 │ * and the attribution grids (people × time, AI tool × severity). 5 │ * 6 │ * All pure over the unified claim rows and the model; nothing here reads a 7 │ * file or the clock. 8 │ * 9 │ * @handles pii on #dashboard -- "Introducer identities per asset and per period, in the configured identity mode" 10 │ * @comment -- "Asset details match a tile by any of its aliases (#id, dotted path, as written), case-insensitively; mitigations never count as exposures, they only change a pair's status" 11 │ */ 12 │ import type { ThreatModel } from '../types/index.js'; 13 │ import type { ClaimState } from '../parser/verification.js'; 14 │ import type { ThreatModelDiff } from '../diff/engine.js';
L10 comment Asset details match a tile by any of its aliases (#id, dotted path, as written), case-insensitively; mitigations never count as exposures, they only change a pair's status
Asset details match a tile by any of its aliases (#id, dotted path, as written), case-insensitively; mitigations never count as exposures, they only change a pair's status
5 │ * 6 │ * All pure over the unified claim rows and the model; nothing here reads a 7 │ * file or the clock. 8 │ * 9 │ * @handles pii on #dashboard -- "Introducer identities per asset and per period, in the configured identity mode" 10 │ * @comment -- "Asset details match a tile by any of its aliases (#id, dotted path, as written), case-insensitively; mitigations never count as exposures, they only change a pair's status" 11 │ */ 12 │ import type { ThreatModel } from '../types/index.js'; 13 │ import type { ClaimState } from '../parser/verification.js'; 14 │ import type { ThreatModelDiff } from '../diff/engine.js'; 15 │ import type { AssetHeatmapEntry } from './data.js';
L5 exposes #dashboard → #path-traversal
readFileSync reads the file each annotation's location names, for the code context
1 │ /** 2 │ * GuardLink Dashboard — the per-file annotation list behind the Code page 3 │ * and its drawer, with a few lines of source context around each annotation. 4 │ * 5 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads the file each annotation's location names, for the code context" 6 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Relative locations are resolved against root; an absolute location is read as given because it came from the parser, not from a caller" 7 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 8 │ * @comment -- "Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page" 9 │ */ 10 │ import { readFileSync } from 'node:fs';
L6 mitigates #path-validation mitigates #path-traversal
Relative locations are resolved against root; an absolute location is read as given because it came from the parser, not from a caller
1 │ /** 2 │ * GuardLink Dashboard — the per-file annotation list behind the Code page 3 │ * and its drawer, with a few lines of source context around each annotation. 4 │ * 5 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads the file each annotation's location names, for the code context" 6 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Relative locations are resolved against root; an absolute location is read as given because it came from the parser, not from a caller" 7 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 8 │ * @comment -- "Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page" 9 │ */ 10 │ import { readFileSync } from 'node:fs'; 11 │ import { isAbsolute, resolve } from 'node:path';
L7 flow SourceFiles → #dashboard
Code snippet reads
2 │ * GuardLink Dashboard — the per-file annotation list behind the Code page 3 │ * and its drawer, with a few lines of source context around each annotation. 4 │ * 5 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads the file each annotation's location names, for the code context" 6 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Relative locations are resolved against root; an absolute location is read as given because it came from the parser, not from a caller" 7 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 8 │ * @comment -- "Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page" 9 │ */ 10 │ import { readFileSync } from 'node:fs'; 11 │ import { isAbsolute, resolve } from 'node:path'; 12 │ import type { ThreatModel } from '../types/index.js';
L8 comment Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page
Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page
3 │ * and its drawer, with a few lines of source context around each annotation. 4 │ * 5 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads the file each annotation's location names, for the code context" 6 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Relative locations are resolved against root; an absolute location is read as given because it came from the parser, not from a caller" 7 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 8 │ * @comment -- "Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page" 9 │ */ 10 │ import { readFileSync } from 'node:fs'; 11 │ import { isAbsolute, resolve } from 'node:path'; 12 │ import type { ThreatModel } from '../types/index.js'; 13 │ import type { ExposureRow, AssetHeatmapEntry } from './data.js';
L26 mitigates #output-encoding mitigates #xss
Every value the drawers, twins and tooltip render from the embedded data passes through esc() or textContent; URLs are pre-built server-side and attribute-escaped here
21 │ * 22 │ * Nothing from the model is interpolated into this string at generation time; 23 │ * the page's data arrives through the JSON constants emitted before it, and 24 │ * everything rendered from them goes through the client `esc()` or textContent. 25 │ * 26 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every value the drawers, twins and tooltip render from the embedded data passes through esc() or textContent; URLs are pre-built server-side and attribute-escaped here" 27 │ * @comment -- "No network: the clipboard API with a textarea fallback, history.replaceState for filters, localStorage only for theme and rail state" 28 │ */ 29 │ import { ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; 30 │ import { HYPOTHESES_FILE } from '../hypothesis/ledger.js'; 31 │
L27 comment No network: the clipboard API with a textarea fallback, history.replaceState for filters, localStorage only for theme and rail state
No network: the clipboard API with a textarea fallback, history.replaceState for filters, localStorage only for theme and rail state
22 │ * Nothing from the model is interpolated into this string at generation time; 23 │ * the page's data arrives through the JSON constants emitted before it, and 24 │ * everything rendered from them goes through the client `esc()` or textContent. 25 │ * 26 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every value the drawers, twins and tooltip render from the embedded data passes through esc() or textContent; URLs are pre-built server-side and attribute-escaped here" 27 │ * @comment -- "No network: the clipboard API with a textarea fallback, history.replaceState for filters, localStorage only for theme and rail state" 28 │ */ 29 │ import { ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; 30 │ import { HYPOTHESES_FILE } from '../hypothesis/ledger.js'; 31 │ 32 │ export const CLIENT_JS = `
L100 comment Stats mirror the model's own scope: on a --feature model these are the feature's numbers, and the dashboard suppresses the ones that only mean something project-wide
Stats mirror the model's own scope: on a --feature model these are the feature's numbers, and the dashboard suppresses the ones that only mean something project-wide
95 │ * rendered, and `renderCodePage`/the top bar withhold it on a slice, because 96 │ * "annotated files over source files" measures a repository and a slice's 97 │ * answer to it is 100% by construction. Nothing to fix here; the caller decides 98 │ * what a number is allowed to claim. 99 │ * 100 │ * @comment -- "Stats mirror the model's own scope: on a --feature model these are the feature's numbers, and the dashboard suppresses the ones that only mean something project-wide" 101 │ */ 102 │ export function computeStats(model: ThreatModel): DashboardStats { 103 │ return { 104 │ annotations: model.annotations_parsed, 105 │ sourceFiles: model.source_files,
L292 handles #dashboard: pii
Author identities from git, already in the configured identity mode
287 │ /** 288 │ * The Attribution page's data, or null when no record carries `blame` — the 289 │ * page is then not rendered at all, so a dashboard built without `--blame` is 290 │ * byte-for-byte what it was. 291 │ * 292 │ * @handles pii on #dashboard -- "Author identities from git, already in the configured identity mode" 293 │ * @comment -- "Pure projection of record.blame; the summariser is shared with the CLI and the report so every surface agrees on the numbers" 294 │ */ 295 │ export function computeAttribution(model: ThreatModel): AttributionData | null { 296 │ const entries = entriesFromModel(model); 297 │ if (entries.length === 0) return null;
L293 comment Pure projection of record.blame; the summariser is shared with the CLI and the report so every surface agrees on the numbers
Pure projection of record.blame; the summariser is shared with the CLI and the report so every surface agrees on the numbers
288 │ * The Attribution page's data, or null when no record carries `blame` — the 289 │ * page is then not rendered at all, so a dashboard built without `--blame` is 290 │ * byte-for-byte what it was. 291 │ * 292 │ * @handles pii on #dashboard -- "Author identities from git, already in the configured identity mode" 293 │ * @comment -- "Pure projection of record.blame; the summariser is shared with the CLI and the report so every surface agrees on the numbers" 294 │ */ 295 │ export function computeAttribution(model: ThreatModel): AttributionData | null { 296 │ const entries = entriesFromModel(model); 297 │ if (entries.length === 0) return null; 298 │ const context = (model as ThreatModel & { blame_context?: BlameContext }).blame_context;
L393 comment Every item points somewhere: a hash route into the filtered view, a guardlink command, or both. Nothing here is a bare number
Every item points somewhere: a hash route into the filtered view, a guardlink command, or both. Nothing here is a bare number
388 │ * exposures, then claims whose code moved (stale), then a first verify when no 389 │ * ledger exists, then AI-introduced open exposures, then governance items, 390 │ * then repository coverage. A slice withholds the project-wide measures 391 │ * (coverage, first-verify) that only mean something for the whole repository. 392 │ * 393 │ * @comment -- "Every item points somewhere: a hash route into the filtered view, a guardlink command, or both. Nothing here is a bare number" 394 │ */ 395 │ export function computeActions(input: ActionInput): DashboardAction[] { 396 │ const { model, exposures, confirmed, verification, attribution, scope } = input; 397 │ const out: DashboardAction[] = []; 398 │
L547 flow LedgerFile → #dashboard
Claim states for the badges and the stale-claims action
542 │ * record, so identity is the join. The map is empty when no ledger exists 543 │ * (`report.ledger === 'absent'`), so a dashboard on a never-verified 544 │ * repository renders no badges rather than a wall of "unverified"; the report 545 │ * still carries the unverified count the actions list needs. 546 │ * 547 │ * @flows LedgerFile -> #dashboard via readLedger -- "Claim states for the badges and the stale-claims action" 548 │ * @comment -- "Reads .guardlink/verified.json once; the classification itself is the parser's, not re-derived here" 549 │ */ 550 │ export interface LedgerStates { 551 │ report: VerificationReport; 552 │ byLocation: Map<object, ClaimState>;
L548 comment Reads .guardlink/verified.json once; the classification itself is the parser's, not re-derived here
Reads .guardlink/verified.json once; the classification itself is the parser's, not re-derived here
543 │ * (`report.ledger === 'absent'`), so a dashboard on a never-verified 544 │ * repository renders no badges rather than a wall of "unverified"; the report 545 │ * still carries the unverified count the actions list needs. 546 │ * 547 │ * @flows LedgerFile -> #dashboard via readLedger -- "Claim states for the badges and the stale-claims action" 548 │ * @comment -- "Reads .guardlink/verified.json once; the classification itself is the parser's, not re-derived here" 549 │ */ 550 │ export interface LedgerStates { 551 │ report: VerificationReport; 552 │ byLocation: Map<object, ClaimState>; 553 │ /** Who locked the claim and when, for claims the ledger holds. */
L15 flow ThreatModel → #dashboard
Threat model relationships rendered as Mermaid source
10 │ * All three generators share a single alias map so that #id, bare id, name, and 11 │ * path.join() forms of an asset/threat/control collapse onto the same node. 12 │ * This removes the long-standing duplicate-node bug that made the Mermaid 13 │ * diagrams render the same asset twice whenever sources mixed ref forms. 14 │ * 15 │ * @flows ThreatModel -> #dashboard via generateThreatGraph -- "Threat model relationships rendered as Mermaid source" 16 │ * @mitigates #dashboard against #xss using #output-encoding -- "Diagram labels are sanitized before emission as Mermaid source" 17 │ * @comment -- "Alias map collapses #id / name / path-joined ref forms so all three diagrams agree on identity" 18 │ */ 19 │ 20 │ import type { ThreatModel } from '../types/index.js';
L16 mitigates #output-encoding mitigates #xss
Diagram labels are sanitized before emission as Mermaid source
11 │ * path.join() forms of an asset/threat/control collapse onto the same node. 12 │ * This removes the long-standing duplicate-node bug that made the Mermaid 13 │ * diagrams render the same asset twice whenever sources mixed ref forms. 14 │ * 15 │ * @flows ThreatModel -> #dashboard via generateThreatGraph -- "Threat model relationships rendered as Mermaid source" 16 │ * @mitigates #dashboard against #xss using #output-encoding -- "Diagram labels are sanitized before emission as Mermaid source" 17 │ * @comment -- "Alias map collapses #id / name / path-joined ref forms so all three diagrams agree on identity" 18 │ */ 19 │ 20 │ import type { ThreatModel } from '../types/index.js'; 21 │ import { buildCoverageIndex } from '../parser/coverage.js';
L17 comment Alias map collapses #id / name / path-joined ref forms so all three diagrams agree on identity
Alias map collapses #id / name / path-joined ref forms so all three diagrams agree on identity
12 │ * This removes the long-standing duplicate-node bug that made the Mermaid 13 │ * diagrams render the same asset twice whenever sources mixed ref forms. 14 │ * 15 │ * @flows ThreatModel -> #dashboard via generateThreatGraph -- "Threat model relationships rendered as Mermaid source" 16 │ * @mitigates #dashboard against #xss using #output-encoding -- "Diagram labels are sanitized before emission as Mermaid source" 17 │ * @comment -- "Alias map collapses #id / name / path-joined ref forms so all three diagrams agree on identity" 18 │ */ 19 │ 20 │ import type { ThreatModel } from '../types/index.js'; 21 │ import { buildCoverageIndex } from '../parser/coverage.js'; 22 │ import type { ReachSummary } from '../reach/index.js';
L26 flow GitRepo → #dashboard
The model at --since <ref>, diffed against the one rendered
21 │ * model's array order and drops `generated_at`: `docs/examples/threat-dashboard.html` 22 │ * is committed, and a volatile field would churn it on every regeneration. 23 │ * Everything else the page reads is content-derived or, for attribution, 24 │ * fixed per HEAD (the as-of date is the HEAD commit's). 25 │ * 26 │ * @flows GitRepo -> #dashboard via loadSince -- "The model at --since <ref>, diffed against the one rendered" 27 │ * 28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)"
L28 exposes #dashboard → #xss
generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants
23 │ * Everything else the page reads is content-derived or, for attribution, 24 │ * fixed per HEAD (the as-of date is the HEAD commit's). 25 │ * 26 │ * @flows GitRepo -> #dashboard via loadSince -- "The model at --since <ref>, diffed against the one rendered" 27 │ * 28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads"
L29 mitigates #output-encoding mitigates #xss
esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it
24 │ * fixed per HEAD (the as-of date is the HEAD commit's). 25 │ * 26 │ * @flows GitRepo -> #dashboard via loadSince -- "The model at --since <ref>, diffed against the one rendered" 27 │ * 28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action"
L30 exposes #dashboard → #path-traversal
readFileSync reads code files for annotation context
25 │ * 26 │ * @flows GitRepo -> #dashboard via loadSince -- "The model at --since <ref>, diffed against the one rendered" 27 │ * 28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links"
L31 mitigates #path-validation mitigates #path-traversal
resolve() with root constrains file access (annotations.ts)
26 │ * @flows GitRepo -> #dashboard via loadSince -- "The model at --since <ref>, diffed against the one rendered" 27 │ * 28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output"
L32 flow ThreatModel → #dashboard
Model statistics input
27 │ * 28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data"
L33 flow SourceFiles → #dashboard
Code snippet reads
28 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "generateDashboardHTML() interpolates model descriptions, asset names, git identities, commit trailers and report prose into the page markup, the SVG diagrams and the embedded JSON constants" 29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode"
L34 flow LedgerFile → #dashboard
Claim states for badges and the stale-claims action
29 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value in every page module, the layout modules escape every SVG label, report prose is escaped before markup is added back, and serialized data escapes every < before embedding in a script element, so no tag-like text survives in it" 30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard"
L35 flow GitConfig → #dashboard
The remote's web host, for file and commit links
30 │ * @exposes #dashboard to #path-traversal [medium] cwe:CWE-22 -- "readFileSync reads code files for annotation context" 31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()"
L36 flow #dashboard → HTML
Generated HTML output
31 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "resolve() with root constrains file access (annotations.ts)" 32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()" 41 │ * @comment -- "A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer"
L37 handles #dashboard: internal
Processes and displays threat model data
32 │ * @flows ThreatModel -> #dashboard via computeStats -- "Model statistics input" 33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()" 41 │ * @comment -- "A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer" 42 │ * @comment -- "No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin"
L38 handles #dashboard: pii
Author identities from attribution, in the configured identity mode
33 │ * @flows SourceFiles -> #dashboard via readFileSync -- "Code snippet reads" 34 │ * @flows LedgerFile -> #dashboard via computeLedgerStates -- "Claim states for badges and the stale-claims action" 35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()" 41 │ * @comment -- "A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer" 42 │ * @comment -- "No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin" 43 │ */
L40 mitigates #output-encoding mitigates #xss
Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()
35 │ * @flows GitConfig -> #dashboard via detectRepoLinks -- "The remote's web host, for file and commit links" 36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()" 41 │ * @comment -- "A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer" 42 │ * @comment -- "No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin" 43 │ */ 44 │ import type { ThreatModel } from '../types/index.js'; 45 │ import { listFeatures, filterByFeature } from '../parser/feature-filter.js';
L41 comment A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer
A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer
36 │ * @flows #dashboard -> HTML via return -- "Generated HTML output" 37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()" 41 │ * @comment -- "A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer" 42 │ * @comment -- "No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin" 43 │ */ 44 │ import type { ThreatModel } from '../types/index.js'; 45 │ import { listFeatures, filterByFeature } from '../parser/feature-filter.js'; 46 │ import { canonicaliser } from '../mcp/subgraph.js';
L42 comment No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin
No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin
37 │ * @handles internal on #dashboard -- "Processes and displays threat model data" 38 │ * @handles pii on #dashboard -- "Author identities from attribution, in the configured identity mode" 39 │ * @feature "Dashboard" -- "Interactive HTML threat model dashboard" 40 │ * @mitigates #dashboard against #xss using #output-encoding -- "Feature scope names come from @feature annotations and the --feature flag; every one is rendered through esc()" 41 │ * @comment -- "A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer" 42 │ * @comment -- "No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin" 43 │ */ 44 │ import type { ThreatModel } from '../types/index.js'; 45 │ import { listFeatures, filterByFeature } from '../parser/feature-filter.js'; 46 │ import { canonicaliser } from '../mcp/subgraph.js'; 47 │ import { canonicalizeModelOrder } from '../parser/canonical-order.js';
L108 flow LedgerFile → #dashboard
Outcomes for the badges, the drawer and the open count
103 │ } 104 │ 105 │ export function generateDashboardHTML(rawModel: ThreatModel, root?: string, analyses?: ThreatReportWithContent[], opts: DashboardOptions = {}): string { 106 │ const model = canonicalizeModelOrder(rawModel); 107 │ // The tested state of every exposure, when a ledger exists: a refuted claim is not open. 108 │ // @flows LedgerFile -> #dashboard via readHypotheses -- "Outcomes for the badges, the drawer and the open count" 109 │ const hypothesesRead = root ? readHypotheses(root) : null; 110 │ const hypotheses = hypothesesRead ? classifyHypotheses(model, hypothesesRead) : null; 111 │ if (hypotheses) attachHypotheses(model, hypotheses); 112 │ // Read from rawModel: canonicalisation reorders, it does not add fields. 113 │ const scope = featureScope(rawModel);
L16 mitigates #output-encoding mitigates #xss
esc() HTML-encodes every interpolated value; hrefs built here are encoded too, so a hash route carrying a search term cannot break out of the attribute
11 │ * 12 │ * Every value that came from the model, from git, or from a file name passes 13 │ * through `esc()` here or in the page that calls these. The one exception is 14 │ * markup these helpers build themselves. 15 │ * 16 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value; hrefs built here are encoded too, so a hash route carrying a search term cannot break out of the attribute" 17 │ * @comment -- "statCard keeps its exact markup: the feature filter rewrites the inventory tiles by label, and a test greps the Open Threats tile by that string" 18 │ */ 19 │ import type { RepoLinks } from './links.js'; 20 │ import type { ThreatModel } from '../types/index.js'; 21 │ import type { ClaimState } from '../parser/verification.js';
L17 comment statCard keeps its exact markup: the feature filter rewrites the inventory tiles by label, and a test greps the Open Threats tile by that string
statCard keeps its exact markup: the feature filter rewrites the inventory tiles by label, and a test greps the Open Threats tile by that string
12 │ * Every value that came from the model, from git, or from a file name passes 13 │ * through `esc()` here or in the page that calls these. The one exception is 14 │ * markup these helpers build themselves. 15 │ * 16 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() HTML-encodes every interpolated value; hrefs built here are encoded too, so a hash route carrying a search term cannot break out of the attribute" 17 │ * @comment -- "statCard keeps its exact markup: the feature filter rewrites the inventory tiles by label, and a test greps the Open Threats tile by that string" 18 │ */ 19 │ import type { RepoLinks } from './links.js'; 20 │ import type { ThreatModel } from '../types/index.js'; 21 │ import type { ClaimState } from '../parser/verification.js'; 22 │
L4 exposes #dashboard → #xss
Generates HTML with threat model data
1 │ /** 2 │ * GuardLink Dashboard — Self-contained HTML threat model dashboard. 3 │ * 4 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "Generates HTML with threat model data" 5 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() function encodes all interpolated values" 6 │ * @flows ThreatModel -> #dashboard via generateDashboardHTML -- "Model to HTML transformation" 7 │ * @comment -- "Self-contained HTML; no external data injection after generation" 8 │ */ 9 │
L5 mitigates #output-encoding mitigates #xss
esc() function encodes all interpolated values
1 │ /** 2 │ * GuardLink Dashboard — Self-contained HTML threat model dashboard. 3 │ * 4 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "Generates HTML with threat model data" 5 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() function encodes all interpolated values" 6 │ * @flows ThreatModel -> #dashboard via generateDashboardHTML -- "Model to HTML transformation" 7 │ * @comment -- "Self-contained HTML; no external data injection after generation" 8 │ */ 9 │ 10 │ export { generateDashboardHTML } from './generate.js';
L6 flow ThreatModel → #dashboard
Model to HTML transformation
1 │ /** 2 │ * GuardLink Dashboard — Self-contained HTML threat model dashboard. 3 │ * 4 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "Generates HTML with threat model data" 5 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() function encodes all interpolated values" 6 │ * @flows ThreatModel -> #dashboard via generateDashboardHTML -- "Model to HTML transformation" 7 │ * @comment -- "Self-contained HTML; no external data injection after generation" 8 │ */ 9 │ 10 │ export { generateDashboardHTML } from './generate.js'; 11 │ export type { DashboardOptions } from './generate.js';
L7 comment Self-contained HTML; no external data injection after generation
Self-contained HTML; no external data injection after generation
2 │ * GuardLink Dashboard — Self-contained HTML threat model dashboard. 3 │ * 4 │ * @exposes #dashboard to #xss [high] cwe:CWE-79 -- "Generates HTML with threat model data" 5 │ * @mitigates #dashboard against #xss using #output-encoding -- "esc() function encodes all interpolated values" 6 │ * @flows ThreatModel -> #dashboard via generateDashboardHTML -- "Model to HTML transformation" 7 │ * @comment -- "Self-contained HTML; no external data injection after generation" 8 │ */ 9 │ 10 │ export { generateDashboardHTML } from './generate.js'; 11 │ export type { DashboardOptions } from './generate.js'; 12 │ export { loadSince } from './since.js';
L15 comment Pure: no I/O, no DOM. Every string here is raw model text; the renderers escape it at the point they emit markup
Pure: no I/O, no DOM. Every string here is raw model text; the renderers escape it at the point they emit markup
10 │ * 11 │ * A flow endpoint that is not a declared asset is an EXTERNAL node: outside the 12 │ * model. It is drawn dashed, and when a `@boundary` names it, it sits in the 13 │ * "Across a trust line" zone. 14 │ * 15 │ * @comment -- "Pure: no I/O, no DOM. Every string here is raw model text; the renderers escape it at the point they emit markup" 16 │ */ 17 │ import type { ThreatModel } from '../../types/index.js'; 18 │ import { canonicaliser } from '../../parser/canonical-ref.js'; 19 │ import type { ClaimView } from '../pages/context.js'; 20 │
L21 mitigates #output-encoding mitigates #xss
renderHoodSvg and renderNodeDetail escape every label, mechanism, path and description with their own esc(); keys in hrefs go through encodeURIComponent
16 │ * source verbatim (`fn.toString()`) and the browser draws a walk with the 17 │ * very function the generator used for the first frame and the tests use. 18 │ * One implementation, and nothing in it measures the DOM, so a hidden or 19 │ * zero-size panel draws exactly as a visible one. 20 │ * 21 │ * @mitigates #dashboard against #xss using #output-encoding -- "renderHoodSvg and renderNodeDetail escape every label, mechanism, path and description with their own esc(); keys in hrefs go through encodeURIComponent" 22 │ */ 23 │ import type { DiagramModel, GNode } from './graph.js'; 24 │ import { SEV_RANK, isOpenState } from './graph.js'; 25 │ 26 │ export const HOOD_BUDGET = 7;
L17 mitigates #output-encoding mitigates #xss
renderMatrix escapes every asset, group and threat label with its own esc()
12 │ * `renderMatrix` is SELF-CONTAINED — no imports, no outer references — so the 13 │ * page embeds its source and the filter chips and compact toggle re-render with 14 │ * the same function the generator used for the first frame. Geometry comes from 15 │ * the counts alone. 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "renderMatrix escapes every asset, group and threat label with its own esc()" 18 │ */ 19 │ import type { DiagramModel } from './graph.js'; 20 │ 21 │ const SEV_INDEX = ['critical', 'high', 'medium', 'low', 'unset'] as const; 22 │ const STATE_INDEX = ['open', 'confirmed', 'mitigated', 'refuted', 'accepted'] as const;
L17 mitigates #output-encoding mitigates #xss
Actor, capability, asset, approver and identity names reach the SVG only through xml() and attrs()
12 │ * ✕ in warm when nothing does; 13 │ * - effects are rows in their asset's slab: warm "no gate" for an ungated 14 │ * mutation, mint "gated ⊢ approver" with a bar where a human decides 15 │ * first, hollow "reads only". 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "Actor, capability, asset, approver and identity names reach the SVG only through xml() and attrs()" 18 │ */ 19 │ import type { ReachSummary, ReachEffectChip } from '../../reach/index.js'; 20 │ import { layoutTripartite, sCurve, type TriItem, type TriLink } from './tripartite.js'; 21 │ import { attrs, fitLeft, r1, tip, xml, dotPattern, textWidth } from './text.js'; 22 │
L20 mitigates #output-encoding mitigates #xss
Node labels, mechanisms, descriptions and boundary labels reach the SVG only through xml() and attrs()
15 │ * 16 │ * Nothing is dropped for size. A name that would sit closer than 11 px to 17 │ * another is folded — the tick stays, with its name on hover — and the footer 18 │ * says how many were. 19 │ * 20 │ * @mitigates #dashboard against #xss using #output-encoding -- "Node labels, mechanisms, descriptions and boundary labels reach the SVG only through xml() and attrs()" 21 │ */ 22 │ import type { DiagramModel, GFlow, GNode } from './graph.js'; 23 │ import { markOf, markLabel } from './graph.js'; 24 │ import { placeNames } from './tripartite.js'; 25 │ import { attrs, fitLeft, r1, tip, xml, dotPattern, textWidth } from './text.js';
L15 mitigates #output-encoding mitigates #xss
Asset labels, threat labels, paths and classifications are escaped through xml() and attrs()
10 │ * 11 │ * Nothing here can overflow: rows wrap, so 18 assets and 1,800 draw the same 12 │ * way. Wrapping is the browser's flex layout over widths computed from data; 13 │ * nothing is measured. 14 │ * 15 │ * @mitigates #dashboard against #xss using #output-encoding -- "Asset labels, threat labels, paths and classifications are escaped through xml() and attrs()" 16 │ */ 17 │ import type { DiagramModel, GExposure, GNode } from './graph.js'; 18 │ import { SEV_RANK, isOpenState, markLabel } from './graph.js'; 19 │ import { attrs, tip, xml, plural } from './text.js'; 20 │
L35 mitigates #output-encoding mitigates #xss
Node labels, mechanisms, descriptions and boundary text reach the SVG only through xml() and attrs()
30 │ * Element kinds (process, data store, external entity) are not declared in the 31 │ * model. They are inferred here, in one place, and every tooltip says so. 32 │ * 33 │ * Laid out from data and estimated glyph widths; nothing is measured. 34 │ * 35 │ * @mitigates #dashboard against #xss using #output-encoding -- "Node labels, mechanisms, descriptions and boundary text reach the SVG only through xml() and attrs()" 36 │ */ 37 │ import type { DiagramModel, GBoundary, GFlow, GNode } from './graph.js'; 38 │ import { attrs, cutLeft, dotPattern, plural, r1, textWidth, tip, xml } from './text.js'; 39 │ 40 │ export type ElementKind = 'process' | 'store' | 'entity';
L10 mitigates #output-encoding mitigates #xss
xml() encodes &, <, > and both quotes for every text node and attribute the diagram modules emit
5 │ * Every diagram is laid out from data and ESTIMATED glyph widths, never by 6 │ * measuring the DOM. A panel that is hidden, zero-sized or not yet attached — 7 │ * a background editor tab, a collapsed tab, a page printed to PDF — lays out 8 │ * exactly as a visible one does, so no coordinate can come out NaN. 9 │ * 10 │ * @mitigates #dashboard against #xss using #output-encoding -- "xml() encodes &, <, > and both quotes for every text node and attribute the diagram modules emit" 11 │ * @comment -- "Pure string helpers; nothing here reads a document" 12 │ */ 13 │ 14 │ /** Average advance per character, as a fraction of the font size. Conservative: names are placed with room to spare. */ 15 │ const MONO_ADVANCE = 0.62;
L11 comment Pure string helpers; nothing here reads a document
Pure string helpers; nothing here reads a document
6 │ * measuring the DOM. A panel that is hidden, zero-sized or not yet attached — 7 │ * a background editor tab, a collapsed tab, a page printed to PDF — lays out 8 │ * exactly as a visible one does, so no coordinate can come out NaN. 9 │ * 10 │ * @mitigates #dashboard against #xss using #output-encoding -- "xml() encodes &, <, > and both quotes for every text node and attribute the diagram modules emit" 11 │ * @comment -- "Pure string helpers; nothing here reads a document" 12 │ */ 13 │ 14 │ /** Average advance per character, as a fraction of the font size. Conservative: names are placed with room to spare. */ 15 │ const MONO_ADVANCE = 0.62; 16 │ const SANS_ADVANCE = 0.56;
L13 mitigates #output-encoding mitigates #xss
Asset, threat and control labels and descriptions reach the SVG only through xml() and attrs()
8 │ * 9 │ * Laid out here, at generation time, and emitted as an SVG string: the drawing 10 │ * exists before any script runs and cannot be laid out wrong by a hidden panel. 11 │ * The client only adds hover (it toggles classes; CSS dims) and pin. 12 │ * 13 │ * @mitigates #dashboard against #xss using #output-encoding -- "Asset, threat and control labels and descriptions reach the SVG only through xml() and attrs()" 14 │ */ 15 │ import type { DiagramModel, GExposure, Sev } from './graph.js'; 16 │ import { SEV_RANK, isOpenState, markOf, markLabel } from './graph.js'; 17 │ import { layoutTripartite, sCurve, placeNames, type TriItem, type TriLink } from './tripartite.js'; 18 │ import { attrs, fitLeft, r1, tip, xml, dotPattern } from './text.js';
L21 comment Pure geometry over keys; no DOM, no measurement. The callers own every label and every colour
Pure geometry over keys; no DOM, no measurement. The callers own every label and every colour
16 │ * - a slot is as tall as the threads it carries (`pitch` each), never less 17 │ * than its column's minimum; 18 │ * - thread ends inside a slot are sorted by the partner's position, so 19 │ * threads leaving one slot never cross each other there. 20 │ * 21 │ * @comment -- "Pure geometry over keys; no DOM, no measurement. The callers own every label and every colour" 22 │ */ 23 │ 24 │ export interface TriItem { 25 │ key: string; 26 │ col: 0 | 1 | 2;
L10 exposes #dashboard → #path-traversal
detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path
5 │ * file (and line) at `HEAD` of the default branch, and a commit. `HEAD` rather 6 │ * than a pinned SHA keeps a committed dashboard from churning on every 7 │ * regeneration. Detection reads `.git/config` directly — no `git` subprocess, 8 │ * no network — mirroring `readGitSha` in `src/workspace/metadata.ts`. 9 │ * 10 │ * @exposes #dashboard to #path-traversal [low] cwe:CWE-22 -- "detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path" 11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard"
L11 mitigates #path-validation mitigates #path-traversal
Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root
6 │ * than a pinned SHA keeps a committed dashboard from churning on every 7 │ * regeneration. Detection reads `.git/config` directly — no `git` subprocess, 8 │ * no network — mirroring `readGitSha` in `src/workspace/metadata.ts`. 9 │ * 10 │ * @exposes #dashboard to #path-traversal [low] cwe:CWE-22 -- "detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path" 11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard" 16 │ * @comment -- "Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort."
L12 exposes #dashboard → #data-exposure
detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML
7 │ * regeneration. Detection reads `.git/config` directly — no `git` subprocess, 8 │ * no network — mirroring `readGitSha` in `src/workspace/metadata.ts`. 9 │ * 10 │ * @exposes #dashboard to #path-traversal [low] cwe:CWE-22 -- "detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path" 11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard" 16 │ * @comment -- "Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort." 17 │ */
L13 mitigates #output-encoding mitigates #data-exposure
linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web
8 │ * no network — mirroring `readGitSha` in `src/workspace/metadata.ts`. 9 │ * 10 │ * @exposes #dashboard to #path-traversal [low] cwe:CWE-22 -- "detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path" 11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard" 16 │ * @comment -- "Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort." 17 │ */ 18 │
L14 flow GitConfig → #dashboard
remote.origin.url read from .git/config (or the worktree's gitdir config)
9 │ * 10 │ * @exposes #dashboard to #path-traversal [low] cwe:CWE-22 -- "detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path" 11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard" 16 │ * @comment -- "Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort." 17 │ */ 18 │ 19 │ import { readFileSync } from 'node:fs';
L15 flow #dashboard → DashboardHTML
Web, file and commit links embedded in the generated dashboard
10 │ * @exposes #dashboard to #path-traversal [low] cwe:CWE-22 -- "detectRepoLinks reads <root>/.git/config and, when .git is a worktree file, follows its `gitdir:` pointer to a config elsewhere on disk; a crafted .git file can name any readable path" 11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard" 16 │ * @comment -- "Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort." 17 │ */ 18 │ 19 │ import { readFileSync } from 'node:fs'; 20 │ import { isAbsolute, join } from 'node:path';
L16 comment Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort.
Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort.
11 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Only the fixed `config` name is read under the pointed-to gitdir, only `remote.origin.url` is extracted from it, and the result is discarded unless it parses as an http(s)/ssh remote to a web host; file links reject `..` segments and absolute paths and fall back to the repo web root" 12 │ * @exposes #dashboard to #data-exposure [low] cwe:CWE-200 -- "detectRepoLinks() reads remote.origin.url from .git/config, which may embed user:token@host credentials that would land in the committed HTML" 13 │ * @mitigates #dashboard against #data-exposure using #output-encoding -- "linksFromRemote rebuilds every link from hostname and path only; userinfo is dropped before anything is emitted, so credentials never reach RepoLinks.web" 14 │ * @flows GitConfig -> #dashboard via readFileSync -- "remote.origin.url read from .git/config (or the worktree's gitdir config)" 15 │ * @flows #dashboard -> DashboardHTML via RepoLinks -- "Web, file and commit links embedded in the generated dashboard" 16 │ * @comment -- "Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort." 17 │ */ 18 │ 19 │ import { readFileSync } from 'node:fs'; 20 │ import { isAbsolute, join } from 'node:path'; 21 │
L38 comment Accepts scp-style (git@host:org/repo.git), ssh://, http:// and https:// remotes. Strips `.git` and trailing slashes from the path.
Accepts scp-style (git@host:org/repo.git), ssh://, http:// and https:// remotes. Strips `.git` and trailing slashes from the path.
33 │ 34 │ /** 35 │ * Parse a git remote URL into RepoLinks; null when it is not http(s)/ssh to a 36 │ * recognisable host. 37 │ * 38 │ * @comment -- "Accepts scp-style (git@host:org/repo.git), ssh://, http:// and https:// remotes. Strips `.git` and trailing slashes from the path." 39 │ */ 40 │ export function linksFromRemote(url: string): RepoLinks | null { 41 │ const parsed = parseRemote(url.trim()); 42 │ if (!parsed) return null; 43 │
L84 mitigates #path-validation mitigates #path-traversal
Absolute paths and any `..` segment (either separator) are refused; each surviving segment is encodeURIComponent-ed so `#`, `?` and spaces cannot rewrite the URL
79 │ * Encode a repo-relative path one segment at a time. Null when the path could 80 │ * escape the repo — absolute, or containing a `..` segment — or when nothing is 81 │ * left after dropping `.` and empty segments. Callers fall back to the web root 82 │ * on null: a link must never point outside the repository. 83 │ * 84 │ * @mitigates #dashboard against #path-traversal using #path-validation -- "Absolute paths and any `..` segment (either separator) are refused; each surviving segment is encodeURIComponent-ed so `#`, `?` and spaces cannot rewrite the URL" 85 │ */ 86 │ function safeFilePath(path: string): string | null { 87 │ if (path.startsWith('/') || path.startsWith('\\')) return null; 88 │ const segments = path.split(/[\/\\]/).filter((s) => s !== '' && s !== '.'); 89 │ if (segments.length === 0 || segments.includes('..')) return null;
L118 comment Best effort and side-effect free: every failure mode (no .git, junk pointer, dangling gitdir, config without origin, local-path origin) collapses to null rather than throwing, so dashboard generation never depends on this succeeding
Best effort and side-effect free: every failure mode (no .git, junk pointer, dangling gitdir, config without origin, local-path origin) collapses to null rather than throwing, so dashboard generation never depends on this succeeding
113 │ * When `.git` is a file (worktree or submodule checkout) it holds a single 114 │ * `gitdir: <path>` line; the config lives under that directory instead. The 115 │ * pointer is followed as-is — relative paths resolve against `root` — because 116 │ * git itself wrote it, and only the fixed `config` filename is read from it. 117 │ * 118 │ * @comment -- "Best effort and side-effect free: every failure mode (no .git, junk pointer, dangling gitdir, config without origin, local-path origin) collapses to null rather than throwing, so dashboard generation never depends on this succeeding" 119 │ */ 120 │ export function detectRepoLinks(root: string): RepoLinks | null { 121 │ const origin = readOriginUrl(root); 122 │ return origin === null ? null : linksFromRemote(origin); 123 │ }
L17 mitigates #output-encoding mitigates #xss
renderMarkdown escapes the whole report before adding markup back; link targets are allow-listed by scheme
12 │ * fenced code blocks (including ```mermaid, shown as source), block quotes, 13 │ * ordered and unordered lists with nesting by indentation, pipe tables, 14 │ * horizontal rules and links. That is what the report prompts produce; the 15 │ * page does not need a general CommonMark engine, and does not load one. 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "renderMarkdown escapes the whole report before adding markup back; link targets are allow-listed by scheme" 18 │ * @flows SavedReport -> #dashboard via renderMarkdown -- "Threat-report prose rendered to HTML at generation time" 19 │ * @comment -- "Replaces the CDN copy of marked: the page makes no network request to render a report" 20 │ */ 21 │ 22 │ const escHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
L18 flow SavedReport → #dashboard
Threat-report prose rendered to HTML at generation time
13 │ * ordered and unordered lists with nesting by indentation, pipe tables, 14 │ * horizontal rules and links. That is what the report prompts produce; the 15 │ * page does not need a general CommonMark engine, and does not load one. 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "renderMarkdown escapes the whole report before adding markup back; link targets are allow-listed by scheme" 18 │ * @flows SavedReport -> #dashboard via renderMarkdown -- "Threat-report prose rendered to HTML at generation time" 19 │ * @comment -- "Replaces the CDN copy of marked: the page makes no network request to render a report" 20 │ */ 21 │ 22 │ const escHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 23 │
L19 comment Replaces the CDN copy of marked: the page makes no network request to render a report
Replaces the CDN copy of marked: the page makes no network request to render a report
14 │ * horizontal rules and links. That is what the report prompts produce; the 15 │ * page does not need a general CommonMark engine, and does not load one. 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "renderMarkdown escapes the whole report before adding markup back; link targets are allow-listed by scheme" 18 │ * @flows SavedReport -> #dashboard via renderMarkdown -- "Threat-report prose rendered to HTML at generation time" 19 │ * @comment -- "Replaces the CDN copy of marked: the page makes no network request to render a report" 20 │ */ 21 │ 22 │ const escHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 23 │ 24 │ function safeHref(raw: string): string | null {
L13 mitigates #output-encoding mitigates #xss
Actor, asset and capability names, descriptions and citations are model text; every one goes through esc(), including chip titles
8 │ * not cover each), the ungated mutations, the gates, egress from a reach, the 9 │ * injection-to-tool routes and the OWASP LLM Top 10 rows. Every number comes 10 │ * from `summarizeReach`, which the report's "Agents and LLM Reach" section 11 │ * prints too. 12 │ * 13 │ * @mitigates #dashboard against #xss using #output-encoding -- "Actor, asset and capability names, descriptions and citations are model text; every one goes through esc(), including chip titles" 14 │ * @flows ThreatModel -> #dashboard via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the Agents page" 15 │ * @comment -- "Chips carry state the design way: a capability is an outlined chip with ✓ (entitled) or ✕ (unentitled), a gated effect carries ⊢, an ungated mutation is a filled warm chip, a read is a plain tag" 16 │ */ 17 │ import { esc, scopeLabel, pageHead, subHead, sortableHead, rowAttrs, locCellShort, descCell, pager, plural, statTile } from '../html.js'; 18 │ import type { ReachSummary, ReachEffectChip, ReachCapabilityChip, ReachLoc } from '../../reach/index.js';
L14 flow ThreatModel → #dashboard
Reaches, effects, gates, entitlements and flows for the Agents page
9 │ * injection-to-tool routes and the OWASP LLM Top 10 rows. Every number comes 10 │ * from `summarizeReach`, which the report's "Agents and LLM Reach" section 11 │ * prints too. 12 │ * 13 │ * @mitigates #dashboard against #xss using #output-encoding -- "Actor, asset and capability names, descriptions and citations are model text; every one goes through esc(), including chip titles" 14 │ * @flows ThreatModel -> #dashboard via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the Agents page" 15 │ * @comment -- "Chips carry state the design way: a capability is an outlined chip with ✓ (entitled) or ✕ (unentitled), a gated effect carries ⊢, an ungated mutation is a filled warm chip, a read is a plain tag" 16 │ */ 17 │ import { esc, scopeLabel, pageHead, subHead, sortableHead, rowAttrs, locCellShort, descCell, pager, plural, statTile } from '../html.js'; 18 │ import type { ReachSummary, ReachEffectChip, ReachCapabilityChip, ReachLoc } from '../../reach/index.js'; 19 │ import type { PageContext } from './context.js';
L15 comment Chips carry state the design way: a capability is an outlined chip with ✓ (entitled) or ✕ (unentitled), a gated effect carries ⊢, an ungated mutation is a filled warm chip, a read is a plain tag
Chips carry state the design way: a capability is an outlined chip with ✓ (entitled) or ✕ (unentitled), a gated effect carries ⊢, an ungated mutation is a filled warm chip, a read is a plain tag
10 │ * from `summarizeReach`, which the report's "Agents and LLM Reach" section 11 │ * prints too. 12 │ * 13 │ * @mitigates #dashboard against #xss using #output-encoding -- "Actor, asset and capability names, descriptions and citations are model text; every one goes through esc(), including chip titles" 14 │ * @flows ThreatModel -> #dashboard via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the Agents page" 15 │ * @comment -- "Chips carry state the design way: a capability is an outlined chip with ✓ (entitled) or ✕ (unentitled), a gated effect carries ⊢, an ungated mutation is a filled warm chip, a read is a plain tag" 16 │ */ 17 │ import { esc, scopeLabel, pageHead, subHead, sortableHead, rowAttrs, locCellShort, descCell, pager, plural, statTile } from '../html.js'; 18 │ import type { ReachSummary, ReachEffectChip, ReachCapabilityChip, ReachLoc } from '../../reach/index.js'; 19 │ import type { PageContext } from './context.js'; 20 │ import { actorTables } from './tables.js';
L11 mitigates #output-encoding mitigates #xss
Asset names, paths, threat labels and classifications are escaped
6 │ * exposure; controls; flows in and out; trust lines; data classes. Headers 7 │ * sort — by risk, by exposures, by name. A row expands to its exposures, with 8 │ * the way onto the threat graph and into its data flow. The lifecycle records 9 │ * (validations, ownership, audits, assumptions) follow as tables. 10 │ * 11 │ * @mitigates #dashboard against #xss using #output-encoding -- "Asset names, paths, threat labels and classifications are escaped" 12 │ * @comment -- "Rows keep data-ff-asset (every alias of the asset, |-separated) so the feature filter can hide what a feature never touches" 13 │ */ 14 │ import { esc, pageHead, sevBadge, stateChip, sortableHead, plural } from '../html.js'; 15 │ import type { PageContext } from './context.js'; 16 │ import type { DiagramModel, GNode } from '../layout/graph.js';
L12 comment Rows keep data-ff-asset (every alias of the asset, |-separated) so the feature filter can hide what a feature never touches
Rows keep data-ff-asset (every alias of the asset, |-separated) so the feature filter can hide what a feature never touches
7 │ * sort — by risk, by exposures, by name. A row expands to its exposures, with 8 │ * the way onto the threat graph and into its data flow. The lifecycle records 9 │ * (validations, ownership, audits, assumptions) follow as tables. 10 │ * 11 │ * @mitigates #dashboard against #xss using #output-encoding -- "Asset names, paths, threat labels and classifications are escaped" 12 │ * @comment -- "Rows keep data-ff-asset (every alias of the asset, |-separated) so the feature filter can hide what a feature never touches" 13 │ */ 14 │ import { esc, pageHead, sevBadge, stateChip, sortableHead, plural } from '../html.js'; 15 │ import type { PageContext } from './context.js'; 16 │ import type { DiagramModel, GNode } from '../layout/graph.js'; 17 │ import { SEV_RANK, isOpenState } from '../layout/graph.js';
L9 mitigates #output-encoding mitigates #xss
Identities, model names, shas, paths and statuses all pass through esc(); a co-author trailer is free text in a commit message and is the realistic vector
4 │ * 5 │ * Rendered only when the model went through `attachBlame`. Every number here 6 │ * is explained once in a reading guide next to it, and every identity is a 7 │ * link that narrows the claims table to that person or tool. 8 │ * 9 │ * @mitigates #dashboard against #xss using #output-encoding -- "Identities, model names, shas, paths and statuses all pass through esc(); a co-author trailer is free text in a commit message and is the realistic vector" 10 │ * @handles pii on #dashboard -- "Author identities rendered into a page that is often committed; blame.identity=hash in config.json is the setting for a shared dashboard" 11 │ * @comment -- "No wall clock: ages are measured to the HEAD commit's date, so two generations at the same HEAD are byte-identical" 12 │ */ 13 │ import { esc, kpi, chip, sortableHead, rowAttrs, numCell, sevBadge, sevRank, locCellShort, whoLink, badge, stateChip, sectionHead, subHead, plural, num, pager, colgroup, icon, wholeModelNote } from '../html.js'; 14 │ import type { AttributionData } from '../data.js';
L10 handles #dashboard: pii
Author identities rendered into a page that is often committed; blame.identity=hash in config.json is the setting for a shared dashboard
5 │ * Rendered only when the model went through `attachBlame`. Every number here 6 │ * is explained once in a reading guide next to it, and every identity is a 7 │ * link that narrows the claims table to that person or tool. 8 │ * 9 │ * @mitigates #dashboard against #xss using #output-encoding -- "Identities, model names, shas, paths and statuses all pass through esc(); a co-author trailer is free text in a commit message and is the realistic vector" 10 │ * @handles pii on #dashboard -- "Author identities rendered into a page that is often committed; blame.identity=hash in config.json is the setting for a shared dashboard" 11 │ * @comment -- "No wall clock: ages are measured to the HEAD commit's date, so two generations at the same HEAD are byte-identical" 12 │ */ 13 │ import { esc, kpi, chip, sortableHead, rowAttrs, numCell, sevBadge, sevRank, locCellShort, whoLink, badge, stateChip, sectionHead, subHead, plural, num, pager, colgroup, icon, wholeModelNote } from '../html.js'; 14 │ import type { AttributionData } from '../data.js'; 15 │ import type { PageContext, ClaimView, DrawerRef } from './context.js';
L11 comment No wall clock: ages are measured to the HEAD commit's date, so two generations at the same HEAD are byte-identical
No wall clock: ages are measured to the HEAD commit's date, so two generations at the same HEAD are byte-identical
6 │ * is explained once in a reading guide next to it, and every identity is a 7 │ * link that narrows the claims table to that person or tool. 8 │ * 9 │ * @mitigates #dashboard against #xss using #output-encoding -- "Identities, model names, shas, paths and statuses all pass through esc(); a co-author trailer is free text in a commit message and is the realistic vector" 10 │ * @handles pii on #dashboard -- "Author identities rendered into a page that is often committed; blame.identity=hash in config.json is the setting for a shared dashboard" 11 │ * @comment -- "No wall clock: ages are measured to the HEAD commit's date, so two generations at the same HEAD are byte-identical" 12 │ */ 13 │ import { esc, kpi, chip, sortableHead, rowAttrs, numCell, sevBadge, sevRank, locCellShort, whoLink, badge, stateChip, sectionHead, subHead, plural, num, pager, colgroup, icon, wholeModelNote } from '../html.js'; 14 │ import type { AttributionData } from '../data.js'; 15 │ import type { PageContext, ClaimView, DrawerRef } from './context.js'; 16 │ import type { Cohort, TrendBucket } from '../../blame/types.js';
L11 mitigates #output-encoding mitigates #xss
File paths, summaries, descriptions and code lines are escaped
6 │ * File coverage is a property of the REPOSITORY — annotated files over source 7 │ * files. On a slice the number is either the project's (wrong) or the feature's 8 │ * own files over themselves (100% by construction). Both are withheld and named 9 │ * as withheld, with the command that does answer them. 10 │ * 11 │ * @mitigates #dashboard against #xss using #output-encoding -- "File paths, summaries, descriptions and code lines are escaped" 12 │ * @comment -- "The withheld-on-a-slice sentence and the coverage strings are pinned by tests; file cards gain search text and a host link" 13 │ * @comment -- "No inline handlers: a file header carries data-toggle-file and an annotation data-annotation, and the page's one delegated listener acts on them" 14 │ */ 15 │ import { esc, scopeLabel, pageHead, subHead, copyButton, plural, icon, sevBadge, stateChip, claimStateBadge } from '../html.js'; 16 │ import type { PageContext } from './context.js';
L12 comment The withheld-on-a-slice sentence and the coverage strings are pinned by tests; file cards gain search text and a host link
The withheld-on-a-slice sentence and the coverage strings are pinned by tests; file cards gain search text and a host link
7 │ * files. On a slice the number is either the project's (wrong) or the feature's 8 │ * own files over themselves (100% by construction). Both are withheld and named 9 │ * as withheld, with the command that does answer them. 10 │ * 11 │ * @mitigates #dashboard against #xss using #output-encoding -- "File paths, summaries, descriptions and code lines are escaped" 12 │ * @comment -- "The withheld-on-a-slice sentence and the coverage strings are pinned by tests; file cards gain search text and a host link" 13 │ * @comment -- "No inline handlers: a file header carries data-toggle-file and an annotation data-annotation, and the page's one delegated listener acts on them" 14 │ */ 15 │ import { esc, scopeLabel, pageHead, subHead, copyButton, plural, icon, sevBadge, stateChip, claimStateBadge } from '../html.js'; 16 │ import type { PageContext } from './context.js'; 17 │ import { codeTables } from './tables.js';
L13 comment No inline handlers: a file header carries data-toggle-file and an annotation data-annotation, and the page's one delegated listener acts on them
No inline handlers: a file header carries data-toggle-file and an annotation data-annotation, and the page's one delegated listener acts on them
8 │ * own files over themselves (100% by construction). Both are withheld and named 9 │ * as withheld, with the command that does answer them. 10 │ * 11 │ * @mitigates #dashboard against #xss using #output-encoding -- "File paths, summaries, descriptions and code lines are escaped" 12 │ * @comment -- "The withheld-on-a-slice sentence and the coverage strings are pinned by tests; file cards gain search text and a host link" 13 │ * @comment -- "No inline handlers: a file header carries data-toggle-file and an annotation data-annotation, and the page's one delegated listener acts on them" 14 │ */ 15 │ import { esc, scopeLabel, pageHead, subHead, copyButton, plural, icon, sevBadge, stateChip, claimStateBadge } from '../html.js'; 16 │ import type { PageContext } from './context.js'; 17 │ import { codeTables } from './tables.js'; 18 │ import type { FileRisk } from '../analytics.js';
L11 flow ThreatModel → #dashboard
Records joined with coverage, ledger state and attribution into table rows
6 │ * mitigation: its coverage status, its ledger state (verified / stale / 7 │ * unverified, when a ledger exists), and its attribution (when the model went 8 │ * through `attachBlame`). Every claim gets the URL of its file on the repo 9 │ * host when one is known, so the tables and the drawer never build links. 10 │ * 11 │ * @flows ThreatModel -> #dashboard via buildClaims -- "Records joined with coverage, ledger state and attribution into table rows" 12 │ * @handles pii on #dashboard -- "Author identities from attribution, already in the configured identity mode" 13 │ * @comment -- "Pure; every string here is raw and is escaped by the page that renders it" 14 │ */ 15 │ import type { ReachSummary } from '../../reach/index.js'; 16 │ import type { ExposureHypothesis, ThreatModel } from '../../types/index.js';
L12 handles #dashboard: pii
Author identities from attribution, already in the configured identity mode
7 │ * unverified, when a ledger exists), and its attribution (when the model went 8 │ * through `attachBlame`). Every claim gets the URL of its file on the repo 9 │ * host when one is known, so the tables and the drawer never build links. 10 │ * 11 │ * @flows ThreatModel -> #dashboard via buildClaims -- "Records joined with coverage, ledger state and attribution into table rows" 12 │ * @handles pii on #dashboard -- "Author identities from attribution, already in the configured identity mode" 13 │ * @comment -- "Pure; every string here is raw and is escaped by the page that renders it" 14 │ */ 15 │ import type { ReachSummary } from '../../reach/index.js'; 16 │ import type { ExposureHypothesis, ThreatModel } from '../../types/index.js'; 17 │ import type { ClaimState, VerificationReport } from '../../parser/verification.js';
L13 comment Pure; every string here is raw and is escaped by the page that renders it
Pure; every string here is raw and is escaped by the page that renders it
8 │ * through `attachBlame`). Every claim gets the URL of its file on the repo 9 │ * host when one is known, so the tables and the drawer never build links. 10 │ * 11 │ * @flows ThreatModel -> #dashboard via buildClaims -- "Records joined with coverage, ledger state and attribution into table rows" 12 │ * @handles pii on #dashboard -- "Author identities from attribution, already in the configured identity mode" 13 │ * @comment -- "Pure; every string here is raw and is escaped by the page that renders it" 14 │ */ 15 │ import type { ReachSummary } from '../../reach/index.js'; 16 │ import type { ExposureHypothesis, ThreatModel } from '../../types/index.js'; 17 │ import type { ClaimState, VerificationReport } from '../../parser/verification.js'; 18 │ import { buildCoverageIndex } from '../../parser/coverage.js';
L25 mitigates #output-encoding mitigates #xss
Labels reach the SVG through the layout modules' xml()/attrs(); everything interpolated here goes through esc()
20 │ * classification tables are the data flow's table twin. 21 │ * 22 │ * Mermaid text is still generated for the `.mmd` artifacts and the report; 23 │ * none of it is on this page. 24 │ * 25 │ * @mitigates #dashboard against #xss using #output-encoding -- "Labels reach the SVG through the layout modules' xml()/attrs(); everything interpolated here goes through esc()" 26 │ */ 27 │ import { esc, sectionHead, wholeModelNote, scopeLabel, locInline, stateChip } from '../html.js'; 28 │ import { plural } from '../layout/text.js'; 29 │ import type { PageContext } from './context.js'; 30 │ import type { DiagramModel } from '../layout/graph.js';
L20 mitigates #output-encoding mitigates #xss
Every cell, attribute and identity is escaped; the search text is escaped as an attribute value; the matrix SVG escapes its own labels
15 │ * `data-status`, `data-state`, `data-who`, `data-search`) and open the claim 16 │ * drawer through `data-claim`. Tables are fixed-layout so a long description 17 │ * or path clamps instead of stretching the row; the full text stays in the 18 │ * cell title and the drawer. 19 │ * 20 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every cell, attribute and identity is escaped; the search text is escaped as an attribute value; the matrix SVG escapes its own labels" 21 │ * @handles pii on #dashboard -- "Introducer identities in the people × time grid" 22 │ * @comment -- "Scope wording lives in a note, not in headings: the client feature filter rewrites headings by textContent" 23 │ */ 24 │ import { esc, chip, sortableHead, rowAttrs, sevBadge, sevRank, locCellShort, claimStateBadge, whoLink, badge, scopeLabel, subHead, normSev, descCell, colgroup, pager, pageHead, stateChip, heatTable, barList, plural, routeWithQuery, numCell, shortPath } from '../html.js'; 25 │ import type { PageContext, ClaimView } from './context.js';
L21 handles #dashboard: pii
Introducer identities in the people × time grid
16 │ * drawer through `data-claim`. Tables are fixed-layout so a long description 17 │ * or path clamps instead of stretching the row; the full text stays in the 18 │ * cell title and the drawer. 19 │ * 20 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every cell, attribute and identity is escaped; the search text is escaped as an attribute value; the matrix SVG escapes its own labels" 21 │ * @handles pii on #dashboard -- "Introducer identities in the people × time grid" 22 │ * @comment -- "Scope wording lives in a note, not in headings: the client feature filter rewrites headings by textContent" 23 │ */ 24 │ import { esc, chip, sortableHead, rowAttrs, sevBadge, sevRank, locCellShort, claimStateBadge, whoLink, badge, scopeLabel, subHead, normSev, descCell, colgroup, pager, pageHead, stateChip, heatTable, barList, plural, routeWithQuery, numCell, shortPath } from '../html.js'; 25 │ import type { PageContext, ClaimView } from './context.js'; 26 │ import { computeControlCoverage, computeSeverityStatus, computeIntroductionHeat, computeToolSeverity, computeOwnership, computeSensitiveData, computeAssetThreatMatrix, SEV_ORDER } from '../analytics.js';
L22 comment Scope wording lives in a note, not in headings: the client feature filter rewrites headings by textContent
Scope wording lives in a note, not in headings: the client feature filter rewrites headings by textContent
17 │ * or path clamps instead of stretching the row; the full text stays in the 18 │ * cell title and the drawer. 19 │ * 20 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every cell, attribute and identity is escaped; the search text is escaped as an attribute value; the matrix SVG escapes its own labels" 21 │ * @handles pii on #dashboard -- "Introducer identities in the people × time grid" 22 │ * @comment -- "Scope wording lives in a note, not in headings: the client feature filter rewrites headings by textContent" 23 │ */ 24 │ import { esc, chip, sortableHead, rowAttrs, sevBadge, sevRank, locCellShort, claimStateBadge, whoLink, badge, scopeLabel, subHead, normSev, descCell, colgroup, pager, pageHead, stateChip, heatTable, barList, plural, routeWithQuery, numCell, shortPath } from '../html.js'; 25 │ import type { PageContext, ClaimView } from './context.js'; 26 │ import { computeControlCoverage, computeSeverityStatus, computeIntroductionHeat, computeToolSeverity, computeOwnership, computeSensitiveData, computeAssetThreatMatrix, SEV_ORDER } from '../analytics.js'; 27 │ import type { SevKey } from '../analytics.js';
L17 mitigates #output-encoding mitigates #xss
Every model value, action text and identity is rendered through esc(); hrefs are attribute-escaped
12 │ * 13 │ * The headline and the three panels are rendered once for the whole model and 14 │ * once per @feature, so the feature dropdown swaps in numbers computed by the 15 │ * same code rather than patching text in the browser. 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every model value, action text and identity is rendered through esc(); hrefs are attribute-escaped" 18 │ * @comment -- "The inventory keeps statCard markup and labels: the feature filter rewrites the Open Threats and Mitigated tiles by label, and a test greps the tile" 19 │ */ 20 │ import { esc, statCard, sevBadge, sevRank, scopeLabel, subHead, copyButton, locInline, whoLink, plural, normSev, routeWithQuery, mixBar, statTile, stateChip, scopeTag, type Segment } from '../html.js'; 21 │ import type { PageContext } from './context.js'; 22 │ import type { ChangeSummary, ChangedClaim } from '../analytics.js';
L18 comment The inventory keeps statCard markup and labels: the feature filter rewrites the Open Threats and Mitigated tiles by label, and a test greps the tile
The inventory keeps statCard markup and labels: the feature filter rewrites the Open Threats and Mitigated tiles by label, and a test greps the tile
13 │ * The headline and the three panels are rendered once for the whole model and 14 │ * once per @feature, so the feature dropdown swaps in numbers computed by the 15 │ * same code rather than patching text in the browser. 16 │ * 17 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every model value, action text and identity is rendered through esc(); hrefs are attribute-escaped" 18 │ * @comment -- "The inventory keeps statCard markup and labels: the feature filter rewrites the Open Threats and Mitigated tiles by label, and a test greps the tile" 19 │ */ 20 │ import { esc, statCard, sevBadge, sevRank, scopeLabel, subHead, copyButton, locInline, whoLink, plural, normSev, routeWithQuery, mixBar, statTile, stateChip, scopeTag, type Segment } from '../html.js'; 21 │ import type { PageContext } from './context.js'; 22 │ import type { ChangeSummary, ChangedClaim } from '../analytics.js'; 23 │ import { computeRiskGrade } from '../grade.js';
L12 mitigates #output-encoding mitigates #xss
Report prose goes through renderMarkdown (escape first, allow-listed link schemes); every finding field goes through esc()
7 │ * (`../markdown.ts`), which escapes the whole text before adding markup back, 8 │ * and is shown or hidden by the selector. The page loads no Markdown library. 9 │ * The findings block a report declares is drawn as a table above its prose, 10 │ * each id a way into the Exposures table. 11 │ * 12 │ * @mitigates #dashboard against #xss using #output-encoding -- "Report prose goes through renderMarkdown (escape first, allow-listed link schemes); every finding field goes through esc()" 13 │ * @comment -- "Whole-project documents: --feature does not narrow them, and the page says so on a slice. With no saved report the page renders its own empty state so the copy buttons exist without the client" 14 │ */ 15 │ import { esc, scopeLabel, pageHead, copyButton, icon, sevBadge, stateChip } from '../html.js'; 16 │ import type { PageContext } from './context.js'; 17 │ import type { Finding } from '../../analyze/findings.js';
L13 comment Whole-project documents: --feature does not narrow them, and the page says so on a slice. With no saved report the page renders its own empty state so the copy buttons exist without the client
Whole-project documents: --feature does not narrow them, and the page says so on a slice. With no saved report the page renders its own empty state so the copy buttons exist without the client
8 │ * and is shown or hidden by the selector. The page loads no Markdown library. 9 │ * The findings block a report declares is drawn as a table above its prose, 10 │ * each id a way into the Exposures table. 11 │ * 12 │ * @mitigates #dashboard against #xss using #output-encoding -- "Report prose goes through renderMarkdown (escape first, allow-listed link schemes); every finding field goes through esc()" 13 │ * @comment -- "Whole-project documents: --feature does not narrow them, and the page says so on a slice. With no saved report the page renders its own empty state so the copy buttons exist without the client" 14 │ */ 15 │ import { esc, scopeLabel, pageHead, copyButton, icon, sevBadge, stateChip } from '../html.js'; 16 │ import type { PageContext } from './context.js'; 17 │ import type { Finding } from '../../analyze/findings.js'; 18 │ import { renderMarkdown } from '../markdown.js';
L13 mitigates #output-encoding mitigates #xss
Every cell is escaped, including owner names, rationale and entitlement citations
8 │ * - developer comments and shielded regions under Code. 9 │ * 10 │ * Every table is sortable and searchable; rows carry the attributes the client 11 │ * filters act on. 12 │ * 13 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every cell is escaped, including owner names, rationale and entitlement citations" 14 │ * @comment -- "The empty-state sentence and its condition are kept word for word (flows are not part of it): the feature-slice test pins the sentence" 15 │ */ 16 │ import { esc, scopeLabel, subHead, sortableHead, rowAttrs, locCellShort, descCell, pager } from '../html.js'; 17 │ import type { PageContext } from './context.js'; 18 │
L14 comment The empty-state sentence and its condition are kept word for word (flows are not part of it): the feature-slice test pins the sentence
The empty-state sentence and its condition are kept word for word (flows are not part of it): the feature-slice test pins the sentence
9 │ * 10 │ * Every table is sortable and searchable; rows carry the attributes the client 11 │ * filters act on. 12 │ * 13 │ * @mitigates #dashboard against #xss using #output-encoding -- "Every cell is escaped, including owner names, rationale and entitlement citations" 14 │ * @comment -- "The empty-state sentence and its condition are kept word for word (flows are not part of it): the feature-slice test pins the sentence" 15 │ */ 16 │ import { esc, scopeLabel, subHead, sortableHead, rowAttrs, locCellShort, descCell, pager } from '../html.js'; 17 │ import type { PageContext } from './context.js'; 18 │ 19 │ type Ctx = Pick<PageContext, 'model' | 'scope' | 'links' | 'reach'>;
L57 exposes #dashboard → #dos
Measures caller-supplied diagram text with regular expressions
52 │ * 53 │ * Raising them would not help anyway. The measured 257-file threat graph renders 54 │ * at 39,609 × 32,646 px with 0.2% of it visible at the fitted zoom; drawing more 55 │ * of that is not the same as showing it. 56 │ * 57 │ * @exposes #dashboard to #dos [low] -- "Measures caller-supplied diagram text with regular expressions" 58 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input" 59 │ * @flows DiagramSource -> #dashboard via checkRenderBudget -- "Generated Mermaid text measured before it is written or served" 60 │ * @comment -- "The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module" 61 │ */ 62 │
L58 mitigates #regex-anchoring mitigates #dos
Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input
53 │ * Raising them would not help anyway. The measured 257-file threat graph renders 54 │ * at 39,609 × 32,646 px with 0.2% of it visible at the fitted zoom; drawing more 55 │ * of that is not the same as showing it. 56 │ * 57 │ * @exposes #dashboard to #dos [low] -- "Measures caller-supplied diagram text with regular expressions" 58 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input" 59 │ * @flows DiagramSource -> #dashboard via checkRenderBudget -- "Generated Mermaid text measured before it is written or served" 60 │ * @comment -- "The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module" 61 │ */ 62 │ 63 │ /**
L59 flow DiagramSource → #dashboard
Generated Mermaid text measured before it is written or served
54 │ * at 39,609 × 32,646 px with 0.2% of it visible at the fitted zoom; drawing more 55 │ * of that is not the same as showing it. 56 │ * 57 │ * @exposes #dashboard to #dos [low] -- "Measures caller-supplied diagram text with regular expressions" 58 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input" 59 │ * @flows DiagramSource -> #dashboard via checkRenderBudget -- "Generated Mermaid text measured before it is written or served" 60 │ * @comment -- "The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module" 61 │ */ 62 │ 63 │ /** 64 │ * Mermaid's own limits, at the version the dashboard loads.
L60 comment The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module
The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module
55 │ * of that is not the same as showing it. 56 │ * 57 │ * @exposes #dashboard to #dos [low] -- "Measures caller-supplied diagram text with regular expressions" 58 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input" 59 │ * @flows DiagramSource -> #dashboard via checkRenderBudget -- "Generated Mermaid text measured before it is written or served" 60 │ * @comment -- "The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module" 61 │ */ 62 │ 63 │ /** 64 │ * Mermaid's own limits, at the version the dashboard loads. 65 │ *
L6 exposes #dashboard → #cmd-injection
loadSince() passes the --since ref the user typed to parseAtRef() and into the git log and git rev-list argv
1 │ /** 2 │ * GuardLink Dashboard — `--since <ref>`: the model at a git ref, diffed 3 │ * against the model being rendered, plus the ref's place in history. The 4 │ * page turns it into the "what changed" strip and the "new" marks on rows. 5 │ * 6 │ * @exposes #dashboard to #cmd-injection [low] cwe:CWE-78 -- "loadSince() passes the --since ref the user typed to parseAtRef() and into the git log and git rev-list argv" 7 │ * @mitigates #dashboard against #cmd-injection using #param-commands -- "Checked against a strict ref shape first, then only ever passed as one argv element to execFileSync; parseAtRef rev-parses it before reading anything" 8 │ * @flows GitRepo -> #dashboard via parseAtRef -- "The threat model as it was at the ref" 9 │ * @comment -- "Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page" 10 │ */ 11 │ import { execFileSync } from 'node:child_process';
L7 mitigates #param-commands mitigates #cmd-injection
Checked against a strict ref shape first, then only ever passed as one argv element to execFileSync; parseAtRef rev-parses it before reading anything
2 │ * GuardLink Dashboard — `--since <ref>`: the model at a git ref, diffed 3 │ * against the model being rendered, plus the ref's place in history. The 4 │ * page turns it into the "what changed" strip and the "new" marks on rows. 5 │ * 6 │ * @exposes #dashboard to #cmd-injection [low] cwe:CWE-78 -- "loadSince() passes the --since ref the user typed to parseAtRef() and into the git log and git rev-list argv" 7 │ * @mitigates #dashboard against #cmd-injection using #param-commands -- "Checked against a strict ref shape first, then only ever passed as one argv element to execFileSync; parseAtRef rev-parses it before reading anything" 8 │ * @flows GitRepo -> #dashboard via parseAtRef -- "The threat model as it was at the ref" 9 │ * @comment -- "Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page" 10 │ */ 11 │ import { execFileSync } from 'node:child_process'; 12 │ import type { ThreatModel } from '../types/index.js';
L8 flow GitRepo → #dashboard
The threat model as it was at the ref
3 │ * against the model being rendered, plus the ref's place in history. The 4 │ * page turns it into the "what changed" strip and the "new" marks on rows. 5 │ * 6 │ * @exposes #dashboard to #cmd-injection [low] cwe:CWE-78 -- "loadSince() passes the --since ref the user typed to parseAtRef() and into the git log and git rev-list argv" 7 │ * @mitigates #dashboard against #cmd-injection using #param-commands -- "Checked against a strict ref shape first, then only ever passed as one argv element to execFileSync; parseAtRef rev-parses it before reading anything" 8 │ * @flows GitRepo -> #dashboard via parseAtRef -- "The threat model as it was at the ref" 9 │ * @comment -- "Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page" 10 │ */ 11 │ import { execFileSync } from 'node:child_process'; 12 │ import type { ThreatModel } from '../types/index.js'; 13 │ import { parseAtRef, getChangedFiles, diffModels } from '../diff/index.js';
L9 comment Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page
Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page
4 │ * page turns it into the "what changed" strip and the "new" marks on rows. 5 │ * 6 │ * @exposes #dashboard to #cmd-injection [low] cwe:CWE-78 -- "loadSince() passes the --since ref the user typed to parseAtRef() and into the git log and git rev-list argv" 7 │ * @mitigates #dashboard against #cmd-injection using #param-commands -- "Checked against a strict ref shape first, then only ever passed as one argv element to execFileSync; parseAtRef rev-parses it before reading anything" 8 │ * @flows GitRepo -> #dashboard via parseAtRef -- "The threat model as it was at the ref" 9 │ * @comment -- "Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page" 10 │ */ 11 │ import { execFileSync } from 'node:child_process'; 12 │ import type { ThreatModel } from '../types/index.js'; 13 │ import { parseAtRef, getChangedFiles, diffModels } from '../diff/index.js'; 14 │ import type { SinceInput } from './analytics.js';
L26 comment Static CSS only; nothing here interpolates model data, and nothing here references a remote resource
Static CSS only; nothing here interpolates model data, and nothing here references a remote resource
21 │ * sits on; text never wears a severity colour. 22 │ * 23 │ * Type is the system stack, or Inter and JetBrains Mono when installed. The 24 │ * page requests no web font. 25 │ * 26 │ * @comment -- "Static CSS only; nothing here interpolates model data, and nothing here references a remote resource" 27 │ */ 28 │ 29 │ /** The token blocks: the only place a colour literal may appear. */ 30 │ export const TOKENS_CSS = ` 31 │ :root {
L15 comment Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)
Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)
10 │ * - Identity keys: assets by path/id, threats/controls by id/canonical_name, 11 │ * relationships by (asset, threat) or (source, target) composite keys 12 │ * - Delta categories: added, removed, modified (severity/description changed) 13 │ * - Risk delta: tracks net change in unmitigated exposure count 14 │ * 15 │ * @comment -- "Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)" 16 │ * @flows ThreatModel -> #diff via diffModels -- "Before/after models compared into a structured delta" 17 │ * @comment -- "A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with" 18 │ */ 19 │ 20 │ import type {
L16 flow ThreatModel → #diff
Before/after models compared into a structured delta
11 │ * relationships by (asset, threat) or (source, target) composite keys 12 │ * - Delta categories: added, removed, modified (severity/description changed) 13 │ * - Risk delta: tracks net change in unmitigated exposure count 14 │ * 15 │ * @comment -- "Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)" 16 │ * @flows ThreatModel -> #diff via diffModels -- "Before/after models compared into a structured delta" 17 │ * @comment -- "A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with" 18 │ */ 19 │ 20 │ import type { 21 │ ThreatModel,
L17 comment A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with
A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with
12 │ * - Delta categories: added, removed, modified (severity/description changed) 13 │ * - Risk delta: tracks net change in unmitigated exposure count 14 │ * 15 │ * @comment -- "Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)" 16 │ * @flows ThreatModel -> #diff via diffModels -- "Before/after models compared into a structured delta" 17 │ * @comment -- "A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with" 18 │ */ 19 │ 20 │ import type { 21 │ ThreatModel, 22 │ ThreatModelAsset, ThreatModelThreat, ThreatModelControl, ThreatModelActor,
L4 comment Pure string formatting; no I/O. Stale entitlements print even when the delta is otherwise empty — a claim whose cited authz code moved is exactly the thing that must not disappear quietly (actor-entitlement design §3.3)
Pure string formatting; no I/O. Stale entitlements print even when the delta is otherwise empty — a claim whose cited authz code moved is exactly the thing that must not disappear quietly (actor-entitlement design §3.3)
1 │ /** 2 │ * GuardLink Diff — Human-readable output formatter. 3 │ * 4 │ * @comment -- "Pure string formatting; no I/O. Stale entitlements print even when the delta is otherwise empty — a claim whose cited authz code moved is exactly the thing that must not disappear quietly (actor-entitlement design §3.3)" 5 │ */ 6 │ 7 │ import type { ThreatModelDiff, Change } from './engine.js'; 8 │ 9 │ export function formatDiff(diff: ThreatModelDiff): string {
L6 exposes #diff → #cmd-injection
execSync runs git commands with ref argument
1 │ /** 2 │ * GuardLink Diff — Git integration. 3 │ * Resolves git refs to threat models by checking out files at a given commit 4 │ * and parsing them in a temp directory. 5 │ * 6 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "execSync runs git commands with ref argument" 7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree"
L7 mitigates #input-sanitize mitigates #cmd-injection
rev-parse validates ref exists before use in other commands
2 │ * GuardLink Diff — Git integration. 3 │ * Resolves git refs to threat models by checking out files at a given commit 4 │ * and parsing them in a temp directory. 5 │ * 6 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "execSync runs git commands with ref argument" 7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution"
L8 exposes #diff → #arbitrary-write
writeFileSync creates files in temp directory
3 │ * Resolves git refs to threat models by checking out files at a given commit 4 │ * and parsing them in a temp directory. 5 │ * 6 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "execSync runs git commands with ref argument" 7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes"
L9 mitigates #path-validation mitigates #arbitrary-write
mkdtempSync creates isolated temp dir; rmSync cleans up
4 │ * and parsing them in a temp directory. 5 │ * 6 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "execSync runs git commands with ref argument" 7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output"
L10 exposes #diff → #path-traversal
git show extracts files based on ls-tree output
5 │ * 6 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "execSync runs git commands with ref argument" 7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output" 15 │ * @boundary #diff and GitRepo (#git-boundary) -- "Trust boundary at git command execution"
L11 mitigates #glob-filtering mitigates #path-traversal
Files constrained to relevantFiles from git ls-tree
6 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "execSync runs git commands with ref argument" 7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output" 15 │ * @boundary #diff and GitRepo (#git-boundary) -- "Trust boundary at git command execution" 16 │ */
L12 flow GitRef → #diff
Git command execution
7 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse validates ref exists before use in other commands" 8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output" 15 │ * @boundary #diff and GitRepo (#git-boundary) -- "Trust boundary at git command execution" 16 │ */ 17 │
L13 flow #diff → TempDir
Extracted file writes
8 │ * @exposes #diff to #arbitrary-write [medium] cwe:CWE-73 -- "writeFileSync creates files in temp directory" 9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output" 15 │ * @boundary #diff and GitRepo (#git-boundary) -- "Trust boundary at git command execution" 16 │ */ 17 │ 18 │ import { execSync } from 'node:child_process';
L14 flow #diff → ThreatModel
Parsed model output
9 │ * @mitigates #diff against #arbitrary-write using #path-validation -- "mkdtempSync creates isolated temp dir; rmSync cleans up" 10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output" 15 │ * @boundary #diff and GitRepo (#git-boundary) -- "Trust boundary at git command execution" 16 │ */ 17 │ 18 │ import { execSync } from 'node:child_process'; 19 │ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
L15 boundary #diff ↔ GitRepo
Trust boundary at git command execution
10 │ * @exposes #diff to #path-traversal [medium] cwe:CWE-22 -- "git show extracts files based on ls-tree output" 11 │ * @mitigates #diff against #path-traversal using #glob-filtering -- "Files constrained to relevantFiles from git ls-tree" 12 │ * @flows GitRef -> #diff via execSync -- "Git command execution" 13 │ * @flows #diff -> TempDir via writeFileSync -- "Extracted file writes" 14 │ * @flows #diff -> ThreatModel via parseProject -- "Parsed model output" 15 │ * @boundary #diff and GitRepo (#git-boundary) -- "Trust boundary at git command execution" 16 │ */ 17 │ 18 │ import { execSync } from 'node:child_process'; 19 │ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; 20 │ import { join } from 'node:path';
L92 exposes #diff → #cmd-injection
ref is interpolated into an execSync git command
87 │ * Feeds `diffModels({ changedFiles })` so an @entitles whose cited authorization 88 │ * code moved is reported as stale (actor-entitlement design §3.7). Returns [] on 89 │ * any git failure — staleness is advisory, and a diff that cannot resolve the ref 90 │ * should still report the rest of the delta. 91 │ * 92 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "ref is interpolated into an execSync git command" 93 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse --verify must resolve ref to a single revision before it reaches the diff command; a shell metacharacter makes rev-parse fail, so the function returns [] instead of running the second command" 94 │ * @flows GitRef -> #diff via execSync -- "Ref input to git diff --name-only" 95 │ * @flows #diff -> ChangedFileList via return -- "Repo-relative paths used for entitlement staleness" 96 │ */ 97 │ export function getChangedFiles(root: string, ref: string): string[] {
L93 mitigates #input-sanitize mitigates #cmd-injection
rev-parse --verify must resolve ref to a single revision before it reaches the diff command; a shell metacharacter makes rev-parse fail, so the function returns [] instead of running the second command
88 │ * code moved is reported as stale (actor-entitlement design §3.7). Returns [] on 89 │ * any git failure — staleness is advisory, and a diff that cannot resolve the ref 90 │ * should still report the rest of the delta. 91 │ * 92 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "ref is interpolated into an execSync git command" 93 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse --verify must resolve ref to a single revision before it reaches the diff command; a shell metacharacter makes rev-parse fail, so the function returns [] instead of running the second command" 94 │ * @flows GitRef -> #diff via execSync -- "Ref input to git diff --name-only" 95 │ * @flows #diff -> ChangedFileList via return -- "Repo-relative paths used for entitlement staleness" 96 │ */ 97 │ export function getChangedFiles(root: string, ref: string): string[] { 98 │ try {
L94 flow GitRef → #diff
Ref input to git diff --name-only
89 │ * any git failure — staleness is advisory, and a diff that cannot resolve the ref 90 │ * should still report the rest of the delta. 91 │ * 92 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "ref is interpolated into an execSync git command" 93 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse --verify must resolve ref to a single revision before it reaches the diff command; a shell metacharacter makes rev-parse fail, so the function returns [] instead of running the second command" 94 │ * @flows GitRef -> #diff via execSync -- "Ref input to git diff --name-only" 95 │ * @flows #diff -> ChangedFileList via return -- "Repo-relative paths used for entitlement staleness" 96 │ */ 97 │ export function getChangedFiles(root: string, ref: string): string[] { 98 │ try { 99 │ execSync(`git rev-parse --verify ${ref}`, { cwd: root, stdio: 'pipe' });
L95 flow #diff → ChangedFileList
Repo-relative paths used for entitlement staleness
90 │ * should still report the rest of the delta. 91 │ * 92 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "ref is interpolated into an execSync git command" 93 │ * @mitigates #diff against #cmd-injection using #input-sanitize -- "rev-parse --verify must resolve ref to a single revision before it reaches the diff command; a shell metacharacter makes rev-parse fail, so the function returns [] instead of running the second command" 94 │ * @flows GitRef -> #diff via execSync -- "Ref input to git diff --name-only" 95 │ * @flows #diff -> ChangedFileList via return -- "Repo-relative paths used for entitlement staleness" 96 │ */ 97 │ export function getChangedFiles(root: string, ref: string): string[] { 98 │ try { 99 │ execSync(`git rev-parse --verify ${ref}`, { cwd: root, stdio: 'pipe' }); 100 │ const raw = execSync(`git diff --name-only ${ref} --`, { cwd: root, encoding: 'utf-8' });
L4 exposes #diff → #cmd-injection
git.ts uses execSync with ref argument
1 │ /** 2 │ * GuardLink Diff — exports. 3 │ * 4 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "git.ts uses execSync with ref argument" 5 │ * @audit #diff -- "Git commands use execSync; ref is validated with rev-parse before use" 6 │ * @flows GitRef -> #diff via parseAtRef -- "Git reference input" 7 │ */ 8 │ 9 │ export { diffModels, type ThreatModelDiff, type DiffSummary, type Change, type ChangeKind, type DiffOptions, type StaleEntitlement } from './engine.js';
L5 audit Audit: #diff
Git commands use execSync; ref is validated with rev-parse before use
1 │ /** 2 │ * GuardLink Diff — exports. 3 │ * 4 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "git.ts uses execSync with ref argument" 5 │ * @audit #diff -- "Git commands use execSync; ref is validated with rev-parse before use" 6 │ * @flows GitRef -> #diff via parseAtRef -- "Git reference input" 7 │ */ 8 │ 9 │ export { diffModels, type ThreatModelDiff, type DiffSummary, type Change, type ChangeKind, type DiffOptions, type StaleEntitlement } from './engine.js'; 10 │ export { formatDiff, formatDiffMarkdown } from './format.js';
L6 flow GitRef → #diff
Git reference input
1 │ /** 2 │ * GuardLink Diff — exports. 3 │ * 4 │ * @exposes #diff to #cmd-injection [high] cwe:CWE-78 -- "git.ts uses execSync with ref argument" 5 │ * @audit #diff -- "Git commands use execSync; ref is validated with rev-parse before use" 6 │ * @flows GitRef -> #diff via parseAtRef -- "Git reference input" 7 │ */ 8 │ 9 │ export { diffModels, type ThreatModelDiff, type DiffSummary, type Change, type ChangeKind, type DiffOptions, type StaleEntitlement } from './engine.js'; 10 │ export { formatDiff, formatDiffMarkdown } from './format.js'; 11 │ export { parseAtRef, getCurrentRef, getChangedFiles } from './git.js';
L11 exposes #gate → #arbitrary-write
stripViolations rewrites source files named by the model's locations
6 │ * to find what the run added, lints only those, and reports. The follow-up is 7 │ * the re-prompt. The strip removes the lines of added claims that still fail, 8 │ * after checking each line really holds the verb, and returns them so nothing 9 │ * is lost. 10 │ * 11 │ * @exposes #gate to #arbitrary-write [medium] cwe:CWE-73 -- "stripViolations rewrites source files named by the model's locations" 12 │ * @mitigates #gate against #arbitrary-write using #path-validation -- "Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there" 13 │ * @flows ThreatModel -> #gate via runGate -- "The model before and after an agent run" 14 │ * @flows #gate -> AgentPrompt via buildGateFollowUp -- "The violations as a re-prompt" 15 │ * @flows #gate -> SourceFiles via stripViolations -- "Rejected annotation lines removed" 16 │ * @comment -- "Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong"
L12 mitigates #path-validation mitigates #arbitrary-write
Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there
7 │ * the re-prompt. The strip removes the lines of added claims that still fail, 8 │ * after checking each line really holds the verb, and returns them so nothing 9 │ * is lost. 10 │ * 11 │ * @exposes #gate to #arbitrary-write [medium] cwe:CWE-73 -- "stripViolations rewrites source files named by the model's locations" 12 │ * @mitigates #gate against #arbitrary-write using #path-validation -- "Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there" 13 │ * @flows ThreatModel -> #gate via runGate -- "The model before and after an agent run" 14 │ * @flows #gate -> AgentPrompt via buildGateFollowUp -- "The violations as a re-prompt" 15 │ * @flows #gate -> SourceFiles via stripViolations -- "Rejected annotation lines removed" 16 │ * @comment -- "Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong" 17 │ */
L13 flow ThreatModel → #gate
The model before and after an agent run
8 │ * after checking each line really holds the verb, and returns them so nothing 9 │ * is lost. 10 │ * 11 │ * @exposes #gate to #arbitrary-write [medium] cwe:CWE-73 -- "stripViolations rewrites source files named by the model's locations" 12 │ * @mitigates #gate against #arbitrary-write using #path-validation -- "Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there" 13 │ * @flows ThreatModel -> #gate via runGate -- "The model before and after an agent run" 14 │ * @flows #gate -> AgentPrompt via buildGateFollowUp -- "The violations as a re-prompt" 15 │ * @flows #gate -> SourceFiles via stripViolations -- "Rejected annotation lines removed" 16 │ * @comment -- "Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong" 17 │ */ 18 │ import { readFileSync, writeFileSync } from 'node:fs';
L14 flow #gate → AgentPrompt
The violations as a re-prompt
9 │ * is lost. 10 │ * 11 │ * @exposes #gate to #arbitrary-write [medium] cwe:CWE-73 -- "stripViolations rewrites source files named by the model's locations" 12 │ * @mitigates #gate against #arbitrary-write using #path-validation -- "Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there" 13 │ * @flows ThreatModel -> #gate via runGate -- "The model before and after an agent run" 14 │ * @flows #gate -> AgentPrompt via buildGateFollowUp -- "The violations as a re-prompt" 15 │ * @flows #gate -> SourceFiles via stripViolations -- "Rejected annotation lines removed" 16 │ * @comment -- "Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong" 17 │ */ 18 │ import { readFileSync, writeFileSync } from 'node:fs'; 19 │ import { resolve, sep } from 'node:path';
L15 flow #gate → SourceFiles
Rejected annotation lines removed
10 │ * 11 │ * @exposes #gate to #arbitrary-write [medium] cwe:CWE-73 -- "stripViolations rewrites source files named by the model's locations" 12 │ * @mitigates #gate against #arbitrary-write using #path-validation -- "Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there" 13 │ * @flows ThreatModel -> #gate via runGate -- "The model before and after an agent run" 14 │ * @flows #gate -> AgentPrompt via buildGateFollowUp -- "The violations as a re-prompt" 15 │ * @flows #gate -> SourceFiles via stripViolations -- "Rejected annotation lines removed" 16 │ * @comment -- "Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong" 17 │ */ 18 │ import { readFileSync, writeFileSync } from 'node:fs'; 19 │ import { resolve, sep } from 'node:path'; 20 │ import type { ThreatModel } from '../types/index.js';
L16 comment Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong
Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong
11 │ * @exposes #gate to #arbitrary-write [medium] cwe:CWE-73 -- "stripViolations rewrites source files named by the model's locations" 12 │ * @mitigates #gate against #arbitrary-write using #path-validation -- "Every path is resolved under root and refused outside it; a line is removed only when its text carries the annotation verb the model says is there" 13 │ * @flows ThreatModel -> #gate via runGate -- "The model before and after an agent run" 14 │ * @flows #gate -> AgentPrompt via buildGateFollowUp -- "The violations as a re-prompt" 15 │ * @flows #gate -> SourceFiles via stripViolations -- "Rejected annotation lines removed" 16 │ * @comment -- "Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong" 17 │ */ 18 │ import { readFileSync, writeFileSync } from 'node:fs'; 19 │ import { resolve, sep } from 'node:path'; 20 │ import type { ThreatModel } from '../types/index.js'; 21 │ import { relationRecords, type ClaimSource } from '../parser/claim-key.js';
L4 comment The acceptance check behind `guardlink annotate` and the standalone `guardlink lint`
The acceptance check behind `guardlink annotate` and the standalone `guardlink lint`
1 │ /** 2 │ * GuardLink Gate — barrel. 3 │ * 4 │ * @comment -- "The acceptance check behind `guardlink annotate` and the standalone `guardlink lint`" 5 │ */ 6 │ export { lintAnnotations, hasCodeReference, hasEvidenceWords, RULE_FIX, type Violation, type LintRule, type LintLevel, type LintOptions } from './lint.js'; 7 │ export { runGate, formatGateReport, buildGateFollowUp, stripViolations, type GateReport, type Stripped } from './gate.js'; 8 │
L8 flow ThreatModel → #gate
Claims and their descriptions
3 │ * 4 │ * Pure over the model. Each rule mirrors one line of the evidence bar in 5 │ * src/playbooks/evidence.ts: what the prompt demands is what this checks, so 6 │ * an agent that ignored the bar is caught by the same words it ignored. 7 │ * 8 │ * @flows ThreatModel -> #gate via lintAnnotations -- "Claims and their descriptions" 9 │ * @comment -- "Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others" 10 │ * @comment -- "@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held" 11 │ */ 12 │ import type { ThreatModel, ThreatModelExposure, ThreatModelConfirmed } from '../types/index.js'; 13 │ import { relationRecords } from '../parser/claim-key.js';
L9 comment Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others
Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others
4 │ * Pure over the model. Each rule mirrors one line of the evidence bar in 5 │ * src/playbooks/evidence.ts: what the prompt demands is what this checks, so 6 │ * an agent that ignored the bar is caught by the same words it ignored. 7 │ * 8 │ * @flows ThreatModel -> #gate via lintAnnotations -- "Claims and their descriptions" 9 │ * @comment -- "Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others" 10 │ * @comment -- "@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held" 11 │ */ 12 │ import type { ThreatModel, ThreatModelExposure, ThreatModelConfirmed } from '../types/index.js'; 13 │ import { relationRecords } from '../parser/claim-key.js'; 14 │ import { buildCoverageIndex } from '../parser/coverage.js';
L10 comment @agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held
@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held
5 │ * src/playbooks/evidence.ts: what the prompt demands is what this checks, so 6 │ * an agent that ignored the bar is caught by the same words it ignored. 7 │ * 8 │ * @flows ThreatModel -> #gate via lintAnnotations -- "Claims and their descriptions" 9 │ * @comment -- "Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others" 10 │ * @comment -- "@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held" 11 │ */ 12 │ import type { ThreatModel, ThreatModelExposure, ThreatModelConfirmed } from '../types/index.js'; 13 │ import { relationRecords } from '../parser/claim-key.js'; 14 │ import { buildCoverageIndex } from '../parser/coverage.js'; 15 │
L90 exposes #dashboard → #dos
measureLegibility scans caller-supplied diagram text with regular expressions, once per line
85 │ * "legible" in one repository and not in another, and the number is a property 86 │ * of a panel size and a font size, neither of which a project chooses. A reader 87 │ * who wants more on screen does not want a bigger budget; they want a different 88 │ * question, which is what the view catalogue is for. 89 │ * 90 │ * @exposes #dashboard to #dos [low] cwe:CWE-400 -- "measureLegibility scans caller-supplied diagram text with regular expressions, once per line" 91 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input, and each line is scanned a fixed number of times" 92 │ * @flows DiagramSource -> #dashboard via measureLegibility -- "Generated Mermaid text measured for node and edge count before a view decides to draw it" 93 │ * @comment -- "The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits" 94 │ */ 95 │
L91 mitigates #regex-anchoring mitigates #dos
Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input, and each line is scanned a fixed number of times
86 │ * of a panel size and a font size, neither of which a project chooses. A reader 87 │ * who wants more on screen does not want a bigger budget; they want a different 88 │ * question, which is what the view catalogue is for. 89 │ * 90 │ * @exposes #dashboard to #dos [low] cwe:CWE-400 -- "measureLegibility scans caller-supplied diagram text with regular expressions, once per line" 91 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input, and each line is scanned a fixed number of times" 92 │ * @flows DiagramSource -> #dashboard via measureLegibility -- "Generated Mermaid text measured for node and edge count before a view decides to draw it" 93 │ * @comment -- "The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits" 94 │ */ 95 │ 96 │ import { mermaidRenderText, countMermaidEdges, MERMAID_LIMITS } from '../dashboard/render-budget.js';
L92 flow DiagramSource → #dashboard
Generated Mermaid text measured for node and edge count before a view decides to draw it
87 │ * who wants more on screen does not want a bigger budget; they want a different 88 │ * question, which is what the view catalogue is for. 89 │ * 90 │ * @exposes #dashboard to #dos [low] cwe:CWE-400 -- "measureLegibility scans caller-supplied diagram text with regular expressions, once per line" 91 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input, and each line is scanned a fixed number of times" 92 │ * @flows DiagramSource -> #dashboard via measureLegibility -- "Generated Mermaid text measured for node and edge count before a view decides to draw it" 93 │ * @comment -- "The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits" 94 │ */ 95 │ 96 │ import { mermaidRenderText, countMermaidEdges, MERMAID_LIMITS } from '../dashboard/render-budget.js'; 97 │
L93 comment The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits
The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits
88 │ * question, which is what the view catalogue is for. 89 │ * 90 │ * @exposes #dashboard to #dos [low] cwe:CWE-400 -- "measureLegibility scans caller-supplied diagram text with regular expressions, once per line" 91 │ * @mitigates #dashboard against #dos using #regex-anchoring -- "Every pattern is line-anchored or a bounded character class; no nested quantifier can backtrack across the input, and each line is scanned a fixed number of times" 92 │ * @flows DiagramSource -> #dashboard via measureLegibility -- "Generated Mermaid text measured for node and edge count before a view decides to draw it" 93 │ * @comment -- "The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits" 94 │ */ 95 │ 96 │ import { mermaidRenderText, countMermaidEdges, MERMAID_LIMITS } from '../dashboard/render-budget.js'; 97 │ 98 │ /**
L52 flow ThreatModel → #dashboard
Model narrowed to a neighbourhood that fits the legibility budget
47 │ * `question` is not documentation, it is part of the view. A view that exists 48 │ * because the data allows it is not the same as one somebody wants, and the 49 │ * page renders this string verbatim so a reader can tell which they are looking 50 │ * at before they read the answer. 51 │ * 52 │ * @flows ThreatModel -> #dashboard via growWithinBudget -- "Model narrowed to a neighbourhood that fits the legibility budget" 53 │ * @comment -- "Pure functions over an already-parsed ThreatModel: no file I/O, no user input, no network, and the renderer is injected so this module never depends on the dashboard" 54 │ * @comment -- "Selection goes through selectSubgraph's `nodes` option rather than reimplementing the filter, so a view and an MCP graph query narrow the model the same way" 55 │ */ 56 │ 57 │ import { selectSubgraph, graphEdges, canonicaliser, type Direction, type GraphEdge } from '../mcp/subgraph.js';
L53 comment Pure functions over an already-parsed ThreatModel: no file I/O, no user input, no network, and the renderer is injected so this module never depends on the dashboard
Pure functions over an already-parsed ThreatModel: no file I/O, no user input, no network, and the renderer is injected so this module never depends on the dashboard
48 │ * because the data allows it is not the same as one somebody wants, and the 49 │ * page renders this string verbatim so a reader can tell which they are looking 50 │ * at before they read the answer. 51 │ * 52 │ * @flows ThreatModel -> #dashboard via growWithinBudget -- "Model narrowed to a neighbourhood that fits the legibility budget" 53 │ * @comment -- "Pure functions over an already-parsed ThreatModel: no file I/O, no user input, no network, and the renderer is injected so this module never depends on the dashboard" 54 │ * @comment -- "Selection goes through selectSubgraph's `nodes` option rather than reimplementing the filter, so a view and an MCP graph query narrow the model the same way" 55 │ */ 56 │ 57 │ import { selectSubgraph, graphEdges, canonicaliser, type Direction, type GraphEdge } from '../mcp/subgraph.js'; 58 │ import { checkLegibility, LEGIBILITY_BUDGET, type LegibilityBudget, type LegibilityVerdict } from './legibility.js';
L54 comment Selection goes through selectSubgraph's `nodes` option rather than reimplementing the filter, so a view and an MCP graph query narrow the model the same way
Selection goes through selectSubgraph's `nodes` option rather than reimplementing the filter, so a view and an MCP graph query narrow the model the same way
49 │ * page renders this string verbatim so a reader can tell which they are looking 50 │ * at before they read the answer. 51 │ * 52 │ * @flows ThreatModel -> #dashboard via growWithinBudget -- "Model narrowed to a neighbourhood that fits the legibility budget" 53 │ * @comment -- "Pure functions over an already-parsed ThreatModel: no file I/O, no user input, no network, and the renderer is injected so this module never depends on the dashboard" 54 │ * @comment -- "Selection goes through selectSubgraph's `nodes` option rather than reimplementing the filter, so a view and an MCP graph query narrow the model the same way" 55 │ */ 56 │ 57 │ import { selectSubgraph, graphEdges, canonicaliser, type Direction, type GraphEdge } from '../mcp/subgraph.js'; 58 │ import { checkLegibility, LEGIBILITY_BUDGET, type LegibilityBudget, type LegibilityVerdict } from './legibility.js'; 59 │ import type { ThreatModel } from '../types/index.js';
L19 flow ThreatModel → #cli
Declared boundaries joined to the ledger by claim key
14 │ * exposure ledger applies to refuted and confirmed. 15 │ * 16 │ * A boundary outcome never demotes or closes an exposure, and nothing here 17 │ * reads one into the exposure states. 18 │ * 19 │ * @flows ThreatModel -> #cli via classifyBoundaryClaims -- "Declared boundaries joined to the ledger by claim key" 20 │ * @comment -- "Pure over the model and a ledger read, like classifyHypotheses; recording goes through recordBoundaryOutcome in commands.ts, which shares the exposure path's evidence check and upsert" 21 │ */ 22 │ import type { ThreatModel, SourceLocation } from '../types/index.js'; 23 │ import { relationRecords, isClaimKey } from '../parser/claim-key.js'; 24 │ import { isBoundaryEntry, type BoundaryEntry, type BoundaryOutcome, type HypothesesRead, type HypothesisOutcomeRecord } from './ledger.js';
L20 comment Pure over the model and a ledger read, like classifyHypotheses; recording goes through recordBoundaryOutcome in commands.ts, which shares the exposure path's evidence check and upsert
Pure over the model and a ledger read, like classifyHypotheses; recording goes through recordBoundaryOutcome in commands.ts, which shares the exposure path's evidence check and upsert
15 │ * 16 │ * A boundary outcome never demotes or closes an exposure, and nothing here 17 │ * reads one into the exposure states. 18 │ * 19 │ * @flows ThreatModel -> #cli via classifyBoundaryClaims -- "Declared boundaries joined to the ledger by claim key" 20 │ * @comment -- "Pure over the model and a ledger read, like classifyHypotheses; recording goes through recordBoundaryOutcome in commands.ts, which shares the exposure path's evidence check and upsert" 21 │ */ 22 │ import type { ThreatModel, SourceLocation } from '../types/index.js'; 23 │ import { relationRecords, isClaimKey } from '../parser/claim-key.js'; 24 │ import { isBoundaryEntry, type BoundaryEntry, type BoundaryOutcome, type HypothesesRead, type HypothesisOutcomeRecord } from './ledger.js'; 25 │
L10 flow ThreatModel → #cli
Claims joined to the ledger by key
5 │ * anchor hash is the one it was recorded against; when the code beneath the 6 │ * claim moves, a refutation lapses to untested (with the old outcome 7 │ * attached) and a confirmation asks for a retest. A source `@confirmed` is a 8 │ * confirmation with no ledger entry. 9 │ * 10 │ * @flows ThreatModel -> #cli via classifyHypotheses -- "Claims joined to the ledger by key" 11 │ * @flows #cli -> ThreatModel via attachHypotheses -- "The state stamped onto each exposure for the dashboard, the report and the lint" 12 │ * @comment -- "Pure over the model and a ledger read; no clock, no file, no git — ranking is arithmetic on facts the model already holds" 13 │ */ 14 │ import type { ThreatModel, ThreatModelExposure, ExposureHypothesis, SourceLocation } from '../types/index.js'; 15 │ import { relationRecords } from '../parser/claim-key.js';
L11 flow #cli → ThreatModel
The state stamped onto each exposure for the dashboard, the report and the lint
6 │ * claim moves, a refutation lapses to untested (with the old outcome 7 │ * attached) and a confirmation asks for a retest. A source `@confirmed` is a 8 │ * confirmation with no ledger entry. 9 │ * 10 │ * @flows ThreatModel -> #cli via classifyHypotheses -- "Claims joined to the ledger by key" 11 │ * @flows #cli -> ThreatModel via attachHypotheses -- "The state stamped onto each exposure for the dashboard, the report and the lint" 12 │ * @comment -- "Pure over the model and a ledger read; no clock, no file, no git — ranking is arithmetic on facts the model already holds" 13 │ */ 14 │ import type { ThreatModel, ThreatModelExposure, ExposureHypothesis, SourceLocation } from '../types/index.js'; 15 │ import { relationRecords } from '../parser/claim-key.js'; 16 │ import { isExposureEntry, type HypothesesRead, type HypothesisEntry, type HypothesisOutcomeRecord } from './ledger.js';
L12 comment Pure over the model and a ledger read; no clock, no file, no git — ranking is arithmetic on facts the model already holds
Pure over the model and a ledger read; no clock, no file, no git — ranking is arithmetic on facts the model already holds
7 │ * attached) and a confirmation asks for a retest. A source `@confirmed` is a 8 │ * confirmation with no ledger entry. 9 │ * 10 │ * @flows ThreatModel -> #cli via classifyHypotheses -- "Claims joined to the ledger by key" 11 │ * @flows #cli -> ThreatModel via attachHypotheses -- "The state stamped onto each exposure for the dashboard, the report and the lint" 12 │ * @comment -- "Pure over the model and a ledger read; no clock, no file, no git — ranking is arithmetic on facts the model already holds" 13 │ */ 14 │ import type { ThreatModel, ThreatModelExposure, ExposureHypothesis, SourceLocation } from '../types/index.js'; 15 │ import { relationRecords } from '../parser/claim-key.js'; 16 │ import { isExposureEntry, type HypothesesRead, type HypothesisEntry, type HypothesisOutcomeRecord } from './ledger.js'; 17 │
L10 exposes #cli → #arbitrary-write
writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed
5 │ * anchor. `importScan` joins a cxg report's findings to claims and records 6 │ * the confirmed ones. `confirmedLine` and `writeConfirmedLine` offer, and on 7 │ * request insert, the `@confirmed` annotation that reflects a confirmation 8 │ * in the source, right beneath its `@exposes`. 9 │ * 10 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed" 11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write"
L11 mitigates #path-validation mitigates #arbitrary-write
Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written
6 │ * the confirmed ones. `confirmedLine` and `writeConfirmedLine` offer, and on 7 │ * request insert, the `@confirmed` annotation that reflects a confirmation 8 │ * in the source, right beneath its `@exposes`. 9 │ * 10 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed" 11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted"
L12 exposes #cli → #insecure-deser
importScan() JSON.parses a scan report
7 │ * request insert, the `@confirmed` annotation that reflects a confirmation 8 │ * in the source, right beneath its `@exposes`. 9 │ * 10 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed" 11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence"
L13 mitigates #config-validation mitigates #insecure-deser
Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored
8 │ * in the source, right beneath its `@exposes`. 9 │ * 10 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed" 11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor"
L14 flow ScanReport → #cli
cxg findings joined to claims
9 │ * 10 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed" 11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent"
L15 flow #cli → SourceFiles
The @confirmed line, on --write
10 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "writeConfirmedLine() edits the source file a claim's location names; importScan() reads the scan path the user passed" 11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it"
L16 handles #cli: internal
Request and response evidence from scans, redacted
11 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Both paths are resolved under the project root and refused outside it; the edit inserts one line beneath an @exposes whose text is checked before anything is written" 12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes"
L17 comment A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence
A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence
12 │ * @exposes #cli to #insecure-deser [low] cwe:CWE-502 -- "importScan() JSON.parses a scan report" 13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status"
L18 comment importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor
importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor
13 │ * @mitigates #cli against #insecure-deser using #config-validation -- "Only the fields the loader reads are taken, each coerced to its type; evidence goes through redactEvidence before it is stored" 14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes"
L19 comment The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent
The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent
14 │ * @flows ScanReport -> #cli via importScan -- "cxg findings joined to claims" 15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from"
L20 comment The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it
The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it
15 │ * @flows #cli -> SourceFiles via writeConfirmedLine -- "The @confirmed line, on --write" 16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either"
L21 validates #config-validation validates #cli
tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes
16 │ * @handles internal on #cli -- "Request and response evidence from scans, redacted" 17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see"
L22 comment A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status
A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status
17 │ * @comment -- "A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence" 18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see" 27 │ * @audit #cli -- "The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either"
L23 comment confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes
confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes
18 │ * @comment -- "importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor" 19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see" 27 │ * @audit #cli -- "The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either" 28 │ */
L24 exposes #cli → #arbitrary-write
scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from
19 │ * @comment -- "The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent" 20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see" 27 │ * @audit #cli -- "The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either" 28 │ */ 29 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
L25 mitigates #input-sanitize mitigates #arbitrary-write
Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either
20 │ * @comment -- "The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it" 21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see" 27 │ * @audit #cli -- "The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either" 28 │ */ 29 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 30 │ import { resolve, sep } from 'node:path';
L26 validates #input-sanitize validates #cli
tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see
21 │ * @validates #config-validation for #cli -- "tests/hypothesis.test.ts forwards a real generateSarif result's emitted surfaces into the scan finding without naming a field, in every placement generated from CLAIM_KEY_SURFACES, so neither the names nor the containers the reader accepts can drift from what the export writes" 22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see" 27 │ * @audit #cli -- "The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either" 28 │ */ 29 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 30 │ import { resolve, sep } from 'node:path'; 31 │ import type { ThreatModel, SourceLocation } from '../types/index.js';
L27 audit Audit: #cli
The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either
22 │ * @comment -- "A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status" 23 │ * @comment -- "confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes" 24 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "scanEvidence() interpolates scan-report-controlled strings — template_id, title, matched_patterns, request, response — into the @confirmed description writeConfirmedLine() splices into a source file; a newline in any of them ends the annotation and places report-controlled text on the next line of someone's doc-block, in the syntax their threat model is parsed from" 25 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "Sanitisation covers every grammar the text passes through, not only ours. GuardLink's: every scan-controlled value goes through oneLine() at the one boundary it enters through (scanEvidence) rather than per interpolation, then escapeDesc() for quotes and backslashes, and the line that will actually be written is re-parsed and refused unless it reads back as exactly one @confirmed. The HOST language's: commentFormAt() derives the comment form from the SOURCE LINE being written into — not from the file extension, which is a proxy the stripper does not share — and that one derivation both breaks the closer that would end the comment and reproduces the terminator when the @exposes closes its own comment, since the re-parse reads the bare annotation outside the comment it is spliced into and is blind to either" 26 │ * @validates #input-sanitize for #cli -- "tests/hypothesis.test.ts drives scan-controlled newlines and each host block closer through the real write path — including a language absent from the extension fallback and a self-closing comment — and asserts with src/structure that the host file still resolves the symbol it documents, which the annotation re-parse alone cannot see" 27 │ * @audit #cli -- "The stale bucket refuses a join rather than guessing it, and offers no by-hand target on purpose: the claims the coarse tiers would have named are different claims, so recording this evidence against one is the confirmation the key just refused. The malformed bucket refuses for the same reason one level earlier. The CLI exits non-zero when any finding lands in either" 28 │ */ 29 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 30 │ import { resolve, sep } from 'node:path'; 31 │ import type { ThreatModel, SourceLocation } from '../types/index.js'; 32 │ import { hasEvidenceWords } from '../gate/lint.js';
L4 handles #cli: internal
Evidence strings printed to the terminal
1 │ /** 2 │ * GuardLink Hypotheses — text for the terminal. 3 │ * 4 │ * @handles internal on #cli -- "Evidence strings printed to the terminal" 5 │ * @comment -- "Plain padded tables like printStatus; nothing here reads a file" 6 │ * @comment -- "A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does" 7 │ * @comment -- "formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same" 8 │ */ 9 │ import type { HypothesisClassification, HypothesisRecord, RankedHypothesis } from './classify.js';
L5 comment Plain padded tables like printStatus; nothing here reads a file
Plain padded tables like printStatus; nothing here reads a file
1 │ /** 2 │ * GuardLink Hypotheses — text for the terminal. 3 │ * 4 │ * @handles internal on #cli -- "Evidence strings printed to the terminal" 5 │ * @comment -- "Plain padded tables like printStatus; nothing here reads a file" 6 │ * @comment -- "A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does" 7 │ * @comment -- "formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same" 8 │ */ 9 │ import type { HypothesisClassification, HypothesisRecord, RankedHypothesis } from './classify.js'; 10 │ import type { HypothesisEntry, BoundaryEntry } from './ledger.js';
L6 comment A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does
A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does
1 │ /** 2 │ * GuardLink Hypotheses — text for the terminal. 3 │ * 4 │ * @handles internal on #cli -- "Evidence strings printed to the terminal" 5 │ * @comment -- "Plain padded tables like printStatus; nothing here reads a file" 6 │ * @comment -- "A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does" 7 │ * @comment -- "formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same" 8 │ */ 9 │ import type { HypothesisClassification, HypothesisRecord, RankedHypothesis } from './classify.js'; 10 │ import type { HypothesisEntry, BoundaryEntry } from './ledger.js'; 11 │ import type { BoundaryClaimClassification, BoundaryClaimRecord } from './boundary.js';
L7 comment formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same
formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same
2 │ * GuardLink Hypotheses — text for the terminal. 3 │ * 4 │ * @handles internal on #cli -- "Evidence strings printed to the terminal" 5 │ * @comment -- "Plain padded tables like printStatus; nothing here reads a file" 6 │ * @comment -- "A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does" 7 │ * @comment -- "formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same" 8 │ */ 9 │ import type { HypothesisClassification, HypothesisRecord, RankedHypothesis } from './classify.js'; 10 │ import type { HypothesisEntry, BoundaryEntry } from './ledger.js'; 11 │ import type { BoundaryClaimClassification, BoundaryClaimRecord } from './boundary.js'; 12 │ import type { ImportResult, ScanFinding } from './commands.js';
L37 comment The header counted the rows it was handed, so once -n bounded every renderer a 142-claim queue printed 10 to test: not a smaller answer to the same question but a wrong answer to it, because that line is the only place the table says how much there is. It names both numbers when the page is short of the queue, and reads exactly as it always did when it is not — a run that hides nothing must not grow a notice about hiding. `total` is REQUIRED and deliberately carries no default: defaulting it to q.length hands a caller who forgets it the permissive answer — truncated false, and a 10-of-142 page reporting `10 to test` — which is the wrong number this renderer exists to stop, reinstalled as a silent default. Required, an omission is a build failure instead of a confident wrong answer
The header counted the rows it was handed, so once -n bounded every renderer a 142-claim queue printed 10 to test: not a smaller answer to the same question but a wrong answer to it, because that line is the only place the table says how much there is. It names both numbers when the page is short of the queue, and reads exactly as it always did when it is not — a run that hides nothing must not grow a notice about hiding. `total` is REQUIRED and deliberately carries no default: defaulting it to q.length hands a caller who forgets it the permissive answer — truncated false, and a 10-of-142 page reporting `10 to test` — which is the wrong number this renderer exists to stop, reinstalled as a silent default. Required, an omission is a build failure instead of a confident wrong answer
32 │ } 33 │ 34 │ /** 35 │ * The queue as a table. `total` is the queue length BEFORE `-n` bounded it. 36 │ * 37 │ * @comment -- "The header counted the rows it was handed, so once -n bounded every renderer a 142-claim queue printed 10 to test: not a smaller answer to the same question but a wrong answer to it, because that line is the only place the table says how much there is. It names both numbers when the page is short of the queue, and reads exactly as it always did when it is not — a run that hides nothing must not grow a notice about hiding. `total` is REQUIRED and deliberately carries no default: defaulting it to q.length hands a caller who forgets it the permissive answer — truncated false, and a 10-of-142 page reporting `10 to test` — which is the wrong number this renderer exists to stop, reinstalled as a silent default. Required, an omission is a build failure instead of a confident wrong answer" 38 │ * @comment -- "`total` decides EVERY branch here, the empty page included. The early return is the one sentence in this function that names a state rather than counting — it says every exposure has an outcome that still holds — so it is owed to `total === 0` and not to an empty page: `formatQueue([], 142)` asked the queue for nothing and got nothing back, which says where the page ended, never that there is nothing outstanding. That branch was the last place in this file where a required total was accepted and then not read, and an empty page over a non-empty queue now falls through to the ordinary header and renders `0 of 142 to test`, which counts and claims nothing" 39 │ */ 40 │ export function formatQueue(q: RankedHypothesis[], total: number): string { 41 │ if (total === 0) return 'Nothing to test: every exposure has an outcome that still holds.'; 42 │ const lines = [q.length < total ? `${q.length} of ${total} to test` : `${total} to test`, '', ` ${pad('#', 4)}${pad('state', 9)}${pad('severity', 10)}${pad('asset', 18)}${pad('threat', 22)}${pad('where', 34)}why`];
L38 comment `total` decides EVERY branch here, the empty page included. The early return is the one sentence in this function that names a state rather than counting — it says every exposure has an outcome that still holds — so it is owed to `total === 0` and not to an empty page: `formatQueue([], 142)` asked the queue for nothing and got nothing back, which says where the page ended, never that there is nothing outstanding. That branch was the last place in this file where a required total was accepted and then not read, and an empty page over a non-empty queue now falls through to the ordinary header and renders `0 of 142 to test`, which counts and claims nothing
`total` decides EVERY branch here, the empty page included. The early return is the one sentence in this function that names a state rather than counting — it says every exposure has an outcome that still holds — so it is owed to `total === 0` and not to an empty page: `formatQueue([], 142)` asked the queue for nothing and got nothing back, which says where the page ended, never that there is nothing outstanding. That branch was the last place in this file where a required total was accepted and then not read, and an empty page over a non-empty queue now falls through to the ordinary header and renders `0 of 142 to test`, which counts and claims nothing
33 │ 34 │ /** 35 │ * The queue as a table. `total` is the queue length BEFORE `-n` bounded it. 36 │ * 37 │ * @comment -- "The header counted the rows it was handed, so once -n bounded every renderer a 142-claim queue printed 10 to test: not a smaller answer to the same question but a wrong answer to it, because that line is the only place the table says how much there is. It names both numbers when the page is short of the queue, and reads exactly as it always did when it is not — a run that hides nothing must not grow a notice about hiding. `total` is REQUIRED and deliberately carries no default: defaulting it to q.length hands a caller who forgets it the permissive answer — truncated false, and a 10-of-142 page reporting `10 to test` — which is the wrong number this renderer exists to stop, reinstalled as a silent default. Required, an omission is a build failure instead of a confident wrong answer" 38 │ * @comment -- "`total` decides EVERY branch here, the empty page included. The early return is the one sentence in this function that names a state rather than counting — it says every exposure has an outcome that still holds — so it is owed to `total === 0` and not to an empty page: `formatQueue([], 142)` asked the queue for nothing and got nothing back, which says where the page ended, never that there is nothing outstanding. That branch was the last place in this file where a required total was accepted and then not read, and an empty page over a non-empty queue now falls through to the ordinary header and renders `0 of 142 to test`, which counts and claims nothing" 39 │ */ 40 │ export function formatQueue(q: RankedHypothesis[], total: number): string { 41 │ if (total === 0) return 'Nothing to test: every exposure has an outcome that still holds.'; 42 │ const lines = [q.length < total ? `${q.length} of ${total} to test` : `${total} to test`, '', ` ${pad('#', 4)}${pad('state', 9)}${pad('severity', 10)}${pad('asset', 18)}${pad('threat', 22)}${pad('where', 34)}why`]; 43 │ for (const r of q) {
L55 comment A bounded brief that does not say it is bounded is a misleading brief. This one closes by telling an operator to hand it to bugb intake, so a 10-item page of a 142-claim queue reads as the whole of what there is to test and the 132 it never names read as nothing left to do. Both numbers are stated above the list and again on that closing line, since either can be the one an operator reads, along with the flag that asks for the rest. An untruncated brief is unchanged, down to the byte. The notice COUNTS the remainder and deliberately names no state for it: rankUntested queues untested and retest together, so a state noun here can misdescribe what is hidden — calling a claim that was confirmed, and whose code has since moved, untested, in the very line an operator hands to bugb intake. Membership is decided by that filter and is free to change without anyone reading this string; a line that only counts cannot fall out of step with what it counts, and the per-item labels below keep the composition legible where it belongs. The remainder clause agrees in number, because a remainder of exactly one is an ordinary count and `The other 1 are` is the kind of seam that tells a reader the sentence was never run at that value. The page size stays a numeral even when it is one: a bounded notice has to name both numbers, so it is not dropped for grammar. `total` is REQUIRED here for the same reason as in formatQueue — a default would return the permissive answer to a caller who forgot it, in the one renderer an operator hands to bugb intake
A bounded brief that does not say it is bounded is a misleading brief. This one closes by telling an operator to hand it to bugb intake, so a 10-item page of a 142-claim queue reads as the whole of what there is to test and the 132 it never names read as nothing left to do. Both numbers are stated above the list and again on that closing line, since either can be the one an operator reads, along with the flag that asks for the rest. An untruncated brief is unchanged, down to the byte. The notice COUNTS the remainder and deliberately names no state for it: rankUntested queues untested and retest together, so a state noun here can misdescribe what is hidden — calling a claim that was confirmed, and whose code has since moved, untested, in the very line an operator hands to bugb intake. Membership is decided by that filter and is free to change without anyone reading this string; a line that only counts cannot fall out of step with what it counts, and the per-item labels below keep the composition legible where it belongs. The remainder clause agrees in number, because a remainder of exactly one is an ordinary count and `The other 1 are` is the kind of seam that tells a reader the sentence was never run at that value. The page size stays a numeral even when it is one: a bounded notice has to name both numbers, so it is not dropped for grammar. `total` is REQUIRED here for the same reason as in formatQueue — a default would return the permissive answer to a caller who forgot it, in the one renderer an operator hands to bugb intake
50 │ 51 │ /** 52 │ * The queue as a brief `bugb intake` can take. `total` is the queue length 53 │ * BEFORE `-n` bounded it. 54 │ * 55 │ * @comment -- "A bounded brief that does not say it is bounded is a misleading brief. This one closes by telling an operator to hand it to bugb intake, so a 10-item page of a 142-claim queue reads as the whole of what there is to test and the 132 it never names read as nothing left to do. Both numbers are stated above the list and again on that closing line, since either can be the one an operator reads, along with the flag that asks for the rest. An untruncated brief is unchanged, down to the byte. The notice COUNTS the remainder and deliberately names no state for it: rankUntested queues untested and retest together, so a state noun here can misdescribe what is hidden — calling a claim that was confirmed, and whose code has since moved, untested, in the very line an operator hands to bugb intake. Membership is decided by that filter and is free to change without anyone reading this string; a line that only counts cannot fall out of step with what it counts, and the per-item labels below keep the composition legible where it belongs. The remainder clause agrees in number, because a remainder of exactly one is an ordinary count and `The other 1 are` is the kind of seam that tells a reader the sentence was never run at that value. The page size stays a numeral even when it is one: a bounded notice has to name both numbers, so it is not dropped for grammar. `total` is REQUIRED here for the same reason as in formatQueue — a default would return the permissive answer to a caller who forgot it, in the one renderer an operator hands to bugb intake" 56 │ */ 57 │ export function formatIntake(q: RankedHypothesis[], project: string, total: number): string { 58 │ const truncated = q.length < total; 59 │ const hidden = total - q.length; 60 │ const lines = [`# Test plan for ${project} — from guardlink hypothesis next`, '', 'Test these exposures in order. Each is a GuardLink claim; record the result with `guardlink hypothesis confirm|refute <file:line> --evidence "…"` or import the scan with `--from-scan`.', ''];
L176 comment One block per FINDING, deliberately — the header counts findings and two probes hitting one claim is worth seeing. So a claim several findings joined to appears more than once, each block showing the entry that finding produced. Withholding and the exit code are the opposite case: those are decisions about a CLAIM, are derived per claim in the CLI, and must not be read off a per-finding list
One block per FINDING, deliberately — the header counts findings and two probes hitting one claim is worth seeing. So a claim several findings joined to appears more than once, each block showing the entry that finding produced. Withholding and the exit code are the opposite case: those are decisions about a CLAIM, are derived per claim in the CLI, and must not be read off a per-finding list
171 │ if (r.confirmed.length > 0 && !anyStamp) { 172 │ lines.push('', `⚠ No finding in this report carried a claim key, so every join below matched where a claim sits rather than which claim it is. A claim that came to occupy a tested line cannot be told from the claim that was tested. Have the scanner forward ${CLAIM_KEY_FINGERPRINT} from the SARIF export.`); 173 │ } else if (stamped < r.confirmed.length) { 174 │ lines.push('', `⚠ ${r.confirmed.length - stamped} of ${r.confirmed.length} findings carried no claim key and were joined on the weaker match; each is labelled below.`); 175 │ } 176 │ // @comment -- "One block per FINDING, deliberately — the header counts findings and two probes hitting one claim is worth seeing. So a claim several findings joined to appears more than once, each block showing the entry that finding produced. Withholding and the exit code are the opposite case: those are decisions about a CLAIM, are derived per claim in the CLI, and must not be read off a per-finding list" 177 │ for (const c of r.confirmed) lines.push('', formatOutcome(c.record, c.entry)); 178 │ for (const b of r.boundaries) lines.push('', formatBoundaryOutcome(b.record, b.entry)); 179 │ // @comment -- "This formatter emits NO model coordinate. It runs before --write splices anything, so every line it could name is a pre-write position, and a target handed to an operator has to name where the claim is once this run is finished — a key-verified write higher up the same file moves an ambiguous candidate as readily as it moves a withheld one. The by-hand commands for an ambiguous finding therefore live in the CLI beside the withheld ones, after the writes, through the one afterWrites calculation. The only file:line left below is the SCAN REPORT's own, which is correct as the report's claim about what it tested and is not ours to shift" 180 │ for (const s of r.stale) { 181 │ const at = s.finding.annotation ? `, stamped at ${s.finding.annotation.file}:${s.finding.annotation.line}` : '';
L179 comment This formatter emits NO model coordinate. It runs before --write splices anything, so every line it could name is a pre-write position, and a target handed to an operator has to name where the claim is once this run is finished — a key-verified write higher up the same file moves an ambiguous candidate as readily as it moves a withheld one. The by-hand commands for an ambiguous finding therefore live in the CLI beside the withheld ones, after the writes, through the one afterWrites calculation. The only file:line left below is the SCAN REPORT's own, which is correct as the report's claim about what it tested and is not ours to shift
This formatter emits NO model coordinate. It runs before --write splices anything, so every line it could name is a pre-write position, and a target handed to an operator has to name where the claim is once this run is finished — a key-verified write higher up the same file moves an ambiguous candidate as readily as it moves a withheld one. The by-hand commands for an ambiguous finding therefore live in the CLI beside the withheld ones, after the writes, through the one afterWrites calculation. The only file:line left below is the SCAN REPORT's own, which is correct as the report's claim about what it tested and is not ours to shift
174 │ lines.push('', `⚠ ${r.confirmed.length - stamped} of ${r.confirmed.length} findings carried no claim key and were joined on the weaker match; each is labelled below.`); 175 │ } 176 │ // @comment -- "One block per FINDING, deliberately — the header counts findings and two probes hitting one claim is worth seeing. So a claim several findings joined to appears more than once, each block showing the entry that finding produced. Withholding and the exit code are the opposite case: those are decisions about a CLAIM, are derived per claim in the CLI, and must not be read off a per-finding list" 177 │ for (const c of r.confirmed) lines.push('', formatOutcome(c.record, c.entry)); 178 │ for (const b of r.boundaries) lines.push('', formatBoundaryOutcome(b.record, b.entry)); 179 │ // @comment -- "This formatter emits NO model coordinate. It runs before --write splices anything, so every line it could name is a pre-write position, and a target handed to an operator has to name where the claim is once this run is finished — a key-verified write higher up the same file moves an ambiguous candidate as readily as it moves a withheld one. The by-hand commands for an ambiguous finding therefore live in the CLI beside the withheld ones, after the writes, through the one afterWrites calculation. The only file:line left below is the SCAN REPORT's own, which is correct as the report's claim about what it tested and is not ours to shift" 180 │ for (const s of r.stale) { 181 │ const at = s.finding.annotation ? `, stamped at ${s.finding.annotation.file}:${s.finding.annotation.line}` : ''; 182 │ if (s.boundary) { 183 │ lines.push('', `Stale ${s.finding.id} (${s.finding.template_id}) was tested against a boundary that is no longer in the model — deleted, or its sides, id, description or file edited.`); 184 │ lines.push(` boundary claim key ${short(s.finding.boundary_claim_key ?? '', 24)}${at}`);
L4 comment The tested state of every exposure and declared boundary: a ledger of outcomes with evidence, expiry by code hash, and a ranked queue
The tested state of every exposure and declared boundary: a ledger of outcomes with evidence, expiry by code hash, and a ranked queue
1 │ /** 2 │ * GuardLink Hypotheses — barrel. 3 │ * 4 │ * @comment -- "The tested state of every exposure and declared boundary: a ledger of outcomes with evidence, expiry by code hash, and a ranked queue" 5 │ */ 6 │ export { HYPOTHESES_FILE, HYPOTHESES_SCHEMA, readHypotheses, writeHypotheses, emptyHypotheses, serializeHypotheses } from './ledger.js'; 7 │ export type { HypothesisOutcome, HypothesisSource, HypothesisAnchor, HypothesisOutcomeRecord, HypothesisEntry, HypothesesLedger, HypothesesStatus, HypothesesRead, BoundaryOutcome, BoundaryEntry, LedgerEntry, LedgerOutcome } from './ledger.js'; 8 │ export { classifyBoundaryClaims, resolveBoundaryTarget } from './boundary.js'; 9 │ export type { BoundaryClaimState, BoundaryClaimRecord, BoundaryClaimSummary, BoundaryClaimClassification } from './boundary.js';
L18 exposes #cli → #arbitrary-write
writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved
13 │ * through). Both live in one `entries` list keyed by claim key; the outcome 14 │ * says which kind an entry is, and one entry never mixes the two. A reader 15 │ * that predates boundary outcomes rejects a ledger holding one as corrupt 16 │ * rather than dropping it on its next write. 17 │ * 18 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved" 19 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes" 20 │ * @flows LedgerFile -> #cli via readHypotheses -- "Outcomes read back for classification" 21 │ * @flows #cli -> LedgerFile via writeHypotheses -- "Outcomes recorded" 22 │ * @handles internal on #cli -- "Evidence strings from tests and scans; scan evidence is redacted before it is written" 23 │ * @comment -- "A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones"
L19 mitigates #path-validation mitigates #arbitrary-write
The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes
14 │ * says which kind an entry is, and one entry never mixes the two. A reader 15 │ * that predates boundary outcomes rejects a ledger holding one as corrupt 16 │ * rather than dropping it on its next write. 17 │ * 18 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved" 19 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes" 20 │ * @flows LedgerFile -> #cli via readHypotheses -- "Outcomes read back for classification" 21 │ * @flows #cli -> LedgerFile via writeHypotheses -- "Outcomes recorded" 22 │ * @handles internal on #cli -- "Evidence strings from tests and scans; scan evidence is redacted before it is written" 23 │ * @comment -- "A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones" 24 │ */
L20 flow LedgerFile → #cli
Outcomes read back for classification
15 │ * that predates boundary outcomes rejects a ledger holding one as corrupt 16 │ * rather than dropping it on its next write. 17 │ * 18 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved" 19 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes" 20 │ * @flows LedgerFile -> #cli via readHypotheses -- "Outcomes read back for classification" 21 │ * @flows #cli -> LedgerFile via writeHypotheses -- "Outcomes recorded" 22 │ * @handles internal on #cli -- "Evidence strings from tests and scans; scan evidence is redacted before it is written" 23 │ * @comment -- "A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
L21 flow #cli → LedgerFile
Outcomes recorded
16 │ * rather than dropping it on its next write. 17 │ * 18 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved" 19 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes" 20 │ * @flows LedgerFile -> #cli via readHypotheses -- "Outcomes read back for classification" 21 │ * @flows #cli -> LedgerFile via writeHypotheses -- "Outcomes recorded" 22 │ * @handles internal on #cli -- "Evidence strings from tests and scans; scan evidence is redacted before it is written" 23 │ * @comment -- "A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { dirname, join } from 'node:path';
L22 handles #cli: internal
Evidence strings from tests and scans; scan evidence is redacted before it is written
17 │ * 18 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved" 19 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes" 20 │ * @flows LedgerFile -> #cli via readHypotheses -- "Outcomes read back for classification" 21 │ * @flows #cli -> LedgerFile via writeHypotheses -- "Outcomes recorded" 22 │ * @handles internal on #cli -- "Evidence strings from tests and scans; scan evidence is redacted before it is written" 23 │ * @comment -- "A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { dirname, join } from 'node:path'; 27 │ import type { AnchorScope } from '../types/index.js';
L23 comment A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones
A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones
18 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeHypotheses() writes .guardlink/hypotheses.json under the root the caller resolved" 19 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is fixed relative to the project root (HYPOTHESES_FILE); nothing in an entry chooses where the file goes" 20 │ * @flows LedgerFile -> #cli via readHypotheses -- "Outcomes read back for classification" 21 │ * @flows #cli -> LedgerFile via writeHypotheses -- "Outcomes recorded" 22 │ * @handles internal on #cli -- "Evidence strings from tests and scans; scan evidence is redacted before it is written" 23 │ * @comment -- "A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { dirname, join } from 'node:path'; 27 │ import type { AnchorScope } from '../types/index.js'; 28 │ import { ANCHOR_HASH_VERSION } from '../structure/hash.js';
L5 exposes #init → #path-traversal
Reads package.json, pyproject.toml, etc. from root
1 │ /** 2 │ * GuardLink init — Project detection utilities. 3 │ * Detects language, project name, and existing agent instruction files. 4 │ * 5 │ * @exposes #init to #path-traversal [low] cwe:CWE-22 -- "Reads package.json, pyproject.toml, etc. from root" 6 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with root constrains; reads well-known files only" 7 │ * @flows ProjectRoot -> #init via detectProject -- "Project detection input" 8 │ * @comment -- "Detection is read-only; no file writes" 9 │ */ 10 │
L6 mitigates #path-validation mitigates #path-traversal
join() with root constrains; reads well-known files only
1 │ /** 2 │ * GuardLink init — Project detection utilities. 3 │ * Detects language, project name, and existing agent instruction files. 4 │ * 5 │ * @exposes #init to #path-traversal [low] cwe:CWE-22 -- "Reads package.json, pyproject.toml, etc. from root" 6 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with root constrains; reads well-known files only" 7 │ * @flows ProjectRoot -> #init via detectProject -- "Project detection input" 8 │ * @comment -- "Detection is read-only; no file writes" 9 │ */ 10 │ 11 │ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
L7 flow ProjectRoot → #init
Project detection input
2 │ * GuardLink init — Project detection utilities. 3 │ * Detects language, project name, and existing agent instruction files. 4 │ * 5 │ * @exposes #init to #path-traversal [low] cwe:CWE-22 -- "Reads package.json, pyproject.toml, etc. from root" 6 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with root constrains; reads well-known files only" 7 │ * @flows ProjectRoot -> #init via detectProject -- "Project detection input" 8 │ * @comment -- "Detection is read-only; no file writes" 9 │ */ 10 │ 11 │ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; 12 │ import { join, basename } from 'node:path';
L8 comment Detection is read-only; no file writes
Detection is read-only; no file writes
3 │ * Detects language, project name, and existing agent instruction files. 4 │ * 5 │ * @exposes #init to #path-traversal [low] cwe:CWE-22 -- "Reads package.json, pyproject.toml, etc. from root" 6 │ * @mitigates #init against #path-traversal using #path-validation -- "join() with root constrains; reads well-known files only" 7 │ * @flows ProjectRoot -> #init via detectProject -- "Project detection input" 8 │ * @comment -- "Detection is read-only; no file writes" 9 │ */ 10 │ 11 │ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; 12 │ import { join, basename } from 'node:path'; 13 │
L9 comment Migrations should never modify existing user files. Only create missing ones.
Migrations should never modify existing user files. Only create missing ones.
4 │ * Small idempotent operations for projects upgrading from older versions of 5 │ * GuardLink that don't have the full v1.5.x layout. Designed to be safe to 6 │ * call repeatedly and to fail closed (do nothing) when the project doesn't 7 │ * have a `.guardlink/` directory at all. 8 │ * 9 │ * @comment -- "Migrations should never modify existing user files. Only create missing ones." 10 │ */ 11 │ 12 │ import { existsSync, writeFileSync } from 'node:fs'; 13 │ import { join } from 'node:path'; 14 │ import { detectProject } from './detect.js';
L16 exposes #init → #arbitrary-write
Decides whether init may overwrite an existing definitions/config file
11 │ * authored content. A definitions file holding declarations the template does 12 │ * not, and a `config.json` carrying settings the template does not, are user 13 │ * work product. They survive `--force` and are reported as preserved. Genuine 14 │ * overwrite intent is spelled `--reset`, which is its own confirmation. 15 │ * 16 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Decides whether init may overwrite an existing definitions/config file" 17 │ * @mitigates #init against #arbitrary-write using #config-validation -- "Overwrite refused when the target parses to declarations the template lacks; --reset required to override" 18 │ * @flows DefinitionsFile -> #init via definitionsArePopulated -- "Existing declarations read to decide preservation" 19 │ * @flows ConfigFile -> #init via configIsCustomised -- "Existing config compared against template defaults" 20 │ * @comment -- "Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty" 21 │ */
L17 mitigates #config-validation mitigates #arbitrary-write
Overwrite refused when the target parses to declarations the template lacks; --reset required to override
12 │ * not, and a `config.json` carrying settings the template does not, are user 13 │ * work product. They survive `--force` and are reported as preserved. Genuine 14 │ * overwrite intent is spelled `--reset`, which is its own confirmation. 15 │ * 16 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Decides whether init may overwrite an existing definitions/config file" 17 │ * @mitigates #init against #arbitrary-write using #config-validation -- "Overwrite refused when the target parses to declarations the template lacks; --reset required to override" 18 │ * @flows DefinitionsFile -> #init via definitionsArePopulated -- "Existing declarations read to decide preservation" 19 │ * @flows ConfigFile -> #init via configIsCustomised -- "Existing config compared against template defaults" 20 │ * @comment -- "Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty" 21 │ */ 22 │
L18 flow DefinitionsFile → #init
Existing declarations read to decide preservation
13 │ * work product. They survive `--force` and are reported as preserved. Genuine 14 │ * overwrite intent is spelled `--reset`, which is its own confirmation. 15 │ * 16 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Decides whether init may overwrite an existing definitions/config file" 17 │ * @mitigates #init against #arbitrary-write using #config-validation -- "Overwrite refused when the target parses to declarations the template lacks; --reset required to override" 18 │ * @flows DefinitionsFile -> #init via definitionsArePopulated -- "Existing declarations read to decide preservation" 19 │ * @flows ConfigFile -> #init via configIsCustomised -- "Existing config compared against template defaults" 20 │ * @comment -- "Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty" 21 │ */ 22 │ 23 │ import { parseString } from '../parser/parse-file.js';
L19 flow ConfigFile → #init
Existing config compared against template defaults
14 │ * overwrite intent is spelled `--reset`, which is its own confirmation. 15 │ * 16 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Decides whether init may overwrite an existing definitions/config file" 17 │ * @mitigates #init against #arbitrary-write using #config-validation -- "Overwrite refused when the target parses to declarations the template lacks; --reset required to override" 18 │ * @flows DefinitionsFile -> #init via definitionsArePopulated -- "Existing declarations read to decide preservation" 19 │ * @flows ConfigFile -> #init via configIsCustomised -- "Existing config compared against template defaults" 20 │ * @comment -- "Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty" 21 │ */ 22 │ 23 │ import { parseString } from '../parser/parse-file.js'; 24 │
L20 comment Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty
Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty
15 │ * 16 │ * @exposes #init to #arbitrary-write [high] cwe:CWE-73 -- "Decides whether init may overwrite an existing definitions/config file" 17 │ * @mitigates #init against #arbitrary-write using #config-validation -- "Overwrite refused when the target parses to declarations the template lacks; --reset required to override" 18 │ * @flows DefinitionsFile -> #init via definitionsArePopulated -- "Existing declarations read to decide preservation" 19 │ * @flows ConfigFile -> #init via configIsCustomised -- "Existing config compared against template defaults" 20 │ * @comment -- "Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty" 21 │ */ 22 │ 23 │ import { parseString } from '../parser/parse-file.js'; 24 │ 25 │ /**
L40 shield Placement examples for both modes; they would otherwise parse as real annotations
35 │ * Shielded because of D22. The inline branch shows a doc-block, whose ` * @…` 36 │ * lines are indistinguishable from real annotations once the comment prefix is 37 │ * stripped — that is exactly how the GL-402 README template injected `#api`, 38 │ * `#sqli` and `cwe:CWE-89` into this repo's own model. 39 │ */ 40 │ // @shield:begin -- "Placement examples for both modes; they would otherwise parse as real annotations" 41 │ export function annotationPlacementSection( 42 │ project: ProjectInfo, 43 │ mode: AnnotationMode | null, 44 │ ): string { 45 │ // A repo with annotations in both places. This is the exact state D27 said the
L123 shield Shielded region
118 │ ${example} 119 │ ${notRead} 120 │ Do not create \`.gal\` sidecars under \`.guardlink/annotations/\` in this mode; a repo with 121 │ both is a mixed repo, and that is the failure this section exists to prevent.`; 122 │ } 123 │ // @shield:end 124 │ 125 │ // ─── Inline placement, in the host language's real doc-block ───────── 126 │ 127 │ // @shield:begin -- "Placement examples per language; every verb line below is a template, not a claim" 128 │ /**
L127 shield Placement examples per language; every verb line below is a template, not a claim
122 │ } 123 │ // @shield:end 124 │ 125 │ // ─── Inline placement, in the host language's real doc-block ───────── 126 │ 127 │ // @shield:begin -- "Placement examples per language; every verb line below is a template, not a claim" 128 │ /** 129 │ * The doc-block an author of this language actually writes, and the forms this 130 │ * parser does not read. 131 │ * 132 │ * The old version of this offered two examples — a `#` one and a JSDoc one —
L194 shield Shielded region
189 │ case 'unknown': 190 │ default: 191 │ return { example: block('ts', 'export function login(email: string) { … }'), notRead: '' }; 192 │ } 193 │ } 194 │ // @shield:end 195 │ 196 │ // ─── Canonical reference document ──────────────────────────────────── 197 │ 198 │ /** 199 │ * docs/GUARDLINK_REFERENCE.md — the single source of truth for annotation syntax.
L225 shield The annotation reference's own syntax block: verb lines at column 0 with no comment marker, which is what an annotation lost inside a docstring looks like
220 │ /** `--no-root-files` keeps everything inside `.guardlink/`; otherwise `docs/`. */ 221 │ export function referenceDocPath(rootFiles: boolean): string { 222 │ return rootFiles ? REFERENCE_DOC_IN_DOCS : REFERENCE_DOC_IN_GUARDLINK; 223 │ } 224 │ 225 │ // @shield:begin -- "The annotation reference's own syntax block: verb lines at column 0 with no comment marker, which is what an annotation lost inside a docstring looks like" 226 │ export function referenceDocContent(project: ProjectInfo): string { 227 │ return `# GuardLink — Annotation Reference 228 │ 229 │ > Canonical reference for **${project.name}**. All agent instruction files point here. 230 │ > Full specification: [docs/SPEC.md](https://github.com/Bugb-Technologies/guardlink/blob/main/docs/SPEC.md)
L330 shield Shielded region
325 │ - \`guardlink_validate\` — check for syntax errors 326 │ - \`guardlink_status\` — coverage stats 327 │ - \`guardlink_entitlement_propose\` / \`guardlink_entitlement_list\` — propose an \`@entitles\` claim and see what happened to it. There is no accept tool: a human accepts, with \`guardlink entitle\`. 328 │ `; 329 │ } 330 │ // @shield:end 331 │ 332 │ // ─── Agent instruction content (compact — points to reference doc) ─── 333 │ 334 │ /** 335 │ * The opening for a repository that has a model. Unchanged text, now with a
L398 shield agentInstructions renders the Quick Syntax block: verb lines at column 0 that document the grammar rather than claim anything
393 │ | ask how far along this is | \`guardlink status .\` — coverage, and which files carry nothing | 394 │ 395 │ Without MCP, the same answers come from \`guardlink status .\` and \`guardlink validate .\`.`; 396 │ } 397 │ 398 │ // @shield:begin -- "agentInstructions renders the Quick Syntax block: verb lines at column 0 that document the grammar rather than claim anything" 399 │ /** 400 │ * What this block is allowed to claim about the repository it is written into. 401 │ * 402 │ * Both fields answer the same question — is the sentence I am about to write 403 │ * true HERE — and both default to the conservative answer, because this block
L552 shield Shielded region
547 │ --file common/archiver/filestore/archiver.go --line 61 \\ 548 │ --rationale "By design: the archival URI is namespace configuration. Authz: common/api/metadata.go:189" 549 │ \`\`\` 550 │ `.trimStart(); 551 │ } 552 │ // @shield:end 553 │ 554 │ // ─── Model-aware instruction block (for sync) ────────────────────── 555 │ 556 │ /** 557 │ * Build a threat model context section that gets embedded into agent instructions.
L1047 shield README example annotations, excluded from parsing — they would otherwise register as real records
1042 │ * Deliberately carries no wall-clock timestamp. It is regenerated on every1043 │ * `guardlink sync`, and a timestamp would make a tracked file churn on every1044 │ * run while telling the reader nothing they cannot get from `annotation_hash`,1045 │ * which changes only when the annotations do.1046 │ */1047 │ // @shield:begin -- "README example annotations, excluded from parsing — they would otherwise register as real records"1048 │ export function guardlinkReadmeContent(project: ProjectInfo, ctx: ReadmeContext): string {1049 │ const defs = `definitions${project.definitionsExt}`;1050 │ const m = ctx.model;1051 │ const external = ctx.mode === 'external';1052 │ const referencePath = ctx.referencePath;
L1363 shield Shielded region
1358 │ For CWE queries, check \`external_id.declared\` before reading \`count: 0\` as coverage:1359 │ \`false\` means this model has never heard of that weakness class, which is not the same1360 │ as declaring it and finding nothing exposed.1361 │ `;1362 │ }1363 │ // @shield:end1364 │
L17 exposes #mcp → #path-traversal
Caller-supplied file path is resolved against the project root
12 │ * 13 │ * The status vocabulary is the load-bearing part. "No annotations" and "never 14 │ * looked at" are different answers, and returning an empty result for both is 15 │ * how a caller concludes a file is clean when the parser simply never read it. 16 │ * 17 │ * @exposes #mcp to #path-traversal [medium] cwe:CWE-22 -- "Caller-supplied file path is resolved against the project root" 18 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Paths are resolved then required to stay inside root; outside paths are reported, never read" 19 │ * @flows MCPClient -> #mcp via guardlink_context -- "File path input" 20 │ * @flows ThreatModel -> #mcp via fileContext -- "Model grouped by location.file" 21 │ * @comment -- "Pure projection over the parsed model; the only I/O is an existence check performed by the caller" 22 │ */
L18 mitigates #path-validation mitigates #path-traversal
Paths are resolved then required to stay inside root; outside paths are reported, never read
13 │ * The status vocabulary is the load-bearing part. "No annotations" and "never 14 │ * looked at" are different answers, and returning an empty result for both is 15 │ * how a caller concludes a file is clean when the parser simply never read it. 16 │ * 17 │ * @exposes #mcp to #path-traversal [medium] cwe:CWE-22 -- "Caller-supplied file path is resolved against the project root" 18 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Paths are resolved then required to stay inside root; outside paths are reported, never read" 19 │ * @flows MCPClient -> #mcp via guardlink_context -- "File path input" 20 │ * @flows ThreatModel -> #mcp via fileContext -- "Model grouped by location.file" 21 │ * @comment -- "Pure projection over the parsed model; the only I/O is an existence check performed by the caller" 22 │ */ 23 │
L19 flow MCPClient → #mcp
File path input
14 │ * looked at" are different answers, and returning an empty result for both is 15 │ * how a caller concludes a file is clean when the parser simply never read it. 16 │ * 17 │ * @exposes #mcp to #path-traversal [medium] cwe:CWE-22 -- "Caller-supplied file path is resolved against the project root" 18 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Paths are resolved then required to stay inside root; outside paths are reported, never read" 19 │ * @flows MCPClient -> #mcp via guardlink_context -- "File path input" 20 │ * @flows ThreatModel -> #mcp via fileContext -- "Model grouped by location.file" 21 │ * @comment -- "Pure projection over the parsed model; the only I/O is an existence check performed by the caller" 22 │ */ 23 │ 24 │ import { lookup, type LookupResult } from './lookup.js';
L20 flow ThreatModel → #mcp
Model grouped by location.file
15 │ * how a caller concludes a file is clean when the parser simply never read it. 16 │ * 17 │ * @exposes #mcp to #path-traversal [medium] cwe:CWE-22 -- "Caller-supplied file path is resolved against the project root" 18 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Paths are resolved then required to stay inside root; outside paths are reported, never read" 19 │ * @flows MCPClient -> #mcp via guardlink_context -- "File path input" 20 │ * @flows ThreatModel -> #mcp via fileContext -- "Model grouped by location.file" 21 │ * @comment -- "Pure projection over the parsed model; the only I/O is an existence check performed by the caller" 22 │ */ 23 │ 24 │ import { lookup, type LookupResult } from './lookup.js'; 25 │ import { buildCoverageIndex } from '../parser/coverage.js';
L21 comment Pure projection over the parsed model; the only I/O is an existence check performed by the caller
Pure projection over the parsed model; the only I/O is an existence check performed by the caller
16 │ * 17 │ * @exposes #mcp to #path-traversal [medium] cwe:CWE-22 -- "Caller-supplied file path is resolved against the project root" 18 │ * @mitigates #mcp against #path-traversal using #path-validation -- "Paths are resolved then required to stay inside root; outside paths are reported, never read" 19 │ * @flows MCPClient -> #mcp via guardlink_context -- "File path input" 20 │ * @flows ThreatModel -> #mcp via fileContext -- "Model grouped by location.file" 21 │ * @comment -- "Pure projection over the parsed model; the only I/O is an existence check performed by the caller" 22 │ */ 23 │ 24 │ import { lookup, type LookupResult } from './lookup.js'; 25 │ import { buildCoverageIndex } from '../parser/coverage.js'; 26 │ import { isScannedPath } from '../parser/languages.js';
L17 exposes #mcp → #info-disclosure
Envelope discloses the absolute project root and git SHA to the connected client
12 │ * that reads a known shape. 13 │ * 14 │ * Before this there was no metadata on the MCP surface at all — `populateMetadata` 15 │ * runs only in the CLI report path, and the tools returned bare payloads. 16 │ * 17 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "Envelope discloses the absolute project root and git SHA to the connected client" 18 │ * @audit #mcp -- "Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model." 19 │ * @flows ThreatModel -> #mcp via buildEnvelope -- "Model content hashed for the freshness envelope" 20 │ * @flows GitRepo -> #mcp via readFileSync -- "HEAD read directly from .git, no subprocess" 21 │ * @comment -- "git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call" 22 │ */
L18 audit Audit: #mcp
Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model.
13 │ * 14 │ * Before this there was no metadata on the MCP surface at all — `populateMetadata` 15 │ * runs only in the CLI report path, and the tools returned bare payloads. 16 │ * 17 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "Envelope discloses the absolute project root and git SHA to the connected client" 18 │ * @audit #mcp -- "Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model." 19 │ * @flows ThreatModel -> #mcp via buildEnvelope -- "Model content hashed for the freshness envelope" 20 │ * @flows GitRepo -> #mcp via readFileSync -- "HEAD read directly from .git, no subprocess" 21 │ * @comment -- "git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call" 22 │ */ 23 │
L19 flow ThreatModel → #mcp
Model content hashed for the freshness envelope
14 │ * Before this there was no metadata on the MCP surface at all — `populateMetadata` 15 │ * runs only in the CLI report path, and the tools returned bare payloads. 16 │ * 17 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "Envelope discloses the absolute project root and git SHA to the connected client" 18 │ * @audit #mcp -- "Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model." 19 │ * @flows ThreatModel -> #mcp via buildEnvelope -- "Model content hashed for the freshness envelope" 20 │ * @flows GitRepo -> #mcp via readFileSync -- "HEAD read directly from .git, no subprocess" 21 │ * @comment -- "git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call" 22 │ */ 23 │ 24 │ import { resolve } from 'node:path';
L20 flow GitRepo → #mcp
HEAD read directly from .git, no subprocess
15 │ * runs only in the CLI report path, and the tools returned bare payloads. 16 │ * 17 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "Envelope discloses the absolute project root and git SHA to the connected client" 18 │ * @audit #mcp -- "Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model." 19 │ * @flows ThreatModel -> #mcp via buildEnvelope -- "Model content hashed for the freshness envelope" 20 │ * @flows GitRepo -> #mcp via readFileSync -- "HEAD read directly from .git, no subprocess" 21 │ * @comment -- "git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call" 22 │ */ 23 │ 24 │ import { resolve } from 'node:path'; 25 │ import { readGitSha } from '../workspace/metadata.js';
L21 comment git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call
git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call
16 │ * 17 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "Envelope discloses the absolute project root and git SHA to the connected client" 18 │ * @audit #mcp -- "Root and SHA are disclosed deliberately: without them a client cannot tell which repo answered (D9). Client is already trusted with the full threat model." 19 │ * @flows ThreatModel -> #mcp via buildEnvelope -- "Model content hashed for the freshness envelope" 20 │ * @flows GitRepo -> #mcp via readFileSync -- "HEAD read directly from .git, no subprocess" 21 │ * @comment -- "git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call" 22 │ */ 23 │ 24 │ import { resolve } from 'node:path'; 25 │ import { readGitSha } from '../workspace/metadata.js'; 26 │ import { computeAnnotationHash } from '../parser/annotation-hash.js';
L25 flow ConfigFile → #mcp
Annotation mode read for the initialize instructions
20 │ * The tool *names* are prose, and are held accurate by a test that cross-checks 21 │ * every `guardlink_*` mentioned here against the server's real `tools/list`. A 22 │ * renamed or deleted tool fails that test rather than silently misdirecting an 23 │ * agent forever. 24 │ * 25 │ * @flows ConfigFile -> #mcp via readFileSync -- "Annotation mode read for the initialize instructions" 26 │ * @comment -- "Pure string builder; the only I/O is one optional config read by the caller" 27 │ */ 28 │ 29 │ import { SUPPORTED_QUERY_FORMS } from './lookup.js'; 30 │ import { readConfiguredMode, type AnnotationMode } from '../parser/annotation-mode.js';
L26 comment Pure string builder; the only I/O is one optional config read by the caller
Pure string builder; the only I/O is one optional config read by the caller
21 │ * every `guardlink_*` mentioned here against the server's real `tools/list`. A 22 │ * renamed or deleted tool fails that test rather than silently misdirecting an 23 │ * agent forever. 24 │ * 25 │ * @flows ConfigFile -> #mcp via readFileSync -- "Annotation mode read for the initialize instructions" 26 │ * @comment -- "Pure string builder; the only I/O is one optional config read by the caller" 27 │ */ 28 │ 29 │ import { SUPPORTED_QUERY_FORMS } from './lookup.js'; 30 │ import { readConfiguredMode, type AnnotationMode } from '../parser/annotation-mode.js'; 31 │
L24 exposes #mcp → #redos
Regex patterns applied to query strings
19 │ * - "gates [for #asset]" → @gates claims: who approves before effects on an asset land 20 │ * - "ungated effects [for #asset]" → mutating @effects no @gates covers 21 │ * - "boundary #config" → boundaries involving asset 22 │ * - Free text → fuzzy match across assets, threats, controls 23 │ * 24 │ * @exposes #mcp to #redos [low] cwe:CWE-1333 -- "Regex patterns applied to query strings" 25 │ * @mitigates #mcp against #redos using #regex-anchoring -- "Patterns are simple and bounded" 26 │ * @flows QueryString -> #mcp via lookup -- "Query input path" 27 │ * @comment -- "Pure function; no I/O; operates on in-memory ThreatModel" 28 │ * @comment -- "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries" 29 │ */
L25 mitigates #regex-anchoring mitigates #redos
Patterns are simple and bounded
20 │ * - "ungated effects [for #asset]" → mutating @effects no @gates covers 21 │ * - "boundary #config" → boundaries involving asset 22 │ * - Free text → fuzzy match across assets, threats, controls 23 │ * 24 │ * @exposes #mcp to #redos [low] cwe:CWE-1333 -- "Regex patterns applied to query strings" 25 │ * @mitigates #mcp against #redos using #regex-anchoring -- "Patterns are simple and bounded" 26 │ * @flows QueryString -> #mcp via lookup -- "Query input path" 27 │ * @comment -- "Pure function; no I/O; operates on in-memory ThreatModel" 28 │ * @comment -- "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries" 29 │ */ 30 │
L26 flow QueryString → #mcp
Query input path
21 │ * - "boundary #config" → boundaries involving asset 22 │ * - Free text → fuzzy match across assets, threats, controls 23 │ * 24 │ * @exposes #mcp to #redos [low] cwe:CWE-1333 -- "Regex patterns applied to query strings" 25 │ * @mitigates #mcp against #redos using #regex-anchoring -- "Patterns are simple and bounded" 26 │ * @flows QueryString -> #mcp via lookup -- "Query input path" 27 │ * @comment -- "Pure function; no I/O; operates on in-memory ThreatModel" 28 │ * @comment -- "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries" 29 │ */ 30 │ 31 │ import { buildCoverageIndex, findUnmitigatedExposures } from '../parser/coverage.js';
L27 comment Pure function; no I/O; operates on in-memory ThreatModel
Pure function; no I/O; operates on in-memory ThreatModel
22 │ * - Free text → fuzzy match across assets, threats, controls 23 │ * 24 │ * @exposes #mcp to #redos [low] cwe:CWE-1333 -- "Regex patterns applied to query strings" 25 │ * @mitigates #mcp against #redos using #regex-anchoring -- "Patterns are simple and bounded" 26 │ * @flows QueryString -> #mcp via lookup -- "Query input path" 27 │ * @comment -- "Pure function; no I/O; operates on in-memory ThreatModel" 28 │ * @comment -- "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries" 29 │ */ 30 │ 31 │ import { buildCoverageIndex, findUnmitigatedExposures } from '../parser/coverage.js'; 32 │ import { ACCEPTANCE_REGISTER_ID, ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js';
L28 comment The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries
The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries
23 │ * 24 │ * @exposes #mcp to #redos [low] cwe:CWE-1333 -- "Regex patterns applied to query strings" 25 │ * @mitigates #mcp against #redos using #regex-anchoring -- "Patterns are simple and bounded" 26 │ * @flows QueryString -> #mcp via lookup -- "Query input path" 27 │ * @comment -- "Pure function; no I/O; operates on in-memory ThreatModel" 28 │ * @comment -- "The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries" 29 │ */ 30 │ 31 │ import { buildCoverageIndex, findUnmitigatedExposures } from '../parser/coverage.js'; 32 │ import { ACCEPTANCE_REGISTER_ID, ACCEPTANCE_REGISTER_NOTE } from '../parser/acceptance.js'; 33 │ import { actorResolver, findUnentitledReaches, buildReachAnalysis } from '../parser/reach.js';
L42 exposes #mcp → #dos
Unbounded depth on a dense graph expands the frontier
37 │ * made of refs that already exist in the model, and there is no user input left 38 │ * to interpret, so hops walk canonical identity only. Fuzzy matching at hop N 39 │ * would let `#cli` pull in `#llm-client` at every level and the frontier would 40 │ * blow out into everything sharing three characters. 41 │ * 42 │ * @exposes #mcp to #dos [low] cwe:CWE-400 -- "Unbounded depth on a dense graph expands the frontier" 43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood"
L43 mitigates #resource-limits mitigates #dos
Depth is clamped; visited set makes every node terminal, so cycles cannot revisit
38 │ * to interpret, so hops walk canonical identity only. Fuzzy matching at hop N 39 │ * would let `#cli` pull in `#llm-client` at every level and the frontier would 40 │ * blow out into everything sharing three characters. 41 │ * 42 │ * @exposes #mcp to #dos [low] cwe:CWE-400 -- "Unbounded depth on a dense graph expands the frontier" 43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood" 48 │ * @comment -- "Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes"
L44 exposes #mcp → #info-disclosure
D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing
39 │ * would let `#cli` pull in `#llm-client` at every level and the frontier would 40 │ * blow out into everything sharing three characters. 41 │ * 42 │ * @exposes #mcp to #dos [low] cwe:CWE-400 -- "Unbounded depth on a dense graph expands the frontier" 43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood" 48 │ * @comment -- "Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes" 49 │ */
L45 mitigates #resource-limits mitigates #info-disclosure
unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary
40 │ * blow out into everything sharing three characters. 41 │ * 42 │ * @exposes #mcp to #dos [low] cwe:CWE-400 -- "Unbounded depth on a dense graph expands the frontier" 43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood" 48 │ * @comment -- "Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes" 49 │ */ 50 │
L46 validates #resource-limits validates #mcp
tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it
41 │ * 42 │ * @exposes #mcp to #dos [low] cwe:CWE-400 -- "Unbounded depth on a dense graph expands the frontier" 43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood" 48 │ * @comment -- "Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes" 49 │ */ 50 │ 51 │ import { resolveAssetRef, type MatchKind } from './lookup.js';
L47 flow ThreatModel → #mcp
Model filtered to a neighbourhood
42 │ * @exposes #mcp to #dos [low] cwe:CWE-400 -- "Unbounded depth on a dense graph expands the frontier" 43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood" 48 │ * @comment -- "Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes" 49 │ */ 50 │ 51 │ import { resolveAssetRef, type MatchKind } from './lookup.js'; 52 │ import { filterByFeature } from '../parser/feature-filter.js';
L48 comment Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes
Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes
43 │ * @mitigates #mcp against #dos using #resource-limits -- "Depth is clamped; visited set makes every node terminal, so cycles cannot revisit" 44 │ * @exposes #mcp to #info-disclosure [low] cwe:CWE-200 -- "D34: the subgraph spread carried the whole repo's unannotated_files inventory into a neighbourhood answer — 8094 paths, 654.8 KB, on a query that resolved nothing" 45 │ * @mitigates #mcp against #info-disclosure using #resource-limits -- "unannotated_files is filtered to the subgraph's own files here, and dropped entirely by withoutFileInventory at the graph emission boundary" 46 │ * @validates #resource-limits for #mcp -- "tests/graph-sparse-repo.test.ts builds two repos differing only in unannotated file count and pins that payload size does not follow it" 47 │ * @flows ThreatModel -> #mcp via selectSubgraph -- "Model filtered to a neighbourhood" 48 │ * @comment -- "Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes" 49 │ */ 50 │ 51 │ import { resolveAssetRef, type MatchKind } from './lookup.js'; 52 │ import { filterByFeature } from '../parser/feature-filter.js'; 53 │ import { canonicaliser } from '../parser/canonical-ref.js';
L47 exposes #parser → #config-tamper
readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear
42 │ * every count above it looks healthier for its absence (the argument 43 │ * `ci/index.ts` already makes about parse diagnostics). An unattributed 44 │ * acceptance parses, lands in the model, and is then NAMED — by the gate, by 45 │ * `validate`, and in the review UI. Loud beats absent. 46 │ * 47 │ * @exposes #parser to #config-tamper [medium] cwe:CWE-15 -- "readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear" 48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path"
L48 mitigates #config-validation mitigates #config-tamper
Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken
43 │ * `ci/index.ts` already makes about parse diagnostics). An unattributed 44 │ * acceptance parses, lands in the model, and is then NAMED — by the gate, by 45 │ * `validate`, and in the review UI. Loud beats absent. 46 │ * 47 │ * @exposes #parser to #config-tamper [medium] cwe:CWE-15 -- "readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear" 48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy"
L49 comment What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference
What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference
44 │ * acceptance parses, lands in the model, and is then NAMED — by the gate, by 45 │ * `validate`, and in the review UI. Loud beats absent. 46 │ * 47 │ * @exposes #parser to #config-tamper [medium] cwe:CWE-15 -- "readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear" 48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left"
L50 audit Audit: #parser
The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit
45 │ * `validate`, and in the review UI. Loud beats absent. 46 │ * 47 │ * @exposes #parser to #config-tamper [medium] cwe:CWE-15 -- "readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear" 48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression"
L51 exposes #parser → #insecure-deser
JSON.parse of a repository file
46 │ * 47 │ * @exposes #parser to #config-tamper [medium] cwe:CWE-15 -- "readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear" 48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only"
L52 mitigates #config-validation mitigates #insecure-deser
Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path
47 │ * @exposes #parser to #config-tamper [medium] cwe:CWE-15 -- "readAcceptancePolicy reads .guardlink/config.json to decide how strict this project's acceptance rule is, so anyone who can edit that file can lower the bar their own acceptances have to clear" 48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries"
L53 flow ThreatModel → #parser
Acceptances read and checked against policy
48 │ * @mitigates #parser against #config-tamper using #config-validation -- "Every field is type- and range-checked and falls back to the built-in default; an absent, unreadable or malformed config yields DEFAULT_ACCEPTANCE_POLICY rather than an off switch, so a broken config cannot disable the policy by being broken" 49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it"
L54 flow ThreatModel → #parser
Qualifying acceptances read for how much of their horizon is left
49 │ * @comment -- "What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference" 50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them"
L55 comment acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression
acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression
50 │ * @audit #parser -- "The thresholds here are the price of an acceptance. Lowering min_justification or raising max_horizon_days in a PR is a governance change wearing a config diff, and deserves the same review as the acceptances it will admit" 51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads"
L56 flow ConfigFile → #parser
Per-project policy thresholds, read only
51 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse of a repository file" 52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes"
L57 handles #parser: internal
Reads the reviewer name and justification text an acceptance carries
52 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Parsed inside try/catch; only four scalar fields are read, each through a type guard, and nothing from the file is executed or used as a path" 53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes" 62 │ * @comment -- "Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently"
L58 comment Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it
Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it
53 │ * @flows ThreatModel -> #parser via findAcceptanceDefects -- "Acceptances read and checked against policy" 54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes" 62 │ * @comment -- "Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently" 63 │ */
L59 comment Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them
Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them
54 │ * @flows ThreatModel -> #parser via findExpiringAcceptances -- "Qualifying acceptances read for how much of their horizon is left" 55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes" 62 │ * @comment -- "Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently" 63 │ */ 64 │
L60 comment acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads
acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads
55 │ * @comment -- "acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression" 56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes" 62 │ * @comment -- "Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently" 63 │ */ 64 │ 65 │ import { readFileSync } from 'node:fs';
L61 comment ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes
ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes
56 │ * @flows ConfigFile -> #parser via readAcceptancePolicy -- "Per-project policy thresholds, read only" 57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes" 62 │ * @comment -- "Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently" 63 │ */ 64 │ 65 │ import { readFileSync } from 'node:fs'; 66 │ import { join } from 'node:path';
L62 comment Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently
Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently
57 │ * @handles internal on #parser -- "Reads the reviewer name and justification text an acceptance carries" 58 │ * @comment -- "Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it" 59 │ * @comment -- "Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them" 60 │ * @comment -- "acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads" 61 │ * @comment -- "ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes" 62 │ * @comment -- "Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently" 63 │ */ 64 │ 65 │ import { readFileSync } from 'node:fs'; 66 │ import { join } from 'node:path'; 67 │ import type { ThreatModel, ThreatModelAcceptance, ThreatModelExposure, SourceLocation } from '../types/index.js';
L28 flow ThreatModel → #parser
Model content read for hashing
23 │ * 24 │ * Records are sorted before hashing, so reordering annotations within a file 25 │ * does not change the result. They are sorted as a multiset — duplicates are 26 │ * kept — so deleting one of two identical annotations still changes the hash. 27 │ * 28 │ * @flows ThreatModel -> #parser via computeAnnotationHash -- "Model content read for hashing" 29 │ * @comment -- "Pure function; no I/O; deterministic across machines and annotation modes" 30 │ * @assumes #parser -- "location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)" 31 │ * @comment -- "Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically" 32 │ * @comment -- "v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)" 33 │ * @comment -- "v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'"
L29 comment Pure function; no I/O; deterministic across machines and annotation modes
Pure function; no I/O; deterministic across machines and annotation modes
24 │ * Records are sorted before hashing, so reordering annotations within a file 25 │ * does not change the result. They are sorted as a multiset — duplicates are 26 │ * kept — so deleting one of two identical annotations still changes the hash. 27 │ * 28 │ * @flows ThreatModel -> #parser via computeAnnotationHash -- "Model content read for hashing" 29 │ * @comment -- "Pure function; no I/O; deterministic across machines and annotation modes" 30 │ * @assumes #parser -- "location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)" 31 │ * @comment -- "Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically" 32 │ * @comment -- "v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)" 33 │ * @comment -- "v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'" 34 │ */
L30 assumes Assumes: #parser
location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)
25 │ * does not change the result. They are sorted as a multiset — duplicates are 26 │ * kept — so deleting one of two identical annotations still changes the hash. 27 │ * 28 │ * @flows ThreatModel -> #parser via computeAnnotationHash -- "Model content read for hashing" 29 │ * @comment -- "Pure function; no I/O; deterministic across machines and annotation modes" 30 │ * @assumes #parser -- "location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)" 31 │ * @comment -- "Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically" 32 │ * @comment -- "v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)" 33 │ * @comment -- "v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'" 34 │ */ 35 │
L31 comment Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically
Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically
26 │ * kept — so deleting one of two identical annotations still changes the hash. 27 │ * 28 │ * @flows ThreatModel -> #parser via computeAnnotationHash -- "Model content read for hashing" 29 │ * @comment -- "Pure function; no I/O; deterministic across machines and annotation modes" 30 │ * @assumes #parser -- "location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)" 31 │ * @comment -- "Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically" 32 │ * @comment -- "v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)" 33 │ * @comment -- "v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'" 34 │ */ 35 │ 36 │ import { createHash } from 'node:crypto';
L32 comment v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)
v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)
27 │ * 28 │ * @flows ThreatModel -> #parser via computeAnnotationHash -- "Model content read for hashing" 29 │ * @comment -- "Pure function; no I/O; deterministic across machines and annotation modes" 30 │ * @assumes #parser -- "location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)" 31 │ * @comment -- "Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically" 32 │ * @comment -- "v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)" 33 │ * @comment -- "v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'" 34 │ */ 35 │ 36 │ import { createHash } from 'node:crypto'; 37 │ import type { ThreatModel, SourceLocation } from '../types/index.js';
L33 comment v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'
v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'
28 │ * @flows ThreatModel -> #parser via computeAnnotationHash -- "Model content read for hashing" 29 │ * @comment -- "Pure function; no I/O; deterministic across machines and annotation modes" 30 │ * @assumes #parser -- "location.file is the logical source path in both inline and external mode (parse-file.ts resolves @source before the model is assembled)" 31 │ * @comment -- "Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically" 32 │ * @comment -- "v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)" 33 │ * @comment -- "v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'" 34 │ */ 35 │ 36 │ import { createHash } from 'node:crypto'; 37 │ import type { ThreatModel, SourceLocation } from '../types/index.js'; 38 │
L10 flow ThreatModel → #parser
Annotation locations read to infer storage mode
5 │ * records include/exclude globs but not the mode, so the annotations themselves 6 │ * are the only authority. An annotation carries `location.origin_file` when it 7 │ * was read from a standalone `.gal` sidecar and resolved back to its logical 8 │ * source through `@source`; inline annotations have no origin. 9 │ * 10 │ * @flows ThreatModel -> #parser via detectAnnotationMode -- "Annotation locations read to infer storage mode" 11 │ * @comment -- "Pure function; mixed is a real answer, not an error — a repo mid-migration is genuinely both" 12 │ */ 13 │ 14 │ import { readFileSync } from 'node:fs'; 15 │ import { join } from 'node:path';
L11 comment Pure function; mixed is a real answer, not an error — a repo mid-migration is genuinely both
Pure function; mixed is a real answer, not an error — a repo mid-migration is genuinely both
6 │ * are the only authority. An annotation carries `location.origin_file` when it 7 │ * was read from a standalone `.gal` sidecar and resolved back to its logical 8 │ * source through `@source`; inline annotations have no origin. 9 │ * 10 │ * @flows ThreatModel -> #parser via detectAnnotationMode -- "Annotation locations read to infer storage mode" 11 │ * @comment -- "Pure function; mixed is a real answer, not an error — a repo mid-migration is genuinely both" 12 │ */ 13 │ 14 │ import { readFileSync } from 'node:fs'; 15 │ import { join } from 'node:path'; 16 │ import type { ThreatModel, SourceLocation } from '../types/index.js';
L21 exposes #parser → #arbitrary-write
Writes annotation sidecars from tool input
16 │ * 17 │ * `@agents`, `@reaches`, `@effects` and `@gates` are written like any other 18 │ * relation. They record what the code can do, not what anyone decided it may, 19 │ * so an agent reading a tool registration is exactly who should write them. 20 │ * 21 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" 22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append"
L22 mitigates #path-validation mitigates #arbitrary-write
Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected
17 │ * `@agents`, `@reaches`, `@effects` and `@gates` are written like any other 18 │ * relation. They record what the code can do, not what anyone decided it may, 19 │ * so an agent reading a tool registration is exactly who should write them. 20 │ * 21 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" 22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append" 27 │ * @comment -- "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated"
L23 exposes #parser → #insecure-deser
Annotation lines arrive as caller-supplied text
18 │ * relation. They record what the code can do, not what anyone decided it may, 19 │ * so an agent reading a tool registration is exactly who should write them. 20 │ * 21 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" 22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append" 27 │ * @comment -- "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated" 28 │ */
L24 mitigates #input-sanitize mitigates #insecure-deser
Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected
19 │ * so an agent reading a tool registration is exactly who should write them. 20 │ * 21 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" 22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append" 27 │ * @comment -- "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated" 28 │ */ 29 │
L25 flow MCPClient → #parser
Structured annotation write path
20 │ * 21 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" 22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append" 27 │ * @comment -- "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated" 28 │ */ 29 │ 30 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
L26 flow #parser → FileSystem
Sidecar append
21 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation sidecars from tool input" 22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append" 27 │ * @comment -- "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated" 28 │ */ 29 │ 30 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 31 │ import { dirname, resolve } from 'node:path';
L27 comment Idempotent by construction: an identical @source block is detected and skipped rather than duplicated
Idempotent by construction: an identical @source block is detected and skipped rather than duplicated
22 │ * @mitigates #parser against #arbitrary-write using #path-validation -- "Target is always resolveGalPath(root, file); the caller cannot choose the path, and a file escaping root is rejected" 23 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-20 -- "Annotation lines arrive as caller-supplied text" 24 │ * @mitigates #parser against #insecure-deser using #input-sanitize -- "Every line is re-parsed with parseLine before write; anything that does not parse, or parses as @accepts, is rejected" 25 │ * @flows MCPClient -> #parser via applyAnnotations -- "Structured annotation write path" 26 │ * @flows #parser -> FileSystem via writeFileSync -- "Sidecar append" 27 │ * @comment -- "Idempotent by construction: an identical @source block is detected and skipped rather than duplicated" 28 │ */ 29 │ 30 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 31 │ import { dirname, resolve } from 'node:path'; 32 │ import { parseLine } from './parse-line.js';
L24 flow ThreatModel → #parser
Model ordered deterministically before emission
19 │ * This sorts at the **emission boundary** rather than in the parser. Parser 20 │ * output order is observable behaviour and changing it is out of scope; what 21 │ * matters is that nothing written to disk inherits an accident of directory 22 │ * enumeration. 23 │ * 24 │ * @flows ThreatModel -> #parser via canonicalizeModelOrder -- "Model ordered deterministically before emission" 25 │ * @comment -- "Pure; returns a new model, mutates nothing. Sort is total, so the result cannot depend on input order." 26 │ */ 27 │ 28 │ import type { ThreatModel } from '../types/index.js'; 29 │
L25 comment Pure; returns a new model, mutates nothing. Sort is total, so the result cannot depend on input order.
Pure; returns a new model, mutates nothing. Sort is total, so the result cannot depend on input order.
20 │ * output order is observable behaviour and changing it is out of scope; what 21 │ * matters is that nothing written to disk inherits an accident of directory 22 │ * enumeration. 23 │ * 24 │ * @flows ThreatModel -> #parser via canonicalizeModelOrder -- "Model ordered deterministically before emission" 25 │ * @comment -- "Pure; returns a new model, mutates nothing. Sort is total, so the result cannot depend on input order." 26 │ */ 27 │ 28 │ import type { ThreatModel } from '../types/index.js'; 29 │ 30 │ /**
L37 comment Aliasing can only ever ADD coverage, so the map is built from declared assets only; an undeclared ref resolves to itself, which is the pre-D47 behaviour
Aliasing can only ever ADD coverage, so the map is built from declared assets only; an undeclared ref resolves to itself, which is the pre-D47 behaviour
32 │ * A reference therefore resolves to a different string than it did before if 33 │ * and only if the model declares an asset whose dotted path or id it matches. 34 │ * Two references collapse together only when the model itself says they are the 35 │ * same declared asset. Nothing is inferred from spelling similarity. 36 │ * 37 │ * @comment -- "Aliasing can only ever ADD coverage, so the map is built from declared assets only; an undeclared ref resolves to itself, which is the pre-D47 behaviour" 38 │ */ 39 │ import type { ThreatModel } from '../types/index.js'; 40 │ 41 │ /** 42 │ * A function mapping any asset reference to its canonical id.
L16 comment Pure function over annotation text; no I/O, no filesystem check — a citation is a claim about where authz lives, and verifying the path exists is a reviewer's job (§3.4)
Pure function over annotation text; no I/O, no filesystem check — a citation is a claim about where authz lives, and verifying the path exists is a reviewer's job (§3.4)
11 │ * 12 │ * "By design: the archival URI is namespace configuration. 13 │ * Authz: ScopeCluster/AccessAdmin at common/api/metadata.go:189" 14 │ * ^^^^^^^^^^^^^^^^^^^^^^^^^ 15 │ * 16 │ * @comment -- "Pure function over annotation text; no I/O, no filesystem check — a citation is a claim about where authz lives, and verifying the path exists is a reviewer's job (§3.4)" 17 │ * @mitigates #parser against #redos using #regex-anchoring -- "Tokenizes on whitespace first, then matches each path segment with an anchored, quantifier-free-nesting pattern — no backtracking over the whole description" 18 │ */ 19 │ 20 │ import type { EntitlementCitation } from '../types/index.js'; 21 │
L17 mitigates #regex-anchoring mitigates #redos
Tokenizes on whitespace first, then matches each path segment with an anchored, quantifier-free-nesting pattern — no backtracking over the whole description
12 │ * "By design: the archival URI is namespace configuration. 13 │ * Authz: ScopeCluster/AccessAdmin at common/api/metadata.go:189" 14 │ * ^^^^^^^^^^^^^^^^^^^^^^^^^ 15 │ * 16 │ * @comment -- "Pure function over annotation text; no I/O, no filesystem check — a citation is a claim about where authz lives, and verifying the path exists is a reviewer's job (§3.4)" 17 │ * @mitigates #parser against #redos using #regex-anchoring -- "Tokenizes on whitespace first, then matches each path segment with an anchored, quantifier-free-nesting pattern — no backtracking over the whole description" 18 │ */ 19 │ 20 │ import type { EntitlementCitation } from '../types/index.js'; 21 │ 22 │ // ─── Token patterns ──────────────────────────────────────────────────
L20 comment Pure functions over an assembled ThreatModel; no I/O
Pure functions over an assembled ThreatModel; no I/O
15 │ * the two answer different questions. 16 │ * 17 │ * Identical claims in one file get an ordinal suffix in document order, so a 18 │ * repeated `@comment` is two ledger entries rather than one that flaps. 19 │ * 20 │ * @comment -- "Pure functions over an assembled ThreatModel; no I/O" 21 │ */ 22 │ import { createHash } from 'node:crypto'; 23 │ import type { 24 │ ThreatModel, SourceLocation, AnnotationVerb, 25 │ ThreatModelMitigation, ThreatModelExposure, ThreatModelConfirmed, ThreatModelAcceptance,
L8 exposes #parser → #arbitrary-write
Writes modified content back to discovered files
3 │ * Scans project source files and removes all GuardLink annotation lines. 4 │ * Standalone .gal files are treated as raw annotation text. 5 │ * 6 │ * Used by `guardlink clear` and `/clear` to let users start fresh with annotations. 7 │ * 8 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes modified content back to discovered files" 9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids"
L9 exposes #parser → #path-traversal
Glob patterns determine which files are modified
4 │ * Standalone .gal files are treated as raw annotation text. 5 │ * 6 │ * Used by `guardlink clear` and `/clear` to let users start fresh with annotations. 7 │ * 8 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes modified content back to discovered files" 9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path"
L10 mitigates #glob-filtering mitigates #path-traversal
DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope
5 │ * 6 │ * Used by `guardlink clear` and `/clear` to let users start fresh with annotations. 7 │ * 8 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes modified content back to discovered files" 9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path"
L11 audit Audit: #parser
Destructive operation requires explicit user confirmation via dryRun flag
6 │ * Used by `guardlink clear` and `/clear` to let users start fresh with annotations. 7 │ * 8 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes modified content back to discovered files" 9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path" 16 │ * @handles internal on #parser -- "Operates on project source files only"
L12 comment No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration
No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration
7 │ * 8 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes modified content back to discovered files" 9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path" 16 │ * @handles internal on #parser -- "Operates on project source files only" 17 │ */
L13 comment #mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids
#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids
8 │ * @exposes #parser to #arbitrary-write [high] cwe:CWE-73 -- "Writes modified content back to discovered files" 9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path" 16 │ * @handles internal on #parser -- "Operates on project source files only" 17 │ */ 18 │
L14 flow ProjectRoot → #parser
File discovery path
9 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns determine which files are modified" 10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path" 16 │ * @handles internal on #parser -- "Operates on project source files only" 17 │ */ 18 │ 19 │ import fg from 'fast-glob';
L15 flow #parser → SourceFiles
Modified file write path
10 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks sensitive dirs; cwd constrains scope" 11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path" 16 │ * @handles internal on #parser -- "Operates on project source files only" 17 │ */ 18 │ 19 │ import fg from 'fast-glob'; 20 │ import { readFile, writeFile } from 'node:fs/promises';
L16 handles #parser: internal
Operates on project source files only
11 │ * @audit #parser -- "Destructive operation requires explicit user confirmation via dryRun flag" 12 │ * @comment -- "No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration" 13 │ * @comment -- "#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids" 14 │ * @flows ProjectRoot -> #parser via fast-glob -- "File discovery path" 15 │ * @flows #parser -> SourceFiles via writeFile -- "Modified file write path" 16 │ * @handles internal on #parser -- "Operates on project source files only" 17 │ */ 18 │ 19 │ import fg from 'fast-glob'; 20 │ import { readFile, writeFile } from 'node:fs/promises'; 21 │ import { relative } from 'node:path';
L8 exposes #parser → #redos
Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source
3 │ 4 │ /** 5 │ * Comment prefix stripping per §2.9. 6 │ * Strips the host language's comment prefix to expose the annotation text. 7 │ * 8 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source" 9 │ * @mitigates #parser against #redos using #regex-anchoring -- "Every pattern is anchored at ^ and bounded — decoration is {1,3}, the marker tail is a character-by-character scan, and no pattern nests a quantifier" 10 │ * @mitigates #parser against #dos using #resource-limits -- "consumeMarkerTail is linear in the marker, not the line: it stops at the first character that is not the marker's own" 11 │ * @comment -- "Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment" 12 │ * @validates #regex-anchoring for #parser -- "tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null" 13 │ */
L9 mitigates #regex-anchoring mitigates #redos
Every pattern is anchored at ^ and bounded — decoration is {1,3}, the marker tail is a character-by-character scan, and no pattern nests a quantifier
4 │ /** 5 │ * Comment prefix stripping per §2.9. 6 │ * Strips the host language's comment prefix to expose the annotation text. 7 │ * 8 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source" 9 │ * @mitigates #parser against #redos using #regex-anchoring -- "Every pattern is anchored at ^ and bounded — decoration is {1,3}, the marker tail is a character-by-character scan, and no pattern nests a quantifier" 10 │ * @mitigates #parser against #dos using #resource-limits -- "consumeMarkerTail is linear in the marker, not the line: it stops at the first character that is not the marker's own" 11 │ * @comment -- "Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment" 12 │ * @validates #regex-anchoring for #parser -- "tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null" 13 │ */ 14 │
L10 mitigates #resource-limits mitigates #dos
consumeMarkerTail is linear in the marker, not the line: it stops at the first character that is not the marker's own
5 │ * Comment prefix stripping per §2.9. 6 │ * Strips the host language's comment prefix to expose the annotation text. 7 │ * 8 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source" 9 │ * @mitigates #parser against #redos using #regex-anchoring -- "Every pattern is anchored at ^ and bounded — decoration is {1,3}, the marker tail is a character-by-character scan, and no pattern nests a quantifier" 10 │ * @mitigates #parser against #dos using #resource-limits -- "consumeMarkerTail is linear in the marker, not the line: it stops at the first character that is not the marker's own" 11 │ * @comment -- "Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment" 12 │ * @validates #regex-anchoring for #parser -- "tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null" 13 │ */ 14 │ 15 │ /**
L11 comment Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment
Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment
6 │ * Strips the host language's comment prefix to expose the annotation text. 7 │ * 8 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source" 9 │ * @mitigates #parser against #redos using #regex-anchoring -- "Every pattern is anchored at ^ and bounded — decoration is {1,3}, the marker tail is a character-by-character scan, and no pattern nests a quantifier" 10 │ * @mitigates #parser against #dos using #resource-limits -- "consumeMarkerTail is linear in the marker, not the line: it stops at the first character that is not the marker's own" 11 │ * @comment -- "Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment" 12 │ * @validates #regex-anchoring for #parser -- "tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null" 13 │ */ 14 │ 15 │ /** 16 │ * The single-line comment openers §2.9 recognises, longest first.
L12 validates #regex-anchoring validates #parser
tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null
7 │ * 8 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Marker and decoration matching runs on every line of every scanned file, including attacker-supplied source" 9 │ * @mitigates #parser against #redos using #regex-anchoring -- "Every pattern is anchored at ^ and bounded — decoration is {1,3}, the marker tail is a character-by-character scan, and no pattern nests a quantifier" 10 │ * @mitigates #parser against #dos using #resource-limits -- "consumeMarkerTail is linear in the marker, not the line: it stops at the first character that is not the marker's own" 11 │ * @comment -- "Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment" 12 │ * @validates #regex-anchoring for #parser -- "tests/comment-forms.test.ts pins every §2.9.1 form and, negatively, that a non-comment still returns null" 13 │ */ 14 │ 15 │ /** 16 │ * The single-line comment openers §2.9 recognises, longest first. 17 │ *
L97 flow ThreatModel → #parser
Every unmitigated-exposure answer in the product routes through this predicate
92 │ * anchor. Measured before landing: 0 exposures change state on this repository 93 │ * (16 → 16) and 0 on the external-mode expense-api fixture (11 → 11), because 94 │ * every same-pair exposure and mitigation in both sits in a module header or a 95 │ * different file. 96 │ * 97 │ * @flows ThreatModel -> #parser via isCovered -- "Every unmitigated-exposure answer in the product routes through this predicate" 98 │ * @comment -- "Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too" 99 │ * @comment -- "A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site" 100 │ * @comment -- "Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction" 101 │ */ 102 │
L98 comment Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too
Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too
93 │ * (16 → 16) and 0 on the external-mode expense-api fixture (11 → 11), because 94 │ * every same-pair exposure and mitigation in both sits in a module header or a 95 │ * different file. 96 │ * 97 │ * @flows ThreatModel -> #parser via isCovered -- "Every unmitigated-exposure answer in the product routes through this predicate" 98 │ * @comment -- "Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too" 99 │ * @comment -- "A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site" 100 │ * @comment -- "Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction" 101 │ */ 102 │ 103 │ import type {
L99 comment A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site
A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site
94 │ * every same-pair exposure and mitigation in both sits in a module header or a 95 │ * different file. 96 │ * 97 │ * @flows ThreatModel -> #parser via isCovered -- "Every unmitigated-exposure answer in the product routes through this predicate" 98 │ * @comment -- "Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too" 99 │ * @comment -- "A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site" 100 │ * @comment -- "Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction" 101 │ */ 102 │ 103 │ import type { 104 │ ThreatModel, ThreatModelExposure, ThreatModelMitigation, ThreatModelAcceptance,
L100 comment Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction
Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction
95 │ * different file. 96 │ * 97 │ * @flows ThreatModel -> #parser via isCovered -- "Every unmitigated-exposure answer in the product routes through this predicate" 98 │ * @comment -- "Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too" 99 │ * @comment -- "A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site" 100 │ * @comment -- "Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction" 101 │ */ 102 │ 103 │ import type { 104 │ ThreatModel, ThreatModelExposure, ThreatModelMitigation, ThreatModelAcceptance, 105 │ SourceLocation, CoverageStats,
L15 comment Pure filtering utility; no I/O
Pure filtering utility; no I/O
10 │ * `.guardlink/definitions.*`, which carries no `@feature`, so scoping them by 11 │ * file removed every one of them and left a feature view whose relationships 12 │ * pointed at nothing. They are instead resolved from what the kept relations 13 │ * reference — the node vocabulary follows the edges. 14 │ * 15 │ * @comment -- "Pure filtering utility; no I/O" 16 │ * @comment -- "Relations are file-scoped, definitions are reference-scoped. Filtering definitions by file emptied the asset heatmap, dropped every threat severity and rendered diagrams as bare ids — the same failure selectSubgraph avoids by always keeping the node vocabulary" 17 │ */ 18 │ 19 │ import type { ThreatModel } from '../types/index.js'; 20 │
L16 comment Relations are file-scoped, definitions are reference-scoped. Filtering definitions by file emptied the asset heatmap, dropped every threat severity and rendered diagrams as bare ids — the same failure selectSubgraph avoids by always keeping the node vocabulary
Relations are file-scoped, definitions are reference-scoped. Filtering definitions by file emptied the asset heatmap, dropped every threat severity and rendered diagrams as bare ids — the same failure selectSubgraph avoids by always keeping the node vocabulary
11 │ * file removed every one of them and left a feature view whose relationships 12 │ * pointed at nothing. They are instead resolved from what the kept relations 13 │ * reference — the node vocabulary follows the edges. 14 │ * 15 │ * @comment -- "Pure filtering utility; no I/O" 16 │ * @comment -- "Relations are file-scoped, definitions are reference-scoped. Filtering definitions by file emptied the asset heatmap, dropped every threat severity and rendered diagrams as bare ids — the same failure selectSubgraph avoids by always keeping the node vocabulary" 17 │ */ 18 │ 19 │ import type { ThreatModel } from '../types/index.js'; 20 │ 21 │ /**
L30 comment Exclusion list for the annotation counter; external_refs is derived from annotations, not an annotation
Exclusion list for the annotation counter; external_refs is derived from annotations, not an annotation
25 │ * annotation that mentioned the tag — counting it would count that annotation 26 │ * twice. It is the only such key today; the list exists so that the counter 27 │ * below can work by shape (see `countAnnotations`) instead of by a hardcoded 28 │ * inventory of collection names. 29 │ * 30 │ * @comment -- "Exclusion list for the annotation counter; external_refs is derived from annotations, not an annotation" 31 │ */ 32 │ const NON_ANNOTATION_LOCATED_KEYS = new Set<string>(['external_refs']); 33 │ 34 │ /** A row is an annotation if it carries a source location. */ 35 │ function isLocatedRow(v: unknown): v is { location: { file: string } } {
L52 comment Derives annotation totals from the model's own shape so a newly added collection is counted the day it lands
Derives annotation totals from the model's own shape so a newly added collection is counted the day it lands
47 │ * the first time a verb is added: the new collection ships in the model and is 48 │ * silently missing from the count, which is exactly the class of drift this 49 │ * whole change is fixing. Scalars and `string[]` (`annotated_files`, 50 │ * `unannotated_files`) fail the element test, so only annotation rows count. 51 │ * 52 │ * @comment -- "Derives annotation totals from the model's own shape so a newly added collection is counted the day it lands" 53 │ */ 54 │ export function countAnnotations(model: ThreatModel, files?: Set<string>): number { 55 │ let n = 0; 56 │ for (const [key, value] of Object.entries(model)) { 57 │ if (!Array.isArray(value) || value.length === 0) continue;
L100 comment Filtered models must never report project-wide totals — a reader acts on the numbers beside the contents
Filtered models must never report project-wide totals — a reader acts on the numbers beside the contents
95 │ * describes the feature rather than the project it was cut from, and which 96 │ * carries `filtered_by_features` so a consumer can say it is showing a slice. 97 │ * Feature matching is case-insensitive; `filtered_by_features` records the 98 │ * names as the caller wrote them. 99 │ * 100 │ * @comment -- "Filtered models must never report project-wide totals — a reader acts on the numbers beside the contents" 101 │ */ 102 │ export function filterByFeature(model: ThreatModel, featureNames: string[]): ThreatModel { 103 │ const wantedLower = new Set(featureNames.map(n => n.toLowerCase())); 104 │ const fileFeatureMap = buildFileFeatureMap(model); 105 │
L20 exposes #parser → #dos
Fingerprint globs the project tree on every call
15 │ * 16 │ * External mode is the reason this matters more than it looks. When annotations 17 │ * live in `.guardlink/annotations/**.gal`, the sidecar changes while the source 18 │ * file does not, so nothing incidental signals that the model moved. 19 │ * 20 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Fingerprint globs the project tree on every call" 21 │ * @mitigates #parser against #dos using #resource-limits -- "Reuses DEFAULT_EXCLUDE; stats come from the glob walk, no extra stat() calls, no file reads" 22 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory metadata scan for cache validity" 23 │ * @comment -- "Metadata only: path, size, mtime. File contents are never read." 24 │ */ 25 │
L21 mitigates #resource-limits mitigates #dos
Reuses DEFAULT_EXCLUDE; stats come from the glob walk, no extra stat() calls, no file reads
16 │ * External mode is the reason this matters more than it looks. When annotations 17 │ * live in `.guardlink/annotations/**.gal`, the sidecar changes while the source 18 │ * file does not, so nothing incidental signals that the model moved. 19 │ * 20 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Fingerprint globs the project tree on every call" 21 │ * @mitigates #parser against #dos using #resource-limits -- "Reuses DEFAULT_EXCLUDE; stats come from the glob walk, no extra stat() calls, no file reads" 22 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory metadata scan for cache validity" 23 │ * @comment -- "Metadata only: path, size, mtime. File contents are never read." 24 │ */ 25 │ 26 │ import fg from 'fast-glob';
L22 flow ProjectRoot → #parser
Directory metadata scan for cache validity
17 │ * live in `.guardlink/annotations/**.gal`, the sidecar changes while the source 18 │ * file does not, so nothing incidental signals that the model moved. 19 │ * 20 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Fingerprint globs the project tree on every call" 21 │ * @mitigates #parser against #dos using #resource-limits -- "Reuses DEFAULT_EXCLUDE; stats come from the glob walk, no extra stat() calls, no file reads" 22 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory metadata scan for cache validity" 23 │ * @comment -- "Metadata only: path, size, mtime. File contents are never read." 24 │ */ 25 │ 26 │ import fg from 'fast-glob'; 27 │ import { createHash } from 'node:crypto';
L23 comment Metadata only: path, size, mtime. File contents are never read.
Metadata only: path, size, mtime. File contents are never read.
18 │ * file does not, so nothing incidental signals that the model moved. 19 │ * 20 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Fingerprint globs the project tree on every call" 21 │ * @mitigates #parser against #dos using #resource-limits -- "Reuses DEFAULT_EXCLUDE; stats come from the glob walk, no extra stat() calls, no file reads" 22 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory metadata scan for cache validity" 23 │ * @comment -- "Metadata only: path, size, mtime. File contents are never read." 24 │ */ 25 │ 26 │ import fg from 'fast-glob'; 27 │ import { createHash } from 'node:crypto'; 28 │ import { DEFAULT_INCLUDE, DEFAULT_EXCLUDE } from './parse-project.js';
L20 flow SourceFile → #parser
Source path mapped to its annotation sidecar
15 │ * 16 │ * Appending `.gal` rather than replacing the extension is deliberate: it keeps 17 │ * the mapping total and reversible. `login.ts` and `login.js` in one directory 18 │ * map to distinct sidecars, which a replace-the-extension scheme would collide. 19 │ * 20 │ * @flows SourceFile -> #parser via resolveGalPath -- "Source path mapped to its annotation sidecar" 21 │ * @comment -- "Pure path arithmetic; no I/O. The inverse is exact, which is what makes migration reversible." 22 │ */ 23 │ 24 │ import { isAbsolute, join, relative, resolve } from 'node:path'; 25 │
L21 comment Pure path arithmetic; no I/O. The inverse is exact, which is what makes migration reversible.
Pure path arithmetic; no I/O. The inverse is exact, which is what makes migration reversible.
16 │ * Appending `.gal` rather than replacing the extension is deliberate: it keeps 17 │ * the mapping total and reversible. `login.ts` and `login.js` in one directory 18 │ * map to distinct sidecars, which a replace-the-extension scheme would collide. 19 │ * 20 │ * @flows SourceFile -> #parser via resolveGalPath -- "Source path mapped to its annotation sidecar" 21 │ * @comment -- "Pure path arithmetic; no I/O. The inverse is exact, which is what makes migration reversible." 22 │ */ 23 │ 24 │ import { isAbsolute, join, relative, resolve } from 'node:path'; 25 │ 26 │ /** Where externalised annotations live, relative to the project root. */
L22 comment Pure functions over parsed locations; reads no files and no user input
Pure functions over parsed locations; reads no files and no user input
17 │ * 18 │ * Unknown is read as file-level, never as a handler. That is the direction that 19 │ * can only fail to narrow — it reproduces the behaviour before handler scope 20 │ * existed — rather than the direction that invents a site the author never drew. 21 │ * 22 │ * @comment -- "Pure functions over parsed locations; reads no files and no user input" 23 │ */ 24 │ 25 │ import type { SourceLocation } from '../types/index.js'; 26 │ 27 │ /** A claim's handler: the line range of the code its annotation is attached to. */
L47 flow ThreatModel → #parser
Live model hashed and compared against the exported one
42 │ * carrying a hash that differs from the live model is provably behind it; an 43 │ * export carrying no hash at all predates the stamp and cannot be judged either 44 │ * way. Those are different answers and this module returns different verdicts 45 │ * for them, because `unverifiable` is honest and `stale` would be a guess. 46 │ * 47 │ * @flows ThreatModel -> #parser via checkHandoff -- "Live model hashed and compared against the exported one" 48 │ * @flows ExportFile -> #parser via readFileSync -- "The JSON export consumers read, re-read here only for its provenance stamp" 49 │ * @assumes #parser -- "`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance" 50 │ * @comment -- "No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26" 51 │ * @comment -- "`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own" 52 │ * @comment -- "Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce"
L48 flow ExportFile → #parser
The JSON export consumers read, re-read here only for its provenance stamp
43 │ * export carrying no hash at all predates the stamp and cannot be judged either 44 │ * way. Those are different answers and this module returns different verdicts 45 │ * for them, because `unverifiable` is honest and `stale` would be a guess. 46 │ * 47 │ * @flows ThreatModel -> #parser via checkHandoff -- "Live model hashed and compared against the exported one" 48 │ * @flows ExportFile -> #parser via readFileSync -- "The JSON export consumers read, re-read here only for its provenance stamp" 49 │ * @assumes #parser -- "`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance" 50 │ * @comment -- "No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26" 51 │ * @comment -- "`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own" 52 │ * @comment -- "Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce" 53 │ */
L49 assumes Assumes: #parser
`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance
44 │ * way. Those are different answers and this module returns different verdicts 45 │ * for them, because `unverifiable` is honest and `stale` would be a guess. 46 │ * 47 │ * @flows ThreatModel -> #parser via checkHandoff -- "Live model hashed and compared against the exported one" 48 │ * @flows ExportFile -> #parser via readFileSync -- "The JSON export consumers read, re-read here only for its provenance stamp" 49 │ * @assumes #parser -- "`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance" 50 │ * @comment -- "No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26" 51 │ * @comment -- "`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own" 52 │ * @comment -- "Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce" 53 │ */ 54 │
L50 comment No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26
No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26
45 │ * for them, because `unverifiable` is honest and `stale` would be a guess. 46 │ * 47 │ * @flows ThreatModel -> #parser via checkHandoff -- "Live model hashed and compared against the exported one" 48 │ * @flows ExportFile -> #parser via readFileSync -- "The JSON export consumers read, re-read here only for its provenance stamp" 49 │ * @assumes #parser -- "`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance" 50 │ * @comment -- "No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26" 51 │ * @comment -- "`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own" 52 │ * @comment -- "Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce" 53 │ */ 54 │ 55 │ import { existsSync, readFileSync } from 'node:fs';
L51 comment `not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own
`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own
46 │ * 47 │ * @flows ThreatModel -> #parser via checkHandoff -- "Live model hashed and compared against the exported one" 48 │ * @flows ExportFile -> #parser via readFileSync -- "The JSON export consumers read, re-read here only for its provenance stamp" 49 │ * @assumes #parser -- "`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance" 50 │ * @comment -- "No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26" 51 │ * @comment -- "`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own" 52 │ * @comment -- "Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce" 53 │ */ 54 │ 55 │ import { existsSync, readFileSync } from 'node:fs'; 56 │ import { join } from 'node:path';
L52 comment Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce
Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce
47 │ * @flows ThreatModel -> #parser via checkHandoff -- "Live model hashed and compared against the exported one" 48 │ * @flows ExportFile -> #parser via readFileSync -- "The JSON export consumers read, re-read here only for its provenance stamp" 49 │ * @assumes #parser -- "`metadata.annotation_hash` on an export was written by `guardlink parse -o` over the same hash function this module calls. Nothing verifies that: a hand-edited stamp reads as current, and the answer this module gives is only as good as the file's provenance" 50 │ * @comment -- "No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26" 51 │ * @comment -- "`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own" 52 │ * @comment -- "Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce" 53 │ */ 54 │ 55 │ import { existsSync, readFileSync } from 'node:fs'; 56 │ import { join } from 'node:path'; 57 │ import { computeAnnotationHash } from './annotation-hash.js';
L23 exposes #parser → #dos
Widening the scan set grows the file count a single parse walks; every added extension is another glob fast-glob expands and another file read into memory
18 │ * Measured on a probe repo carrying one annotated file per language §2.9 names: 19 │ * read in **6 of 43** extensions. That is what made `bravos annotate` 20 │ * unusable outside a handful of languages — the agent's analysis was sound, its 21 │ * `@exposes` landed in the file, and the model stayed empty. 22 │ * 23 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Widening the scan set grows the file count a single parse walks; every added extension is another glob fast-glob expands and another file read into memory" 24 │ * @mitigates #parser against #dos using #resource-limits -- "The set is bounded by SPEC §2.9's own table rather than open-ended, DEFAULT_EXCLUDE still prunes node_modules/dist/vendor/target, and no extension here matches a lock file, a bundle or a binary" 25 │ * @comment -- "Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation" 26 │ * @validates #glob-filtering for #parser -- "tests/scanned-languages.test.ts writes an annotation in every language named here and asserts the model read it, and asserts the scan set and the marker table are the same list" 27 │ */ 28 │
L24 mitigates #resource-limits mitigates #dos
The set is bounded by SPEC §2.9's own table rather than open-ended, DEFAULT_EXCLUDE still prunes node_modules/dist/vendor/target, and no extension here matches a lock file, a bundle or a binary
19 │ * read in **6 of 43** extensions. That is what made `bravos annotate` 20 │ * unusable outside a handful of languages — the agent's analysis was sound, its 21 │ * `@exposes` landed in the file, and the model stayed empty. 22 │ * 23 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Widening the scan set grows the file count a single parse walks; every added extension is another glob fast-glob expands and another file read into memory" 24 │ * @mitigates #parser against #dos using #resource-limits -- "The set is bounded by SPEC §2.9's own table rather than open-ended, DEFAULT_EXCLUDE still prunes node_modules/dist/vendor/target, and no extension here matches a lock file, a bundle or a binary" 25 │ * @comment -- "Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation" 26 │ * @validates #glob-filtering for #parser -- "tests/scanned-languages.test.ts writes an annotation in every language named here and asserts the model read it, and asserts the scan set and the marker table are the same list" 27 │ */ 28 │ 29 │ /**
L25 comment Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation
Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation
20 │ * unusable outside a handful of languages — the agent's analysis was sound, its 21 │ * `@exposes` landed in the file, and the model stayed empty. 22 │ * 23 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Widening the scan set grows the file count a single parse walks; every added extension is another glob fast-glob expands and another file read into memory" 24 │ * @mitigates #parser against #dos using #resource-limits -- "The set is bounded by SPEC §2.9's own table rather than open-ended, DEFAULT_EXCLUDE still prunes node_modules/dist/vendor/target, and no extension here matches a lock file, a bundle or a binary" 25 │ * @comment -- "Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation" 26 │ * @validates #glob-filtering for #parser -- "tests/scanned-languages.test.ts writes an annotation in every language named here and asserts the model read it, and asserts the scan set and the marker table are the same list" 27 │ */ 28 │ 29 │ /** 30 │ * Lower-case extension (with dot) → the single-line comment marker that
L26 validates #glob-filtering validates #parser
tests/scanned-languages.test.ts writes an annotation in every language named here and asserts the model read it, and asserts the scan set and the marker table are the same list
21 │ * `@exposes` landed in the file, and the model stayed empty. 22 │ * 23 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Widening the scan set grows the file count a single parse walks; every added extension is another glob fast-glob expands and another file read into memory" 24 │ * @mitigates #parser against #dos using #resource-limits -- "The set is bounded by SPEC §2.9's own table rather than open-ended, DEFAULT_EXCLUDE still prunes node_modules/dist/vendor/target, and no extension here matches a lock file, a bundle or a binary" 25 │ * @comment -- "Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation" 26 │ * @validates #glob-filtering for #parser -- "tests/scanned-languages.test.ts writes an annotation in every language named here and asserts the model read it, and asserts the scan set and the marker table are the same list" 27 │ */ 28 │ 29 │ /** 30 │ * Lower-case extension (with dot) → the single-line comment marker that 31 │ * language writes, per SPEC §2.9.
L18 exposes #parser → #insecure-deser
JSON.parse on a committed file under .guardlink/
13 │ * A file that exists but does not parse, or names another schema, or holds an 14 │ * entry of the wrong shape, is CORRUPT — reported once through a diagnostic 15 │ * and otherwise treated as absent. It is never silently rewritten: `verify` 16 │ * refuses to write over it without `--force`. 17 │ * 18 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse on a committed file under .guardlink/" 19 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded" 20 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeLedger writes one fixed path under root" 21 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is the constant LEDGER_FILE joined to root; no caller supplies a path" 22 │ * @flows LedgerFile -> #parser via readLedger -- "Recorded hashes and verifiers" 23 │ * @flows #cli -> LedgerFile via writeLedger -- "The only write the verify surfaces perform"
L19 mitigates #config-validation mitigates #insecure-deser
Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded
14 │ * entry of the wrong shape, is CORRUPT — reported once through a diagnostic 15 │ * and otherwise treated as absent. It is never silently rewritten: `verify` 16 │ * refuses to write over it without `--force`. 17 │ * 18 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse on a committed file under .guardlink/" 19 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded" 20 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeLedger writes one fixed path under root" 21 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is the constant LEDGER_FILE joined to root; no caller supplies a path" 22 │ * @flows LedgerFile -> #parser via readLedger -- "Recorded hashes and verifiers" 23 │ * @flows #cli -> LedgerFile via writeLedger -- "The only write the verify surfaces perform" 24 │ */
L20 exposes #cli → #arbitrary-write
writeLedger writes one fixed path under root
15 │ * and otherwise treated as absent. It is never silently rewritten: `verify` 16 │ * refuses to write over it without `--force`. 17 │ * 18 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse on a committed file under .guardlink/" 19 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded" 20 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeLedger writes one fixed path under root" 21 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is the constant LEDGER_FILE joined to root; no caller supplies a path" 22 │ * @flows LedgerFile -> #parser via readLedger -- "Recorded hashes and verifiers" 23 │ * @flows #cli -> LedgerFile via writeLedger -- "The only write the verify surfaces perform" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
L21 mitigates #path-validation mitigates #arbitrary-write
The path is the constant LEDGER_FILE joined to root; no caller supplies a path
16 │ * refuses to write over it without `--force`. 17 │ * 18 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse on a committed file under .guardlink/" 19 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded" 20 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeLedger writes one fixed path under root" 21 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is the constant LEDGER_FILE joined to root; no caller supplies a path" 22 │ * @flows LedgerFile -> #parser via readLedger -- "Recorded hashes and verifiers" 23 │ * @flows #cli -> LedgerFile via writeLedger -- "The only write the verify surfaces perform" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { dirname, join } from 'node:path';
L22 flow LedgerFile → #parser
Recorded hashes and verifiers
17 │ * 18 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse on a committed file under .guardlink/" 19 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded" 20 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeLedger writes one fixed path under root" 21 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is the constant LEDGER_FILE joined to root; no caller supplies a path" 22 │ * @flows LedgerFile -> #parser via readLedger -- "Recorded hashes and verifiers" 23 │ * @flows #cli -> LedgerFile via writeLedger -- "The only write the verify surfaces perform" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { dirname, join } from 'node:path'; 27 │ import type { AnchorScope, AnnotationVerb, ParseDiagnostic } from '../types/index.js';
L23 flow #cli → LedgerFile
The only write the verify surfaces perform
18 │ * @exposes #parser to #insecure-deser [low] cwe:CWE-502 -- "JSON.parse on a committed file under .guardlink/" 19 │ * @mitigates #parser against #insecure-deser using #config-validation -- "Shape is validated field by field before any entry is trusted; anything else is corrupt, not partially loaded" 20 │ * @exposes #cli to #arbitrary-write [low] cwe:CWE-73 -- "writeLedger writes one fixed path under root" 21 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "The path is the constant LEDGER_FILE joined to root; no caller supplies a path" 22 │ * @flows LedgerFile -> #parser via readLedger -- "Recorded hashes and verifiers" 23 │ * @flows #cli -> LedgerFile via writeLedger -- "The only write the verify surfaces perform" 24 │ */ 25 │ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { dirname, join } from 'node:path'; 27 │ import type { AnchorScope, AnnotationVerb, ParseDiagnostic } from '../types/index.js'; 28 │ import { ANCHOR_HASH_VERSION } from '../structure/hash.js';
L6 exposes #parser → #path-traversal
File path from caller read via readFile; no validation here
1 │ /** 2 │ * GuardLink — File-level parser. 3 │ * Reads source files and extracts all GuardLink annotations. 4 │ * Standalone .gal files are treated as raw annotation text. 5 │ * 6 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "File path from caller read via readFile; no validation here" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped"
L7 exposes #parser → #dos
Large files loaded entirely into memory
2 │ * GuardLink — File-level parser. 3 │ * Reads source files and extracts all GuardLink annotations. 4 │ * Standalone .gal files are treated as raw annotation text. 5 │ * 6 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "File path from caller read via readFile; no validation here" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed"
L8 audit Audit: #parser
Path validation delegated to callers (CLI/MCP validate root)
3 │ * Reads source files and extracts all GuardLink annotations. 4 │ * Standalone .gal files are treated as raw annotation text. 5 │ * 6 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "File path from caller read via readFile; no validation here" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed" 13 │ * @validates #input-sanitize for #parser -- "tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics"
L9 flow FilePath → #parser
Disk read path
4 │ * Standalone .gal files are treated as raw annotation text. 5 │ * 6 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "File path from caller read via readFile; no validation here" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed" 13 │ * @validates #input-sanitize for #parser -- "tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics" 14 │ */
L10 flow #parser → Annotations
Parsed annotation output
5 │ * 6 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "File path from caller read via readFile; no validation here" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed" 13 │ * @validates #input-sanitize for #parser -- "tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics" 14 │ */ 15 │
L11 flow #parser → ParseDiagnostics
Lines that were meant to be annotations and were not read, named rather than dropped
6 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "File path from caller read via readFile; no validation here" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed" 13 │ * @validates #input-sanitize for #parser -- "tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics" 14 │ */ 15 │ 16 │ import { readFile } from 'node:fs/promises';
L12 comment A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed
A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed
7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large files loaded entirely into memory" 8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed" 13 │ * @validates #input-sanitize for #parser -- "tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics" 14 │ */ 15 │ 16 │ import { readFile } from 'node:fs/promises'; 17 │ import type { Annotation, ParseDiagnostic, ParseResult, SourceLocation } from '../types/index.js';
L13 validates #input-sanitize validates #parser
tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics
8 │ * @audit #parser -- "Path validation delegated to callers (CLI/MCP validate root)" 9 │ * @flows FilePath -> #parser via readFile -- "Disk read path" 10 │ * @flows #parser -> Annotations via parseString -- "Parsed annotation output" 11 │ * @flows #parser -> ParseDiagnostics via parseString -- "Lines that were meant to be annotations and were not read, named rather than dropped" 12 │ * @comment -- "A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed" 13 │ * @validates #input-sanitize for #parser -- "tests/comment-forms.test.ts pins that doc-tag and decorator traffic produces neither annotations nor diagnostics" 14 │ */ 15 │ 16 │ import { readFile } from 'node:fs/promises'; 17 │ import type { Annotation, ParseDiagnostic, ParseResult, SourceLocation } from '../types/index.js'; 18 │ import { isStandaloneAnnotationFile, stripCommentPrefix } from './comment-strip.js';
L5 exposes #parser → #redos
Complex regex patterns applied to annotation text
1 │ /** 2 │ * GuardLink — Line-level annotation parser. 3 │ * Parses a single comment line into a typed Annotation. 4 │ * 5 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to annotation text" 6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class"
L6 mitigates #regex-anchoring mitigates #redos
All patterns are anchored (^...$) to prevent backtracking
1 │ /** 2 │ * GuardLink — Line-level annotation parser. 3 │ * Parses a single comment line into a typed Annotation. 4 │ * 5 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to annotation text" 6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" 11 │ * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query"
L7 comment Regex patterns designed with bounded quantifiers and explicit structure
Regex patterns designed with bounded quantifiers and explicit structure
2 │ * GuardLink — Line-level annotation parser. 3 │ * Parses a single comment line into a typed Annotation. 4 │ * 5 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to annotation text" 6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" 11 │ * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query" 12 │ */
L8 comment @entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)
@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)
3 │ * Parses a single comment line into a typed Annotation. 4 │ * 5 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to annotation text" 6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" 11 │ * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query" 12 │ */ 13 │
L9 comment @entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)
@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)
4 │ * 5 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to annotation text" 6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" 11 │ * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query" 12 │ */ 13 │ 14 │ import type {
L10 mitigates #regex-anchoring mitigates #redos
@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class
5 │ * @exposes #parser to #redos [medium] cwe:CWE-1333 -- "Complex regex patterns applied to annotation text" 6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" 11 │ * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query" 12 │ */ 13 │ 14 │ import type { 15 │ Annotation, DataClassification, EffectClass,
L11 comment @effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query
@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query
6 │ * @mitigates #parser against #redos using #regex-anchoring -- "All patterns are anchored (^...$) to prevent backtracking" 7 │ * @comment -- "Regex patterns designed with bounded quantifiers and explicit structure" 8 │ * @comment -- "@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)" 9 │ * @comment -- "@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)" 10 │ * @mitigates #parser against #redos using #regex-anchoring -- "@agents, @reaches, @effects and @gates reuse the anchored ASSET_REF, THREAT_REF, CAPABILITY and DESC fragments and add only a closed alternation for the effect class" 11 │ * @comment -- "@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query" 12 │ */ 13 │ 14 │ import type { 15 │ Annotation, DataClassification, EffectClass, 16 │ ParseDiagnostic, SourceLocation,
L5 exposes #parser → #path-traversal
Glob patterns could escape root directory
1 │ /** 2 │ * GuardLink — Project-level parser. 3 │ * Walks a directory, parses all source files, and assembles a ThreatModel. 4 │ * 5 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns could escape root directory" 6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output"
L6 mitigates #glob-filtering mitigates #path-traversal
DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan
1 │ /** 2 │ * GuardLink — Project-level parser. 3 │ * Walks a directory, parses all source files, and assembles a ThreatModel. 4 │ * 5 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns could escape root directory" 6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations"
L7 exposes #parser → #dos
Large projects with many files could exhaust memory
2 │ * GuardLink — Project-level parser. 3 │ * Walks a directory, parses all source files, and assembles a ThreatModel. 4 │ * 5 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns could escape root directory" 6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)"
L8 mitigates #resource-limits mitigates #dos
DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count
3 │ * Walks a directory, parses all source files, and assembles a ThreatModel. 4 │ * 5 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns could escape root directory" 6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)"
L9 flow ProjectRoot → #parser
Directory traversal path
4 │ * 5 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns could escape root directory" 6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)" 14 │ * @boundary #parser and FileSystem (#fs-boundary) -- "Trust boundary between parser and disk I/O"
L10 flow #parser → ThreatModel
Aggregated threat model output
5 │ * @exposes #parser to #path-traversal [high] cwe:CWE-22 -- "Glob patterns could escape root directory" 6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)" 14 │ * @boundary #parser and FileSystem (#fs-boundary) -- "Trust boundary between parser and disk I/O" 15 │ */
L11 comment Scans standalone .gal files in addition to comment-based source annotations
Scans standalone .gal files in addition to comment-based source annotations
6 │ * @mitigates #parser against #path-traversal using #glob-filtering -- "DEFAULT_EXCLUDE blocks node_modules, .git; fast-glob cwd constrains scan" 7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)" 14 │ * @boundary #parser and FileSystem (#fs-boundary) -- "Trust boundary between parser and disk I/O" 15 │ */ 16 │
L12 comment Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)
Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)
7 │ * @exposes #parser to #dos [medium] cwe:CWE-400 -- "Large projects with many files could exhaust memory" 8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)" 14 │ * @boundary #parser and FileSystem (#fs-boundary) -- "Trust boundary between parser and disk I/O" 15 │ */ 16 │ 17 │ import fg from 'fast-glob';
L13 comment Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)
Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)
8 │ * @mitigates #parser against #dos using #resource-limits -- "DEFAULT_EXCLUDE skips build artifacts, tests; limits effective file count" 9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)" 14 │ * @boundary #parser and FileSystem (#fs-boundary) -- "Trust boundary between parser and disk I/O" 15 │ */ 16 │ 17 │ import fg from 'fast-glob'; 18 │ import { GAL_GLOB, sourceGlobs } from './languages.js';
L14 boundary #parser ↔ FileSystem
Trust boundary between parser and disk I/O
9 │ * @flows ProjectRoot -> #parser via fast-glob -- "Directory traversal path" 10 │ * @flows #parser -> ThreatModel via assembleModel -- "Aggregated threat model output" 11 │ * @comment -- "Scans standalone .gal files in addition to comment-based source annotations" 12 │ * @comment -- "Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)" 13 │ * @comment -- "Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)" 14 │ * @boundary #parser and FileSystem (#fs-boundary) -- "Trust boundary between parser and disk I/O" 15 │ */ 16 │ 17 │ import fg from 'fast-glob'; 18 │ import { GAL_GLOB, sourceGlobs } from './languages.js'; 19 │ import { existsSync, readFileSync } from 'node:fs';
L143 exposes #parser → #path-traversal
`@source file:` is author-controlled text and is joined onto the project root to test existence
138 │ * scan set would fire on correct annotations. 139 │ * 140 │ * One diagnostic per (sidecar, missing path) pair: a sidecar with twelve 141 │ * annotations for one moved file is one mistake, not twelve. 142 │ * 143 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "`@source file:` is author-controlled text and is joined onto the project root to test existence" 144 │ * @mitigates #parser against #path-traversal using #path-validation -- "Existence only: no read, no write, and the result reaches the model as a warning string that echoes the path the author already wrote" 145 │ * @flows AnnotationSource -> #parser via existsSync -- "Declared @source paths probed for existence" 146 │ * @comment -- "A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove" 147 │ */ 148 │ function missingSourceDiagnostics(root: string, annotations: Annotation[]): ParseDiagnostic[] {
L144 mitigates #path-validation mitigates #path-traversal
Existence only: no read, no write, and the result reaches the model as a warning string that echoes the path the author already wrote
139 │ * 140 │ * One diagnostic per (sidecar, missing path) pair: a sidecar with twelve 141 │ * annotations for one moved file is one mistake, not twelve. 142 │ * 143 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "`@source file:` is author-controlled text and is joined onto the project root to test existence" 144 │ * @mitigates #parser against #path-traversal using #path-validation -- "Existence only: no read, no write, and the result reaches the model as a warning string that echoes the path the author already wrote" 145 │ * @flows AnnotationSource -> #parser via existsSync -- "Declared @source paths probed for existence" 146 │ * @comment -- "A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove" 147 │ */ 148 │ function missingSourceDiagnostics(root: string, annotations: Annotation[]): ParseDiagnostic[] { 149 │ const seen = new Set<string>();
L145 flow AnnotationSource → #parser
Declared @source paths probed for existence
140 │ * One diagnostic per (sidecar, missing path) pair: a sidecar with twelve 141 │ * annotations for one moved file is one mistake, not twelve. 142 │ * 143 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "`@source file:` is author-controlled text and is joined onto the project root to test existence" 144 │ * @mitigates #parser against #path-traversal using #path-validation -- "Existence only: no read, no write, and the result reaches the model as a warning string that echoes the path the author already wrote" 145 │ * @flows AnnotationSource -> #parser via existsSync -- "Declared @source paths probed for existence" 146 │ * @comment -- "A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove" 147 │ */ 148 │ function missingSourceDiagnostics(root: string, annotations: Annotation[]): ParseDiagnostic[] { 149 │ const seen = new Set<string>(); 150 │ const diagnostics: ParseDiagnostic[] = [];
L146 comment A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove
A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove
141 │ * annotations for one moved file is one mistake, not twelve. 142 │ * 143 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "`@source file:` is author-controlled text and is joined onto the project root to test existence" 144 │ * @mitigates #parser against #path-traversal using #path-validation -- "Existence only: no read, no write, and the result reaches the model as a warning string that echoes the path the author already wrote" 145 │ * @flows AnnotationSource -> #parser via existsSync -- "Declared @source paths probed for existence" 146 │ * @comment -- "A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove" 147 │ */ 148 │ function missingSourceDiagnostics(root: string, annotations: Annotation[]): ParseDiagnostic[] { 149 │ const seen = new Set<string>(); 150 │ const diagnostics: ParseDiagnostic[] = []; 151 │ for (const ann of annotations) {
L50 comment Pure functions over an assembled ThreatModel; no I/O. Uncited entitlements never cover a reach, and a gate scoped to a capability never covers an effect whose route is unknown, so both joins err toward showing a capability or an effect rather than hiding one
Pure functions over an assembled ThreatModel; no I/O. Uncited entitlements never cover a reach, and a gate scoped to a capability never covers an effect whose route is unknown, so both joins err toward showing a capability or an effect rather than hiding one
45 │ * 46 │ * `buildReachAnalysis` is the derived block the model JSON carries as 47 │ * `reach_analysis` (SPEC §5.5): both joins above, with the claim key of each 48 │ * row so a consumer can join it to the SARIF `guardlink/agent-reach` results. 49 │ * 50 │ * @comment -- "Pure functions over an assembled ThreatModel; no I/O. Uncited entitlements never cover a reach, and a gate scoped to a capability never covers an effect whose route is unknown, so both joins err toward showing a capability or an effect rather than hiding one" 51 │ */ 52 │ import type { 53 │ ThreatModel, ThreatModelReach, ThreatModelEntitlement, ThreatModelEffect, ThreatModelGate, 54 │ EffectClass, SourceLocation, 55 │ } from '../types/index.js';
L19 exposes #parser → #path-traversal
Reads source files named by @source blocks
14 │ * Reports, never repairs. A proposed line is a suggestion for a human or an 15 │ * agent to accept — silently rewriting an anchor would move an annotation onto 16 │ * code nobody chose for it, which is the same failure as the drift, arrived at 17 │ * faster. 18 │ * 19 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads source files named by @source blocks" 20 │ * @mitigates #parser against #path-traversal using #path-validation -- "Paths are joined to root and skipped when missing; nothing outside the model is read" 21 │ * @flows ThreatModel -> #parser via findAnchorDrift -- "Recorded anchors compared against current source" 22 │ * @comment -- "Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages" 23 │ */ 24 │
L20 mitigates #path-validation mitigates #path-traversal
Paths are joined to root and skipped when missing; nothing outside the model is read
15 │ * agent to accept — silently rewriting an anchor would move an annotation onto 16 │ * code nobody chose for it, which is the same failure as the drift, arrived at 17 │ * faster. 18 │ * 19 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads source files named by @source blocks" 20 │ * @mitigates #parser against #path-traversal using #path-validation -- "Paths are joined to root and skipped when missing; nothing outside the model is read" 21 │ * @flows ThreatModel -> #parser via findAnchorDrift -- "Recorded anchors compared against current source" 22 │ * @comment -- "Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages" 23 │ */ 24 │ 25 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
L21 flow ThreatModel → #parser
Recorded anchors compared against current source
16 │ * code nobody chose for it, which is the same failure as the drift, arrived at 17 │ * faster. 18 │ * 19 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads source files named by @source blocks" 20 │ * @mitigates #parser against #path-traversal using #path-validation -- "Paths are joined to root and skipped when missing; nothing outside the model is read" 21 │ * @flows ThreatModel -> #parser via findAnchorDrift -- "Recorded anchors compared against current source" 22 │ * @comment -- "Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages" 23 │ */ 24 │ 25 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { join } from 'node:path';
L22 comment Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages
Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages
17 │ * faster. 18 │ * 19 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads source files named by @source blocks" 20 │ * @mitigates #parser against #path-traversal using #path-validation -- "Paths are joined to root and skipped when missing; nothing outside the model is read" 21 │ * @flows ThreatModel -> #parser via findAnchorDrift -- "Recorded anchors compared against current source" 22 │ * @comment -- "Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages" 23 │ */ 24 │ 25 │ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 26 │ import { join } from 'node:path'; 27 │ import type { ThreatModel, SourceLocation } from '../types/index.js';
L19 flow ThreatModel → #parser
Route @flows indexed by handler scope
14 │ * the `POST /pay` handler, which is the route a scanner then probed. A route 15 │ * now belongs to the handler it is declared on, and when the model does not say 16 │ * which route reaches a claim the answer is `ambiguous`, with the candidates, 17 │ * rather than whichever route happened to be written first. 18 │ * 19 │ * @flows ThreatModel -> #parser via buildRouteIndex -- "Route @flows indexed by handler scope" 20 │ * @comment -- "Pure functions over the parsed model; the path is returned verbatim from the annotation and never resolved, fetched or joined to a base URL" 21 │ */ 22 │ 23 │ import type { HttpRoute, SourceLocation, ThreatModelFlow } from '../types/index.js'; 24 │ import { normalizeRef } from './coverage.js';
L20 comment Pure functions over the parsed model; the path is returned verbatim from the annotation and never resolved, fetched or joined to a base URL
Pure functions over the parsed model; the path is returned verbatim from the annotation and never resolved, fetched or joined to a base URL
15 │ * now belongs to the handler it is declared on, and when the model does not say 16 │ * which route reaches a claim the answer is `ambiguous`, with the candidates, 17 │ * rather than whichever route happened to be written first. 18 │ * 19 │ * @flows ThreatModel -> #parser via buildRouteIndex -- "Route @flows indexed by handler scope" 20 │ * @comment -- "Pure functions over the parsed model; the path is returned verbatim from the annotation and never resolved, fetched or joined to a base URL" 21 │ */ 22 │ 23 │ import type { HttpRoute, SourceLocation, ThreatModelFlow } from '../types/index.js'; 24 │ import { normalizeRef } from './coverage.js'; 25 │ import { handlerScope, scopeEncloses, type HandlerScope } from './handler-scope.js';
L7 mitigates #prefix-ownership mitigates #tag-collision
findDanglingRefs ensures #id refs resolve to definitions
2 │ * GuardLink — Shared validation helpers. 3 │ * 4 │ * Extracted from cli/index.ts and tui/commands.ts to eliminate duplication 5 │ * and ensure consistent validation logic across all entry points. 6 │ * 7 │ * @mitigates #parser against #tag-collision using #prefix-ownership -- "findDanglingRefs ensures #id refs resolve to definitions" 8 │ * @comment -- "@confirmed refs validated same as @exposes for asset and threat" 9 │ * @comment -- "findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing" 10 │ * @comment -- "findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable" 11 │ * @comment -- "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent" 12 │ */
L8 comment @confirmed refs validated same as @exposes for asset and threat
@confirmed refs validated same as @exposes for asset and threat
3 │ * 4 │ * Extracted from cli/index.ts and tui/commands.ts to eliminate duplication 5 │ * and ensure consistent validation logic across all entry points. 6 │ * 7 │ * @mitigates #parser against #tag-collision using #prefix-ownership -- "findDanglingRefs ensures #id refs resolve to definitions" 8 │ * @comment -- "@confirmed refs validated same as @exposes for asset and threat" 9 │ * @comment -- "findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing" 10 │ * @comment -- "findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable" 11 │ * @comment -- "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent" 12 │ */ 13 │
L9 comment findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing
findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing
4 │ * Extracted from cli/index.ts and tui/commands.ts to eliminate duplication 5 │ * and ensure consistent validation logic across all entry points. 6 │ * 7 │ * @mitigates #parser against #tag-collision using #prefix-ownership -- "findDanglingRefs ensures #id refs resolve to definitions" 8 │ * @comment -- "@confirmed refs validated same as @exposes for asset and threat" 9 │ * @comment -- "findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing" 10 │ * @comment -- "findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable" 11 │ * @comment -- "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent" 12 │ */ 13 │ 14 │ import {
L10 comment findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable
findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable
5 │ * and ensure consistent validation logic across all entry points. 6 │ * 7 │ * @mitigates #parser against #tag-collision using #prefix-ownership -- "findDanglingRefs ensures #id refs resolve to definitions" 8 │ * @comment -- "@confirmed refs validated same as @exposes for asset and threat" 9 │ * @comment -- "findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing" 10 │ * @comment -- "findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable" 11 │ * @comment -- "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent" 12 │ */ 13 │ 14 │ import { 15 │ isConventionalGalPath, sourceFileForGal, galPathFor, offConventionMessage,
L11 comment findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent
findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent
6 │ * 7 │ * @mitigates #parser against #tag-collision using #prefix-ownership -- "findDanglingRefs ensures #id refs resolve to definitions" 8 │ * @comment -- "@confirmed refs validated same as @exposes for asset and threat" 9 │ * @comment -- "findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing" 10 │ * @comment -- "findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable" 11 │ * @comment -- "findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent" 12 │ */ 13 │ 14 │ import { 15 │ isConventionalGalPath, sourceFileForGal, galPathFor, offConventionMessage, 16 │ } from './gal-path.js';
L29 flow ThreatModel → #parser
Anchors on every relation record compared with the ledger
24 │ * never stale: comparing a fallback hash against a token hash recorded on a 25 │ * machine where the grammar loaded is meaningless, and treating that as 26 │ * staleness would fail `--strict` builds on a packaging defect rather than a 27 │ * code change. 28 │ * 29 │ * @flows ThreatModel -> #parser via classifyClaims -- "Anchors on every relation record compared with the ledger" 30 │ * @flows LedgerFile -> #parser via classifyClaims -- "Recorded hashes, already read by the caller" 31 │ * @comment -- "Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model" 32 │ */ 33 │ import type { ThreatModel, SourceLocation, Anchor } from '../types/index.js'; 34 │ import { relationRecords, type ClaimVerb } from './claim-key.js';
L30 flow LedgerFile → #parser
Recorded hashes, already read by the caller
25 │ * machine where the grammar loaded is meaningless, and treating that as 26 │ * staleness would fail `--strict` builds on a packaging defect rather than a 27 │ * code change. 28 │ * 29 │ * @flows ThreatModel -> #parser via classifyClaims -- "Anchors on every relation record compared with the ledger" 30 │ * @flows LedgerFile -> #parser via classifyClaims -- "Recorded hashes, already read by the caller" 31 │ * @comment -- "Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model" 32 │ */ 33 │ import type { ThreatModel, SourceLocation, Anchor } from '../types/index.js'; 34 │ import { relationRecords, type ClaimVerb } from './claim-key.js'; 35 │ import type { Ledger, LedgerEntry, LedgerRead, LedgerStatus } from './ledger.js';
L31 comment Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model
Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model
26 │ * staleness would fail `--strict` builds on a packaging defect rather than a 27 │ * code change. 28 │ * 29 │ * @flows ThreatModel -> #parser via classifyClaims -- "Anchors on every relation record compared with the ledger" 30 │ * @flows LedgerFile -> #parser via classifyClaims -- "Recorded hashes, already read by the caller" 31 │ * @comment -- "Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model" 32 │ */ 33 │ import type { ThreatModel, SourceLocation, Anchor } from '../types/index.js'; 34 │ import { relationRecords, type ClaimVerb } from './claim-key.js'; 35 │ import type { Ledger, LedgerEntry, LedgerRead, LedgerStatus } from './ledger.js'; 36 │ import { ANCHOR_HASH_VERSION } from '../structure/hash.js';
L12 flow #cli → LedgerFile
The ledger the verify surfaces write
7 │ * applyVerification(ledger, plan, identity) → the new ledger, not yet written 8 │ * 9 │ * Re-locking a stale claim asserts the control still holds, so the default 10 │ * mode never does it; `stale`, `all` and a named target do, and say so. 11 │ * 12 │ * @flows #cli -> LedgerFile via applyVerification -- "The ledger the verify surfaces write" 13 │ * @exposes #cli to #cmd-injection [low] cwe:CWE-78 -- "git is spawned for the verifier name and HEAD" 14 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv; no shell, no caller-supplied argument" 15 │ * @comment -- "Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix" 16 │ */ 17 │ import { execFileSync } from 'node:child_process';
L13 exposes #cli → #cmd-injection
git is spawned for the verifier name and HEAD
8 │ * 9 │ * Re-locking a stale claim asserts the control still holds, so the default 10 │ * mode never does it; `stale`, `all` and a named target do, and say so. 11 │ * 12 │ * @flows #cli -> LedgerFile via applyVerification -- "The ledger the verify surfaces write" 13 │ * @exposes #cli to #cmd-injection [low] cwe:CWE-78 -- "git is spawned for the verifier name and HEAD" 14 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv; no shell, no caller-supplied argument" 15 │ * @comment -- "Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix" 16 │ */ 17 │ import { execFileSync } from 'node:child_process'; 18 │ import { userInfo } from 'node:os';
L14 mitigates #param-commands mitigates #cmd-injection
execFileSync with a fixed argv; no shell, no caller-supplied argument
9 │ * Re-locking a stale claim asserts the control still holds, so the default 10 │ * mode never does it; `stale`, `all` and a named target do, and say so. 11 │ * 12 │ * @flows #cli -> LedgerFile via applyVerification -- "The ledger the verify surfaces write" 13 │ * @exposes #cli to #cmd-injection [low] cwe:CWE-78 -- "git is spawned for the verifier name and HEAD" 14 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv; no shell, no caller-supplied argument" 15 │ * @comment -- "Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix" 16 │ */ 17 │ import { execFileSync } from 'node:child_process'; 18 │ import { userInfo } from 'node:os'; 19 │ import type { Ledger, LedgerEntry } from './ledger.js';
L15 comment Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix
Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix
10 │ * mode never does it; `stale`, `all` and a named target do, and say so. 11 │ * 12 │ * @flows #cli -> LedgerFile via applyVerification -- "The ledger the verify surfaces write" 13 │ * @exposes #cli to #cmd-injection [low] cwe:CWE-78 -- "git is spawned for the verifier name and HEAD" 14 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv; no shell, no caller-supplied argument" 15 │ * @comment -- "Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix" 16 │ */ 17 │ import { execFileSync } from 'node:child_process'; 18 │ import { userInfo } from 'node:os'; 19 │ import type { Ledger, LedgerEntry } from './ledger.js'; 20 │ import { emptyLedger } from './ledger.js';
L4 comment Pure string formatting; no I/O. Every hop prints its file:line because a path that cannot be walked in an editor is an assertion rather than a finding, and the empty case states which kind of empty it is — no undefended route in the annotated graph is not the same claim as no undefended route in the code
Pure string formatting; no I/O. Every hop prints its file:line because a path that cannot be walked in an editor is an assertion rather than a finding, and the empty case states which kind of empty it is — no undefended route in the annotated graph is not the same claim as no undefended route in the code
1 │ /** 2 │ * GuardLink Paths — human-readable output formatter. 3 │ * 4 │ * @comment -- "Pure string formatting; no I/O. Every hop prints its file:line because a path that cannot be walked in an editor is an assertion rather than a finding, and the empty case states which kind of empty it is — no undefended route in the annotated graph is not the same claim as no undefended route in the code" 5 │ */ 6 │ 7 │ import type { PathFinding, EndpointClassification } from './index.js'; 8 │ 9 │ /** `UserInput --req.body--> #api --writeFileSync--> FileSystem` */
L17 flow ThreatModel → #parser
Reads flows, assets, mitigations and boundaries from the parsed model
12 │ * and which side it sits on follows from its degree — no inbound flow means 13 │ * data originates there, no outbound flow means data terminates there. On this 14 │ * repo that lands on UserPrompt/EnvVars/RawStdin and FileSystem/Commands/ 15 │ * TempDir without naming any of them here. 16 │ * 17 │ * @flows ThreatModel -> #parser via findUnmitigatedPaths -- "Reads flows, assets, mitigations and boundaries from the parsed model" 18 │ * @comment -- "Pure analyzer: no file I/O, no user input, no network. Operates only on an already-parsed ThreatModel, which is why it needs no @exposes of its own" 19 │ * @comment -- "Walks flow edges only. @boundary edges are undirected and used for crossing detection, and @transfers moves responsibility rather than data, so neither is a hop a path may take" 20 │ */ 21 │ 22 │ import { graphEdges } from '../mcp/subgraph.js';
L18 comment Pure analyzer: no file I/O, no user input, no network. Operates only on an already-parsed ThreatModel, which is why it needs no @exposes of its own
Pure analyzer: no file I/O, no user input, no network. Operates only on an already-parsed ThreatModel, which is why it needs no @exposes of its own
13 │ * data originates there, no outbound flow means data terminates there. On this 14 │ * repo that lands on UserPrompt/EnvVars/RawStdin and FileSystem/Commands/ 15 │ * TempDir without naming any of them here. 16 │ * 17 │ * @flows ThreatModel -> #parser via findUnmitigatedPaths -- "Reads flows, assets, mitigations and boundaries from the parsed model" 18 │ * @comment -- "Pure analyzer: no file I/O, no user input, no network. Operates only on an already-parsed ThreatModel, which is why it needs no @exposes of its own" 19 │ * @comment -- "Walks flow edges only. @boundary edges are undirected and used for crossing detection, and @transfers moves responsibility rather than data, so neither is a hop a path may take" 20 │ */ 21 │ 22 │ import { graphEdges } from '../mcp/subgraph.js'; 23 │ import { canonicaliser } from '../parser/canonical-ref.js';
L19 comment Walks flow edges only. @boundary edges are undirected and used for crossing detection, and @transfers moves responsibility rather than data, so neither is a hop a path may take
Walks flow edges only. @boundary edges are undirected and used for crossing detection, and @transfers moves responsibility rather than data, so neither is a hop a path may take
14 │ * repo that lands on UserPrompt/EnvVars/RawStdin and FileSystem/Commands/ 15 │ * TempDir without naming any of them here. 16 │ * 17 │ * @flows ThreatModel -> #parser via findUnmitigatedPaths -- "Reads flows, assets, mitigations and boundaries from the parsed model" 18 │ * @comment -- "Pure analyzer: no file I/O, no user input, no network. Operates only on an already-parsed ThreatModel, which is why it needs no @exposes of its own" 19 │ * @comment -- "Walks flow edges only. @boundary edges are undirected and used for crossing detection, and @transfers moves responsibility rather than data, so neither is a hop a path may take" 20 │ */ 21 │ 22 │ import { graphEdges } from '../mcp/subgraph.js'; 23 │ import { canonicaliser } from '../parser/canonical-ref.js'; 24 │ import type { PathHop } from '../mcp/subgraph.js';
L5 comment Bodies are markdown that lands verbatim in the agent prompt and in the SKILL.md; keep each one readable on its own
Bodies are markdown that lands verbatim in the agent prompt and in the SKILL.md; keep each one readable on its own
1 │ /** 2 │ * The six annotate playbooks. Each body is a complete method: purpose, phases, 3 │ * what to write, what not to, and when to stop. They share the evidence bar. 4 │ * 5 │ * @comment -- "Bodies are markdown that lands verbatim in the agent prompt and in the SKILL.md; keep each one readable on its own" 6 │ */ 7 │ import type { Playbook } from './types.js'; 8 │ import { EVIDENCE_BAR, WRITE_LAST, STOP_CONDITIONS } from './evidence.js'; 9 │ 10 │ const NEVER = `Never write \`@accepts\` or \`@entitles\`; never delete or rewrite an existing annotation; never write executable code.`;
L5 comment Kept in one place so the lint in src/gate mirrors it rule for rule: what the prompt demands is what the gate checks
Kept in one place so the lint in src/gate mirrors it rule for rule: what the prompt demands is what the gate checks
1 │ /** 2 │ * The evidence bar every annotate playbook shares. This is the text that makes 3 │ * "only real threats" the default rather than something a user has to ask for. 4 │ * 5 │ * @comment -- "Kept in one place so the lint in src/gate mirrors it rule for rule: what the prompt demands is what the gate checks" 6 │ */ 7 │ 8 │ export const EVIDENCE_BAR = `### The evidence bar — what an @exposes must carry 9 │ An \`@exposes\` is a claim that an attacker can reach this code with input they control and that nothing in the path stops them. Write one only when you can name all four: 10 │ 1. **Entry point** — the handler, command, consumer or listener where the input arrives (function name, route or file:line).
L4 comment Annotate playbooks compose into buildAnnotatePrompt; report shapes into buildUserMessage; skills into guardlink init
Annotate playbooks compose into buildAnnotatePrompt; report shapes into buildUserMessage; skills into guardlink init
1 │ /** 2 │ * GuardLink Playbooks — barrel. 3 │ * 4 │ * @comment -- "Annotate playbooks compose into buildAnnotatePrompt; report shapes into buildUserMessage; skills into guardlink init" 5 │ */ 6 │ export type { PlaybookKind, PlaybookId, AnnotatePlaybookId, ReportShapeId, Playbook, PlaybookSelection } from './types.js'; 7 │ export { ANNOTATE_PLAYBOOKS } from './annotate.js'; 8 │ export { REPORT_SHAPES } from './report.js'; 9 │ export { EVIDENCE_BAR, WRITE_LAST, STOP_CONDITIONS } from './evidence.js';
L6 comment 'full' is the framework's own structure and appends nothing; the other three are audience shapes
'full' is the framework's own structure and appends nothing; the other three are audience shapes
1 │ /** 2 │ * Report shapes: who the report is for changes what goes on the page, not 3 │ * what the analysis is. The framework (STRIDE, DREAD, …) stays the method; 4 │ * the shape is appended to the user message. 5 │ * 6 │ * @comment -- "'full' is the framework's own structure and appends nothing; the other three are audience shapes" 7 │ */ 8 │ import type { Playbook } from './types.js'; 9 │ 10 │ export const REPORT_SHAPES: readonly Playbook[] = [ 11 │ {
L6 exposes #cli → #redos
selectAnnotatePlaybook() runs each Playbook.triggers RegExp against the prompt text the user typed
1 │ /** 2 │ * Selecting a playbook: an explicit id wins, then keyword rules in a fixed 3 │ * order, then the default. Rules, not a model, so the same words always pick 4 │ * the same method and the choice can be printed with its reason. 5 │ * 6 │ * @exposes #cli to #redos [low] cwe:CWE-1333 -- "selectAnnotatePlaybook() runs each Playbook.triggers RegExp against the prompt text the user typed" 7 │ * @mitigates #cli against #redos using #regex-anchoring -- "Every trigger is a short literal or word-boundary pattern with no nested quantifiers; the prompt is capped before matching" 8 │ * @comment -- "Order matters where prompts match several playbooks: chains beats exploitable because a prompt that asks for chains also asks for exploitability, and diff beats coverage because a change is narrower than the tree" 9 │ */ 10 │ import type { AnnotatePlaybookId, ReportShapeId, Playbook, PlaybookSelection, PlaybookId } from './types.js'; 11 │ import { ANNOTATE_PLAYBOOKS } from './annotate.js';
L7 mitigates #regex-anchoring mitigates #redos
Every trigger is a short literal or word-boundary pattern with no nested quantifiers; the prompt is capped before matching
2 │ * Selecting a playbook: an explicit id wins, then keyword rules in a fixed 3 │ * order, then the default. Rules, not a model, so the same words always pick 4 │ * the same method and the choice can be printed with its reason. 5 │ * 6 │ * @exposes #cli to #redos [low] cwe:CWE-1333 -- "selectAnnotatePlaybook() runs each Playbook.triggers RegExp against the prompt text the user typed" 7 │ * @mitigates #cli against #redos using #regex-anchoring -- "Every trigger is a short literal or word-boundary pattern with no nested quantifiers; the prompt is capped before matching" 8 │ * @comment -- "Order matters where prompts match several playbooks: chains beats exploitable because a prompt that asks for chains also asks for exploitability, and diff beats coverage because a change is narrower than the tree" 9 │ */ 10 │ import type { AnnotatePlaybookId, ReportShapeId, Playbook, PlaybookSelection, PlaybookId } from './types.js'; 11 │ import { ANNOTATE_PLAYBOOKS } from './annotate.js'; 12 │ import { REPORT_SHAPES } from './report.js';
L8 comment Order matters where prompts match several playbooks: chains beats exploitable because a prompt that asks for chains also asks for exploitability, and diff beats coverage because a change is narrower than the tree
Order matters where prompts match several playbooks: chains beats exploitable because a prompt that asks for chains also asks for exploitability, and diff beats coverage because a change is narrower than the tree
3 │ * order, then the default. Rules, not a model, so the same words always pick 4 │ * the same method and the choice can be printed with its reason. 5 │ * 6 │ * @exposes #cli to #redos [low] cwe:CWE-1333 -- "selectAnnotatePlaybook() runs each Playbook.triggers RegExp against the prompt text the user typed" 7 │ * @mitigates #cli against #redos using #regex-anchoring -- "Every trigger is a short literal or word-boundary pattern with no nested quantifiers; the prompt is capped before matching" 8 │ * @comment -- "Order matters where prompts match several playbooks: chains beats exploitable because a prompt that asks for chains also asks for exploitability, and diff beats coverage because a change is narrower than the tree" 9 │ */ 10 │ import type { AnnotatePlaybookId, ReportShapeId, Playbook, PlaybookSelection, PlaybookId } from './types.js'; 11 │ import { ANNOTATE_PLAYBOOKS } from './annotate.js'; 12 │ import { REPORT_SHAPES } from './report.js'; 13 │
L7 comment Frontmatter is the two keys every skill loader reads (name, description); the body is the playbook verbatim plus how to invoke the CLI equivalent
Frontmatter is the two keys every skill loader reads (name, description); the body is the playbook verbatim plus how to invoke the CLI equivalent
2 │ * The same method as a skill file, so a developer's own agent session can run 3 │ * it without going through the CLI. `guardlink init` writes these under 4 │ * `.claude/skills/`; the generated marker is how `sync` tells a file it wrote 5 │ * from one a person edited. 6 │ * 7 │ * @comment -- "Frontmatter is the two keys every skill loader reads (name, description); the body is the playbook verbatim plus how to invoke the CLI equivalent" 8 │ */ 9 │ import type { Playbook } from './types.js'; 10 │ 11 │ export const SKILL_GENERATED_MARKER = '<!-- guardlink:generated -->'; 12 │
L10 comment Pure data and pure functions: nothing here reads a file, runs a process or talks to a model
Pure data and pure functions: nothing here reads a file, runs a process or talks to a model
5 │ * threats, mapping architecture, chasing chains, reporting for an executive. 6 │ * The user's prompt narrows scope and intent; the playbook supplies the 7 │ * phases, the evidence bar and the stop conditions, so a vague prompt and an 8 │ * expert prompt run the same method. 9 │ * 10 │ * @comment -- "Pure data and pure functions: nothing here reads a file, runs a process or talks to a model" 11 │ */ 12 │ 13 │ export type PlaybookKind = 'annotate' | 'report'; 14 │ 15 │ export type AnnotatePlaybookId = 'map' | 'exploitable' | 'chains' | 'diff' | 'coverage' | 'verify';
L5 comment Re-exports only; the derivation lives in summary.ts and the joins in parser/reach.ts
Re-exports only; the derivation lives in summary.ts and the joins in parser/reach.ts
1 │ /** 2 │ * GuardLink Reach — what each agent and principal can reach, for the dashboard 3 │ * and the report. 4 │ * 5 │ * @comment -- "Re-exports only; the derivation lives in summary.ts and the joins in parser/reach.ts" 6 │ */ 7 │ export { summarizeReach, hasReach } from './summary.js'; 8 │ export type { 9 │ ReachSummary, ReachActorRow, ReachColumn, ReachCell, ReachCapabilityChip, ReachEffectChip, LooseEffects, 10 │ UnentitledRow, NearMissRow, UngatedRow, GateRow, EgressRow, InjectionRoute, OwaspRow, OwaspItem, OwaspId, ReachLoc,
L38 comment Pure functions over an assembled ThreatModel; no I/O and no input surface. Every row keeps the file:line of the annotation it came from, so each picture can be traced back to source
Pure functions over an assembled ThreatModel; no I/O and no input surface. Every row keeps the file:line of the annotation it came from, so each picture can be traced back to source
33 │ * to the same code as the agent's tool registration, so model-written 34 │ * arguments reach it directly. 35 │ * 36 │ * Each is a target for review or test, never a finding the model has proved. 37 │ * 38 │ * @comment -- "Pure functions over an assembled ThreatModel; no I/O and no input surface. Every row keeps the file:line of the annotation it came from, so each picture can be traced back to source" 39 │ */ 40 │ import type { ThreatModel, ThreatModelReach, EffectClass, SourceLocation } from '../types/index.js'; 41 │ import { 42 │ actorResolver, boundCode, findUnentitledReaches, classifyEffects, isMutatingEffect, 43 │ type EffectGating, type GateCoverBlocker, type ReachCoverBlocker,
L12 comment Pure function: ThreatModel to markdown lines. Model text lands in markdown table cells, where mdCell keeps a pipe or newline from breaking out of the cell; no HTML or shell is produced here
Pure function: ThreatModel to markdown lines. Model text lands in markdown table cells, where mdCell keeps a pipe or newline from breaking out of the cell; no HTML or shell is produced here
7 │ * `generateAgentReachReport` (`guardlink report --agents`) wraps the same lines 8 │ * in a document of their own, so a team can publish an LLM threat model alone 9 │ * or as part of the whole one. Every number comes from `summarizeReach`, which 10 │ * the dashboard's Agents page draws too. 11 │ * 12 │ * @comment -- "Pure function: ThreatModel to markdown lines. Model text lands in markdown table cells, where mdCell keeps a pipe or newline from breaking out of the cell; no HTML or shell is produced here" 13 │ * @flows ThreatModel -> #report via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the agent section" 14 │ */ 15 │ import type { ThreatModel } from '../types/index.js'; 16 │ import { summarizeReach, hasReach, type ReachSummary, type ReachEffectChip, type ReachLoc } from '../reach/index.js'; 17 │ import { findUnmitigatedExposures } from '../parser/coverage.js';
L13 flow ThreatModel → #report
Reaches, effects, gates, entitlements and flows for the agent section
8 │ * in a document of their own, so a team can publish an LLM threat model alone 9 │ * or as part of the whole one. Every number comes from `summarizeReach`, which 10 │ * the dashboard's Agents page draws too. 11 │ * 12 │ * @comment -- "Pure function: ThreatModel to markdown lines. Model text lands in markdown table cells, where mdCell keeps a pipe or newline from breaking out of the cell; no HTML or shell is produced here" 13 │ * @flows ThreatModel -> #report via summarizeReach -- "Reaches, effects, gates, entitlements and flows for the agent section" 14 │ */ 15 │ import type { ThreatModel } from '../types/index.js'; 16 │ import { summarizeReach, hasReach, type ReachSummary, type ReachEffectChip, type ReachLoc } from '../reach/index.js'; 17 │ import { findUnmitigatedExposures } from '../parser/coverage.js'; 18 │ import { canonicaliser } from '../parser/canonical-ref.js';
L4 comment Report generation is pure transformation; no I/O in this module
Report generation is pure transformation; no I/O in this module
1 │ /** 2 │ * GuardLink Report — exports. 3 │ * 4 │ * @comment -- "Report generation is pure transformation; no I/O in this module" 5 │ * @comment -- "File writes handled by CLI/MCP callers" 6 │ */ 7 │ 8 │ export { generateMermaid } from './mermaid.js'; 9 │ export { generateReport } from './report.js';
L5 comment File writes handled by CLI/MCP callers
File writes handled by CLI/MCP callers
1 │ /** 2 │ * GuardLink Report — exports. 3 │ * 4 │ * @comment -- "Report generation is pure transformation; no I/O in this module" 5 │ * @comment -- "File writes handled by CLI/MCP callers" 6 │ */ 7 │ 8 │ export { generateMermaid } from './mermaid.js'; 9 │ export { generateReport } from './report.js'; 10 │ export { generateAgentReachReport, emitAgentReach } from './agent-reach.js';
L105 flow FeatureName → #report
A @feature name written in source reaches the diagram title
100 │ // system"; four nodes titled `feature slice "Dashboard"` read as a slice. 101 │ // Mermaid frontmatter, so the title renders rather than sitting in a `%%` 102 │ // comment nobody sees. Emitted only for a filtered model, so unfiltered output 103 │ // (and its cross-process hash, D23) is untouched. 104 │ // 105 │ // @flows FeatureName -> #report via filtered_by_features -- "A @feature name written in source reaches the diagram title" 106 │ // @mitigates #report against #xss using #output-encoding -- "The name is quote-escaped before it enters the YAML frontmatter scalar, so it cannot close the string and append Mermaid directives or an init block into a diagram a viewer renders as HTML" 107 │ // @comment -- "@feature names are single-line by construction (parse-line.ts), so there is no newline to break the scalar; the quote is the only delimiter that needs escaping" 108 │ const features = filteredFeatures(model); 109 │ if (features.length > 0) { 110 │ lines.push('---');
L106 mitigates #output-encoding mitigates #xss
The name is quote-escaped before it enters the YAML frontmatter scalar, so it cannot close the string and append Mermaid directives or an init block into a diagram a viewer renders as HTML
101 │ // Mermaid frontmatter, so the title renders rather than sitting in a `%%` 102 │ // comment nobody sees. Emitted only for a filtered model, so unfiltered output 103 │ // (and its cross-process hash, D23) is untouched. 104 │ // 105 │ // @flows FeatureName -> #report via filtered_by_features -- "A @feature name written in source reaches the diagram title" 106 │ // @mitigates #report against #xss using #output-encoding -- "The name is quote-escaped before it enters the YAML frontmatter scalar, so it cannot close the string and append Mermaid directives or an init block into a diagram a viewer renders as HTML" 107 │ // @comment -- "@feature names are single-line by construction (parse-line.ts), so there is no newline to break the scalar; the quote is the only delimiter that needs escaping" 108 │ const features = filteredFeatures(model); 109 │ if (features.length > 0) { 110 │ lines.push('---'); 111 │ lines.push(`title: "Feature slice — ${features.map(f => f.replace(/"/g, "'")).join(', ')} (not the whole project)"`);
L107 comment @feature names are single-line by construction (parse-line.ts), so there is no newline to break the scalar; the quote is the only delimiter that needs escaping
@feature names are single-line by construction (parse-line.ts), so there is no newline to break the scalar; the quote is the only delimiter that needs escaping
102 │ // comment nobody sees. Emitted only for a filtered model, so unfiltered output 103 │ // (and its cross-process hash, D23) is untouched. 104 │ // 105 │ // @flows FeatureName -> #report via filtered_by_features -- "A @feature name written in source reaches the diagram title" 106 │ // @mitigates #report against #xss using #output-encoding -- "The name is quote-escaped before it enters the YAML frontmatter scalar, so it cannot close the string and append Mermaid directives or an init block into a diagram a viewer renders as HTML" 107 │ // @comment -- "@feature names are single-line by construction (parse-line.ts), so there is no newline to break the scalar; the quote is the only delimiter that needs escaping" 108 │ const features = filteredFeatures(model); 109 │ if (features.length > 0) { 110 │ lines.push('---'); 111 │ lines.push(`title: "Feature slice — ${features.map(f => f.replace(/"/g, "'")).join(', ')} (not the whole project)"`); 112 │ lines.push('---');
L6 comment Pure function: transforms ThreatModel to markdown string
Pure function: transforms ThreatModel to markdown string
1 │ /** 2 │ * GuardLink Report — Markdown report generator. 3 │ * Produces a human-readable threat model report with 4 │ * embedded Mermaid diagram, finding tables, and coverage stats. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel to markdown string" 7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input"
L7 comment No file I/O; caller (CLI/MCP) handles write
No file I/O; caller (CLI/MCP) handles write
2 │ * GuardLink Report — Markdown report generator. 3 │ * Produces a human-readable threat model report with 4 │ * embedded Mermaid diagram, finding tables, and coverage stats. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel to markdown string" 7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input" 12 │ * @flows #report -> Markdown via return -- "Report output"
L8 comment A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem
A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem
3 │ * Produces a human-readable threat model report with 4 │ * embedded Mermaid diagram, finding tables, and coverage stats. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel to markdown string" 7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input" 12 │ * @flows #report -> Markdown via return -- "Report output" 13 │ */
L9 flow FeatureName → #report
@feature names reach the title, every heading and the footer
4 │ * embedded Mermaid diagram, finding tables, and coverage stats. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel to markdown string" 7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input" 12 │ * @flows #report -> Markdown via return -- "Report output" 13 │ */ 14 │
L10 comment Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates
Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates
5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel to markdown string" 7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input" 12 │ * @flows #report -> Markdown via return -- "Report output" 13 │ */ 14 │ 15 │ import type { ThreatModel, ThreatModelExposure, ThreatModelEntitlement, Severity } from '../types/index.js';
L11 flow ThreatModel → #report
Model input
6 │ * @comment -- "Pure function: transforms ThreatModel to markdown string" 7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input" 12 │ * @flows #report -> Markdown via return -- "Report output" 13 │ */ 14 │ 15 │ import type { ThreatModel, ThreatModelExposure, ThreatModelEntitlement, Severity } from '../types/index.js'; 16 │ import { generateMermaid } from './mermaid.js';
L12 flow #report → Markdown
Report output
7 │ * @comment -- "No file I/O; caller (CLI/MCP) handles write" 8 │ * @comment -- "A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem" 9 │ * @flows FeatureName -> #report via filtered_by_features -- "@feature names reach the title, every heading and the footer" 10 │ * @comment -- "Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates" 11 │ * @flows ThreatModel -> #report via generateReport -- "Model input" 12 │ * @flows #report -> Markdown via return -- "Report output" 13 │ */ 14 │ 15 │ import type { ThreatModel, ThreatModelExposure, ThreatModelEntitlement, Severity } from '../types/index.js'; 16 │ import { generateMermaid } from './mermaid.js'; 17 │ import { generateSequenceDiagram } from './sequence.js';
L1538 handles #report: pii
Author identities from git, in the configured identity mode
1533 │ * who declared its fix, and which AI tool co-authored those commits. Rendered1534 │ * only when records carry `blame` (`guardlink report --blame`); otherwise the1535 │ * report is byte-for-byte what it was. Identities arrive already in the1536 │ * configured mode (name, email or hash), so nothing here decides what to show.1537 │ *1538 │ * @handles pii on #report -- "Author identities from git, in the configured identity mode"1539 │ * @comment -- "AI credit is what a commit declared in a trailer, never detected from code; a claim whose commits carry no trailer stays human. Every degraded status is listed so the reader never mistakes an unattributed claim for a clean one"1540 │ */1541 │ function renderAttribution(model: ThreatModel, slice: Slice): string[] {1542 │ const entries = entriesFromModel(model);1543 │ if (entries.length === 0) return [];
L1539 comment AI credit is what a commit declared in a trailer, never detected from code; a claim whose commits carry no trailer stays human. Every degraded status is listed so the reader never mistakes an unattributed claim for a clean one
AI credit is what a commit declared in a trailer, never detected from code; a claim whose commits carry no trailer stays human. Every degraded status is listed so the reader never mistakes an unattributed claim for a clean one
1534 │ * only when records carry `blame` (`guardlink report --blame`); otherwise the1535 │ * report is byte-for-byte what it was. Identities arrive already in the1536 │ * configured mode (name, email or hash), so nothing here decides what to show.1537 │ *1538 │ * @handles pii on #report -- "Author identities from git, in the configured identity mode"1539 │ * @comment -- "AI credit is what a commit declared in a trailer, never detected from code; a claim whose commits carry no trailer stays human. Every degraded status is listed so the reader never mistakes an unattributed claim for a clean one"1540 │ */1541 │ function renderAttribution(model: ThreatModel, slice: Slice): string[] {1542 │ const entries = entriesFromModel(model);1543 │ if (entries.length === 0) return [];1544 │ const summary = summarise(entries);
L6 comment Pure function: transforms ThreatModel flows to Mermaid sequence diagram
Pure function: transforms ThreatModel flows to Mermaid sequence diagram
1 │ /** 2 │ * GuardLink Report — Mermaid sequence diagram generator. 3 │ * Builds a sequence diagram from @flows annotations showing 4 │ * the step-by-step interactions between system participants. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel flows to Mermaid sequence diagram" 7 │ * @comment -- "Titles itself from filtered_by_features when the model is a --feature slice: a sequence of three participants reads as the system's interactions unless the picture says which slice it is" 8 │ * @flows ThreatModel -> #report via generateSequenceDiagram -- "Sequence diagram generation" 9 │ */ 10 │ 11 │ import type { ThreatModel } from '../types/index.js';
L7 comment Titles itself from filtered_by_features when the model is a --feature slice: a sequence of three participants reads as the system's interactions unless the picture says which slice it is
Titles itself from filtered_by_features when the model is a --feature slice: a sequence of three participants reads as the system's interactions unless the picture says which slice it is
2 │ * GuardLink Report — Mermaid sequence diagram generator. 3 │ * Builds a sequence diagram from @flows annotations showing 4 │ * the step-by-step interactions between system participants. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel flows to Mermaid sequence diagram" 7 │ * @comment -- "Titles itself from filtered_by_features when the model is a --feature slice: a sequence of three participants reads as the system's interactions unless the picture says which slice it is" 8 │ * @flows ThreatModel -> #report via generateSequenceDiagram -- "Sequence diagram generation" 9 │ */ 10 │ 11 │ import type { ThreatModel } from '../types/index.js'; 12 │
L8 flow ThreatModel → #report
Sequence diagram generation
3 │ * Builds a sequence diagram from @flows annotations showing 4 │ * the step-by-step interactions between system participants. 5 │ * 6 │ * @comment -- "Pure function: transforms ThreatModel flows to Mermaid sequence diagram" 7 │ * @comment -- "Titles itself from filtered_by_features when the model is a --feature slice: a sequence of three participants reads as the system's interactions unless the picture says which slice it is" 8 │ * @flows ThreatModel -> #report via generateSequenceDiagram -- "Sequence diagram generation" 9 │ */ 10 │ 11 │ import type { ThreatModel } from '../types/index.js'; 12 │ 13 │ /**
L48 flow FeatureName → #report
A @feature name written in source reaches the sequence diagram title
43 │ } 44 │ 45 │ export function generateSequenceDiagram(model: ThreatModel): string { 46 │ const lines: string[] = []; 47 │ 48 │ // @flows FeatureName -> #report via filtered_by_features -- "A @feature name written in source reaches the sequence diagram title" 49 │ // @mitigates #report against #xss using #output-encoding -- "Quote-escaped before entering the YAML frontmatter scalar, and the `:` that would terminate a Note label is stripped below — a feature name is model data, not markup" 50 │ const features = filteredFeatures(model); 51 │ if (features.length > 0) { 52 │ lines.push('---'); 53 │ lines.push(`title: "Feature slice — ${features.map(f => f.replace(/"/g, "'")).join(', ')} (not the whole project)"`);
L49 mitigates #output-encoding mitigates #xss
Quote-escaped before entering the YAML frontmatter scalar, and the `:` that would terminate a Note label is stripped below — a feature name is model data, not markup
44 │ 45 │ export function generateSequenceDiagram(model: ThreatModel): string { 46 │ const lines: string[] = []; 47 │ 48 │ // @flows FeatureName -> #report via filtered_by_features -- "A @feature name written in source reaches the sequence diagram title" 49 │ // @mitigates #report against #xss using #output-encoding -- "Quote-escaped before entering the YAML frontmatter scalar, and the `:` that would terminate a Note label is stripped below — a feature name is model data, not markup" 50 │ const features = filteredFeatures(model); 51 │ if (features.length > 0) { 52 │ lines.push('---'); 53 │ lines.push(`title: "Feature slice — ${features.map(f => f.replace(/"/g, "'")).join(', ')} (not the whole project)"`); 54 │ lines.push('---');
L27 exposes #cli → #arbitrary-write
Accepting a proposal writes an @entitles line into a source file named by the proposal
22 │ * validation error, so writing one directly is visible rather than free. 23 │ * 24 │ * The ledger is the audit trail: a decision is appended, never a record deleted, 25 │ * so a rejection stays readable next to the claim it refused. 26 │ * 27 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Accepting a proposal writes an @entitles line into a source file named by the proposal" 28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation"
L28 mitigates #path-validation mitigates #arbitrary-write
Target must resolve inside the project root and name an existing file with the anchor line in range
23 │ * 24 │ * The ledger is the audit trail: a decision is appended, never a record deleted, 25 │ * so a rejection stays readable next to the claim it refused. 26 │ * 27 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Accepting a proposal writes an @entitles line into a source file named by the proposal" 28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written"
L29 exposes #cli → #insecure-deser
JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written
24 │ * The ledger is the audit trail: a decision is appended, never a record deleted, 25 │ * so a rejection stays readable next to the claim it refused. 26 │ * 27 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Accepting a proposal writes an @entitles line into a source file named by the proposal" 28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input"
L30 mitigates #config-validation mitigates #insecure-deser
parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file
25 │ * so a rejection stays readable next to the claim it refused. 26 │ * 27 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Accepting a proposal writes an @entitles line into a source file named by the proposal" 28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output"
L31 mitigates #resource-limits mitigates #insecure-deser
Ledger reads are capped at MAX_LEDGER_BYTES
26 │ * 27 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Accepting a proposal writes an @entitles line into a source file named by the proposal" 28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input"
L32 exposes #cli → #arbitrary-write
Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation
27 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Accepting a proposal writes an @entitles line into a source file named by the proposal" 28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation"
L33 mitigates #input-sanitize mitigates #arbitrary-write
oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written
28 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Target must resolve inside the project root and name an existing file with the anchor line in range" 29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human"
L34 flow AgentProposal → #cli
Agent-side proposal input
29 │ * @exposes #cli to #insecure-deser [medium] cwe:CWE-502 -- "JSON.parse of .guardlink/entitlement-proposals.json, which an agent or another repo may have written" 30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it"
L35 flow #cli → ProposalLedger
Proposal artifact output
30 │ * @mitigates #cli against #insecure-deser using #config-validation -- "parseLedger validates shape field by field and drops nothing silently — a malformed ledger is an error, not a partial read; `inert` is recomputed from the citation rather than trusted from the file" 31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it" 40 │ * @comment -- "There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it"
L36 flow ProposalLedger → #cli
Proposal artifact input
31 │ * @mitigates #cli against #insecure-deser using #resource-limits -- "Ledger reads are capped at MAX_LEDGER_BYTES" 32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it" 40 │ * @comment -- "There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it" 41 │ */
L37 flow #cli → SourceFiles
Accepted entitlement lands as an @entitles annotation
32 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Agent-supplied rationale and human decision notes are interpolated into annotation text, where a newline would forge a second annotation" 33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it" 40 │ * @comment -- "There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it" 41 │ */ 42 │
L38 handles #cli: internal
Processes actor/capability claims, authz citations, and the name of the accepting human
33 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() — the one definition in review/index.ts, re-exported here rather than copied — collapses every character a host grammar treats as ending a line: the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII three. It runs before escapeDesc, and every built line is re-parsed with parseLine before it is written" 34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it" 40 │ * @comment -- "There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it" 41 │ */ 42 │ 43 │ import { execFileSync } from 'node:child_process';
L39 audit Audit: #cli
Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it
34 │ * @flows AgentProposal -> #cli via proposeEntitlement -- "Agent-side proposal input" 35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it" 40 │ * @comment -- "There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it" 41 │ */ 42 │ 43 │ import { execFileSync } from 'node:child_process'; 44 │ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
L40 comment There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it
There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it
35 │ * @flows #cli -> ProposalLedger via writeFile -- "Proposal artifact output" 36 │ * @flows ProposalLedger -> #cli via readFile -- "Proposal artifact input" 37 │ * @flows #cli -> SourceFiles via applyProposalDecision -- "Accepted entitlement lands as an @entitles annotation" 38 │ * @handles internal on #cli -- "Processes actor/capability claims, authz citations, and the name of the accepting human" 39 │ * @audit #cli -- "Every acceptance records who accepted, when, and where the annotation landed; an inert or ownership-class acceptance records the acknowledgement that was required to make it" 40 │ * @comment -- "There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it" 41 │ */ 42 │ 43 │ import { execFileSync } from 'node:child_process'; 44 │ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; 45 │ import { dirname, join, resolve, sep } from 'node:path';
L499 comment Writes `against <threat>` as well as `on <asset>`, because the join is the triple (design §9.3): a claim missing either clause matches no finding and demotes nothing. Omitting the threat here made acceptance produce a DEAD annotation — the proposal ledger carried it, the written line did not, so the only sanctioned way to create an entitlement produced one that could never work. Observed on a real repository before this fix, not hypothesised.
Writes `against <threat>` as well as `on <asset>`, because the join is the triple (design §9.3): a claim missing either clause matches no finding and demotes nothing. Omitting the threat here made acceptance produce a DEAD annotation — the proposal ledger carried it, the written line did not, so the only sanctioned way to create an entitlement produced one that could never work. Observed on a real repository before this fix, not hypothesised.
494 │ * 495 │ * Validating with the real parser rather than a second regex is the point: if 496 │ * `guardlink parse` would not read the line back as an entitlement, the claim 497 │ * would be silently absent from the model — the failure this design is about. 498 │ */ 499 │ // @comment -- "Writes `against <threat>` as well as `on <asset>`, because the join is the triple (design §9.3): a claim missing either clause matches no finding and demotes nothing. Omitting the threat here made acceptance produce a DEAD annotation — the proposal ledger carried it, the written line did not, so the only sanctioned way to create an entitlement produced one that could never work. Observed on a real repository before this fix, not hypothesised." 500 │ export function buildEntitlesBody(p: { 501 │ actor: string; capability: string; asset?: string; threat?: string; rationale: string; 502 │ }): string { 503 │ const asset = p.asset ? ` on ${p.asset}` : ''; 504 │ const threat = p.threat ? ` against ${p.threat}` : '';
L869 mitigates #param-commands mitigates #cmd-injection
execFileSync with a fixed argv and no shell; nothing user-supplied reaches the command
864 │ 865 │ /** 866 │ * The local git/OS identity, offered to a human at a TTY as something to 867 │ * confirm. NEVER recorded without that confirmation — see `explicitDecider`. 868 │ * 869 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv and no shell; nothing user-supplied reaches the command" 870 │ * @flows GitConfig -> #cli via execFileSync -- "Reads the local git identity to offer it as a prompt suggestion; never recorded unconfirmed" 871 │ * @comment -- "Suggestion only. The unattended paths (scripted --accept, --from batch, MCP) do not call this at all: a name nobody confirmed is a forged signature, and the caller refuses instead" 872 │ */ 873 │ export function identitySuggestion(root: string): string | undefined { 874 │ try {
L870 flow GitConfig → #cli
Reads the local git identity to offer it as a prompt suggestion; never recorded unconfirmed
865 │ /** 866 │ * The local git/OS identity, offered to a human at a TTY as something to 867 │ * confirm. NEVER recorded without that confirmation — see `explicitDecider`. 868 │ * 869 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv and no shell; nothing user-supplied reaches the command" 870 │ * @flows GitConfig -> #cli via execFileSync -- "Reads the local git identity to offer it as a prompt suggestion; never recorded unconfirmed" 871 │ * @comment -- "Suggestion only. The unattended paths (scripted --accept, --from batch, MCP) do not call this at all: a name nobody confirmed is a forged signature, and the caller refuses instead" 872 │ */ 873 │ export function identitySuggestion(root: string): string | undefined { 874 │ try { 875 │ const name = execFileSync('git', ['config', 'user.name'], {
L871 comment Suggestion only. The unattended paths (scripted --accept, --from batch, MCP) do not call this at all: a name nobody confirmed is a forged signature, and the caller refuses instead
Suggestion only. The unattended paths (scripted --accept, --from batch, MCP) do not call this at all: a name nobody confirmed is a forged signature, and the caller refuses instead
866 │ * The local git/OS identity, offered to a human at a TTY as something to 867 │ * confirm. NEVER recorded without that confirmation — see `explicitDecider`. 868 │ * 869 │ * @mitigates #cli against #cmd-injection using #param-commands -- "execFileSync with a fixed argv and no shell; nothing user-supplied reaches the command" 870 │ * @flows GitConfig -> #cli via execFileSync -- "Reads the local git identity to offer it as a prompt suggestion; never recorded unconfirmed" 871 │ * @comment -- "Suggestion only. The unattended paths (scripted --accept, --from batch, MCP) do not call this at all: a name nobody confirmed is a forged signature, and the caller refuses instead" 872 │ */ 873 │ export function identitySuggestion(root: string): string | undefined { 874 │ try { 875 │ const name = execFileSync('git', ['config', 'user.name'], { 876 │ cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
L28 exposes #cli → #arbitrary-write
Writes @accepts/@audit annotations into source files
23 │ * `by <who>` the name of the human who signed, as `guardlink entitle` has 24 │ * always recorded for the other human-only verb 25 │ * `until <date>` a horizon, after which it stops covering anything 26 │ * a justification long enough to be a reason rather than a category 27 │ * 28 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "Writes @accepts/@audit annotations into source files" 29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input"
L29 mitigates #path-validation mitigates #arbitrary-write
Only modifies files already in the parsed project
24 │ * always recorded for the other human-only verb 25 │ * `until <date>` a horizon, after which it stops covering anything 26 │ * a justification long enough to be a reason rather than a category 27 │ * 28 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "Writes @accepts/@audit annotations into source files" 29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output"
L30 exposes #cli → #arbitrary-write
Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation
25 │ * `until <date>` a horizon, after which it stops covering anything 26 │ * a justification long enough to be a reason rather than a category 27 │ * 28 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "Writes @accepts/@audit annotations into source files" 29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text"
L31 mitigates #input-sanitize mitigates #arbitrary-write
oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied
26 │ * a justification long enough to be a reason rather than a category 27 │ * 28 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "Writes @accepts/@audit annotations into source files" 29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced"
L32 audit Audit: #cli
Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it
27 │ * 28 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "Writes @accepts/@audit annotations into source files" 29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced" 37 │ * @comment -- "assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it"
L33 flow ThreatModel → #cli
Exposure list input
28 │ * @exposes #cli to #arbitrary-write [medium] cwe:CWE-73 -- "Writes @accepts/@audit annotations into source files" 29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced" 37 │ * @comment -- "assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it" 38 │ */
L34 flow #cli → SourceFiles
Annotation insertion output
29 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Only modifies files already in the parsed project" 30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced" 37 │ * @comment -- "assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it" 38 │ */ 39 │
L35 handles #cli: internal
Processes exposure metadata, reviewer identity and justification text
30 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-74 -- "Reviewer-supplied justification and author name are interpolated into annotation text, where a newline would forge a second annotation" 31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced" 37 │ * @comment -- "assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it" 38 │ */ 39 │ 40 │ import { readFile, writeFile } from 'node:fs/promises';
L36 comment applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced
applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced
31 │ * @mitigates #cli against #arbitrary-write using #input-sanitize -- "oneLine() collapses every character a host grammar treats as ending a line — the Unicode mandatory breaks LF, CR, NEL, LS and PS, plus VT, FF and TAB, not the ASCII two — before escapeDesc on every field that reaches a built line. A lone U+2028 ends a // comment in ECMAScript and put report-controlled text in code position, and the \\s{2,} collapse never caught it. One definition, re-exported to entitlements.ts rather than copied" 32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced" 37 │ * @comment -- "assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it" 38 │ */ 39 │ 40 │ import { readFile, writeFile } from 'node:fs/promises'; 41 │ import { extname, resolve } from 'node:path';
L37 comment assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it
assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it
32 │ * @audit #cli -- "Every acceptance records who decided, why, and until when; the rule is enforced in applyReviewAction so no caller can route around it" 33 │ * @flows ThreatModel -> #cli via getReviewableExposures -- "Exposure list input" 34 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 35 │ * @handles internal on #cli -- "Processes exposure metadata, reviewer identity and justification text" 36 │ * @comment -- "applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced" 37 │ * @comment -- "assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it" 38 │ */ 39 │ 40 │ import { readFile, writeFile } from 'node:fs/promises'; 41 │ import { extname, resolve } from 'node:path'; 42 │ import { commentStyleForExt, stripCommentPrefix } from '../parser/comment-strip.js';
L412 comment Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules
Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules
407 │ * a caller composes annotation text without knowing whether the target is a 408 │ * JSDoc block, a `#` comment, or a raw `.gal` file. 409 │ * 410 │ * Returns the number of lines inserted. 411 │ * 412 │ * @comment -- "Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules" 413 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation lines into a caller-supplied file path" 414 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Anchor line must exist in the file, and callers resolve the path against the parsed project root" 415 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 416 │ */ 417 │ export async function insertAnnotationsAt(
L413 exposes #cli → #arbitrary-write
Writes annotation lines into a caller-supplied file path
408 │ * JSDoc block, a `#` comment, or a raw `.gal` file. 409 │ * 410 │ * Returns the number of lines inserted. 411 │ * 412 │ * @comment -- "Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules" 413 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation lines into a caller-supplied file path" 414 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Anchor line must exist in the file, and callers resolve the path against the parsed project root" 415 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 416 │ */ 417 │ export async function insertAnnotationsAt( 418 │ root: string,
L414 mitigates #path-validation mitigates #arbitrary-write
Anchor line must exist in the file, and callers resolve the path against the parsed project root
409 │ * 410 │ * Returns the number of lines inserted. 411 │ * 412 │ * @comment -- "Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules" 413 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation lines into a caller-supplied file path" 414 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Anchor line must exist in the file, and callers resolve the path against the parsed project root" 415 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 416 │ */ 417 │ export async function insertAnnotationsAt( 418 │ root: string, 419 │ anchor: { file: string; line: number },
L415 flow #cli → SourceFiles
Annotation insertion output
410 │ * Returns the number of lines inserted. 411 │ * 412 │ * @comment -- "Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules" 413 │ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Writes annotation lines into a caller-supplied file path" 414 │ * @mitigates #cli against #arbitrary-write using #path-validation -- "Anchor line must exist in the file, and callers resolve the path against the parsed project root" 415 │ * @flows #cli -> SourceFiles via writeFile -- "Annotation insertion output" 416 │ */ 417 │ export async function insertAnnotationsAt( 418 │ root: string, 419 │ anchor: { file: string; line: number }, 420 │ build: (style: CommentStyle) => string[],
L16 comment Pure functions over an in-memory tree; no I/O, no user input beyond the source text already read by the parser
Pure functions over an in-memory tree; no I/O, no user input beyond the source text already read by the parser
11 │ * 12 │ * The order of the special cases matters and is the spec's: no grammar, then 13 │ * first-node, then no-sibling, then import-sibling, then YAML, then the 14 │ * general rule. First match wins. 15 │ * 16 │ * @comment -- "Pure functions over an in-memory tree; no I/O, no user input beyond the source text already read by the parser" 17 │ */ 18 │ import type { Node } from 'web-tree-sitter'; 19 │ import type { Anchor, AnchorReason } from '../types/index.js'; 20 │ import { hashNode, isCommentType } from './hash.js'; 21 │
L10 exposes #parser → #path-traversal
Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact
5 │ * paths and before the model is assembled. `assembleModel` shares each 6 │ * location object by reference, so an anchor set here is the anchor every 7 │ * surface sees. Files are grouped by LOGICAL path: in external mode that is 8 │ * the source the sidecar describes, never the sidecar. 9 │ * 10 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact" 11 │ * @mitigates #parser against #path-traversal using #path-validation -- "Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read" 12 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "One structure parse per annotated file" 13 │ * @mitigates #parser against #dos using #resource-limits -- "Only files that carry annotations are parsed, once each, and the tree is released immediately" 14 │ * @flows SourceFiles -> #parser via attachAnchors -- "Annotated files re-read for structure" 15 │ * @comment -- "A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository"
L11 mitigates #path-validation mitigates #path-traversal
Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read
6 │ * location object by reference, so an anchor set here is the anchor every 7 │ * surface sees. Files are grouped by LOGICAL path: in external mode that is 8 │ * the source the sidecar describes, never the sidecar. 9 │ * 10 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact" 11 │ * @mitigates #parser against #path-traversal using #path-validation -- "Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read" 12 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "One structure parse per annotated file" 13 │ * @mitigates #parser against #dos using #resource-limits -- "Only files that carry annotations are parsed, once each, and the tree is released immediately" 14 │ * @flows SourceFiles -> #parser via attachAnchors -- "Annotated files re-read for structure" 15 │ * @comment -- "A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository" 16 │ */
L12 exposes #parser → #dos
One structure parse per annotated file
7 │ * surface sees. Files are grouped by LOGICAL path: in external mode that is 8 │ * the source the sidecar describes, never the sidecar. 9 │ * 10 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact" 11 │ * @mitigates #parser against #path-traversal using #path-validation -- "Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read" 12 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "One structure parse per annotated file" 13 │ * @mitigates #parser against #dos using #resource-limits -- "Only files that carry annotations are parsed, once each, and the tree is released immediately" 14 │ * @flows SourceFiles -> #parser via attachAnchors -- "Annotated files re-read for structure" 15 │ * @comment -- "A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository" 16 │ */ 17 │ import { readFile } from 'node:fs/promises';
L13 mitigates #resource-limits mitigates #dos
Only files that carry annotations are parsed, once each, and the tree is released immediately
8 │ * the source the sidecar describes, never the sidecar. 9 │ * 10 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact" 11 │ * @mitigates #parser against #path-traversal using #path-validation -- "Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read" 12 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "One structure parse per annotated file" 13 │ * @mitigates #parser against #dos using #resource-limits -- "Only files that carry annotations are parsed, once each, and the tree is released immediately" 14 │ * @flows SourceFiles -> #parser via attachAnchors -- "Annotated files re-read for structure" 15 │ * @comment -- "A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository" 16 │ */ 17 │ import { readFile } from 'node:fs/promises'; 18 │ import { resolve, sep } from 'node:path';
L14 flow SourceFiles → #parser
Annotated files re-read for structure
9 │ * 10 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact" 11 │ * @mitigates #parser against #path-traversal using #path-validation -- "Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read" 12 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "One structure parse per annotated file" 13 │ * @mitigates #parser against #dos using #resource-limits -- "Only files that carry annotations are parsed, once each, and the tree is released immediately" 14 │ * @flows SourceFiles -> #parser via attachAnchors -- "Annotated files re-read for structure" 15 │ * @comment -- "A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository" 16 │ */ 17 │ import { readFile } from 'node:fs/promises'; 18 │ import { resolve, sep } from 'node:path'; 19 │ import type { Annotation } from '../types/index.js';
L15 comment A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository
A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository
10 │ * @exposes #parser to #path-traversal [low] cwe:CWE-22 -- "Reads the file each annotation's location names, and a .gal @source path is author-supplied text that survives normalisation with ../ intact" 11 │ * @mitigates #parser against #path-traversal using #path-validation -- "Each logical path is resolved against root and skipped unless it is root or lies under root + sep; the file is never opened otherwise, so nothing outside the scanned tree is read" 12 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "One structure parse per annotated file" 13 │ * @mitigates #parser against #dos using #resource-limits -- "Only files that carry annotations are parsed, once each, and the tree is released immediately" 14 │ * @flows SourceFiles -> #parser via attachAnchors -- "Annotated files re-read for structure" 15 │ * @comment -- "A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository" 16 │ */ 17 │ import { readFile } from 'node:fs/promises'; 18 │ import { resolve, sep } from 'node:path'; 19 │ import type { Annotation } from '../types/index.js'; 20 │ import { parseStructure } from './index.js';
L14 comment Data only: no I/O, no user input. The paths it produces are joined under the package's own grammars/ directory
Data only: no I/O, no user input. The paths it produces are joined under the package's own grammars/ directory
9 │ * 10 │ * Every version is exact. The WASM inside a grammar package is built against a 11 │ * tree-sitter ABI the pinned `web-tree-sitter` runtime must accept; a caret 12 │ * here would let a grammar move to an ABI the runtime rejects. 13 │ * 14 │ * @comment -- "Data only: no I/O, no user input. The paths it produces are joined under the package's own grammars/ directory" 15 │ */ 16 │ import { dirname, join, resolve } from 'node:path'; 17 │ import { fileURLToPath } from 'node:url'; 18 │ 19 │ export interface GrammarSource {
L14 comment Pure functions over an in-memory syntax tree or a string; no I/O
Pure functions over an in-memory syntax tree or a string; no I/O
9 │ * 10 │ * Tokens are joined with a control character rather than concatenated, so the 11 │ * sequences (ab, c) and (a, bc) cannot collide — the same reasoning as 12 │ * annotation-hash.ts. 13 │ * 14 │ * @comment -- "Pure functions over an in-memory syntax tree or a string; no I/O" 15 │ */ 16 │ import { createHash } from 'node:crypto'; 17 │ import type { Node } from 'web-tree-sitter'; 18 │ 19 │ /** Bump when token selection or the separator changes. Part of every emitted hash. */
L11 flow SourceFile → #parser
File content already read by the parser, parsed again for structure
6 │ * line describe" and "where is the declaration called X", both as `Anchor`s 7 │ * carrying a content hash. Files without a grammar answer with a file-scope 8 │ * anchor over a plain-text hash, so every caller gets an anchor and no caller 9 │ * has to know whether tree-sitter was involved. 10 │ * 11 │ * @flows SourceFile -> #parser via parseStructure -- "File content already read by the parser, parsed again for structure" 12 │ * @comment -- "Takes content, not a path to read: the parser owns file I/O and its path validation; this layer never opens a file" 13 │ */ 14 │ import { extname } from 'node:path'; 15 │ import type { Tree, Node } from 'web-tree-sitter'; 16 │ import type { Anchor, AnchorReason } from '../types/index.js';
L12 comment Takes content, not a path to read: the parser owns file I/O and its path validation; this layer never opens a file
Takes content, not a path to read: the parser owns file I/O and its path validation; this layer never opens a file
7 │ * carrying a content hash. Files without a grammar answer with a file-scope 8 │ * anchor over a plain-text hash, so every caller gets an anchor and no caller 9 │ * has to know whether tree-sitter was involved. 10 │ * 11 │ * @flows SourceFile -> #parser via parseStructure -- "File content already read by the parser, parsed again for structure" 12 │ * @comment -- "Takes content, not a path to read: the parser owns file I/O and its path validation; this layer never opens a file" 13 │ */ 14 │ import { extname } from 'node:path'; 15 │ import type { Tree, Node } from 'web-tree-sitter'; 16 │ import type { Anchor, AnchorReason } from '../types/index.js'; 17 │ import { languageForExtension } from './grammars.js';
L16 exposes #parser → #dos
Grammar WASM is loaded into memory per language; a pathological source file costs one parse
11 │ * is still attempted — silent `no-grammar` when absent, `grammar-failed` and 12 │ * warned once if present but unloadable. A file that exists but fails to load is 13 │ * always `grammar-failed` and warned once, because that is a runtime packaging 14 │ * defect, not a fallback. 15 │ * 16 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Grammar WASM is loaded into memory per language; a pathological source file costs one parse" 17 │ * @mitigates #parser against #dos using #resource-limits -- "One runtime init and one load per language per process; trees are parsed on demand and deleted by callers" 18 │ * @flows GrammarFile -> #parser via Language.load -- "Bundled WASM read from the package's grammars/ directory" 19 │ * @comment -- "Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load" 20 │ */ 21 │ import { existsSync } from 'node:fs';
L17 mitigates #resource-limits mitigates #dos
One runtime init and one load per language per process; trees are parsed on demand and deleted by callers
12 │ * warned once if present but unloadable. A file that exists but fails to load is 13 │ * always `grammar-failed` and warned once, because that is a runtime packaging 14 │ * defect, not a fallback. 15 │ * 16 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Grammar WASM is loaded into memory per language; a pathological source file costs one parse" 17 │ * @mitigates #parser against #dos using #resource-limits -- "One runtime init and one load per language per process; trees are parsed on demand and deleted by callers" 18 │ * @flows GrammarFile -> #parser via Language.load -- "Bundled WASM read from the package's grammars/ directory" 19 │ * @comment -- "Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load" 20 │ */ 21 │ import { existsSync } from 'node:fs'; 22 │ import { join } from 'node:path';
L18 flow GrammarFile → #parser
Bundled WASM read from the package's grammars/ directory
13 │ * always `grammar-failed` and warned once, because that is a runtime packaging 14 │ * defect, not a fallback. 15 │ * 16 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Grammar WASM is loaded into memory per language; a pathological source file costs one parse" 17 │ * @mitigates #parser against #dos using #resource-limits -- "One runtime init and one load per language per process; trees are parsed on demand and deleted by callers" 18 │ * @flows GrammarFile -> #parser via Language.load -- "Bundled WASM read from the package's grammars/ directory" 19 │ * @comment -- "Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load" 20 │ */ 21 │ import { existsSync } from 'node:fs'; 22 │ import { join } from 'node:path'; 23 │ import { Parser, Language } from 'web-tree-sitter';
L19 comment Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load
Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load
14 │ * defect, not a fallback. 15 │ * 16 │ * @exposes #parser to #dos [low] cwe:CWE-400 -- "Grammar WASM is loaded into memory per language; a pathological source file costs one parse" 17 │ * @mitigates #parser against #dos using #resource-limits -- "One runtime init and one load per language per process; trees are parsed on demand and deleted by callers" 18 │ * @flows GrammarFile -> #parser via Language.load -- "Bundled WASM read from the package's grammars/ directory" 19 │ * @comment -- "Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load" 20 │ */ 21 │ import { existsSync } from 'node:fs'; 22 │ import { join } from 'node:path'; 23 │ import { Parser, Language } from 'web-tree-sitter'; 24 │ import type { Tree } from 'web-tree-sitter';
L7 exposes #tui → #api-key-exposure
API keys loaded from and saved to config files
2 │ * GuardLink TUI — Config persistence for LLM settings. 3 │ * 4 │ * Now delegates to the unified agents/config.ts resolution chain. 5 │ * Keeps backward compatibility with tui-config.json (legacy). 6 │ * 7 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from and saved to config files" 8 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "Delegates to agents/config.ts with masking" 9 │ * @flows ConfigFile -> #tui via loadProjectConfig -- "Config load path" 10 │ * @flows #tui -> ConfigFile via saveProjectConfig -- "Config save path" 11 │ * @handles secrets on #tui -- "API keys stored in .guardlink/config.json" 12 │ */
L8 mitigates #key-redaction mitigates #api-key-exposure
Delegates to agents/config.ts with masking
3 │ * 4 │ * Now delegates to the unified agents/config.ts resolution chain. 5 │ * Keeps backward compatibility with tui-config.json (legacy). 6 │ * 7 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from and saved to config files" 8 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "Delegates to agents/config.ts with masking" 9 │ * @flows ConfigFile -> #tui via loadProjectConfig -- "Config load path" 10 │ * @flows #tui -> ConfigFile via saveProjectConfig -- "Config save path" 11 │ * @handles secrets on #tui -- "API keys stored in .guardlink/config.json" 12 │ */ 13 │
L9 flow ConfigFile → #tui
Config load path
4 │ * Now delegates to the unified agents/config.ts resolution chain. 5 │ * Keeps backward compatibility with tui-config.json (legacy). 6 │ * 7 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from and saved to config files" 8 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "Delegates to agents/config.ts with masking" 9 │ * @flows ConfigFile -> #tui via loadProjectConfig -- "Config load path" 10 │ * @flows #tui -> ConfigFile via saveProjectConfig -- "Config save path" 11 │ * @handles secrets on #tui -- "API keys stored in .guardlink/config.json" 12 │ */ 13 │ 14 │ import type { LLMConfig, LLMProvider } from '../analyze/llm.js';
L10 flow #tui → ConfigFile
Config save path
5 │ * Keeps backward compatibility with tui-config.json (legacy). 6 │ * 7 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from and saved to config files" 8 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "Delegates to agents/config.ts with masking" 9 │ * @flows ConfigFile -> #tui via loadProjectConfig -- "Config load path" 10 │ * @flows #tui -> ConfigFile via saveProjectConfig -- "Config save path" 11 │ * @handles secrets on #tui -- "API keys stored in .guardlink/config.json" 12 │ */ 13 │ 14 │ import type { LLMConfig, LLMProvider } from '../analyze/llm.js'; 15 │ import { resolveConfig, saveProjectConfig, loadProjectConfig } from '../agents/config.js';
L11 handles #tui: secrets
API keys stored in .guardlink/config.json
6 │ * 7 │ * @exposes #tui to #api-key-exposure [high] cwe:CWE-798 -- "API keys loaded from and saved to config files" 8 │ * @mitigates #tui against #api-key-exposure using #key-redaction -- "Delegates to agents/config.ts with masking" 9 │ * @flows ConfigFile -> #tui via loadProjectConfig -- "Config load path" 10 │ * @flows #tui -> ConfigFile via saveProjectConfig -- "Config save path" 11 │ * @handles secrets on #tui -- "API keys stored in .guardlink/config.json" 12 │ */ 13 │ 14 │ import type { LLMConfig, LLMProvider } from '../analyze/llm.js'; 15 │ import { resolveConfig, saveProjectConfig, loadProjectConfig } from '../agents/config.js'; 16 │
L9 exposes #tui → #path-traversal
User-supplied dir argument resolved via path.resolve
4 │ * GuardLink TUI — Interactive terminal interface. 5 │ * 6 │ * Claude Code-style inline REPL: stays in your terminal, 7 │ * slash commands + freeform AI chat, Ctrl+C to exit. 8 │ * 9 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing"
L10 mitigates #path-validation mitigates #path-traversal
resolve() canonicalizes paths; starts from cwd
5 │ * 6 │ * Claude Code-style inline REPL: stays in your terminal, 7 │ * slash commands + freeform AI chat, Ctrl+C to exit. 8 │ * 9 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input"
L11 exposes #tui → #api-key-exposure
API keys displayed in banner via resolveLLMConfig
6 │ * Claude Code-style inline REPL: stays in your terminal, 7 │ * slash commands + freeform AI chat, Ctrl+C to exit. 8 │ * 9 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input" 16 │ * @handles secrets on #tui -- "Displays LLM config including masked API keys"
L12 audit Audit: #tui
API keys masked via maskKey() in banner display
7 │ * slash commands + freeform AI chat, Ctrl+C to exit. 8 │ * 9 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input" 16 │ * @handles secrets on #tui -- "Displays LLM config including masked API keys" 17 │ */
L13 flow UserInput → #tui
Interactive command input
8 │ * 9 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input" 16 │ * @handles secrets on #tui -- "Displays LLM config including masked API keys" 17 │ */ 18 │
L14 flow #tui → Commands
Command routing
9 │ * @exposes #tui to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" 10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input" 16 │ * @handles secrets on #tui -- "Displays LLM config including masked API keys" 17 │ */ 18 │ 19 │ import { createInterface } from 'node:readline';
L15 boundary #tui ↔ UserInput
Trust boundary at interactive input
10 │ * @mitigates #tui against #path-traversal using #path-validation -- "resolve() canonicalizes paths; starts from cwd" 11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input" 16 │ * @handles secrets on #tui -- "Displays LLM config including masked API keys" 17 │ */ 18 │ 19 │ import { createInterface } from 'node:readline'; 20 │ import { resolve, basename } from 'node:path';
L16 handles #tui: secrets
Displays LLM config including masked API keys
11 │ * @exposes #tui to #api-key-exposure [medium] cwe:CWE-798 -- "API keys displayed in banner via resolveLLMConfig" 12 │ * @audit #tui -- "API keys masked via maskKey() in banner display" 13 │ * @flows UserInput -> #tui via readline -- "Interactive command input" 14 │ * @flows #tui -> Commands via dispatch -- "Command routing" 15 │ * @boundary #tui and UserInput (#tui-input-boundary) -- "Trust boundary at interactive input" 16 │ * @handles secrets on #tui -- "Displays LLM config including masked API keys" 17 │ */ 18 │ 19 │ import { createInterface } from 'node:readline'; 20 │ import { resolve, basename } from 'node:path'; 21 │ import { existsSync, readFileSync } from 'node:fs';
L15 exposes #tui → #dos
Rapid keystrokes could consume CPU in raw mode
10 │ * /files Browse files 11 │ * /assets Asset tree 12 │ * 13 │ * Uses raw stdin mode for full keystroke control. 14 │ * 15 │ * @exposes #tui to #dos [low] cwe:CWE-400 -- "Rapid keystrokes could consume CPU in raw mode" 16 │ * @mitigates #tui against #dos using #resource-limits -- "Keystroke buffer bounded by terminal width" 17 │ * @flows RawStdin -> #tui via process.stdin -- "Raw keystroke input" 18 │ * @flows #tui -> Terminal via process.stdout -- "ANSI escape sequence output" 19 │ * @comment -- "Raw mode enables full keystroke control for command palette" 20 │ */
L16 mitigates #resource-limits mitigates #dos
Keystroke buffer bounded by terminal width
11 │ * /assets Asset tree 12 │ * 13 │ * Uses raw stdin mode for full keystroke control. 14 │ * 15 │ * @exposes #tui to #dos [low] cwe:CWE-400 -- "Rapid keystrokes could consume CPU in raw mode" 16 │ * @mitigates #tui against #dos using #resource-limits -- "Keystroke buffer bounded by terminal width" 17 │ * @flows RawStdin -> #tui via process.stdin -- "Raw keystroke input" 18 │ * @flows #tui -> Terminal via process.stdout -- "ANSI escape sequence output" 19 │ * @comment -- "Raw mode enables full keystroke control for command palette" 20 │ */ 21 │
L17 flow RawStdin → #tui
Raw keystroke input
12 │ * 13 │ * Uses raw stdin mode for full keystroke control. 14 │ * 15 │ * @exposes #tui to #dos [low] cwe:CWE-400 -- "Rapid keystrokes could consume CPU in raw mode" 16 │ * @mitigates #tui against #dos using #resource-limits -- "Keystroke buffer bounded by terminal width" 17 │ * @flows RawStdin -> #tui via process.stdin -- "Raw keystroke input" 18 │ * @flows #tui -> Terminal via process.stdout -- "ANSI escape sequence output" 19 │ * @comment -- "Raw mode enables full keystroke control for command palette" 20 │ */ 21 │ 22 │ import chalk from 'chalk';
L18 flow #tui → Terminal
ANSI escape sequence output
13 │ * Uses raw stdin mode for full keystroke control. 14 │ * 15 │ * @exposes #tui to #dos [low] cwe:CWE-400 -- "Rapid keystrokes could consume CPU in raw mode" 16 │ * @mitigates #tui against #dos using #resource-limits -- "Keystroke buffer bounded by terminal width" 17 │ * @flows RawStdin -> #tui via process.stdin -- "Raw keystroke input" 18 │ * @flows #tui -> Terminal via process.stdout -- "ANSI escape sequence output" 19 │ * @comment -- "Raw mode enables full keystroke control for command palette" 20 │ */ 21 │ 22 │ import chalk from 'chalk'; 23 │
L19 comment Raw mode enables full keystroke control for command palette
Raw mode enables full keystroke control for command palette
14 │ * 15 │ * @exposes #tui to #dos [low] cwe:CWE-400 -- "Rapid keystrokes could consume CPU in raw mode" 16 │ * @mitigates #tui against #dos using #resource-limits -- "Keystroke buffer bounded by terminal width" 17 │ * @flows RawStdin -> #tui via process.stdin -- "Raw keystroke input" 18 │ * @flows #tui -> Terminal via process.stdout -- "ANSI escape sequence output" 19 │ * @comment -- "Raw mode enables full keystroke control for command palette" 20 │ */ 21 │ 22 │ import chalk from 'chalk'; 23 │ 24 │ const BRAVOS = '#2dd4a7';
L10 flow PackageJson → #cli
Version string read from package.json
5 │ * never report a version it is not. The CLI already does this (`--version` used 6 │ * to be a hardcoded literal that silently fell out of sync); this is the same 7 │ * technique behind one export so the next surface that needs it does not invent 8 │ * a third copy. 9 │ * 10 │ * @flows PackageJson -> #cli via readFileSync -- "Version string read from package.json" 11 │ * @comment -- "Resolved relative to this module, so it is correct from both src/ and dist/" 12 │ */ 13 │ 14 │ import { readFileSync } from 'node:fs'; 15 │
L11 comment Resolved relative to this module, so it is correct from both src/ and dist/
Resolved relative to this module, so it is correct from both src/ and dist/
6 │ * to be a hardcoded literal that silently fell out of sync); this is the same 7 │ * technique behind one export so the next surface that needs it does not invent 8 │ * a third copy. 9 │ * 10 │ * @flows PackageJson -> #cli via readFileSync -- "Version string read from package.json" 11 │ * @comment -- "Resolved relative to this module, so it is correct from both src/ and dist/" 12 │ */ 13 │ 14 │ import { readFileSync } from 'node:fs'; 15 │ 16 │ /**
L56 asset Workspace.Estate
Estate-wide open-risk read-back over a merged report
51 │ * actually opens, so a fix that lived only in the writer would have left the 52 │ * defect fully intact in the place it gets read. The three states are therefore 53 │ * re-made here against the same fields, to the same split, rather than inferred 54 │ * or assumed clean — and a repo that cannot say is marked `?`, never `✓`. 55 │ * 56 │ * @asset Workspace.Estate (#estate-view) -- "Estate-wide open-risk read-back over a merged report" 57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read"
L57 exposes #estate-view → #vacuous-pass
Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health
52 │ * defect fully intact in the place it gets read. The three states are therefore 53 │ * re-made here against the same fields, to the same split, rather than inferred 54 │ * or assumed clean — and a repo that cannot say is marked `?`, never `✓`. 55 │ * 56 │ * @asset Workspace.Estate (#estate-view) -- "Estate-wide open-risk read-back over a merged report" 57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact"
L58 mitigates #fail-closed mitigates #vacuous-pass
Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields
53 │ * re-made here against the same fields, to the same split, rather than inferred 54 │ * or assumed clean — and a repo that cannot say is marked `?`, never `✓`. 55 │ * 56 │ * @asset Workspace.Estate (#estate-view) -- "Estate-wide open-risk read-back over a merged report" 57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse"
L59 flow RepoParseState → #estate-view
Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer
54 │ * or assumed clean — and a repo that cannot say is marked `?`, never `✓`. 55 │ * 56 │ * @asset Workspace.Estate (#estate-view) -- "Estate-wide open-risk read-back over a merged report" 57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse" 64 │ * @comment -- "A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed"
L60 flow MergedReportFile → #estate-view
The JSON `guardlink merge --json` wrote, read back
55 │ * 56 │ * @asset Workspace.Estate (#estate-view) -- "Estate-wide open-risk read-back over a merged report" 57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse" 64 │ * @comment -- "A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed" 65 │ */
L61 flow #estate-view → #cli
Open exposures, reproduced exploits, and how much of the estate was actually read
56 │ * @asset Workspace.Estate (#estate-view) -- "Estate-wide open-risk read-back over a merged report" 57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse" 64 │ * @comment -- "A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed" 65 │ */ 66 │
L62 exposes #estate-view → #insecure-deser
Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact
57 │ * @exposes #estate-view to #vacuous-pass [high] cwe:CWE-754 -- "Ticked a repository green on open/confirmed counts alone, so a repo whose parse errored — its @mitigates lines never read — and a repo whose report cannot say either way both rendered as a clean bill of health" 58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse" 64 │ * @comment -- "A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed" 65 │ */ 66 │ 67 │ import { readFile } from 'node:fs/promises';
L63 mitigates #config-validation mitigates #insecure-deser
readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse
58 │ * @mitigates #estate-view against #vacuous-pass using #fail-closed -- "Per-repo parse errors and unknown parse state are read from repo_statuses and rendered as themselves; parse errors exit 1 under --strict, matching mergeVerdict's split over the same fields" 59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse" 64 │ * @comment -- "A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed" 65 │ */ 66 │ 67 │ import { readFile } from 'node:fs/promises'; 68 │ import type {
L64 comment A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed
A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed
59 │ * @flows RepoParseState -> #estate-view via estateReport -- "Each member repo's parse errors, warnings and unread annotation lines, carried from the merged report onto the estate answer" 60 │ * @flows MergedReportFile -> #estate-view via readMergedReport -- "The JSON `guardlink merge --json` wrote, read back" 61 │ * @flows #estate-view -> #cli via estateReport -- "Open exposures, reproduced exploits, and how much of the estate was actually read" 62 │ * @exposes #estate-view to #insecure-deser [medium] cwe:CWE-502 -- "Parses a merged report JSON supplied by path, which may come from another team's pipeline artifact" 63 │ * @mitigates #estate-view against #insecure-deser using #config-validation -- "readMergedReport refuses any payload missing the merged-report shape rather than answering from a partial parse" 64 │ * @comment -- "A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed" 65 │ */ 66 │ 67 │ import { readFile } from 'node:fs/promises'; 68 │ import type { 69 │ ThreatModelConfirmed, ThreatModelExposure, Severity,
L4 comment Workspace module: config loading, merge engine, link-project setup
Workspace module: config loading, merge engine, link-project setup
1 │ /** 2 │ * GuardLink Workspace — Multi-repo linking and merge. 3 │ * 4 │ * @comment -- "Workspace module: config loading, merge engine, link-project setup" 5 │ */ 6 │ 7 │ export type { 8 │ WorkspaceConfig, WorkspaceRepo, 9 │ TagOwnership, UnresolvedRef, MergeWarning, MergeWarningCode,
L7 asset Workspace.Link
Multi-repo workspace linking setup
2 │ * GuardLink Workspace — link-project command logic. 3 │ * 4 │ * Scaffolds workspace.yaml in each repo and updates agent instruction 5 │ * files with workspace context so agents write cross-repo-aware annotations. 6 │ * 7 │ * @asset Workspace.Link (#workspace-link) -- "Multi-repo workspace linking setup" 8 │ * @flows UserArgs -> #workspace-link via linkProject -- "CLI args to workspace scaffolding" 9 │ * @flows #workspace-link -> AgentFiles via updateAgentWorkspaceContext -- "Inject workspace context" 10 │ */ 11 │ 12 │ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
L8 flow UserArgs → #workspace-link
CLI args to workspace scaffolding
3 │ * 4 │ * Scaffolds workspace.yaml in each repo and updates agent instruction 5 │ * files with workspace context so agents write cross-repo-aware annotations. 6 │ * 7 │ * @asset Workspace.Link (#workspace-link) -- "Multi-repo workspace linking setup" 8 │ * @flows UserArgs -> #workspace-link via linkProject -- "CLI args to workspace scaffolding" 9 │ * @flows #workspace-link -> AgentFiles via updateAgentWorkspaceContext -- "Inject workspace context" 10 │ */ 11 │ 12 │ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'; 13 │ import { writeFileSync } from 'node:fs';
L9 flow #workspace-link → AgentFiles
Inject workspace context
4 │ * Scaffolds workspace.yaml in each repo and updates agent instruction 5 │ * files with workspace context so agents write cross-repo-aware annotations. 6 │ * 7 │ * @asset Workspace.Link (#workspace-link) -- "Multi-repo workspace linking setup" 8 │ * @flows UserArgs -> #workspace-link via linkProject -- "CLI args to workspace scaffolding" 9 │ * @flows #workspace-link -> AgentFiles via updateAgentWorkspaceContext -- "Inject workspace context" 10 │ */ 11 │ 12 │ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'; 13 │ import { writeFileSync } from 'node:fs'; 14 │ import { resolve, basename, dirname, join } from 'node:path';
L45 asset Workspace.Merge
Cross-repo threat model unification
40 │ * zero: reading "this report cannot say" as "it read everything" is the same 41 │ * invented answer as reading "no repositories loaded" as "nothing is wrong", 42 │ * one level down. Parse ERRORS gate under `--strict`; warnings never do, which 43 │ * is the split `ci` already makes about the same numbers. 44 │ * 45 │ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" 46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge"
L46 threat Tag_Collision
Duplicate tag definitions across repos
41 │ * invented answer as reading "no repositories loaded" as "nothing is wrong", 42 │ * one level down. Parse ERRORS gate under `--strict`; warnings never do, which 43 │ * is the split `ci` already makes about the same numbers. 44 │ * 45 │ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" 46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output"
L47 mitigates #prefix-ownership mitigates #tag-collision
Tag prefix determines owning repo
42 │ * one level down. Parse ERRORS gate under `--strict`; warnings never do, which 43 │ * is the split `ci` already makes about the same numbers. 44 │ * 45 │ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" 46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" 52 │ * @flows #merge-engine -> #cli via mergeVerdict -- "The exit code, decided once from the merged report and the paths that produced it"
L48 exposes #merge-engine → #vacuous-pass
A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate
43 │ * is the split `ci` already makes about the same numbers. 44 │ * 45 │ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" 46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" 52 │ * @flows #merge-engine -> #cli via mergeVerdict -- "The exit code, decided once from the merged report and the paths that produced it" 53 │ */
L49 mitigates #fail-closed mitigates #vacuous-pass
mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read
44 │ * 45 │ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" 46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" 52 │ * @flows #merge-engine -> #cli via mergeVerdict -- "The exit code, decided once from the merged report and the paths that produced it" 53 │ */ 54 │
L50 flow ReportJSON → #merge-engine
Per-repo reports feed into merge
45 │ * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" 46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" 52 │ * @flows #merge-engine -> #cli via mergeVerdict -- "The exit code, decided once from the merged report and the paths that produced it" 53 │ */ 54 │ 55 │ import { readFile } from 'node:fs/promises';
L51 flow #merge-engine → MergedReport
Unified output
46 │ * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" 47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" 52 │ * @flows #merge-engine -> #cli via mergeVerdict -- "The exit code, decided once from the merged report and the paths that produced it" 53 │ */ 54 │ 55 │ import { readFile } from 'node:fs/promises'; 56 │ import { basename, dirname, resolve as resolvePath } from 'node:path';
L52 flow #merge-engine → #cli
The exit code, decided once from the merged report and the paths that produced it
47 │ * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" 48 │ * @exposes #merge-engine to #vacuous-pass [high] cwe:CWE-754 -- "A glob that expanded to nothing, or a report set that loaded nothing, produced a merged model with zero of everything — and the summary of an empty model is indistinguishable from the summary of a clean estate" 49 │ * @mitigates #merge-engine against #vacuous-pass using #fail-closed -- "mergeVerdict fails on zero repos loaded and on any glob that matched no file, regardless of --strict; the count it reports is of repositories actually read" 50 │ * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" 51 │ * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" 52 │ * @flows #merge-engine -> #cli via mergeVerdict -- "The exit code, decided once from the merged report and the paths that produced it" 53 │ */ 54 │ 55 │ import { readFile } from 'node:fs/promises'; 56 │ import { basename, dirname, resolve as resolvePath } from 'node:path'; 57 │ import fg from 'fast-glob';
L151 flow FileSystem → #merge-engine
Report paths discovered by expanding the caller's glob patterns against cwd
146 │ * 147 │ * Order is made deterministic (sorted within each pattern) because `fast-glob` 148 │ * does not promise one, and a merged dashboard whose repo list reorders between 149 │ * runs on identical input is a diff nobody can read. 150 │ * 151 │ * @flows FileSystem -> #merge-engine via resolveReportPaths -- "Report paths discovered by expanding the caller's glob patterns against cwd" 152 │ * @comment -- "A literal filename containing glob metacharacters is read as a pattern; it lands in unmatched rather than in a false ENOENT, and the CLI's message names the pattern verbatim so the cause is visible" 153 │ */ 154 │ export async function resolveReportPaths( 155 │ patterns: string[], 156 │ cwd: string = process.cwd(),
L152 comment A literal filename containing glob metacharacters is read as a pattern; it lands in unmatched rather than in a false ENOENT, and the CLI's message names the pattern verbatim so the cause is visible
A literal filename containing glob metacharacters is read as a pattern; it lands in unmatched rather than in a false ENOENT, and the CLI's message names the pattern verbatim so the cause is visible
147 │ * Order is made deterministic (sorted within each pattern) because `fast-glob` 148 │ * does not promise one, and a merged dashboard whose repo list reorders between 149 │ * runs on identical input is a diff nobody can read. 150 │ * 151 │ * @flows FileSystem -> #merge-engine via resolveReportPaths -- "Report paths discovered by expanding the caller's glob patterns against cwd" 152 │ * @comment -- "A literal filename containing glob metacharacters is read as a pattern; it lands in unmatched rather than in a false ENOENT, and the CLI's message names the pattern verbatim so the cause is visible" 153 │ */ 154 │ export async function resolveReportPaths( 155 │ patterns: string[], 156 │ cwd: string = process.cwd(), 157 │ ): Promise<ResolvedReportPaths> {
L7 asset Workspace.Metadata
Report provenance data
2 │ * GuardLink Workspace — Report metadata population. 3 │ * 4 │ * Enriches a ThreatModel with provenance metadata (git SHA, branch, 5 │ * workspace info) for the report JSON contract. 6 │ * 7 │ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" 8 │ * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" 9 │ * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" 10 │ * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" 11 │ * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" 12 │ * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all"
L8 flow GitRepo → #report-metadata
Git info extraction
3 │ * 4 │ * Enriches a ThreatModel with provenance metadata (git SHA, branch, 5 │ * workspace info) for the report JSON contract. 6 │ * 7 │ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" 8 │ * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" 9 │ * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" 10 │ * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" 11 │ * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" 12 │ * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all" 13 │ */
L9 flow #report-metadata → ThreatModel
Metadata injection
4 │ * Enriches a ThreatModel with provenance metadata (git SHA, branch, 5 │ * workspace info) for the report JSON contract. 6 │ * 7 │ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" 8 │ * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" 9 │ * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" 10 │ * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" 11 │ * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" 12 │ * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all" 13 │ */ 14 │
L10 flow GitConfig → #report-metadata
remote.origin.url, HEAD and branch for SARIF versionControlProvenance
5 │ * workspace info) for the report JSON contract. 6 │ * 7 │ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" 8 │ * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" 9 │ * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" 10 │ * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" 11 │ * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" 12 │ * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all" 13 │ */ 14 │ 15 │ import { execSync } from 'node:child_process';
L11 exposes #report-metadata → #data-exposure
remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning
6 │ * 7 │ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" 8 │ * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" 9 │ * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" 10 │ * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" 11 │ * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" 12 │ * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all" 13 │ */ 14 │ 15 │ import { execSync } from 'node:child_process'; 16 │ import { readFileSync, existsSync } from 'node:fs';
L12 mitigates #key-redaction mitigates #data-exposure
readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all
7 │ * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" 8 │ * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" 9 │ * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" 10 │ * @flows GitConfig -> #report-metadata via readVersionControl -- "remote.origin.url, HEAD and branch for SARIF versionControlProvenance" 11 │ * @exposes #report-metadata to #data-exposure [low] cwe:CWE-200 -- "remote.origin.url may embed user:token@host credentials, and the SARIF it lands in is uploaded to code scanning" 12 │ * @mitigates #report-metadata against #data-exposure using #key-redaction -- "readVersionControl keeps only linksFromRemote().web, rebuilt from hostname and path; userinfo is dropped, and a remote that is not http(s)/ssh yields no provenance at all" 13 │ */ 14 │ 15 │ import { execSync } from 'node:child_process'; 16 │ import { readFileSync, existsSync } from 'node:fs'; 17 │ import { join } from 'node:path';
L65 flow TagRegistry → #merge-engine
Recorded tag ownership becomes the namespace the coverage join is scoped by
60 │ * bucket into two and a relation can lose coverage, never gain it. A tag with 61 │ * one definer, or none, scopes to the empty namespace and joins exactly as it 62 │ * did before — which is every tag in every single-repository model, and why 63 │ * nothing outside `merge` passes this. 64 │ * 65 │ * @flows TagRegistry -> #merge-engine via buildOwnerScope -- "Recorded tag ownership becomes the namespace the coverage join is scoped by" 66 │ * @comment -- "Reads also_defined_in and definitions_differ, so a merged report written before those fields existed scopes to nothing and answers exactly as the build that wrote it did" 67 │ * @comment -- "A tag declared identically in every repo that declares it is one shared vocabulary, not a collision: scoping it would invent a finding in every workspace that ships one definitions file to all repos" 68 │ */ 69 │ 70 │ import type { SitedRelation } from '../parser/coverage.js';
L66 comment Reads also_defined_in and definitions_differ, so a merged report written before those fields existed scopes to nothing and answers exactly as the build that wrote it did
Reads also_defined_in and definitions_differ, so a merged report written before those fields existed scopes to nothing and answers exactly as the build that wrote it did
61 │ * one definer, or none, scopes to the empty namespace and joins exactly as it 62 │ * did before — which is every tag in every single-repository model, and why 63 │ * nothing outside `merge` passes this. 64 │ * 65 │ * @flows TagRegistry -> #merge-engine via buildOwnerScope -- "Recorded tag ownership becomes the namespace the coverage join is scoped by" 66 │ * @comment -- "Reads also_defined_in and definitions_differ, so a merged report written before those fields existed scopes to nothing and answers exactly as the build that wrote it did" 67 │ * @comment -- "A tag declared identically in every repo that declares it is one shared vocabulary, not a collision: scoping it would invent a finding in every workspace that ships one definitions file to all repos" 68 │ */ 69 │ 70 │ import type { SitedRelation } from '../parser/coverage.js'; 71 │ import { normalizeRef } from '../parser/coverage.js';
L67 comment A tag declared identically in every repo that declares it is one shared vocabulary, not a collision: scoping it would invent a finding in every workspace that ships one definitions file to all repos
A tag declared identically in every repo that declares it is one shared vocabulary, not a collision: scoping it would invent a finding in every workspace that ships one definitions file to all repos
62 │ * did before — which is every tag in every single-repository model, and why 63 │ * nothing outside `merge` passes this. 64 │ * 65 │ * @flows TagRegistry -> #merge-engine via buildOwnerScope -- "Recorded tag ownership becomes the namespace the coverage join is scoped by" 66 │ * @comment -- "Reads also_defined_in and definitions_differ, so a merged report written before those fields existed scopes to nothing and answers exactly as the build that wrote it did" 67 │ * @comment -- "A tag declared identically in every repo that declares it is one shared vocabulary, not a collision: scoping it would invent a finding in every workspace that ships one definitions file to all repos" 68 │ */ 69 │ 70 │ import type { SitedRelation } from '../parser/coverage.js'; 71 │ import { normalizeRef } from '../parser/coverage.js'; 72 │ import type { TagOwnership } from './types.js';
L7 asset Workspace.Config
Multi-repo workspace definition
2 │ * GuardLink Workspace — Types for multi-repo linking. 3 │ * 4 │ * workspace.yaml lives in each repo's .guardlink/ directory. 5 │ * It declares membership in a workspace and lists sibling repos. 6 │ * 7 │ * @asset Workspace.Config (#workspace-config) -- "Multi-repo workspace definition" 8 │ * @threat Config_Tampering (#config-tamper) [medium] cwe:CWE-15 -- "Malicious workspace.yaml could misdirect agent annotations" 9 │ * @mitigates #workspace-config against #config-tamper using #yaml-validation -- "Schema validation on load" 10 │ */ 11 │ 12 │ import type { ThreatModel, ExternalRef, ReportParseState } from '../types/index.js';
L8 threat Config_Tampering
Malicious workspace.yaml could misdirect agent annotations
3 │ * 4 │ * workspace.yaml lives in each repo's .guardlink/ directory. 5 │ * It declares membership in a workspace and lists sibling repos. 6 │ * 7 │ * @asset Workspace.Config (#workspace-config) -- "Multi-repo workspace definition" 8 │ * @threat Config_Tampering (#config-tamper) [medium] cwe:CWE-15 -- "Malicious workspace.yaml could misdirect agent annotations" 9 │ * @mitigates #workspace-config against #config-tamper using #yaml-validation -- "Schema validation on load" 10 │ */ 11 │ 12 │ import type { ThreatModel, ExternalRef, ReportParseState } from '../types/index.js'; 13 │
L9 mitigates #yaml-validation mitigates #config-tamper
Schema validation on load
4 │ * workspace.yaml lives in each repo's .guardlink/ directory. 5 │ * It declares membership in a workspace and lists sibling repos. 6 │ * 7 │ * @asset Workspace.Config (#workspace-config) -- "Multi-repo workspace definition" 8 │ * @threat Config_Tampering (#config-tamper) [medium] cwe:CWE-15 -- "Malicious workspace.yaml could misdirect agent annotations" 9 │ * @mitigates #workspace-config against #config-tamper using #yaml-validation -- "Schema validation on load" 10 │ */ 11 │ 12 │ import type { ThreatModel, ExternalRef, ReportParseState } from '../types/index.js'; 13 │ 14 │ // ─── Workspace Configuration (workspace.yaml) ───────────────────────
File Coverage
153 of 177 files have GuardLink annotations
Unannotated Files (24)guardlink unannotated .

Source files with no GuardLink annotations. Not all files need annotations — only those touching security boundaries.

.github/ISSUE_TEMPLATE/bug_report.yml
.github/ISSUE_TEMPLATE/config.yml
.github/ISSUE_TEMPLATE/feature_request.yml
.github/workflows/ci.yml
.github/workflows/release.yml
docs/examples/guardlink-pentest.html
eslint.config.js
examples/ci/per-repo-report.yml
examples/ci/workspace-merge.yml
examples/github-action.yml
scripts/build-grammars.ts
scripts/postbuild-chmod.mjs
scripts/query-set.mjs
src/analyze/format.ts
src/dashboard/grade.ts
src/graph/index.ts
src/index.ts
src/init/picker.ts
src/parser/format.ts
src/parser/index.ts
src/parser/normalize.ts
src/tui/format.ts
src/types/index.ts
vitest.config.ts
Shielded regions36

Code regions where annotations are intentionally suppressed via @shield.

Reason
Example annotation block for reference, excluded from parsing
prompts.ts:297src/agents
No reason provided
prompts.ts:310src/agents
Description examples, excluded from parsing
prompts.ts:317src/agents
No reason provided
prompts.ts:327src/agents
Flow examples, excluded from parsing
prompts.ts:334src/agents
No reason provided
prompts.ts:343src/agents
Boundary examples, excluded from parsing
prompts.ts:351src/agents
No reason provided
prompts.ts:357src/agents
Actor example, excluded from parsing
prompts.ts:418src/agents
No reason provided
prompts.ts:420src/agents
Entitlement example, excluded from parsing
prompts.ts:437src/agents
No reason provided
prompts.ts:440src/agents
Agent reach example, excluded from parsing
prompts.ts:454src/agents
No reason provided
prompts.ts:458src/agents
@accepts alternative examples, excluded from parsing
prompts.ts:473src/agents
No reason provided
prompts.ts:483src/agents
Definition syntax examples, excluded from parsing
prompts.ts:510src/agents
No reason provided
prompts.ts:514src/agents
Relationship syntax examples, excluded from parsing
prompts.ts:519src/agents
No reason provided
prompts.ts:535src/agents
Externalized relationship examples, excluded from parsing
prompts.ts:540src/agents
No reason provided
prompts.ts:545src/agents
graph/index example content, excluded from parsing
emit.ts:867src/artifacts
No reason provided
emit.ts:973src/artifacts
graph/README example content, excluded from parsing
emit.ts:977src/artifacts
No reason provided
emit.ts:1069src/artifacts
Placement examples for both modes; they would otherwise parse as real annotations
templates.ts:40src/init
No reason provided
templates.ts:123src/init
Placement examples per language; every verb line below is a template, not a claim
templates.ts:127src/init
No reason provided
templates.ts:194src/init
The annotation reference's own syntax block: verb lines at column 0 with no comment marker, which is what an annotation lost inside a docstring looks like
templates.ts:225src/init
No reason provided
templates.ts:330src/init
agentInstructions renders the Quick Syntax block: verb lines at column 0 that document the grammar rather than claim anything
templates.ts:398src/init
No reason provided
templates.ts:552src/init
README example annotations, excluded from parsing — they would otherwise register as real records
templates.ts:1047src/init
No reason provided
templates.ts:1363src/init
Developer comments250
Comment
Agent binaries are hardcoded; no user-controlled binary names
index.ts:7src/agents
parseAgentFlag extracts flags from args; no injection risk
index.ts:8src/agents
The @entitles section instructs agents to file a proposal rather than write the annotation: the rule itself is enforced in src/review/entitlements.ts (an @entitles with no accepted proposal is a validation error), because a prompt is guidance and this claim needs a gate (actor-entitlement design §3.6)
prompts.ts:18src/agents
Row counts are uncapped on purpose and the byte budget is the only ceiling: a truncated flow graph made the agent duplicate edges it was never shown, and a missing hop reads as findPath's 'no directed path' — a false clean rather than a coverage gap
prompts.ts:71src/agents
The method sits above the generic rules on purpose: the agent reads it first, and the evidence bar it carries is what the gate later checks
prompts.ts:145src/agents
The last block wins when a report carries more than one, because a model that restated its findings restated them last
findings.ts:11src/analyze
Prompt templates are static; no user input interpolation in system prompts
prompts.ts:7src/analyze
customPrompt is appended to user message, not system prompt — bounded injection risk
prompts.ts:8src/analyze
The old `parseInt(args.max_results || '20', 10)` yielded NaN on a malformed argument, and `results.length >= NaN` is false forever — so the bound vanished and the walk covered the whole tree. Clamping is what makes the limit a limit
tools.ts:15src/analyze
SARIF generation is pure transformation; no I/O in this module
index.ts:4src/analyzer
File writes handled by CLI/MCP callers
index.ts:5src/analyzer
Pure transform over the parsed model: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1)
sarif-context.ts:45src/analyzer
Pure transform: no I/O. Reads nothing from @entitles or @actor (SPEC §6.1); the suppressions it writes come only from @mitigates and @accepts, and the agent-reach results come from @agents, @reaches, @effects and @gates as written
sarif-pentest.ts:62src/analyzer
Pure function: transforms ThreatModel to SARIF JSON; no I/O
sarif.ts:50src/analyzer
runs[0].properties.acceptance_register names the register whose acceptances removed results from this export — the annotations in this repo, never the server decision log. Envelope only: results and tool stay byte-identical, the same §3.2 line annotation_hash sits on
sarif.ts:51src/analyzer
runs[0].properties.annotation_hash stamps the export with the annotations it was cut from (R10), so a hygiene gate can tell a current SARIF from one built three commits ago — this file is the pentest surface, and a stale one decides which exposures get tested
sarif.ts:52src/analyzer
@entitles has no export semantics by design: SARIF for a model with entitlements is byte-identical to one without, so an entitlement can never hide an exposure from the pentest export (actor-entitlement design §3.2)
sarif.ts:53src/analyzer
Exposure and confirmed results carry codegraph_reachability{http_method,http_path} from the route @flows declared on the finding's own handler, else its file, else its asset (route_attribution says which), so downstream HTTP consumers can target the endpoint; emitted verbatim from the annotation, no base path assumed. When the model does not tie a finding to one route it carries route_attribution 'ambiguous' and route_candidates instead, never a guessed route
sarif.ts:54src/analyzer
EXPOSURE results carry the claim key as partialFingerprints['guardlink/claimKey'], mirrored to properties.claimKey — the same identity the hypothesis ledger keys an entry on (src/parser/claim-key.ts). The threat id is derived from (asset, threat, file) and so is shared by siblings in one file; the claim key digests the claim's own words, so it separates those siblings and is unchanged by any edit that is not the claim — including a line move and an edit to the code beneath it
sarif.ts:55src/analyzer
CONFIRMED results carry threatId and no claim key, on purpose: the verb is part of the key digest, so an @exposes and the @confirmed proving it hold different keys, and the ledger keys entries by exposure only. A key stamped from a confirmed result could join to nothing, so guardlink does not emit one rather than emitting an identifier that cannot be used
sarif.ts:56src/analyzer
Declared context (taxa, relatedLocations, codeFlows, run.graphs, rule help and security-severity, automationDetails, versionControlProvenance) is appended after every existing member; results, their order, rule ids and message.text do not change, and the strip test pins that against an export cut before the context existed
sarif.ts:57src/analyzer
--min-severity filters unmitigated exposures only: a @confirmed result is a reproduced exploit and is always exported
sarif.ts:58src/analyzer
Artifacts carry a provenance header so a stale one is detectable rather than merely wrong
emit.ts:31src/artifacts
by-feature/*.mmd declare themselves partial in the header: a narrowed diagram that does not say it is narrowed reads as a complete one that is missing things
emit.ts:32src/artifacts
annotation_hash stays project-wide on per-feature files — it records the state the view was cut from, which is what keeps `validate --artifacts` a single comparison for every artifact
emit.ts:33src/artifacts
The field is invisible to the annotation hash (a whitelist of claim fields) and stripped from the committed model.json, so attaching it changes no tracked artifact; the same goes for blame_context, the commit counts the dashboard's rates need
attach.ts:9src/blame
Opt-in and non-mutating by default: computeBlame returns a Map; the model is byte-identical afterwards. Attribution of an AI is only ever what a commit declared
compute.ts:30src/blame
as_of is the HEAD commit's author date, never the wall clock, so every age in the summary is a function of the checkout alone
compute.ts:31src/blame
A commit credits its author and each human co-author once; a bot-authored commit with no human co-author has an agent: author and credits no person. An AI tool is credited once per commit however many trailers name it
compute.ts:265src/blame
The default ignore_revs is .git-blame-ignore-revs, the file GitHub already honours; git.ts passes it only when it exists
config.ts:27src/blame
Every degraded status is printed next to the claim it degrades, so a reader never mistakes an unattributed claim for a clean one
format.ts:10src/blame
A rate with no denominator (no commit counts, or an identity with no commits) prints as a dash, never as 0: an absent number must not read as a good one
format.ts:11src/blame
Read-only by construction: no command here writes to the repository, and optional index refreshes are disabled
git.ts:21src/blame
Reachable from HEAD only, never --all: what other branches carry is not this checkout's history
git.ts:205src/blame
Public surface of src/blame: computeBlame (non-mutating), attachBlame (CLI --blame), the payload builders, the config reader and listCommits, the one history walk behind the commit counts
index.ts:9src/blame
Every list is sorted (rows by introduced desc, then identity; hot files by open desc; quarters contiguous and ascending) so two runs on the same HEAD print the same bytes — the dashboard and report determinism tests depend on it
summary.ts:26src/blame
Pure over BlameEntry[] and CommitCounts: no I/O, no clock. The trend axis is bounded by the date range of the entries, everything else by their count
summary.ts:27src/blame
Declared, not detected: a commit is credited to an AI only through a matching author identity or trailer. Nothing here inspects code or guesses
trailers.ts:25src/blame
Pure type declarations for the blame module; no I/O. The optional `blame` field these describe is invisible to the annotation hash and stripped from the committed model.json, exactly like `anchor`
types.ts:14src/blame
Exit code is a pure function of (strict, exposures, confirmed, drift, demotable stale, parse errors, unqualified acceptances) OR the coverage floor, and lives in the summary, so JSON consumers see the same verdict the shell got
index.ts:183src/ci
The expiry warning boundary is bugb-server's DEFAULT_ACCEPTANCE_WARN_DAYS (14, clamped 0..365) rather than a number chosen here: two registers disagreeing about whether a waiver is in trouble is the bug one level up
index.ts:185src/ci
--min-coverage is the ONE exit-code term that ignores --strict: it asserts the model exists rather than grading what it says, and behind --strict it would be unreachable for every advisory run
index.ts:186src/ci
A lapsing acceptance never moves the exit code. It is a clock reading, not a finding, and a build that goes red on a date nobody chose is a gate teams delete rather than act on
index.ts:187src/ci
Exposures and drift are serialized as the types the parser already produces — no renamed fields, so guardlink.ci/v1 cannot drift from the model it reports
index.ts:188src/ci
The third check reads .guardlink/verified.json and never writes it; a corrupt ledger is reported once and treated as absent
index.ts:189src/ci
@confirmed is not filtered by acceptance state at all: @accepts does not silence a reproduced exploit anywhere else in the product and must not do so here
index.ts:190src/ci
--severity narrows risk findings only; drift, parse errors, stale claims and unqualified acceptances carry no severity and a severity threshold says nothing about them
index.ts:191src/ci
Closes the same blind spot ci's third check already closed (src/ci/index.ts) for the validate/CI-gate surface
index.ts:405src/cli
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
index.ts:562src/cli
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
index.ts:680src/cli
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
index.ts:1245src/cli
--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
index.ts:1285src/cli
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
index.ts:1847src/cli
`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
index.ts:1850src/cli
`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
index.ts:1851src/cli
`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
index.ts:1858src/cli
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
index.ts:1866src/cli
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
index.ts:1869src/cli
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
index.ts:1873src/cli
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
index.ts:1874src/cli
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
index.ts:1877src/cli
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
index.ts:1888src/cli
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
index.ts:1890src/cli
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
index.ts:1893src/cli
--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
index.ts:1895src/cli
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
index.ts:1896src/cli
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
index.ts:1897src/cli
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
index.ts:1909src/cli
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
index.ts:1910src/cli
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
index.ts:1915src/cli
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
index.ts:1926src/cli
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
index.ts:1953src/cli
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
index.ts:1956src/cli
Displays ledger state (absent, corrupt, or counts) computed by the caller; this function performs no I/O of its own
index.ts:3878src/cli
Prints only what the payload already holds; no I/O here
index.ts:3956src/cli
Pure over a parsed model and an already-loaded worklist: no process, no file, no network. The description is matched for a stated access level and quoted back, so a reader sees the exact word the check read
boundary-check.ts:46src/codegraph
Guard evidence text (a decorator or middleware as written) and the boundary suggestions built from it reach the annotate prompt under the same prompt-injection exposure as route paths: source-derived prose the agent could read itself, never an instruction
index.ts:65src/codegraph
Never builds a graph and never installs anything; every failure is a status, and a status other than available changes nothing downstream
index.ts:70src/codegraph
Asset details match a tile by any of its aliases (#id, dotted path, as written), case-insensitively; mitigations never count as exposures, they only change a pair's status
analytics.ts:10src/dashboard
Ported unchanged from the first generate.ts; a file that cannot be read yields an empty context, never a failed page
annotations.ts:8src/dashboard
No network: the clipboard API with a textarea fallback, history.replaceState for filters, localStorage only for theme and rail state
client.ts:27src/dashboard
Stats mirror the model's own scope: on a --feature model these are the feature's numbers, and the dashboard suppresses the ones that only mean something project-wide
data.ts:100src/dashboard
Pure projection of record.blame; the summariser is shared with the CLI and the report so every surface agrees on the numbers
data.ts:293src/dashboard
Every item points somewhere: a hash route into the filtered view, a guardlink command, or both. Nothing here is a bare number
data.ts:393src/dashboard
Reads .guardlink/verified.json once; the classification itself is the parser's, not re-derived here
data.ts:548src/dashboard
Alias map collapses #id / name / path-joined ref forms so all three diagrams agree on identity
diagrams.ts:17src/dashboard
A model narrowed with --feature carries filtered_by_features. The page then declares itself a slice in the title, the top bar and a banner, and suppresses the project-wide measures (file coverage, unannotated files) that a slice cannot answer
generate.ts:41src/dashboard
No remote reference of any kind is emitted: the page renders offline and under a host policy that blocks every network origin
generate.ts:42src/dashboard
statCard keeps its exact markup: the feature filter rewrites the inventory tiles by label, and a test greps the Open Threats tile by that string
html.ts:17src/dashboard
Self-contained HTML; no external data injection after generation
index.ts:7src/dashboard
Pure: no I/O, no DOM. Every string here is raw model text; the renderers escape it at the point they emit markup
graph.ts:15src/dashboard/layout
Pure string helpers; nothing here reads a document
text.ts:11src/dashboard/layout
Pure geometry over keys; no DOM, no measurement. The callers own every label and every colour
tripartite.ts:21src/dashboard/layout
Pure and deterministic: URL parsing is string work, detection is two file reads at most. Hosts are classified by hostname substring (github/gitlab/bitbucket); anything else is `other` and gets GitHub-shaped links as a best effort.
links.ts:16src/dashboard
Accepts scp-style (git@host:org/repo.git), ssh://, http:// and https:// remotes. Strips `.git` and trailing slashes from the path.
links.ts:38src/dashboard
Best effort and side-effect free: every failure mode (no .git, junk pointer, dangling gitdir, config without origin, local-path origin) collapses to null rather than throwing, so dashboard generation never depends on this succeeding
links.ts:118src/dashboard
Replaces the CDN copy of marked: the page makes no network request to render a report
markdown.ts:19src/dashboard
Chips carry state the design way: a capability is an outlined chip with ✓ (entitled) or ✕ (unentitled), a gated effect carries ⊢, an ungated mutation is a filled warm chip, a read is a plain tag
agents.ts:15src/dashboard/pages
Rows keep data-ff-asset (every alias of the asset, |-separated) so the feature filter can hide what a feature never touches
assets.ts:12src/dashboard/pages
No wall clock: ages are measured to the HEAD commit's date, so two generations at the same HEAD are byte-identical
attribution.ts:11src/dashboard/pages
The withheld-on-a-slice sentence and the coverage strings are pinned by tests; file cards gain search text and a host link
code.ts:12src/dashboard/pages
No inline handlers: a file header carries data-toggle-file and an annotation data-annotation, and the page's one delegated listener acts on them
code.ts:13src/dashboard/pages
Pure; every string here is raw and is escaped by the page that renders it
context.ts:13src/dashboard/pages
Scope wording lives in a note, not in headings: the client feature filter rewrites headings by textContent
exposures.ts:22src/dashboard/pages
The inventory keeps statCard markup and labels: the feature filter rewrites the Open Threats and Mitigated tiles by label, and a test greps the tile
overview.ts:18src/dashboard/pages
Whole-project documents: --feature does not narrow them, and the page says so on a slice. With no saved report the page renders its own empty state so the copy buttons exist without the client
reports.ts:13src/dashboard/pages
The empty-state sentence and its condition are kept word for word (flows are not part of it): the feature-slice test pins the sentence
tables.ts:14src/dashboard/pages
The limits are Mermaid's shipped defaults, read out of mermaid@11.17.2's bundle and cited above — deriving them rather than guessing is the whole point of the module
render-budget.ts:60src/dashboard
Deterministic per ref: the ref's commit date and the commit count are facts of history, so two runs at the same HEAD produce the same page
since.ts:9src/dashboard
Static CSS only; nothing here interpolates model data, and nothing here references a remote resource
styles.ts:26src/dashboard
Pure model-vs-model comparator; no I/O. Entitlement staleness is the one thing it cannot derive from the two models, so the changed-file list is passed in by the caller (see getChangedFiles in git.ts)
engine.ts:15src/diff
A reach that becomes unentitled is reported on its own, like a new unmitigated exposure: newUnentitledReaches comes from findUnentitledReaches, the join lookup answers with
engine.ts:17src/diff
Pure string formatting; no I/O. Stale entitlements print even when the delta is otherwise empty — a claim whose cited authz code moved is exactly the thing that must not disappear quietly (actor-entitlement design §3.3)
format.ts:4src/diff
Never edits an annotation's text: a rejected claim is re-prompted, then removed; rewriting would hide that it was wrong
gate.ts:16src/gate
The acceptance check behind `guardlink annotate` and the standalone `guardlink lint`
index.ts:4src/gate
Errors are what the gate rejects; warnings are what it reports. Governance verbs (@accepts, @entitles) are errors only for claims under check, because a human may legitimately have written the others
lint.ts:9src/gate
@agents, @reaches, @effects and @gates are observations an agent may write, so no rule rejects them. A @gates that does not say what the approval step is gets the vague-description warning, because a gate claim is what a reviewer reads to decide a route is held
lint.ts:10src/gate
The numbers are measured against a specific panel and font size, both cited above — deriving rather than guessing is the same discipline render-budget.ts applies to Mermaid's own limits
legibility.ts:93src/graph
Pure functions over an already-parsed ThreatModel: no file I/O, no user input, no network, and the renderer is injected so this module never depends on the dashboard
views.ts:53src/graph
Selection goes through selectSubgraph's `nodes` option rather than reimplementing the filter, so a view and an MCP graph query narrow the model the same way
views.ts:54src/graph
Pure over the model and a ledger read, like classifyHypotheses; recording goes through recordBoundaryOutcome in commands.ts, which shares the exposure path's evidence check and upsert
boundary.ts:20src/hypothesis
Pure over the model and a ledger read; no clock, no file, no git — ranking is arithmetic on facts the model already holds
classify.ts:12src/hypothesis
A confirmation is held to the same evidence bar the gate holds @confirmed to; a refutation needs evidence too, just not the same words, because 'the validator rejected it' is evidence of absence
commands.ts:17src/hypothesis
importScan() resolves a finding carrying a claim_key against the whole record set BEFORE the location/asset-threat/CWE tiers, which run only for an unstamped finding; a key that names no claim is stale, never re-joined by a coarser tier. The key names the claim itself, so it survives a line move and an edit to the code beneath it, and separates a sibling claim that landed on the tested line. Two byte-identical claims in one file share a digest and are told apart only by an ordinal in document order, so deleting the earlier one hands its key to the survivor
commands.ts:18src/hypothesis
The claim key is read under every name in CLAIM_KEY_NAMES and from every container generated from CLAIM_KEY_SURFACES (src/parser/claim-key.ts) — the same definition guardlink sarif emits through — so the finding's top level, its annotation object, and either emitted surface nested under its own name at either level all resolve. Names AND surfaces live in that one definition because each time only one dimension was shared the disagreement simply moved to the other, and an unrecognised stamp takes the weaker join while the output tells the operator to start sending the stamp they already sent
commands.ts:19src/hypothesis
The @validates below sits here rather than on its test on purpose: .guardlink/config.json excludes the tests directory and every nested one from the scan, so an annotation moved into tests/hypothesis.test.ts is never parsed — it would leave the threat model entirely and a lookup of the validations for #cli would answer nothing instead of pointing anywhere. Every validation in this repo lives with the implementation it proves and names its test in the description; see src/parser/parse-file.ts, src/parser/comment-strip.ts and src/mcp/subgraph.ts. Do not move it
commands.ts:20src/hypothesis
A stamp is only taken as ours when it matches CLAIM_KEY_PATTERN; three names across six containers include free-form bags another tool may also write a claim_key into. All candidates are collected first and the shape check partitions them AFTER, because validating only the first hit checked the wrong candidate — a foreign placeholder sorting ahead of our own emitted claimKey in the same forwarded bag buried a valid key. The key is the first VALID candidate in precedence order; only when none is valid is the finding refused as malformed, and unshaped values found beside a good key are reported as a note that changes neither the join nor the exit status
commands.ts:22src/hypothesis
confirmedLine() states the join provenance in a scan-derived @confirmed, because that line is a claim in someone's repository that this exposure was tested, not a report line. The CLI refuses to write one that was not key-verified; the provenance in the text is what keeps that refusal meaningful after the caller changes
commands.ts:23src/hypothesis
Plain padded tables like printStatus; nothing here reads a file
format.ts:5src/hypothesis
A contested join — more than one well-formed claim key on one finding, naming different claims — is labelled with its own words and its losing keys are listed, because 'key-verified' is a claim about what happened and a precedence tiebreak establishes less than agreement does
format.ts:6src/hypothesis
formatImport() prints the stale and malformed buckets beside ambiguous and unmatched. A finding whose stamped claim key names no claim in the model says so and offers no by-hand target, because every claim it could name there is a different claim; a finding whose stamp is not a claim key at all names the value and the field it arrived in, so the producer can be fixed. Each confirmation is labelled with the identity that joined it, and a report carrying no stamps at all says that the weaker join was used — a key-verified confirmation and an unverified one must not read the same
format.ts:7src/hypothesis
The header counted the rows it was handed, so once -n bounded every renderer a 142-claim queue printed 10 to test: not a smaller answer to the same question but a wrong answer to it, because that line is the only place the table says how much there is. It names both numbers when the page is short of the queue, and reads exactly as it always did when it is not — a run that hides nothing must not grow a notice about hiding. `total` is REQUIRED and deliberately carries no default: defaulting it to q.length hands a caller who forgets it the permissive answer — truncated false, and a 10-of-142 page reporting `10 to test` — which is the wrong number this renderer exists to stop, reinstalled as a silent default. Required, an omission is a build failure instead of a confident wrong answer
format.ts:37src/hypothesis
`total` decides EVERY branch here, the empty page included. The early return is the one sentence in this function that names a state rather than counting — it says every exposure has an outcome that still holds — so it is owed to `total === 0` and not to an empty page: `formatQueue([], 142)` asked the queue for nothing and got nothing back, which says where the page ended, never that there is nothing outstanding. That branch was the last place in this file where a required total was accepted and then not read, and an empty page over a non-empty queue now falls through to the ordinary header and renders `0 of 142 to test`, which counts and claims nothing
format.ts:38src/hypothesis
A bounded brief that does not say it is bounded is a misleading brief. This one closes by telling an operator to hand it to bugb intake, so a 10-item page of a 142-claim queue reads as the whole of what there is to test and the 132 it never names read as nothing left to do. Both numbers are stated above the list and again on that closing line, since either can be the one an operator reads, along with the flag that asks for the rest. An untruncated brief is unchanged, down to the byte. The notice COUNTS the remainder and deliberately names no state for it: rankUntested queues untested and retest together, so a state noun here can misdescribe what is hidden — calling a claim that was confirmed, and whose code has since moved, untested, in the very line an operator hands to bugb intake. Membership is decided by that filter and is free to change without anyone reading this string; a line that only counts cannot fall out of step with what it counts, and the per-item labels below keep the composition legible where it belongs. The remainder clause agrees in number, because a remainder of exactly one is an ordinary count and `The other 1 are` is the kind of seam that tells a reader the sentence was never run at that value. The page size stays a numeral even when it is one: a bounded notice has to name both numbers, so it is not dropped for grammar. `total` is REQUIRED here for the same reason as in formatQueue — a default would return the permissive answer to a caller who forgot it, in the one renderer an operator hands to bugb intake
format.ts:55src/hypothesis
One block per FINDING, deliberately — the header counts findings and two probes hitting one claim is worth seeing. So a claim several findings joined to appears more than once, each block showing the entry that finding produced. Withholding and the exit code are the opposite case: those are decisions about a CLAIM, are derived per claim in the CLI, and must not be read off a per-finding list
format.ts:176src/hypothesis
This formatter emits NO model coordinate. It runs before --write splices anything, so every line it could name is a pre-write position, and a target handed to an operator has to name where the claim is once this run is finished — a key-verified write higher up the same file moves an ambiguous candidate as readily as it moves a withheld one. The by-hand commands for an ambiguous finding therefore live in the CLI beside the withheld ones, after the writes, through the one afterWrites calculation. The only file:line left below is the SCAN REPORT's own, which is correct as the report's claim about what it tested and is not ours to shift
format.ts:179src/hypothesis
The tested state of every exposure and declared boundary: a ledger of outcomes with evidence, expiry by code hash, and a ranked queue
index.ts:4src/hypothesis
A corrupt file is reported, never rebuilt in place: an outcome ledger that quietly emptied itself would turn refuted findings back into open ones
ledger.ts:23src/hypothesis
Detection is read-only; no file writes
detect.ts:8src/init
Migrations should never modify existing user files. Only create missing ones.
migrate.ts:9src/init
Fail-closed: an unreadable or unparseable existing file is treated as populated, never as empty
preserve.ts:20src/init
Pure projection over the parsed model; the only I/O is an existence check performed by the caller
context.ts:21src/mcp
git SHA is read from .git/HEAD rather than spawned via execSync: this runs on every tool call
freshness.ts:21src/mcp
D35: the `guardlink-mcp` bin executes this module directly, so it carries a shebang and a main guard. Startup errors go to stderr — stdout is the JSON-RPC channel and a stray line there corrupts the transport.
index.ts:10src/mcp
Pure string builder; the only I/O is one optional config read by the caller
instructions.ts:26src/mcp
Pure function; no I/O; operates on in-memory ThreatModel
lookup.ts:27src/mcp
The reach forms project the parsed model and add no input surface. `unentitled reaches` answers from findUnentitledReaches, the same join diff uses, so the two cannot disagree about can minus may; `ungated effects` answers from buildReachAnalysis, the block the model export carries
lookup.ts:28src/mcp
No guardlink_entitlement_accept tool exists on purpose: proposing is an agent's job, granting authority is not (actor-entitlement design §3.6)
server.ts:47src/mcp
Entitlement accepted by zippon on 2026-08-10 via guardlink entitle (proposal ent-mcp_agent.mcp.data_exposure).
server.ts:58src/mcp
Non-mutating: computeBlame leaves the cached model untouched, so a later guardlink_parse in the same session never carries blame unasked. Nothing is written to the repository
server.ts:1223src/mcp
Pure function over the model; returns a ThreatModel so the existing Mermaid generator needs no changes
subgraph.ts:48src/mcp
Skips node_modules and .guardlink directories
suggest.ts:20src/mcp
What config CANNOT reach is the point: scope and expiry semantics are not settings. A project can move a threshold; it cannot declare that an acceptance covers a file it was not written in, because that is what the word means rather than a preference
acceptance.ts:49src/parser
acceptance.warn_days is the one policy field that cannot re-open or silence a finding: it decides only how early a reader hears that a QUALIFYING acceptance is running out. Coverage never consults it, so a project lowering it to 0 loses a warning and gains no suppression
acceptance.ts:55src/parser
Pure functions apart from readAcceptancePolicy; `now` is a parameter so a test can pin the clock rather than skew it
acceptance.ts:58src/parser
Scope and expiry are enforced in coverage.ts, not here — this module defines them and answers questions about them, coverage applies them
acceptance.ts:59src/parser
acceptanceBlastRadius is read by guardlink review BEFORE the justification prompt: a reviewer typing one line sees how many exposures it removes from the gate and from the SARIF a pentest reads
acceptance.ts:60src/parser
ACCEPTANCE_REGISTER_* name the ONE register this codebase reads — @accepts in the repository's own source. The server decision log (POST /v1/decisions), whose author is an authenticated principal and which the acceptance-deadline scan reads, is a different register with opposite guarantees and is not read here. Every surface that shows an acceptance says which one it is showing, from these constants, so there is one sentence to change when that answer changes
acceptance.ts:61src/parser
Neither the signer nor the horizon is defaulted on an unattended path: --by used to fall back to git user.name (a fabricated attribution that renders identically to a real signature) and --until used to default to max_horizon_days, the ceiling. default_horizon_days is a SUGGESTION offered at a TTY, never applied silently
acceptance.ts:62src/parser
Pure function; no I/O; deterministic across machines and annotation modes
annotation-hash.ts:29src/parser
Excludes line, origin_file, origin_line and parent_symbol so inline and external authoring of the same model hash identically
annotation-hash.ts:31src/parser
v2 adds @actor and @entitles records. Before that a migration could drop or rewrite every entitlement in a repo and the hash gate would still report the model unchanged — a silent all-clear, which is the failure mode the entitlement design exists to prevent (actor-entitlement design §2)
annotation-hash.ts:32src/parser
v3 adds an acceptance's accepted_by and expires. Those two fields decide whether an acceptance still covers anything, so a hash that could not see them would call a renewed or re-signed governance decision 'unchanged'
annotation-hash.ts:33src/parser
Pure function; mixed is a real answer, not an error — a repo mid-migration is genuinely both
annotation-mode.ts:11src/parser
Idempotent by construction: an identical @source block is detected and skipped rather than duplicated
apply-annotations.ts:27src/parser
Pure; returns a new model, mutates nothing. Sort is total, so the result cannot depend on input order.
canonical-order.ts:25src/parser
Aliasing can only ever ADD coverage, so the map is built from declared assets only; an undeclared ref resolves to itself, which is the pre-D47 behaviour
canonical-ref.ts:37src/parser
Pure function over annotation text; no I/O, no filesystem check — a citation is a claim about where authz lives, and verifying the path exists is a reviewer's job (§3.4)
citation.ts:16src/parser
Pure functions over an assembled ThreatModel; no I/O
claim-key.ts:20src/parser
No @entitles here on purpose: #local-dev is genuinely entitled to the #arbitrary-write effect (clear exists to rewrite these files), but that pair already carries @mitigates, so it is never an open finding and the claim would demote nothing. Recorded and withdrawn in .guardlink/entitlement-proposals.json — an entitlement on a covered pair is decoration
clear.ts:12src/parser
#mcp-agent is granted nothing. It can only preview a clear (dry_run defaults to true at src/mcp/server.ts:505), and a preview writes nothing, so it answers no exposure — an entitlement that joins no finding is decoration. Nor is it entitled to clear-annotations: no code makes an MCP caller obtain the confirmation its tool description asks for, so granting it would be the over-grant §2 forbids
clear.ts:13src/parser
Widening what is consumed AFTER a recognised marker is safe; widening the marker set would not be. The openers in LINE_MARKERS are unchanged from the single-marker version, so no line that was code became a comment
comment-strip.ts:11src/parser
Inline annotations never carry parent_symbol (parse-file.ts populates it only from @source); they are sited by handler scope instead, read from the structure-layer anchor, so the rule now applies in inline repos too
coverage.ts:98src/parser
A location with no anchor (anchors: false, an unreadable file, a model from before anchors) is file-level and never narrowed — unknown can only fail to narrow, never invent a site
coverage.ts:99src/parser
Narrowing is one-directional by construction: coversExposure starts from the (asset, threat) match the old key computed and only ever subtracts. A refactor that made it additive would be a silent-wrong-answer path in the opposite direction
coverage.ts:100src/parser
Pure filtering utility; no I/O
feature-filter.ts:15src/parser
Relations are file-scoped, definitions are reference-scoped. Filtering definitions by file emptied the asset heatmap, dropped every threat severity and rendered diagrams as bare ids — the same failure selectSubgraph avoids by always keeping the node vocabulary
feature-filter.ts:16src/parser
Exclusion list for the annotation counter; external_refs is derived from annotations, not an annotation
feature-filter.ts:30src/parser
Derives annotation totals from the model's own shape so a newly added collection is counted the day it lands
feature-filter.ts:52src/parser
Filtered models must never report project-wide totals — a reader acts on the numbers beside the contents
feature-filter.ts:100src/parser
Metadata only: path, size, mtime. File contents are never read.
fingerprint.ts:23src/parser
Pure path arithmetic; no I/O. The inverse is exact, which is what makes migration reversible.
gal-path.ts:21src/parser
Pure functions over parsed locations; reads no files and no user input
handler-scope.ts:22src/parser
No @exposes here on purpose. This module reads one constant path under the project root and emits the path plus two content hashes — no annotation text, no signer, no file contents — so there is no risk to declare, and declaring one would attach a mitigation to the #parser/#data-exposure pair that silences the open exposure at migrate-mode.ts:26
handoff.ts:50src/parser
`not-needed` is a real verdict, not a skip: with nothing in a sidecar there is nothing an export would rescue, and inline annotations survive a consumer's fallback on their own
handoff.ts:51src/parser
Every failure to read the stamp returns `unverifiable`, never `current` — the one answer that would silence this check is the one an error cannot produce
handoff.ts:52src/parser
Widening which FILES are opened is safe; widening what counts as a COMMENT would not be. This module adds no marker: every style here was already recognised by stripCommentPrefix, which is why tests/scanned-languages.test.ts pins the negative direction — a bare @exposes in a newly-scanned .php file is still not an annotation
languages.ts:25src/parser
Restoring a prefix is inferred from context (block comment vs line comment) rather than recorded in the .gal — recording it would put presentation data in the threat model
migrate-mode.ts:30src/parser
A line lost before parseLine is a security claim that reached no threat model and produced no warning; the two comment-form diagnostics here exist so the next unread form cannot survive a release unnoticed
parse-file.ts:12src/parser
Regex patterns designed with bounded quantifiers and explicit structure
parse-line.ts:7src/parser
@entitles capability is a single-token identifier by grammar, so prose in that position is a parse error rather than a label nothing can group or compare (actor-entitlement design §3.1)
parse-line.ts:8src/parser
@entitles takes optional `on <asset>` and `against <threat>` clauses because the join is the (actor, asset, threat) triple, not the capability — a capability-keyed join would demote every threat on the asset including one discovered later (actor-entitlement design §9.3)
parse-line.ts:9src/parser
@effects takes a closed effect set, like @handles takes a closed classification set: an open string cannot be compared across repositories or drive a query
parse-line.ts:11src/parser
Scans standalone .gal files in addition to comment-based source annotations
parse-project.ts:11src/parser
Every @entitles gets its citation resolved at assembly time and carries inert:true when uncited, so no consumer can read an uncited privilege claim as an effective one by forgetting to check (actor-entitlement design §3.4)
parse-project.ts:12src/parser
Same reasoning for imprecise:true when the claim omits `on <asset>` or `against <threat>`, plus canEntitlementDemote/entitlementDemotionBlockers so the cited-and-precise test is written once here rather than re-derived by every consumer — a consumer that checks only !inert would demote on an entitlement that joins nothing (actor-entitlement design §9.7)
parse-project.ts:13src/parser
A traversing path is already a broken annotation and this is the diagnostic that says so; refusing to stat it would return the silence this function exists to remove
parse-project.ts:146src/parser
Pure functions over an assembled ThreatModel; no I/O. Uncited entitlements never cover a reach, and a gate scoped to a capability never covers an effect whose route is unknown, so both joins err toward showing a capability or an effect rather than hiding one
reach.ts:50src/parser
Symbol matching is deliberately loose: it looks for the name as a whole word, not for a specific declaration syntax, so it works across languages
reanchor.ts:22src/parser
Pure functions over the parsed model; the path is returned verbatim from the annotation and never resolved, fetched or joined to a base URL
route.ts:20src/parser
@confirmed refs validated same as @exposes for asset and threat
validate.ts:8src/parser
findUnresolvedBoundarySides errors on a directed @boundary whose outer or inner side names no declared asset and no @flows endpoint, so a declared direction cannot point consumers at nothing
validate.ts:9src/parser
findUndeclaredActors / findInertEntitlements implement the two mechanical @entitles checks from docs/prd/actor-entitlement-design.md §3.7 — both typo-class; entitlement intent is not machine-checkable
validate.ts:10src/parser
findUndeclaredActors also covers the actor on @agents/@reaches and the approver on @gates; findAgentReachConflicts errors when one actor is written under both @agents and @reaches, because which verb names an actor is what says whether it is an agent
validate.ts:11src/parser
Pure function; no I/O. Demotion is a SET of claim keys handed to coverage.ts, never a rewrite of the model
verification.ts:31src/parser
Identity comes from git config or the OS user, is prefixed human: here and agent: in the MCP tool, and is recorded verbatim so a later gate can key on the prefix
verify.ts:15src/parser
Pure string formatting; no I/O. Every hop prints its file:line because a path that cannot be walked in an editor is an assertion rather than a finding, and the empty case states which kind of empty it is — no undefended route in the annotated graph is not the same claim as no undefended route in the code
format.ts:4src/paths
Pure analyzer: no file I/O, no user input, no network. Operates only on an already-parsed ThreatModel, which is why it needs no @exposes of its own
index.ts:18src/paths
Walks flow edges only. @boundary edges are undirected and used for crossing detection, and @transfers moves responsibility rather than data, so neither is a hop a path may take
index.ts:19src/paths
Bodies are markdown that lands verbatim in the agent prompt and in the SKILL.md; keep each one readable on its own
annotate.ts:5src/playbooks
Kept in one place so the lint in src/gate mirrors it rule for rule: what the prompt demands is what the gate checks
evidence.ts:5src/playbooks
Annotate playbooks compose into buildAnnotatePrompt; report shapes into buildUserMessage; skills into guardlink init
index.ts:4src/playbooks
'full' is the framework's own structure and appends nothing; the other three are audience shapes
report.ts:6src/playbooks
Order matters where prompts match several playbooks: chains beats exploitable because a prompt that asks for chains also asks for exploitability, and diff beats coverage because a change is narrower than the tree
select.ts:8src/playbooks
Frontmatter is the two keys every skill loader reads (name, description); the body is the playbook verbatim plus how to invoke the CLI equivalent
skills.ts:7src/playbooks
Pure data and pure functions: nothing here reads a file, runs a process or talks to a model
types.ts:10src/playbooks
Re-exports only; the derivation lives in summary.ts and the joins in parser/reach.ts
index.ts:5src/reach
Pure functions over an assembled ThreatModel; no I/O and no input surface. Every row keeps the file:line of the annotation it came from, so each picture can be traced back to source
summary.ts:38src/reach
Pure function: ThreatModel to markdown lines. Model text lands in markdown table cells, where mdCell keeps a pipe or newline from breaking out of the cell; no HTML or shell is produced here
agent-reach.ts:12src/report
Report generation is pure transformation; no I/O in this module
index.ts:4src/report
File writes handled by CLI/MCP callers
index.ts:5src/report
@feature names are single-line by construction (parse-line.ts), so there is no newline to break the scalar; the quote is the only delimiter that needs escaping
mermaid.ts:107src/report
Pure function: transforms ThreatModel to markdown string
report.ts:6src/report
No file I/O; caller (CLI/MCP) handles write
report.ts:7src/report
A model narrowed by --feature carries filtered_by_features, and every heading, caption and empty-state sentence here is scoped from it: a report that reads as project-wide while describing one feature is a wrong answer about what is and is not covered, not a cosmetic problem
report.ts:8src/report
Feature names are model data and land in markdown text, never in HTML or a shell — the markdown consumer escapes; the two Mermaid titles that do need escaping carry their own @mitigates
report.ts:10src/report
AI credit is what a commit declared in a trailer, never detected from code; a claim whose commits carry no trailer stays human. Every degraded status is listed so the reader never mistakes an unattributed claim for a clean one
report.ts:1539src/report
Pure function: transforms ThreatModel flows to Mermaid sequence diagram
sequence.ts:6src/report
Titles itself from filtered_by_features when the model is a --feature slice: a sequence of three participants reads as the system's interactions unless the picture says which slice it is
sequence.ts:7src/report
There is no MCP accept tool and no agent-callable accept path: the only writer of an accepted entitlement is a human at `guardlink entitle`, which is why the CLI is the surface and this module refuses a decision with no name on it
entitlements.ts:40src/review
Writes `against <threat>` as well as `on <asset>`, because the join is the triple (design §9.3): a claim missing either clause matches no finding and demotes nothing. Omitting the threat here made acceptance produce a DEAD annotation — the proposal ledger carried it, the written line did not, so the only sanctioned way to create an entitlement produced one that could never work. Observed on a real repository before this fix, not hypothesised.
entitlements.ts:499src/review
Suggestion only. The unattended paths (scripted --accept, --from batch, MCP) do not call this at all: a name nobody confirmed is a forged signature, and the caller refuses instead
entitlements.ts:871src/review
applyReviewAction throws rather than returning a flag: a writer that can be ignored by a caller who forgot to check is the hole this replaced
index.ts:36src/review
assertAcceptable never SUPPLIES a missing `by` or `until`, it refuses. Defaulting either is how a signature gets forged (git user.name) or how the longest lease the policy allows becomes the cheapest one to take (the max_horizon_days ceiling) — so the choke point that enforces the rule is also the one place that will not paper over it
index.ts:37src/review
Shared by exposure review (@accepts/@audit) and entitlement acceptance (@entitles) so both writers place annotations by the same rules
index.ts:412src/review
Pure functions over an in-memory tree; no I/O, no user input beyond the source text already read by the parser
anchor.ts:16src/structure
A file that cannot be read or cannot be parsed nulls that file's anchors and warns once; it never fails the parse, because one pathological file must not take down every command in the repository
attach.ts:15src/structure
Data only: no I/O, no user input. The paths it produces are joined under the package's own grammars/ directory
grammars.ts:14src/structure
Pure functions over an in-memory syntax tree or a string; no I/O
hash.ts:14src/structure
Takes content, not a path to read: the parser owns file I/O and its path validation; this layer never opens a file
index.ts:12src/structure
Paths come only from grammarPath(language) over the package's own grammars/ directory; no caller-supplied path reaches Language.load
runtime.ts:19src/structure
Non-mutating on purpose: ctx.model is reused by every later command, and /blame must not make /report carry attribution unasked
commands.ts:1008src/tui
Derived query over @flows and @mitigates; every hop it prints is an existing annotation location, so a finding here cannot cite a line that was never written
commands.ts:1081src/tui
Raw mode enables full keystroke control for command palette
input.ts:19src/tui
Resolved relative to this module, so it is correct from both src/ and dist/
version.ts:11src
A report written before tag_registry carried also_defined_in scopes to nothing and answers exactly as the build that wrote it did — stated rather than silently assumed
estate.ts:64src/workspace
Workspace module: config loading, merge engine, link-project setup
index.ts:4src/workspace
A literal filename containing glob metacharacters is read as a pattern; it lands in unmatched rather than in a false ENOENT, and the CLI's message names the pattern verbatim so the cause is visible
merge.ts:152src/workspace
Reads also_defined_in and definitions_differ, so a merged report written before those fields existed scopes to nothing and answers exactly as the build that wrote it did
owner-scope.ts:66src/workspace
A tag declared identically in every repo that declares it is one shared vocabulary, not a collision: scoping it would invent a finding in every workspace that ships one definitions file to all repos
owner-scope.ts:67src/workspace

Agents & reach

No @agents, @reaches, @effects or @gates annotations, so nothing in the model says what an embedded agent or another principal can reach.

Declare it where each fact lives in the code — on the tool registration, the code that acts, and the approval step:

@agents #support-agent to run-sql on #tool-surface -- "run_sql tool"
@effects write on #users-db as #db-admin -- "Runs model-written SQL"
@gates #payments by #support-human for issue-refund -- "requireApproval() waits for a person"
Actors3

Principals in the authorization model. An actor is an AI agent when some @agents names it.

Description
#ci-runner — 0 0 0
GitHub Actions job invoking the built CLI (see .github/workflows/ci.yml); has no TTY, so it cannot answer a confirmation prompt, and is deliberately granted no entitlement
definitions.ts:77.guardlink
#local-dev — 0 0 0
Runs the guardlink binary from a shell on the project; already holds write access to the same working tree guardlink edits, and has an interactive TTY that can answer a confirmation prompt
definitions.ts:75.guardlink
#mcp-agent — 0 0 0 AI coding agent calling guardlink tools over stdio through the MCP server; acts only with the tool set src/mcp/server.ts exposes, not with the operator's shell
definitions.ts:76.guardlink
Entitlements1

Capabilities a principal is claimed to hold by design. The join is (actor, asset, threat) — a row with either missing joins no finding and cannot demote one. An entitlement never suppresses a finding and never gates testing; it only changes what downstream triage recommends. A claim that cites no authorization code is inert and has no effect.

Rationale
#mcp-agent read-threat-model #mcp #data-exposure src/mcp/index.ts:23
By design: guardlink mcp exists to hand a connected coding agent the threat model — that disclosure is the product, not a leak. No privilege gain either: the agent already reads the annotated source these records are parsed from, so the assembled model tells it nothing it could not derive itself. Authorization is the channel: the server is stdio-only with no network listener, so the only client is the process the operator launched, at src/mcp/index.ts:23
server.ts:57src/mcp

Reports

Reports written by guardlink threat-report (STRIDE, DREAD, PASTA, attacker, rapid, general or a custom prompt). Pick one to read it here; copy or download it to share.

1 saved

STRIDE Threat Analysis — GuardLink

Generated: 2026-03-12T19:14:22Z Framework: STRIDE Analyst: Claude Opus 4.5


Executive Summary

GuardLink is a security annotation tool that parses source code, generates threat models, and integrates with AI coding agents. The analysis identified 15 unmitigated exposures across 10 assets, with 1 critical, 4 high, 5 medium, and 5 low severity findings.

Key Risk Areas:

  1. Command Injection (Critical) — Child process spawning in CLI/TUI/MCP with external input
  2. Prompt Injection (High) — User prompts concatenated into agent instructions without sanitization
  3. Arbitrary File Write (High) — Parser can write to files discovered via glob patterns
  4. Data Exposure (Medium) — Threat model data exposed to MCP clients and LLM providers

Overall Risk Posture: MODERATE — Core security controls are in place (path validation, parameterized commands, key redaction), but several attack vectors remain unmitigated due to architectural constraints (prompt injection relies on LLM provider safety, child process spawning is delegated to trusted agents).


Part 1 — Annotation Validation

Critical Findings

#cli → #cmd-injection [CRITICAL]

Location: src/cli/index.ts:31 Annotation: @exposes #cli to #cmd-injection [critical] -- "Agent launcher spawns child processes" Audit: @audit #cli -- "Child process spawning delegated to agents/launcher.ts with explicit args"

Validation: ✅ ACCURATE The CLI delegates process spawning to agents/launcher.ts which uses spawn() with explicit argument arrays rather than shell interpolation. The #param-commands control is correctly applied. However, the exposure remains because the binary names come from a hardcoded registry — if that registry were compromised or extended with user input, command injection would be possible.

Recommendation: Add runtime validation that agent binary paths exist and are not symlinks to unexpected locations.

High Severity Findings

#agent-launcher → #prompt-injection [HIGH]

Location: src/agents/prompts.ts:6 Annotation: @exposes #agent-launcher to #prompt-injection [high] -- "User prompt concatenated into agent instruction text"

Validation: ⚠️ CONCERNING User prompts are directly concatenated into the instruction text sent to coding agents. While the @audit notes that "prompt injection mitigated by agent's own safety measures," this creates a transitive trust dependency — GuardLink's security relies on the security of external agents (Claude Code, Cursor, Codex).

Risk: A malicious user could craft prompts that instruct the agent to:

  • Modify security annotations maliciously
  • Exfiltrate sensitive code via the annotation system
  • Bypass GuardLink's intended workflow

Recommendation: Implement prompt templating with strict escaping, or use structured message formats that separate system instructions from user content.

#mcp → #cmd-injection [HIGH]

Location: src/mcp/index.ts:4 Annotation: @exposes #mcp to #cmd-injection [high] -- "Accepts tool calls from external MCP clients"

Validation: ✅ ACCURATE The MCP server accepts tool calls over stdio from any connected client. While Zod schema validation is applied to inputs, the attack surface includes:

  • guardlink_annotate — passes prompts to LLM
  • guardlink_threat_report — spawns analysis processes
  • guardlink_diff — executes git commands

Mitigating Factor: The @audit notes "All tool calls validated by server.ts before execution" — this is correct, but validation focuses on schema conformance, not semantic safety.

#parser → #arbitrary-write [HIGH]

Location: src/parser/clear.ts:7 Annotation: @exposes #parser to #arbitrary-write [high] -- "Writes modified content back to discovered files"

Validation: ✅ ACCURATE with caveat The clearAnnotations() function writes to files discovered via fast-glob. The #glob-filtering control applies DEFAULT_EXCLUDE patterns, but:

  • A malicious .gitignore or custom exclude patterns could be manipulated
  • Symlinks within the project could redirect writes outside the project

Mitigating Factor: @audit notes "Destructive operation requires explicit user confirmation via dryRun flag" — this is a UX control, not a technical one.

#tui → #cmd-injection [HIGH]

Location: src/tui/commands.ts:11 Annotation: @exposes #tui to #cmd-injection [high] -- "/annotate and /threat-report spawn child processes"

Validation: ✅ ACCURATE Same pattern as CLI — delegated to agents/launcher.ts. The TUI adds risk because it accepts freeform user input in an interactive session, making it easier to craft malicious commands.

Medium Severity Findings

#mcp → #data-exposure [MEDIUM]

Location: src/mcp/server.ts:34 Annotation: @exposes #mcp to #data-exposure [medium] -- "Resources expose full threat model to MCP clients"

Validation: ✅ ACCURATE MCP resources (guardlink://model, guardlink://definitions, guardlink://unmitigated) expose the complete threat model to any connected client. This is by design for AI agent integration, but represents information disclosure risk if:

  • MCP transport is compromised
  • Unauthorized clients connect to the stdio interface
  • Threat model contains sensitive architectural details

Low Severity Findings

All low-severity #data-exposure and #dos findings are accurately annotated and appropriately flagged with @audit annotations acknowledging the accepted risk.


Part 2 — STRIDE Threat Model

S — Spoofing

Finding S1: No Authentication on MCP Transport Severity: Medium Assets: #mcp Evidence: The MCP server uses stdio transport with no authentication mechanism. Any process that can connect to stdin/stdout can impersonate a legitimate client.

Annotation Gap: No @exposes #mcp to #spoofing annotation exists.

Attack Scenario:

  1. Attacker gains code execution on developer machine
  2. Attacker spawns process that connects to GuardLink MCP server
  3. Attacker issues tool calls to exfiltrate threat model or modify annotations

Mitigation: Consider adding MCP client authentication (capability tokens, process attestation) or documenting this as an accepted risk with @accepts.


T — Tampering

Finding T1: Annotation Tampering via Prompt Injection Severity: High Assets: #agent-launcher, #mcp Evidence: User prompts are concatenated into agent instructions (src/agents/prompts.ts:6). A malicious prompt could instruct the agent to:

  • Add false @mitigates annotations to hide vulnerabilities
  • Remove @exposes annotations from critical code
  • Inject malicious @shield blocks to hide code from analysis

Data Flow: UserPrompt → #agent-launcher → AgentProcess → SourceFiles

Annotation Accuracy: The @exposes #agent-launcher to #prompt-injection [high] correctly identifies this risk.

Mitigation:

  1. Implement annotation integrity checks (hash of annotation blocks)
  2. Require human approval for annotation changes via AI agents
  3. Add @audit trail for all AI-generated annotations

Finding T2: Config File Tampering Severity: Medium Assets: #workspace-config Evidence: src/workspace/types.ts:9 notes @threat Config_Tampering (#config-tamper) [medium] with mitigation via #yaml-validation.

Annotation Accuracy: ✅ Correctly annotated with mitigation.


R — Repudiation

Finding R1: No Audit Trail for AI-Generated Changes Severity: Medium Assets: #agent-launcher, #tui, #cli Evidence: When AI agents modify annotations via /annotate or guardlink annotate, there is no persistent audit log of:

  • What changes were made
  • Which agent made them
  • What prompt triggered the change

Annotation Gap: @audit annotations exist on assets, but no @flows document the audit trail path.

Recommendation: Add @flows #agent-launcher → AuditLog via appendFile with corresponding implementation.

Finding R2: Threat Report Attribution Severity: Low Assets: #llm-client Evidence: AI-generated threat reports are saved to .guardlink/threat-reports/ with timestamps, but the LLM model and configuration used are not always recorded in the report metadata.

Mitigation: Reports should include full provenance (model, provider, API version, prompt hash).


I — Information Disclosure

Finding I1: Full Threat Model Exposure to LLM Providers Severity: Medium Assets: #llm-client Evidence: src/analyze/index.ts:12 — @exposes #llm-client to #data-exposure [low] with @audit noting "Threat model data intentionally sent to LLM for analysis."

Concern: The annotation rates this as LOW severity, but the actual risk depends on:

  • Sensitivity of the codebase being analyzed
  • LLM provider data retention policies
  • Whether code snippets include secrets (the #key-redaction control may not catch all patterns)

Recommendation: Upgrade severity to MEDIUM and add configurable redaction patterns.

Finding I2: API Key Storage Severity: Medium Assets: #init Evidence: src/init/index.ts:12 — API keys written to .guardlink/config.json. The @audit notes ".gitignore entry added automatically."

Residual Risk:

  • .gitignore can be overwritten or removed
  • Backup tools may capture config files
  • IDE indexing may expose keys

Mitigation: Consider using system keychain or environment variables exclusively.

Finding I3: SARIF Export Reveals Architecture Severity: Low Assets: #sarif Evidence: src/analyzer/sarif.ts:15 — SARIF output includes file paths, line numbers, and vulnerability descriptions. This is intentional for CI/CD integration but could reveal:

  • Internal architecture to external parties
  • Security weaknesses if SARIF files are leaked

Annotation Accuracy: ✅ Correctly annotated with @audit acknowledging intentional disclosure.


D — Denial of Service

Finding D1: No Resource Limits on File Processing Severity: Low Assets: #suggest, #parser Evidence:

  • src/mcp/suggest.ts:16 — "Large files loaded into memory for pattern scanning"
  • No explicit file size limits in fast-glob configuration

Attack Scenario: Crafted repository with extremely large files or deep directory trees could exhaust memory.

Annotation Accuracy: ✅ Correctly annotated with @audit accepting the risk.

Mitigation: Add #resource-limits control with explicit file size caps (e.g., 10MB per file, 1000 files max).

Finding D2: Agent Spawn Timeout Severity: Low Assets: #agent-launcher Evidence: src/agents/launcher.ts:15 — "No timeout on foreground spawn; agent controls duration"

Annotation Accuracy: ✅ Correctly annotated. The @audit notes this is intentional for interactive sessions.


E — Elevation of Privilege

Finding E1: MCP Tool Call Privilege Escalation Severity: High Assets: #mcp Evidence: MCP tools execute with the same privileges as the GuardLink process. A compromised or malicious MCP client could:

  • Write files anywhere the process has access (guardlink_sarif, guardlink_report)
  • Execute git commands (guardlink_diff)
  • Spawn child processes (guardlink_annotate)

Data Flow: MCPClient → #mcp via tool_call → FileSystem via writeFile

Annotation Gap: No @exposes #mcp to #privilege-escalation annotation exists.

Mitigation:

  1. Add capability-based access control for MCP tools
  2. Implement path sandboxing for file writes
  3. Add @exposes #mcp to #privilege-escalation annotation

Finding E2: Agent Process Inherits Environment Severity: Medium Assets: #agent-launcher Evidence: EnvVars → #agent-launcher via process.env — spawned agents inherit the full environment, including API keys and sensitive configuration.

Mitigation: Implement environment filtering to pass only necessary variables to agents.


Priority Action Items

Critical (Immediate)

  1. Add prompt sanitization to src/agents/prompts.ts — escape or template user prompts before concatenation
  2. Document MCP security model — add @assumes annotation about trusted client requirement

High (This Sprint)

  1. Implement path sandboxing for MCP file write operations
  2. Add environment filtering in launchAgent() to prevent credential leakage
  3. Add @exposes annotations for spoofing and privilege escalation gaps

Medium (Next Sprint)

  1. Add audit logging for AI-generated annotation changes
  2. Implement file size limits in parser and suggest modules
  3. Upgrade #data-exposure severity for LLM client to MEDIUM

Low (Backlog)

  1. Consider system keychain for API key storage
  2. Add provenance metadata to threat reports

Annotation Coverage Summary

CategoryAnnotatedMissing
Assets160
Threats152 (spoofing, priv-esc)
Controls122 (audit-logging, env-filtering)
Data Flows68~5 (audit trails)
Boundaries90

Overall Coverage: 85% — Good annotation discipline with gaps in newer attack categories (prompt injection defense, AI agent trust boundaries).


Report generated by Claude Opus 4.5 using GuardLink STRIDE framework.

Write another:

guardlink threat-report strideguardlink threat-report dreadguardlink threat-report pastaguardlink threat-report attackerguardlink threat-report rapidguardlink threat-report general guardlink threat-report general --custom "focus on auth"