@aiwg/cli 2026.9.20 → 2026.9.21
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/agentic/code/providers/capability-matrix.yaml +58 -6
- package/agentic/code/providers/model-capabilities.v1.json +34 -0
- package/agentic/code/providers/model-catalog.v1.json +55 -13
- package/bin/aiwg.mjs +2 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/enrichment/cli.js +1 -1
- package/dist/src/artifacts/views/cli.js +1 -1
- package/dist/src/audit/operator-decision.js +10 -0
- package/dist/src/auth/credential-store.js +127 -27
- package/dist/src/catalog/builtin-models.json +272 -40
- package/dist/src/catalog/cli.js +1 -1
- package/dist/src/catalog/cli.mjs +1 -1
- package/dist/src/cli/handlers/effect.js +872 -0
- package/dist/src/cli/handlers/help.js +2 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/models.js +41 -10
- package/dist/src/cli/handlers/ralph.js +1 -1
- package/dist/src/cli/handlers/sessions.js +113 -37
- package/dist/src/cli/handlers/subcommands.js +31 -0
- package/dist/src/cli/handlers/use.js +46 -9
- package/dist/src/cli/handlers/utilities.js +10 -0
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/scope-resolver.js +112 -9
- package/dist/src/cli/services/deployment-verification.js +5 -0
- package/dist/src/config/aiwg-config.js +3 -0
- package/dist/src/decision/acceptance-rollout.js +93 -0
- package/dist/src/decision/acceptance.js +175 -0
- package/dist/src/decision/adapters/jev.js +234 -13
- package/dist/src/decision/adapters/llm-subagent.js +60 -12
- package/dist/src/decision/admission.js +550 -0
- package/dist/src/decision/batch-benchmark.js +47 -0
- package/dist/src/decision/batch-receipts/accounting.js +75 -0
- package/dist/src/decision/batch-receipts/export.js +40 -0
- package/dist/src/decision/batch-receipts/index.js +9 -0
- package/dist/src/decision/batch-receipts/protection.js +40 -0
- package/dist/src/decision/batch-receipts/receipt.js +44 -0
- package/dist/src/decision/batch-receipts/result-store.js +341 -0
- package/dist/src/decision/batch-receipts/store.js +362 -0
- package/dist/src/decision/batch-receipts/types.js +2 -0
- package/dist/src/decision/batch-receipts/validate.js +127 -0
- package/dist/src/decision/batch.js +68 -0
- package/dist/src/decision/calibration/index.js +4 -0
- package/dist/src/decision/calibration/receipts.js +130 -0
- package/dist/src/decision/calibration/registry.js +258 -0
- package/dist/src/decision/calibration/types.js +2 -0
- package/dist/src/decision/compile-cache/benchmark.js +62 -0
- package/dist/src/decision/compile-cache/compatibility.js +65 -0
- package/dist/src/decision/compile-cache/file-store.js +280 -0
- package/dist/src/decision/compile-cache/identity.js +26 -0
- package/dist/src/decision/compile-cache/index.js +9 -0
- package/dist/src/decision/compile-cache/prefix.js +21 -0
- package/dist/src/decision/compile-cache/records.js +86 -0
- package/dist/src/decision/compile-cache/runtime.js +68 -0
- package/dist/src/decision/compile-cache/store.js +108 -0
- package/dist/src/decision/compile-cache/types.js +2 -0
- package/dist/src/decision/context-plan.js +234 -0
- package/dist/src/decision/context-qualification.js +60 -0
- package/dist/src/decision/ensemble/aggregate.js +215 -0
- package/dist/src/decision/ensemble/contract.js +316 -0
- package/dist/src/decision/ensemble/drift.js +51 -0
- package/dist/src/decision/ensemble/index.js +6 -0
- package/dist/src/decision/ensemble/report.js +71 -0
- package/dist/src/decision/ensemble/types.js +2 -0
- package/dist/src/decision/evaluate.js +1024 -78
- package/dist/src/decision/file-lifecycle-store.js +168 -0
- package/dist/src/decision/graph-beam.js +40 -0
- package/dist/src/decision/graph-budget.js +110 -0
- package/dist/src/decision/graph-decision-bridge.js +93 -0
- package/dist/src/decision/graph-evidence.js +123 -0
- package/dist/src/decision/graph-flow-adapter.js +56 -0
- package/dist/src/decision/graph-flow.js +105 -0
- package/dist/src/decision/graph-parallel.js +79 -0
- package/dist/src/decision/graph-patterns.js +20 -0
- package/dist/src/decision/graph-run-store.js +66 -0
- package/dist/src/decision/graph-run.js +166 -0
- package/dist/src/decision/graph-skill-bridge.js +91 -0
- package/dist/src/decision/graph-templates.js +56 -0
- package/dist/src/decision/graph.js +115 -0
- package/dist/src/decision/index.js +35 -0
- package/dist/src/decision/job-accounting.js +93 -0
- package/dist/src/decision/job-contract.js +186 -0
- package/dist/src/decision/job-effects.js +95 -0
- package/dist/src/decision/job-evaluate.js +80 -0
- package/dist/src/decision/job-gateway.js +121 -0
- package/dist/src/decision/job-http.js +194 -0
- package/dist/src/decision/job-payload-store.js +258 -0
- package/dist/src/decision/job-poll-limiter.js +92 -0
- package/dist/src/decision/job-quota.js +158 -0
- package/dist/src/decision/job-runtime.js +240 -0
- package/dist/src/decision/job-scheduler.js +26 -0
- package/dist/src/decision/job-service.js +117 -0
- package/dist/src/decision/job-store.js +264 -0
- package/dist/src/decision/job-worker.js +141 -0
- package/dist/src/decision/lifecycle.js +148 -0
- package/dist/src/decision/patterns/artifacts.js +173 -0
- package/dist/src/decision/patterns/catalog.js +108 -0
- package/dist/src/decision/patterns/durable-review.js +237 -0
- package/dist/src/decision/patterns/index.js +8 -0
- package/dist/src/decision/patterns/playground.js +401 -0
- package/dist/src/decision/patterns/recorded.js +43 -0
- package/dist/src/decision/patterns/specs.js +174 -0
- package/dist/src/decision/patterns/types.js +2 -0
- package/dist/src/decision/portable-secrets.js +53 -0
- package/dist/src/decision/projection.js +210 -0
- package/dist/src/decision/qualification/capture.js +67 -0
- package/dist/src/decision/qualification/drift.js +68 -0
- package/dist/src/decision/qualification/gate-evidence.js +172 -0
- package/dist/src/decision/qualification/gates.js +83 -0
- package/dist/src/decision/qualification/index.js +12 -0
- package/dist/src/decision/qualification/load.js +275 -0
- package/dist/src/decision/qualification/manifest.js +37 -0
- package/dist/src/decision/qualification/privacy.js +41 -0
- package/dist/src/decision/qualification/quality.js +213 -0
- package/dist/src/decision/qualification/release.js +116 -0
- package/dist/src/decision/qualification/runner.js +408 -0
- package/dist/src/decision/qualification/types.js +2 -0
- package/dist/src/decision/receipts.js +30 -7
- package/dist/src/decision/result-cache/index.js +6 -0
- package/dist/src/decision/result-cache/integrity.js +28 -0
- package/dist/src/decision/result-cache/key.js +28 -0
- package/dist/src/decision/result-cache/service.js +92 -0
- package/dist/src/decision/result-cache/store.js +327 -0
- package/dist/src/decision/result-cache/types.js +3 -0
- package/dist/src/decision/review/authorization.js +110 -0
- package/dist/src/decision/review/effect-ledger-adapter.js +321 -0
- package/dist/src/decision/review/index.js +12 -0
- package/dist/src/decision/review/migrate.js +34 -0
- package/dist/src/decision/review/operator-audit.js +59 -0
- package/dist/src/decision/review/recovery-adapters.js +252 -0
- package/dist/src/decision/review/recovery.js +33 -0
- package/dist/src/decision/review/service.js +498 -0
- package/dist/src/decision/review/store.js +219 -0
- package/dist/src/decision/review/types.js +2 -0
- package/dist/src/decision/review/validate.js +205 -0
- package/dist/src/decision/scheduler.js +115 -0
- package/dist/src/decision/telemetry/context.js +42 -0
- package/dist/src/decision/telemetry/debug-sidecar.js +152 -0
- package/dist/src/decision/telemetry/exporter.js +77 -0
- package/dist/src/decision/telemetry/file-debug-backend.js +112 -0
- package/dist/src/decision/telemetry/index.js +13 -0
- package/dist/src/decision/telemetry/mapping.js +146 -0
- package/dist/src/decision/telemetry/metrics.js +164 -0
- package/dist/src/decision/telemetry/otlp-http.js +183 -0
- package/dist/src/decision/telemetry/redaction.js +93 -0
- package/dist/src/decision/telemetry/retention.js +109 -0
- package/dist/src/decision/telemetry/runtime.js +310 -0
- package/dist/src/decision/telemetry/trace.js +158 -0
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/validate.js +135 -5
- package/dist/src/effects/checkpoint-sinks.js +94 -0
- package/dist/src/effects/checkpoint.js +76 -0
- package/dist/src/effects/decision-links.js +54 -0
- package/dist/src/effects/errors.js +48 -0
- package/dist/src/effects/identity.js +178 -0
- package/dist/src/effects/index.js +25 -0
- package/dist/src/effects/keyring.js +160 -0
- package/dist/src/effects/keys.js +130 -0
- package/dist/src/effects/ledger.js +435 -0
- package/dist/src/effects/lifecycle.js +110 -0
- package/dist/src/effects/lock-recovery.js +269 -0
- package/dist/src/effects/reader.js +103 -0
- package/dist/src/effects/records.js +114 -0
- package/dist/src/effects/redaction.js +51 -0
- package/dist/src/effects/schema.js +73 -0
- package/dist/src/effects/store.js +239 -0
- package/dist/src/effects/types.js +49 -0
- package/dist/src/effects/verifiers/decision.js +168 -0
- package/dist/src/effects/verifiers/file.js +106 -0
- package/dist/src/effects/verifiers/git.js +206 -0
- package/dist/src/effects/verifiers/index.js +90 -0
- package/dist/src/effects/verifiers/registry.js +148 -0
- package/dist/src/effects/verifiers/review.js +123 -0
- package/dist/src/effects/verifiers/tracker.js +299 -0
- package/dist/src/effects/verifiers/types.js +21 -0
- package/dist/src/effects/verify.js +295 -0
- package/dist/src/evidence/bundle.js +2 -0
- package/dist/src/extensions/commands/definitions.js +28 -0
- package/dist/src/extensions/manifest.js +1 -0
- package/dist/src/marketplace/provenance.js +25 -27
- package/dist/src/mcp/cli.mjs +11 -1
- package/dist/src/mcp/grok-build-config.mjs +189 -0
- package/dist/src/mcp/registry.js +21 -1
- package/dist/src/mcp/registry.mjs +20 -1
- package/dist/src/models/grok-build-models.js +356 -0
- package/dist/src/models/index.js +1 -0
- package/dist/src/models/model-capabilities.v1.json +34 -0
- package/dist/src/models/model-catalog.v1.json +55 -13
- package/dist/src/models/model-discovery.js +41 -5
- package/dist/src/models/provider-policy.js +3 -2
- package/dist/src/plugin/skill-command-translator.js +1 -0
- package/dist/src/providers/capability-matrix.yaml +58 -6
- package/dist/src/providers/grok-build-acp.js +192 -0
- package/dist/src/providers/grok-build-dispatch.js +251 -0
- package/dist/src/providers/grok-build-headless.js +170 -0
- package/dist/src/providers/muse-paths.js +105 -0
- package/dist/src/providers/provider-definitions.js +93 -7
- package/dist/src/providers/provider-definitions.mjs +21 -1
- package/dist/src/providers/provider-inventory.js +3 -0
- package/dist/src/security/artifact-attestation.js +4 -8
- package/dist/src/security/signing.js +119 -0
- package/dist/src/sessions/adapters/grok-build.js +187 -0
- package/dist/src/sessions/adapters/muse.js +412 -0
- package/dist/src/sessions/contracts.js +1 -1
- package/dist/src/sessions/grok-build-cli.js +196 -0
- package/dist/src/sessions/index.js +3 -0
- package/dist/src/sessions/workspace-discovery.js +9 -5
- package/dist/src/skills/deployer.js +33 -5
- package/dist/src/smiths/context-pipeline/claude-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/context-file-safety.js +18 -0
- package/dist/src/smiths/context-pipeline/finalization.js +1 -32
- package/dist/src/smiths/context-pipeline/generator.js +12 -0
- package/dist/src/smiths/context-pipeline/index.js +1 -1
- package/dist/src/smiths/context-pipeline/managed-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/muse-bridge.js +50 -0
- package/dist/src/smiths/context-pipeline/provider-policy.js +5 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +22 -16
- package/dist/src/smiths/hook-bridge/grok-build-translator.js +64 -0
- package/dist/src/smiths/hook-bridge/index.js +3 -0
- package/dist/src/smiths/hook-bridge/shim.js +2 -0
- package/dist/src/smiths/hook-bridge/types.js +5 -0
- package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
- package/dist/src/storage/protected-files.js +102 -0
- package/dist/src/tracker/forge-http.js +371 -0
- package/dist/src/tracker/project-inputs.js +41 -0
- package/package.json +2 -1
- package/schemas/decision/BatchReceipt.v1.schema.json +50 -0
- package/schemas/decision/CalibrationArtifact.v1.schema.json +76 -0
- package/schemas/decision/CalibrationCompatibility.v1.schema.json +18 -0
- package/schemas/decision/CalibrationGovernanceReceipt.v1.schema.json +44 -0
- package/schemas/decision/DecisionAdmissionEvidence.v1.schema.json +43 -0
- package/schemas/decision/DecisionBinding.schema.json +309 -0
- package/schemas/decision/DecisionBinding.v1alpha2.schema.json +369 -0
- package/schemas/decision/DecisionCacheTelemetry.v1.schema.json +20 -0
- package/schemas/decision/DecisionChampionChallenger.v1.schema.json +100 -0
- package/schemas/decision/DecisionCompileCacheEntry.v1.schema.json +35 -0
- package/schemas/decision/DecisionCompileCacheIdentity.v1.schema.json +26 -0
- package/schemas/decision/DecisionCompileCacheTombstone.v1.schema.json +31 -0
- package/schemas/decision/DecisionContextEvidence.v1.schema.json +136 -0
- package/schemas/decision/DecisionDefinition.schema.json +159 -0
- package/schemas/decision/DecisionDefinition.v1alpha2.schema.json +203 -0
- package/schemas/decision/DecisionDriftResponse.v1.schema.json +55 -0
- package/schemas/decision/DecisionEnsembleAggregate.v1.schema.json +86 -0
- package/schemas/decision/DecisionEnsembleIntegrityReport.v1.schema.json +64 -0
- package/schemas/decision/DecisionEnsemblePolicy.v1.schema.json +130 -0
- package/schemas/decision/DecisionGraph.v1.schema.json +77 -0
- package/schemas/decision/DecisionJob.v1.schema.json +71 -0
- package/schemas/decision/DecisionLifecyclePolicy.v1.schema.json +52 -0
- package/schemas/decision/DecisionProjectionPolicy.v1.schema.json +65 -0
- package/schemas/decision/DecisionProviderPrefixCompatibility.v1.schema.json +45 -0
- package/schemas/decision/DecisionProviderPrefixEvidence.v1.schema.json +17 -0
- package/schemas/decision/DecisionProviderPrefixIdentity.v1.schema.json +36 -0
- package/schemas/decision/DecisionResult.schema.json +524 -0
- package/schemas/decision/DecisionResult.v1alpha2.schema.json +619 -0
- package/schemas/decision/DecisionResultCacheEntry.v1.schema.json +53 -0
- package/schemas/decision/DecisionReview.schema.json +29 -0
- package/schemas/decision/DecisionRuleset.schema.json +313 -0
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +313 -0
- package/schemas/decision/DecisionTelemetryTrace.v1.schema.json +95 -0
- package/schemas/decision/RulesetResult.schema.json +721 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +891 -0
- package/schemas/security/pr-evidence-receipt.v1.schema.json +77 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +71 -1
- package/tools/agents/deploy-agents.mjs +21 -6
- package/tools/agents/providers/base.mjs +7 -4
- package/tools/agents/providers/codex.mjs +1 -1
- package/tools/agents/providers/grok-build.d.mts +7 -0
- package/tools/agents/providers/grok-build.mjs +249 -9
- package/tools/agents/providers/model-role.mjs +6 -3
- package/tools/agents/providers/muse-hooks.mjs +785 -0
- package/tools/agents/providers/muse-paths.mjs +112 -0
- package/tools/agents/providers/muse.mjs +336 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** D30 compile/prefix cache, D03 receipt replay and D15 result cache evidence, each a verified evidence manifest. */
|
|
3
|
+
export const QUALIFICATION_CACHE_LAYERS = ['compilePrefixCache', 'receiptReplay', 'resultCache'];
|
|
4
|
+
const REQUIRED_PINS = [
|
|
5
|
+
'definition', 'ruleset', 'binding', 'adapter', 'requestedModel', 'servedModel',
|
|
6
|
+
'policy', 'calibration', 'dataset', 'split', 'seed', 'priceCatalog',
|
|
7
|
+
'compilePrefixCache', 'receiptReplay', 'resultCache',
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Derives the three cache-layer pins from verified evidence manifests. Each
|
|
11
|
+
* manifest must contain only executable, passing, digest-bound evidence, and
|
|
12
|
+
* the three layers must come from different runs.
|
|
13
|
+
*/
|
|
14
|
+
export function deriveCacheLayerPins(layers) {
|
|
15
|
+
const pins = {};
|
|
16
|
+
for (const layer of QUALIFICATION_CACHE_LAYERS) {
|
|
17
|
+
const evidence = layers[layer];
|
|
18
|
+
if (!evidence || evidence.manifest.schemaVersion !== 'decision-qualification-evidence-manifest/v1'
|
|
19
|
+
|| !/^sha256:[0-9a-f]{64}$/.test(evidence.digest) || !evidence.manifest.evidence.length
|
|
20
|
+
|| evidence.manifest.evidence.some(item => !item.executable || item.outcome !== 'pass'
|
|
21
|
+
|| !/^sha256:[0-9a-f]{64}$/.test(item.artifact.digest) || !item.sourceGoldens.length)) {
|
|
22
|
+
throw new Error(`cache layer evidence is not verified: ${layer}`);
|
|
23
|
+
}
|
|
24
|
+
pins[layer] = evidence.digest;
|
|
25
|
+
}
|
|
26
|
+
if (new Set(QUALIFICATION_CACHE_LAYERS.map(layer => layers[layer].manifest.runId)).size !== QUALIFICATION_CACHE_LAYERS.length) {
|
|
27
|
+
throw new Error('compile/prefix, receipt replay and result cache evidence must come from distinct runs');
|
|
28
|
+
}
|
|
29
|
+
return pins;
|
|
30
|
+
}
|
|
31
|
+
/** A release record cannot upgrade a HOLD/ROLLBACK from #2037/#2048 integrity. */
|
|
32
|
+
export function buildQualificationReleaseRecord(executed, input) {
|
|
33
|
+
const { manifest, report, verification } = executed;
|
|
34
|
+
if (!manifest.runId || !manifest.sourceCommit || !input.environment.trim() || !input.commands.length
|
|
35
|
+
|| input.commands.some(command => !command.trim()) || input.integrity.sample_n < 0) {
|
|
36
|
+
throw new Error('incomplete qualification release metadata');
|
|
37
|
+
}
|
|
38
|
+
const derived = input.cacheLayers ? deriveCacheLayerPins(input.cacheLayers) : {};
|
|
39
|
+
for (const [layer, pin] of Object.entries(derived)) {
|
|
40
|
+
if (input.pins[layer] !== undefined && input.pins[layer] !== pin)
|
|
41
|
+
throw new Error(`release pin ${layer} does not match its evidence`);
|
|
42
|
+
}
|
|
43
|
+
const pinned = { ...input.pins, ...derived };
|
|
44
|
+
for (const pin of REQUIRED_PINS) {
|
|
45
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(pinned[pin] ?? ''))
|
|
46
|
+
throw new Error(`missing release pin: ${pin}`);
|
|
47
|
+
}
|
|
48
|
+
if (new Set([pinned.compilePrefixCache, pinned.receiptReplay, pinned.resultCache]).size !== 3) {
|
|
49
|
+
throw new Error('compile/prefix, receipt replay and result cache evidence must be distinct');
|
|
50
|
+
}
|
|
51
|
+
for (const [name, value] of [...Object.entries(input.budgets), ...Object.entries(input.actuals)]) {
|
|
52
|
+
if (!name || !Number.isFinite(value) || value < 0)
|
|
53
|
+
throw new Error('invalid qualification resource bound');
|
|
54
|
+
}
|
|
55
|
+
const verified = new Map(verification.map(item => [item.caseId, item.verified]));
|
|
56
|
+
const suites = manifest.evidence.map(item => ({
|
|
57
|
+
caseId: item.caseId, outcome: item.outcome, evidenceHash: item.digest, verified: verified.get(item.caseId) === true,
|
|
58
|
+
})).sort((a, b) => a.caseId.localeCompare(b.caseId));
|
|
59
|
+
const releaseDecision = input.integrity.release_gate.decision;
|
|
60
|
+
const withinBudgets = Object.entries(input.budgets).length > 0
|
|
61
|
+
&& Object.keys(input.actuals).length === Object.keys(input.budgets).length
|
|
62
|
+
&& Object.entries(input.budgets).every(([name, limit]) => input.actuals[name] !== undefined && input.actuals[name] <= limit);
|
|
63
|
+
const integrityVerified = input.integrity.integrity_state === 'verified'
|
|
64
|
+
&& input.integrity.integrity_mode !== 'standard'
|
|
65
|
+
&& input.integrity.trusted_score_source !== 'local-unverified'
|
|
66
|
+
&& input.integrity.compromise_labels.length === 0
|
|
67
|
+
&& input.integrity.sample_n > 0 && input.integrity.uncertainty !== null;
|
|
68
|
+
const benchmark = input.benchmark;
|
|
69
|
+
const benchmarkVerified = benchmark !== undefined && benchmark.decision === 'pass'
|
|
70
|
+
&& /^sha256:[0-9a-f]{64}$/.test(benchmark.planDigest)
|
|
71
|
+
&& benchmark.planDigest === benchmark.trustedPlanDigest
|
|
72
|
+
&& Number.isSafeInteger(benchmark.sampleN) && Number.isSafeInteger(benchmark.minimumN)
|
|
73
|
+
&& benchmark.minimumN > 0 && benchmark.sampleN >= benchmark.minimumN;
|
|
74
|
+
const snapshot = input.integritySnapshot;
|
|
75
|
+
const snapshotVerified = snapshot !== undefined && /^sha256:[0-9a-f]{64}$/.test(snapshot.digest)
|
|
76
|
+
&& Number.isSafeInteger(snapshot.artifactCount) && snapshot.artifactCount > 0;
|
|
77
|
+
const metricsRecorded = input.metrics !== undefined && input.metrics.overall.sampleN > 0;
|
|
78
|
+
const complete = withinBudgets && integrityVerified && benchmarkVerified && snapshotVerified && metricsRecorded && !manifest.dirty && input.reviewer !== null && input.reviewer.trim().length > 0
|
|
79
|
+
&& suites.length === manifest.cases.length && suites.every(item => item.outcome === 'pass' && item.verified)
|
|
80
|
+
&& report.gates.length === 7 && report.gates.every(gate => gate.status === 'pass')
|
|
81
|
+
&& report.decision === 'PROMOTE';
|
|
82
|
+
const decision = releaseDecision === 'ROLLBACK'
|
|
83
|
+
|| report.decision === 'ROLLBACK' || input.integrity.integrity_state === 'compromised' || input.integrity.compromise_labels.length > 0 ? 'ROLLBACK'
|
|
84
|
+
: complete && releaseDecision === 'PROMOTE' ? 'PROMOTE' : 'HOLD';
|
|
85
|
+
const fields = {
|
|
86
|
+
schemaVersion: 'decision-qualification-release/v1',
|
|
87
|
+
runId: manifest.runId, sourceCommit: manifest.sourceCommit, dirty: manifest.dirty,
|
|
88
|
+
environment: input.environment, commands: [...input.commands],
|
|
89
|
+
pins: Object.fromEntries(Object.entries(pinned).sort(([a], [b]) => a.localeCompare(b))),
|
|
90
|
+
budgets: Object.fromEntries(Object.entries(input.budgets).sort(([a], [b]) => a.localeCompare(b))),
|
|
91
|
+
actuals: Object.fromEntries(Object.entries(input.actuals).sort(([a], [b]) => a.localeCompare(b))),
|
|
92
|
+
reviewer: input.reviewer, suites, gates: [...report.gates].sort((a, b) => a.id.localeCompare(b.id)),
|
|
93
|
+
integrity: input.integrity, benchmark: benchmark ?? null,
|
|
94
|
+
metrics: input.metrics ?? null, integritySnapshot: snapshot ?? null,
|
|
95
|
+
cacheLayers: input.cacheLayers ? Object.fromEntries(QUALIFICATION_CACHE_LAYERS.map(layer => [layer, {
|
|
96
|
+
runId: input.cacheLayers[layer].manifest.runId, digest: input.cacheLayers[layer].digest,
|
|
97
|
+
caseIds: input.cacheLayers[layer].manifest.evidence.map(item => item.caseId).sort()
|
|
98
|
+
}])) : null,
|
|
99
|
+
decision,
|
|
100
|
+
};
|
|
101
|
+
return { ...fields, digest: `sha256:${createHash('sha256').update(JSON.stringify(fields)).digest('hex')}` };
|
|
102
|
+
}
|
|
103
|
+
/** Deliberately excludes private callback details, stdout/stderr and raw captures. */
|
|
104
|
+
export function qualificationReleaseSummary(record) {
|
|
105
|
+
return [
|
|
106
|
+
`# Qualification ${record.runId}`,
|
|
107
|
+
`- Decision: ${record.decision}`,
|
|
108
|
+
`- Commit: ${record.sourceCommit}${record.dirty ? ' (dirty)' : ''}`,
|
|
109
|
+
`- Integrity: ${record.integrity.integrity_state}; sample_n=${record.integrity.sample_n}; snapshot=${record.integritySnapshot?.digest ?? 'missing'}`,
|
|
110
|
+
`- Held-out: ${record.metrics ? `n=${record.metrics.overall.sampleN}, selective risk=${record.metrics.overall.selectiveRisk ?? 'n/a'}, review rate=${record.metrics.overall.reviewRate}` : 'missing'}`,
|
|
111
|
+
`- Gates: ${record.gates.map(gate => `${gate.id}=${gate.status}`).join(', ')}`,
|
|
112
|
+
`- Evidence: ${record.suites.filter(item => item.verified).length}/${record.suites.length} verified`,
|
|
113
|
+
`- Manifest: ${record.digest}`,
|
|
114
|
+
].join('\n');
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=release.js.map
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { evaluateQualification } from './gates.js';
|
|
5
|
+
import { deriveGateEvidence, DERIVED_GATE_EVIDENCE, GATE_ARTIFACT_FLAGS, GATE_ARTIFACT_SCHEMAS, validateGateArtifact, } from './gate-evidence.js';
|
|
6
|
+
import { scanQualificationPrivacy } from './privacy.js';
|
|
7
|
+
/** Named master-test-plan suites plus the M01-M11 amendment IDs. */
|
|
8
|
+
export const QUALIFICATION_EVIDENCE_ID = /^(?:CON|POL|BCH|CTX|CNC|CAN|RTY|REC|SEC|PRV|CAL|DRF|CCP|M(?:0[1-9]|1[01]))-[A-Z0-9][A-Z0-9._-]*$/;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
|
+
const DEFAULT_MAX_ARTIFACT_BYTES = 256 * 1024;
|
|
11
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
12
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
return fallback;
|
|
15
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
16
|
+
throw new Error(`qualification bound must be an integer in [${minimum}, ${maximum}]`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function safeSegment(value, label) {
|
|
21
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value))
|
|
22
|
+
throw new Error(`unsafe ${label}: ${value}`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function canonicalJson(value) {
|
|
26
|
+
return `${JSON.stringify(value, (_key, item) => {
|
|
27
|
+
if (typeof item === 'bigint')
|
|
28
|
+
return item.toString();
|
|
29
|
+
return item;
|
|
30
|
+
})}\n`;
|
|
31
|
+
}
|
|
32
|
+
function digest(bytes) {
|
|
33
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
34
|
+
}
|
|
35
|
+
async function withTimeout(timeoutMs, operation) {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
let timer;
|
|
38
|
+
try {
|
|
39
|
+
return await Promise.race([
|
|
40
|
+
Promise.resolve().then(() => operation(controller.signal)),
|
|
41
|
+
new Promise((_resolve, reject) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
controller.abort();
|
|
44
|
+
reject(new Error(`qualification execution exceeded ${timeoutMs}ms`));
|
|
45
|
+
}, timeoutMs);
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
if (timer)
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function writeArtifact(root, runId, artifact, maxBytes) {
|
|
55
|
+
const relative = `${safeSegment(runId, 'run id')}/${safeSegment(artifact.caseId, 'case id')}.json`;
|
|
56
|
+
const target = resolve(root, relative);
|
|
57
|
+
const bytes = canonicalJson(artifact);
|
|
58
|
+
if (Buffer.byteLength(bytes) > maxBytes)
|
|
59
|
+
throw new Error(`qualification artifact exceeds ${maxBytes} bytes`);
|
|
60
|
+
await mkdir(dirname(target), { recursive: true });
|
|
61
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
62
|
+
await writeFile(temporary, bytes, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
63
|
+
await rename(temporary, target);
|
|
64
|
+
return {
|
|
65
|
+
caseId: artifact.caseId,
|
|
66
|
+
executable: artifact.outcome !== 'skip',
|
|
67
|
+
outcome: artifact.outcome,
|
|
68
|
+
artifact: relative,
|
|
69
|
+
digest: digest(bytes),
|
|
70
|
+
testEvidenceIds: [...artifact.testEvidenceIds],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function executeCase(item, plan, timeoutMs, maxBytes) {
|
|
74
|
+
const started = Date.now();
|
|
75
|
+
const testEvidenceIds = [...(item.evidenceIds ?? [])];
|
|
76
|
+
if (testEvidenceIds.some(id => !QUALIFICATION_EVIDENCE_ID.test(id))
|
|
77
|
+
|| new Set(testEvidenceIds).size !== testEvidenceIds.length)
|
|
78
|
+
throw new Error(`invalid named qualification evidence for ${item.id}`);
|
|
79
|
+
testEvidenceIds.sort();
|
|
80
|
+
const executor = plan.executors[item.id];
|
|
81
|
+
let result = { outcome: 'fail' };
|
|
82
|
+
let error;
|
|
83
|
+
// A generic callback carries no authenticated provider or served-model proof.
|
|
84
|
+
// Never let an offline/mock executor manufacture an artifact labelled live.
|
|
85
|
+
if (!executor || plan.manifest.mode === 'live') {
|
|
86
|
+
return writeArtifact(plan.artifactRoot, plan.manifest.runId, {
|
|
87
|
+
schemaVersion: 'decision-qualification-artifact/v1', runId: plan.manifest.runId,
|
|
88
|
+
caseId: item.id, outcome: 'skip', durationMs: 0, testEvidenceIds,
|
|
89
|
+
error: plan.manifest.mode === 'live' ? 'live-evidence-unavailable' : 'executor-not-registered',
|
|
90
|
+
}, maxBytes);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
result = await withTimeout(timeoutMs, signal => executor({ caseId: item.id, runId: plan.manifest.runId, signal }));
|
|
94
|
+
if (result.outcome !== 'pass' && result.outcome !== 'fail')
|
|
95
|
+
throw new Error('executor returned an invalid outcome');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
result = { outcome: 'fail' };
|
|
99
|
+
// Executor exceptions can include private provider bodies or input state.
|
|
100
|
+
// Keep diagnostic text out of persistent artifacts and exported reports.
|
|
101
|
+
error = 'executor-failed';
|
|
102
|
+
}
|
|
103
|
+
let publicDetails;
|
|
104
|
+
if (result.details !== undefined && plan.sanitizeDetails) {
|
|
105
|
+
try {
|
|
106
|
+
publicDetails = plan.sanitizeDetails(result.details, item.id);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// A broken sanitizer cannot convert a private payload into release evidence.
|
|
110
|
+
result = { outcome: 'fail' };
|
|
111
|
+
error = 'details-sanitization-failed';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Check the serialized representation (not just string-valued leaves): JSON
|
|
115
|
+
// escaping must not let a canary bypass the privacy gate. Do not include the
|
|
116
|
+
// offending value or its index in the artifact or any thrown diagnostic.
|
|
117
|
+
const canaries = plan.privacyCanaries ?? [];
|
|
118
|
+
if (canaries.some(value => typeof value !== 'string' || value.length === 0)) {
|
|
119
|
+
throw new Error('privacy canaries must be nonempty strings');
|
|
120
|
+
}
|
|
121
|
+
if (publicDetails !== undefined && canaries.length) {
|
|
122
|
+
try {
|
|
123
|
+
const serialized = JSON.stringify(publicDetails);
|
|
124
|
+
if (canaries.some(value => serialized.includes(value) || serialized.includes(JSON.stringify(value).slice(1, -1)))) {
|
|
125
|
+
result = { outcome: 'fail' };
|
|
126
|
+
publicDetails = undefined;
|
|
127
|
+
error = 'privacy-canary-detected';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
result = { outcome: 'fail' };
|
|
132
|
+
publicDetails = undefined;
|
|
133
|
+
error = 'details-sanitization-failed';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return writeArtifact(plan.artifactRoot, plan.manifest.runId, {
|
|
137
|
+
schemaVersion: 'decision-qualification-artifact/v1', runId: plan.manifest.runId,
|
|
138
|
+
caseId: item.id, outcome: result.outcome, durationMs: Math.max(0, Date.now() - started), testEvidenceIds,
|
|
139
|
+
...(publicDetails === undefined ? {} : { details: publicDetails }), ...(error ? { error } : {}),
|
|
140
|
+
}, maxBytes);
|
|
141
|
+
}
|
|
142
|
+
async function mapConcurrent(items, concurrency, work) {
|
|
143
|
+
const output = new Array(items.length);
|
|
144
|
+
let cursor = 0;
|
|
145
|
+
async function worker() {
|
|
146
|
+
while (cursor < items.length) {
|
|
147
|
+
const index = cursor++;
|
|
148
|
+
output[index] = await work(items[index]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
152
|
+
return output;
|
|
153
|
+
}
|
|
154
|
+
/** Executes cases and gate checks; neither evidence outcomes nor flags are accepted from the caller. */
|
|
155
|
+
export async function executeQualificationPlan(plan) {
|
|
156
|
+
const timeoutMs = boundedInteger(plan.timeoutMs, DEFAULT_TIMEOUT_MS, 1, 10 * 60_000);
|
|
157
|
+
const concurrency = boundedInteger(plan.concurrency, DEFAULT_CONCURRENCY, 1, 32);
|
|
158
|
+
const maxBytes = boundedInteger(plan.maxArtifactBytes, DEFAULT_MAX_ARTIFACT_BYTES, 256, 4 * 1024 * 1024);
|
|
159
|
+
const evidence = await mapConcurrent(plan.manifest.cases, concurrency, item => executeCase(item, plan, timeoutMs, maxBytes));
|
|
160
|
+
const derivedNames = new Set(DERIVED_GATE_EVIDENCE);
|
|
161
|
+
const checkEntries = Object.entries(plan.evidenceChecks ?? {})
|
|
162
|
+
.filter(([name]) => !derivedNames.has(name))
|
|
163
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
164
|
+
const flagValues = await mapConcurrent(checkEntries, concurrency, async ([name, check]) => {
|
|
165
|
+
try {
|
|
166
|
+
const passed = await withTimeout(timeoutMs, signal => check({ runId: plan.manifest.runId, signal }));
|
|
167
|
+
return [name, passed === true];
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return [name, false];
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const evidenceFlags = Object.fromEntries(flagValues);
|
|
174
|
+
// Caller-provided positive flags cannot impersonate a complete privacy scan.
|
|
175
|
+
// Missing captures or canaries remain false, even if a callback returns true.
|
|
176
|
+
try {
|
|
177
|
+
evidenceFlags['privacy-scan-clean'] = plan.privacyCaptures && plan.privacyCanaries
|
|
178
|
+
? scanQualificationPrivacy(plan.privacyCaptures, plan.privacyCanaries).clean : false;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
evidenceFlags['privacy-scan-clean'] = false;
|
|
182
|
+
}
|
|
183
|
+
const gateArtifacts = await attachGateArtifacts(plan, evidence, maxBytes);
|
|
184
|
+
const manifest = { ...plan.manifest, evidence, evidenceFlags,
|
|
185
|
+
...(Object.keys(gateArtifacts).length ? { gateArtifacts } : {}) };
|
|
186
|
+
// Record the derived values for readers; the evaluator recomputes them and
|
|
187
|
+
// never trusts these entries. Hash verification is only known after re-reading.
|
|
188
|
+
for (const [name, value] of Object.entries(deriveGateEvidence(manifest))) {
|
|
189
|
+
if (name !== 'evidence-hashes-verified')
|
|
190
|
+
evidenceFlags[name] = value;
|
|
191
|
+
}
|
|
192
|
+
return manifest;
|
|
193
|
+
}
|
|
194
|
+
async function readBoundedFile(path, maxBytes) {
|
|
195
|
+
try {
|
|
196
|
+
const stat = await lstat(path);
|
|
197
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
198
|
+
return 'not-file';
|
|
199
|
+
if (stat.size > maxBytes)
|
|
200
|
+
return 'too-large';
|
|
201
|
+
return await readFile(path);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return 'missing';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function parseJson(bytes) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function attachGateArtifacts(plan, evidence, maxBytes) {
|
|
216
|
+
const refs = {};
|
|
217
|
+
const context = { manifest: { ...plan.manifest, evidence }, accepted: {} };
|
|
218
|
+
// Fixed order: calibration evidence binds to the accepted split plan.
|
|
219
|
+
for (const flag of GATE_ARTIFACT_FLAGS) {
|
|
220
|
+
const source = plan.gateArtifacts?.[flag];
|
|
221
|
+
if (!source)
|
|
222
|
+
continue;
|
|
223
|
+
const bytes = await readBoundedFile(resolve(source), maxBytes);
|
|
224
|
+
if (!Buffer.isBuffer(bytes))
|
|
225
|
+
continue;
|
|
226
|
+
const parsed = parseJson(bytes);
|
|
227
|
+
if (!validateGateArtifact(flag, parsed, context))
|
|
228
|
+
continue;
|
|
229
|
+
const relative = `${safeSegment(plan.manifest.runId, 'run id')}/gates/${flag}.json`;
|
|
230
|
+
const target = resolve(plan.artifactRoot, relative);
|
|
231
|
+
await mkdir(dirname(target), { recursive: true });
|
|
232
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
233
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
234
|
+
await rename(temporary, target);
|
|
235
|
+
refs[flag] = { artifact: relative, digest: digest(bytes), schemaVersion: GATE_ARTIFACT_SCHEMAS[flag] };
|
|
236
|
+
context.accepted[flag] = parsed;
|
|
237
|
+
}
|
|
238
|
+
return refs;
|
|
239
|
+
}
|
|
240
|
+
/** Re-reads gate artifacts from the artifact root and re-validates their content against the run. */
|
|
241
|
+
export async function verifyQualificationGateArtifacts(manifest, artifactRoot, maxArtifactBytes = DEFAULT_MAX_ARTIFACT_BYTES) {
|
|
242
|
+
const maxBytes = boundedInteger(maxArtifactBytes, DEFAULT_MAX_ARTIFACT_BYTES, 256, 4 * 1024 * 1024);
|
|
243
|
+
const refs = manifest.gateArtifacts ?? {};
|
|
244
|
+
const known = new Set(GATE_ARTIFACT_FLAGS);
|
|
245
|
+
const results = [];
|
|
246
|
+
const context = { manifest, accepted: {} };
|
|
247
|
+
for (const flag of Object.keys(refs).filter(name => !known.has(name)).sort()) {
|
|
248
|
+
results.push({ flag, verified: false, reason: 'unknown-flag' });
|
|
249
|
+
}
|
|
250
|
+
for (const flag of GATE_ARTIFACT_FLAGS) {
|
|
251
|
+
const ref = refs[flag];
|
|
252
|
+
if (!ref)
|
|
253
|
+
continue;
|
|
254
|
+
const path = containedArtifactPath(artifactRoot, ref.artifact);
|
|
255
|
+
if (!path || !ref.artifact.startsWith(`${manifest.runId}/gates/`)) {
|
|
256
|
+
results.push({ flag, verified: false, reason: 'unsafe-path' });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const bytes = await readBoundedFile(path, maxBytes);
|
|
260
|
+
if (!Buffer.isBuffer(bytes)) {
|
|
261
|
+
results.push({ flag, verified: false, reason: bytes });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (digest(bytes) !== ref.digest) {
|
|
265
|
+
results.push({ flag, verified: false, reason: 'digest-mismatch' });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const parsed = parseJson(bytes);
|
|
269
|
+
if (ref.schemaVersion !== GATE_ARTIFACT_SCHEMAS[flag] || !validateGateArtifact(flag, parsed, context)) {
|
|
270
|
+
results.push({ flag, verified: false, reason: 'invalid-artifact' });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
context.accepted[flag] = parsed;
|
|
274
|
+
results.push({ flag, verified: true });
|
|
275
|
+
}
|
|
276
|
+
return results;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Derives `privacy-scan-clean` for an executed run from surfaces captured
|
|
280
|
+
* around the whole run (see `captureQualificationLifetime`). The value comes
|
|
281
|
+
* from the scanner, as with `privacyCaptures`; it is never taken from the caller.
|
|
282
|
+
*/
|
|
283
|
+
export function withQualificationPrivacyScan(manifest, captures, canaries) {
|
|
284
|
+
let clean = false;
|
|
285
|
+
try {
|
|
286
|
+
clean = scanQualificationPrivacy(captures, canaries).clean;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
clean = false;
|
|
290
|
+
}
|
|
291
|
+
return { ...manifest, evidenceFlags: { ...manifest.evidenceFlags, 'privacy-scan-clean': clean } };
|
|
292
|
+
}
|
|
293
|
+
function containedArtifactPath(root, relative) {
|
|
294
|
+
if (relative.startsWith('/') || relative.includes('\\'))
|
|
295
|
+
return null;
|
|
296
|
+
const rootPath = resolve(root);
|
|
297
|
+
const candidate = resolve(rootPath, relative);
|
|
298
|
+
return candidate.startsWith(`${rootPath}${sep}`) ? candidate : null;
|
|
299
|
+
}
|
|
300
|
+
async function verifiedSource(root, relative) {
|
|
301
|
+
const path = containedArtifactPath(root, relative);
|
|
302
|
+
if (!path)
|
|
303
|
+
throw new Error(`unsafe qualification evidence source: ${relative}`);
|
|
304
|
+
const stat = await lstat(path);
|
|
305
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
306
|
+
throw new Error(`qualification evidence source is not a regular file: ${relative}`);
|
|
307
|
+
return { path: relative, digest: digest(await readFile(path)) };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Builds D11's evidence linkage only from runner-produced artifacts that still
|
|
311
|
+
* verify and checked-in source goldens that can be independently hashed.
|
|
312
|
+
*/
|
|
313
|
+
export async function createQualificationEvidenceManifest(manifest, artifactRoot, sourceRoot, sources) {
|
|
314
|
+
const verification = await verifyQualificationArtifacts(manifest, artifactRoot);
|
|
315
|
+
const verified = new Map(verification.map(item => [item.caseId, item.verified]));
|
|
316
|
+
const evidence = await Promise.all(manifest.evidence.map(async (item) => {
|
|
317
|
+
if (!item.executable || !item.artifact || !item.digest || verified.get(item.caseId) !== true) {
|
|
318
|
+
throw new Error(`qualification evidence is not executable and verified: ${item.caseId}`);
|
|
319
|
+
}
|
|
320
|
+
const sourcePaths = sources[item.caseId];
|
|
321
|
+
if (!sourcePaths?.length)
|
|
322
|
+
throw new Error(`qualification evidence has no source golden: ${item.caseId}`);
|
|
323
|
+
return {
|
|
324
|
+
caseId: item.caseId,
|
|
325
|
+
testEvidenceIds: [...(item.testEvidenceIds ?? [])],
|
|
326
|
+
executable: true,
|
|
327
|
+
outcome: item.outcome,
|
|
328
|
+
artifact: { path: item.artifact, digest: item.digest },
|
|
329
|
+
sourceGoldens: await Promise.all(sourcePaths.map(path => verifiedSource(sourceRoot, path))),
|
|
330
|
+
};
|
|
331
|
+
}));
|
|
332
|
+
return {
|
|
333
|
+
schemaVersion: 'decision-qualification-evidence-manifest/v1',
|
|
334
|
+
runId: manifest.runId,
|
|
335
|
+
sourceCommit: manifest.sourceCommit,
|
|
336
|
+
evidence,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/** Persists the evidence linkage beside the runner artifacts for CI/release collection. */
|
|
340
|
+
export async function writeQualificationEvidenceManifest(manifest, artifactRoot, sourceRoot, sources) {
|
|
341
|
+
const evidenceManifest = await createQualificationEvidenceManifest(manifest, artifactRoot, sourceRoot, sources);
|
|
342
|
+
const relative = `${safeSegment(manifest.runId, 'run id')}/evidence-manifest.json`;
|
|
343
|
+
const target = resolve(artifactRoot, relative);
|
|
344
|
+
const bytes = canonicalJson(evidenceManifest);
|
|
345
|
+
if (Buffer.byteLength(bytes) > DEFAULT_MAX_ARTIFACT_BYTES) {
|
|
346
|
+
throw new Error(`qualification evidence manifest exceeds ${DEFAULT_MAX_ARTIFACT_BYTES} bytes`);
|
|
347
|
+
}
|
|
348
|
+
await mkdir(dirname(target), { recursive: true });
|
|
349
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
350
|
+
await writeFile(temporary, bytes, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
351
|
+
await rename(temporary, target);
|
|
352
|
+
return { manifest: evidenceManifest, artifact: relative, digest: digest(bytes) };
|
|
353
|
+
}
|
|
354
|
+
/** Re-reads artifacts and verifies containment, file type, content, and exact SHA-256. */
|
|
355
|
+
export async function verifyQualificationArtifacts(manifest, artifactRoot, maxArtifactBytes = DEFAULT_MAX_ARTIFACT_BYTES) {
|
|
356
|
+
const maxBytes = boundedInteger(maxArtifactBytes, DEFAULT_MAX_ARTIFACT_BYTES, 256, 4 * 1024 * 1024);
|
|
357
|
+
return Promise.all(manifest.evidence.map(async (item) => {
|
|
358
|
+
if (!item.artifact || !item.digest)
|
|
359
|
+
return { caseId: item.caseId, verified: false, reason: 'missing' };
|
|
360
|
+
const path = containedArtifactPath(artifactRoot, item.artifact);
|
|
361
|
+
if (!path)
|
|
362
|
+
return { caseId: item.caseId, verified: false, reason: 'unsafe-path' };
|
|
363
|
+
try {
|
|
364
|
+
const stat = await lstat(path);
|
|
365
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
366
|
+
return { caseId: item.caseId, verified: false, reason: 'not-file' };
|
|
367
|
+
if (stat.size > maxBytes)
|
|
368
|
+
return { caseId: item.caseId, verified: false, reason: 'too-large' };
|
|
369
|
+
const bytes = await readFile(path);
|
|
370
|
+
if (digest(bytes) !== item.digest)
|
|
371
|
+
return { caseId: item.caseId, verified: false, reason: 'digest-mismatch' };
|
|
372
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
373
|
+
if (parsed.schemaVersion !== 'decision-qualification-artifact/v1' || parsed.runId !== manifest.runId
|
|
374
|
+
|| parsed.caseId !== item.caseId || parsed.outcome !== item.outcome
|
|
375
|
+
|| JSON.stringify(parsed.testEvidenceIds) !== JSON.stringify(item.testEvidenceIds ?? [])) {
|
|
376
|
+
return { caseId: item.caseId, verified: false, reason: 'invalid-artifact' };
|
|
377
|
+
}
|
|
378
|
+
return { caseId: item.caseId, verified: true };
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return { caseId: item.caseId, verified: false, reason: error instanceof SyntaxError ? 'invalid-artifact' : 'missing' };
|
|
382
|
+
}
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
/** Promotion-capable evaluation that fails closed when any artifact cannot be independently verified. */
|
|
386
|
+
export async function evaluateExecutedQualification(manifest, artifactRoot, maxArtifactBytes) {
|
|
387
|
+
const verification = await verifyQualificationArtifacts(manifest, artifactRoot, maxArtifactBytes);
|
|
388
|
+
const gateVerification = await verifyQualificationGateArtifacts(manifest, artifactRoot, maxArtifactBytes);
|
|
389
|
+
const verified = new Map(verification.map(item => [item.caseId, item.verified]));
|
|
390
|
+
const gateVerified = new Set(gateVerification.filter(item => item.verified).map(item => item.flag));
|
|
391
|
+
const hardened = {
|
|
392
|
+
...manifest,
|
|
393
|
+
evidence: manifest.evidence.map(item => verified.get(item.caseId) === true ? item : { ...item, executable: false }),
|
|
394
|
+
gateArtifacts: Object.fromEntries(Object.entries(manifest.gateArtifacts ?? {}).filter(([flag]) => gateVerified.has(flag))),
|
|
395
|
+
};
|
|
396
|
+
const caseIds = new Set(manifest.cases.map(item => item.id));
|
|
397
|
+
const artifactsVerified = verification.length > 0 && verification.length === caseIds.size
|
|
398
|
+
&& verification.every(item => item.verified && caseIds.has(item.caseId))
|
|
399
|
+
&& gateVerification.every(item => item.verified);
|
|
400
|
+
return { manifest, verification, gateVerification,
|
|
401
|
+
report: evaluateQualification(hardened, undefined, { artifactsVerified }) };
|
|
402
|
+
}
|
|
403
|
+
/** Executes, persists, verifies, and evaluates in one promotion-capable pipeline. */
|
|
404
|
+
export async function executeAndEvaluateQualification(plan) {
|
|
405
|
+
const manifest = await executeQualificationPlan(plan);
|
|
406
|
+
return evaluateExecutedQualification(manifest, plan.artifactRoot, plan.maxArtifactBytes);
|
|
407
|
+
}
|
|
408
|
+
//# sourceMappingURL=runner.js.map
|
|
@@ -2,6 +2,9 @@ import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto
|
|
|
2
2
|
import { link, mkdir, open, readFile, readdir, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { canonicalJson } from '../security/artifact-trust.js';
|
|
5
|
+
import { assertNoPortableSecretMaterial } from './portable-secrets.js';
|
|
6
|
+
import { assertDecisionResultWriterVersion } from './validate.js';
|
|
7
|
+
import { isTraceparent } from './telemetry/context.js';
|
|
5
8
|
export function decisionInvocationFingerprint(input) {
|
|
6
9
|
return `sha256:${createHash('sha256').update(canonicalJson({
|
|
7
10
|
invocationId: input.invocationId, input: input.value, definitions: input.definitions,
|
|
@@ -44,13 +47,31 @@ export function validateReceipt(receipt, invocationId, projectId) {
|
|
|
44
47
|
|| !Array.isArray(receipt.pending.attempts)))
|
|
45
48
|
|| (receipt.state === 'completed' && (!receipt.result || receipt.result.spec.invocationId !== invocationId
|
|
46
49
|
|| receipt.result.spec.status === 'error' && receipt.result.spec.reason === 'execution-uncertain'))
|
|
47
|
-
|| (receipt.state !== 'completed' && receipt.result !== undefined)
|
|
50
|
+
|| (receipt.state !== 'completed' && receipt.result !== undefined)
|
|
51
|
+
|| (receipt.traceParent !== undefined && !isTraceparent(receipt.traceParent))) {
|
|
48
52
|
throw new DecisionReceiptIntegrityError('Invalid decision receipt');
|
|
49
53
|
}
|
|
54
|
+
assertReceiptPortable(receipt);
|
|
55
|
+
}
|
|
56
|
+
/** Receipts are portable artifacts: handles and payloads must never carry secret material. */
|
|
57
|
+
function assertReceiptPortable(receipt) {
|
|
58
|
+
const reject = (message) => new DecisionReceiptIntegrityError(message);
|
|
59
|
+
receipt.remoteHandles.forEach(handle => assertNoPortableSecretMaterial(handle, 'Decision receipt remote handle', reject));
|
|
60
|
+
assertNoPortableSecretMaterial(receipt.evaluations, 'Decision receipt evaluations', reject);
|
|
61
|
+
assertNoPortableSecretMaterial(receipt.pending, 'Decision receipt pending evaluation', reject);
|
|
62
|
+
if (receipt.result !== undefined)
|
|
63
|
+
assertNoPortableSecretMaterial(receipt.result, 'Decision receipt result', reject);
|
|
50
64
|
}
|
|
51
65
|
export function nextReceipt(previous, state, extra = {}) {
|
|
52
66
|
if (!allowed[previous.state].includes(state))
|
|
53
67
|
throw new DecisionReceiptIntegrityError('Illegal receipt transition');
|
|
68
|
+
// Receipt payloads are result writers: newly written results pass the v1alpha2 writer gate.
|
|
69
|
+
if (extra.result !== undefined)
|
|
70
|
+
assertDecisionResultWriterVersion(extra.result);
|
|
71
|
+
for (const [alias, result] of Object.entries(extra.evaluations ?? {})) {
|
|
72
|
+
if (!Object.hasOwn(previous.evaluations, alias))
|
|
73
|
+
assertDecisionResultWriterVersion(result);
|
|
74
|
+
}
|
|
54
75
|
const updatedAtEpochMs = Math.max(previous.updatedAtEpochMs, extra.updatedAtEpochMs ?? Date.now());
|
|
55
76
|
const next = { ...structuredClone(previous), ...structuredClone(extra), state, revision: previous.revision + 1,
|
|
56
77
|
updatedAtEpochMs, ...(state === 'completed' ? { completedAtEpochMs: extra.completedAtEpochMs ?? updatedAtEpochMs } : {}) };
|
|
@@ -63,10 +84,12 @@ export function nextReceipt(previous, state, extra = {}) {
|
|
|
63
84
|
validateReceipt(next, previous.invocationId, previous.projectId);
|
|
64
85
|
return next;
|
|
65
86
|
}
|
|
66
|
-
function initial(invocationId, projectId, fingerprint) {
|
|
87
|
+
function initial(invocationId, projectId, fingerprint, options = {}) {
|
|
67
88
|
const acquiredAtEpochMs = Date.now();
|
|
89
|
+
// traceParent is fixed at acquisition: nextReceipt() copies it and transitions cannot supply it.
|
|
68
90
|
const receipt = { schema: 'decision-receipt/v2', revision: 1, acquiredAtEpochMs, updatedAtEpochMs: acquiredAtEpochMs,
|
|
69
|
-
projectId, invocationId, fingerprint, state: 'acquired', remoteHandles: [], evaluations: {}, pending: null
|
|
91
|
+
projectId, invocationId, fingerprint, state: 'acquired', remoteHandles: [], evaluations: {}, pending: null,
|
|
92
|
+
...(options.traceParent !== undefined ? { traceParent: options.traceParent } : {}) };
|
|
70
93
|
validateReceipt(receipt, invocationId, projectId);
|
|
71
94
|
return receipt;
|
|
72
95
|
}
|
|
@@ -97,14 +120,14 @@ export class MemoryDecisionReceiptStore {
|
|
|
97
120
|
validateReceipt(receipt, invocationId, projectId);
|
|
98
121
|
return structuredClone(receipt);
|
|
99
122
|
}
|
|
100
|
-
async acquire(invocationId, projectId, fingerprint) {
|
|
123
|
+
async acquire(invocationId, projectId, fingerprint, options = {}) {
|
|
101
124
|
await this.check(projectId);
|
|
102
125
|
const existing = this.receipts.get(invocationId);
|
|
103
126
|
if (existing) {
|
|
104
127
|
validateReceipt(existing, invocationId, projectId);
|
|
105
128
|
return { owner: false, receipt: structuredClone(existing) };
|
|
106
129
|
}
|
|
107
|
-
const receipt = initial(invocationId, projectId, fingerprint);
|
|
130
|
+
const receipt = initial(invocationId, projectId, fingerprint, options);
|
|
108
131
|
this.receipts.set(invocationId, receipt);
|
|
109
132
|
return { owner: true, receipt: structuredClone(receipt) };
|
|
110
133
|
}
|
|
@@ -210,12 +233,12 @@ export class FileDecisionReceiptStore {
|
|
|
210
233
|
}
|
|
211
234
|
return structuredClone(previous);
|
|
212
235
|
}
|
|
213
|
-
async acquire(invocationId, projectId, fingerprint) {
|
|
236
|
+
async acquire(invocationId, projectId, fingerprint, options = {}) {
|
|
214
237
|
await this.check(projectId);
|
|
215
238
|
const existing = await this.read(invocationId, projectId);
|
|
216
239
|
if (existing)
|
|
217
240
|
return { owner: false, receipt: existing };
|
|
218
|
-
const receipt = initial(invocationId, projectId, fingerprint);
|
|
241
|
+
const receipt = initial(invocationId, projectId, fingerprint, options);
|
|
219
242
|
if (await this.persist(invocationId, receipt))
|
|
220
243
|
return { owner: true, receipt };
|
|
221
244
|
const winner = await this.read(invocationId, projectId);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { canonicalJson } from '../../security/artifact-trust.js';
|
|
2
|
+
import { assertNoPortableSecretMaterial } from '../portable-secrets.js';
|
|
3
|
+
import { resultCacheSha256 } from './key.js';
|
|
4
|
+
export class ResultCacheIntegrityError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export function entryIntegrityDigest(entry) {
|
|
7
|
+
return resultCacheSha256(canonicalJson(entry));
|
|
8
|
+
}
|
|
9
|
+
export function assertResultCacheEntry(entry) {
|
|
10
|
+
if (entry.schemaVersion !== 'decision-result-cache/v1' || entry.revision !== 1)
|
|
11
|
+
throw new ResultCacheIntegrityError('Unsupported cache entry schema');
|
|
12
|
+
const { integrityDigest, ...unsigned } = entry;
|
|
13
|
+
if (entryIntegrityDigest(unsigned) !== integrityDigest)
|
|
14
|
+
throw new ResultCacheIntegrityError('Cache entry integrity check failed');
|
|
15
|
+
if (entry.evidence.resultDigest !== resultCacheSha256(canonicalJson(entry.evidence.result)))
|
|
16
|
+
throw new ResultCacheIntegrityError('Cached result digest mismatch');
|
|
17
|
+
if (entry.expiresAtEpochMs <= entry.createdAtEpochMs)
|
|
18
|
+
throw new ResultCacheIntegrityError('Invalid cache freshness interval');
|
|
19
|
+
if (entry.evidence.durationMs < 0)
|
|
20
|
+
throw new ResultCacheIntegrityError('Invalid original duration');
|
|
21
|
+
if (entry.evidence.status === 'success' && entry.evidence.failureReason !== 'none')
|
|
22
|
+
throw new ResultCacheIntegrityError('Successful evidence has a failure reason');
|
|
23
|
+
if (entry.evidence.status === 'terminal-failure' && entry.evidence.failureReason !== 'invalid-input')
|
|
24
|
+
throw new ResultCacheIntegrityError('Unsafe negative cache entry');
|
|
25
|
+
// Cache entries are portable (export/replay); never admit or serve secret material.
|
|
26
|
+
assertNoPortableSecretMaterial(entry, 'Cache entry', message => new ResultCacheIntegrityError(message));
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=integrity.js.map
|