@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.
Files changed (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,250 @@
1
+ // Replay manifests and environment identity (X-203).
2
+ //
3
+ // Extends the existing proof-of-concept sandbox path (a payload run under the
4
+ // confinement backend, judged by an observed effect) with an identity for the run
5
+ // and a way to repeat it. It is NOT a second sandbox or a second runner: a replay
6
+ // is an oracle run (`oracles/oracle.js`), which goes through `runInBoundary` and
7
+ // therefore through the supervised process tree, output cap and cleanup of
8
+ // CORE-003. What this module adds is the pinned, validated description of the run.
9
+ //
10
+ // A manifest pins: the repository commit, the patch hash, the fixture hash, the
11
+ // toolchain (and container digest when there is one), the oracle id, version and
12
+ // logic digest, the inputs and the budgets. Replaying it on the same supported
13
+ // environment reproduces the verdict, and the identical verification record id.
14
+ //
15
+ // Honesty rules, each with a test:
16
+ // - A manifest is REJECTED, and nothing executes, when a content hash does not
17
+ // match the content, when the oracle changed since the manifest was made, when
18
+ // its scope asks for more than the oracle's declared scope (network, another
19
+ // oracle, a write outside the workspace) or when a budget exceeds the oracle's
20
+ // ceiling. The manifest is closed-world, so a field such as a model verdict
21
+ // cannot ride along.
22
+ // - A missing toolchain, container image or container runtime, or a disabled
23
+ // feature, is a typed PREREQUISITE and the attempt is resumable. Nothing is
24
+ // substituted for the execution that did not happen: the outcome is `not-run`
25
+ // (or `unsupported` when this build can never meet it), with no evidence.
26
+ // - This build never fetches anything. A prerequisite whose acquisition would
27
+ // need the network is reported as such, with the network denied.
28
+ // - Container execution is not implemented. A manifest pinning a container
29
+ // digest is therefore `unsupported` here, never run outside the container it
30
+ // names.
31
+ // - Toolchain identity is exact (runtime version, platform, architecture). A
32
+ // different patch release is a different environment, reported as an unmet
33
+ // prerequisite rather than silently tolerated.
34
+ import { digestOf, semanticId } from '../assurance/identity.js';
35
+ import { SCHEMA_VERSION, isCommit, isDigest, isPlainObject } from '../assurance/schema-kit.js';
36
+ import { detectBackend } from '../../sandbox/capabilities.js';
37
+ import { runOracle, recordWithoutExecution, isSafeRelativePath, FEATURE_ID } from '../oracles/oracle.js';
38
+ import * as registry from '../oracles/registry.js';
39
+
40
+ export const REPLAY_SCHEMA = 'agentic-security/replay-manifest';
41
+ export const ATTEMPT_SCHEMA = 'agentic-security/replay-attempt';
42
+ export const MAX_ATTEMPTS = 10;
43
+
44
+ const ALLOWED = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'repository', 'patch', 'fixture', 'toolchain', 'oracle', 'entry', 'inputs', 'inputsDigest', 'budgets', 'scope', 'expected'];
45
+ const REQUIRED = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'repository', 'patch', 'fixture', 'toolchain', 'oracle', 'entry', 'inputs', 'inputsDigest', 'budgets', 'scope'];
46
+ const ID_FIELDS = ALLOWED.filter((k) => k !== 'id');
47
+ const REPLAY_BUDGET_KEYS = ['timeoutMs', 'graceMs', 'maxOutputBytes'];
48
+ const EXPECTED_OUTCOMES = ['confirmed', 'refuted', 'inconclusive'];
49
+
50
+ /** The pinned scope a replay may have: exactly the oracle it names, no network, workspace-only writes. */
51
+ export function allowedScope(oracleId) {
52
+ return { executes: [oracleId], network: false, writes: 'workspace-only' };
53
+ }
54
+
55
+ /** Sanitized identity of the execution environment: facts about the toolchain, nothing about the host. */
56
+ export function environmentIdentity(overrides = {}) {
57
+ const base = {
58
+ runtime: 'node', version: process.versions.node, platform: process.platform, arch: process.arch,
59
+ containerDigest: null, backend: detectBackend(),
60
+ };
61
+ const env = { ...base, ...overrides };
62
+ return { ...env, toolchainDigest: digestOf({ runtime: env.runtime, version: env.version, platform: env.platform, arch: env.arch, containerDigest: env.containerDigest }) };
63
+ }
64
+
65
+ /**
66
+ * Build a manifest from the pieces of a verification. The oracle identity and the
67
+ * environment come from this build, not from the caller.
68
+ */
69
+ export function createReplayManifest({ hypothesisId, commit, fixtureFiles, patchFiles = null, oracleId, entry, inputs, budgets = {}, expected, environment }) {
70
+ const oracle = registry.getOracle(oracleId);
71
+ if (!oracle) throw new Error(`unknown oracle '${oracleId}'`);
72
+ const env = environment || environmentIdentity();
73
+ const m = {
74
+ schema: REPLAY_SCHEMA, schemaVersion: SCHEMA_VERSION, hypothesisId,
75
+ repository: { commit },
76
+ patch: patchFiles && Object.keys(patchFiles).length ? { digest: digestOf(patchFiles) } : null,
77
+ fixture: { digest: digestOf(fixtureFiles) },
78
+ toolchain: { runtime: env.runtime, version: env.version, platform: env.platform, arch: env.arch, containerDigest: env.containerDigest ?? null },
79
+ oracle: { id: oracle.id, version: oracle.version, logicDigest: oracle.logicDigest },
80
+ entry, inputs, inputsDigest: digestOf(inputs),
81
+ budgets: { timeoutMs: oracle.budgets.timeoutMs, graceMs: oracle.budgets.graceMs, maxOutputBytes: oracle.budgets.maxOutputBytes, ...budgets },
82
+ scope: allowedScope(oracle.id),
83
+ };
84
+ if (expected) m.expected = expected;
85
+ m.id = manifestId(m);
86
+ return m;
87
+ }
88
+
89
+ export function manifestId(m) { return semanticId('rpl', m, ID_FIELDS); }
90
+
91
+ function err(errors, code, path, message) { errors.push({ code, path, message }); }
92
+
93
+ /**
94
+ * Validate a manifest against the content it describes. Rejects, and the caller
95
+ * must not execute, on any error. Pure: reads no file and runs nothing.
96
+ */
97
+ export function validateReplayManifest(m, { fixtureFiles, patchFiles = null } = {}) {
98
+ const errors = [];
99
+ if (!isPlainObject(m)) { err(errors, 'NOT_AN_OBJECT', '', 'manifest must be an object'); return { ok: false, errors }; }
100
+ if (m.schema !== REPLAY_SCHEMA) { err(errors, 'SCHEMA_MISMATCH', 'schema', `expected '${REPLAY_SCHEMA}'`); return { ok: false, errors }; }
101
+ if (typeof m.schemaVersion !== 'string' || m.schemaVersion.split('.')[0] !== '1') { err(errors, 'UNSUPPORTED_MAJOR', 'schemaVersion', 'only major version 1 is supported'); return { ok: false, errors }; }
102
+ for (const k of Object.keys(m)) if (!ALLOWED.includes(k)) err(errors, 'UNKNOWN_FIELD', k, `field '${k}' is not part of a replay manifest`);
103
+ for (const k of REQUIRED) if (m[k] === undefined) err(errors, 'MISSING_FIELD', k, `required field '${k}' is missing`);
104
+ if (errors.length) return { ok: false, errors };
105
+
106
+ if (typeof m.hypothesisId !== 'string' || !m.hypothesisId) err(errors, 'BAD_TYPE', 'hypothesisId', 'must be a non-empty string');
107
+ if (!isPlainObject(m.repository) || !isCommit(m.repository.commit)) err(errors, 'BAD_COMMIT', 'repository.commit', 'a replay pins an exact 40 or 64 character commit');
108
+ if (!isPlainObject(m.fixture) || !isDigest(m.fixture.digest)) err(errors, 'BAD_DIGEST', 'fixture.digest', 'must be sha256:<64 hex>');
109
+ else if (!isPlainObject(fixtureFiles) || digestOf(fixtureFiles) !== m.fixture.digest) err(errors, 'HASH_MISMATCH', 'fixture.digest', 'the fixture content does not match its pinned hash');
110
+ const havePatch = isPlainObject(patchFiles) && Object.keys(patchFiles).length > 0;
111
+ if (m.patch === null) {
112
+ if (havePatch) err(errors, 'HASH_MISMATCH', 'patch', 'patch content was supplied but the manifest pins no patch');
113
+ } else if (!isPlainObject(m.patch) || !isDigest(m.patch.digest)) err(errors, 'BAD_DIGEST', 'patch.digest', 'must be null or { digest: sha256:<64 hex> }');
114
+ else if (!havePatch || digestOf(patchFiles) !== m.patch.digest) err(errors, 'HASH_MISMATCH', 'patch.digest', 'the patch content does not match its pinned hash');
115
+ if (!isDigest(m.inputsDigest) || !isPlainObject(m.inputs) || digestOf(m.inputs) !== m.inputsDigest) err(errors, 'HASH_MISMATCH', 'inputsDigest', 'the inputs do not match their pinned hash');
116
+
117
+ // Paths are part of the allowed execution scope: a fixture or patch that names a file outside the workspace is refused here,
118
+ // before anything can run, even when its hash matches.
119
+ for (const [label, files] of [['fixture', fixtureFiles], ['patch', patchFiles]]) {
120
+ for (const rel of Object.keys(isPlainObject(files) ? files : {})) {
121
+ if (!isSafeRelativePath(rel)) err(errors, 'SCOPE_VIOLATION', `${label}.files`, `path ${JSON.stringify(String(rel).slice(0, 60))} is not a safe relative path inside the workspace`);
122
+ }
123
+ }
124
+ if (typeof m.entry === 'string' && isPlainObject(fixtureFiles) && !(m.entry in { ...fixtureFiles, ...(isPlainObject(patchFiles) ? patchFiles : {}) })) err(errors, 'SCOPE_VIOLATION', 'entry', 'the entry must be one of the pinned files');
125
+
126
+ const tc = m.toolchain;
127
+ if (!isPlainObject(tc) || tc.runtime !== 'node' || typeof tc.version !== 'string' || typeof tc.platform !== 'string' || typeof tc.arch !== 'string') err(errors, 'BAD_TYPE', 'toolchain', 'needs runtime node, version, platform and arch');
128
+ else if (tc.containerDigest !== null && !isDigest(tc.containerDigest)) err(errors, 'BAD_DIGEST', 'toolchain.containerDigest', 'must be null or sha256:<64 hex>');
129
+
130
+ const oracle = isPlainObject(m.oracle) ? registry.getOracle(m.oracle.id) : null;
131
+ if (!oracle) err(errors, 'UNKNOWN_ORACLE', 'oracle.id', 'the manifest names an oracle this build does not have');
132
+ else {
133
+ if (m.oracle.version !== oracle.version || m.oracle.logicDigest !== oracle.logicDigest) err(errors, 'ORACLE_MISMATCH', 'oracle', 'the oracle version or logic digest differs from the pinned one: this is not the oracle the manifest was made with');
134
+ const sc = m.scope;
135
+ const want = allowedScope(oracle.id);
136
+ if (!isPlainObject(sc) || Object.keys(sc).some((k) => !(k in want))) err(errors, 'SCOPE_VIOLATION', 'scope', 'unknown scope keys');
137
+ else {
138
+ if (JSON.stringify(sc.executes) !== JSON.stringify(want.executes)) err(errors, 'SCOPE_VIOLATION', 'scope.executes', 'a replay may execute only the oracle it names');
139
+ if (sc.network !== false) err(errors, 'SCOPE_VIOLATION', 'scope.network', 'replay grants no network access');
140
+ if (sc.writes !== 'workspace-only') err(errors, 'SCOPE_VIOLATION', 'scope.writes', 'replay writes only inside its workspace');
141
+ }
142
+ const b = m.budgets;
143
+ if (!isPlainObject(b)) err(errors, 'BAD_TYPE', 'budgets', 'must be an object');
144
+ else {
145
+ for (const k of Object.keys(b)) if (!REPLAY_BUDGET_KEYS.includes(k)) err(errors, 'UNKNOWN_FIELD', `budgets.${k}`, 'not a replay budget');
146
+ for (const k of REPLAY_BUDGET_KEYS) {
147
+ if (!Number.isInteger(b[k]) || b[k] < 1) err(errors, 'BAD_TYPE', `budgets.${k}`, 'must be a positive integer');
148
+ else if (b[k] > oracle.budgets[k]) err(errors, 'BUDGET_EXCEEDED', `budgets.${k}`, `exceeds the oracle ceiling of ${oracle.budgets[k]}`);
149
+ }
150
+ }
151
+ }
152
+ if (typeof m.entry !== 'string' || !m.entry) err(errors, 'BAD_TYPE', 'entry', 'must be a non-empty string');
153
+ if (m.expected !== undefined && (!isPlainObject(m.expected) || Object.keys(m.expected).some((k) => k !== 'outcome') || !EXPECTED_OUTCOMES.includes(m.expected.outcome))) err(errors, 'BAD_TYPE', 'expected', `must be { outcome: one of ${EXPECTED_OUTCOMES.join(', ')} }`);
154
+ if (m.id !== manifestId(m)) err(errors, 'ID_MISMATCH', 'id', 'the manifest id does not match its content');
155
+ return { ok: errors.length === 0, errors };
156
+ }
157
+
158
+ // ---------------------------------------------------------------- prerequisites
159
+
160
+ /**
161
+ * Typed prerequisites that are not met on this host. `resumable` is true when
162
+ * meeting it is something an operator can do (install a toolchain, enable the
163
+ * feature) and false when this build cannot meet it at all.
164
+ */
165
+ export function unmetPrerequisites(m, host = environmentIdentity()) {
166
+ const out = [];
167
+ const tc = m.toolchain;
168
+ if (tc.containerDigest !== null) {
169
+ out.push({ kind: 'container-runtime', id: tc.containerDigest, state: 'unsupported', resumable: false, reason: 'container execution is not implemented in this build; the run is never executed outside the container the manifest names' });
170
+ out.push({ kind: 'container-image', id: tc.containerDigest, state: 'missing', resumable: false, reason: 'this build neither holds nor pulls container images' });
171
+ out.push({ kind: 'network', id: 'image-acquisition', state: 'denied', resumable: false, reason: 'acquiring a container image needs network access, which replay does not grant' });
172
+ }
173
+ if (tc.version !== host.version || tc.platform !== host.platform || tc.arch !== host.arch) {
174
+ out.push({
175
+ kind: 'toolchain', id: `${tc.runtime}@${tc.version} ${tc.platform}/${tc.arch}`, state: 'missing', resumable: true,
176
+ reason: `this host runs ${host.runtime}@${host.version} ${host.platform}/${host.arch}; the manifest pins an exact toolchain`,
177
+ });
178
+ out.push({ kind: 'network', id: 'toolchain-acquisition', state: 'denied', resumable: true, reason: 'installing the pinned toolchain needs network access, which replay does not grant; install it out of band and resume' });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function nextAttempt(resume, manifestId_) {
184
+ if (resume === undefined || resume === null) return { n: 1, history: [] };
185
+ if (!isPlainObject(resume) || resume.schema !== ATTEMPT_SCHEMA || resume.manifestId !== manifestId_) return { error: 'the attempt does not belong to this manifest' };
186
+ if (resume.status !== 'awaiting-prerequisites') return { error: `an attempt in status '${resume.status}' is not resumable` };
187
+ if (!Number.isInteger(resume.attempt) || resume.attempt < 1 || !Array.isArray(resume.history)) return { error: 'the attempt record is malformed' };
188
+ if (resume.attempt >= MAX_ATTEMPTS) return { error: `the attempt limit of ${MAX_ATTEMPTS} was reached` };
189
+ return { n: resume.attempt + 1, history: resume.history.slice(-MAX_ATTEMPTS) };
190
+ }
191
+
192
+ function attemptRecord(manifestId_, n, history, status, entry) {
193
+ return { schema: ATTEMPT_SCHEMA, manifestId: manifestId_, attempt: n, status, history: [...history, { attempt: n, ...entry }] };
194
+ }
195
+
196
+ /**
197
+ * Replay a manifest. Never throws.
198
+ *
199
+ * @param {object} bundle { manifest, fixtureFiles, patchFiles? }
200
+ * @param {object} [o]
201
+ * @param {object} [o.resume] an attempt returned earlier with status 'awaiting-prerequisites'
202
+ * @param {object} [o.hostEnvironment] environment identity override (test seam)
203
+ * @param {object} [o.config] assurance config (the verification-oracles feature is off by default)
204
+ * @param {object} [o.runOptions] passed to runOracle (test seams: deps, probeEnv, signal)
205
+ * @returns {Promise<object>} { status: 'completed'|'awaiting-prerequisites'|'rejected', executed, outcome, record, receipt, reproduced, prerequisites, attempt, errors }
206
+ */
207
+ export async function replayManifest(bundle, o = {}) {
208
+ const { manifest, fixtureFiles, patchFiles = null } = bundle || {};
209
+ const valid = validateReplayManifest(manifest, { fixtureFiles, patchFiles });
210
+ if (!valid.ok) return { status: 'rejected', executed: false, outcome: null, record: null, receipt: null, reproduced: null, prerequisites: [], attempt: null, errors: valid.errors };
211
+
212
+ const step = nextAttempt(o.resume, manifest.id);
213
+ if (step.error) return { status: 'rejected', executed: false, outcome: null, record: null, receipt: null, reproduced: null, prerequisites: [], attempt: null, errors: [{ code: 'ATTEMPT_REJECTED', path: 'resume', message: step.error }] };
214
+
215
+ const request = {
216
+ oracleId: manifest.oracle.id, hypothesisId: manifest.hypothesisId, commit: manifest.repository.commit,
217
+ files: { ...fixtureFiles, ...(patchFiles || {}) }, entry: manifest.entry, inputs: manifest.inputs, attempt: step.n,
218
+ budgets: manifest.budgets,
219
+ };
220
+ const waiting = (prerequisites, outcome, reason) => {
221
+ const record = recordWithoutExecution(manifest.oracle.id, request, outcome, reason, registry);
222
+ return {
223
+ status: 'awaiting-prerequisites', executed: false, outcome, record, receipt: null, reproduced: null, prerequisites, errors: [],
224
+ attempt: attemptRecord(manifest.id, step.n, step.history, 'awaiting-prerequisites', { outcome, prerequisites: prerequisites.map((p) => `${p.kind}:${p.state}`) }),
225
+ };
226
+ };
227
+
228
+ const unmet = unmetPrerequisites(manifest, o.hostEnvironment || environmentIdentity());
229
+ if (unmet.length) {
230
+ const never = unmet.some((p) => !p.resumable);
231
+ return waiting(unmet, never ? 'unsupported' : 'not-run', `replay prerequisite(s) not met: ${unmet.map((p) => `${p.kind} ${p.id} (${p.state})`).join('; ')}; nothing was executed`);
232
+ }
233
+
234
+ const run = await runOracle(request, { config: o.config, ...(o.runOptions || {}) });
235
+ if (run.status === 'rejected') return { status: 'rejected', executed: false, outcome: null, record: null, receipt: null, reproduced: null, prerequisites: [], attempt: null, errors: (run.errors || []).map((m) => ({ code: 'REQUEST_REJECTED', path: '', message: m })) };
236
+ if (!run.executed) {
237
+ const feature = run.status === 'disabled';
238
+ const prereq = feature
239
+ ? [{ kind: 'feature', id: FEATURE_ID, state: 'disabled', resumable: true, reason: run.reason }]
240
+ : (run.prerequisites || [{ id: 'trust-boundary', reason: run.reason }]).map((p) => ({ kind: p.id === 'trust-boundary' ? 'trust-boundary' : 'oracle-prerequisite', id: p.id, state: 'unmet', resumable: false, reason: p.reason }));
241
+ return waiting(prereq, run.outcome === 'unsupported' ? 'unsupported' : 'not-run', run.reason);
242
+ }
243
+ const reproduced = manifest.expected ? run.outcome === manifest.expected.outcome : null;
244
+ return {
245
+ status: 'completed', executed: true, outcome: run.outcome, record: run.record, receipt: run.receipt, reproduced, disclosure: run.disclosure,
246
+ ...(run.report !== undefined ? { report: run.report } : {}),
247
+ prerequisites: [], errors: [], run: run.run, environment: run.environment,
248
+ attempt: attemptRecord(manifest.id, step.n, step.history, 'completed', { outcome: run.outcome }),
249
+ };
250
+ }
@@ -0,0 +1,140 @@
1
+ // Provider adapter with auditable telemetry (X-602.AC03).
2
+ //
3
+ // One function turns a task run into an outcome record. It owns NO network client. Every call goes through guardedModelCall
4
+ // (assurance/bounded-io.js), which gates on the `model-routing` feature, evaluates the egress policy BEFORE the prompt exists, redacts
5
+ // the payload, enforces the request-size limit, runs the injected transport under a deadline with bounded retries, and appends each
6
+ // decision to the egress audit chain. The transport is the caller's: a test passes a fake, production passes whatever the operator
7
+ // configured. There is no default transport and no cloud fallback.
8
+ //
9
+ // The telemetry this module emits is built from an ALLOWLIST. It carries the provider id, model, endpoint HOST (no userinfo, path or
10
+ // query), status, a fixed failure code, token counts, cache state, latency and byte counts. It never carries the prompt, the response
11
+ // text, a transport error message (which can echo a request) or any credential, and nothing is added to it by spreading an object.
12
+ // The prompt/response stay with the caller; telemetry is what is safe to log, hash and calibrate on.
13
+ //
14
+ // Outcomes: provider failures, partial responses, cancellation, timeouts and cache hits are all kept as outcome records. A failed call
15
+ // yields a record with unknown (null) usage, never zero tokens and never a dropped row.
16
+
17
+ import { guardedModelCall } from '../assurance/bounded-io.js';
18
+ import { envPresent } from '../assurance/config.js';
19
+ import { payloadMetrics } from '../../egress/audit.js';
20
+ import { buildRoutingOutcome } from './outcomes.js';
21
+ import { costOf } from './economics.js';
22
+
23
+ /** Failure codes a provider error may carry into telemetry. Anything else becomes `unknown-failure`; the message is never kept. */
24
+ export const PROVIDER_FAILURE_CODES = Object.freeze(['rate-limit', 'server-error', 'overloaded', 'auth', 'bad-request', 'network', 'unknown-failure']);
25
+ const FINISH_REASONS = Object.freeze(['stop', 'length', 'content-filter', 'tool-use', 'error']);
26
+
27
+ const nonNegInt = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
28
+
29
+ /** The endpoint reduced to scheme and host. A URL with userinfo or a query string (a common place for a key) loses both. */
30
+ export function endpointHost(endpoint) {
31
+ try { const u = new URL(String(endpoint)); return `${u.protocol}//${u.host}`; } catch { return null; }
32
+ }
33
+
34
+ function usageOf(raw) {
35
+ if (!raw || typeof raw !== 'object') return { inputTokens: null, outputTokens: null, cachedInputTokens: null, source: 'unknown' };
36
+ const inputTokens = nonNegInt(raw.inputTokens);
37
+ const outputTokens = nonNegInt(raw.outputTokens);
38
+ let cachedInputTokens = nonNegInt(raw.cachedInputTokens);
39
+ if (cachedInputTokens !== null && inputTokens !== null && cachedInputTokens > inputTokens) cachedInputTokens = null; // inconsistent: unknown, not clamped
40
+ const reported = inputTokens !== null || outputTokens !== null;
41
+ return { inputTokens, outputTokens, cachedInputTokens, source: reported ? 'measured' : 'unknown' };
42
+ }
43
+
44
+ function cacheStateOf(usage, hint) {
45
+ if (usage.cachedInputTokens !== null && usage.inputTokens !== null) {
46
+ if (usage.cachedInputTokens === 0) return 'miss';
47
+ return usage.cachedInputTokens >= usage.inputTokens ? 'hit' : 'partial';
48
+ }
49
+ return ['hit', 'partial', 'miss', 'ineligible'].includes(hint) ? hint : 'unknown';
50
+ }
51
+
52
+ /**
53
+ * Create an adapter for one provider model.
54
+ *
55
+ * @param {object} o
56
+ * @param {object} o.config resolveAssuranceConfig() result (the `model-routing` feature must be enabled)
57
+ * @param {string} o.scanRoot
58
+ * @param {string} o.provider provider slot id (for example 'provider-a'), recorded in telemetry
59
+ * @param {string} o.model
60
+ * @param {string|null} [o.modelVersion]
61
+ * @param {string|null} o.endpoint operator-configured; null yields a typed `missing-provider` outcome, never a default
62
+ * @param {(req)=>Promise<{text:string, finishReason?:string, usage?:object, cacheState?:string}>} o.transport injected; receives redacted text only
63
+ * @param {object} [o.priceBook]
64
+ * @param {{name:string, env?:object}|null} [o.credential] names the environment variable the transport needs; only presence is checked
65
+ * @param {()=>number} [o.clock] monotonic milliseconds, injectable for tests
66
+ */
67
+ export function createProviderAdapter({ config, scanRoot, provider, model, modelVersion = null, endpoint, transport, priceBook = null, credential = null, clock = () => Number(process.hrtime.bigint() / 1_000_000n) }) {
68
+ return {
69
+ provider, model, modelVersion,
70
+ /**
71
+ * Run one task. Always resolves to `{ outcome, telemetry, text }`: `outcome` is a validated routing-outcome record (or null if the
72
+ * task itself was malformed), `telemetry` the allowlisted record, `text` the redacted-side response for the caller only.
73
+ * `signal` lets the caller cancel; a cancelled run is recorded as `cancelled`, not dropped.
74
+ */
75
+ async invoke({ task, runId, prompt, filePath = null, signal = null, group = null, observedAt = null, purpose = 'routing-task' }) {
76
+ const requirements = credential ? [{ kind: 'credential', name: credential.name, present: envPresent(credential.name, credential.env || process.env) }] : [];
77
+ let captured = null; let failure = null; let cancelled = false; let calls = 0;
78
+ const t0 = clock();
79
+ const res = await guardedModelCall({
80
+ config, featureId: 'model-routing', scanRoot, endpoint, purpose, text: prompt, filePath, model, requirements,
81
+ call: async (req) => {
82
+ calls += 1;
83
+ if (signal?.aborted) { cancelled = true; const e = new Error('cancelled'); e.retryable = false; throw e; }
84
+ const inner = signal ? AbortSignal.any([signal, req.signal]) : req.signal;
85
+ let out;
86
+ try {
87
+ out = await Promise.race([
88
+ transport({ endpoint: req.endpoint, text: req.text, signal: inner, timeoutMs: req.timeoutMs }),
89
+ new Promise((_, rej) => { if (signal) signal.addEventListener('abort', () => { cancelled = true; const e = new Error('cancelled'); e.retryable = false; rej(e); }, { once: true }); }),
90
+ ]);
91
+ } catch (e) {
92
+ if (!cancelled) failure = { code: PROVIDER_FAILURE_CODES.includes(e?.code) ? e.code : 'unknown-failure', retryable: !!e?.retryable };
93
+ throw Object.assign(new Error(cancelled ? 'cancelled' : 'provider failure'), { retryable: !cancelled && !!e?.retryable });
94
+ }
95
+ captured = out && typeof out === 'object' ? out : { text: String(out ?? '') };
96
+ return typeof captured.text === 'string' ? captured.text : '';
97
+ },
98
+ });
99
+ const latencyMs = Math.max(0, clock() - t0);
100
+ const promptMetrics = payloadMetrics(String(prompt ?? ''));
101
+
102
+ let status; let code = res.code ?? null; let usage = usageOf(null); let finish = null; let hint = 'unknown';
103
+ if (cancelled) { status = 'cancelled'; code = 'cancelled'; }
104
+ else if (res.status === 'ok' || res.status === 'degraded') {
105
+ usage = usageOf(captured?.usage);
106
+ finish = FINISH_REASONS.includes(captured?.finishReason) ? captured.finishReason : null;
107
+ hint = captured?.cacheState;
108
+ status = res.truncated || finish === 'length' || finish === 'content-filter' ? 'partial' : 'completed';
109
+ } else if (res.code === 'timeout') status = 'timeout';
110
+ else if (failure) { status = 'provider-error'; code = failure.code; }
111
+ else if (['egress-denied', 'missing-provider', 'missing-credential', 'limit-exceeded', 'disabled', 'kill-switch', 'platform-unsupported', 'invalid-config'].includes(res.code) || res.status === 'disabled') status = 'blocked';
112
+ else status = 'failed';
113
+
114
+ const attempts = Number.isInteger(res.attempts) ? res.attempts : calls;
115
+ const retries = Math.max(0, attempts - 1);
116
+ usage = { ...usage, retries };
117
+ const cacheState = cacheStateOf(usage, hint);
118
+
119
+ // Retries that reported no usage are bounded by the request size, never priced at zero.
120
+ const cost = priceBook ? costOf({
121
+ usage, model, priceBook, retries: Array.from({ length: retries }, () => ({})), estimatedRequestTokens: promptMetrics.tokenCount || null,
122
+ toolExecutionUsd: 0, // this adapter runs no tools: a known zero, not an unmeasured one
123
+ }) : null;
124
+ const costUsd = cost && cost.totalStatus === 'measured' ? cost.totalUsd : null;
125
+ const costStatus = cost ? cost.totalStatus : 'unknown';
126
+
127
+ const built = buildRoutingOutcome({
128
+ runId, task, model, modelVersion, status, outcome: status === 'completed' || status === 'partial' ? 'delayed' : 'unknown',
129
+ group, usage, costUsd, costStatus, latencyMs, cacheState, observedAt, synthetic: !!task?.synthetic,
130
+ });
131
+ const telemetry = Object.freeze({
132
+ provider, model, modelVersion, endpoint: endpointHost(endpoint), status, code, finishReason: finish, attempts, cacheState,
133
+ usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, cachedInputTokens: usage.cachedInputTokens, retries, source: usage.source },
134
+ latencyMs, requestBytes: promptMetrics.byteCount, requestTokensEstimated: promptMetrics.tokenCount, redactions: res.redactions ?? 0,
135
+ cost: cost ? { priceVersion: cost.priceVersion, currency: cost.currency, billingBasis: cost.billingBasis, status: cost.totalStatus, lowerUsd: cost.totalLowerUsd, upperUsd: cost.totalUpperUsd } : null,
136
+ });
137
+ return { outcome: built.ok ? built.outcome : null, outcomeErrors: built.errors, telemetry, text: status === 'completed' || status === 'partial' ? (res.text ?? '') : '' };
138
+ },
139
+ };
140
+ }