ADR-0015: Trust boundaries and merge-time integrity¶
| Status | Accepted (partial: merge-result / max_age arming / typed providers per ADR-0017 §1/§4/§6; P2-E5) |
| Date | 2026-07-21 |
| Deciders | Konrad Heimel |
| Context links | ADR-0007 effects · ADR-0008 routing · ADR-0009 modes · ADR-0010 config · ADR-0011 ports · adversarial design review 2026-07-21 (findings F1, F3, F4, F5, F13, F14) |
Context¶
An adversarial review of the design found that the trust model was implicit — and, as written, broken: policies were loaded from the very branch they gate (F1), the merge action was not pinned to the evaluated commit (F3), facts could go stale between evaluation and a deferred merge (F4), the CI job definition itself is author-editable in self-service repos (F5), and the webhook mode deferred its security requirements while claiming architecture-readiness (F13). This ADR fixes the trust boundary explicitly. Principle: everything that decides is loaded from a trusted ref; everything that is judged comes from the untrusted branch; every write action re-verifies what it is about to act on.
Decision (proposed)¶
1. Policy is trusted input → loaded from the target ref, never the MR branch¶
.assent/** (config, bindings, packs, referenced Rego, templates, tests' expectations) is
always loaded from the target/base ref of the MR. The source branch contributes only the
material under judgment: the diff and the branch state for scope: branch rules.
Any MR that touches .assent/** routes to the implicit meta-class assent-policy,
which is block-by-default: policy changes always require human review and can never be
vouched by the policies they modify. Repos may relax this only to challenge, never to
vouch. A mandatory golden e2e case: "MR edits its own policy → BLOCK."
(Recommended hardening: forge-level CODEOWNERS/approval rule on .assent/** requiring a
human — see §5.)
2. Merge is SHA-guarded (no TOCTOU)¶
Publisher.Merge and Publisher.Approve carry the evaluated head SHA from Decision.Pins
and use the forge's compare-and-swap: GitLab merge?sha=, GitHub merge sha parameter.
If HEAD has moved, the merge fails closed and the new pipeline re-evaluates. A forge
adapter that cannot SHA-guard merges must declare the capability gap, and the engine then
never auto-merges on that forge. This is a required case in the conformance suite (ADR-0005).
3. Fact freshness is bounded¶
Decision.Pins records per-provider resolution timestamps and the full resolved fact set
(which also makes replay hermetic — see ADR-0011 amendment). When the merge is deferred
(forge-native gate, ADR-0009), staleness is bounded by: (a) any new push re-runs the
pipeline and re-resolves facts (forge behavior), and (b) a configurable
facts.max_age (default: 24h) after which an armed auto-merge is considered expired — the
summary comment states the expiry and a pipeline re-run is required. Repos with
security-sensitive facts should set a short max_age or use serve mode (v1.x) which
re-evaluates on events.
4. The pipeline running assent must not be author-editable¶
CI-first only works if the job definition is trusted. Adoption prerequisite (documented in
the walkthrough and CI templates, verified by assent doctor): the assent job must come from
a protected source — GitLab: pipeline configuration from a protected/included file
outside the MR branch's control (compliance pipeline / instance template) or a
merge-request-approved pipeline model; GitHub: workflows from the target branch
(pull_request runs the base-ref workflow for forks; same-repo branches need branch
protection on workflow paths). The token is least-privilege: merge+comment on this project
only. The docs must state plainly: putting the assent job in an author-editable
.gitlab-ci.yml with a privileged token is an unsupported, insecure topology.
5. Bot approval is the gate — say it out loud¶
When assent approves and merges, its approval satisfies the forge's "1 approval required"
rule; there is no residual human gate. This is intended and must be documented, together
with the defense-in-depth option: a forge approval rule on .assent/** (and optionally on
prod/** classes) requiring an identity assent cannot provide.
6. Serve mode: security requirements fixed now, implementation later¶
So that v1 port shapes don't foreclose them (F13): webhook signature verification (HMAC / forge-native), event dedup key (MR id + head SHA + event id), idempotent publishing (findings carry stable ids; re-posting is an upsert), per-repo token scoping (no instance-wide credential), and the same target-ref policy loading as §1.
Consequences¶
- The classifier (ADR-0008) gains the built-in
assent-policymeta-class; ADR-0010's layout section references §1; ADR-0014's expectation-trust question is resolved the same way: expectations forassent testin CI come from the target ref when.assent/**changed. scanbacktests policies from the ref being tested, not from each historical MR — already consistent with §1.- Publisher port signatures change:
Approve/Mergetake the pinned SHA (ADR-0011 amended). - Threat model becomes documentable: this ADR seeds SECURITY.md.
Counterpoints considered¶
- "Loading policy from the target ref makes policy changes hard to test in their own MR." —
No:
assent testand--dry-runrun locally against the branch; CI additionally runs the branch's own tests as tests (L1). Only the gating decision uses target-ref policy. The policy MR is gated by the old policy + human review — exactly right. - "SHA-guard + freshness makes automerge slower." — It makes automerge checkable. The fast path (APPROVE at evaluation time, merge immediately, SHA still HEAD) is unaffected.
Additions (2026-07-21, independent security review A-03/A-04/A-05/A-10)¶
7. Provider/plugin trust model (A-03)¶
Providers run without the forge write token — the provider host passes a narrow FactQuery
and receives facts; the approve/merge credential never enters a provider's environment or
process. Exec/gRPC provider binaries are digest-pinned in config.yaml (optional cosign
verification); default recommendation is no in-process third-party code — subprocess with
narrow JSON RPC (tier 2) or WASM (tier 4) are the sanctioned paths, and the gRPC tier is
documented as elevated-risk until sandboxing lands. Provider timeouts fail closed (facts
unknown → fail-safe per ADR-0007 tri-state).
8. Execution-authority matrix (A-04)¶
| Mode / context | May hold write token | May auto-approve/merge |
|---|---|---|
| CI from protected/trusted config (§4) | yes (least-privilege) | yes |
| CI on fork / untrusted-contributor MR | no | advisory-only (report, no writes) |
serve (v1.x, §6) |
yes (per-repo scoped) | yes |
--dry-run / explain / test / scan |
no | never |
assent doctor refuses to arm auto-merge when it cannot verify the protected-config
precondition.
9. Input resource safety & report retention (A-05, review P1-3)¶
Parsing and evaluation run under hard limits — max file size/count, max diff bytes, nesting depth, YAML anchor/alias expansion caps, symlink and path-traversal rejection, parse+eval deadlines, CEL cost budget — breach fails closed to REVIEW (spec'd with ADR-0003). The summary comment always embeds the decision hash and a link to the report artifact; docs must warn that CI artifact retention limits the audit window and recommend a retention policy for report artifacts.