@clear-capabilities/agentic-security-scanner 0.159.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/CLAUDE.md CHANGED
@@ -13,7 +13,7 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
13
13
  | `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
14
14
  | `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
15
15
  | `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
16
- | `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
16
+ | `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup). With `plan_digest` it applies a MULTI-FILE NixOS option fix (see "Multi-file apply_fix" below): all files or none, one backup per file, one history group |
17
17
  | `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
18
18
  | `read_scratchpad` | ✓ | paginated read of scratchpad files |
19
19
  | `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
@@ -42,6 +42,8 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
42
42
  | Path-escape refusal | `tools.js::_confine` lexical check before any fs call |
43
43
  | Reserved-write paths | `tools.js::RESERVED_WRITE_*` — `.git/`, `.github/`, `.gitlab/`, `.circleci/`, `.buildkite/`, `.agentic-security/`, `node_modules/`, `.terraform/`, `.aws/`, `k8s/`, manifest basenames, `*.tf`, `docker-compose.yml` |
44
44
  | HMAC integrity on findings | `posture/integrity.js` — per-install random key at `$XDG_CONFIG_HOME/agentic-security/scan-key`. **Not** hostname-derived. |
45
+ | Multi-file plan binding | `tools.js::_multiFilePlanDigest` / `_applyMultiFilePlan`: `apply_fix` never accepts file content for a multi-file plan; it recomputes the plan from the signed finding + live tree and compares a digest (finding id, stableId, every path, SHA-256 of every pre/post image) with the caller's `plan_digest`. The lifecycle's `preWrite` hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written |
46
+ | Multi-file confinement | `tools.js::_checkMultiFileTargets`: EVERY file of the plan gets clean root-relative form (no absolute, `..`, `.`, backslash, NUL, empty segment), `_confine` (no symlink leaf, no escape), a no-symlinked-parent check (realpath must equal the lexical path), the reserved-write list, existence, no duplicates, at most 8 files. One bad file refuses the whole plan |
45
47
  | Patches pass through unredacted | `tools.js` synthesize_fix / apply_fix — premortem-derived. Patches are not findings; redacting them silently corrupts valid fixes. |
46
48
  | Secret redaction on findings | `redact.js` — applied to snippet/description/title/vuln/remediation/trace |
47
49
  | Audit log | `audit.js` — NDJSON, hash-chained, at `.agentic-security/mcp-audit.log`. Set `$AGENTIC_SECURITY_AUDIT_WEBHOOK=<url>` to also fire-and-forget POST every entry to a remote witness — closes the full-file-rewrite blind spot. Failures land in `mcp-audit.remote-errors.log` and never block a tool call. |
@@ -70,5 +72,34 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
70
72
 
71
73
  `scan_diff`, `verify_fix` and `synthesize_fix` add the import closure of a Haskell or Nix file and the manifests as context
72
74
  (`language/context.js` `withLanguageContext`, bounded in files and bytes, read-only) and still report only the files asked
73
- about. The tool that previews a fix for these files uses the language fixers; `apply_fix` is unchanged (its generic confined-write
74
- path), and the language-aware apply with backup and `undo` is the CLI's `fix --apply`.
75
+ about. The tool that previews a fix for these files uses the language fixers. A single-file `apply_fix` is unchanged (its generic
76
+ confined-write path); the language-aware apply with backup and `undo` for everything else is the CLI's `fix --apply`.
77
+
78
+ ## Multi-file `apply_fix` (NixOS option fixes, `plan_digest`)
79
+
80
+ A NixOS option is judged on the effective configuration, so one fix can edit several files (every definition at the winning
81
+ priority). Flow: `synthesize_fix` returns `languageFix.multiFile` (the files), `languageFix.edits` (per file: path + SHA-256 of
82
+ the before and after image, **no content**) and `languageFix.planDigest` after running the verification gates read-only. The
83
+ caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
84
+ high-impact change).
85
+
86
+ Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
87
+ re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
88
+ exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
89
+ `stale:true` with no hint of the expected value. Order of checks: `confirm:true` -> last-scan HMAC -> finding exists -> not shadow
90
+ -> digest format -> Nix finding with stableId -> plan exists and has 2+ files -> digest equal -> every file confined and not
91
+ reserved -> high-impact approval (shared `_highImpactApprovalRefusal`, same code as the `patch` branch) -> lifecycle
92
+ (`validateNixFix`, apply): syntax, rescan (original gone, nothing new at medium+), effective-value gate, then `preWrite`, then
93
+ `writeManyWithBackup` (checks every file unchanged since planning and backs every file up before the first write). History is
94
+ one `languageGroupId` group: `undo` / `undoLast` revert all files or none.
95
+
96
+ Results are non-leaky: absolute paths are scrubbed to `<root>` and passed through `redactString`. A failed write is reported as
97
+ `applied:false` with `rolledBack` and `rollbackIncomplete` (+ `restoreManually` file names) honestly; an incomplete rollback is
98
+ never reported as success.
99
+
100
+ What this deliberately does NOT do: no caller-supplied multi-file `patch` (would reopen the plan-binding problem), no Haskell
101
+ multi-file plans (the Haskell planner produces none), no `flake.lock` relock plans (`.lock` is on the reserved-write list and
102
+ the relock path is not a finding fix), no fix-attempt-budget enforcement (the language lifecycle records attempts but, as in the
103
+ CLI, does not enforce a cap), and no `fixMeta` honesty gate (that gate judges a caller's residual/verdict claims, which this
104
+ path does not take). The `preWrite` digest re-check is defence in depth over a deterministic planner and has no independent
105
+ test that fails when it is removed (the earlier digest comparison is the pinned control).
package/src/mcp/tools.js CHANGED
@@ -349,7 +349,7 @@ function _maybeOffload(sessionRoot, toolName, items) {
349
349
  //
350
350
  // Exported under the `_internals` convention this codebase already uses
351
351
  // (see posture/poc-inprocess.js). Not part of the MCP tool surface.
352
- export const _internals = { _confine, isReservedWrite: _isReservedWritePath };
352
+ export const _internals = { _confine, isReservedWrite: _isReservedWritePath, checkMultiFileTargets: _checkMultiFileTargets, multiFilePlanDigest: _multiFilePlanDigest };
353
353
 
354
354
  export const scan_diff = {
355
355
  name: 'scan_diff',
@@ -603,10 +603,153 @@ export const explain_finding = {
603
603
  },
604
604
  };
605
605
 
606
+ // FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
607
+ // shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
608
+ // when the change is not high-impact or is properly approved.
609
+ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
610
+ if (!materialClassification.highImpactCategories.length) return null;
611
+ const cats = materialClassification.highImpactCategories.join(', ');
612
+ const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
613
+ const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
614
+ typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
615
+ typeof approval.reason === 'string' && approval.reason.trim().length > 0);
616
+ if (!hasApprovalEvidence) {
617
+ return {
618
+ _meta: META, applied: false,
619
+ reason: `high-impact change (${cats}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
620
+ materialClassification,
621
+ };
622
+ }
623
+ const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
624
+ const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
625
+ const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
626
+ if (!identityCheck.verified) {
627
+ return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${identityCheck.reason}`, materialClassification };
628
+ }
629
+ // FR-1003: separation-of-duties, same no-op-unless-configured gate as apply-fix-service.js's own copy; see approver-registry.js.
630
+ const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
631
+ if (!sodCheck.ok) {
632
+ return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${sodCheck.reason}`, materialClassification };
633
+ }
634
+ return null;
635
+ }
636
+
637
+ // ─── Multi-file plans (apply_fix with plan_digest) ───────────────────────────
638
+ // A NixOS option fix can edit several files at once (every definition at the winning priority). apply_fix never accepts file
639
+ // contents for this: it RECOMPUTES the plan from the signed finding and the live tree, and writes only if that plan's digest is
640
+ // the one synthesize_fix returned. The digest binds the finding, every file path, the exact pre-image and the exact post-image
641
+ // of each edit, so a changed tree, a changed finding, or an altered digest all refuse. The write itself is the language
642
+ // lifecycle's all-or-nothing writeManyWithBackup (one backup per file, one history group, honest rollback).
643
+ const MULTI_FILE_MAX = 8;
644
+ const _sha256 = (t) => crypto.createHash('sha256').update(String(t), 'utf8').digest('hex');
645
+ function _multiFilePlanDigest(finding, edits) {
646
+ const canon = edits.map((e) => ({ file: String(e.file), before: _sha256(e.before), after: _sha256(e.after) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
647
+ return _sha256(JSON.stringify({ v: 'mf-plan/1', id: finding.id || null, stableId: finding.stableId || null, edits: canon }));
648
+ }
649
+ // Every file of a multi-file plan gets the SAME checks a single-file apply_fix target gets: root-relative lexical form,
650
+ // confinement (no symlink leaf, no symlinked parent, no escape), reserved-write list, and existence. Returns {ok, reason, abs}.
651
+ function _checkMultiFileTargets(sessionRoot, edits) {
652
+ if (!Array.isArray(edits) || edits.length < 2) return { ok: false, reason: 'a multi-file plan needs at least two edits' };
653
+ if (edits.length > MULTI_FILE_MAX) return { ok: false, reason: `a multi-file plan may edit at most ${MULTI_FILE_MAX} files` };
654
+ const rootReal = fs.realpathSync(path.resolve(sessionRoot));
655
+ const seen = new Set();
656
+ const abs = {};
657
+ for (const e of edits) {
658
+ const rel = e && e.file;
659
+ if (typeof rel !== 'string' || !rel || rel.includes('\0') || rel.includes('\\') || path.isAbsolute(rel) || rel.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) {
660
+ return { ok: false, reason: `path-escape refused: plan file "${path.isAbsolute(String(rel)) ? '(absolute path)' : String(rel).slice(0, 120)}" is not a clean root-relative path` };
661
+ }
662
+ if (seen.has(rel)) return { ok: false, reason: `the same file appears twice in the plan: ${rel}` };
663
+ seen.add(rel);
664
+ let real;
665
+ try { real = _confine(sessionRoot, rel, 'plan file'); }
666
+ catch (err) { return { ok: false, reason: `path-escape refused: ${String(err.message).split(rootReal).join('<root>')}` }; }
667
+ if (path.relative(rootReal, real).split(path.sep).join('/') !== rel) return { ok: false, reason: `path-escape refused: plan file "${rel}" resolves through a symbolic link` };
668
+ if (_isReservedWritePath(sessionRoot, real)) return { ok: false, reason: `reserved path refused: ${rel}` };
669
+ if (!fs.existsSync(real)) return { ok: false, reason: `plan file not found: ${rel}` };
670
+ abs[rel] = real;
671
+ }
672
+ return { ok: true, abs };
673
+ }
674
+ const _scrubRoot = (sessionRoot, text) => {
675
+ let out = String(text);
676
+ const roots = new Set([path.resolve(sessionRoot)]);
677
+ try { roots.add(fs.realpathSync(path.resolve(sessionRoot))); } catch { /* root vanished: the plain path is still scrubbed */ }
678
+ for (const r of roots) out = out.split(r).join('<root>');
679
+ return redactString(out);
680
+ };
681
+ const _gateSummary = (g) => (g ? {
682
+ path: g.path ? g.path.ok : undefined, syntax: g.syntax ? g.syntax.ok : undefined,
683
+ rescan: g.rescan ? { ok: g.rescan.ok, originalGone: g.rescan.originalGone, newMediumOrHigher: g.rescan.newMediumOrHigher } : undefined,
684
+ effective: g.effective ? { ok: g.effective.ok, ran: g.effective.ran === true, detail: g.effective.detail } : undefined,
685
+ compile: g.compile ? { ran: g.compile.ran === true, ok: g.compile.ok } : undefined,
686
+ } : null);
687
+
688
+ async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status }) {
689
+ const refuse = (reason, extra = {}) => ({ _meta: META, applied: false, multiFile: true, reason, ...extra });
690
+ if (typeof planDigest !== 'string' || !/^[0-9a-f]{64}$/.test(planDigest)) return refuse('plan_digest must be the 64-character hex digest returned by synthesize_fix');
691
+ if (!f.stableId) return refuse('finding has no stableId, so a plan cannot be verified against it');
692
+ const lc = await import('../language/context.js');
693
+ if (lc.languageOfFinding(f) !== 'nix') return refuse('multi-file plans are produced for NixOS findings only');
694
+ const nf = await import('../language/nix-fix.js');
695
+ const proj = lc.loadLanguageProject(ctx.sessionRoot);
696
+ const files = proj.files;
697
+ // The plan is derived here, from the signed finding and the live tree. Nothing the caller sends can supply file content.
698
+ const plan = nf.planNixFix(f, files);
699
+ if (!plan || plan.ok === false) return refuse(`no applicable plan: ${_scrubRoot(ctx.sessionRoot, (plan && plan.reason) || 'no deterministic fix')}`);
700
+ if (!Array.isArray(plan.edits) || plan.edits.length < 2) return refuse('this finding has a single-file plan; use apply_fix without plan_digest');
701
+ if (_multiFilePlanDigest(f, plan.edits) !== planDigest) {
702
+ return refuse('plan_digest does not match the plan computed now for this finding: the project files or the finding changed since synthesize_fix, or the digest was altered. Run synthesize_fix again and review the new plan.', { stale: true });
703
+ }
704
+ const targets = _checkMultiFileTargets(ctx.sessionRoot, plan.edits);
705
+ if (!targets.ok) return refuse(targets.reason);
706
+ const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
707
+ if (!dryRun) {
708
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
709
+ if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
710
+ }
711
+ // The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
712
+ // and the pre-write hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written.
713
+ const preWrite = (verifiedPlan) => {
714
+ const vedits = Array.isArray(verifiedPlan.edits) ? verifiedPlan.edits : [];
715
+ if (_multiFilePlanDigest(f, vedits) !== planDigest) return { ok: false, detail: 'the verified plan differs from the previewed plan' };
716
+ const again = _checkMultiFileTargets(ctx.sessionRoot, vedits);
717
+ return again.ok ? { ok: true } : { ok: false, detail: again.reason };
718
+ };
719
+ let res;
720
+ try { res = await nf.validateNixFix(f, { files, apply: !dryRun, root: ctx.sessionRoot, preWrite }); }
721
+ catch (e) { return refuse(`plan verification failed: ${_scrubRoot(ctx.sessionRoot, e.message)}`); }
722
+ const gates = _gateSummary(res.gates);
723
+ const touched = plan.edits.map((e) => e.file);
724
+ if (res.status === 'verified' && dryRun) {
725
+ return { _meta: META, applied: false, dryRun: true, verified: true, multiFile: true, files: touched, planDigest, gates, materialClassification: classification, diff: res.preview };
726
+ }
727
+ if (res.status === 'applied') {
728
+ let acceptance = null;
729
+ try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
730
+ const hist = Array.isArray(res.history) ? res.history : [];
731
+ const allRecorded = hist.length === touched.length && hist.every(Boolean);
732
+ return {
733
+ _meta: META, applied: true, verified: true, multiFile: true, files: touched, planDigest,
734
+ groupId: res.backup && res.backup.id, historyIds: hist.map((h) => (h ? h.id : null)),
735
+ ...(allRecorded ? {} : { warning: 'the files were written and backed up, but the fix history could not record every entry; restore with the group backup under .agentic-security/fix-backups' }),
736
+ gates, integrity: status, acceptance, materialClassification: classification,
737
+ };
738
+ }
739
+ // Blocked or failed: nothing is left changed unless rollbackIncomplete says otherwise.
740
+ const incomplete = res.rolledBack === false;
741
+ return {
742
+ _meta: META, applied: false, multiFile: true, files: touched,
743
+ reason: _scrubRoot(ctx.sessionRoot, res.reason || `plan not applied (${res.status})`),
744
+ gates,
745
+ ...(res.rolledBack !== undefined ? { rolledBack: res.rolledBack === true, rollbackIncomplete: incomplete, ...(incomplete ? { restoreManually: (res.rollbackFailed || []).map(String) } : {}) } : {}),
746
+ };
747
+ }
748
+
606
749
  // ─── apply_fix ───────────────────────────────────────────────────────────────
607
750
  export const apply_fix = {
608
751
  name: 'apply_fix',
609
- description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
752
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification. MULTI-FILE NixOS FIX: when synthesize_fix returns `languageFix.planDigest` (an option defined in several files, edited together), pass it back as `plan_digest`. apply_fix then RECOMPUTES the plan itself from the signed finding and the live files, refuses unless its digest matches (so a changed tree, a changed finding or an altered digest all refuse; you never send file content), applies the same confinement, reserved-path, shadow and high-impact-approval checks to EVERY file, runs the verification gates (syntax, rescan with no new finding, the option resolves to the intended value on the patched configuration) before writing, and writes all files or none with one backup per file and one history group that `undo` reverts together. A rollback that could not complete is reported as `rollbackIncomplete` with the files to restore. `plan_digest` cannot be combined with `patch`.',
610
753
  inputSchema: {
611
754
  type: 'object',
612
755
  additionalProperties: false,
@@ -614,6 +757,7 @@ export const apply_fix = {
614
757
  finding_id: { type: 'string', minLength: 1, maxLength: 256 },
615
758
  confirm: { type: 'boolean' },
616
759
  dry_run: { type: 'boolean' },
760
+ plan_digest: { type: 'string', minLength: 64, maxLength: 64 },
617
761
  patch: {
618
762
  type: 'object',
619
763
  additionalProperties: { type: 'string', maxLength: 500_000 },
@@ -673,7 +817,7 @@ export const apply_fix = {
673
817
  },
674
818
  required: ['finding_id', 'confirm'],
675
819
  },
676
- async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
820
+ async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null, plan_digest = null }, ctx) {
677
821
  if (confirm !== true) {
678
822
  return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
679
823
  }
@@ -687,6 +831,15 @@ export const apply_fix = {
687
831
  return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
688
832
  }
689
833
 
834
+ // Multi-file plan (a NixOS option defined in several files). The plan is recomputed here and bound by plan_digest; the
835
+ // caller can never supply file content on this path, and it cannot be combined with `patch`.
836
+ if (plan_digest !== null && plan_digest !== undefined) {
837
+ if (patch && typeof patch === 'object' && Object.keys(patch).length) {
838
+ return { _meta: META, applied: false, reason: 'plan_digest and patch cannot be combined: a multi-file plan is computed by the scanner, not supplied by the caller' };
839
+ }
840
+ return _applyMultiFilePlan({ f, planDigest: plan_digest, dryRun: dry_run === true, fixMeta, ctx, status });
841
+ }
842
+
690
843
  // #3 — verifier-approved patch path. When the caller supplies `patch` (a
691
844
  // files map, same shape as verify_fix), apply_fix re-runs the verifier
692
845
  // INLINE and writes only if it passes: the original finding's stableId is
@@ -762,39 +915,8 @@ export const apply_fix = {
762
915
  if (dry_run) {
763
916
  return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
764
917
  }
765
- if (materialClassification.highImpactCategories.length) {
766
- const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
767
- const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
768
- typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
769
- typeof approval.reason === 'string' && approval.reason.trim().length > 0);
770
- if (!hasApprovalEvidence) {
771
- return {
772
- _meta: META, applied: false,
773
- reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
774
- materialClassification,
775
- };
776
- }
777
- const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
778
- const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
779
- const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
780
- if (!identityCheck.verified) {
781
- return {
782
- _meta: META, applied: false,
783
- reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
784
- materialClassification,
785
- };
786
- }
787
- // FR-1003: separation-of-duties, same no-op-unless-configured gate
788
- // as apply-fix-service.js's own copy — see approver-registry.js.
789
- const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
790
- if (!sodCheck.ok) {
791
- return {
792
- _meta: META, applied: false,
793
- reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
794
- materialClassification,
795
- };
796
- }
797
- }
918
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
919
+ if (approvalRefusal) return approvalRefusal;
798
920
  const written = [];
799
921
  try {
800
922
  for (const [rel, v] of Object.entries(confinedAbs)) {
@@ -1025,7 +1147,7 @@ export const verify_fix = {
1025
1147
  // sequence with the returned blob.
1026
1148
  export const synthesize_fix = {
1027
1149
  name: 'synthesize_fix',
1028
- description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
1150
+ description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy. For a NixOS option defined in several files, `languageFix.multiFile` lists every file, `languageFix.edits` gives each file\'s before/after SHA-256 (no file content) and `languageFix.planDigest` is the value to pass to apply_fix as `plan_digest`; the preview is verified but nothing is written.',
1029
1151
  inputSchema: {
1030
1152
  type: 'object',
1031
1153
  additionalProperties: false,
@@ -1079,8 +1201,15 @@ export const synthesize_fix = {
1079
1201
  const proj = lc.loadLanguageProject(ctx.sessionRoot);
1080
1202
  const prev = await lc.languageFixPreview(f, proj.files);
1081
1203
  languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
1082
- // apply_fix writes ONE file; a fix that spans several files must go through the CLI lifecycle (atomic, grouped undo).
1083
- if (prev.ok && prev.edits) languageFix.multiFile = prev.edits.map((e) => e.file);
1204
+ // A fix that spans several files is applied with apply_fix + plan_digest (atomic, one history group); see _applyMultiFilePlan.
1205
+ if (prev.ok && prev.edits) {
1206
+ languageFix.multiFile = prev.edits.map((e) => e.file);
1207
+ // The digest binds finding + every file + exact pre/post image. Pass it to apply_fix as plan_digest; apply_fix
1208
+ // recomputes the plan itself and refuses on any difference. No file content is ever sent back to apply_fix.
1209
+ languageFix.planDigest = _multiFilePlanDigest(f, prev.edits);
1210
+ languageFix.edits = prev.edits.map((e) => ({ file: e.file, beforeSha256: _sha256(e.before), afterSha256: _sha256(e.after) }));
1211
+ languageFix.applyWith = { finding_id: f.id, confirm: true, plan_digest: languageFix.planDigest };
1212
+ }
1084
1213
  else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
1085
1214
  }
1086
1215
  } catch { /* best-effort: the preview is advisory */ }
@@ -734,8 +734,12 @@ function buildNarrative(finding, signals) {
734
734
  ` (controls reducing cost: ${[...signals.controls].join(', ')})` : '';
735
735
  const line = finding.line || finding.source?.line || finding.sink?.line || 0;
736
736
 
737
+ // A dependency advisory (Hackage, Nix) carries no `vuln` text, and `vuln` also feeds finding identity, so it is not invented there: the
738
+ // narrative builds its own label from what the finding does state, and leaves out a location clause it has no location for.
739
+ const label = finding.vuln || finding.title || advisoryLabel(finding);
740
+ const where = finding.file ? ` on \`${finding.file}:${line}\`` : '';
737
741
  const narrative =
738
- `${finding.vuln || finding.title} on \`${finding.file}:${line}\` ` +
742
+ `${label}${where} ` +
739
743
  `could ${dataAtRisk.includes('rce') ? 'lead to' : 'expose'} ${dataPhrase}. ` +
740
744
  `Context: ${indProfile.label} / ${jurStr}${controlsStr}. ` +
741
745
  `Estimated cost: best ${fmtMoney(total.low)} · likely ${fmtMoney(total.likely)} · worst ${fmtMoney(total.high)}. ` +
@@ -778,6 +782,16 @@ function buildNarrative(finding, signals) {
778
782
  };
779
783
  }
780
784
 
785
+ /** A truthful label for a finding with no `vuln` or `title`: the advisory and package it names, else a plain "Finding". */
786
+ function advisoryLabel(f) {
787
+ const id = f.osvId || f.advisory || (Array.isArray(f.ids) && f.ids[0]) || null;
788
+ const pkg = f.name ? `${f.name}${f.version ? ` ${f.version}` : ''}` : null;
789
+ if (id && pkg) return `Advisory ${id} in ${pkg}`;
790
+ if (id) return `Advisory ${id}`;
791
+ if (pkg) return `Vulnerable dependency ${pkg}`;
792
+ return 'Finding';
793
+ }
794
+
781
795
  function describeDataAtRisk(dataAtRisk, userCount) {
782
796
  const n = userCount.toLocaleString();
783
797
  if (dataAtRisk.includes('rce')) return 'full server compromise (remote code execution)';
@@ -213,7 +213,11 @@ async function withLock(lockPath, fn) {
213
213
  fsp.readFile(lockPath, 'utf8').catch(() => ''),
214
214
  ]);
215
215
  const pid = parseInt(pidStr.trim(), 10);
216
- const pidAlive = Number.isFinite(pid) && isProcessAlive(pid);
216
+ // A holder creates the file (`wx`) and only then writes its PID, so
217
+ // an EMPTY file younger than a few seconds is a lock being taken,
218
+ // not a dead one. Reaping it would hand the lock to two processes.
219
+ const mid = !pidStr.trim() && Date.now() - st.mtimeMs < 2000;
220
+ const pidAlive = mid || (Number.isFinite(pid) && isProcessAlive(pid));
217
221
  const old = Date.now() - st.mtimeMs > 30000;
218
222
  if (!pidAlive || old) {
219
223
  try {
@@ -225,6 +229,11 @@ async function withLock(lockPath, fn) {
225
229
  await fsp.unlink(lockPath);
226
230
  }
227
231
  } catch {}
232
+ // Bounded: a reap that cannot make progress (unlink refused, the
233
+ // holder changing under us) must end in the named timeout error,
234
+ // never spin until an outer watchdog kills the process.
235
+ if (Date.now() - start > TIMEOUT_MS) throw new Error('remediation-ledger: lock timed out');
236
+ await new Promise((r) => setTimeout(r, 25));
228
237
  continue;
229
238
  }
230
239
  } catch {}
@@ -13,6 +13,44 @@
13
13
  const _GH_WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/.*\.ya?ml$/i;
14
14
  const _NONPROD_RE = /(?:^|\/)(?:tests?|examples?|fixtures?)\//i;
15
15
 
16
+
17
+ // Matches of the workflow-wide `env:` secret pattern, as { index, end }. See the entry in PIPELINE_PATTERNS.
18
+ // The header is `^env\s*:\s*\n`; its greedy `\s*` ends at the last newline of the whitespace run, and every earlier choice
19
+ // of newline only replays the same lines, so the lazy line loop starts at the line after that newline. Each following line
20
+ // is either the assignment (the tail, tried first, as the lazy loop does) or must itself start with a space or tab.
21
+ const _ENV_HEAD_RE = /^env\s*:\s*/gm;
22
+ const _ENV_TAIL_RE = /[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/y;
23
+ export function* scanEnvSecrets(raw, stats) {
24
+ const head = new RegExp(_ENV_HEAD_RE.source, _ENV_HEAD_RE.flags);
25
+ const tail = new RegExp(_ENV_TAIL_RE.source, _ENV_TAIL_RE.flags);
26
+ let pos = 0;
27
+ while (pos <= raw.length) {
28
+ head.lastIndex = pos;
29
+ const h = head.exec(raw);
30
+ if (!h) return;
31
+ const afterHead = h.index + h[0].length;
32
+ const colon = raw.indexOf(':', h.index + 3);
33
+ const lastNl = raw.lastIndexOf('\n', afterHead - 1);
34
+ if (lastNl <= colon) { pos = h.index + 1; continue; } // no newline after the colon: not a header
35
+ let s = lastNl + 1;
36
+ let end = -1;
37
+ for (;;) {
38
+ tail.lastIndex = s;
39
+ if (stats) stats.tailAttempts += 1;
40
+ const t = tail.exec(raw);
41
+ if (t) { end = s + t[0].length; break; }
42
+ const c = raw.charCodeAt(s);
43
+ if (c !== 32 && c !== 9) break; // the line loop needs an indented line
44
+ const nl = raw.indexOf('\n', s);
45
+ if (nl < 0) break;
46
+ s = nl + 1;
47
+ }
48
+ if (end < 0) { pos = h.index + 1; continue; }
49
+ yield { index: h.index, end };
50
+ pos = end > h.index ? end : h.index + 1;
51
+ }
52
+ }
53
+
16
54
  const PIPELINE_PATTERNS = [
17
55
  // ── build hooks common to Haskell (ghcup, Cabal, Stack, Hackage) and Nix pipelines ──
18
56
  {
@@ -34,7 +72,12 @@ const PIPELINE_PATTERNS = [
34
72
  fix: 'Do not accept a flake\'s own configuration, disable the sandbox, add unauthenticated substituters or evaluate impurely in CI. Put the trust decision in the repository\'s reviewed configuration instead.',
35
73
  },
36
74
  {
37
- re: /^env\s*:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gm,
75
+ // Equivalent to /^env\s*:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gm,
76
+ // which backtracked catastrophically on a comment-blanked workflow (blanking leaves runs of blank lines, and `\s*`, `\n` and
77
+ // the lazy line loop can all claim them): a real repository's CI directory made a scan run for many minutes. The matcher
78
+ // below returns the same match starts and ends, in one pass over each `env:` block. Pinned against the original expression
79
+ // by test/pipeline-env-secret.test.js.
80
+ scan: scanEnvSecrets,
38
81
  vuln: 'Pipeline: secret exposed at workflow-wide environment scope',
39
82
  sev: 'medium', cwe: 'CWE-522',
40
83
  fix: 'Set secrets such as a Hackage or Cachix token on the single step that needs them (`env:` under that step), not at the top of the workflow where every step and every third-party action can read them.',
@@ -105,9 +148,13 @@ export function scanPipeline(fp, raw) {
105
148
  if (p.contextNeg && present) continue; // suppress: required context exists
106
149
  if (!p.contextNeg && !present) continue;
107
150
  }
108
- const re = new RegExp(p.re.source, p.re.flags.includes('g') ? p.re.flags : p.re.flags + 'g');
109
- let m;
110
- while ((m = re.exec(raw))) {
151
+ let matches;
152
+ if (p.scan) matches = p.scan(raw);
153
+ else {
154
+ const re = new RegExp(p.re.source, p.re.flags.includes('g') ? p.re.flags : p.re.flags + 'g');
155
+ matches = (function* () { let mm; while ((mm = re.exec(raw))) yield mm; })();
156
+ }
157
+ for (const m of matches) {
111
158
  const line = raw.substring(0, m.index).split('\n').length;
112
159
  if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
113
160
  const id = `pipeline:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 48)}`;