Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
/action.yml @SollanSystems
/.github/workflows/ @SollanSystems
/.github/CODEOWNERS @SollanSystems
# The anchor carries the head a gate corroborates, so an actor who can edit it
# re-points the anchor at a head they had attested — a gate-defining path in
# exactly the sense /loop/ and /action.yml are (ADR 0002 amendment, 2026-07-29).
loop-anchor.json @SollanSystems
82 changes: 82 additions & 0 deletions .github/workflows/attest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,85 @@ jobs:
fi
echo "chain head: $OBSERVED_HEAD" >> "$GITHUB_STEP_SUMMARY"
echo "attestation: $ATTESTATION" >> "$GITHUB_STEP_SUMMARY"

- name: resolve the attestation we just minted
id: resolve
# HONEST SCOPE: this is a WITHIN-RUN exercise. The anchor is written from a head
# minted seconds earlier in this same job, so it proves the resolve path works
# against the real gh and the real index — it does NOT prove cross-run detection,
# because this workflow seeds an ephemeral RUNNER_TEMP workspace and there is no
# persistent store to anchor across runs. Do not read this green as a cross-run
# proof. A true cross-run dogfood needs a persistent store and is out of scope.
#
# It runs THE SHIPPED script — the same entry point action.yml's `anchor` input
# calls. No verify-and-classify logic is reimplemented here: if it were, the only
# thing getting real-gh mileage would be a duplicate no adopter ever runs.
env:
GH_TOKEN: ${{ github.token }}
HEAD: ${{ steps.gate.outputs.chain-head }}
run: |
python -B - "$HEAD" "${RUNNER_TEMP}/loop-anchor.json" <<'PY'
import json
import sys

json.dump({"schema": "loop-engineer/anchor@1", "chain_head": sys.argv[1]},
open(sys.argv[2], "w"))
PY
# The attestation index is eventually consistent; bound the wait rather than
# assuming it, and fail loud when the budget is exhausted. The retry wraps the
# SCRIPT, so `anchor_attestation_unavailable` — its honest answer while the
# index catches up — is what is being retried.
for attempt in 1 2 3 4 5 6; do
if python -B scripts/action_anchor_resolve.py \
--anchor "${RUNNER_TEMP}/loop-anchor.json" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow SollanSystems/loop-engineer/.github/workflows/attest.yml \
--runner-temp "$RUNNER_TEMP" \
--github-output "$GITHUB_OUTPUT"; then
break
fi
if [ "$attempt" = "6" ]; then
echo "::error::action_anchor_resolve.py never corroborated the attestation we just minted"
exit 1
fi
sleep 5
done

- name: assert the resolved attestation proves D1 landed
env:
OUTCOME: ${{ steps.resolve.outputs.anchor-outcome }}
SUBJECT: ${{ steps.resolve.outputs.subject-path }}
PREDICATE: ${{ steps.resolve.outputs.predicate-path }}
WS: ${{ steps.seed.outputs.workspace }}
run: |
if [ "$OUTCOME" != "corroborated" ]; then
echo "::error::anchor-outcome was '$OUTCOME', expected 'corroborated'"
exit 1
fi
python -B - "$SUBJECT" "$PREDICATE" <<'PY'
import hashlib
import json
import pathlib
import sys

subject = pathlib.Path(sys.argv[1])
predicate = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))
raw = subject.read_bytes()
# D1's byte form, regenerated by loop.verdict.subject_bytes from the carried
# head alone: 64 bytes, lowercase hex, no trailing newline.
assert subject.name == "loop-chain-head", subject.name
assert len(raw) == 64, len(raw)
assert not raw.endswith(b"\n")
head = raw.decode("ascii")
assert head == predicate["chain"]["head"], (head, predicate["chain"]["head"])
# THE crispest proof that D1 landed. In all three attestations this repo minted
# before this slice, subject[0].digest.sha256 EQUALLED predicate.chain.head. The
# subject digest is now the hash of a FILE CONTAINING the head, so it must differ.
subject_digest = hashlib.sha256(raw).hexdigest()
assert subject_digest != head, "subject digest still equals the head: D1 did not land"
print(f"subject sha256 {subject_digest} != chain head {head}")
PY
# The first live exercise of the agreement path, on the same extraction an
# adopter gets. The signer-trust policy already passed inside the script —
# `corroborated` is unreachable without it.
python -B -m loop verdict --compare "$PREDICATE" "$WS"
93 changes: 93 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,99 @@ jobs:
echo "::error::the always-run anchor step recorded '$GATE_HEAD', not the observed head"
exit 1
fi

- name: Ancestry survives a grown store where head equality cannot
env:
ANCHOR_WS: ${{ steps.seed.outputs.workspace }}
ANCHOR_HEAD: ${{ steps.seed.outputs.head }}
# F3, proven live and within a single run: appending events MOVES the head, so
# feeding run N's head to --expect-chain-head at run N+1 fails by construction.
# The inverted pair below is the only ancestry coverage this repo can honestly
# claim — its attest workspace is ephemeral, so a true cross-run dogfood needs a
# persistent store and is out of scope.
run: |
python -B - "$ANCHOR_WS" <<'PY'
import json
import pathlib
import sys

sys.path.insert(0, ".")
from loop.events import SQLiteEventStore

ws = pathlib.Path(sys.argv[1])
store = SQLiteEventStore(ws / ".loop" / "events.db")
for iteration_id in (2, 3):
store.append("ci-anchor-probe", "iteration_appended",
{"iteration_id": iteration_id, "outcome": "task_passed"},
actor="ci_anchor_probe")
state_path = ws / ".loop" / "state.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["iteration_id"] = 3
state_path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
PY
# The anchored head is still an ANCESTOR: exit 0.
python -B -m loop doctor --expect-chain-ancestor "$ANCHOR_HEAD" "$ANCHOR_WS"
# The same digest as an exact head EXPECTATION now fails: exit 1, with the
# equality code and NOT the ancestry code. Inverted so an unexpected pass fails.
set +e
out="$(python -B -m loop doctor --expect-chain-head "$ANCHOR_HEAD" "$ANCHOR_WS" 2>&1)"
status=$?
set -e
if [ "$status" -ne 1 ]; then
echo "::error::expect-chain-head exited $status on a grown store; expected 1"
echo "$out"
exit 1
fi
case "$out" in
*chain_anchor_not_ancestor*)
echo "::error::the grown store reported chain_anchor_not_ancestor; the two codes must not collapse"
echo "$out"; exit 1 ;;
esac
case "$out" in
*chain_anchor_mismatch*) ;;
*) echo "::error::doctor did not report chain_anchor_mismatch"; echo "$out"; exit 1 ;;
esac

gates-fallback:
name: gates (structural-fallback leg)
runs-on: ubuntu-latest
# Every other job installs jsonschema, so the pyyaml-only leg — the one the anchor@1
# and verdict@1 structural hand-checks actually run in — had NO CI cover at all. That
# was measured: loosening a fullmatch to a match in loop/anchor.py kills 4 tests in
# this leg but only 2 with jsonschema installed, because the schema layer masks the
# rest, so a drift between _structural_violation and anchor.schema.json would pass CI.
# Deliberately a SEPARATE job rather than a matrix leg on `gates`: a matrix would
# rename `gates`' check contexts, and this repo's branch ruleset pins its required
# contexts by name.
steps:
- uses: actions/checkout@v7

- uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Install gate dependencies (no jsonschema)
# pyyaml is required even here: scripts/validate_frontmatter.py imports yaml
# unconditionally, so a truly bare environment fails collection.
run: python -m pip install --upgrade pip pyyaml pytest

- name: Assert the leg really ran without jsonschema
# Without this the job silently becomes a duplicate of `gates` the moment
# something adds jsonschema to the install line above.
run: |
python -B - <<'PY'
import importlib.util
import sys

if importlib.util.find_spec("jsonschema") is not None:
print("::error::jsonschema is installed: this leg is not the fallback leg")
sys.exit(1)
print("jsonschema absent: structural-fallback leg confirmed")
PY

- name: Test suite (structural fallback)
run: python -B -m pytest -q -p no:cacheprovider scripts

recipe-openhands:
name: recipe (openhands)
runs-on: ubuntu-latest
Expand Down
51 changes: 46 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ Statement, and never reads an environment variable — `scripts/test_verdict_pur
makes each boundary mechanical.

The composite action gains an opt-in `attest` input (default false): it writes
the predicate to the runner temp dir and hands it to `actions/attest` with
`subject-name: loop-chain-head` / `subject-digest: sha256:<chain-head>`,
exposing `attestation-url`/`attestation-id` outputs; a legible permission
the predicate to the runner temp dir and hands it to `actions/attest` as a
`subject-path` — a file whose entire content is the chain head, exactly 64
lowercase hex bytes with no trailing newline, produced by the single definition
`loop verdict --emit-subject` — exposing `attestation-url`/`attestation-id`
outputs; a legible permission
precheck replaces the raw OIDC 403, and an empty chain head skips with a
warning rather than shipping a malformed subject. `.github/workflows/attest.yml`
mints a real attestation on every push to main over a workspace seeded through
Expand All @@ -43,8 +45,47 @@ just a signed weakened gate. An agent with ordinary merge rights can loosen
`loop/**`/`schemas/**`/`action.yml`/the workflow and then mint a perfectly
genuine attestation for the result — the control is code-owner review on those
paths, which is in force only once the repository ruleset requires it — and an
unattested chain rewrite is detected at best one run late. Verification (`--compare`, anchor auto-resolution, signer-trust policy)
is slice 4b and does not ship here.
unattested chain rewrite is detected at best one run late.

**A verdict you can check (slice 4b of tamper-evident provenance).** Verification
ships alongside emission, so this release describes one coherent state rather
than half a mechanism.

`loop verdict --compare <file|-> <workspace>` compares an attested predicate
against the local projection over four facets — `run_id`, `chain.head`, the whole
`terminal` object, and the verified-evidence digest set — exiting 0 on agreement,
1 on disagreement and 2 on refusal. It accepts a **bare** predicate only: an
in-toto Statement or a `gh --format json` envelope is refused by name with the
documented jq path to unwrap. `signature_checked` is the literal `false` on every
path and there is no flag to flip it — authenticity is `gh attestation verify`'s
job, it runs first, and neither check implies the other. `doctor` and `tool` are
deliberately not compared: both are environment-coupled, so comparing them would
make an honest environment difference read as tampering.

`loop doctor --expect-chain-ancestor <sha256>` (or `--anchor <path>`, resolving
the digest from a tracked `loop-engineer/anchor@1` file) asks the answerable
cross-run question — *was this digest ever my head?* — because
`--expect-chain-head` is exact current-head equality and fails by construction
once a store grows. Ancestry is established by **replay**, recomputing every hash,
never by trusting the stored `event_hash` column: a tamperer who can rewrite the
store can also insert a row bearing the anchored digest. `loop/attestation.py`
adds a pure signer-trust policy over already-verified certificate claims that
**refuses** when a claim it needs is absent, and `scripts/action_anchor_resolve.py`
is the single `gh` call site, fail-closed on anything it cannot confidently
classify. All of it is normative in `reference/repo-os-contract.md` §24.

**Behavioral change:** the attested subject is now a head-bearing file, so the
three attestations minted before this release carry a different subject form.
They remain valid records of what they were.

What this does not buy, beyond the limits above: anchor trust is **exactly
ordinary write access** to the anchor file — an actor who can edit it re-points it
at a head they had attested. An attestation can corroborate a carried head but can
never discover one, because GitHub exposes no endpoint that lists attestations
without a subject digest. Attestations are deletable and no retention window is
documented, so a missing one is a typed failure rather than a skip. And the
independent-audit property holds for **public** repositories: a private repository
signs against GitHub's own instance, which has no public transparency log.

## 0.11.0 — 2026-07-26

Expand Down
Loading
Loading