@clear-capabilities/agentic-security-scanner 0.144.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/bin/agentic-security.js +294 -3
  3. package/dist/113.index.js +11 -3
  4. package/dist/178.index.js +24 -6
  5. package/dist/271.index.js +165 -0
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +22 -0
  8. package/dist/444.index.js +11 -2
  9. package/dist/449.index.js +76 -12
  10. package/dist/526.index.js +11 -3
  11. package/dist/637.index.js +27 -5
  12. package/dist/970.index.js +65 -1
  13. package/dist/agentic-security.mjs +9 -9
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +14 -8
  16. package/src/compare.js +6 -1
  17. package/src/dataflow/CLAUDE.md +1 -1
  18. package/src/engine.js +488 -29
  19. package/src/fix/apply-fix-service.js +1 -0
  20. package/src/history-scan.js +22 -5
  21. package/src/ir/CLAUDE.md +1 -1
  22. package/src/lsp/server.js +49 -2
  23. package/src/mcp/tools.js +20 -0
  24. package/src/pipeline/assurance-mode.js +64 -1
  25. package/src/pipeline/finding-schema.js +8 -1
  26. package/src/posture/CLAUDE.md +121 -0
  27. package/src/posture/accuracy-scorecard.js +60 -0
  28. package/src/posture/artifact-registry.js +24 -0
  29. package/src/posture/auditor-walkthrough.js +116 -13
  30. package/src/posture/compliance-policy.js +12 -2
  31. package/src/posture/cross-repo-memory.js +7 -2
  32. package/src/posture/fix-history.js +25 -2
  33. package/src/posture/fix-verify.js +9 -1
  34. package/src/posture/fleet.js +0 -0
  35. package/src/posture/git-history.js +13 -5
  36. package/src/posture/material-change.js +21 -2
  37. package/src/posture/mttr.js +75 -12
  38. package/src/posture/pre-incident-archaeology.js +39 -7
  39. package/src/posture/privacy-framework.js +14 -0
  40. package/src/posture/provenance/ai-authorship.js +68 -0
  41. package/src/posture/provenance/branch-entry.js +80 -0
  42. package/src/posture/provenance/cache.js +143 -0
  43. package/src/posture/provenance/confidence.js +36 -0
  44. package/src/posture/provenance/coordinator.js +786 -0
  45. package/src/posture/provenance/dag-walk.js +249 -0
  46. package/src/posture/provenance/evidence-attribution.js +59 -0
  47. package/src/posture/provenance/git-evidence.js +310 -0
  48. package/src/posture/provenance/lifecycle.js +208 -0
  49. package/src/posture/provenance/missing-control-resolver.js +137 -0
  50. package/src/posture/provenance/origin-resolver.js +342 -0
  51. package/src/posture/provenance/predicate-replay.js +133 -0
  52. package/src/posture/provenance/providers/config.js +39 -0
  53. package/src/posture/provenance/providers/github.js +62 -0
  54. package/src/posture/provenance/providers/gitlab.js +58 -0
  55. package/src/posture/provenance/repo-lineage.js +74 -0
  56. package/src/posture/provenance/sca-origin.js +139 -0
  57. package/src/posture/provenance/schema.js +255 -0
  58. package/src/posture/provenance/transitive-sca.js +147 -0
  59. package/src/posture/provenance/validate.js +30 -0
  60. package/src/posture/provenance-evidence-bundle.js +144 -0
  61. package/src/posture/sbom-diff.js +15 -2
  62. package/src/posture/secret-history.js +10 -2
  63. package/src/posture/state-dir.js +38 -14
  64. package/src/posture/vuln-archaeology.js +8 -2
  65. package/src/pr-delta.js +25 -4
  66. package/src/report/index.js +197 -3
  67. package/src/runScan.js +34 -5
  68. package/src/sast/rate-limit.js +33 -3
  69. package/src/util/git-hardening.js +128 -0
@@ -12,8 +12,10 @@
12
12
  // introducingCommit: { sha, author, ts, message } | null,
13
13
  // }
14
14
  //
15
- // We invoke `git log` and `git show` via the shell. If the project is not a
16
- // git repository (no `.git` at root), we return `{ available: false }`.
15
+ // We invoke `git log` and `git show` via `execFileSync` (an argv array, NOT
16
+ // a shell) against `root` the SCANNED project's repository, not this
17
+ // project's own trusted checkout. If the project is not a git repository
18
+ // (no `.git` at root), we return `{ available: false }`.
17
19
  //
18
20
  // This is intentionally light: we do not re-run the SAST detector on every
19
21
  // historical revision. We use a simple substring-presence probe — does the
@@ -21,10 +23,30 @@
21
23
  // Sufficient for the common case and dramatically cheaper than full
22
24
  // re-scanning. Customers who want forensic-grade archaeology can re-run the
23
25
  // scanner against `git checkout`-ed historical revisions.
26
+ //
27
+ // Hardened + de-shelled per FR-PROV-024 / the second Finding Provenance PRD
28
+ // audit sweep (found missing here by a follow-up review that grepped for
29
+ // `child_process` usage beyond just `execFileSync('git'` call sites). Before
30
+ // this fix, `gitShow` built a SHELL command string
31
+ // (`` `git show ${sha}:"${file}"` ``) from `file`, which is a finding's
32
+ // repo-relative path — attacker-controlled in a hostile repo. Double quotes
33
+ // in `sh` do NOT suppress `$(...)`/backticks, so a repo containing a file
34
+ // literally named `a$(cmd).js` would execute `cmd` via command substitution
35
+ // the moment this ran. Switching to `execFileSync` with an argv array
36
+ // removes the shell entirely — there is no interpreter left to interpret
37
+ // `$(...)`, so this is not "quote it better," it closes the injection class
38
+ // outright. `sha` here always originates from THIS module's own
39
+ // `gitLogForFile` (`%H`, real git-produced hex), but is still validated
40
+ // against the same SHA_RE every other resolver in this codebase uses, on
41
+ // the same "never trust, always validate" convention as
42
+ // provenance/git-evidence.js.
24
43
 
25
- import { execSync } from 'node:child_process';
44
+ import { execFileSync } from 'node:child_process';
26
45
  import * as fs from 'node:fs';
27
46
  import * as path from 'node:path';
47
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
48
+
49
+ const SHA_RE = /^[0-9a-f]{4,40}$/i;
28
50
 
29
51
  function isGitRepo(root) {
30
52
  try {
@@ -34,9 +56,10 @@ function isGitRepo(root) {
34
56
 
35
57
  function gitLogForFile(root, file, limit = 50) {
36
58
  try {
37
- const out = execSync(
38
- `git log --pretty=format:%H%x1f%an%x1f%aI%x1f%s --max-count=${limit} -- "${file}"`,
39
- { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
59
+ const out = execFileSync(
60
+ 'git',
61
+ hardenGitArgs(['log', '--pretty=format:%H%x1f%an%x1f%aI%x1f%s', `--max-count=${limit}`, '--', file]),
62
+ { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
40
63
  );
41
64
  return out.split(/\n/).filter(Boolean).map(line => {
42
65
  const [sha, author, ts, message] = line.split('\x1f');
@@ -46,8 +69,17 @@ function gitLogForFile(root, file, limit = 50) {
46
69
  }
47
70
 
48
71
  function gitShow(root, sha, file) {
72
+ if (typeof sha !== 'string' || !SHA_RE.test(sha)) return null;
49
73
  try {
50
- return execSync(`git show ${sha}:"${file}"`, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
74
+ // `./` makes git resolve `file` relative to cwd (root) same convention
75
+ // as provenance/git-evidence.js's getBlobAtCommit, and load-bearing for
76
+ // the same reason: it also keeps a `file` that happens to start with
77
+ // `-` from being parsed as a flag once concatenated onto `sha:`.
78
+ // `--no-textconv`: this is the blob-cat form of `show` (`<sha>:<path>`,
79
+ // not a diff) — VERIFIED not reachable via a hostile textconv driver in
80
+ // current git (same as git-evidence.js's getBlobAtCommit), kept for
81
+ // defense-in-depth/uniformity.
82
+ return execFileSync('git', hardenGitArgs(['show', '--no-textconv', `${sha}:./${file}`]), { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
51
83
  } catch { return null; }
52
84
  }
53
85
 
@@ -37,6 +37,7 @@
37
37
  import { loadFramework, evaluateFramework } from './auditor-walkthrough.js';
38
38
  import { statePath, safeWriteState } from './state-dir.js';
39
39
  import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
40
+ import { PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
40
41
 
41
42
  export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
42
43
 
@@ -209,6 +210,19 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
209
210
  parser: 'COMPLIANCE',
210
211
  family: 'privacy-compliance',
211
212
  complianceControl: { framework: PRIVACY_FRAMEWORK_ID, id: c.id, codeTestable: c.codeTestable || 'no' },
213
+ controlRefs: r.controlRefs || [],
214
+ // PRD Section 8 REQUIRED DISCLAIMER. `derivedProvenance` carries an
215
+ // origin commit, author and confidence onto a COMPLIANCE claim, and
216
+ // `compliance --privacy --format json` prints this object straight to
217
+ // stdout -- a second human-facing surface for the same data the auditor
218
+ // walkthrough disclaims inline. Found by a review of the walkthrough
219
+ // fix, which had asserted renderWalkthrough was the only consumer. The
220
+ // disclaimer travels ON the record rather than beside it, because a
221
+ // JSON consumer can slice a single finding out of the array and the
222
+ // caveat has to survive that.
223
+ derivedProvenance: r.derivedProvenance
224
+ ? { ...r.derivedProvenance, disclaimer: PROVENANCE_COMPLIANCE_DISCLAIMER }
225
+ : null,
212
226
  });
213
227
  }
214
228
 
@@ -0,0 +1,68 @@
1
+ // AI-authorship verification hook (Finding Provenance PRD, M4 §4.3).
2
+ //
3
+ // No concrete external signed-commit-metadata standard exists yet to target
4
+ // (the spec's own words) — so this is an extensible REGISTRY a future
5
+ // verifier plugs into, not a hardcoded vendor integration. With nothing
6
+ // registered (today's real state, and likely for some time), every
7
+ // finding's aiAuthorship stays `unknown` — matching the PRD's explicit
8
+ // default: "Unknown unless signed, verifiable generation metadata exists."
9
+ //
10
+ // Scoped to SAST findingOrigin only (see this plan's own scope-correction
11
+ // note) — a transitive/direct SCA origin is a manifest edit, not source
12
+ // authorship in the sense this hook asks about.
13
+ //
14
+ // KNOWN LIMITATION: `commitMeta` (git-evidence.js) is the only shape any
15
+ // verifier registered here ever receives — `{commit, authorName,
16
+ // authorEmail, authorDate, committerDate, summary}`, where `summary` is
17
+ // `%s` (the subject line only), NOT the full commit message body (`%B`).
18
+ // A future verifier that needs to inspect a trailer buried in the body
19
+ // (e.g. a "Co-Authored-By" line that isn't also the subject) cannot see it
20
+ // through this shape today. `commitMeta` is a widely-used shared primitive
21
+ // elsewhere in this directory, so widening it is a bigger, more careful
22
+ // change than this task's budget — deliberately left as a known limitation
23
+ // rather than silently expanding it here.
24
+
25
+ const _verifiers = new Map();
26
+
27
+ /**
28
+ * Register a verifier. `verifyFn(commitMeta) -> {status, verifier} | null`.
29
+ * A later registration under the SAME name replaces the earlier one (a
30
+ * re-register, not a stack) — matches this codebase's own precedent
31
+ * elsewhere (verification-separation.js's "one verifier, one vote per lens,
32
+ * a re-vote replaces rather than stuffs").
33
+ */
34
+ export function registerAIAuthorshipVerifier(name, verifyFn) {
35
+ if (typeof name !== 'string' || !name || typeof verifyFn !== 'function') return false;
36
+ _verifiers.set(name, verifyFn);
37
+ return true;
38
+ }
39
+
40
+ /** Test/reset helper — never called from production code. */
41
+ export function _clearAIAuthorshipVerifiers() {
42
+ _verifiers.clear();
43
+ }
44
+
45
+ /**
46
+ * Consults every registered verifier in registration order; the first one
47
+ * to return a non-null result wins (first-registered-first-consulted, not
48
+ * "last wins" — an explicit choice: a more specific verifier should be
49
+ * registered first if precedence matters, rather than this function
50
+ * guessing which of several opinions to prefer). Defaults to
51
+ * {status:'unknown', verifier:null} with nothing registered or every
52
+ * verifier declining to answer.
53
+ *
54
+ * NEVER THROWS: a verifier that throws is treated as "no opinion", exactly
55
+ * like predicate-replay.js and missing-control-resolver.js already treat a
56
+ * throwing caller-supplied function elsewhere in this directory.
57
+ */
58
+ export function resolveAIAuthorship(commitMeta) {
59
+ if (!commitMeta) return { status: 'unknown', verifier: null };
60
+ for (const [name, verifyFn] of _verifiers) {
61
+ let result;
62
+ try { result = verifyFn(commitMeta); } catch { continue; }
63
+ if (result && typeof result === 'object' && result.status) {
64
+ return { status: result.status, verifier: result.verifier || name };
65
+ }
66
+ }
67
+ return { status: 'unknown', verifier: null };
68
+ }
@@ -0,0 +1,80 @@
1
+ // Branch-introduction resolver (Finding Provenance PRD, Scenario D).
2
+ //
3
+ // Given a finding's already-resolved origin commit, answers "where did this
4
+ // land on the target branch" — either the origin commit itself (it was
5
+ // committed directly on the branch) or the merge commit that brought it in
6
+ // from a feature branch (`git merge --no-ff`). This is a second, distinct
7
+ // question from origin-resolver.js's "which commit introduced this": a
8
+ // commit can be the true origin and still never have existed on the branch
9
+ // people actually deploy from until a later merge folded it in.
10
+ //
11
+ // Same input-validation posture as git-evidence.js, and for the same reason:
12
+ // `originCommit`/`targetRef` are attacker-influenceable-shaped strings
13
+ // (finding data, CLI args) flowing into `execFileSync('git', [...])`. A
14
+ // value like `--output=/tmp/pwned` must never reach argv as a bare token —
15
+ // see git-evidence.js's SHA_RE/SINCE_RE comments for the incident this
16
+ // guards against (a nominally read-only git call gaining write side effects
17
+ // via flag injection).
18
+
19
+ import * as cp from 'node:child_process';
20
+ import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
21
+
22
+ const GIT_TIMEOUT_MS = 2000;
23
+
24
+ // Same hostile-repo hardening as git-evidence.js's `_run`, and for the same
25
+ // reason (FR-PROV-024 / second audit) — this module reimplements its own
26
+ // `_run` rather than importing git-evidence.js's, but that must not mean
27
+ // skipping the config/env hardening every other resolver in this directory
28
+ // gets. `--no-textconv` isn't blanket-applied here: this module's own
29
+ // `_run` calls are `rev-parse`/`merge-base`/`log --format=%H` (no diff/blob
30
+ // CONTENT rendered), so there's no textconv surface to close — see
31
+ // git-evidence.js for the calls where `--no-textconv` is load-bearing.
32
+ function _run(scanRoot, args) {
33
+ try {
34
+ const stdout = cp.execFileSync('git', hardenGitArgs(args), {
35
+ cwd: scanRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS,
36
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024,
37
+ env: hardenGitEnv(),
38
+ });
39
+ return { ok: true, stdout };
40
+ } catch (e) {
41
+ return { ok: false, stdout: '' };
42
+ }
43
+ }
44
+
45
+ // Git SHAs (full or abbreviated) are always lowercase/uppercase hex, 4-40
46
+ // chars. Rejecting anything else closes off flag injection.
47
+ const SHA_RE = /^[0-9a-f]{4,40}$/i;
48
+ function _isSha(sha) {
49
+ return typeof sha === 'string' && SHA_RE.test(sha);
50
+ }
51
+
52
+ // `targetRef` is used as a revision (both standalone and as the right side
53
+ // of a `originCommit..targetRef` range), so it must look like a ref/branch/
54
+ // sha — never start with `-` (which git would parse as an option) and
55
+ // contain only characters refs can hold.
56
+ const REF_RE = /^[A-Za-z0-9._/-]+$/;
57
+ function _isSafeRef(ref) {
58
+ return typeof ref === 'string' && ref.length > 0 && !ref.startsWith('-') && REF_RE.test(ref);
59
+ }
60
+
61
+ function _currentBranchName(scanRoot, targetRef) {
62
+ const r = _run(scanRoot, ['rev-parse', '--abbrev-ref', targetRef]);
63
+ return r.ok ? r.stdout.trim() : targetRef;
64
+ }
65
+
66
+ export function resolveBranchEntry(scanRoot, originCommit, targetRef = 'HEAD') {
67
+ if (!_isSha(originCommit)) return null;
68
+ if (!_isSafeRef(targetRef)) return null;
69
+
70
+ const reachable = _run(scanRoot, ['merge-base', '--is-ancestor', originCommit, targetRef]);
71
+ if (!reachable.ok) return null;
72
+
73
+ const branchName = _currentBranchName(scanRoot, targetRef);
74
+ const r = _run(scanRoot, ['log', '--merges', '--ancestry-path', '--reverse', '--format=%H', `${originCommit}..${targetRef}`]);
75
+ if (r.ok && r.stdout.trim()) {
76
+ const firstMerge = r.stdout.trim().split('\n')[0];
77
+ return { commit: firstMerge, ref: `refs/heads/${branchName}`, relationship: 'merge' };
78
+ }
79
+ return { commit: originCommit, ref: `refs/heads/${branchName}`, relationship: 'direct' };
80
+ }
@@ -0,0 +1,143 @@
1
+ // Content-addressed provenance cache.
2
+ //
3
+ // Paths and writes both go through posture/state-dir.js rather than joining
4
+ // the state directory name by hand. That seam is what enforces the two
5
+ // invariants this module would otherwise each have to remember: it refuses to
6
+ // create state outside a project root, and it honours the read-only scan
7
+ // switch (`--no-state` / AGENTIC_SECURITY_NO_STATE), so scanning somebody
8
+ // else's tree leaves it byte-identical. See test/no-stray-state.test.js — a
9
+ // cache that quietly writes during a read-only scan is exactly the litter that
10
+ // guard exists to prevent.
11
+ //
12
+ // PRIVACY AT REST (second independent Finding Provenance PRD audit): this
13
+ // cache deliberately stores the FULL provenance record — including raw
14
+ // `authorEmail` — not a pre-redacted one. That is a considered choice, not an
15
+ // oversight: `redactFindingProvenance` is applied per output boundary
16
+ // (report/index.js, mcp/tools.js), and different callers in the SAME scan
17
+ // legitimately want different presentations of the SAME cached record — one
18
+ // reader wants the default-redacted view, another passes
19
+ // `--include-author-email`, a third wants `--pseudonymize-authors`. If the
20
+ // cache stored an already-redacted record, whichever policy was in effect at
21
+ // WRITE time would win for every reader forever, silently breaking that
22
+ // per-call flexibility. So redaction stays a read-time/output-time concern,
23
+ // exactly as documented in posture/CLAUDE.md's "Privacy" section, and this
24
+ // cache is accepted as an at-rest store of raw personal data.
25
+ //
26
+ // The mitigation applied here is a permissions floor, not encryption: every
27
+ // write tightens the `provenance-cache/` directory to 0700 and the entry file
28
+ // to 0600 (same posture this project already uses for the per-install HMAC
29
+ // key at integrity.js's `scan-key`, mode 0600 / 0700 parent). That defeats
30
+ // "any local user/process can read this," which is the realistic at-rest
31
+ // threat for a developer machine or CI runner; it does NOT defeat an attacker
32
+ // with root or the operating-system user's own privileges — no local file
33
+ // permission ever does. Encryption-at-rest with a per-install key (the same
34
+ // pattern as `scan-key`) was considered and rejected for this task's scope:
35
+ // unlike the HMAC key, which only ever needs to reproduce a symmetric digest,
36
+ // a cache that must serve back the exact original record on every read would
37
+ // need the plaintext decrypted on every `cacheGet`, which does not raise the
38
+ // bar much over a permissions floor while adding real complexity (key
39
+ // rotation, corrupt-ciphertext handling) for a cache that is disposable and
40
+ // content-addressed to begin with. If that tradeoff is revisited, encrypting
41
+ // only `findingOrigin.authorEmail` (not the whole record) would preserve this
42
+ // module's byte-identical round-trip property, which is asserted by this
43
+ // file's own tests.
44
+
45
+ import * as fs from 'node:fs';
46
+ import * as path from 'node:path';
47
+ import * as crypto from 'node:crypto';
48
+ import { statePath, safeWriteState } from '../state-dir.js';
49
+ import { FINDING_PROVENANCE_SCHEMA_VERSION } from './schema.js';
50
+
51
+ // Permission floor for the cache directory and every entry inside it — see
52
+ // the module header. Applied on every write, not just at directory creation.
53
+ //
54
+ // Precisely what that does and does not retrofit, because the distinction
55
+ // matters and an earlier version of this comment overstated it: ANY write to
56
+ // this directory retightens the DIRECTORY to 0700, including a write for some
57
+ // unrelated key. An individual ENTRY's own mode is only rewritten when that
58
+ // exact content-addressed key is written again — which a cache hit
59
+ // short-circuits, so in practice a pre-existing 0644 entry keeps that mode.
60
+ // It is still unreachable by another local user, because Unix requires
61
+ // traversal (x) permission on every parent directory to open a file by path,
62
+ // and the 0700 directory denies exactly that. The directory mode is what
63
+ // carries the protection; the file mode is defense in depth.
64
+ const CACHE_DIR_MODE = 0o700;
65
+ const CACHE_FILE_MODE = 0o600;
66
+
67
+ // Its own top-level `.agentic-security/provenance-cache/` directory, NOT
68
+ // nested under `provenance/` (where it lived through M0-M4) — the artifact
69
+ // registry (posture/artifact-registry.js) can only apply retention per
70
+ // TOP-LEVEL directory name, and this cache (pure HEAD-keyed memo, safely
71
+ // regenerable) needs a TTL that the provenance/ lifecycle ledger (permanent
72
+ // history) must never get. See PRD Section 8 / artifact-registry.js's
73
+ // 'provenance-cache' entry. Single helper so the read and write paths can
74
+ // never drift apart.
75
+ function keyPath(scanRoot, key) {
76
+ const hash = crypto.createHash('sha256').update(key).digest('hex');
77
+ return statePath(scanRoot, 'provenance-cache', hash + '.json');
78
+ }
79
+
80
+ /**
81
+ * The schema version is part of the key, and is added HERE rather than by the
82
+ * caller so that no caller can forget it.
83
+ *
84
+ * `validate.js` rejects a provenance object stamped with a version this build
85
+ * does not understand — but a cache hit never reached that check: `cacheGet`
86
+ * returns the parsed object as-is. With the version outside the key, entries
87
+ * written by an older schema stayed live key hits after a version bump and
88
+ * flowed straight through, defeating the exact scenario the version field was
89
+ * added to guard. Including it means a bump silently misses every stale entry
90
+ * instead, which is the correct outcome: they are recomputed, not trusted.
91
+ *
92
+ * `lineageKey` (M4 §4.2 final-review fix) covers the resolved
93
+ * `.agentic-security/repo-lineage.json` cross-repo link the same way
94
+ * `historyBoundary` already covers `--provenance-since`: a cross-repo
95
+ * `partial` result IS cacheable, so without this the cache key had no field
96
+ * reflecting which (if any) lineage link produced it. Adding, removing, or
97
+ * repointing the declaration at the same HEAD would then keep serving a
98
+ * stale pre-lineage or a stale cross-repo answer. Callers pass the resolved
99
+ * link's own `${path}@${atCommit}`, or the literal `'none'` when
100
+ * `loadRepoLineage` returns nothing — never omit it in a way that collapses
101
+ * both cases to the same empty string the other fields default to.
102
+ */
103
+ export function makeCacheKey({ repoHead, stableId, detectorVersion, historyBoundary, mode, lineageKey }) {
104
+ return [
105
+ FINDING_PROVENANCE_SCHEMA_VERSION,
106
+ repoHead || '', stableId || '', detectorVersion || '', historyBoundary || '', mode || '', lineageKey || 'none',
107
+ ].join('|');
108
+ }
109
+
110
+ export function cacheGet(scanRoot, key) {
111
+ // Read directly and let the catch handle "missing" — an explicit
112
+ // existsSync() check first is a check-then-use race for no benefit, since
113
+ // the catch already covers every failure mode a stale check would too.
114
+ try {
115
+ return JSON.parse(fs.readFileSync(keyPath(scanRoot, key), 'utf8'));
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ export function cacheSet(scanRoot, key, value) {
122
+ try {
123
+ // safeWriteState creates the directory, applies the project-root check and
124
+ // returns false (never throws) when the read-only switch is on. A refused
125
+ // write is a cache miss next time, which is correct behaviour, not an error.
126
+ // category:'provenance-cache' lets lsp/server.js keep THIS write alive
127
+ // while every other state write stays suppressed on every save — see
128
+ // state-dir.js's withStateWritesDisabled.
129
+ const fp = keyPath(scanRoot, key);
130
+ const wrote = safeWriteState(fp, JSON.stringify(value), { category: 'provenance-cache' });
131
+ if (wrote) {
132
+ // Permission floor (see module header) — best-effort, applied AFTER a
133
+ // successful write so a chmod failure (e.g. an unsupported filesystem)
134
+ // never turns a real cache write into a reported failure.
135
+ try {
136
+ fs.chmodSync(path.dirname(fp), CACHE_DIR_MODE);
137
+ fs.chmodSync(fp, CACHE_FILE_MODE);
138
+ } catch { /* best-effort — see above */ }
139
+ }
140
+ } catch {
141
+ // best-effort — cache failures must never fail a scan
142
+ }
143
+ }
@@ -0,0 +1,36 @@
1
+ import { CONFIDENCE_LEVEL } from './schema.js';
2
+
3
+ // `renameAmbiguous`: the one production caller (coordinator.js, on the
4
+ // `status:'complete'` path) never passes this true today — see the comment
5
+ // there for why a genuine rename-ambiguous case cannot reach `complete`
6
+ // under the current resolver architecture, so `reasons.push('rename_ambiguous')`
7
+ // below is currently unreachable from real scans. Left wired rather than
8
+ // removed: a caller that DOES have a real rename signal (e.g. a future
9
+ // `complete`-status path that resolves via a rename-following replay) can
10
+ // pass `renameAmbiguous: true` without this function changing at all, and
11
+ // `test/posture/provenance-confidence.test.js` exercises the branch directly.
12
+ export function assessConfidence({
13
+ parentBoundaryVerified = false, historyComplete = false, detectorCompatible = true,
14
+ renameAmbiguous = false, shallow = false, budgetExhausted = false,
15
+ } = {}) {
16
+ const reasons = [];
17
+ if (budgetExhausted) return { level: CONFIDENCE_LEVEL.UNKNOWN, score: 0, reasons: ['budget_exhausted'] };
18
+
19
+ if (parentBoundaryVerified && historyComplete && detectorCompatible && !renameAmbiguous) {
20
+ reasons.push('parent_absence_verified', 'complete_history');
21
+ return { level: CONFIDENCE_LEVEL.HIGH, score: 0.95, reasons };
22
+ }
23
+ if (detectorCompatible && (!historyComplete || !parentBoundaryVerified) && !shallow) {
24
+ if (!historyComplete) reasons.push('partial_history');
25
+ if (!parentBoundaryVerified) reasons.push('no_parent_to_test');
26
+ return { level: CONFIDENCE_LEVEL.MEDIUM, score: 0.65, reasons };
27
+ }
28
+ if (shallow || renameAmbiguous || !detectorCompatible) {
29
+ if (shallow) reasons.push('shallow_history');
30
+ if (renameAmbiguous) reasons.push('rename_ambiguous');
31
+ if (!detectorCompatible) reasons.push('detector_incompatible');
32
+ return { level: CONFIDENCE_LEVEL.LOW, score: 0.35, reasons };
33
+ }
34
+ reasons.push('no_defensible_origin');
35
+ return { level: CONFIDENCE_LEVEL.UNKNOWN, score: 0, reasons };
36
+ }