Agent on the laptop, tests on a worker
A coding agent writes the code on a laptop. The slow test suite runs on a build worker. You want one push to carry both facts: which agent was at work, and that the tests passed for exactly the commit being pushed. This tutorial splits the evidence across the two machines and chains the halves in the style of the in-toto demo, where Alice writes the layout, Bob does the first steps and Carl packages.
Why two machines need two collections
The alps-evidence attestor records the coding agent it can see in CI/lock's own
process ancestry. Run it on the laptop, under the agent, and it finds the agent. Run
it on a worker you reached over ssh, and the ancestry is bash, login, sshd or
tailscaled, then launchd or init: no agent, so the record honestly says
incomplete. So the agent records itself where it runs, and the worker records the
test run where it runs.
The record is an observation, not an identity. A process can name itself anything,
and the attestor says so in every record (enforcement: false). Chaining the
collections proves they are about the same commit and come from the signers the
policy names. It does not prove the agent is who its record says.
The cast
- Alice owns the repository. She writes and signs the policy and activates it.
- Bob is a coding agent on a developer laptop, enrolled with
cilock enroll agent. He recordspush-metadata. - Carl is the build worker. He runs the test suite for the exact commit Bob made
and records
push-gate. Carl usually has his own workload identity, such as a CI job's OIDC token, exactly as Carl has his own key in the in-toto demo.
Step 1: Bob records himself
On the laptop, at the repository root, after committing:
git commit -m "feat: parse the new header"
cilock attest --step push-metadata -a git -a alps-evidenceThe collection, abridged:
{
"name": "push-metadata",
"subject": ["git/v0.1/commithash:115be760… {sha1: 115be760…}"],
"attestations": {
"git/v0.1": {
"commithash": "115be760…", "commithashverified": true,
"treehash": "b036250e…", "workdirprefix": ""
},
"alps-evidence/v0.1": {
"status": "detected",
"invoker": {"vendor": "anthropic", "product": "claude-code"},
"model": {"value": "opus", "assurance": "configuration-observed"},
"assurance": {"enforcement": false}
}
}
}The model above came from a settings file. It is the alias the agent was configured
with, graded configuration-observed, and not necessarily the exact model that ran.
Step 2: Carl runs the tests
Bob's commit is not on origin yet, and it cannot get there first: Pushgate would refuse
the push for want of Carl's evidence. So Bob hands the commit to Carl directly. A git
bundle carries it as one file:
# On Bob's machine
git bundle create /tmp/push.bundle origin/main..HEAD
scp /tmp/push.bundle carl:/tmp/push.bundleOn the worker, take exactly that commit from the bundle and wrap the test command:
git fetch origin main # the bundle's prerequisite commits
git fetch /tmp/push.bundle HEAD && git checkout --detach 115be760…
cilock run --step push-gate -a git -a alps-evidence -- make testFetching over SSH from Bob's repository works as well (git fetch bob:/path/to/repo HEAD).
Either way the commit never passes through origin before it has its evidence. The
git attestor records the commit Carl checked out, so the two collections still chain
on 115be760… however it travelled.
The collection, abridged. The ALPS record is Carl's own and honestly incomplete:
{
"name": "push-gate",
"subject": ["git/v0.1/commithash:115be760… {sha1: 115be760…}"],
"attestations": {
"git/v0.1": {
"commithash": "115be760…", "commithashverified": true,
"treehash": "b036250e…"
},
"command-run/v0.2": {"cmd": ["make", "test"], "exitcode": 0},
"alps-evidence/v0.1": {"status": "incomplete"}
}
}Step 3: Bob pushes
git push origin HEAD
cilock pushgate status --remote origin --waitPushgate reads Bob's push-metadata before Carl's push-gate when it looks for the
agent record, so the push shows Bob's observation rather than the worker's empty one.
What chains the two halves
The artifact both steps share is the commit, not a file. The git attestor recomputes the commit id from the commit object's bytes with collision detection, and a commit names exactly one tree, so two different trees cannot carry the same id. Both collections carry it as a signed subject.
Do not chain on the material digest of the working directory. It covers untracked and ignored files, which always differ between a laptop and a worker, and a clean worker checkout is what proves the tests ran on the commit.
As an in-toto layout
In in-toto, MATCH only consumes artifacts. It never fails by itself. REQUIRE and DISALLOW are what refuse, so every MATCH here sits between them:
{
"steps": [
{
"name": "push-metadata", "pubkeys": ["<bob>"],
"expected_materials": [["ALLOW", "git:commit/*"], ["DISALLOW", "*"]]
},
{
"name": "push-gate", "pubkeys": ["<carl>"],
"expected_materials": [
["REQUIRE", "git:commit/115be760…"],
["MATCH", "git:commit/*", "WITH", "MATERIALS", "FROM", "push-metadata"],
["DISALLOW", "*"]
],
"expected_command": ["make", "test"]
}
]
}git:commit/… is an abstract artifact in the sense of in-toto
ITE-4, whose own
example is MATCH commit/* … FROM merge-pull-request-250.
As a CI/lock policy
attestationsFrom lifts Bob's collections into Carl's Rego input as
input.steps["push-metadata"].collections (see the
policy schema). The matched rule is the MATCH; the
deny on not matched is the REQUIRE and DISALLOW; the deny on a dirty tree is what
makes "the tests ran on this commit" true.
{
"steps": {
"push-metadata": {
"name": "push-metadata",
"functionaries": [{"type": "root", "certConstraint": {
"commonname": "*", "dnsnames": ["*"], "emails": ["*"], "organizations": ["*"],
"uris": ["spiffe://platform.testifysec.com/tenant/YOUR-TENANT-ID/agent/BOB-AGENT-ID"],
"roots": ["fulcio-root"]}}],
"attestations": [
{"type": "https://aflock.ai/attestations/git/v0.1"},
{"type": "https://aflock.ai/attestations/alps-evidence/v0.1"}
]
},
"push-gate": {
"name": "push-gate",
"attestationsFrom": ["push-metadata"],
"functionaries": [{"type": "root", "certConstraint": {
"commonname": "*", "dnsnames": ["*"], "emails": ["*"], "organizations": ["*"],
"uris": ["https://github.com/YOUR-ORG/YOUR-REPO/.github/workflows/ci.yml@refs/heads/main"],
"extensions": {"Issuer": "https://token.actions.githubusercontent.com"},
"roots": ["fulcio-root"]}}],
"attestations": [
{"type": "https://aflock.ai/attestations/command-run/v0.2", "regopolicies": ["<exit 0, exact argv>"]},
{"type": "https://aflock.ai/attestations/git/v0.1", "regopolicies": ["<chain.rego>"]}
]
}
}
}package chain.commit
git := "https://aflock.ai/attestations/git/v0.1"
# Both bindings must be verified: commithashverified is omitempty, so an
# unverified binding has no key and `== true` refuses it.
matched {
input.attestation.commithashverified == true
c := input.steps["push-metadata"].collections[_]
m := c.attestations[git]
m.commithashverified == true
m.commithash == input.attestation.commithash
m.treehash == input.attestation.treehash
}
deny[msg] {
not matched
msg := "no verified push-metadata collection names the verified commit this gate tested"
}
# The git attestor omits an empty status (omitempty), so a clean tree has no
# status key; any entry is a dirty path. Only an absent key or an empty object
# is clean: false, null, a string or a list is not.
deny[msg] {
not clean
msg := "the worker's tree was not clean, so the tests did not run on the named commit"
}
clean {
s := object.get(input.attestation, "status", {})
is_object(s)
count(s) == 0
}On Pushgate the platform already binds every collection in a push decision to the pushed commit, so this rule mostly makes the chain visible to the people who read the policy. It matters more when you verify the two collections yourself, outside Pushgate.
Reference generated from the product documentation. Match commands and support details to your installed release.