@clear-capabilities/agentic-security-scanner 0.162.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 +26 -0
- package/bin/agentic-security.js +87 -0
- 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 +1 -1
- 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 +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- 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 +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- 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 +5 -2
- 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 +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- 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/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 +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- 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 +1 -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/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { applyMeasuredTrust } from './model-trust.js';
|
|
2
|
+
import { featureStatus } from './assurance/config.js';
|
|
3
|
+
import { buildRoutingTask } from './routing/outcomes.js';
|
|
4
|
+
import { routeConstrained } from './routing/decide.js';
|
|
5
|
+
import { resolveRoutingControl } from './routing/control.js';
|
|
2
6
|
// Capability-based model routing for cost-sensitive subagent dispatch.
|
|
3
7
|
//
|
|
4
8
|
// A declarative CWE/severity → model policy. When the orchestrator is about to
|
|
@@ -131,6 +135,42 @@ export function routeModelWithTrust(finding, ledger = null) {
|
|
|
131
135
|
);
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
// X-604: constrained, calibrated routing, behind the `model-routing` feature (off by default).
|
|
139
|
+
//
|
|
140
|
+
// With the feature off (or no configuration given) this returns EXACTLY what `routeModelWithTrust` returns, the same object shape, so
|
|
141
|
+
// nothing existing changes (test/routing/unchanged-when-off.test.js pins the outputs). With it on, the capability route above becomes
|
|
142
|
+
// the BASELINE: capability, privacy, context, minimum-quality and budget constraints are applied first (routing/decide.js), cost or
|
|
143
|
+
// latency is optimised only among compliant candidates, and the decision carries its explanation. A blocked decision selects nothing:
|
|
144
|
+
// `model` is null and `blocked` is true, so a caller can never mistake it for a route.
|
|
145
|
+
const EXT_LANGUAGE = { js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', java: 'java', kt: 'kotlin', go: 'go', rb: 'ruby', php: 'php', cs: 'csharp', rs: 'rust', hs: 'haskell', nix: 'nix' };
|
|
146
|
+
function languageOfFinding(finding) {
|
|
147
|
+
const m = typeof finding?.file === 'string' ? finding.file.match(/\.([A-Za-z0-9]+)$/) : null;
|
|
148
|
+
return (m && EXT_LANGUAGE[m[1].toLowerCase()]) || 'unknown';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function routeModelWithPolicy(finding, ctx = {}) {
|
|
152
|
+
const baseline = routeModelWithTrust(finding, ctx.ledger || null);
|
|
153
|
+
if (!ctx.config || featureStatus(ctx.config, 'model-routing').status !== 'ok') return baseline;
|
|
154
|
+
// X-608.AC03: the operator can pin or disable adaptive routing. Disabled leaves the capability route exactly as it was; a pin chooses
|
|
155
|
+
// that model without optimisation (sending a prompt still passes the egress policy in the guarded adapter). A bad value fails closed.
|
|
156
|
+
const control = ctx.routingControl || resolveRoutingControl({ env: ctx.env || process.env, options: ctx.routingOptions || {} });
|
|
157
|
+
if (control.mode === 'disabled') return { ...baseline, routingControl: control };
|
|
158
|
+
if (control.mode === 'pinned') return { model: control.pin, effort: baseline.effort, reason: control.reason, trust: baseline.trust, baseline, pinned: true, routingControl: control };
|
|
159
|
+
const built = buildRoutingTask({
|
|
160
|
+
taskId: ctx.taskId || finding?.stableId || finding?.id || 'unidentified-task', taskKind: ctx.taskKind || 'repair',
|
|
161
|
+
language: ctx.language || languageOfFinding(finding), vulnClass: parseCwe(finding?.cwe) || 'no-cwe',
|
|
162
|
+
contextTokens: ctx.contextTokens ?? 0, requiredCapabilities: ctx.requiredCapabilities || [], dataClass: ctx.dataClass || 'source-code',
|
|
163
|
+
synthetic: ctx.synthetic === true,
|
|
164
|
+
});
|
|
165
|
+
if (!built.ok) return { model: null, effort: null, blocked: true, reason: `routing task is invalid: ${built.errors.map((e) => e.path).join(', ')}`, trust: baseline.trust, baseline };
|
|
166
|
+
const decision = routeConstrained({
|
|
167
|
+
task: built.task, candidates: ctx.candidates || [], calibration: ctx.calibration || null, policy: ctx.policy, now: ctx.now,
|
|
168
|
+
baselineRoute: baseline, egress: ctx.egress, scanRoot: ctx.scanRoot || null, manifestEgress: ctx.manifestEgress || null, allowSynthetic: ctx.allowSynthetic === true,
|
|
169
|
+
});
|
|
170
|
+
if (decision.status === 'blocked') return { model: null, effort: null, blocked: true, reason: decision.reason, trust: baseline.trust, baseline, decision };
|
|
171
|
+
return { model: decision.selected.model, effort: baseline.effort, reason: decision.reason, trust: baseline.trust, baseline, decision, constrained: decision.status === 'routed' };
|
|
172
|
+
}
|
|
173
|
+
|
|
134
174
|
// Route a list of findings. Returns [{ finding, model, effort, reason }, …].
|
|
135
175
|
export function routeModelForFindings(findings) {
|
|
136
176
|
const list = Array.isArray(findings) ? findings : [];
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
// tree termination are not implemented on the namespace backend), which the
|
|
24
24
|
// runner reports as `unsupported`, and no Linux outcome is claimed.
|
|
25
25
|
import { defineOracle } from './oracle.js';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
26
27
|
import { digestOf } from '../assurance/identity.js';
|
|
28
|
+
import { expressionProblems } from '../invariants/expressions.js';
|
|
29
|
+
import { evaluateAssertions, sanitizeSnapshot, eventSequence, hasDurableEffect } from '../invariants/state-assertions.js';
|
|
27
30
|
|
|
28
31
|
const PLATFORMS = Object.freeze({
|
|
29
32
|
darwin: { status: 'supported', note: 'executed by this repository\'s tests on the macOS development host (userspace confinement backend)' },
|
|
@@ -357,4 +360,172 @@ finish({ results });
|
|
|
357
360
|
},
|
|
358
361
|
});
|
|
359
362
|
|
|
360
|
-
|
|
363
|
+
// ------------------------------------------------------------------ business state (X-404)
|
|
364
|
+
|
|
365
|
+
const BS_MAX_OPS = 16;
|
|
366
|
+
const BS_MAX_ITEMS = 12;
|
|
367
|
+
|
|
368
|
+
function bsStepProblem(step, actorIds) {
|
|
369
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) return 'a step must be an object';
|
|
370
|
+
if (!actorIds.has(step.actor)) return 'a step actor must be a declared actor';
|
|
371
|
+
if (!isId(step.action)) return 'a step action must be an identifier';
|
|
372
|
+
if (step.args !== undefined) {
|
|
373
|
+
if (!Array.isArray(step.args) || step.args.length > 4) return 'step args must be an array of at most 4';
|
|
374
|
+
try { if (Buffer.byteLength(JSON.stringify(step.args)) > 400) return 'step args are too large'; } catch { return 'step args must be JSON'; }
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Problems with one sequence of items (steps or deterministic parallel groups), counting operations toward the shared ceiling. */
|
|
380
|
+
function bsSequenceProblem(items, actorIds, label) {
|
|
381
|
+
if (!Array.isArray(items) || items.length < 1 || items.length > BS_MAX_ITEMS) return `${label} must hold 1 to ${BS_MAX_ITEMS} items`;
|
|
382
|
+
let ops = 0;
|
|
383
|
+
for (const it of items) {
|
|
384
|
+
if (it && Array.isArray(it.parallel)) {
|
|
385
|
+
if (it.parallel.length < 2 || it.parallel.length > 4) return 'a parallel group holds 2 to 4 steps';
|
|
386
|
+
const sched = it.schedule;
|
|
387
|
+
if (!Array.isArray(sched) || sched.length !== it.parallel.length || [...sched].sort().join() !== it.parallel.map((_, i) => i).join()) return 'a parallel group needs a schedule that is a permutation of its steps';
|
|
388
|
+
for (const s of it.parallel) { const why = bsStepProblem(s, actorIds); if (why) return why; }
|
|
389
|
+
ops += it.parallel.length;
|
|
390
|
+
} else {
|
|
391
|
+
const why = bsStepProblem(it, actorIds);
|
|
392
|
+
if (why) return why;
|
|
393
|
+
ops += 1;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return ops > BS_MAX_OPS ? `${label} exceeds ${BS_MAX_OPS} operations` : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
400
|
+
|
|
401
|
+
const businessState = defineOracle({
|
|
402
|
+
id: 'business-state', class: 'business-state', version: '1',
|
|
403
|
+
requiresFeature: 'invariant-scenarios',
|
|
404
|
+
description: 'Does a bounded stateful scenario drive an application into a state its invariant forbids? The harness gives the application a durable store and an effect recorder, plays a control sequence and an adversarial sequence (twice, to detect nondeterministic scheduling), and logs every durable write and emitted effect. The verifier evaluates the invariant\'s forbidden outcomes over that log; the response status is recorded but never decides.',
|
|
405
|
+
prerequisites: ['node-runtime', 'confinement-backend'],
|
|
406
|
+
platforms: PLATFORMS, budgets: COMMON_BUDGETS,
|
|
407
|
+
negativeControls: [{ id: 'tenant-scoped-writes', description: 'the same application scoping every write to the caller\'s tenant must leave the forbidden state unreached while its control flow still changes durable state', expectedOutcome: 'refuted' }],
|
|
408
|
+
fixtures: { dir: 'test/fixtures/oracles/business-state', positive: 'positive', negative: 'negative', inconclusive: 'inconclusive' },
|
|
409
|
+
limitations: [
|
|
410
|
+
'the application is a factory taking no arguments that returns action functions (ctx, ...args); ctx.store and ctx.emit are in-memory stand-ins, so durable state held anywhere else (a real database, a queue, the file system) is not observed',
|
|
411
|
+
'concurrency is cooperative scheduling inside one process with a fixed start order: it exercises interleavings at await points, not parallel execution, and a scenario whose two runs disagree is reported inconclusive rather than guessed',
|
|
412
|
+
'only the declared forbidden outcomes are checked, over the declared actors, tenants and steps; a clean result is a statement about this bounded scenario, not about the application',
|
|
413
|
+
'a target that recognises the oracle and forges its own log entries is not distinguishable from a real violation (the same limit every adapter states)',
|
|
414
|
+
],
|
|
415
|
+
harnessSource: `${PROLOGUE}
|
|
416
|
+
import crypto from 'node:crypto';
|
|
417
|
+
const clone = (v) => (v === undefined ? null : JSON.parse(JSON.stringify(v)));
|
|
418
|
+
const actorOf = (id) => INPUT.actors.find((a) => a.id === id);
|
|
419
|
+
async function play(items, phase) {
|
|
420
|
+
const data = new Map();
|
|
421
|
+
for (const s of INPUT.seed) data.set(s.key, clone(s.value));
|
|
422
|
+
const initial = Object.fromEntries(data);
|
|
423
|
+
const log = [];
|
|
424
|
+
const api = await Promise.resolve(fn());
|
|
425
|
+
async function runOp(opIndex, itemIndex, step) {
|
|
426
|
+
const a = actorOf(step.actor);
|
|
427
|
+
const entry = { item: itemIndex, op: opIndex, phase, actor: step.actor, action: step.action, ok: false, status: null, response: null, writes: [], events: [] };
|
|
428
|
+
log.push(entry);
|
|
429
|
+
const raw = [];
|
|
430
|
+
const ctx = {
|
|
431
|
+
id: a.id, tenant: a.tenant, role: a.role,
|
|
432
|
+
store: {
|
|
433
|
+
read: (k) => (data.has(String(k)) ? clone(data.get(String(k))) : null),
|
|
434
|
+
list: (p) => [...data.keys()].filter((k) => k.startsWith(String(p))).sort(),
|
|
435
|
+
write: (k, v) => { k = String(k); raw.push({ key: k, before: data.has(k) ? clone(data.get(k)) : null, after: clone(v) }); data.set(k, clone(v)); },
|
|
436
|
+
remove: (k) => { k = String(k); if (!data.has(k)) return; raw.push({ key: k, before: clone(data.get(k)), after: null }); data.delete(k); },
|
|
437
|
+
},
|
|
438
|
+
emit: (name, payload) => { entry.events.push({ name: String(name).slice(0, 64), key: payload && payload.key !== undefined ? String(payload.key).slice(0, 64) : null }); },
|
|
439
|
+
};
|
|
440
|
+
try {
|
|
441
|
+
const f = api && api[step.action];
|
|
442
|
+
if (typeof f !== 'function') throw new Error('no such action');
|
|
443
|
+
const r = await Promise.resolve(f.call(api, ctx, ...(step.args || [])));
|
|
444
|
+
entry.ok = true;
|
|
445
|
+
entry.status = r && typeof r.status === 'number' ? r.status : null;
|
|
446
|
+
try { entry.response = JSON.stringify(r === undefined ? null : r).slice(0, 300); } catch (e) { entry.response = null; }
|
|
447
|
+
} catch (e) { entry.error = text(e).slice(0, 80); }
|
|
448
|
+
const net = new Map();
|
|
449
|
+
for (const w of raw) { const p = net.get(w.key); net.set(w.key, p ? { key: w.key, before: p.before, after: w.after } : w); }
|
|
450
|
+
entry.writes = [...net.values()].filter((w) => JSON.stringify(w.before) !== JSON.stringify(w.after));
|
|
451
|
+
}
|
|
452
|
+
let i = 0;
|
|
453
|
+
for (const item of items) {
|
|
454
|
+
if (item.parallel) await Promise.allSettled(item.schedule.map((idx) => runOp(idx, i, item.parallel[idx])));
|
|
455
|
+
else await runOp(0, i, item);
|
|
456
|
+
i++;
|
|
457
|
+
}
|
|
458
|
+
const final = Object.fromEntries(data);
|
|
459
|
+
data.clear();
|
|
460
|
+
return { initial, steps: log, final, cleaned: data.size === 0 };
|
|
461
|
+
}
|
|
462
|
+
let c = null; let a1 = null; let a2 = null;
|
|
463
|
+
try { c = await play(INPUT.control, 'control'); } catch (e) { out.controlFailed = true; }
|
|
464
|
+
try { a1 = await play(INPUT.attack, 'attack'); a2 = await play(INPUT.attack, 'attack'); } catch (e) { out.attackFailed = true; }
|
|
465
|
+
out.control = c; out.attack = a1;
|
|
466
|
+
if (a1 && a2) out.replayDigest = crypto.createHash('sha256').update(JSON.stringify(a2)).digest('hex');
|
|
467
|
+
if (JSON.stringify(out).length > 15000) { delete out.control; delete out.attack; out.oversize = true; }
|
|
468
|
+
finish({});
|
|
469
|
+
`,
|
|
470
|
+
validateInputs(i) {
|
|
471
|
+
if (!isId(i.export)) return 'export must be an identifier';
|
|
472
|
+
const inv = i.invariant;
|
|
473
|
+
if (!inv || typeof inv.id !== 'string' || !inv.id.startsWith('inv:') || !isStr(inv.key, 64) || !Number.isInteger(inv.revision) || !isStr(inv.class, 64)) return 'invariant needs id, key, revision and class';
|
|
474
|
+
if (!Array.isArray(i.actors) || i.actors.length < 1 || i.actors.length > 8 || !i.actors.every((a) => a && isStr(a.id, 64) && isStr(a.tenant, 64) && isStr(a.role, 64))) return 'actors must hold 1 to 8 entries with id, tenant and role';
|
|
475
|
+
const actorIds = new Set(i.actors.map((a) => a.id));
|
|
476
|
+
if (actorIds.size !== i.actors.length) return 'actor ids must be unique';
|
|
477
|
+
if (i.markers !== undefined && (!Array.isArray(i.markers) || i.markers.length > 16 || !i.markers.every((m) => m && isStr(m.tenant, 64) && isStr(m.marker, 64)))) return 'markers must be at most 16 entries of tenant and marker';
|
|
478
|
+
if (!Array.isArray(i.seed) || i.seed.length > 16) return 'seed must hold at most 16 records';
|
|
479
|
+
for (const s of i.seed) {
|
|
480
|
+
if (!s || !isStr(s.key, 64) || !s.value || typeof s.value !== 'object' || Array.isArray(s.value)) return 'every seed record needs a key and an object value';
|
|
481
|
+
try { if (Buffer.byteLength(JSON.stringify(s.value)) > 400) return 'a seed record is too large'; } catch { return 'seed values must be JSON'; }
|
|
482
|
+
}
|
|
483
|
+
const c = bsSequenceProblem(i.control, actorIds, 'control'); if (c) return c;
|
|
484
|
+
const a = bsSequenceProblem(i.attack, actorIds, 'attack'); if (a) return a;
|
|
485
|
+
if (!Array.isArray(i.forbidden) || i.forbidden.length < 1 || i.forbidden.length > 8) return 'forbidden must hold 1 to 8 expressions';
|
|
486
|
+
for (const f of i.forbidden) { const p = expressionProblems(f); if (p.length) return `forbidden: ${p[0]}`; }
|
|
487
|
+
if (!Array.isArray(i.cleanup) || i.cleanup.length !== 1 || i.cleanup[0]?.action !== 'discard-world') return 'cleanup must be exactly [{ action: "discard-world" }]';
|
|
488
|
+
return null;
|
|
489
|
+
},
|
|
490
|
+
prepare: (i) => ({ export: i.export, actors: i.actors.map((a) => ({ id: a.id, tenant: a.tenant, role: a.role })), seed: i.seed, control: i.control, attack: i.attack }),
|
|
491
|
+
interpret({ inputs, result }) {
|
|
492
|
+
if (!result || result.loadFailed) return { satisfied: null, preconditionsHeld: false, observed: { targetLoaded: false }, reason: 'the target failed to load' };
|
|
493
|
+
if (result.oversize) return { satisfied: null, preconditionsHeld: false, observed: { logWithinCap: false }, reason: 'the scenario log exceeded the size cap, so no assertion was evaluated' };
|
|
494
|
+
if (result.controlFailed || result.attackFailed || !result.control || !result.attack) return { satisfied: null, preconditionsHeld: false, observed: { applicationStarted: false }, reason: 'the application factory failed to start for the scenario' };
|
|
495
|
+
const actors = Object.fromEntries(inputs.actors.map((a) => [a.id, { tenant: a.tenant, role: a.role }]));
|
|
496
|
+
const common = { invariantId: inputs.invariant.id, forbidden: inputs.forbidden, actors, markers: inputs.markers || [] };
|
|
497
|
+
const controlResults = evaluateAssertions({ ...common, phaseLog: result.control });
|
|
498
|
+
const attackResults = evaluateAssertions({ ...common, phaseLog: result.attack });
|
|
499
|
+
const deterministic = typeof result.replayDigest === 'string' && result.replayDigest === sha256hex(JSON.stringify(result.attack));
|
|
500
|
+
const preconditions = {
|
|
501
|
+
controlRan: result.control.steps.length > 0 && result.control.steps.every((s) => s.ok === true),
|
|
502
|
+
controlDurableEffect: hasDurableEffect(result.control),
|
|
503
|
+
controlClean: controlResults.every((r) => !r.violated),
|
|
504
|
+
cleanedUp: result.control.cleaned === true && result.attack.cleaned === true,
|
|
505
|
+
deterministic,
|
|
506
|
+
};
|
|
507
|
+
const controlHeld = preconditions.controlRan && preconditions.controlDurableEffect && preconditions.controlClean && preconditions.cleanedUp;
|
|
508
|
+
// A forbidden outcome reached by the CONTROL flow is a violation too (the legitimate path itself breaks the contract); it is
|
|
509
|
+
// reported under its own phase and cannot be mistaken for an adversarial finding.
|
|
510
|
+
const assertionsAll = [...attackResults.map((r) => ({ ...r, phase: 'attack' })), ...controlResults.filter((r) => r.violated).map((r) => ({ ...r, phase: 'control' }))];
|
|
511
|
+
const violated = assertionsAll.filter((r) => r.violated);
|
|
512
|
+
const report = {
|
|
513
|
+
schema: 'agentic-security/business-state-report', invariant: { id: inputs.invariant.id, key: inputs.invariant.key, revision: inputs.invariant.revision, class: inputs.invariant.class },
|
|
514
|
+
preconditions, snapshots: { initial: sanitizeSnapshot(result.attack.initial), final: sanitizeSnapshot(result.attack.final) },
|
|
515
|
+
events: eventSequence(result.attack),
|
|
516
|
+
assertions: assertionsAll.map((r) => ({ id: r.id, phase: r.phase, op: r.op, violated: r.violated, ops: r.ops, detail: r.detail, evidenceId: r.evidenceId })),
|
|
517
|
+
};
|
|
518
|
+
const observed = {
|
|
519
|
+
preconditions, violatedAssertions: violated.map((r) => r.id), assertions: assertionsAll.length,
|
|
520
|
+
evidenceIds: assertionsAll.map((r) => r.evidenceId), reportDigest: digestOf(report),
|
|
521
|
+
};
|
|
522
|
+
const evidenceItems = assertionsAll.map((r) => ({ id: r.evidenceId, digest: digestOf({ id: r.id, phase: r.phase, op: r.op, violated: r.violated, ops: r.ops }) }));
|
|
523
|
+
const base = { observed, report, evidenceItems };
|
|
524
|
+
if (!deterministic) return { ...base, satisfied: null, preconditionsHeld: false, reason: 'the adversarial sequence did not reproduce itself across two fresh runs (nondeterministic scheduling), so no verdict is formed' };
|
|
525
|
+
if (violated.length) return { ...base, satisfied: true, preconditionsHeld: controlHeld, reason: `forbidden durable state reached: ${violated.map((r) => `${r.id} in the ${r.phase} sequence (${r.detail})`).join('; ').slice(0, 300)}` };
|
|
526
|
+
if (controlHeld) return { ...base, satisfied: false, preconditionsHeld: true, reason: 'the control flow changed durable state as intended and no forbidden outcome was observed in the adversarial sequence' };
|
|
527
|
+
return { ...base, satisfied: null, preconditionsHeld: false, reason: 'the control flow did not show the application working (no durable effect, an error, or a violation in the legitimate flow), so the absence of a violation proves nothing' };
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
export const ADAPTERS = Object.freeze([injection, authorization, stateTransition, sideEffect, parserResource, replayIdempotency, functionalRegression, businessState]);
|
|
@@ -140,7 +140,8 @@ export function checkFixturePins(adapter, scannerRoot, pins = loadPins(scannerRo
|
|
|
140
140
|
|
|
141
141
|
// ---------------------------------------------------------------- execution
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
// An adapter that declares `requiresFeature` (X-404) needs that feature enabled too; conformance enables exactly the ones the adapter names.
|
|
144
|
+
const enabledConfig = (adapter) => resolveAssuranceConfig({ env: {}, overrides: { features: { [FEATURE_ID]: true, ...(adapter?.requiresFeature ? { [adapter.requiresFeature]: true } : {}) } } });
|
|
144
145
|
|
|
145
146
|
function requestFor(adapter, scannerRoot, kind, over = {}) {
|
|
146
147
|
const f = readFixture(scannerRoot, adapter, kind);
|
|
@@ -164,7 +165,7 @@ function requestFor(adapter, scannerRoot, kind, over = {}) {
|
|
|
164
165
|
export async function runAdapterConformance(adapter, { scannerRoot, run = runOracle, runOptions = {}, harnessProcesses = null } = {}) {
|
|
165
166
|
const checks = [];
|
|
166
167
|
const registry = { getOracle: (id) => (id === adapter.id ? adapter : null) };
|
|
167
|
-
const go = (req, extra = {}) => run(req, { config: enabledConfig(), registry, ...runOptions, ...extra });
|
|
168
|
+
const go = (req, extra = {}) => run(req, { config: enabledConfig(adapter), registry, ...runOptions, ...extra });
|
|
168
169
|
try {
|
|
169
170
|
const pos = await go(requestFor(adapter, scannerRoot, 'positive'));
|
|
170
171
|
if (pos.status === 'unsupported' || pos.status === 'blocked' || pos.status === 'disabled') {
|
|
@@ -46,7 +46,7 @@ import path from 'node:path';
|
|
|
46
46
|
import { runInBoundary } from '../../sandbox/trust-boundary.js';
|
|
47
47
|
import { detectBackend } from '../../sandbox/capabilities.js';
|
|
48
48
|
import { DOMAINS, settleVerification } from '../../sandbox/trust-domains.js';
|
|
49
|
-
import { resolveAssuranceConfig, featureStatus } from '../assurance/config.js';
|
|
49
|
+
import { resolveAssuranceConfig, featureStatus, FEATURES } from '../assurance/config.js';
|
|
50
50
|
import { digestOf, digestOfBytes } from '../assurance/identity.js';
|
|
51
51
|
import { isCommit, isPlainObject } from '../assurance/schema-kit.js';
|
|
52
52
|
import { emitVerification } from '../verification/emit.js';
|
|
@@ -56,6 +56,8 @@ export const ORACLE_CLASSES = Object.freeze([
|
|
|
56
56
|
'injection-execution', 'authorization-decision', 'state-transition', 'side-effect-reachability', 'parser-resource',
|
|
57
57
|
// X-205 and X-204: a replayed request that repeats its effect, and a differential behaviour check for a patch.
|
|
58
58
|
'replay-idempotency', 'functional-regression',
|
|
59
|
+
// X-404: durable business and tenant state judged over a bounded stateful scenario.
|
|
60
|
+
'business-state',
|
|
59
61
|
]);
|
|
60
62
|
const PLATFORM_STATUSES = Object.freeze(['supported', 'unverified', 'unsupported']);
|
|
61
63
|
export const FEATURE_ID = 'verification-oracles';
|
|
@@ -66,6 +68,7 @@ const RECEIPT_SCHEMA = 'agentic-security/oracle-receipt';
|
|
|
66
68
|
const RESERVED_PREFIX = '__oracle_';
|
|
67
69
|
const MAX_INPUT_BYTES = 64 * 1024;
|
|
68
70
|
const MAX_OBSERVED_BYTES = 4 * 1024;
|
|
71
|
+
const MAX_REPORT_BYTES = 32 * 1024;
|
|
69
72
|
const REQUEST_KEYS = ['oracleId', 'hypothesisId', 'finding', 'commit', 'files', 'entry', 'inputs', 'attempt', 'budgets', 'detectorOrigin'];
|
|
70
73
|
const BUDGET_KEYS = ['timeoutMs', 'graceMs', 'maxOutputBytes', 'maxFileBytes', 'maxFiles'];
|
|
71
74
|
|
|
@@ -129,6 +132,7 @@ export function validateOracleSpec(spec) {
|
|
|
129
132
|
}
|
|
130
133
|
if (!isPlainObject(spec.fixtures) || !['dir', 'positive', 'negative', 'inconclusive'].every((k) => typeof spec.fixtures[k] === 'string' && spec.fixtures[k])) errs.push('fixtures need dir, positive, negative and inconclusive');
|
|
131
134
|
if (!Array.isArray(spec.limitations) || spec.limitations.length === 0) errs.push('limitations must be stated');
|
|
135
|
+
if (spec.requiresFeature !== undefined && !(typeof spec.requiresFeature === 'string' && FEATURES[spec.requiresFeature])) errs.push('requiresFeature must name a known feature');
|
|
132
136
|
if (typeof spec.harnessSource !== 'string' || !spec.harnessSource.includes('__INPUT__')) errs.push('harnessSource (verifier-authored) is required');
|
|
133
137
|
for (const fn of ['prepare', 'validateInputs', 'interpret']) if (typeof spec[fn] !== 'function') errs.push(`${fn} must be a function`);
|
|
134
138
|
return errs;
|
|
@@ -335,6 +339,14 @@ export async function runOracle(request, o = {}) {
|
|
|
335
339
|
return none(gate.status === 'unsupported' ? 'unsupported' : 'not-run', `${FEATURE_ID} is not available: ${gate.reason}`, gate.status === 'unsupported' ? 'unsupported' : 'disabled', { gate });
|
|
336
340
|
}
|
|
337
341
|
|
|
342
|
+
// 1b. an adapter may require a second feature of its own (X-404: stateful invariant scenarios are separately opt-in)
|
|
343
|
+
if (oracle.requiresFeature) {
|
|
344
|
+
const g2 = featureStatus(config, oracle.requiresFeature);
|
|
345
|
+
if (g2.status !== 'ok') {
|
|
346
|
+
return none(g2.status === 'unsupported' ? 'unsupported' : 'not-run', `${oracle.requiresFeature} is not available: ${g2.reason}`, g2.status === 'unsupported' ? 'unsupported' : 'disabled', { gate: g2 });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
338
350
|
// 2. prerequisites: an unmet one is `unsupported`, never a pass, and nothing executes
|
|
339
351
|
const env = o.probeEnv || probeEnvironment();
|
|
340
352
|
const unmet = [];
|
|
@@ -413,14 +425,22 @@ export async function runOracle(request, o = {}) {
|
|
|
413
425
|
// identical record id); it is bound into the receipt instead.
|
|
414
426
|
digest: digestOf({ oracle: oracle.logicDigest, observed }), source: `oracle:${oracle.id}@${oracle.version}`,
|
|
415
427
|
}];
|
|
428
|
+
// An adapter may add per-assertion evidence items (stable ids, derived from the observed log) and a bounded report (sanitized
|
|
429
|
+
// snapshots, event sequence, assertion results). Both come from verifier-side interpretation, never from the target.
|
|
430
|
+
if (Array.isArray(judged.evidenceItems)) {
|
|
431
|
+
for (const it of judged.evidenceItems.slice(0, 16)) {
|
|
432
|
+
if (it && typeof it.id === 'string' && typeof it.digest === 'string') evidence.push({ id: it.id, kind: 'observation', producer: 'trusted-runner', digest: it.digest, source: `oracle:${oracle.id}@${oracle.version}` });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const report = judged.report !== undefined && Buffer.byteLength(JSON.stringify(judged.report) ?? '') <= MAX_REPORT_BYTES ? JSON.parse(JSON.stringify(judged.report)) : undefined;
|
|
416
436
|
const out = finishWithRecord(oracle, req, 'completed', outcomeFields(oracle, req, {
|
|
417
437
|
outcome, reason, attempt: attemptNo, evidence, preconditionsValid: evidenceIn.preconditionsHeld, backend: boundary.backend,
|
|
418
|
-
}), { executed: true, run: runInfo, environment, targetOutput: boundary.targetOutput });
|
|
438
|
+
}), { executed: true, run: runInfo, environment, targetOutput: boundary.targetOutput, ...(report !== undefined ? { report } : {}) });
|
|
419
439
|
out.receipt = issueReceipt({
|
|
420
440
|
schema: RECEIPT_SCHEMA, issuedBy: 'verifier',
|
|
421
441
|
oracle: { id: oracle.id, class: oracle.class, version: oracle.version, logicDigest: oracle.logicDigest },
|
|
422
442
|
request: { hypothesisId: req.hypothesisId, commit: req.commit ?? null, entry: req.entry, inputsDigest: digestOf(req.inputs), filesDigest: digestOf(req.files), attempt: attemptNo },
|
|
423
|
-
environment, run: runInfo, observed, harnessDigest,
|
|
443
|
+
environment, run: runInfo, observed, harnessDigest, reportDigest: report !== undefined ? digestOf(report) : null,
|
|
424
444
|
settled: { outcome, satisfied: judged.satisfied, preconditionsHeld: evidenceIn.preconditionsHeld }, recordId: out.record?.id ?? null,
|
|
425
445
|
});
|
|
426
446
|
return out;
|
|
@@ -33,7 +33,7 @@ export function manifestEntry(a) {
|
|
|
33
33
|
return {
|
|
34
34
|
id: a.id, class: a.class, version: a.version, logicDigest: a.logicDigest, description: a.description,
|
|
35
35
|
prerequisites: a.prerequisites.map((id) => ({ id, description: PREREQUISITES[id].description })),
|
|
36
|
-
platforms: a.platforms, budgets: a.budgets,
|
|
36
|
+
platforms: a.platforms, budgets: a.budgets, requiresFeature: a.requiresFeature ?? null,
|
|
37
37
|
negativeControls: a.negativeControls, fixtures: a.fixtures, limitations: a.limitations,
|
|
38
38
|
receipts: 'issued by the verifier domain only (runOracle); a caller-supplied receipt is not valid',
|
|
39
39
|
};
|
|
@@ -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
|
+
|