@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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/CHANGELOG.md +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Non-taint vulnerability classes and the rules for judging them (X-205).
|
|
2
|
+
//
|
|
3
|
+
// A taint engine answers "does attacker data reach a dangerous call". Several
|
|
4
|
+
// defect classes have no such flow: a missing tenant check, a privileged action
|
|
5
|
+
// open to a lower role, a workflow step that can be skipped, a request whose
|
|
6
|
+
// effect repeats when it is delivered again, an unbounded work parameter. A
|
|
7
|
+
// source-to-sink analysis finding nothing says NOTHING about them, so this module
|
|
8
|
+
// states three things, each with a test:
|
|
9
|
+
//
|
|
10
|
+
// 1. WHICH classes are supported (`SCENARIO_CLASSES`), and for each one the
|
|
11
|
+
// adapter that executes it, what the requester must supply, the fixtures that
|
|
12
|
+
// prove it works in both directions, and the limitations a reader must see.
|
|
13
|
+
// Where an existing adapter already covers a class it is REUSED, and the
|
|
14
|
+
// entry says so (`adapterReuse`); only replay/idempotency needed a new one.
|
|
15
|
+
// 2. That taint absence NEVER refutes one of these hypotheses
|
|
16
|
+
// (`judgeNonTaintHypothesis`): without an executed oracle of the class's own
|
|
17
|
+
// adapter the outcome is `not-run`, never `refuted`.
|
|
18
|
+
// 3. Which results may count as a trusted negative (`trustedNegativeDenominator`):
|
|
19
|
+
// only a decided result from an executed oracle with proven preconditions.
|
|
20
|
+
// Unsupported, not-run, inconclusive, error, static-only and model-only results
|
|
21
|
+
// are listed explicitly and excluded; they cannot be counted as "checked and
|
|
22
|
+
// clean".
|
|
23
|
+
//
|
|
24
|
+
// This module is pure data and pure functions: it imports no adapter and runs
|
|
25
|
+
// nothing, so `oracle.js` and `registry.js` can both depend on it.
|
|
26
|
+
|
|
27
|
+
export const NON_TAINT_SCHEMA = 'agentic-security/non-taint-classes';
|
|
28
|
+
|
|
29
|
+
const FIXTURE_ROOT = 'test/fixtures/non-taint';
|
|
30
|
+
|
|
31
|
+
export const SCENARIO_CLASSES = Object.freeze([
|
|
32
|
+
Object.freeze({
|
|
33
|
+
id: 'tenant-authorization',
|
|
34
|
+
title: 'Tenant authorization',
|
|
35
|
+
adapterId: 'authorization-decision', adapterReuse: true,
|
|
36
|
+
claim: 'an actor from one tenant is allowed to read or change another tenant\'s resource',
|
|
37
|
+
cwes: ['CWE-639', 'CWE-566', 'CWE-863', 'CWE-862', 'CWE-285', 'CWE-284'],
|
|
38
|
+
families: ['idor', 'authz', 'missing-authz', 'broken-access-control', 'tenant-isolation'],
|
|
39
|
+
requires: ['an exported decision function taking the actor and the resource (or ids that resolve to them)', 'one control case the function must allow (the owner) and one attack case it must deny (another tenant)'],
|
|
40
|
+
limitations: ['asserts the decision function in isolation; a route or middleware that enforces the check elsewhere is not exercised', 'the scenario names which actors should be denied: the oracle does not infer tenancy rules', 'row-level checks inside a database query are not observed'],
|
|
41
|
+
fixtures: { dir: `${FIXTURE_ROOT}/tenant-authorization`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: 'privileged-action',
|
|
45
|
+
title: 'Privileged action',
|
|
46
|
+
adapterId: 'authorization-decision', adapterReuse: true,
|
|
47
|
+
claim: 'a lower-privileged actor is allowed to perform an action reserved for a higher role',
|
|
48
|
+
cwes: ['CWE-269', 'CWE-250', 'CWE-266', 'CWE-267'],
|
|
49
|
+
families: ['privilege-escalation', 'privileged-action', 'missing-role-check'],
|
|
50
|
+
requires: ['an exported decision function taking the actor and the action', 'one control case with the privileged role (must be allowed) and one attack case with an ordinary role (must be denied)'],
|
|
51
|
+
limitations: ['asserts the decision function in isolation; role checks enforced only by a gateway or framework guard are not exercised', 'a thrown error is not read as a denial', 'role and permission grants stored outside the function are not observed'],
|
|
52
|
+
fixtures: { dir: `${FIXTURE_ROOT}/privileged-action`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
53
|
+
}),
|
|
54
|
+
Object.freeze({
|
|
55
|
+
id: 'workflow-order',
|
|
56
|
+
title: 'Workflow order',
|
|
57
|
+
adapterId: 'state-transition', adapterReuse: true,
|
|
58
|
+
claim: 'a step that must follow another can be performed first (for example a refund before approval)',
|
|
59
|
+
cwes: ['CWE-841', 'CWE-840', 'CWE-696'],
|
|
60
|
+
families: ['workflow', 'business-logic', 'state-machine', 'workflow-order'],
|
|
61
|
+
requires: ['an exported factory returning a machine with `send(event)` and a `state`', 'a legal event sequence with its expected final state, an attack sequence and the forbidden state'],
|
|
62
|
+
limitations: ['covers one factory and finite event lists; it is not a model checker', 'durable effects outside the machine object (a database row, a queued job) are not observed', 'the forbidden state is stated by the scenario: business rules are not inferred'],
|
|
63
|
+
fixtures: { dir: `${FIXTURE_ROOT}/workflow-order`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
64
|
+
}),
|
|
65
|
+
Object.freeze({
|
|
66
|
+
id: 'replay-idempotency',
|
|
67
|
+
title: 'Replay and idempotency',
|
|
68
|
+
adapterId: 'replay-idempotency', adapterReuse: false,
|
|
69
|
+
claim: 'delivering the same request again repeats its side effect (a double charge, a double credit)',
|
|
70
|
+
cwes: ['CWE-294', 'CWE-837', 'CWE-841'],
|
|
71
|
+
families: ['replay', 'idempotency', 'replay-attack', 'duplicate-submission'],
|
|
72
|
+
requires: ['an exported factory that receives a recording stand-in for the effect and returns a request handler', 'a request, a distinct request and a delivery count'],
|
|
73
|
+
limitations: ['deliveries are sequential: a race between concurrent deliveries is not exercised', 'deduplication kept in an external store is not observed, only the handler the factory returns', 'code that imports its effect directly instead of receiving it is not covered'],
|
|
74
|
+
fixtures: { dir: `${FIXTURE_ROOT}/replay-idempotency`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
75
|
+
}),
|
|
76
|
+
Object.freeze({
|
|
77
|
+
id: 'resource-exhaustion',
|
|
78
|
+
title: 'Bounded resource exhaustion',
|
|
79
|
+
adapterId: 'parser-resource', adapterReuse: true,
|
|
80
|
+
claim: 'a bounded hostile parameter makes the code exceed its time or output budget (an uncapped page size, an unbounded loop)',
|
|
81
|
+
cwes: ['CWE-400', 'CWE-770', 'CWE-789', 'CWE-405', 'CWE-834'],
|
|
82
|
+
families: ['resource-exhaustion', 'dos', 'denial-of-service', 'unbounded-loop'],
|
|
83
|
+
requires: ['an exported function taking one string', 'a benign input it must finish and a hostile input described as a unit repeated at most 65536 times'],
|
|
84
|
+
limitations: ['the hostile input is bounded and the run is cut at the supervisor deadline: this shows a budget was exceeded, not how large the blow-up is', 'memory is not measured', 'distributed or network-level exhaustion, and exhaustion that needs a populated datastore, are not exercised'],
|
|
85
|
+
fixtures: { dir: `${FIXTURE_ROOT}/resource-exhaustion`, positive: 'positive', negative: 'negative', scenario: 'scenario.json' },
|
|
86
|
+
}),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Non-taint classes this build recognises and does NOT execute. A finding that maps here is `unsupported`,
|
|
90
|
+
// stated by name, and never counted as checked.
|
|
91
|
+
export const UNSUPPORTED_NON_TAINT = Object.freeze([
|
|
92
|
+
Object.freeze({ id: 'concurrent-race', cwes: ['CWE-362', 'CWE-367'], reason: 'a race needs concurrent scheduling; the adapters deliver sequentially, so no verdict about a race can be formed' }),
|
|
93
|
+
Object.freeze({ id: 'cross-site-request-forgery', cwes: ['CWE-352'], reason: 'needs a browser and a session; no adapter exercises one' }),
|
|
94
|
+
Object.freeze({ id: 'authentication-throttling', cwes: ['CWE-307', 'CWE-799'], reason: 'needs a stateful attempt counter over time and a populated store; the adapters bound a single input, not a campaign' }),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const BY_ID = new Map(SCENARIO_CLASSES.map((c) => [c.id, c]));
|
|
98
|
+
|
|
99
|
+
export function scenarioClassesForAdapter(adapterId) { return SCENARIO_CLASSES.filter((c) => c.adapterId === adapterId); }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The lines a user reads about one class: what it asserts, what the requester must supply (prerequisites), the platform
|
|
103
|
+
* statement and every limitation. A report that shows a class shows these with it.
|
|
104
|
+
* @param {object} cls a SCENARIO_CLASSES entry
|
|
105
|
+
* @param {object} [adapter] the executing adapter (prerequisites and platform statements are taken from it)
|
|
106
|
+
*/
|
|
107
|
+
export function scenarioClassReportLines(cls, adapter = null) {
|
|
108
|
+
const lines = [`${cls.title} (${cls.id}): ${cls.claim}.`];
|
|
109
|
+
lines.push(` oracle: ${cls.adapterId}${cls.adapterReuse ? ' (an existing adapter, reused for this class)' : ' (added for this class)'}`);
|
|
110
|
+
lines.push(` prerequisites: ${[...cls.requires, ...(adapter ? adapter.prerequisites.map((p) => `host: ${p}`) : [])].join('; ')}`);
|
|
111
|
+
if (adapter) lines.push(` platforms: macOS ${adapter.platforms.darwin.status}, Linux ${adapter.platforms.linux.status}, Windows ${adapter.platforms.win32.status}`);
|
|
112
|
+
for (const l of cls.limitations) lines.push(` limitation: ${l}`);
|
|
113
|
+
lines.push(' not established: a clean result covers only the declared scenario; it is not proof the class is absent from the code.');
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------------------------ classification
|
|
118
|
+
|
|
119
|
+
const cweOf = (f) => String(f?.cwe || '').toUpperCase().replace(/^CWE[-_ ]?/, 'CWE-');
|
|
120
|
+
const familyOf = (f) => String(f?.family || '').toLowerCase();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Which non-taint class, supported or not, does this finding belong to? `null` when it is not a recognised non-taint
|
|
124
|
+
* hypothesis (then nothing in this module applies). An explicit `finding.scenarioClass` wins over CWE and family.
|
|
125
|
+
* @returns {null | { classId: string, supported: boolean, title: string, reason?: string }}
|
|
126
|
+
*/
|
|
127
|
+
export function nonTaintClassOf(finding) {
|
|
128
|
+
if (!finding || typeof finding !== 'object') return null;
|
|
129
|
+
if (typeof finding.scenarioClass === 'string') {
|
|
130
|
+
const c = BY_ID.get(finding.scenarioClass);
|
|
131
|
+
if (c) return { classId: c.id, supported: true, title: c.title };
|
|
132
|
+
}
|
|
133
|
+
const cwe = cweOf(finding);
|
|
134
|
+
const fam = familyOf(finding);
|
|
135
|
+
for (const u of UNSUPPORTED_NON_TAINT) {
|
|
136
|
+
if (u.cwes.includes(cwe) || fam === u.id) return { classId: u.id, supported: false, title: u.id.replace(/-/g, ' '), reason: u.reason };
|
|
137
|
+
}
|
|
138
|
+
for (const c of SCENARIO_CLASSES) {
|
|
139
|
+
if (c.cwes.includes(cwe) || c.families.includes(fam)) return { classId: c.id, supported: true, title: c.title };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------------ judging a non-taint hypothesis
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Judge a non-taint hypothesis from what is actually known about it.
|
|
148
|
+
*
|
|
149
|
+
* The rule: absence of a taint flow is not evidence about these classes, so it is never an input to a refutation. An
|
|
150
|
+
* outcome of `refuted` needs an EXECUTED oracle result from the class's own adapter with proven preconditions; anything
|
|
151
|
+
* else is `not-run` (a supported class nobody ran), `unsupported` (a class this build cannot execute) or the oracle's own
|
|
152
|
+
* `confirmed`/`inconclusive`/`error`.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} args
|
|
155
|
+
* @param {object} args.finding
|
|
156
|
+
* @param {object} [args.taint] static taint evidence, for example `{ clean: true }`; recorded, never used to refute
|
|
157
|
+
* @param {object} [args.oracleResult] `{ outcome, oracleId, preconditionsValid }` from a run of an oracle adapter
|
|
158
|
+
* @returns {{ nonTaint: boolean, classId?: string, supported?: boolean, outcome?: string, reason?: string, taintAbsenceUsed: false }}
|
|
159
|
+
*/
|
|
160
|
+
export function judgeNonTaintHypothesis({ finding, taint = null, oracleResult = null } = {}) {
|
|
161
|
+
const cls = nonTaintClassOf(finding);
|
|
162
|
+
if (!cls) return { nonTaint: false, taintAbsenceUsed: false };
|
|
163
|
+
const taintNote = taint && taint.clean === true
|
|
164
|
+
? ' No source-to-sink flow was found, and that says nothing about this class: it is not a refutation.'
|
|
165
|
+
: '';
|
|
166
|
+
if (!cls.supported) {
|
|
167
|
+
return { nonTaint: true, classId: cls.classId, supported: false, outcome: 'unsupported', taintAbsenceUsed: false,
|
|
168
|
+
reason: `${cls.title} is a non-taint class this build does not execute: ${cls.reason}.${taintNote}` };
|
|
169
|
+
}
|
|
170
|
+
const own = BY_ID.get(cls.classId);
|
|
171
|
+
const r = oracleResult && typeof oracleResult === 'object' ? oracleResult : null;
|
|
172
|
+
const decisive = r && r.oracleId === own.adapterId && ['confirmed', 'refuted', 'inconclusive', 'error'].includes(r.outcome);
|
|
173
|
+
if (!decisive) {
|
|
174
|
+
return { nonTaint: true, classId: cls.classId, supported: true, outcome: 'not-run', taintAbsenceUsed: false,
|
|
175
|
+
reason: `no executed ${own.adapterId} oracle result exists for this ${cls.title.toLowerCase()} hypothesis, so it stays unrefuted.${taintNote}` };
|
|
176
|
+
}
|
|
177
|
+
if (r.outcome === 'refuted' && r.preconditionsValid !== true) {
|
|
178
|
+
return { nonTaint: true, classId: cls.classId, supported: true, outcome: 'inconclusive', taintAbsenceUsed: false,
|
|
179
|
+
reason: `the ${own.adapterId} oracle did not show its preconditions held, so its negative result is not a refutation.${taintNote}` };
|
|
180
|
+
}
|
|
181
|
+
return { nonTaint: true, classId: cls.classId, supported: true, outcome: r.outcome, taintAbsenceUsed: false,
|
|
182
|
+
reason: `decided by the executed ${own.adapterId} oracle.${taintNote}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------------ the trusted-negative denominator
|
|
186
|
+
|
|
187
|
+
const ORACLE_IDS = new Set(SCENARIO_CLASSES.map((c) => c.adapterId));
|
|
188
|
+
|
|
189
|
+
function whyExcluded(rec) {
|
|
190
|
+
if (!rec || typeof rec !== 'object') return 'malformed';
|
|
191
|
+
if (rec.outcome === 'unsupported') return 'unsupported';
|
|
192
|
+
if (rec.outcome === 'not-run') return 'not-run';
|
|
193
|
+
if (rec.outcome === 'inconclusive') return 'inconclusive';
|
|
194
|
+
if (rec.outcome === 'error') return 'error';
|
|
195
|
+
if (rec.outcome !== 'confirmed' && rec.outcome !== 'refuted') return 'not-decided';
|
|
196
|
+
if (!rec.oracle || rec.oracle.kind !== 'runtime-replay') return 'no-executed-oracle';
|
|
197
|
+
if (!ORACLE_IDS.has(rec.oracle.id)) return 'oracle-not-in-supported-class-manifest';
|
|
198
|
+
if (!rec.preconditions || rec.preconditions.valid !== true) return 'preconditions-not-proved';
|
|
199
|
+
if (!Array.isArray(rec.evidence) || !rec.evidence.some((e) => e && e.kind === 'trusted-runtime-proof')) return 'no-trusted-runtime-proof';
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The trusted-negative denominator over verification records: the decided results (`confirmed` or `refuted`) of an
|
|
205
|
+
* executed oracle of an advertised class with proven preconditions and trusted runtime proof. Everything else is listed
|
|
206
|
+
* with the reason it is excluded; an unsupported or unrun case can never be counted as a negative.
|
|
207
|
+
*/
|
|
208
|
+
export function trustedNegativeDenominator(records) {
|
|
209
|
+
const list = Array.isArray(records) ? records : [];
|
|
210
|
+
let trustedNegatives = 0;
|
|
211
|
+
let trustedPositives = 0;
|
|
212
|
+
const excluded = [];
|
|
213
|
+
for (const rec of list) {
|
|
214
|
+
const why = whyExcluded(rec);
|
|
215
|
+
if (why) { excluded.push({ id: rec && rec.id ? String(rec.id) : null, outcome: rec && rec.outcome ? String(rec.outcome) : null, reason: why }); continue; }
|
|
216
|
+
if (rec.outcome === 'refuted') trustedNegatives++; else trustedPositives++;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
denominator: trustedNegatives + trustedPositives, trustedNegatives, trustedPositives,
|
|
220
|
+
excluded, unsupported: excluded.filter((e) => e.reason === 'unsupported'),
|
|
221
|
+
total: list.length,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Pluggable storage backends and offline operation for portfolios (X-708.AC02, X-708.AC03).
|
|
2
|
+
//
|
|
3
|
+
// Everything in the portfolio works with no hosted service: state is plain files, written atomically, readable with the standard
|
|
4
|
+
// library. This module adds the one thing a single store file cannot give, a defined way to run it from SEVERAL workers or hosts that
|
|
5
|
+
// share a directory, and a defined answer when that directory is not there.
|
|
6
|
+
//
|
|
7
|
+
// THE BACKEND INTERFACE (what a replacement implements)
|
|
8
|
+
// kind 'local-fs' | 'shared-dir' (a label that appears in every report)
|
|
9
|
+
// describe() { kind, root, consistency } and what the backend does NOT claim
|
|
10
|
+
// probe() { ok: true } or { ok: false, state: 'blocked', code, reason }; never throws, never creates anything
|
|
11
|
+
// storeFile(name) the path of a named document, for the portfolio store and ledger
|
|
12
|
+
// withExclusive(name, fn) run `fn` while holding a cross-process lock; throws BACKEND_UNAVAILABLE if the backend went away
|
|
13
|
+
// acquireLease / renewLease / releaseLease / readLease
|
|
14
|
+
// a named, expiring, FENCED lease (below)
|
|
15
|
+
//
|
|
16
|
+
// REFERENCE IMPLEMENTATION. `FileLockBackend` uses exclusive file creation (`open(..., 'wx')`) in a directory. `createLocalBackend`
|
|
17
|
+
// makes the directory if needed (single user, durable restart). `createSharedBackend` will NOT: it requires a marker file written by
|
|
18
|
+
// an explicit `initSharedBackend`, so an unmounted path, a mistyped path or an empty mount point is a typed `blocked`, never a quietly
|
|
19
|
+
// fresh store.
|
|
20
|
+
//
|
|
21
|
+
// LEASE SEMANTICS (each tested, including two real processes contending):
|
|
22
|
+
// - mutual exclusion: at most one holder has an unexpired lease on a resource at a time;
|
|
23
|
+
// - fencing: every grant increments a `fence` number that never goes down (a release keeps it). A holder presents its fence on
|
|
24
|
+
// renew and release, so a holder that was replaced after its lease expired is refused and learns it lost the lease (`lost`);
|
|
25
|
+
// - expiry takeover: an expired lease can be taken by anyone; the previous holder's renew then fails;
|
|
26
|
+
// - release by anyone but the holder, or with a stale fence, is refused.
|
|
27
|
+
//
|
|
28
|
+
// WHAT IS NOT CLAIMED. These semantics were verified on one machine's local filesystem, with several processes. Exclusive file
|
|
29
|
+
// creation is atomic on a local POSIX filesystem; whether it is atomic on a particular network filesystem depends on that filesystem
|
|
30
|
+
// and its mount options, and this build has not tested any. A shared directory used from several HOSTS therefore needs the operator to
|
|
31
|
+
// verify exclusive-create atomicity on that mount, and lease expiry compares wall clocks, so hosts' clocks must agree to well within a
|
|
32
|
+
// lease. Stale-lock recovery (a holder that died inside a critical section) has a narrow window in which two recoverers can both
|
|
33
|
+
// believe they broke the lock; critical sections here last milliseconds, and `lockStaleMs` is far above that. `networkFilesystem` is
|
|
34
|
+
// reported `unverified` in `describe()`.
|
|
35
|
+
//
|
|
36
|
+
// UNAVAILABLE BACKEND. Selecting a shared backend that cannot be used returns `{ ok: false, state: 'blocked', code, reason }` from
|
|
37
|
+
// `openBackend`, and there is no fallback argument: nothing here ever "degrades" to a local write. Callers must stop, not substitute.
|
|
38
|
+
|
|
39
|
+
import * as crypto from 'node:crypto';
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as os from 'node:os';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
import { digestOf } from '../assurance/identity.js';
|
|
44
|
+
import { findSecret } from './bundle.js';
|
|
45
|
+
import { verifyStore, typedError } from './work-units.js';
|
|
46
|
+
import { ledgerPathFor, readLedger } from './scheduler.js';
|
|
47
|
+
|
|
48
|
+
export const BACKEND_MARKER = '.agentic-security-backend.json';
|
|
49
|
+
export const BACKEND_MARKER_SCHEMA = 'agentic-security/portfolio-backend-marker';
|
|
50
|
+
export const STATE_EXPORT_SCHEMA = 'agentic-security/portfolio-state-export';
|
|
51
|
+
export const BLOCK_CODES = Object.freeze(['backend-missing', 'backend-not-initialized', 'backend-not-a-directory', 'backend-symlink', 'backend-not-writable', 'backend-marker-invalid']);
|
|
52
|
+
export const BACKEND_LIMITS = Object.freeze({ lockStaleMs: 30_000, lockWaitMs: 5000, maxLeaseMs: 6 * 60 * 60_000, maxExportBytes: 64 * 1024 * 1024 });
|
|
53
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
54
|
+
|
|
55
|
+
function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
56
|
+
const unavailable = (probe) => typedError('BACKEND_UNAVAILABLE', `the portfolio backend is unavailable (${probe.code}): ${probe.reason}`, { state: 'blocked', probe });
|
|
57
|
+
|
|
58
|
+
/** The reference backend: exclusive-create file locks in a directory. */
|
|
59
|
+
export class FileLockBackend {
|
|
60
|
+
constructor({ kind, root, requireMarker, lockStaleMs = BACKEND_LIMITS.lockStaleMs, lockWaitMs = BACKEND_LIMITS.lockWaitMs, nodeId = null }) {
|
|
61
|
+
this.kind = kind; this.root = path.resolve(root); this.requireMarker = requireMarker;
|
|
62
|
+
this.lockStaleMs = lockStaleMs; this.lockWaitMs = lockWaitMs;
|
|
63
|
+
this.nodeId = nodeId ?? `${os.hostname()}:${process.pid}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe() {
|
|
67
|
+
return {
|
|
68
|
+
kind: this.kind, root: this.root, requiresMarker: this.requireMarker,
|
|
69
|
+
consistency: {
|
|
70
|
+
mutualExclusion: 'verified for processes sharing one local filesystem (two-process contention test)',
|
|
71
|
+
fencing: 'monotonic per resource; a stale holder is refused on renew and release',
|
|
72
|
+
networkFilesystem: 'unverified',
|
|
73
|
+
clocks: 'lease expiry compares wall clocks; hosts sharing a directory must agree to well within a lease',
|
|
74
|
+
staleLockRecovery: `a lock file older than ${this.lockStaleMs} ms is treated as abandoned; the recovery has a narrow race and is not a substitute for short critical sections`,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
probe() {
|
|
80
|
+
let st;
|
|
81
|
+
try { st = fs.lstatSync(this.root); } catch { return { ok: false, state: 'blocked', code: 'backend-missing', reason: `${this.kind} backend directory does not exist or cannot be read` }; }
|
|
82
|
+
if (st.isSymbolicLink()) return { ok: false, state: 'blocked', code: 'backend-symlink', reason: 'the backend directory is a symbolic link; it is not followed' };
|
|
83
|
+
if (!st.isDirectory()) return { ok: false, state: 'blocked', code: 'backend-not-a-directory', reason: 'the backend path is not a directory' };
|
|
84
|
+
if (this.requireMarker) {
|
|
85
|
+
let m;
|
|
86
|
+
try { m = JSON.parse(fs.readFileSync(path.join(this.root, BACKEND_MARKER), 'utf8')); } catch (e) {
|
|
87
|
+
return { ok: false, state: 'blocked', code: e.code === 'ENOENT' ? 'backend-not-initialized' : 'backend-marker-invalid', reason: e.code === 'ENOENT' ? 'the shared directory has no backend marker: it is not initialized (an unmounted or empty directory is not a store)' : 'the backend marker is unreadable' };
|
|
88
|
+
}
|
|
89
|
+
if (m?.schema !== BACKEND_MARKER_SCHEMA || m.kind !== this.kind) return { ok: false, state: 'blocked', code: 'backend-marker-invalid', reason: 'the backend marker does not describe this kind of backend' };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const probeFile = path.join(this.root, `.probe-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
93
|
+
fs.writeFileSync(probeFile, '', { flag: 'wx' }); fs.unlinkSync(probeFile);
|
|
94
|
+
} catch { return { ok: false, state: 'blocked', code: 'backend-not-writable', reason: 'the backend directory is not writable' }; }
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
storeFile(name = 'portfolio-store.json') {
|
|
99
|
+
if (!NAME_RE.test(name)) throw typedError('BAD_NAME', `'${name}' is not an allowed document name`);
|
|
100
|
+
return path.join(this.root, name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_lockPath(name) {
|
|
104
|
+
if (!NAME_RE.test(name)) throw typedError('BAD_NAME', `'${name}' is not an allowed lock name`);
|
|
105
|
+
return path.join(this.root, '.locks', `${name}.lock`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run `fn` under the named cross-process lock. The backend is re-probed first: a backend that went away is an error, not an empty store. */
|
|
109
|
+
withExclusive(name, fn, { waitMs = this.lockWaitMs } = {}) {
|
|
110
|
+
const p = this.probe();
|
|
111
|
+
if (!p.ok) throw unavailable(p);
|
|
112
|
+
const lock = this._lockPath(name);
|
|
113
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
114
|
+
const token = crypto.randomBytes(12).toString('hex');
|
|
115
|
+
const body = JSON.stringify({ token, node: this.nodeId, pid: process.pid });
|
|
116
|
+
const start = Date.now();
|
|
117
|
+
for (;;) {
|
|
118
|
+
try { fs.writeFileSync(lock, body, { flag: 'wx' }); break; } catch (e) {
|
|
119
|
+
if (e.code !== 'EEXIST') throw e;
|
|
120
|
+
this._breakIfStale(lock);
|
|
121
|
+
if (Date.now() - start > waitMs) throw typedError('LOCK_TIMEOUT', `could not take lock '${name}' within ${waitMs} ms`);
|
|
122
|
+
sleepSync(3);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try { return fn(); } finally {
|
|
126
|
+
// release only OUR lock: if it was recovered as stale and re-taken, the file belongs to someone else now
|
|
127
|
+
try { if (JSON.parse(fs.readFileSync(lock, 'utf8')).token === token) fs.unlinkSync(lock); } catch { /* already gone */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_breakIfStale(lock) {
|
|
132
|
+
let st; let seen;
|
|
133
|
+
try { st = fs.statSync(lock); seen = JSON.parse(fs.readFileSync(lock, 'utf8')).token; } catch { return; }
|
|
134
|
+
if (Date.now() - st.mtimeMs <= this.lockStaleMs) return;
|
|
135
|
+
const grave = `${lock}.broken-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
|
|
136
|
+
try { fs.renameSync(lock, grave); } catch { return; }
|
|
137
|
+
let got = null;
|
|
138
|
+
try { got = JSON.parse(fs.readFileSync(grave, 'utf8')).token; } catch { /* unreadable: treated as the stale one */ }
|
|
139
|
+
if (got !== null && got !== seen) { try { fs.linkSync(grave, lock); } catch { /* the lock was re-taken meanwhile: the documented narrow race */ } }
|
|
140
|
+
try { fs.unlinkSync(grave); } catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_leaseFile(resource) {
|
|
144
|
+
if (!NAME_RE.test(resource)) throw typedError('BAD_NAME', `'${resource}' is not an allowed lease name`);
|
|
145
|
+
return path.join(this.root, 'leases', `${resource}.json`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_readLease(resource) {
|
|
149
|
+
try { return JSON.parse(fs.readFileSync(this._leaseFile(resource), 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw typedError('LEASE_CORRUPT', `lease '${resource}' is unreadable`); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_writeLease(resource, doc) {
|
|
153
|
+
const file = this._leaseFile(resource);
|
|
154
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
155
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
156
|
+
fs.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
157
|
+
fs.renameSync(tmp, file);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
readLease(resource) { return this.withExclusive(`lease-${resource}`, () => this._readLease(resource)); }
|
|
161
|
+
|
|
162
|
+
acquireLease(resource, { holder, ttlMs, now }) {
|
|
163
|
+
if (typeof holder !== 'string' || !holder) throw typedError('BAD_HOLDER', 'a lease needs a holder');
|
|
164
|
+
if (!(Number.isFinite(ttlMs) && ttlMs >= 1) || !Number.isFinite(now)) throw typedError('BAD_LEASE', 'a lease needs a positive ttlMs and a now');
|
|
165
|
+
const ttl = Math.min(ttlMs, BACKEND_LIMITS.maxLeaseMs);
|
|
166
|
+
return this.withExclusive(`lease-${resource}`, () => {
|
|
167
|
+
const cur = this._readLease(resource);
|
|
168
|
+
if (cur && cur.holder && cur.expiresAt > now) return { ok: false, code: 'held', heldBy: cur.holder, expiresAt: cur.expiresAt, fence: cur.fence };
|
|
169
|
+
const fence = (cur?.fence ?? 0) + 1;
|
|
170
|
+
const doc = { resource, holder, fence, expiresAt: now + ttl, acquiredAt: now, node: this.nodeId };
|
|
171
|
+
this._writeLease(resource, doc);
|
|
172
|
+
return { ok: true, fence, expiresAt: doc.expiresAt, takenOver: !!(cur && cur.holder) };
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
renewLease(resource, { holder, fence, ttlMs, now }) {
|
|
177
|
+
const ttl = Math.min(ttlMs, BACKEND_LIMITS.maxLeaseMs);
|
|
178
|
+
return this.withExclusive(`lease-${resource}`, () => {
|
|
179
|
+
const cur = this._readLease(resource);
|
|
180
|
+
if (!cur || cur.holder !== holder || cur.fence !== fence) return { ok: false, code: 'lost', reason: 'the lease is held by another holder or generation' };
|
|
181
|
+
if (cur.expiresAt <= now) return { ok: false, code: 'expired', reason: 'the lease expired; it must be acquired again' };
|
|
182
|
+
cur.expiresAt = now + ttl;
|
|
183
|
+
this._writeLease(resource, cur);
|
|
184
|
+
return { ok: true, fence, expiresAt: cur.expiresAt };
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
releaseLease(resource, { holder, fence }) {
|
|
189
|
+
return this.withExclusive(`lease-${resource}`, () => {
|
|
190
|
+
const cur = this._readLease(resource);
|
|
191
|
+
if (!cur || cur.holder !== holder || cur.fence !== fence) return { ok: false, code: 'not-holder', reason: 'only the current holder, with its fence, can release' };
|
|
192
|
+
this._writeLease(resource, { resource, holder: null, fence: cur.fence, expiresAt: 0, node: this.nodeId });
|
|
193
|
+
return { ok: true };
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Create the marker that makes a directory a shared backend. An explicit operator act; `openBackend` never does it. */
|
|
199
|
+
export function initSharedBackend(dir, { id = crypto.randomBytes(8).toString('hex') } = {}) {
|
|
200
|
+
const root = path.resolve(dir);
|
|
201
|
+
fs.mkdirSync(root, { recursive: true });
|
|
202
|
+
const marker = path.join(root, BACKEND_MARKER);
|
|
203
|
+
if (fs.existsSync(marker)) return { ok: true, created: false };
|
|
204
|
+
fs.writeFileSync(marker, JSON.stringify({ schema: BACKEND_MARKER_SCHEMA, kind: 'shared-dir', id }), { flag: 'wx' });
|
|
205
|
+
return { ok: true, created: true };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const createLocalBackend = (dir, o = {}) => { fs.mkdirSync(path.resolve(dir), { recursive: true }); return new FileLockBackend({ kind: 'local-fs', root: dir, requireMarker: false, ...o }); };
|
|
209
|
+
export const createSharedBackend = (dir, o = {}) => new FileLockBackend({ kind: 'shared-dir', root: dir, requireMarker: true, ...o });
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Select a backend. `mode` is 'local' or 'shared'. A shared backend that is not usable returns `{ ok: false, state: 'blocked', ... }`;
|
|
213
|
+
* there is no fallback to the local backend, by design.
|
|
214
|
+
*/
|
|
215
|
+
export function openBackend({ mode, dir }) {
|
|
216
|
+
if (mode !== 'local' && mode !== 'shared') return { ok: false, state: 'blocked', code: 'invalid-mode', reason: "mode must be 'local' or 'shared'" };
|
|
217
|
+
if (typeof dir !== 'string' || !dir) return { ok: false, state: 'blocked', code: 'backend-missing', reason: 'a backend directory is required' };
|
|
218
|
+
const backend = mode === 'local' ? createLocalBackend(dir) : createSharedBackend(dir);
|
|
219
|
+
const p = backend.probe();
|
|
220
|
+
return p.ok ? { ok: true, backend } : p;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------- offline export and import
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A single self-checking file holding the portfolio store and ledger (and optionally the deletion log), for moving state to an
|
|
227
|
+
* air-gapped machine or archiving it. Reads nothing from the network. Refused if the store fails verification or any text in it has a
|
|
228
|
+
* secret shape.
|
|
229
|
+
*/
|
|
230
|
+
export function exportState({ storeFile, outFile, retentionLog = null, now }) {
|
|
231
|
+
const storeText = fs.readFileSync(storeFile, 'utf8');
|
|
232
|
+
if (storeText.length > BACKEND_LIMITS.maxExportBytes) throw typedError('EXPORT_TOO_LARGE', 'the store is larger than the export limit');
|
|
233
|
+
const store = JSON.parse(storeText);
|
|
234
|
+
const v = verifyStore(store);
|
|
235
|
+
if (!v.ok) throw typedError('STORE_CORRUPT', `refusing to export a store that fails verification: ${v.errors[0].code}`);
|
|
236
|
+
const ledger = readLedger(storeFile);
|
|
237
|
+
const log = retentionLog && fs.existsSync(retentionLog) ? fs.readFileSync(retentionLog, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : [];
|
|
238
|
+
const body = { schema: STATE_EXPORT_SCHEMA, version: 1, ...(store.synthetic ? { synthetic: true } : {}), exportedAtMs: now, store, ledger, retentionLog: log };
|
|
239
|
+
const secret = findSecret(JSON.stringify(body));
|
|
240
|
+
if (secret) throw typedError('SECRET_IN_EXPORT', `the state contains a secret-shaped string (${secret}); it was not exported`);
|
|
241
|
+
const doc = { ...body, digest: digestOf(body) };
|
|
242
|
+
fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true });
|
|
243
|
+
const tmp = `${outFile}.tmp-${process.pid}`;
|
|
244
|
+
fs.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
245
|
+
fs.renameSync(tmp, outFile);
|
|
246
|
+
return { ok: true, digest: doc.digest, units: Object.keys(store.units).length };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Offline verification of an export: re-derives its digest and verifies the store inside. Executes nothing. */
|
|
250
|
+
export function verifyStateExport(file) {
|
|
251
|
+
let doc;
|
|
252
|
+
try {
|
|
253
|
+
const st = fs.lstatSync(file);
|
|
254
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > BACKEND_LIMITS.maxExportBytes) return { ok: false, code: 'BAD_FILE', reason: 'not a regular file within the size limit' };
|
|
255
|
+
doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
256
|
+
} catch { return { ok: false, code: 'BAD_FILE', reason: 'unreadable or not JSON' }; }
|
|
257
|
+
if (doc?.schema !== STATE_EXPORT_SCHEMA || doc.version !== 1) return { ok: false, code: 'BAD_SCHEMA', reason: 'not a portfolio state export of a supported version' };
|
|
258
|
+
const { digest, ...body } = doc;
|
|
259
|
+
if (digestOf(body) !== digest) return { ok: false, code: 'DIGEST_MISMATCH', reason: 'the export was modified after it was written' };
|
|
260
|
+
const v = verifyStore(doc.store);
|
|
261
|
+
if (!v.ok) return { ok: false, code: 'STORE_INVALID', reason: `the store inside fails verification: ${v.errors[0].code}` };
|
|
262
|
+
return { ok: true, doc };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Import into a backend that holds no portfolio store yet. Verifies first; never overwrites. */
|
|
266
|
+
export function importState({ from, backend, name = 'portfolio-store.json' }) {
|
|
267
|
+
const p = backend.probe();
|
|
268
|
+
if (!p.ok) return p;
|
|
269
|
+
const v = verifyStateExport(from);
|
|
270
|
+
if (!v.ok) return { ok: false, state: 'rejected', code: v.code, reason: v.reason };
|
|
271
|
+
const file = backend.storeFile(name);
|
|
272
|
+
return backend.withExclusive('import', () => {
|
|
273
|
+
if (fs.existsSync(file)) return { ok: false, state: 'rejected', code: 'WOULD_OVERWRITE', reason: 'the backend already holds a portfolio store; import never overwrites' };
|
|
274
|
+
fs.writeFileSync(file, JSON.stringify(v.doc.store), { flag: 'wx', mode: 0o600 });
|
|
275
|
+
fs.writeFileSync(ledgerPathFor(file), JSON.stringify(v.doc.ledger), { flag: 'wx', mode: 0o600 });
|
|
276
|
+
return { ok: true, file, units: Object.keys(v.doc.store.units).length };
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|