# Write a policy and prove it before you sign it

Source: https://www.testifysec.com/docs/cilock/use-cases/prove-a-policy/

A Claude Code session writes a CI/lock policy for a Go test step from cilock policy guide, then uses cilock policy prove to show a passing run is admitted and failing, faked and empty runs are refused.

A policy that admits everything looks exactly like a policy that works, until the day it should have said no. The only way to know a gate refuses bad evidence is to feed it bad evidence and watch it refuse. CI/lock builds that loop into the CLI: `cilock policy guide` explains what each goal needs and prints the rules for it, and `cilock policy prove` records real runs of your step, including a deliberately failing one, and verifies them against your draft.

 

In this session a developer asks Claude to write a policy for a small Go repository: admit the test step only when the tests really ran and passed, and prove it locally before a human signs it. Claude has no Pushgate enrollment on this machine, so it writes the draft by hand. In a second turn the developer asks for a local ed25519 key as the signer.

 

A Claude Code session writing a CI/lock policy for a Go test step and proving it with cilock policy prove: a passing run is admitted, a failing run and an empty test suite are refused

[Download terminal recording (.cast)](https://www.testifysec.com/media/docs/recordings/policy-prove.cast)

 

## What happens, step by step

 

1. **Read the guide.** `cilock policy guide --goal tests` prints the evidence the goal needs (`cilock run --step tests -a test-results -- <argv>`), the predicate fields a rule can read, and three seeded Rego rules: `command-succeeded`, `command-pin` and `tests-pass`.
 2. **Scaffolding is refused, so write it by hand.** `cilock policy template` needs an enrolled agent to name as the signer, and this machine has none. Claude builds the draft from the guide's JSON output instead, filling the pin with `["gotestsum","--junitfile","junit.xml","--","-count=1","./..."]`. `-count=1` stops Go from replaying a cached result in place of a real run.
 3. **Prove it.** `cilock policy prove -p .pushgate/policy.json --no-normalize` records the pinned command for real with a throwaway key, then records the step again wrapping `false`. The real run is admitted and the failing run is refused with `wrapped command exited 1, not 0`. Prove deletes the key and the evidence when it finishes, and uploads nothing.
 4. **Try to break it.** Claude does not stop at the two runs prove makes. It runs prove against a scratch copy with one test expectation broken (refused: `1 of 1 tests failed`) and a copy with the test file deleted (refused: `the report recorded 0 tests; zero tests passing is not tests passing`).
 5. **Name a real signer.** In the second turn Claude generates an ed25519 key with `openssl`, reads its keyid with `cilock keyid show`, and adds it as the step's only functionary. `cilock policy validate` rejects the first attempt (`invalid type 'PublicKey' (must be 'publickey' or 'root')`), Claude fixes the case, and validation passes in normal and `--strict` mode.
 6. **Close the gap prove leaves.** Prove signs with its own throwaway key, so it never checks the keyid you will actually use. Claude records evidence with the real key, signs a scratch copy of the policy, and runs `cilock verify` on both: the passing suite exits `0`, the failing suite is refused by both the `command-run` and `test-results` rules. The real policy stays unsigned. Signing it is the human's step.

 

## Why each rule is there

 

Each seeded rule closes a different way to fake a green test step:

 

| Rule | Refuses |
| --- | --- |
| `command-succeeded` | a test command that exited non-zero |
| `command-pin` | any command other than the pinned argv, so wrapping `true` cannot satisfy the step |
| `tests-pass` | a report with failures or errors, a report where nothing passed, and a report with zero tests |

 

The seeded modules also refuse evidence they cannot read. A rule written as `pred.exitcode != 0` passes when `exitcode` is missing entirely, because the comparison is undefined and nothing is denied. The guide's versions test that the field is readable first and deny when it is not. `cilock policy validate` warns about the weak form: *the rego modules deny nothing on an empty predicate*. See [`command-run`](https://www.testifysec.com/docs/cilock/attestors/command-run) and [`test-results`](https://www.testifysec.com/docs/cilock/attestors/test-results) for the fields each rule reads.

 

## Run it yourself

 

The commands in the recording need CI/lock 4.6.0 or later. The session was recorded with `cilock` built from `main` at commit `5b614726`.

 

```bash
cilock policy guide --goal tests
cilock policy validate -p .pushgate/policy.json --strict
cilock policy prove -p .pushgate/policy.json --no-normalize
```

 

Without `--no-normalize`, prove replaces a publickey-only functionary with the enrolled Pushgate agent, which is what you want once the policy gates agent pushes. With an enrolled agent, `cilock policy template --goal tests` writes the trust blocks and seeded rules for you and leaves `__FILL__` slots for the decisions only you can make, such as the pinned command.

 

What a passing proof does not tell you

A passing run proves what the tests cover and nothing more. Claude says so at the end of the first turn: *A passing run only proves what `TestMake` covers.* The policy makes the evidence honest. It does not make thin tests thorough.

 

See [Policy verification](https://www.testifysec.com/docs/cilock/concepts/policy-verification) for how `cilock verify` evaluates a signed policy, and the [CLI reference](https://www.testifysec.com/docs/cilock/reference/cli#cilock-policy-prove) for every prove flag.

Reference generated from the product documentation. Match commands and support details to your installed release.
