@kontourai/flow-agents 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +16 -0
- package/.github/workflows/ci.yml +44 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +62 -0
- package/CONTEXT.md +35 -0
- package/README.md +9 -3
- package/agents/dev.json +1 -1
- package/agents/tool-code-reviewer.json +1 -1
- package/agents/tool-planner.json +3 -3
- package/agents/tool-verifier.json +3 -3
- package/build/src/cli/assignment-provider.d.ts +1 -0
- package/build/src/cli/assignment-provider.js +748 -0
- package/build/src/cli/console-learning-projection.js +3 -2
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/init.js +104 -21
- package/build/src/cli/kit.js +37 -6
- package/build/src/cli/pull-work-provider.js +1 -1
- package/build/src/cli/usage-feedback.js +3 -3
- package/build/src/cli/validate-hook-influence.js +1 -0
- package/build/src/cli/validate-workflow-artifacts.js +27 -7
- package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
- package/build/src/cli/workflow-sidecar.d.ts +14 -7
- package/build/src/cli/workflow-sidecar.js +938 -168
- package/build/src/cli.js +6 -0
- package/build/src/flow-kit/validate.d.ts +23 -0
- package/build/src/flow-kit/validate.js +67 -1
- package/build/src/index.d.ts +1 -1
- package/build/src/index.js +1 -1
- package/build/src/lib/flow-resolver.d.ts +6 -4
- package/build/src/lib/flow-resolver.js +125 -58
- package/build/src/lib/local-artifact-root.d.ts +14 -2
- package/build/src/lib/local-artifact-root.js +22 -5
- package/build/src/lib/workflow-learning-projection.js +2 -2
- package/build/src/runtime-adapters.d.ts +12 -0
- package/build/src/runtime-adapters.js +56 -16
- package/build/src/tools/build-universal-bundles.js +49 -16
- package/build/src/tools/generate-context-map.js +10 -8
- package/build/src/tools/validate-source-tree.js +24 -1
- package/console.telemetry.json +22 -0
- package/context/contracts/artifact-contract.md +40 -7
- package/context/contracts/assignment-provider-contract.md +239 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +5 -2
- package/context/contracts/decision-registry-contract.md +265 -0
- package/context/contracts/delivery-contract.md +3 -1
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +4 -1
- package/context/contracts/review-contract.md +7 -3
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +11 -1
- package/context/contracts/work-item-contract.md +3 -1
- package/context/deferred/parallelization.md +1 -1
- package/context/gate-awareness.md +4 -4
- package/context/scripts/git-status.sh +2 -2
- package/context/scripts/hooks/config-protection.js +502 -2
- package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
- package/context/scripts/hooks/run-hook.js +14 -1
- package/context/scripts/hooks/stop-goal-fit.js +1140 -99
- package/context/scripts/hooks/workflow-steering.js +212 -6
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
- package/docs/adr/0019-kit-dependency-ownership.md +62 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +463 -0
- package/docs/agent-system-guidebook.md +3 -3
- package/docs/agent-usage-feedback-loop.md +6 -6
- package/docs/context-map.md +10 -7
- package/docs/decisions/decision-records.md +57 -0
- package/docs/decisions/index.md +17 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/promotion-gate.md +52 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/developer-architecture.md +1 -1
- package/docs/fixture-ownership.md +4 -0
- package/docs/flow-kit-repository-contract.md +5 -5
- package/docs/getting-started.md +12 -2
- package/docs/integrations/harness-install.md +2 -1
- package/docs/kit-authoring-guide.md +64 -5
- package/docs/knowledge-kit.md +5 -1
- package/docs/learnings/2026-07-improvement-program.md +203 -0
- package/docs/migrations.md +6 -5
- package/docs/north-star.md +2 -2
- package/docs/operating-layers.md +4 -3
- package/docs/repository-structure.md +5 -5
- package/docs/skills-map.md +8 -8
- package/docs/spec/runtime-hook-surface.md +19 -15
- package/docs/spikes/graph-provider-2026-07.md +155 -0
- package/docs/standards-register.md +7 -7
- package/docs/trust-anchor-adoption.md +105 -4
- package/docs/work-item-adapters.md +1 -1
- package/docs/workflow-artifact-lifecycle.md +72 -9
- package/docs/workflow-eval-strategy.md +3 -3
- package/docs/workflow-shared-contracts.md +3 -2
- package/docs/workflow-usage-guide.md +260 -21
- package/evals/acceptance/DEMO-false-completion.md +20 -11
- package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
- package/evals/acceptance/prove-capture-teeth.sh +11 -11
- package/evals/acceptance/test_kiro_harness.sh +1 -1
- package/evals/ci/antigaming-suite.sh +5 -0
- package/evals/ci/run-baseline.sh +71 -1
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
- package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
- package/evals/fixtures/hook-influence/cases.json +32 -0
- package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
- package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
- package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
- package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
- package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
- package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
- package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
- package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
- package/evals/integration/test_actor_identity.sh +254 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_builder_step_producers.sh +33 -23
- package/evals/integration/test_bundle_install.sh +22 -16
- package/evals/integration/test_bundle_lifecycle.sh +11 -11
- package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
- package/evals/integration/test_checkpoint_signing.sh +43 -0
- package/evals/integration/test_claim_lookup.sh +4 -4
- package/evals/integration/test_codex_hook_resolution.sh +114 -0
- package/evals/integration/test_command_log_concurrency.sh +8 -8
- package/evals/integration/test_command_log_fork_classification.sh +3 -3
- package/evals/integration/test_command_log_integrity.sh +16 -16
- package/evals/integration/test_console_learning_projection.sh +1 -1
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_dual_emit_flow_step.sh +145 -0
- package/evals/integration/test_enforcer_expects_driven.sh +12 -12
- package/evals/integration/test_evidence_capture_hook.sh +14 -13
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_agents_statusline.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +52 -0
- package/evals/integration/test_flow_kit_repository.sh +2 -0
- package/evals/integration/test_flowdef_session_activation.sh +6 -6
- package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
- package/evals/integration/test_gate_bypass_chain.sh +52 -40
- package/evals/integration/test_gate_lockdown.sh +97 -73
- package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
- package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
- package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
- package/evals/integration/test_goal_fit_hook.sh +32 -32
- package/evals/integration/test_goal_fit_rederive.sh +9 -9
- package/evals/integration/test_hook_category_behaviors.sh +103 -0
- package/evals/integration/test_hook_influence_cases.sh +1 -0
- package/evals/integration/test_install_merge.sh +262 -1
- package/evals/integration/test_kit_identity_trust.sh +6 -6
- package/evals/integration/test_liveness_conflict_injection.sh +587 -0
- package/evals/integration/test_liveness_heartbeat.sh +677 -0
- package/evals/integration/test_liveness_verdict.sh +394 -0
- package/evals/integration/test_local_flow_kit_install.sh +15 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
- package/evals/integration/test_promote_gate.sh +178 -0
- package/evals/integration/test_publish_delivery.sh +23 -6
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +394 -0
- package/evals/integration/test_pull_work_provider.sh +2 -2
- package/evals/integration/test_reconcile_soundness.sh +37 -13
- package/evals/integration/test_resolvefirststep_security.sh +22 -0
- package/evals/integration/test_runtime_adapter_activation.sh +87 -18
- package/evals/integration/test_session_resume_roundtrip.sh +147 -0
- package/evals/integration/test_sidecar_field_preservation.sh +249 -0
- package/evals/integration/test_trust_reconcile.sh +11 -10
- package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
- package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +759 -0
- package/evals/integration/test_usage_feedback_global.sh +5 -5
- package/evals/integration/test_validate_artifacts_portability.sh +60 -0
- package/evals/integration/test_verify_cli.sh +2 -2
- package/evals/integration/test_veritas_governance_kit.sh +117 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
- package/evals/integration/test_workflow_artifacts.sh +33 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +802 -74
- package/evals/integration/test_workflow_steering_hook.sh +175 -5
- package/evals/lib/codex-provider.sh +1 -1
- package/evals/lib/node.sh +19 -1
- package/evals/run.sh +32 -0
- package/evals/static/test_decisions.sh +150 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +62 -0
- package/evals/static/test_knowledge_providers.sh +23 -0
- package/evals/static/test_package.sh +19 -10
- package/evals/static/test_universal_bundles.sh +48 -3
- package/evals/static/test_workflow_skills.sh +41 -8
- package/kits/builder/flows/build.flow.json +3 -74
- package/kits/builder/flows/publish-learn.flow.json +90 -0
- package/kits/builder/kit.json +11 -0
- package/kits/builder/skills/builder-shape/SKILL.md +1 -1
- package/kits/builder/skills/deliver/SKILL.md +32 -11
- package/kits/builder/skills/design-probe/SKILL.md +4 -4
- package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
- package/kits/builder/skills/execute-plan/SKILL.md +2 -2
- package/kits/builder/skills/fix-bug/SKILL.md +2 -0
- package/kits/builder/skills/gate-review/SKILL.md +6 -6
- package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
- package/kits/builder/skills/learning-review/SKILL.md +7 -7
- package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
- package/kits/builder/skills/plan-work/SKILL.md +27 -5
- package/kits/builder/skills/pull-work/SKILL.md +148 -5
- package/kits/builder/skills/release-readiness/SKILL.md +4 -4
- package/kits/builder/skills/review-work/SKILL.md +24 -1
- package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
- package/kits/builder/skills/verify-work/SKILL.md +8 -1
- package/kits/catalog.json +6 -0
- package/kits/knowledge/kit.json +35 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +125 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +24 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
- package/kits/veritas-governance/docs/README.md +75 -0
- package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
- package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
- package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
- package/kits/veritas-governance/kit.json +16 -0
- package/package.json +11 -5
- package/packaging/README.md +2 -2
- package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
- package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
- package/packaging/manifest.json +9 -10
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/decision-record.schema.json +109 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/schemas/workflow-evidence.schema.json +1 -1
- package/schemas/workflow-state.schema.json +5 -0
- package/scripts/README.md +8 -3
- package/scripts/check-content-boundary.cjs +1 -1
- package/scripts/check-decisions.cjs +356 -0
- package/scripts/ci/derive-claim-status.mjs +74 -0
- package/scripts/ci/trust-reconcile.js +957 -103
- package/scripts/git-status.sh +2 -2
- package/scripts/hooks/claude-telemetry-hook.js +37 -3
- package/scripts/hooks/codex-telemetry-hook.js +36 -2
- package/scripts/hooks/config-protection.js +102 -30
- package/scripts/hooks/evidence-capture.js +3 -3
- package/scripts/hooks/lib/actor-identity.js +368 -0
- package/scripts/hooks/lib/config-protection-remedies.js +69 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
- package/scripts/hooks/lib/liveness-policy.js +127 -0
- package/scripts/hooks/lib/liveness-read.js +105 -12
- package/scripts/hooks/lib/liveness-write.js +46 -0
- package/scripts/hooks/lib/local-artifact-paths.js +12 -6
- package/scripts/hooks/opencode-telemetry-hook.js +27 -1
- package/scripts/hooks/pi-telemetry-hook.js +27 -1
- package/scripts/hooks/stop-goal-fit.js +157 -26
- package/scripts/hooks/workflow-steering.js +77 -3
- package/scripts/install-codex-home.sh +138 -38
- package/scripts/statusline/flow-agents-statusline.js +2 -2
- package/src/cli/assignment-provider.ts +845 -0
- package/src/cli/console-learning-projection.ts +3 -2
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/init.ts +101 -21
- package/src/cli/kit.ts +37 -6
- package/src/cli/public-api.test.mjs +58 -6
- package/src/cli/pull-work-provider.ts +1 -1
- package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
- package/src/cli/usage-feedback.ts +3 -3
- package/src/cli/validate-hook-influence.ts +1 -0
- package/src/cli/validate-workflow-artifacts.ts +27 -7
- package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
- package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
- package/src/cli/workflow-sidecar.ts +933 -168
- package/src/cli.ts +6 -0
- package/src/flow-kit/validate.ts +74 -1
- package/src/index.ts +6 -2
- package/src/lib/flow-resolver.ts +123 -55
- package/src/lib/local-artifact-root.ts +24 -5
- package/src/lib/workflow-learning-projection.ts +2 -2
- package/src/runtime-adapters.ts +61 -15
- package/src/tools/build-universal-bundles.ts +49 -16
- package/src/tools/generate-context-map.ts +10 -8
- package/src/tools/validate-source-tree.ts +24 -1
|
@@ -16,13 +16,56 @@
|
|
|
16
16
|
* e. checkpoint-only bundle (no evidence/claims) → DIVERGENCE (full bundle required)
|
|
17
17
|
*
|
|
18
18
|
* Exit codes:
|
|
19
|
-
* 0 — fresh verify passed AND no divergence (
|
|
20
|
-
*
|
|
19
|
+
* 0 — fresh verify passed AND no divergence (bundle reconciled clean, OR bundle
|
|
20
|
+
* absence is covered by a well-formed, in-scope delivery/DECLARED marker)
|
|
21
|
+
* 1 — fresh verify failed OR any divergence detected OR compile-only fallback OR
|
|
22
|
+
* bundle absent with no valid delivery/DECLARED marker (bundle-required by default)
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
* delivery/trust.bundle or delivery/trust.checkpoint.json),
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Bundle-required by default (ADR 0022 §1): if no bundle is provided (and none
|
|
25
|
+
* auto-discovered at delivery/trust.bundle or delivery/trust.checkpoint.json), the
|
|
26
|
+
* reconciler no longer fails open. It looks for a delivery/DECLARED no-agent-delivery
|
|
27
|
+
* marker (ADR 0022 §2) — a single {scope, reason, approved_by, declared_at} object, or a
|
|
28
|
+
* JSON array of such objects, resolved via the same path seam bundle discovery uses (see
|
|
29
|
+
* resolveDeliveryCandidates()). A well-formed marker whose `scope` matches this change
|
|
30
|
+
* (`ref:`, `commit:`/`commit:a..b`, `author:`, or `branch-prefix:` — see matchesScope())
|
|
31
|
+
* exempts Step 2 ONLY; Step 1 (fresh verify) still runs unconditionally and is never
|
|
32
|
+
* skipped. An absent, malformed (missing any required field), or out-of-scope marker is
|
|
33
|
+
* treated the same as bundle-absence-with-no-exemption: it produces a
|
|
34
|
+
* `bundle-required-no-declared-marker` issue and exits 1. Fail-closed on divergence: any
|
|
35
|
+
* claimed-pass command CI cannot confirm blocks. Fail-closed on compile-only: if no
|
|
36
|
+
* comprehensive verify is configured, exits 1.
|
|
37
|
+
*
|
|
38
|
+
* "Bundle-required" means a bundle FOR THIS CHANGE (ADR 0022 addendum §2), not merely a
|
|
39
|
+
* bundle reachable at the checkout: an AUTO-DISCOVERED bundle whose trust.checkpoint.json
|
|
40
|
+
* `commit_sha` neither equals nor is a git-ancestor of this change's own commit sha
|
|
41
|
+
* (TRUST_RECONCILE_SHA/GITHUB_SHA) is treated as ABSENT, not present — see
|
|
42
|
+
* bundleAttestsThisChange(). Without this, a bundle inherited unchanged from main via
|
|
43
|
+
* update-branch/rebase (live incident: PR #278) would satisfy Step 2 trivially for every
|
|
44
|
+
* dependabot/release-please PR, since neither bot's branch ever runs the deliver skill
|
|
45
|
+
* itself. A stale bundle prints a loud `stale bundle ignored — attests <theirs>, this
|
|
46
|
+
* change is <ours>` line and then falls through to delivery/DECLARED resolution exactly
|
|
47
|
+
* as if no bundle had been discovered at all. This check applies ONLY to auto-discovery —
|
|
48
|
+
* an explicit --bundle/TRUST_RECONCILE_BUNDLE is a deliberate caller choice (tests,
|
|
49
|
+
* programmatic callers), not something silently inherited from committed git tree state.
|
|
50
|
+
*
|
|
51
|
+
* Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement (the
|
|
52
|
+
* `bundle-required-no-declared-marker` fail-closed branch, and the staleness-gate
|
|
53
|
+
* consequence of it above) applies ONLY when this run is gating a proposed change —
|
|
54
|
+
* TRUST_RECONCILE_EVENT (set from `github.event_name` by
|
|
55
|
+
* .github/workflows/trust-reconcile.yml) resolving to anything other than 'push'
|
|
56
|
+
* (conservative default: unknown/absent → enforce; see resolveEnforcementEvent()). On a
|
|
57
|
+
* 'push' event, an absent or stale auto-discovered bundle is a LOUD NO-OP (`push event:
|
|
58
|
+
* ... — skipping Step 2 (gating happened on the PR run)`, exit 0), never a failure and
|
|
59
|
+
* never a delivery/DECLARED requirement — a push run on a protected branch happens AFTER
|
|
60
|
+
* the gating PR run already passed (branch protection already excludes direct pushes),
|
|
61
|
+
* and after a squash-merge the merge commit almost never has git ancestry back to the
|
|
62
|
+
* feature-branch commit a checkpoint was sealed against, so bundle absence/staleness on
|
|
63
|
+
* push is the EXPECTED case, not a divergence (reproduced empirically as a HIGH
|
|
64
|
+
* launch-blocker: without this scoping, every delivery would fail Trust Reconcile on
|
|
65
|
+
* `main` immediately post-merge and Phase 2 attestation minting would stop). Step 1
|
|
66
|
+
* (fresh verify) is unaffected by event scoping — it always runs. A fresh auto-discovered
|
|
67
|
+
* bundle on push still reconciles normally (Step 2 runs, forming the Phase 2 attestation
|
|
68
|
+
* basis) — only absence/staleness is treated differently by event.
|
|
26
69
|
*
|
|
27
70
|
* Inputs (CLI args take precedence over env):
|
|
28
71
|
* --bundle <path> Delivered trust.bundle (JSON) path. May also be a
|
|
@@ -32,13 +75,27 @@
|
|
|
32
75
|
* --repo-root <path> Repository root. Default: TRUST_RECONCILE_REPO_ROOT or cwd.
|
|
33
76
|
*
|
|
34
77
|
* Environment fallbacks:
|
|
35
|
-
* TRUST_RECONCILE_BUNDLE
|
|
36
|
-
* TRUST_RECONCILE_COMMANDS
|
|
37
|
-
* TRUST_RECONCILE_REPO_ROOT
|
|
78
|
+
* TRUST_RECONCILE_BUNDLE Path to delivered bundle (same as --bundle).
|
|
79
|
+
* TRUST_RECONCILE_COMMANDS Comma- or newline-separated canonical commands.
|
|
80
|
+
* TRUST_RECONCILE_REPO_ROOT Repository root.
|
|
81
|
+
* TRUST_RECONCILE_COMMAND_TIMEOUT_MS Timeout (ms) for each canonical/manifest command
|
|
82
|
+
* run (Step 1 fresh-verify + on-demand manifest
|
|
83
|
+
* reconcile). Default: 600000 (10 min).
|
|
84
|
+
* TRUST_RECONCILE_REF/_ACTOR/_SHA Override the delivery/DECLARED scope-matching
|
|
85
|
+
* context (ref / actor / commit sha) that otherwise
|
|
86
|
+
* falls back to GITHUB_HEAD_REF|GITHUB_REF,
|
|
87
|
+
* GITHUB_ACTOR, GITHUB_SHA respectively — for
|
|
88
|
+
* deterministic local/eval testing.
|
|
89
|
+
* TRUST_RECONCILE_EVENT CI event name (`github.event_name`) driving
|
|
90
|
+
* event-scoped bundle-required enforcement (ADR
|
|
91
|
+
* 0022 addendum, part 4) — see
|
|
92
|
+
* resolveEnforcementEvent(). Default (unset/
|
|
93
|
+
* unrecognized): treated as a gating event.
|
|
38
94
|
*
|
|
39
95
|
* Auto-discovery (when no --bundle or TRUST_RECONCILE_BUNDLE is set):
|
|
40
|
-
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
41
|
-
* If neither exists,
|
|
96
|
+
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
97
|
+
* (via the resolveDeliveryCandidates() seam). If neither exists, falls through to
|
|
98
|
+
* delivery/DECLARED marker resolution — see "Bundle-required by default" above.
|
|
42
99
|
*
|
|
43
100
|
* Canonical commands resolution (fail-closed on compile-only):
|
|
44
101
|
* Priority: CLI --commands > TRUST_RECONCILE_COMMANDS env > package.json
|
|
@@ -46,9 +103,11 @@
|
|
|
46
103
|
* "no comprehensive trust-reconcile-verify configured" — refuses to attest a
|
|
47
104
|
* compile-only check.
|
|
48
105
|
*
|
|
49
|
-
* NOTE: This job
|
|
50
|
-
*
|
|
51
|
-
*
|
|
106
|
+
* NOTE: This job IS ALREADY a required status check in GitHub branch protection on
|
|
107
|
+
* `main` (verified via the GitHub branch-protection API — required_status_checks.contexts
|
|
108
|
+
* includes "Trust Reconcile", enforce_admins: true). Disabling or downgrading that
|
|
109
|
+
* requirement is a server-side branch-protection change — it cannot be done by editing
|
|
110
|
+
* this file or .github/workflows/trust-reconcile.yml.
|
|
52
111
|
*
|
|
53
112
|
* Programmatic use:
|
|
54
113
|
* const { runTrustReconcile } = require('./trust-reconcile.js');
|
|
@@ -88,103 +147,223 @@ function isPassingValue(v) {
|
|
|
88
147
|
// CI reconciler and the stop-goal-fit verifier apply the identical exit-code-mask
|
|
89
148
|
// heuristic — see that module for the rules.
|
|
90
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Default manifest/canonical-command execution timeout (ms). Overridable via
|
|
152
|
+
* TRUST_RECONCILE_COMMAND_TIMEOUT_MS. 10 minutes is comfortably above the slowest
|
|
153
|
+
* required-lane check (this repo's own CI lanes allow up to ~10 min); the prior
|
|
154
|
+
* hardcoded 180000ms (3 min) killed legitimately slow evals (e.g.
|
|
155
|
+
* evals/integration/test_workflow_sidecar_writer.sh, ~150s on a fast Mac, >180s on
|
|
156
|
+
* GitHub Actions runners) mid-run, producing a spurious divergence rather than a real
|
|
157
|
+
* failure.
|
|
158
|
+
*/
|
|
159
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 600000;
|
|
160
|
+
|
|
161
|
+
/** Resolve the manifest/canonical-command timeout: TRUST_RECONCILE_COMMAND_TIMEOUT_MS env, else default. */
|
|
162
|
+
function resolveCommandTimeoutMs() {
|
|
163
|
+
const raw = process.env.TRUST_RECONCILE_COMMAND_TIMEOUT_MS;
|
|
164
|
+
if (raw !== undefined && raw !== '') {
|
|
165
|
+
const parsed = Number(raw);
|
|
166
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
167
|
+
}
|
|
168
|
+
return DEFAULT_COMMAND_TIMEOUT_MS;
|
|
169
|
+
}
|
|
170
|
+
|
|
91
171
|
/**
|
|
92
172
|
* Run a single shell command under bash, capturing exit code.
|
|
93
|
-
* @returns {{ cmd, exitCode, passed, stdout, stderr }}
|
|
173
|
+
* @returns {{ cmd, exitCode, passed, timedOut, timeoutMs, stdout, stderr }}
|
|
94
174
|
*/
|
|
95
175
|
function runCommand(cmd, repoRoot) {
|
|
176
|
+
const timeoutMs = resolveCommandTimeoutMs();
|
|
96
177
|
const result = spawnSync('bash', ['-c', cmd], {
|
|
97
178
|
cwd: repoRoot,
|
|
98
179
|
encoding: 'utf8',
|
|
99
|
-
timeout:
|
|
180
|
+
timeout: timeoutMs,
|
|
100
181
|
killSignal: 'SIGKILL',
|
|
101
182
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
102
183
|
});
|
|
103
184
|
const exitCode = (result.status !== null && result.status !== undefined)
|
|
104
185
|
? result.status
|
|
105
186
|
: 1;
|
|
187
|
+
// spawnSync sets result.error (ETIMEDOUT) when the timeout kills the process — this is
|
|
188
|
+
// NOT a real command failure and must be distinguishable in the FAIL log line so a
|
|
189
|
+
// future reader chases the timeout config, not a phantom test bug.
|
|
190
|
+
const timedOut = !!(result.error && result.error.code === 'ETIMEDOUT');
|
|
106
191
|
return {
|
|
107
192
|
cmd,
|
|
108
193
|
exitCode,
|
|
109
194
|
passed: exitCode === 0 && !result.error,
|
|
195
|
+
timedOut,
|
|
196
|
+
timeoutMs,
|
|
110
197
|
stdout: result.stdout || '',
|
|
111
198
|
stderr: result.stderr || '',
|
|
112
199
|
};
|
|
113
200
|
}
|
|
114
201
|
|
|
115
202
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
203
|
+
* Classify a trust.bundle's claims into: reconcilable command claims (test_output +
|
|
204
|
+
* execution.label), session-local claims (attestation/observation/citation), never-captured
|
|
205
|
+
* or unbacked command claims (not-run divergence), and command-backed claims carrying a
|
|
206
|
+
* waiver (waiver-on-command divergence). Returns
|
|
207
|
+
* { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
|
|
118
208
|
*
|
|
119
|
-
* Source of truth: evidence[].execution.label is the command string recorded at
|
|
120
|
-
*
|
|
209
|
+
* Source of truth: evidence[].execution.label is the command string recorded at capture time.
|
|
210
|
+
* evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
|
|
211
|
+
* trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
|
|
121
212
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
213
|
+
* WS8 iteration-2 hardening:
|
|
214
|
+
* - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
|
|
215
|
+
* (Surface's default when unset) but which did NOT reconcile — i.e. it has no
|
|
216
|
+
* manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
|
|
217
|
+
* claim either reconciles against the manifest or is a divergence; it is never accepted on
|
|
218
|
+
* self-reported status. (Previously only the literal claimType `workflow.check.command`
|
|
219
|
+
* was guarded, so a fabricated kind:"test" claim with no command slipped through.)
|
|
220
|
+
* - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
|
|
221
|
+
* command-backed check reconciles against CI or fails; it cannot be waived.
|
|
125
222
|
*/
|
|
126
|
-
function
|
|
223
|
+
function classifyBundleClaims(bundle) {
|
|
127
224
|
const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
|
|
128
225
|
const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
129
226
|
|
|
130
|
-
// Build a map from claimId -> claim for fast lookup
|
|
131
227
|
const claimById = new Map();
|
|
228
|
+
for (const c of claims) if (c && c.id) claimById.set(c.id, c);
|
|
229
|
+
|
|
230
|
+
// Evidence indexing. A missing evidenceType defaults to test_output for backward
|
|
231
|
+
// compatibility with pre-classification bundles (same default classifyEvidence uses).
|
|
232
|
+
const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
|
|
233
|
+
const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
|
|
234
|
+
// WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
|
|
235
|
+
// claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
|
|
236
|
+
// human_attestation/attestation/external claim with no --command is otherwise
|
|
237
|
+
// indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
|
|
238
|
+
const claimEvidenceType = new Map();
|
|
239
|
+
for (const ev of evidence) {
|
|
240
|
+
if (!ev || !ev.claimId) continue;
|
|
241
|
+
const evType = ev.evidenceType || 'test_output';
|
|
242
|
+
if (evType !== 'test_output') {
|
|
243
|
+
if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
claimHasTestOutputEvidence.add(ev.claimId);
|
|
247
|
+
if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
|
|
251
|
+
const waiverOnCommand = [];
|
|
132
252
|
for (const c of claims) {
|
|
133
|
-
if (c
|
|
253
|
+
if (!c || !c.id) continue;
|
|
254
|
+
const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
|
|
255
|
+
if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
|
|
256
|
+
waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
|
|
257
|
+
}
|
|
134
258
|
}
|
|
135
259
|
|
|
136
|
-
|
|
260
|
+
// (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
|
|
261
|
+
// carry an execution.label, and assert pass. Session-local evidenceTypes
|
|
262
|
+
// (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
|
|
263
|
+
// document_citation, calculation_trace) are NOT reconciled per-command — they are handled
|
|
264
|
+
// by the session-local/waiver path below.
|
|
265
|
+
const reconcilable = [];
|
|
266
|
+
const reconcilableClaimIds = new Set();
|
|
137
267
|
const seen = new Set();
|
|
138
|
-
|
|
139
|
-
// (A) Evidence items with execution.label and a passing value
|
|
140
268
|
for (const ev of evidence) {
|
|
141
269
|
if (!ev || !ev.execution || !ev.execution.label) continue;
|
|
142
|
-
if (!isPassingValue(ev.passing)) continue;
|
|
143
|
-
|
|
270
|
+
if (!isPassingValue(ev.passing)) continue;
|
|
271
|
+
const evType = ev.evidenceType || 'test_output';
|
|
272
|
+
if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
|
|
144
273
|
const cmd = normalizeCmd(ev.execution.label);
|
|
145
|
-
if (!cmd
|
|
274
|
+
if (!cmd) continue;
|
|
275
|
+
reconcilableClaimIds.add(ev.claimId);
|
|
276
|
+
if (seen.has(cmd)) continue;
|
|
146
277
|
seen.add(cmd);
|
|
147
|
-
|
|
148
278
|
const claim = claimById.get(ev.claimId);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
commands.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType });
|
|
279
|
+
reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
|
|
152
280
|
}
|
|
153
281
|
|
|
154
|
-
// (B)
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
.filter(ev => ev && ev.claimId && ev.execution && ev.execution.label)
|
|
159
|
-
.map(ev => ev.claimId)
|
|
160
|
-
);
|
|
161
|
-
|
|
282
|
+
// (B) Session-local claims, never-captured command claims, and unreconciled test_output.
|
|
283
|
+
const sessionLocal = [];
|
|
284
|
+
const noEvidenceCommand = [];
|
|
285
|
+
const seenClaims = new Set();
|
|
162
286
|
for (const c of claims) {
|
|
163
287
|
if (!c || !c.id || typeof c.claimType !== 'string') continue;
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
288
|
+
// #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
|
|
289
|
+
// resolved session converges (a fail critique that a later same-reviewer pass superseded no
|
|
290
|
+
// longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
|
|
291
|
+
// a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
|
|
292
|
+
if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
|
|
293
|
+
if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
|
|
294
|
+
if (seenClaims.has(c.id)) continue;
|
|
295
|
+
const status = String(c.status || '');
|
|
296
|
+
const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
|
|
297
|
+
const isFailing = status === 'disputed' || status === 'rejected';
|
|
298
|
+
if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
|
|
299
|
+
seenClaims.add(c.id);
|
|
300
|
+
|
|
301
|
+
// finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
|
|
302
|
+
// (it has test_output evidence but no manifest-matchable execution.label — otherwise it
|
|
303
|
+
// would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
|
|
304
|
+
// manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
|
|
305
|
+
if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
|
|
306
|
+
const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
|
|
307
|
+
noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A workflow.check.command claim with no captured (labeled) evidence is a never-captured
|
|
312
|
+
// claimed pass — not-run divergence (anti-gaming teeth preserved).
|
|
313
|
+
if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
|
|
314
|
+
const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
|
|
315
|
+
noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
|
|
320
|
+
sessionLocal.push({
|
|
180
321
|
claimId: c.id,
|
|
181
|
-
evId: null,
|
|
182
322
|
claimType: c.claimType,
|
|
183
|
-
|
|
323
|
+
assertedStatus: status,
|
|
324
|
+
value: c.value,
|
|
325
|
+
waiver: (waiver && typeof waiver === 'object') ? waiver : null,
|
|
326
|
+
subject: c.subjectId || c.fieldOrBehavior || c.id,
|
|
327
|
+
evidenceType: claimEvidenceType.get(c.id) || 'unknown',
|
|
184
328
|
});
|
|
185
329
|
}
|
|
186
330
|
|
|
187
|
-
return
|
|
331
|
+
return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* WS8 (finding 3): re-derive every claim's status CI-side from the bundle's OWN
|
|
336
|
+
* evidence/events/policies, using @kontourai/surface's canonical deriveClaimStatus (the same
|
|
337
|
+
* function the producer used). The reconciler MUST NOT trust a bundle's self-reported
|
|
338
|
+
* `claim.status`; it compares the asserted status to this re-derived status and treats a
|
|
339
|
+
* mismatch as a `status-misassertion` divergence.
|
|
340
|
+
*
|
|
341
|
+
* Runs the ESM helper scripts/ci/derive-claim-status.mjs via spawnSync (Surface is ESM-only;
|
|
342
|
+
* this reconciler is CJS with a synchronous entrypoint). Surface is resolved from the helper's
|
|
343
|
+
* own location (the reconciler's node_modules), so it works even when reconciling an adopter
|
|
344
|
+
* repo that does not itself depend on Surface.
|
|
345
|
+
*
|
|
346
|
+
* @returns {Map<string,string|null>|null} claimId → derived status; null if re-derivation is
|
|
347
|
+
* unavailable (Surface could not load / helper failed to run). A per-claim null value means
|
|
348
|
+
* that specific claim could not be derived.
|
|
349
|
+
*/
|
|
350
|
+
function deriveClaimStatuses(bundlePath, repoRoot) {
|
|
351
|
+
const helper = path.join(__dirname, 'derive-claim-status.mjs');
|
|
352
|
+
if (!fs.existsSync(helper)) return null;
|
|
353
|
+
const res = spawnSync('node', [helper, bundlePath], { cwd: repoRoot, encoding: 'utf8', timeout: 60000 });
|
|
354
|
+
if (res.status !== 0 || !res.stdout) {
|
|
355
|
+
if (res.stderr) process.stderr.write(`[trust-reconcile] status re-derivation unavailable: ${res.stderr.trim()}
|
|
356
|
+
`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const obj = JSON.parse(res.stdout);
|
|
361
|
+
const m = new Map();
|
|
362
|
+
for (const [k, v] of Object.entries(obj)) m.set(k, v);
|
|
363
|
+
return m;
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
188
367
|
}
|
|
189
368
|
|
|
190
369
|
// ---------------------------------------------------------------------------
|
|
@@ -192,7 +371,7 @@ function getClaimedPassCommands(bundle) {
|
|
|
192
371
|
// ---------------------------------------------------------------------------
|
|
193
372
|
|
|
194
373
|
function parseArgs(argv) {
|
|
195
|
-
const args = { bundle: null, commands: [], repoRoot: null };
|
|
374
|
+
const args = { bundle: null, commands: [], repoRoot: null, manifest: null };
|
|
196
375
|
for (let i = 0; i < argv.length; i++) {
|
|
197
376
|
const arg = argv[i];
|
|
198
377
|
const next = argv[i + 1];
|
|
@@ -203,11 +382,104 @@ function parseArgs(argv) {
|
|
|
203
382
|
i++;
|
|
204
383
|
} else if (arg === '--repo-root' && next) {
|
|
205
384
|
args.repoRoot = next; i++;
|
|
385
|
+
} else if (arg === '--manifest' && next) {
|
|
386
|
+
args.manifest = next; i++;
|
|
206
387
|
}
|
|
207
388
|
}
|
|
208
389
|
return args;
|
|
209
390
|
}
|
|
210
391
|
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// WS8 (ADR 0020): reconcile manifest resolution + slugification.
|
|
394
|
+
// The manifest is a list of named {id, command} entries the reconciler treats as
|
|
395
|
+
// individually CI-reconcilable. It is resolved (priority order) from:
|
|
396
|
+
// 1. CLI --manifest <json>
|
|
397
|
+
// 2. env TRUST_RECONCILE_MANIFEST <json>
|
|
398
|
+
// 3. package.json "trust-reconcile-manifest" (inline array, OR a string command
|
|
399
|
+
// that emits the JSON — this repo points it at run-baseline.sh --manifest-json)
|
|
400
|
+
// 4. evals/ci/run-baseline.sh --manifest-json (this repo's live LANE_* registry —
|
|
401
|
+
// every entry runs in a required ci.yml lane by construction)
|
|
402
|
+
// 5. legacy fallback: the resolved fresh-verify commands as a manifest of size N
|
|
403
|
+
// (so the historical single-command behavior is a strict subset — backward compat).
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/** Slugify a label the same way evals/ci/run-baseline.sh's slugify() does. */
|
|
407
|
+
function slugifyLabel(s) {
|
|
408
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Normalize a raw manifest array (of strings or {id, command}) to {id, command}[]. */
|
|
412
|
+
function normalizeManifestEntries(raw) {
|
|
413
|
+
if (!Array.isArray(raw)) return null;
|
|
414
|
+
const out = [];
|
|
415
|
+
for (const item of raw) {
|
|
416
|
+
if (typeof item === 'string') {
|
|
417
|
+
const cmd = normalizeCmd(item);
|
|
418
|
+
if (cmd) out.push({ id: slugifyLabel(cmd), command: cmd });
|
|
419
|
+
} else if (item && typeof item === 'object' && typeof item.command === 'string') {
|
|
420
|
+
const cmd = normalizeCmd(item.command);
|
|
421
|
+
if (cmd) out.push({ id: item.id ? String(item.id) : slugifyLabel(cmd), command: cmd });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return out.length ? out : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function parseManifestJson(text, label) {
|
|
428
|
+
try {
|
|
429
|
+
return normalizeManifestEntries(JSON.parse(text));
|
|
430
|
+
} catch {
|
|
431
|
+
process.stderr.write(`[trust-reconcile] ignoring ${label}: not a valid JSON array of {id, command} entries\n`);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function runBaselineManifest(repoRoot) {
|
|
437
|
+
const script = path.join(repoRoot, 'evals', 'ci', 'run-baseline.sh');
|
|
438
|
+
if (!fs.existsSync(script)) return null;
|
|
439
|
+
const res = spawnSync('bash', [script, '--manifest-json'], { cwd: repoRoot, encoding: 'utf8', timeout: 30000 });
|
|
440
|
+
if (res.status !== 0 || !res.stdout) return null;
|
|
441
|
+
return parseManifestJson(res.stdout, 'evals/ci/run-baseline.sh --manifest-json');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Resolve the reconcile manifest. Returns { entries: {id, command}[], source }.
|
|
446
|
+
* canonicalCommands is used only for the legacy size-N fallback (tier 5).
|
|
447
|
+
*/
|
|
448
|
+
function resolveManifest(args, repoRoot, canonicalCommands) {
|
|
449
|
+
if (args.manifest) {
|
|
450
|
+
const m = parseManifestJson(args.manifest, '--manifest');
|
|
451
|
+
if (m) return { entries: m, source: 'cli:--manifest' };
|
|
452
|
+
}
|
|
453
|
+
if (process.env.TRUST_RECONCILE_MANIFEST) {
|
|
454
|
+
const m = parseManifestJson(process.env.TRUST_RECONCILE_MANIFEST, 'TRUST_RECONCILE_MANIFEST');
|
|
455
|
+
if (m) return { entries: m, source: 'env:TRUST_RECONCILE_MANIFEST' };
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const pkgPath = path.join(repoRoot, 'package.json');
|
|
459
|
+
if (fs.existsSync(pkgPath)) {
|
|
460
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
461
|
+
const raw = (pkg && pkg['trust-reconcile-manifest'])
|
|
462
|
+
|| (pkg && pkg.scripts && pkg.scripts['trust-reconcile-manifest']);
|
|
463
|
+
if (Array.isArray(raw)) {
|
|
464
|
+
const m = normalizeManifestEntries(raw);
|
|
465
|
+
if (m) return { entries: m, source: 'package.json:trust-reconcile-manifest (inline array)' };
|
|
466
|
+
} else if (typeof raw === 'string' && raw.trim()) {
|
|
467
|
+
const res = spawnSync('bash', ['-c', raw], { cwd: repoRoot, encoding: 'utf8', timeout: 60000 });
|
|
468
|
+
if (res.status === 0 && res.stdout) {
|
|
469
|
+
const m = parseManifestJson(res.stdout, 'package.json trust-reconcile-manifest emit');
|
|
470
|
+
if (m) return { entries: m, source: `package.json:trust-reconcile-manifest (${raw})` };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch { /* ignore */ }
|
|
475
|
+
const rb = runBaselineManifest(repoRoot);
|
|
476
|
+
if (rb) return { entries: rb, source: 'evals/ci/run-baseline.sh' };
|
|
477
|
+
const legacy = (canonicalCommands || [])
|
|
478
|
+
.map((c) => ({ id: slugifyLabel(normalizeCmd(c)), command: normalizeCmd(c) }))
|
|
479
|
+
.filter((e) => e.command);
|
|
480
|
+
return { entries: legacy, source: 'legacy:fresh-verify-commands' };
|
|
481
|
+
}
|
|
482
|
+
|
|
211
483
|
/**
|
|
212
484
|
* Resolve the list of canonical verify commands.
|
|
213
485
|
* Priority: CLI --commands > TRUST_RECONCILE_COMMANDS env > package.json scripts key.
|
|
@@ -242,22 +514,370 @@ function resolveCanonicalCommands(args, repoRoot) {
|
|
|
242
514
|
return null;
|
|
243
515
|
}
|
|
244
516
|
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// delivery/ path-resolution seam (#335-aware) + ADR 0022 DECLARED-marker support.
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
const DELIVERY_DIR = 'delivery';
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Single seam: every delivery/ path resolution in this file routes through here.
|
|
525
|
+
* #335 (per-session delivery paths, e.g. delivery/<session-slug>/<filename> instead of
|
|
526
|
+
* one shared path) is expected to change ONLY this function's body — e.g. glob
|
|
527
|
+
* delivery/<slug-glob>/<filename> and return every match in a defined precedence order,
|
|
528
|
+
* or
|
|
529
|
+
* resolve the current PR's session slug and return its single subpath. discoverBundle()
|
|
530
|
+
* and discoverDeclaredMarker() both iterate whatever candidates this returns and pick the
|
|
531
|
+
* first existing one, so a future per-session layout composes with zero call-site changes.
|
|
532
|
+
*/
|
|
533
|
+
function resolveDeliveryCandidates(repoRoot, filename) {
|
|
534
|
+
return [path.join(repoRoot, DELIVERY_DIR, filename)];
|
|
535
|
+
}
|
|
536
|
+
|
|
245
537
|
/**
|
|
246
538
|
* Auto-discover the delivery bundle when no explicit path is provided.
|
|
247
|
-
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
248
|
-
* Returns null if neither is present
|
|
539
|
+
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
540
|
+
* (via resolveDeliveryCandidates()). Returns null if neither is present — the caller
|
|
541
|
+
* then falls through to delivery/DECLARED marker resolution (ADR 0022 §1); bundle
|
|
542
|
+
* absence is no longer fail-open on its own.
|
|
249
543
|
*/
|
|
250
544
|
function discoverBundle(repoRoot) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
545
|
+
for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
|
|
546
|
+
for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
|
|
547
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Auto-discover the delivery/DECLARED no-agent-delivery marker (ADR 0022 §2) via the
|
|
555
|
+
* same resolveDeliveryCandidates() seam bundle discovery uses. Returns null if absent.
|
|
556
|
+
*/
|
|
557
|
+
function discoverDeclaredMarker(repoRoot) {
|
|
558
|
+
for (const candidate of resolveDeliveryCandidates(repoRoot, 'DECLARED')) {
|
|
256
559
|
if (fs.existsSync(candidate)) return candidate;
|
|
257
560
|
}
|
|
258
561
|
return null;
|
|
259
562
|
}
|
|
260
563
|
|
|
564
|
+
/** Required, non-empty-string fields on every delivery/DECLARED marker entry (ADR 0022 §2). */
|
|
565
|
+
const DECLARED_REQUIRED_FIELDS = ['scope', 'reason', 'approved_by', 'declared_at'];
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Parse and validate a delivery/DECLARED marker file. Accepts either a single
|
|
569
|
+
* {scope, reason, approved_by, declared_at} object, or a JSON array of such objects
|
|
570
|
+
* (small, backward-compatible extension so one marker file can cover multiple
|
|
571
|
+
* non-agent-delivery scopes, e.g. dependabot AND release-please, without a blanket scope).
|
|
572
|
+
*
|
|
573
|
+
* @returns {{ok:false, reason:string}} on unreadable/malformed-JSON input, else
|
|
574
|
+
* {{ok:true, wellFormed:object[], malformed:{entry:*, missing:string[]}[], totalEntries:number}}
|
|
575
|
+
* — every entry is validated; malformed entries are reported individually rather than
|
|
576
|
+
* invalidating the whole file (so one bad entry cannot silently mask a good one).
|
|
577
|
+
*/
|
|
578
|
+
function parseDeclaredMarker(markerPath) {
|
|
579
|
+
let raw;
|
|
580
|
+
try {
|
|
581
|
+
raw = fs.readFileSync(markerPath, 'utf8');
|
|
582
|
+
} catch (err) {
|
|
583
|
+
return { ok: false, reason: `delivery/DECLARED marker is malformed: not valid JSON (unreadable: ${err.message})` };
|
|
584
|
+
}
|
|
585
|
+
let parsed;
|
|
586
|
+
try {
|
|
587
|
+
parsed = JSON.parse(raw);
|
|
588
|
+
} catch {
|
|
589
|
+
return { ok: false, reason: 'delivery/DECLARED marker is malformed: not valid JSON' };
|
|
590
|
+
}
|
|
591
|
+
if (Array.isArray(parsed) && parsed.length === 0) {
|
|
592
|
+
return { ok: false, reason: 'delivery/DECLARED marker is malformed: array contains zero entries' };
|
|
593
|
+
}
|
|
594
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
595
|
+
const wellFormed = [];
|
|
596
|
+
const malformed = [];
|
|
597
|
+
for (const entry of entries) {
|
|
598
|
+
const missing = DECLARED_REQUIRED_FIELDS.filter((field) => {
|
|
599
|
+
const value = entry && typeof entry === 'object' ? entry[field] : undefined;
|
|
600
|
+
return typeof value !== 'string' || value.trim() === '';
|
|
601
|
+
});
|
|
602
|
+
if (missing.length > 0) {
|
|
603
|
+
malformed.push({ entry, missing });
|
|
604
|
+
} else {
|
|
605
|
+
wellFormed.push(entry);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { ok: true, wellFormed, malformed, totalEntries: entries.length };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Resolve the scope-matching context for delivery/DECLARED evaluation:
|
|
613
|
+
* ref = TRUST_RECONCILE_REF || GITHUB_HEAD_REF || GITHUB_REF stripped of 'refs/heads/' || ''
|
|
614
|
+
* actor = TRUST_RECONCILE_ACTOR || GITHUB_ACTOR || ''
|
|
615
|
+
* sha = TRUST_RECONCILE_SHA || GITHUB_SHA || ''
|
|
616
|
+
* An empty context value never matches any scope segment (see matchesScope) — this
|
|
617
|
+
* prevents an empty-string scope segment from becoming an accidental wildcard.
|
|
618
|
+
*/
|
|
619
|
+
function resolveScopeContext(repoRoot) {
|
|
620
|
+
const githubRef = process.env.GITHUB_REF || '';
|
|
621
|
+
const strippedGithubRef = githubRef.startsWith('refs/heads/')
|
|
622
|
+
? githubRef.slice('refs/heads/'.length)
|
|
623
|
+
: githubRef;
|
|
624
|
+
const ref = process.env.TRUST_RECONCILE_REF
|
|
625
|
+
|| process.env.GITHUB_HEAD_REF
|
|
626
|
+
|| strippedGithubRef
|
|
627
|
+
|| '';
|
|
628
|
+
const actor = process.env.TRUST_RECONCILE_ACTOR || process.env.GITHUB_ACTOR || '';
|
|
629
|
+
const sha = process.env.TRUST_RECONCILE_SHA || process.env.GITHUB_SHA || '';
|
|
630
|
+
return { ref, actor, sha, repoRoot };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Resolve the CI event this run is gating (ADR 0022 addendum, part 4 — event-scoped
|
|
635
|
+
* bundle-required enforcement). `TRUST_RECONCILE_EVENT` (set by
|
|
636
|
+
* .github/workflows/trust-reconcile.yml from `github.event_name`) is the source.
|
|
637
|
+
*
|
|
638
|
+
* Conservative default: an unknown/absent event value is treated as a GATING event
|
|
639
|
+
* (enforce) — every existing local/test caller that sets no TRUST_RECONCILE_EVENT keeps
|
|
640
|
+
* today's fail-closed behavior unchanged, and a misconfigured/unrecognized CI event fails
|
|
641
|
+
* toward the STRICTER gate, never the looser post-merge one. Only the literal value
|
|
642
|
+
* 'push' is treated as a post-merge run — bundle-required enforcement (and its
|
|
643
|
+
* staleness-gate consequence) applies to every other event, including
|
|
644
|
+
* `workflow_dispatch`, which is not guaranteed to be post-merge.
|
|
645
|
+
*
|
|
646
|
+
* Residual (HIGH, documented not solved): the VALUE `github.event_name` resolves to is
|
|
647
|
+
* authoritative GitHub Actions context, but the WORKFLOW FILE that sets
|
|
648
|
+
* `TRUST_RECONCILE_EVENT: ${{ github.event_name }}` is itself PR-editable content on a
|
|
649
|
+
* `pull_request` run — a PR could hardcode `TRUST_RECONCILE_EVENT: push` in its own copy
|
|
650
|
+
* of the workflow to fake the post-merge no-op path. This is closed by required
|
|
651
|
+
* code-owner review on `.github/workflows/trust-reconcile.yml` (#225, not yet server-side
|
|
652
|
+
* enforced), the same residual class as every other self-asserted CI input in this file.
|
|
653
|
+
* Step 1 (fresh verify) is unaffected by event scoping either way — it always runs and
|
|
654
|
+
* must pass regardless of which branch this function's caller takes.
|
|
655
|
+
*/
|
|
656
|
+
function resolveEnforcementEvent() {
|
|
657
|
+
return (process.env.TRUST_RECONCILE_EVENT || '').trim() || 'pull_request';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Best-effort single-direction ancestor check: is `ancestorSha` an ancestor of (or equal
|
|
662
|
+
* to) `descendantSha`? Uses `git merge-base --is-ancestor` (a commit is its own ancestor).
|
|
663
|
+
* Never throws — underivable (git missing, shallow clone, unknown sha, etc.) → false.
|
|
664
|
+
* Shared by isShaInRange() (commit: scope ranges) and bundleAttestsThisChange() (the
|
|
665
|
+
* bundle-ownership staleness check, ADR 0022 addendum §2).
|
|
666
|
+
*/
|
|
667
|
+
function isAncestorCommit(repoRoot, ancestorSha, descendantSha) {
|
|
668
|
+
if (!ancestorSha || !descendantSha) return false;
|
|
669
|
+
try {
|
|
670
|
+
const res = spawnSync('git', ['merge-base', '--is-ancestor', ancestorSha, descendantSha], {
|
|
671
|
+
cwd: repoRoot, stdio: 'ignore',
|
|
672
|
+
});
|
|
673
|
+
return !!res && !res.error && res.status === 0;
|
|
674
|
+
} catch {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Best-effort commit-range membership check: is `sha` reachable from `to` AND is `from`
|
|
681
|
+
* an ancestor of `sha`? (a commit is its own ancestor, so sha === from or sha === to both
|
|
682
|
+
* count as in-range). Never throws — underivable → false, never a match.
|
|
683
|
+
*/
|
|
684
|
+
function isShaInRange(repoRoot, sha, from, to) {
|
|
685
|
+
if (!sha || !from || !to) return false;
|
|
686
|
+
return isAncestorCommit(repoRoot, from, sha) && isAncestorCommit(repoRoot, sha, to);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Match a SINGLE scope condition (one of the four forms) against the resolved context.
|
|
691
|
+
* Extracted from matchesScope() so a compound (space-separated, AND) scope can validate
|
|
692
|
+
* each condition independently and still fail closed if any one is unrecognized.
|
|
693
|
+
* String equality/prefix ops ONLY — zero RegExp construction from marker content (AC4).
|
|
694
|
+
* Supported forms: `ref:<exact>`, `commit:<sha>` or `commit:<from>..<to>` (range, via
|
|
695
|
+
* isShaInRange), `author:<exact>`, `branch-prefix:<prefix>` (via String#startsWith).
|
|
696
|
+
* An unrecognized prefix does not match (not an error — fails closed silently). Every
|
|
697
|
+
* arm requires the COMPARED CONTEXT value to be non-empty, so an empty ref/actor/sha
|
|
698
|
+
* (e.g. running outside CI with no override) can never be treated as a wildcard match.
|
|
699
|
+
*/
|
|
700
|
+
function matchesScopeCondition(condition, ctx) {
|
|
701
|
+
const s = typeof condition === 'string' ? condition : '';
|
|
702
|
+
|
|
703
|
+
if (s.startsWith('ref:')) {
|
|
704
|
+
const want = s.slice('ref:'.length);
|
|
705
|
+
return !!want && !!ctx.ref && ctx.ref === want;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (s.startsWith('commit:')) {
|
|
709
|
+
const want = s.slice('commit:'.length);
|
|
710
|
+
if (!want || !ctx.sha) return false;
|
|
711
|
+
const rangeSep = want.indexOf('..');
|
|
712
|
+
if (rangeSep === -1) {
|
|
713
|
+
return ctx.sha === want;
|
|
714
|
+
}
|
|
715
|
+
const from = want.slice(0, rangeSep);
|
|
716
|
+
const to = want.slice(rangeSep + 2);
|
|
717
|
+
if (!from || !to) return false;
|
|
718
|
+
return isShaInRange(ctx.repoRoot, ctx.sha, from, to);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (s.startsWith('author:')) {
|
|
722
|
+
const want = s.slice('author:'.length);
|
|
723
|
+
return !!want && !!ctx.actor && ctx.actor === want;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (s.startsWith('branch-prefix:')) {
|
|
727
|
+
const want = s.slice('branch-prefix:'.length);
|
|
728
|
+
return !!want && !!ctx.ref && ctx.ref.startsWith(want);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Unrecognized prefix (or empty condition) — does not match, not an error.
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Match a delivery/DECLARED marker's `scope` string against the resolved context.
|
|
737
|
+
*
|
|
738
|
+
* `scope` is either a single condition (backward compatible, unchanged behavior) OR
|
|
739
|
+
* multiple space-separated conditions that must ALL match (AND) — e.g.
|
|
740
|
+
* `"author:github-actions[bot] branch-prefix:release-please--"` binds an identity claim
|
|
741
|
+
* (`author:`, bound to the platform-set `GITHUB_ACTOR`, not attacker-controlled) to a
|
|
742
|
+
* narrower blast radius (`branch-prefix:`) rather than trusting either alone. This
|
|
743
|
+
* closes a security-review finding: `ref:`/`branch-prefix:` ALONE match against
|
|
744
|
+
* `GITHUB_HEAD_REF`, which is PUSHER-CONTROLLED on a fork PR — a `ref:`-only or
|
|
745
|
+
* `branch-prefix:`-only scope can therefore be satisfied by anyone who can name a
|
|
746
|
+
* branch, not just the bot the marker is meant to identify. Compound (AND) scopes let a
|
|
747
|
+
* marker require both signals. Any unrecognized-prefix condition anywhere in a compound
|
|
748
|
+
* scope makes the WHOLE scope never match (fail closed) — see matchesScopeCondition().
|
|
749
|
+
* Zero RegExp construction from marker content (AC4) — space-splitting uses a plain
|
|
750
|
+
* string split, not a marker-derived pattern.
|
|
751
|
+
*/
|
|
752
|
+
function matchesScope(scope, ctx) {
|
|
753
|
+
const s = typeof scope === 'string' ? scope : '';
|
|
754
|
+
const conditions = s.split(' ').map((c) => c.trim()).filter(Boolean);
|
|
755
|
+
if (conditions.length === 0) return false;
|
|
756
|
+
return conditions.every((condition) => matchesScopeCondition(condition, ctx));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Orchestrate delivery/DECLARED resolution for the bundle-absent path: discover → parse
|
|
761
|
+
* → scope-match → return {exempt, marker?, diagnostic?}. First in-scope, well-formed
|
|
762
|
+
* entry wins (array form); every entry is validated regardless, and malformed entries
|
|
763
|
+
* are logged individually so a bad entry never silently masks a good one.
|
|
764
|
+
*
|
|
765
|
+
* Diagnostic strings below are pinned (grepped by callers/evals) — do not reword.
|
|
766
|
+
*/
|
|
767
|
+
function resolveDeclaredExemption(repoRoot, ctx) {
|
|
768
|
+
const markerPath = discoverDeclaredMarker(repoRoot);
|
|
769
|
+
if (!markerPath) {
|
|
770
|
+
return {
|
|
771
|
+
exempt: false,
|
|
772
|
+
diagnostic: 'no bundle present and no delivery/DECLARED marker found — bundle-required by default (ADR 0022 §1); publish delivery/trust.bundle, or add a well-formed, in-scope delivery/DECLARED marker',
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const parsed = parseDeclaredMarker(markerPath);
|
|
777
|
+
if (!parsed.ok) {
|
|
778
|
+
return { exempt: false, diagnostic: parsed.reason };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
for (const { missing } of parsed.malformed) {
|
|
782
|
+
process.stderr.write(`[trust-reconcile] delivery/DECLARED marker is malformed: missing required field(s) [${missing.join(', ')}]\n`);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (parsed.wellFormed.length === 0) {
|
|
786
|
+
const first = parsed.malformed[0];
|
|
787
|
+
const diagnostic = first
|
|
788
|
+
? `delivery/DECLARED marker is malformed: missing required field(s) [${first.missing.join(', ')}]`
|
|
789
|
+
: 'delivery/DECLARED marker is malformed: not valid JSON';
|
|
790
|
+
return { exempt: false, diagnostic };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (const marker of parsed.wellFormed) {
|
|
794
|
+
if (matchesScope(marker.scope, ctx)) {
|
|
795
|
+
return { exempt: true, marker };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
exempt: false,
|
|
801
|
+
diagnostic: 'delivery/DECLARED marker present but out of scope for this change',
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Extract the strongest available commit-identity binding from a discovered bundle/
|
|
807
|
+
* checkpoint, for the bundle-ownership staleness check (ADR 0022 addendum §2). Returns
|
|
808
|
+
* the commit sha string the bundle/checkpoint attests to, or null if no usable binding
|
|
809
|
+
* exists (fail-closed: the caller treats null the same as a mismatch — stale/absent).
|
|
810
|
+
*
|
|
811
|
+
* trust.checkpoint.json's envelope carries `commit_sha` directly (stamped by
|
|
812
|
+
* sealTrustCheckpoint() from `git rev-parse HEAD` at seal time —
|
|
813
|
+
* src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
|
|
814
|
+
* (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
|
|
815
|
+
* by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
|
|
816
|
+
* sibling delivery/trust.checkpoint.json (same resolveDeliveryCandidates() seam) for the
|
|
817
|
+
* binding — publishDelivery() always copies both together.
|
|
818
|
+
*/
|
|
819
|
+
function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
|
|
820
|
+
if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
|
|
821
|
+
return bundleJson.commit_sha.trim();
|
|
822
|
+
}
|
|
823
|
+
for (const candidate of resolveDeliveryCandidates(repoRoot, 'trust.checkpoint.json')) {
|
|
824
|
+
if (candidate === bundlePath || !fs.existsSync(candidate)) continue;
|
|
825
|
+
try {
|
|
826
|
+
const checkpoint = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
827
|
+
if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
|
|
828
|
+
return checkpoint.commit_sha.trim();
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// Malformed sibling checkpoint — no usable binding from it; keep looking / fall through.
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Does the discovered bundle/checkpoint attest THIS change? "Bundle-required" (ADR 0022
|
|
839
|
+
* §1) means a bundle FOR THIS CHANGE, not any bundle merely reachable at the checkout — a
|
|
840
|
+
* bundle inherited unchanged from main via update-branch/rebase (live incident: PR #278, a
|
|
841
|
+
* dependabot PR whose stale main-authored trust.checkpoint.json otherwise satisfied Step 2
|
|
842
|
+
* trivially) must NOT be treated as owned by this change.
|
|
843
|
+
*
|
|
844
|
+
* "Attests this change" = the bundle's commit_sha equals this change's own sha, OR is an
|
|
845
|
+
* ancestor of it (the bundle was sealed earlier in the SAME open PR's own linear commit
|
|
846
|
+
* history, before a later delivery commit — the normal, legitimate shape; sealTrustCheckpoint
|
|
847
|
+
* necessarily stamps a commit that precedes its own delivery commit, so exact equality alone
|
|
848
|
+
* would reject every legitimate delivery). Reuses the same `git merge-base --is-ancestor`
|
|
849
|
+
* primitive `commit:` scope ranges already use (isAncestorCommit()) rather than a new check.
|
|
850
|
+
*
|
|
851
|
+
* FAIL CLOSED on ambiguity: no extractable commit_sha (bundle/checkpoint carries none, or
|
|
852
|
+
* this change's own sha is unresolvable) → never treated as fresh/owned.
|
|
853
|
+
*
|
|
854
|
+
* CORRECTNESS REQUIRES the caller's CI context to provide two things (see
|
|
855
|
+
* .github/workflows/trust-reconcile.yml's "CI-context contract" comment for the concrete
|
|
856
|
+
* settings this repo's own required check uses):
|
|
857
|
+
* 1. A full-history (non-shallow) checkout. `git merge-base --is-ancestor` needs the
|
|
858
|
+
* parent commit objects; a shallow clone (the common `fetch-depth: 1` default) does
|
|
859
|
+
* not have them, so the underlying `git merge-base` call exits 128 (unresolvable).
|
|
860
|
+
* 2. `changeSha` bound to the actual commit a checkpoint could plausibly have been
|
|
861
|
+
* sealed against — on a GitHub `pull_request` trigger this is the PR's own HEAD
|
|
862
|
+
* commit, NOT the synthetic merge commit `GITHUB_SHA` resolves to by default (a
|
|
863
|
+
* seal-checkpoint run never stamps a merge commit that did not exist yet).
|
|
864
|
+
* Shallow-clone / missing-object conditions are DEGRADED-BUT-SAFE, never silently
|
|
865
|
+
* accepted: isAncestorCommit() (and therefore this function) fails toward STALE, the same
|
|
866
|
+
* as any other unresolvable ancestry, so a real fresh bundle under a misconfigured shallow
|
|
867
|
+
* checkout is loudly, diagnosably rejected (the `stale bundle ignored — attests <theirs>,
|
|
868
|
+
* this change is <ours>` line still names both shas — an operator can immediately see the
|
|
869
|
+
* mismatch is a checkout-depth artifact, not a real staleness), rather than a bundle CI
|
|
870
|
+
* cannot actually verify slipping through as accepted.
|
|
871
|
+
*
|
|
872
|
+
* @returns {{fresh: boolean, bundleSha: string|null}}
|
|
873
|
+
*/
|
|
874
|
+
function bundleAttestsThisChange(repoRoot, bundlePath, bundleJson, changeSha) {
|
|
875
|
+
const bundleSha = extractBundleCommitSha(repoRoot, bundlePath, bundleJson);
|
|
876
|
+
if (!bundleSha || !changeSha) return { fresh: false, bundleSha };
|
|
877
|
+
const fresh = bundleSha === changeSha || isAncestorCommit(repoRoot, bundleSha, changeSha);
|
|
878
|
+
return { fresh, bundleSha };
|
|
879
|
+
}
|
|
880
|
+
|
|
261
881
|
// ---------------------------------------------------------------------------
|
|
262
882
|
// Core reconcile function (exported for programmatic use)
|
|
263
883
|
// ---------------------------------------------------------------------------
|
|
@@ -275,16 +895,62 @@ function discoverBundle(repoRoot) {
|
|
|
275
895
|
* @param {string|null} [opts.repoRoot] - Repo root path. null = TRUST_RECONCILE_REPO_ROOT env or cwd.
|
|
276
896
|
* @returns {number} Exit code: 0 = pass, 1 = fail/divergence.
|
|
277
897
|
*/
|
|
278
|
-
function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {}) {
|
|
898
|
+
function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, manifest = null } = {}) {
|
|
279
899
|
const resolvedRepoRoot = path.resolve(
|
|
280
900
|
repoRoot || process.env.TRUST_RECONCILE_REPO_ROOT || process.cwd()
|
|
281
901
|
);
|
|
282
902
|
|
|
283
|
-
// Resolve bundle path: explicit arg > env > auto-discovery > null
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
903
|
+
// Resolve bundle path: explicit arg > env > auto-discovery > null. The staleness check
|
|
904
|
+
// below (ADR 0022 addendum §2) applies ONLY to the auto-discovered case — an explicit
|
|
905
|
+
// --bundle/TRUST_RECONCILE_BUNDLE is a deliberate caller choice (tests, programmatic
|
|
906
|
+
// callers), not something silently picked up from committed git tree state, and real CI
|
|
907
|
+
// (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
|
|
908
|
+
// auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
|
|
909
|
+
const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
|
|
910
|
+
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
|
|
911
|
+
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
912
|
+
|
|
913
|
+
// Scope-matching context (ref/actor/sha) — resolved once, reused by both the
|
|
914
|
+
// bundle-ownership staleness check below and the delivery/DECLARED exemption path
|
|
915
|
+
// further down (bundle-absent branch).
|
|
916
|
+
const scopeCtx = resolveScopeContext(resolvedRepoRoot);
|
|
917
|
+
|
|
918
|
+
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
919
|
+
// (and the staleness-gate consequence of it, immediately below) applies ONLY when this
|
|
920
|
+
// run is gating a proposed change. A push run on main happens AFTER the gating PR run
|
|
921
|
+
// already passed — see the isPostMergeEvent branch further down for the full rationale.
|
|
922
|
+
const enforcementEvent = resolveEnforcementEvent();
|
|
923
|
+
const isPostMergeEvent = enforcementEvent === 'push';
|
|
924
|
+
|
|
925
|
+
// Bundle-ownership staleness check (ADR 0022 addendum §2): an AUTO-DISCOVERED bundle
|
|
926
|
+
// must attest THIS change, not a historically-committed bundle inherited via
|
|
927
|
+
// update-branch/rebase (live incident: PR #278). A stale bundle is treated as ABSENT —
|
|
928
|
+
// on a gating event it falls through to the exact same DECLARED/fail-closed branch as
|
|
929
|
+
// no-bundle-at-all; on a push event it falls through to a loud no-op instead (below).
|
|
930
|
+
// A bundle that fails to parse here is NOT treated as stale (left alone so the existing
|
|
931
|
+
// Step 2 "failed to read bundle" diagnostic still fires with its own clearer message).
|
|
932
|
+
// bundleWasDiscardedAsStale is tracked only to word the push-event no-op line accurately
|
|
933
|
+
// (an auto-discovered-but-stale bundle vs. no bundle ever being found are both a no-op
|
|
934
|
+
// on push, but "inherited bundle does not attest" is only true of the former).
|
|
935
|
+
let bundleWasDiscardedAsStale = false;
|
|
936
|
+
if (bundlePath && bundleWasAutoDiscovered) {
|
|
937
|
+
let bundleJsonForStaleness = null;
|
|
938
|
+
try {
|
|
939
|
+
bundleJsonForStaleness = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
|
|
940
|
+
} catch {
|
|
941
|
+
bundleJsonForStaleness = null;
|
|
942
|
+
}
|
|
943
|
+
if (bundleJsonForStaleness) {
|
|
944
|
+
const { fresh, bundleSha } = bundleAttestsThisChange(resolvedRepoRoot, bundlePath, bundleJsonForStaleness, scopeCtx.sha);
|
|
945
|
+
if (!fresh) {
|
|
946
|
+
const theirs = bundleSha || 'no usable commit binding';
|
|
947
|
+
const ours = scopeCtx.sha || 'unknown (no TRUST_RECONCILE_SHA/GITHUB_SHA)';
|
|
948
|
+
process.stdout.write(`[trust-reconcile] stale bundle ignored — attests ${theirs}, this change is ${ours}\n`);
|
|
949
|
+
bundlePath = null;
|
|
950
|
+
bundleWasDiscardedAsStale = true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
288
954
|
|
|
289
955
|
const canonicalCommands = resolveCanonicalCommands({ commands: commands || [] }, resolvedRepoRoot);
|
|
290
956
|
|
|
@@ -299,13 +965,28 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
|
|
|
299
965
|
return 1;
|
|
300
966
|
}
|
|
301
967
|
|
|
968
|
+
// WS8 (ADR 0020): resolve the reconcile manifest (named {id, command} entries used
|
|
969
|
+
// for per-command classification-aware reconcile). The canonical (fresh-verify)
|
|
970
|
+
// commands above remain the standalone CI-truth run; the manifest is the set of
|
|
971
|
+
// required-lane commands a test_output claim is permitted to reconcile against.
|
|
972
|
+
const manifestResolution = resolveManifest({ manifest }, resolvedRepoRoot, canonicalCommands);
|
|
973
|
+
const manifestEntries = manifestResolution.entries;
|
|
974
|
+
const manifestByCmd = new Map();
|
|
975
|
+
for (const e of manifestEntries) manifestByCmd.set(normalizeCmd(e.command), e);
|
|
976
|
+
|
|
302
977
|
process.stdout.write('[trust-reconcile] starting CI trust anchor reconcile\n');
|
|
303
978
|
process.stdout.write(`[trust-reconcile] repo-root: ${resolvedRepoRoot}\n`);
|
|
304
979
|
process.stdout.write(`[trust-reconcile] canonical commands: ${canonicalCommands.join(' | ')}\n`);
|
|
980
|
+
process.stdout.write(`[trust-reconcile] manifest: ${manifestEntries.length} entr${manifestEntries.length === 1 ? 'y' : 'ies'} (source: ${manifestResolution.source})\n`);
|
|
981
|
+
for (const e of manifestEntries) {
|
|
982
|
+
process.stdout.write(`[trust-reconcile] manifest[${e.id}] = ${e.command}\n`);
|
|
983
|
+
}
|
|
305
984
|
if (bundlePath) {
|
|
306
985
|
process.stdout.write(`[trust-reconcile] bundle: ${bundlePath}\n`);
|
|
986
|
+
} else if (isPostMergeEvent) {
|
|
987
|
+
process.stdout.write('[trust-reconcile] no bundle present — push event (post-merge run): Step 1 still runs; Step 2/delivery-DECLARED enforcement does not apply here (ADR 0022 addendum, part 4)\n');
|
|
307
988
|
} else {
|
|
308
|
-
process.stdout.write('[trust-reconcile] no bundle present —
|
|
989
|
+
process.stdout.write('[trust-reconcile] no bundle present — bundle-required by default (ADR 0022 §1); Step 1 still runs, then checking delivery/DECLARED for a Step-2 exemption\n');
|
|
309
990
|
}
|
|
310
991
|
|
|
311
992
|
// -------------------------------------------------------------------------
|
|
@@ -337,6 +1018,8 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
|
|
|
337
1018
|
|
|
338
1019
|
if (result.passed) {
|
|
339
1020
|
process.stdout.write(`[trust-reconcile] PASS: ${cmd}\n`);
|
|
1021
|
+
} else if (result.timedOut) {
|
|
1022
|
+
process.stderr.write(`[trust-reconcile] FAIL: ${cmd} (TIMED OUT after ${result.timeoutMs}ms — this is a timeout kill, not a real command failure; if this command is a legitimately slow check, raise TRUST_RECONCILE_COMMAND_TIMEOUT_MS)\n`);
|
|
340
1023
|
} else {
|
|
341
1024
|
process.stderr.write(`[trust-reconcile] FAIL: ${cmd} (exit ${result.exitCode})\n`);
|
|
342
1025
|
if (result.stderr) {
|
|
@@ -407,25 +1090,45 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
|
|
|
407
1090
|
}
|
|
408
1091
|
}
|
|
409
1092
|
} else {
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
1093
|
+
// WS8 (ADR 0020): classify the bundle's claims into reconcilable (test_output,
|
|
1094
|
+
// manifest-matched), session-local (waiver/Surface-status), and never-captured
|
|
1095
|
+
// command claims — instead of forcing every claim to match one composite command.
|
|
1096
|
+
const { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand } = classifyBundleClaims(bundle);
|
|
1097
|
+
process.stdout.write(`[trust-reconcile] classified bundle: ${reconcilable.length} reconcilable command claim(s), ${sessionLocal.length} session-local claim(s), ${noEvidenceCommand.length} unbacked/unreconciled command claim(s), ${waiverOnCommand.length} waiver-on-command claim(s)\n`);
|
|
413
1098
|
|
|
414
|
-
|
|
415
|
-
|
|
1099
|
+
// finding 3: re-derive every claim's status CI-side (never trust self-reported
|
|
1100
|
+
// claim.status). Consumed by the session-local loop below to catch status-misassertion.
|
|
1101
|
+
const derivedStatus = deriveClaimStatuses(bundlePath, resolvedRepoRoot);
|
|
1102
|
+
if (derivedStatus) {
|
|
1103
|
+
process.stdout.write(`[trust-reconcile] re-derived ${derivedStatus.size} claim status(es) CI-side from the bundle's own evidence/events/policies (self-reported claim.status is NOT trusted)\n`);
|
|
1104
|
+
} else {
|
|
1105
|
+
process.stderr.write(`[trust-reconcile] WARNING: CI-side status re-derivation is unavailable — every session-local pass-asserting claim will fail closed (cannot verify self-reported status)\n`);
|
|
1106
|
+
}
|
|
416
1107
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
1108
|
+
// finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
|
|
1109
|
+
// waiver is a divergence — a command-backed check reconciles against CI or fails; it
|
|
1110
|
+
// cannot be waived away.
|
|
1111
|
+
for (const { claimId, claimType, subject } of waiverOnCommand) {
|
|
1112
|
+
issues.push({
|
|
1113
|
+
type: 'waiver-on-command-check',
|
|
1114
|
+
message: `trust divergence: claim '${claimId}' (${subject}, claimType: ${claimType}) carries a waiver but is backed by test_output evidence — a command-backed check reconciles against CI or fails and cannot be waived`,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
427
1117
|
|
|
428
|
-
|
|
1118
|
+
// not-run divergences: never-captured command claims (no evidence) AND test_output
|
|
1119
|
+
// claims that did not reconcile (no manifest-matchable execution.label).
|
|
1120
|
+
for (const { cmd, claimId, claimType, reason } of noEvidenceCommand) {
|
|
1121
|
+
const message = reason === 'test_output-unreconciled'
|
|
1122
|
+
? `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass with test_output evidence but has no manifest-matched execution.label — a test_output claim must reconcile against the manifest or it is a divergence (never accepted as session-local)`
|
|
1123
|
+
: `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
|
|
1124
|
+
issues.push({ type: 'not-run', cmd, message });
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Reconcilable test_output claims: laundering → manifest match → CI fresh result.
|
|
1128
|
+
for (const { cmd } of reconcilable) {
|
|
1129
|
+
const normalCmd = normalizeCmd(cmd);
|
|
1130
|
+
|
|
1131
|
+
// (a) Laundering operator check — must come first (most specific signal).
|
|
429
1132
|
if (hasLaunderingOperator(cmd)) {
|
|
430
1133
|
issues.push({
|
|
431
1134
|
type: 'laundering',
|
|
@@ -435,21 +1138,41 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
|
|
|
435
1138
|
continue;
|
|
436
1139
|
}
|
|
437
1140
|
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (!
|
|
442
|
-
// (c) CI never ran this claimed-pass command — fail-closed
|
|
1141
|
+
// A test_output claim MUST name a manifest (required-lane) command. An agent
|
|
1142
|
+
// cannot self-label an arbitrary command test_output to dodge the manifest.
|
|
1143
|
+
const entry = manifestByCmd.get(normalCmd);
|
|
1144
|
+
if (!entry) {
|
|
443
1145
|
issues.push({
|
|
444
1146
|
type: 'not-run',
|
|
445
1147
|
cmd,
|
|
446
|
-
message: `trust divergence: agent claimed '${cmd}' passed;
|
|
1148
|
+
message: `trust divergence: agent claimed '${cmd}' passed; command is not in the reconcile manifest — a test_output claim must name a manifest/required-lane command (CI cannot self-declare an arbitrary command)`,
|
|
447
1149
|
});
|
|
448
1150
|
continue;
|
|
449
1151
|
}
|
|
450
1152
|
|
|
1153
|
+
// Prefer the fresh-verify Step 1 result; otherwise re-run this (manifest, and
|
|
1154
|
+
// therefore required-lane) command fresh now. Only manifest commands are ever
|
|
1155
|
+
// run on demand, so the set is bounded by the registry.
|
|
1156
|
+
let ciResult = ciResults.get(normalCmd);
|
|
1157
|
+
if (!ciResult) {
|
|
1158
|
+
process.stdout.write(`[trust-reconcile] manifest reconcile: running '${entry.command}' (id: ${entry.id})\n`);
|
|
1159
|
+
const r = runCommand(entry.command, resolvedRepoRoot);
|
|
1160
|
+
ciResult = { exitCode: r.exitCode, passed: r.passed };
|
|
1161
|
+
ciResults.set(normalCmd, ciResult);
|
|
1162
|
+
if (r.passed) {
|
|
1163
|
+
process.stdout.write(`[trust-reconcile] PASS: ${entry.command}\n`);
|
|
1164
|
+
} else if (r.timedOut) {
|
|
1165
|
+
process.stderr.write(`[trust-reconcile] FAIL: ${entry.command} (TIMED OUT after ${r.timeoutMs}ms — this is a timeout kill, not a real command failure; if this manifest command is a legitimately slow check, raise TRUST_RECONCILE_COMMAND_TIMEOUT_MS)\n`);
|
|
1166
|
+
} else {
|
|
1167
|
+
process.stderr.write(`[trust-reconcile] FAIL: ${entry.command} (exit ${r.exitCode})\n`);
|
|
1168
|
+
if (r.stderr) {
|
|
1169
|
+
const tail = r.stderr.trim().split('\n').slice(-5).join('\n');
|
|
1170
|
+
process.stderr.write(`[trust-reconcile] --- stderr tail ---\n${tail}\n[trust-reconcile] ---\n`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
451
1175
|
if (!ciResult.passed) {
|
|
452
|
-
// (b) Claimed pass + CI fresh FAIL = divergence
|
|
453
1176
|
issues.push({
|
|
454
1177
|
type: 'divergence',
|
|
455
1178
|
cmd,
|
|
@@ -459,9 +1182,139 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
|
|
|
459
1182
|
continue;
|
|
460
1183
|
}
|
|
461
1184
|
|
|
462
|
-
|
|
463
|
-
|
|
1185
|
+
process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' (manifest id: ${entry.id}) — claimed pass, CI fresh run = PASS\n`);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
|
|
1189
|
+
// (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
|
|
1190
|
+
// status. WS8 iteration-2 hardening:
|
|
1191
|
+
// - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
|
|
1192
|
+
// claim.status. A mismatch is a `status-misassertion` divergence. If re-derivation
|
|
1193
|
+
// is unavailable we fail closed (`status-underivable`) — we never fall back to
|
|
1194
|
+
// trusting the bundle's own status.
|
|
1195
|
+
// - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
|
|
1196
|
+
// ONLY with a waiver (printed as a loud WAIVED line). An unwaived `assumed` claim is
|
|
1197
|
+
// an `unwaived-assumed` divergence (restores pre-WS8 semantics where `assumed` alone
|
|
1198
|
+
// never satisfied assertsPass).
|
|
1199
|
+
//
|
|
1200
|
+
// WS8 iteration-4 (converged iteration-3 finding, both gates): a session-local claim
|
|
1201
|
+
// that re-derives `verified` with NO waiver is, by definition, backed only by
|
|
1202
|
+
// human_attestation/attestation/external/etc. evidence with no --command — i.e.
|
|
1203
|
+
// `deriveClaimStatus` only proves the bundle is internally self-consistent, never that
|
|
1204
|
+
// the underlying attestation is true. A fully fabricated bundle (fake claim + fake
|
|
1205
|
+
// evidence + fake event, all hand-authored to agree) is INDISTINGUISHABLE from a real
|
|
1206
|
+
// one at this layer. This was previously printed as a quiet `SESSION-LOCAL OK` line
|
|
1207
|
+
// indistinguishable from a genuinely reconciled check. It is now always printed as a
|
|
1208
|
+
// loud, distinct `ATTESTED (not independently verifiable at L0)` marker — see ADR 0020
|
|
1209
|
+
// Residuals. This does NOT change the exit code (attestations are not blocked at L0;
|
|
1210
|
+
// blocking every honest human-attestation use is not the fix) — it is a visibility-only
|
|
1211
|
+
// change so a reviewer/downstream tool can grep for and count exactly how many claims in
|
|
1212
|
+
// a passing bundle rest on bundle-internal consistency alone rather than independent
|
|
1213
|
+
// (CI fresh-run or cryptographically-signed) verification.
|
|
1214
|
+
let attestedCount = 0;
|
|
1215
|
+
for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal) {
|
|
1216
|
+
// finding 3: re-derive; never trust the asserted status.
|
|
1217
|
+
let status;
|
|
1218
|
+
if (!derivedStatus) {
|
|
1219
|
+
issues.push({
|
|
1220
|
+
type: 'status-underivable',
|
|
1221
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI-side re-derivation is unavailable — refusing to trust a self-reported status (fail-closed)`,
|
|
1222
|
+
});
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const derived = derivedStatus.get(claimId);
|
|
1226
|
+
if (derived === undefined || derived === null) {
|
|
1227
|
+
issues.push({
|
|
1228
|
+
type: 'status-underivable',
|
|
1229
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) could not be re-derived CI-side from the bundle's own evidence/events/policies — refusing to trust its self-reported status '${assertedStatus || 'unknown'}' (fail-closed)`,
|
|
1230
|
+
});
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (derived !== assertedStatus) {
|
|
1234
|
+
issues.push({
|
|
1235
|
+
type: 'status-misassertion',
|
|
1236
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI re-derivation from the bundle's own evidence/events/policies yields '${derived}' — the reconciler does not trust self-reported claim.status`,
|
|
1237
|
+
});
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
status = derived;
|
|
1241
|
+
|
|
1242
|
+
if (status === 'disputed' || status === 'rejected') {
|
|
1243
|
+
issues.push({
|
|
1244
|
+
type: 'session-local-failed',
|
|
1245
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status '${status}' — a failing/rejected claim blocks (session-local classification is not a pass bypass)`,
|
|
1246
|
+
});
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
// finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
|
|
1250
|
+
// session-local claim passes, and it is printed loudly. `verified` still passes on its
|
|
1251
|
+
// own re-derived status.
|
|
1252
|
+
if (waiver && waiver.reason && waiver.approved_by) {
|
|
1253
|
+
process.stdout.write(`[trust-reconcile] WAIVED: ${subject} (${claimType}) status=${status} — ${waiver.reason} (approved by ${waiver.approved_by})\n`);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (status === 'verified') {
|
|
1257
|
+
attestedCount++;
|
|
1258
|
+
process.stdout.write(`[trust-reconcile] ATTESTED (not independently verifiable at L0): '${claimId}' (${claimType}) evidenceType=${evidenceType} — accepted on bundle-internal consistency only; see ADR 0020 Residuals\n`);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
if (status === 'assumed') {
|
|
1262
|
+
issues.push({
|
|
1263
|
+
type: 'unwaived-assumed',
|
|
1264
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status 'assumed' but carries no waiver — 'assumed' alone is not a pass; it requires a documented waiver (--accepted-gap-reason/--waived-by) to be accepted`,
|
|
1265
|
+
});
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
issues.push({
|
|
1269
|
+
type: 'unwaived-session-local',
|
|
1270
|
+
message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts pass with re-derived status '${status || 'unknown'}' but has no waiver and no CI-re-derived verified status`,
|
|
1271
|
+
});
|
|
464
1272
|
}
|
|
1273
|
+
|
|
1274
|
+
// WS8 iteration-4: always emit the summary count, even when zero, so a passing bundle
|
|
1275
|
+
// with zero attested claims is distinguishable in the log from one where the count line
|
|
1276
|
+
// is simply absent (grep-stable for downstream tooling / reviewers).
|
|
1277
|
+
process.stdout.write(`[trust-reconcile] ${attestedCount} attested claim(s) accepted without independent verification\n`);
|
|
1278
|
+
}
|
|
1279
|
+
} else if (isPostMergeEvent) {
|
|
1280
|
+
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
1281
|
+
// (and delivery/DECLARED as its only exemption) exists to gate a PROPOSED change —
|
|
1282
|
+
// it applies on pull_request (and any other/unknown event, conservatively). A push
|
|
1283
|
+
// run on `main` happens AFTER the gating PR run already passed: branch protection
|
|
1284
|
+
// already excludes direct pushes from anyone but the merge machinery, so this run's
|
|
1285
|
+
// job is Step 1 fresh-verify (unconditional, already ran above) plus, IF the
|
|
1286
|
+
// just-merged bundle happens to still attest this exact commit, Step 2 reconcile as
|
|
1287
|
+
// the basis for Phase 2 attestation minting (handled by the `if (bundlePath)` branch
|
|
1288
|
+
// above when fresh — unaffected by this branch). A squash-merge commit on main almost
|
|
1289
|
+
// never has git ancestry back to the feature-branch commit a checkpoint was sealed
|
|
1290
|
+
// against (squash discards the original commit graph — see the staleness-check
|
|
1291
|
+
// addendum), so bundlePath being null here on push is the EXPECTED, common case, not
|
|
1292
|
+
// a divergence — reproduced empirically (synthetic squash pair) as a HIGH
|
|
1293
|
+
// launch-blocker: without this branch, EVERY delivery would falsely fail Trust
|
|
1294
|
+
// Reconcile on main immediately post-merge and Phase 2 attestation minting would stop
|
|
1295
|
+
// entirely. Requiring a delivery/DECLARED marker on every push to main would also be
|
|
1296
|
+
// nonsensical — there is no "change" being gated here to declare an exemption from.
|
|
1297
|
+
// This is a loud no-op: exit 0, no issue pushed.
|
|
1298
|
+
const detail = bundleWasDiscardedAsStale
|
|
1299
|
+
? 'inherited bundle does not attest this commit'
|
|
1300
|
+
: 'no bundle to reconcile';
|
|
1301
|
+
process.stdout.write(`[trust-reconcile] push event: ${detail} — skipping Step 2 (gating happened on the PR run)\n`);
|
|
1302
|
+
} else {
|
|
1303
|
+
// Bundle absent (ADR 0022 §1) — either never discovered, or discovered-but-stale and
|
|
1304
|
+
// nulled out above. The ONLY exemption is a well-formed, in-scope delivery/DECLARED
|
|
1305
|
+
// marker, and it exempts Step 2 ONLY — Step 1 (fresh verify, above) already ran
|
|
1306
|
+
// unconditionally and was never skipped. Reuses the scopeCtx resolved above (same
|
|
1307
|
+
// context the staleness check already used).
|
|
1308
|
+
const exemption = resolveDeclaredExemption(resolvedRepoRoot, scopeCtx);
|
|
1309
|
+
if (exemption.exempt) {
|
|
1310
|
+
const { scope, reason, approved_by, declared_at } = exemption.marker;
|
|
1311
|
+
process.stdout.write(`[trust-reconcile] DECLARED (no-agent-delivery): ${scope} — ${reason} (approved by ${approved_by}, declared ${declared_at})\n`);
|
|
1312
|
+
} else {
|
|
1313
|
+
process.stderr.write(`[trust-reconcile] ${exemption.diagnostic}\n`);
|
|
1314
|
+
issues.push({
|
|
1315
|
+
type: 'bundle-required-no-declared-marker',
|
|
1316
|
+
message: exemption.diagnostic,
|
|
1317
|
+
});
|
|
465
1318
|
}
|
|
466
1319
|
}
|
|
467
1320
|
|
|
@@ -517,6 +1370,7 @@ function main() {
|
|
|
517
1370
|
bundle: args.bundle || null,
|
|
518
1371
|
commands: args.commands,
|
|
519
1372
|
repoRoot: args.repoRoot || null,
|
|
1373
|
+
manifest: args.manifest || null,
|
|
520
1374
|
}));
|
|
521
1375
|
}
|
|
522
1376
|
|