# command-run attestor

Source: https://www.testifysec.com/docs/cilock/attestors/command-run

The cilock command-run attestor records the executed argv, exit code, captured stdout/stderr, and an optional Linux ptrace forensic record, signed into in-toto evidence.

**Predicate version v0.2 (latest)**

Records the command `cilock run` executed — argv, exit code, captured stdout/stderr, and (when `--trace` is enabled on Linux) a per-process ptrace record of opened files, network activity, file mutations, and security-sensitive syscalls.

| Name | `command-run` |
| --- | --- |
| Predicate type | `https://aflock.ai/attestations/command-run/v0.2` |
| Lifecycle | `execute` |
| Default binary? | Yes |
| Recommended trace | off — no syscall tracing needed |
| Auto-attaches when | *Not auto-detected — attach explicitly with `-a`.* |

The facts in this box are generated from the CI/lock binary's own catalog (`cilock tools list`). Do not hand-edit — run `npm run gen:catalog`.

## What it captures

Top-level `CommandRun` fields (json tags from the struct):

- `cmd` — the argv slice that was executed.
 - `stdout` — the verbatim stdout the process wrote (captured into a `bytes.Buffer` and stored as a string).
 - `stderr` — the verbatim stderr, same mechanism.
 - `exitcode` — the child's exit status.
 - `processes` — populated only when `--trace` is on; one `ProcessInfo` entry per traced PID.
 - `program`: the program `argv[0]` started, recorded on **every** run, traced or not, on every platform. There is no flag to turn it off. See [The program record](https://www.testifysec.com/docs/cilock/attestors/command-run#the-program-record).

Each `ProcessInfo` carries: `program`, `processid`, `parentpid`, `programdigest`, `comm`, `cmdline`, `exedigest`, `exedigestSource` (what `exedigest` measures: `mapped-image` is SHA-256 of `/proc/<pid>/exe` read through a single descriptor, claimed only when the backend can PROVE both that the measured file is the image this exec mapped and that the image stayed write-protected across the whole measurement - today only the ptrace backend, which holds the tracee stopped at the execve return; `path-hash` means the value is a copy of `programdigest`, the bytes at the caller-named path when the tracer opened it, which is not proof of what executed; empty means unlabelled, never "mapped image"; a policy comparing `exedigest` against an image allowlist must require `mapped-image`), `exedigestDowngradeReason` (why a backend that would otherwise report the mapped image reported a path hash: `not-kernel-bound` - the exec event carries no kernel identity to bind the measurement to, which is the case for the eBPF backend because its execve event comes from a syscall-entry kprobe fired before the kernel resolves or maps anything, so that backend always downgrades; `unprotected-measurement` - write protection could not be GUARANTEED for the whole measurement, so the bytes hashed need never have executed. This covers the process no longer mapping the image (ETXTBSY lapses once the last executing reference goes away), running on Linux 6.11 or 6.12 where an in-place write to a mapped image is not ETXTBSY-denied, and being unable to determine the kernel version at all - an unknown answer downgrades exactly as a negative one does, because a producer that cannot tell whether writes were possible is in the position of one that knows they were. Empty means no downgrade. It exists so a verifier can tell "the producer declined to claim what it could not prove" from "this producer never populated the field"), `environ`, `specbypassisvuln`, `exitcode` (per-process exit status; for signal-terminated processes uses the shell convention `128 + signal_number`; absent/zero means "still running when trace ended"), `openedfiles` (a `map[path]DigestSet` populated from `openat` syscalls and re-resolved at trace end), plus three nested structures:

- `network` (`NetworkActivity`): `sockets[]`, `connections[]` (each with `syscall`, `family`, `address`, `port`, `fd`, `timestamp`, and `hostname` for TLS SNI from ClientHello on port 443), `dnsLookups[]`.
 - `fileOps` (`FileActivity`): `writes[]` (path resolved via `/proc/pid/fd/N`), `renames[]`, `deletes[]`, `permChanges[]` (mode bits + `setExec`).
 - `syscallEvents[]` — notable syscalls: `memfd_create`, `ptrace`, `mount`, `clone` (with namespace flags), `dup2` (socket→stdio = reverse-shell pattern), `mprotect` (PROT\_EXEC), `prctl` (PR\_SET\_NAME / PR\_SET\_DUMPABLE / PR\_SET\_NO\_NEW\_PRIVS), `setsid`, `setns`, `init_module`/`finit_module`.

## The program record

`cmd` is the text the caller typed, and `terraform` names whatever `PATH` found. `program` names the file:

- `lookup`: `path-search` (a bare name searched on `PATH`), `workdir-relative` (a name with a separator, resolved against the working directory) or `absolute`.
 - `path`: the absolute path the exec's own lookup produced, not lexically cleaned (`./t` run in `/w` is `/w/./t`). On Windows it carries the `PATHEXT` extension `Start` would add (`.\tool` records `...\.\tool.exe`), and cilock hands `Start` that same resolved name, so the file hashed and the file started are one string. With no working directory cilock can name, a relative `path` stays relative, opened against the directory the exec uses.
 - `realPath`, `realPathSource`: the real path after every symlink, read back from the descriptor that was hashed (`proc-self-fd` on Linux, `f-getpath` on macOS), never from a second walk of the name. When the descriptor cannot answer, the name is resolved and kept only if it is the same file (`evalsymlinks-confirmed`); otherwise `realPath` is empty, `realPathSource` is `unresolved`, `realPathReason` says why, and the digest is kept. An unreadable program reports `name`, or on Linux `proc-self-fd` through an `O_PATH` descriptor.
 - `digest`, `sizeBytes`: sha256 (plus any `--hashes` algorithm) of the **whole** file, with no size limit, taken through the same settle-and-bracket read the tracers use.
 - `file`: device, inode, link count, `setId` (setuid, setgid, or a Linux file capability) and `fsType` of the same descriptor. `setId` is always emitted: `true` or `false` only when established, `null` with `setIdReason` when it is not. On Linux the capability is read through the hashed descriptor, and only "no such attribute" or "this file system carries none" establishes `false`; any other error leaves `null`. A rule that requires `setId == false` refuses `null`.
 - `format`: `elf`, `mach-o`, `mach-o-universal`, `pe`, `script` or `other`, from the first bytes the hash read; absent when those bytes could not be read.
 - `host` (`GOOS/GOARCH`) and `workdir`, the directory the child started in.
 - `checkout.relation`: always emitted, and `unknown` in this release with the reason "containment is not computed by this CI/lock build". A rule that requires `outside` refuses it.
 - `wrapper`: present when cilock itself started another program first: `sandbox-exec` under `--trace` on macOS, with its own path and digest.
 - `unresolved`: why there is no `digest`. The only expected cause is a program cilock cannot read (`permission denied: the program is executable but not readable`, for an execute-only file). The command still runs.
 - `executionBinding`: always emitted, in the `scripts[]` vocabulary. It is always `unverified` in this release: the digest is of the file the lookup named, read before the command started, and nothing proves those bytes are the ones the kernel then executed. `bindingReason` says so, or says "the exec target changed after the program was recorded" when anything other than a wrapper cilock names rewrote the exec target before it started. On Linux under `sudo` that includes the `setpriv` privilege drop, which searches `PATH` again as the invoking user.

A pin on `program.digest.sha256` is a pin on `argv[0]` only. `env A=1 tool`, `sh -c "..."`, `timeout 600 tool` and `npx tool` record the launcher, and a shim (`pyenv`, `rustup`, the macOS xcrun `/usr/bin/git`) records the shim, not the tool it starts.

**Absolute paths, including home directories.** `path`, `realPath` and `workdir` are absolute, so on a laptop they carry the login name (`/Users/<login>/.nvm/...`) into every signed attestation and to every place the attestation goes: Archivista, a platform that stores evidence, and the provider of any AI policy, which receives the whole command-run attestor as its input.

Older verifiers ignore the field: it is additive, and the predicate type stays `command-run/v0.2`. A verifier built before it shows Rego no `program`, whatever the signed bytes carry.

## When to use

Always — it's the spine of every `cilock run`. `--trace` turns the attestor from "I ran this command and here is its stdio" into a forensic record of what the process did at the syscall level.

## Flags

| Flag | Shorthand | Default | What it does |
| --- | --- | --- | --- |
| `--trace` | `-r` | `false` | Sets `SysProcAttr.Ptrace = true` on the child and enables the ptrace loop in `tracing_linux.go`. On macOS, enables the sandbox-report tracer. Unsupported platforms refuse tracing. |

| `--trace-file-content` | — | `false` | With tracing on macOS, records bounded workspace text snapshots from file-access reports. Requires explicit opt-in. |

There are no `--attestor-commandrun-*` flags — `commandrun.init()` registers with no `registry.Configurer` options.

## Output shape

```json
{
  "cmd": ["go", "build", "./..."],
  "stdout": "...verbatim bytes the child wrote to stdout...",
  "stderr": "...verbatim bytes the child wrote to stderr...",
  "exitcode": 0,
  "program": {
    "lookup": "path-search",
    "path": "/usr/local/go/bin/go",
    "realPath": "/usr/local/go/bin/go",
    "realPathSource": "proc-self-fd",
    "digest": {"sha256": "..."},
    "sizeBytes": 16100000,
    "file": {"device": 2049, "inode": 1311234, "links": 1, "setId": false, "fsType": "ext4"},
    "format": "elf",
    "host": "linux/amd64",
    "workdir": "/home/runner/work/app/app",
    "checkout": {"relation": "unknown", "reason": "containment is not computed by this CI/lock build"},
    "executionBinding": "unverified",
    "bindingReason": "this CI/lock build does not bind the program to the exec: the digest is of the file the lookup named, read before the command started"
  },
  "processes": [
    {
      "program": "/usr/local/go/bin/go",
      "processid": 12345,
      "parentpid": 12344,
      "programdigest": {"sha256": "..."},
      "comm": "go",
      "cmdline": "go build ./...",
      "exedigest": {"sha256": "..."},
      "exedigestSource": "mapped-image",
      "openedfiles": {"/path/to/go.mod": {"sha256": "..."}},
      "environ": "GOFLAGS=... GOPATH=...",
      "specbypassisvuln": false,
      "network": {
        "sockets": [{"family": "AF_INET", "type": "SOCK_STREAM", "protocol": 0, "fd": -1}],
        "connections": [{"syscall": "connect", "family": "AF_INET", "address": "140.82.112.3", "port": 443, "fd": 7, "timestamp": "2026-05-21T12:00:00Z", "hostname": "proxy.golang.org"}],
        "dnsLookups": [{"serverAddress": "1.1.1.1", "serverPort": 53}]
      },
      "fileOps": {
        "writes": [{"path": "/tmp/go-build/main", "bytes": 4096, "timestamp": "..."}]
      },
      "syscallEvents": [{"syscall": "memfd_create", "detail": "...", "timestamp": "..."}]
    }
  ]
}
```

## Gotchas

- **`stdout` / `stderr` are raw bytes, not digests.** The attestor stores `stdoutBuffer.String()` and `stderrBuffer.String()` into the predicate byte for byte, with two redactions that also apply to `cmd` and every traced `cmdline`: the value of an environment variable whose name marks it as a secret (`TOKEN`, `SECRET`, `PASSWORD`, ...) becomes `[REDACTED]`, and the userinfo of a URL (`http://user:pass@proxy:3128`, `user:pass@proxy:3128`) becomes `******`, the rule the environment attestor applies to its values, read word by word. A URL is never recorded with a host the command did not use: where Go, Python, WHATWG and curl do not read one userinfo and one host, the URL keeps only its scheme and `******@/`, so `https://a@evil.example@github.com/x`, `evil.example:8080/x@github.com` and `pip install git+https://github.com/org/repo@v1.2.3` all lose their host and path. An argv element that is a URL, or a flag whose value is one, is read whole, so `--proxy=http://u:my pw@proxy:3128` is redacted. So is one that is a URL once its tabs and newlines are removed, as WHATWG removes them (`http:/<TAB>/u:pass@proxy:3128`). A `KEY=` prefix on a word or an element is not taken on trust, because `=` is also base64 padding: the positional proxy value `dG9rZW4=@proxy:3128` is a token and a proxy, not a key and an empty userinfo. Such a word is read whole as well as after its key, and where the whole reading finds a credential the key goes with it, so `HTTPS_PROXY=user:pass@proxy:3128` in output becomes `******@proxy:3128`. The name-based redaction runs first, because it finds a value byte for byte and the URL redaction rewrites the bytes: a secret variable whose value holds a URL (`API_TOKEN=https://u:p@host/?token=...`) becomes `[REDACTED]` whole, query token included. In a traced `cmdline` it runs before the elements are read apart, with the NULs between them read as spaces, so a value the shell split into two elements is masked too; in `cmd` each element is matched on its own. Nothing else is changed. Large output goes into the signed envelope as-is, and so does a secret that is neither: a password with a space in it inside a line of output, a token on its own, or a credential glued to more text (`u:p@proxy:3128,localhost`).
 - **Trace capabilities differ by backend.** Linux supports eBPF/ptrace; macOS uses sandbox reports with the limitations below. Unsupported platforms refuse tracing.
 - **macOS tracing needs `log stream`, which macOS runs only for the `admin` group.** A user outside `admin` (a CI account, say) either joins it (`dseditgroup -o edit -a <user> -t user admin`) or gets a sudoers rule for exactly the command cilock runs, and cilock then starts the collector as `sudo -n` without ever prompting. Install it with `visudo -f /etc/sudoers.d/cilock-logstream`:
  
   
  
  ```text
<user> ALL=(root) NOPASSWD: /usr/bin/log stream --style ndjson --predicate senderImagePath CONTAINS\[c\] "sandbox"
```
  
   
  
  The escaped `\[c\]` is required: unescaped, sudoers reads `[c]` as a glob class and the rule stops matching. The rule lets the user read, as root, the Sandbox kext's reports for every process on the machine, which an admin can already do, and nothing else. Without either fix, `--trace` refuses before running the command and prints both. The attestation is the same whichever way the collector started.
 - **Tracing needs `ptrace(2)`.** Default Docker drops it; you need `--cap-add=SYS_PTRACE` (and to not be under a restrictive seccomp profile). Kubernetes pods need an equivalent `securityContext.capabilities.add: [SYS_PTRACE]`.
 - **`openedfiles` is populated from `openat`** — and digested at open time, with a retry pass at trace end (`retryOpenedFiles`) for files that didn't exist yet. Files opened-but-never-read still appear.
 - **TLS SNI extraction is best-effort.** The tracer peeks the first write on any fd that connected to port 443 and parses the ClientHello for the `server_name` extension. Only one peek per fd; non-printable hostnames are discarded.
 - **DNS detection is a heuristic** — any `connect()` to port 53 is recorded as a DNS lookup. Non-DNS traffic to port 53 will be misclassified.

## CLI example

Builtin. cilock always runs this — there's no `--attestations command-run` toggle. With `--trace`, captures ptrace-level syscall info per child process.

```bash
cilock run --step my-step \
  --signer-file-key-path key.pem --outfile attestation.json \
  --trace \
  -- make build 
```

Validated. Always part of every cilock run. See the full real-data example at [https://github.com/aflock-ai/attestor-compliance-examples/tree/main/01-command-run](https://github.com/aflock-ai/attestor-compliance-examples/tree/main/01-command-run).

## See also

- [Catalog row](https://www.testifysec.com/docs/cilock/reference/attestor-catalog)
 - [Defending against supply-chain attacks](https://www.testifysec.com/docs/cilock/tutorials/defending-against-supply-chain-attacks)
 - Upstream: [witness/command-run.md](https://github.com/in-toto/witness/blob/main/docs/attestors/command-run.md)

## macOS traced file content (opt-in)

`cilock run --trace --trace-file-content` extends the `sandbox-exec+oslog` backend with kernel `file-read-data` reports scoped to the working directory using a sandbox `subpath` filter. System and toolchain reads outside that scope are not requested; they are not collected and then discarded in Go. A startup canary must demonstrate that this channel delivers file-access reports; otherwise requested capture refuses before the workload starts. Ordinary tracing and `--script-capture` retain their existing defaults. The flag currently requires macOS.

The scope is deliberately incomplete for reads outside the workspace, including external dependencies and symlink targets outside it. The startup probe uses a separate literal-path reporting rule for its private temporary file; that rule is not applied to the workload.

This captures observed access to workspace files including scripts, Makefiles, includes, and files read by child processes. Each attributed event appears in `processes[].syscalls[]` in v0.2 (`syscallEvents[]` in the expanded representation), with `syscall: "file-read-data"`, a path, timestamp, and `outcome: "permitted-not-confirmed"`. The sandbox reports permission to access, not a successful read or its returned bytes.

`fileAtCollectorOpen` holds a snapshot with `status`, optional `content`, `digest`, and `sizeBytes`. `captured-at-collector-open` means the collector read those bytes after delivery of the report. It is **not execution binding** or proof of what the process consumed. Generated files deleted before delivery may be unavailable; a replacement before collector access can yield different bytes. Nothing is promoted into `openedfiles` or verified `scripts` from these observations. Absence of an event does not establish absence of access.

Content is opt-in separately from script operand capture. Only regular UTF-8 text without NUL bytes under the run's working directory is eligible. Scoped opens refuse symlink escapes; FIFOs/devices are not read. Limits are 64 KiB per file, 4 MiB read per run, and 256 attempted workspace snapshots. Repeated reads consume the same budget because the contents may have changed. No partial body is emitted when a size/budget limit is reached. Status records distinguish outside-workspace, unavailable, binary, non-regular, oversize, changed-during- capture, and exhausted-budget cases. Policies requiring full coverage must refuse these omissions rather than treat them as negative findings.

`summary.diagnostics.darwin.fileReadsObserved` records the capability probe, `fileContentScope` names the workspace, `fileReadReports` counts attributed reports, and `unprovenFileReadReports` counts reports whose process ownership was not established. No content is opened for a process lacking capture-time attribution. These are observations, not a completeness guarantee.

Captured content can contain credentials. Review the capture scope and secret handling before publishing the signed evidence; this flag does not redact source bytes or authorize uploading them. For Jev, use these snapshots as context, not proof that an adversarial build consumed the pictured bytes.

**Predicate version v0.1 (legacy)**

| Name | `command-run-v0.1` |
| --- | --- |
| Predicate type | `https://aflock.ai/attestations/command-run/v0.1` |
| Lifecycle | `execute` |
| Default binary? | No |
| Recommended trace | off — no syscall tracing needed |
| Auto-attaches when | *Not auto-detected — attach explicitly with `-a`.* |

The facts in this box are generated from the CI/lock binary's own catalog (`cilock tools list`). Do not hand-edit — run `npm run gen:catalog`.

 

---

**This page is generated from the CI/lock tool catalog.** Don't edit it here — the source is [`attestation/detection/docs/command-run.doc.md`](https://github.com/aflock-ai/rookery/edit/main/attestation/detection/docs/command-run.doc.md) in [aflock-ai/rookery](https://github.com/aflock-ai/rookery). The same catalog powers `cilock tools show command-run` in the CLI, so a single edit keeps the terminal and the website in sync.

[Edit this page →](https://github.com/aflock-ai/rookery/edit/main/attestation/detection/docs/command-run.doc.md) ·  [Add a new tool →](https://github.com/aflock-ai/rookery/new/main/attestation/detection/docs)

When you open the pull request: if you are an **AI agent**, add the `ai-agent` label. If you are a **human**, add the `human` label.

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