@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1

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 (153) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +30 -0
  44. package/src/dataflow/catalog.js +42 -1
  45. package/src/dataflow/engine.js +67 -2
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,130 @@
1
+ // Dependency-aware resume and invalidation for portfolio work units (X-705).
2
+ //
3
+ // A verified unit is reusable only while everything it was computed from is unchanged. Each verified result records one digest per
4
+ // dependency dimension (code, policy, graph, invariant, oracle, toolchain; work-units.js refuses a result that lacks any). On resume
5
+ // the CURRENT digests are compared dimension by dimension (X-705.AC01):
6
+ //
7
+ // match the unit stays verified and counts.
8
+ // any dimension differs the unit is INVALIDATED: its result moves to `stale` (still inspectable, with the changed dimensions and the
9
+ // digests on both sides) and the unit returns to pending in a new generation. A stale result never counts
10
+ // toward completion (X-705.AC02), and every assurance claim that depends on the unit goes stale with it.
11
+ //
12
+ // One dimension is narrowed with the deployment drift module rather than treated as all-or-nothing: when only the GRAPH digest changed
13
+ // and a drift plan (lineage/deployment/drift.js `planRescan`) is supplied, a unit bound to hypotheses is reused only if the plan is
14
+ // complete and every one of its hypotheses is in the plan's `skipped` set, meaning the diff provably does not touch it. An incomplete
15
+ // plan, an unbound hypothesis or a unit with no hypotheses invalidates: the narrowing fails closed. A unit reused this way is
16
+ // re-stamped with the current graph digest and the stamp is recorded as a `revalidated` event naming the drift plan.
17
+ //
18
+ // Convergence (X-705.AC03) is checked by comparing `scopedResults` of an interrupted or incremental run with a fresh full run's; the
19
+ // executors are deterministic functions of the unit and its current dependencies, so equal inputs give equal results.
20
+
21
+ import { digestOf } from '../assurance/identity.js';
22
+ import { computeRunKey, computeGlobalKey } from '../scan-checkpoint.js';
23
+ import { DEPENDENCY_DIMENSIONS, checkDependencies, typedError, appendEvent as pushEvent } from './work-units.js';
24
+
25
+ /** Code dependency digest from file contents, reusing the scan checkpoint's content-hash run key. */
26
+ export function codeDigestFromFiles(fileContents) {
27
+ return `sha256:${computeRunKey({ engineVersion: '', rulesetVersion: '', bundleSha: '', fileContents, depFileContents: {}, env: {} })}`;
28
+ }
29
+
30
+ /** Toolchain dependency digest from the engine identity, reusing the checkpoint's global key. */
31
+ export function toolchainDigest({ engineVersion, rulesetVersion, bundleSha }) {
32
+ return `sha256:${computeGlobalKey({ engineVersion, rulesetVersion, bundleSha, depFileContents: {}, env: {} })}`;
33
+ }
34
+
35
+ /** Compare recorded and current dependency digests. Returns the dimensions that differ, sorted. */
36
+ export function changedDimensions(recorded, current) {
37
+ // a dimension that is absent on either side is a difference: two unknowns are not a match
38
+ return DEPENDENCY_DIMENSIONS.filter((d) => recorded?.[d] === undefined || recorded[d] !== current?.[d]);
39
+ }
40
+
41
+ function graphUntouched(unit, drift) {
42
+ if (!drift?.plan || drift.plan.status !== 'complete') return { ok: false, reason: 'no complete drift plan' };
43
+ const hyps = Array.isArray(unit.hypothesisIds) ? unit.hypothesisIds : [];
44
+ if (hyps.length === 0) return { ok: false, reason: 'the unit is not bound to hypotheses, so the diff cannot be shown not to touch it' };
45
+ const skipped = new Set(drift.plan.skipped.map((s) => s.hypothesisId));
46
+ const redo = new Set(drift.plan.rescan.map((s) => s.hypothesisId));
47
+ for (const h of hyps) {
48
+ if (redo.has(h)) return { ok: false, reason: `hypothesis ${h} is affected by the graph change` };
49
+ if (!skipped.has(h)) return { ok: false, reason: `hypothesis ${h} is not covered by the drift plan` };
50
+ }
51
+ return { ok: true, reason: 'the drift plan shows the graph change does not touch this unit' };
52
+ }
53
+
54
+ /**
55
+ * Decide, without changing anything, what a resume would reuse and invalidate.
56
+ *
57
+ * @param {object} store
58
+ * @param {(unit:object)=>Object<string,string>} currentDependencies the CURRENT six digests for a unit
59
+ * @param {{plan:object}} [drift] a `planRescan` result, to narrow graph-only changes
60
+ * @param {Object<string,string[]>} [hypothesesByUnit] unit id -> hypothesis ids the unit covers
61
+ */
62
+ export function planResume(store, currentDependencies, { drift = null, hypothesesByUnit = {} } = {}) {
63
+ const reuse = [], invalidate = [], revalidate = [];
64
+ for (const id of Object.keys(store.units).sort()) {
65
+ const u = store.units[id];
66
+ if (u.state !== 'verified') continue;
67
+ const cur = currentDependencies(u);
68
+ const missing = checkDependencies(cur);
69
+ if (missing.length) { invalidate.push({ unitId: id, changed: missing, reason: `current dependency digests are unavailable for: ${missing.join(', ')}` }); continue; }
70
+ const changed = changedDimensions(u.result.dependencies, cur);
71
+ if (changed.length === 0) { reuse.push(id); continue; }
72
+ if (changed.length === 1 && changed[0] === 'graph') {
73
+ const verdict = graphUntouched({ ...u, hypothesisIds: hypothesesByUnit[id] ?? u.hypothesisIds }, drift);
74
+ if (verdict.ok) { revalidate.push({ unitId: id, graph: cur.graph, driftPlan: digestOf(drift.plan.changeIds ?? []), reason: verdict.reason }); continue; }
75
+ invalidate.push({ unitId: id, changed, reason: `graph changed and ${verdict.reason}`, current: cur });
76
+ continue;
77
+ }
78
+ invalidate.push({ unitId: id, changed, reason: `${changed.join(', ')} changed since the result was recorded`, current: cur });
79
+ }
80
+ return { reuse, revalidate, invalidate, reusableCount: reuse.length + revalidate.length };
81
+ }
82
+
83
+ /**
84
+ * Apply a resume plan to the store (mutates). Invalidated units keep their old result in `stale`, return to pending in a new generation
85
+ * and record an `invalidated` event; revalidated units are re-stamped. Returns the invalidated unit ids.
86
+ */
87
+ export function applyResume(store, plan, now) {
88
+ const invalidated = [];
89
+ for (const r of plan.revalidate) {
90
+ const u = store.units[r.unitId];
91
+ if (!u || u.state !== 'verified') throw typedError('PLAN_STALE', `unit ${r.unitId} is no longer verified`);
92
+ u.result = { ...u.result, dependencies: { ...u.result.dependencies, graph: r.graph } };
93
+ u.result.dependencyDigest = digestOf(u.result.dependencies);
94
+ pushEvent(u, { type: 'revalidated', at: now, graph: r.graph, driftPlan: r.driftPlan, reason: r.reason, dependencyDigest: u.result.dependencyDigest });
95
+ }
96
+ for (const inv of plan.invalidate) {
97
+ const u = store.units[inv.unitId];
98
+ if (!u || u.state !== 'verified') throw typedError('PLAN_STALE', `unit ${inv.unitId} is no longer verified`);
99
+ u.stale.push({ ...u.result, invalidatedAt: now, changedDimensions: inv.changed, reason: inv.reason });
100
+ if (u.stale.length > 20) u.stale.shift();
101
+ pushEvent(u, { type: 'invalidated', at: now, changed: inv.changed, reason: inv.reason, previousDependencyDigest: u.result.dependencyDigest });
102
+ u.result = null;
103
+ u.state = 'pending';
104
+ u.generation += 1;
105
+ u.retryCount = 0;
106
+ invalidated.push(inv.unitId);
107
+ }
108
+ return invalidated;
109
+ }
110
+
111
+ /**
112
+ * Mark every assurance claim that rests on an invalidated (or not-currently-verified) unit as stale. A claim is `current` only while all
113
+ * of its units are verified in the store.
114
+ *
115
+ * @param {Array<{id:string, unitIds:string[]}>} claims
116
+ */
117
+ export function assessClaims(store, claims) {
118
+ return claims.map((c) => {
119
+ const notCurrent = c.unitIds.filter((id) => store.units[id]?.state !== 'verified');
120
+ const stale = c.unitIds.filter((id) => store.units[id]?.stale?.length && store.units[id].state !== 'verified');
121
+ return { id: c.id, status: notCurrent.length === 0 ? 'current' : 'stale', notCurrent, staleUnits: stale };
122
+ });
123
+ }
124
+
125
+ /** The stale results, for inspection. They never count toward completion. */
126
+ export function inspectStale(store) {
127
+ const out = [];
128
+ for (const u of Object.values(store.units)) for (const s of u.stale) out.push({ unitId: u.id, repository: u.repository, taskType: u.taskType, ...s });
129
+ return out.sort((a, b) => (a.unitId < b.unitId ? -1 : 1));
130
+ }
@@ -0,0 +1,207 @@
1
+ // Evidence retention for portfolio artifacts (X-708.AC01).
2
+ //
3
+ // `posture/retention-policy.js` ages the scanner's own registered state files. A portfolio keeps a different population: replay
4
+ // evidence that lets someone re-run a check, metadata that describes a release, traces of model calls, and secrets that must not
5
+ // outlive their use. They differ in how long they should live, so a record here has a CLASS, and the policy sets a retention per class:
6
+ //
7
+ // replay-evidence replay manifests and verification receipts. Kept longest: they are what makes a claim re-checkable.
8
+ // metadata manifests, findings, provenance, toolchain identities.
9
+ // model-trace records of model calls (prompts, responses, routing decisions). Short: they hold the most sensitive content.
10
+ // secret anything credential-like captured in the course of a run. Default 0 days (delete at the next sweep) and a hard
11
+ // ceiling of 7 days. A secret is never exported.
12
+ //
13
+ // `RETENTION_DEFAULTS` are engineering defaults, not a regulatory claim; an operator sets their own per class, and a configured value
14
+ // above the class's `maxDays` is clamped down and the clamp is disclosed (a TTL that can be configured to "never" is not a TTL), the
15
+ // same rule retention-policy.js applies.
16
+ //
17
+ // THE ORDER OF PROTECTION, each tested in both directions:
18
+ // 1. a REQUIRED CURRENT receipt is never deleted, whatever its age and whether or not a hold exists. A record is required when it
19
+ // is named in `currentReceiptIds`, or when a unit listed in its `requiredBy` is `verified` in the store right now. When it is also
20
+ // past its retention the plan says so (`expiredButRequired`): the deletion is blocked loudly, never skipped silently. Once the unit
21
+ // goes stale, is cancelled or is re-planned, the record is no longer required and ages out normally.
22
+ // 2. a LEGAL HOLD (identity-bound, reasoned, optionally expiring: the shape and the check of posture/legal-hold.js, which this reuses)
23
+ // on the record, its class or its repository keeps it. A hold on a `secret` is honoured and flagged, because a hold is a legal act.
24
+ // 3. otherwise a record past its class's retention is deleted; a record in a class the policy does not know is KEPT and reported.
25
+ //
26
+ // DELETION IS LOGGED, AND LOGGED FIRST. Each deletion appends an intent entry to a hash-chained log (record id, class, path, size, content
27
+ // digest, reason, age, policy version, actor) and only then removes the file; an outcome entry follows. If the log cannot be written
28
+ // nothing is deleted. The log records that a deletion happened and what it was, never the content. Paths are confined to the root,
29
+ // symlinks are refused, and a directory record is refused (not recursed into).
30
+ //
31
+ // `planRetention` is pure. `applyRetention` re-plans at the moment of deletion with the live store, so a plan made earlier cannot delete
32
+ // a record that has become required since.
33
+
34
+ import * as crypto from 'node:crypto';
35
+ import * as fs from 'node:fs';
36
+ import * as path from 'node:path';
37
+ import { isUnderHold } from '../legal-hold.js';
38
+ import { digestOf } from '../assurance/identity.js';
39
+
40
+ export const RETENTION_CLASSES = Object.freeze(['replay-evidence', 'metadata', 'model-trace', 'secret']);
41
+ export const RETENTION_DEFAULTS = Object.freeze({
42
+ 'replay-evidence': Object.freeze({ defaultDays: 365, maxDays: 1095 }),
43
+ metadata: Object.freeze({ defaultDays: 730, maxDays: 1825 }),
44
+ 'model-trace': Object.freeze({ defaultDays: 30, maxDays: 90 }),
45
+ secret: Object.freeze({ defaultDays: 0, maxDays: 7 }),
46
+ });
47
+ export const RETENTION_LOG_SCHEMA = 'agentic-security/retention-log';
48
+ const DAY = 86_400_000;
49
+ const BUNDLE_ROLE_CLASS = Object.freeze({ 'replay-manifest': 'replay-evidence', receipt: 'replay-evidence', manifest: 'metadata', findings: 'metadata', provenance: 'metadata', toolchain: 'metadata' });
50
+
51
+ /** The retention class of a portfolio bundle role (bundle.js ROLES). An unknown role has no class and is never deleted by this module. */
52
+ export const classOfBundleRole = (role) => BUNDLE_ROLE_CLASS[role] ?? null;
53
+
54
+ /** Validate a policy `{ version, classes: { <class>: { retainDays } } }` and return the effective days per class, with any clamp disclosed. */
55
+ export function resolveRetentionPolicy(policy) {
56
+ const errors = [];
57
+ if (policy !== undefined && policy !== null && (typeof policy !== 'object' || Array.isArray(policy))) return { ok: false, errors: [{ code: 'BAD_POLICY', message: 'a policy is an object' }] };
58
+ const version = policy?.version ?? 'defaults';
59
+ if (typeof version !== 'string' || !version) errors.push({ code: 'BAD_POLICY', message: 'version must be a non-empty string' });
60
+ const effective = {};
61
+ for (const cls of Object.keys(policy?.classes ?? {})) if (!RETENTION_CLASSES.includes(cls)) errors.push({ code: 'UNKNOWN_CLASS', message: `'${cls}' is not a retention class` });
62
+ for (const cls of RETENTION_CLASSES) {
63
+ const b = RETENTION_DEFAULTS[cls];
64
+ const set = policy?.classes?.[cls]?.retainDays;
65
+ if (set !== undefined && !(typeof set === 'number' && Number.isFinite(set) && set >= 0)) { errors.push({ code: 'BAD_DAYS', message: `${cls}.retainDays must be a non-negative number` }); continue; }
66
+ const days = set === undefined ? b.defaultDays : Math.min(set, b.maxDays);
67
+ effective[cls] = { days, source: set === undefined ? 'default' : 'policy', clamped: set !== undefined && set > b.maxDays, maxDays: b.maxDays };
68
+ }
69
+ return errors.length ? { ok: false, errors } : { ok: true, version, effective };
70
+ }
71
+
72
+ /**
73
+ * Validate legal holds. A hold is `{ target, owner, reason, expires_at? }` with `target` one of `{ id }`, `{ class }`, `{ repository }`.
74
+ * Identity-bound (owner), reasoned (reason), and an expiry, when present, must parse. Returns the holds in the shape legal-hold.js reads.
75
+ */
76
+ export function normalizeHolds(holds) {
77
+ const errors = []; const out = [];
78
+ for (const [i, h] of (Array.isArray(holds) ? holds : []).entries()) {
79
+ const t = h?.target;
80
+ const key = t && typeof t === 'object' ? (t.id ? `id:${t.id}` : t.class ? `class:${t.class}` : t.repository ? `repository:${t.repository}` : null) : null;
81
+ if (!key) { errors.push({ code: 'BAD_HOLD', index: i, message: 'a hold targets an id, a class or a repository' }); continue; }
82
+ if (t.class && !RETENTION_CLASSES.includes(t.class)) { errors.push({ code: 'BAD_HOLD', index: i, message: `'${t.class}' is not a retention class` }); continue; }
83
+ if (typeof h.owner !== 'string' || !h.owner) { errors.push({ code: 'BAD_HOLD', index: i, message: 'a hold needs an owner (identity-bound)' }); continue; }
84
+ if (typeof h.reason !== 'string' || !h.reason) { errors.push({ code: 'BAD_HOLD', index: i, message: 'a hold needs a reason' }); continue; }
85
+ if (h.expires_at && !Number.isFinite(Date.parse(h.expires_at))) { errors.push({ code: 'BAD_HOLD', index: i, message: 'expires_at must be a parseable date' }); continue; }
86
+ out.push({ artifact: key, owner: h.owner, reason: h.reason, expires_at: h.expires_at ?? null });
87
+ }
88
+ return { ok: errors.length === 0, errors, holds: out };
89
+ }
90
+
91
+ const createdMs = (r) => (typeof r.createdAt === 'number' ? r.createdAt : Date.parse(r.createdAt));
92
+
93
+ /**
94
+ * @param {object} p
95
+ * @param {Array<{id:string, class:string, path:string, createdAt:number|string, repository?:string, requiredBy?:string[]}>} p.records
96
+ * @param {object} [p.policy]
97
+ * @param {Array} [p.holds]
98
+ * @param {number} p.now milliseconds
99
+ * @param {object} [p.store] a portfolio store: a record whose `requiredBy` names a verified unit is a required current receipt
100
+ * @param {Iterable<string>} [p.currentReceiptIds]
101
+ */
102
+ export function planRetention({ records, policy, holds = [], now, store = null, currentReceiptIds = [] } = {}) {
103
+ const pol = resolveRetentionPolicy(policy);
104
+ if (!pol.ok) return { ok: false, errors: pol.errors };
105
+ const h = normalizeHolds(holds);
106
+ if (!h.ok) return { ok: false, errors: h.errors };
107
+ if (!Number.isFinite(now)) return { ok: false, errors: [{ code: 'NO_NOW', message: 'now (milliseconds) is required' }] };
108
+ const required = new Set(currentReceiptIds);
109
+ const decisions = [];
110
+ for (const r of Array.isArray(records) ? records : []) {
111
+ const base = { id: r?.id ?? null, class: r?.class ?? null, path: r?.path ?? null };
112
+ if (!r || typeof r.id !== 'string' || !r.id || typeof r.path !== 'string' || !Number.isFinite(createdMs(r))) { decisions.push({ ...base, action: 'keep', reason: 'malformed-record', detail: 'a record needs an id, a path and a creation time; it is not deleted' }); continue; }
113
+ if (!RETENTION_CLASSES.includes(r.class)) { decisions.push({ ...base, action: 'keep', reason: 'unclassified', detail: `'${r.class}' is not a retention class; unclassified records are never deleted` }); continue; }
114
+ const ageDays = (now - createdMs(r)) / DAY;
115
+ const ttl = pol.effective[r.class].days;
116
+ const expired = ageDays > ttl;
117
+ const requiredNow = required.has(r.id) || (Array.isArray(r.requiredBy) && r.requiredBy.some((uid) => store?.units?.[uid]?.state === 'verified'));
118
+ const d = { ...base, ageDays: Math.round(ageDays * 100) / 100, ttlDays: ttl, expired };
119
+ if (requiredNow) { decisions.push({ ...d, action: 'protect', reason: 'required-current-receipt', expiredButRequired: expired, detail: expired ? 'past its retention, but a current verified result depends on it: deletion is blocked' : 'a current verified result depends on it' }); continue; }
120
+ const names = [`id:${r.id}`, `class:${r.class}`, ...(r.repository ? [`repository:${r.repository}`] : [])];
121
+ const hold = names.map((n) => isUnderHold(n, h.holds, now)).find(Boolean);
122
+ if (hold) { decisions.push({ ...d, action: 'keep', reason: 'legal-hold', hold: { target: hold.artifact, owner: hold.owner, reason: hold.reason, expires_at: hold.expires_at }, ...(r.class === 'secret' ? { warning: 'a secret is being retained under a legal hold' } : {}) }); continue; }
123
+ decisions.push(expired ? { ...d, action: 'delete', reason: r.class === 'secret' ? 'secret-expired' : 'expired' } : { ...d, action: 'keep', reason: 'within-retention' });
124
+ }
125
+ const count = (a) => decisions.filter((x) => x.action === a).length;
126
+ return {
127
+ ok: true, policyVersion: pol.version, effective: pol.effective, now, decisions,
128
+ summary: { records: decisions.length, delete: count('delete'), keep: count('keep'), protect: count('protect'), expiredButRequired: decisions.filter((x) => x.expiredButRequired).length, heldByLegalHold: decisions.filter((x) => x.reason === 'legal-hold').length },
129
+ };
130
+ }
131
+
132
+ // ---------------------------------------------------------------- the deletion log
133
+
134
+ const entryDigest = (e) => digestOf(e);
135
+
136
+ function readLog(file) {
137
+ let raw;
138
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
139
+ return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
140
+ }
141
+
142
+ /** Verify the hash chain of a deletion log. Returns `{ ok, entries, errors }`. */
143
+ export function verifyRetentionLog(file) {
144
+ let entries;
145
+ try { entries = readLog(file); } catch { return { ok: false, entries: [], errors: [{ code: 'LOG_UNREADABLE', message: 'the deletion log is unreadable' }] }; }
146
+ const errors = []; let prev = null;
147
+ entries.forEach((e, i) => {
148
+ const { digest, ...body } = e;
149
+ if (e.seq !== i + 1 || e.prev !== prev || entryDigest(body) !== digest) errors.push({ code: 'LOG_BROKEN', seq: i + 1, message: `entry ${i + 1} was altered, removed or reordered` });
150
+ prev = digest;
151
+ });
152
+ return { ok: errors.length === 0, entries, errors };
153
+ }
154
+
155
+ function appendLog(file, entry) {
156
+ const v = verifyRetentionLog(file);
157
+ if (!v.ok) throw Object.assign(new Error('the deletion log fails verification; nothing will be deleted'), { code: 'LOG_BROKEN' });
158
+ const last = v.entries.at(-1);
159
+ const body = { schema: RETENTION_LOG_SCHEMA, seq: v.entries.length + 1, prev: last ? last.digest : null, ...entry };
160
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
161
+ const fd = fs.openSync(file, 'a', 0o600);
162
+ try { fs.writeSync(fd, `${JSON.stringify({ ...body, digest: entryDigest(body) })}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
163
+ }
164
+
165
+ function confined(root, rel) {
166
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
167
+ const abs = path.resolve(root, rel);
168
+ const rootReal = fs.realpathSync(root);
169
+ if (abs !== rootReal && !abs.startsWith(rootReal + path.sep) && !abs.startsWith(path.resolve(root) + path.sep)) return null;
170
+ // a directory inside the root that is a link out of it must not carry a deletion out of the root either
171
+ const parentReal = fs.realpathSync(path.dirname(abs));
172
+ if (parentReal !== rootReal && !parentReal.startsWith(rootReal + path.sep)) return null;
173
+ return abs;
174
+ }
175
+
176
+ /**
177
+ * Plan and apply, logging every deletion first. `dryRun` plans only. Returns the plan plus what happened.
178
+ * @returns {{ ok: boolean, plan?: object, deleted: object[], failed: object[], blocked: object[], errors?: object[] }}
179
+ */
180
+ export function applyRetention({ root, logFile, actor, dryRun = false, ...planInput } = {}) {
181
+ const plan = planRetention(planInput);
182
+ if (!plan.ok) return { ok: false, errors: plan.errors, deleted: [], failed: [], blocked: [] };
183
+ const blocked = plan.decisions.filter((d) => d.expiredButRequired).map((d) => ({ id: d.id, reason: d.reason, detail: d.detail }));
184
+ if (dryRun) return { ok: true, plan, dryRun: true, deleted: [], failed: [], blocked };
185
+ if (typeof actor !== 'string' || !actor) return { ok: false, errors: [{ code: 'NO_ACTOR', message: 'a deletion is attributed to an actor' }], plan, deleted: [], failed: [], blocked };
186
+ const deleted = []; const failed = [];
187
+ for (const d of plan.decisions.filter((x) => x.action === 'delete')) {
188
+ const fail = (code, message) => failed.push({ id: d.id, code, message });
189
+ let abs;
190
+ try { abs = confined(root, d.path); } catch { abs = null; }
191
+ if (!abs) { fail('PATH_ESCAPES_ROOT', 'the record path is not inside the retention root'); continue; }
192
+ let st;
193
+ try { st = fs.lstatSync(abs); } catch (e) { fail(e.code === 'ENOENT' ? 'ALREADY_GONE' : 'STAT_FAILED', 'the file is not there to delete'); continue; }
194
+ if (st.isSymbolicLink()) { fail('SYMLINK_REFUSED', 'a symbolic link is not followed or removed'); continue; }
195
+ if (!st.isFile()) { fail('NOT_A_FILE', 'only files are deleted, never directories'); continue; }
196
+ const digest = `sha256:${crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex')}`;
197
+ const entry = { recordId: d.id, class: d.class, path: d.path, bytes: st.size, contentDigest: digest, reason: d.reason, ageDays: d.ageDays, ttlDays: d.ttlDays, policyVersion: plan.policyVersion, actor, at: new Date(plan.now).toISOString() };
198
+ try { appendLog(logFile, { type: 'delete-intent', ...entry }); } catch (e) { fail(e.code ?? 'LOG_FAILED', 'the deletion could not be logged, so the file was not deleted'); continue; }
199
+ try { fs.unlinkSync(abs); } catch (e) {
200
+ try { appendLog(logFile, { type: 'delete-failed', recordId: d.id, code: e.code ?? 'UNLINK_FAILED', at: entry.at }); } catch { /* the intent entry stands */ }
201
+ fail(e.code ?? 'UNLINK_FAILED', 'the file could not be removed'); continue;
202
+ }
203
+ try { appendLog(logFile, { type: 'deleted', recordId: d.id, contentDigest: digest, at: entry.at }); } catch { /* the intent entry already recorded it */ }
204
+ deleted.push({ id: d.id, class: d.class, path: d.path, contentDigest: digest });
205
+ }
206
+ return { ok: true, plan, deleted, failed, blocked };
207
+ }