# Agent on the laptop, tests on a worker

Source: https://www.testifysec.com/docs/cilock/tutorials/agent-on-laptop-rings-on-worker/

A coding agent writes the code on a laptop. The slow test suite runs on a build

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](https://github.com/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 records `push-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:

 

```sh
git commit -m "feat: parse the new header"
cilock attest --step push-metadata -a git -a alps-evidence
```

 

The collection, abridged:

 

```json
{
  "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:

 

```sh
# On Bob's machine
git bundle create /tmp/push.bundle origin/main..HEAD
scp /tmp/push.bundle carl:/tmp/push.bundle
```

 

On the worker, take exactly that commit from the bundle and wrap the test command:

 

```sh
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 test
```

 

Fetching 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:

 

```json
{
  "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

 

```sh
git push origin HEAD
cilock pushgate status --remote origin --wait
```

 

Pushgate 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:

 

```js
{
  "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](https://github.com/in-toto/ITE/blob/master/ITE/4/README.adoc), 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](https://www.testifysec.com/docs/cilock/reference/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.

 

```json
{
  "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>"]}
      ]
    }
  }
}
```

 

```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.
