Garnet Record (install under sensor) / record ↗ · Install dependencies (the workload) reached 2 destinationssystemd └─ hosted-compute-agent ├─ sudo │ └─ provjobd │ └─ → localhost (dns resolver) └─ → localhost (dns resolver) Runner.Worker ├─ bash │ └─ node │ ├─ dash │ │ └─ node │ │ └─ → localhost (dns resolver) │ └─ → localhost (dns resolver) └─ node └─ → localhost (dns resolver)
Runner.Worker ← runner (italic) └─ npm install ← your workflow step (bold) └─ → registry.npmjs[.]org ← outbound connection, defanged
+ new · − no longer recorded, vs the previous profiled commit. A destination shows as both when its process chain changed.
/ ↗ · ` block — `+` new, `−` no longer recorded, unmarked in both:
```md theme={null}
@@ vs @@
```
**Substrate fold.** Nested inside the job fold, holding resolver chatter and unattributed runner chains. Every recorded chain renders somewhere, and each count equals what sits directly beneath it:
```html theme={null}
dns + runner substrate · chain(s)
```
**Destination leaf.** The first recorded name, defanged at the final dot; a bare IP only when no name was recorded. Two structural tags exist — `(dns resolver)`, a loopback address on port 53, and `(instance metadata)`, one of `169.254.169.254`, `169.254.170.2`, `fd00:ec2::254`. Ports, protocol, and PIDs stay in the [Garnet Execution Summary](/quickstart#action-reference) and the JSON:
```md theme={null}
└─ → registry.npmjs[.]org
└─ → 169.254.169.254 (instance metadata)
```
**Attribution.** Carried by typography: `` is lineage attributed to a workflow step below `Runner.Worker` — the PR's own workload — and `` is runner scaffolding. Attribution context, not trust.
**No egress, and truncation.** Both are stated, never silent:
```md theme={null}
/ — no outbound destinations recorded.
rendered of destination associations
```
**Per-job permalink.** The last line of every fold. Read the `profile` parameter; ignore the campaign parameters after it:
```html theme={null}
View this job's Execution Profile in Garnet →
```
Three transforms apply to display only — the Execution Profile, the [Garnet Execution Summary](/quickstart#action-reference), and the JSON keep the recorded value:
* Hostnames are defanged at the final dot: `registry.npmjs.org` renders as `registry.npmjs[.]org`.
* Step names lose the runner's ordinal prefix and any unexpanded workflow expression: `4. Run workload ${{ matrix.name }}` renders as `Run workload`. The comment never shows an ordinal.
* Process names lose a trailing run of four or more digits: `provjobd1326539233` renders as `provjobd`.
Fail closed on three states: **no marked comment** (recording never ran), **pending headline** (wait, don't fail), **`garnet:commit` ≠ head SHA** (stale record). In all three there is no runtime evidence for the current head — fall back to your normal policy. The comment carries recorded facts and no verdict; the decision is yours.
## Agentic reviewers
Paste one prompt — the reviewer finds the comment, checks the pin, and correlates the diff with what actually ran.
Paste into the reviewer's instructions, alongside the PR URL:
````md theme={null}
# Review this pull request against its kernel-recorded execution record
## Locate the record
1. On the PR, find the comment containing the marker ``.
It is a kernel-recorded record of what this PR's CI executed: every recorded
execution chain and every outbound destination, per job.
2. Confirm it covers the current head: the marker ``
must equal the PR's head SHA. If the comment is missing, still recording
(headline reads "Execution Profiles recording for jobs"), or pinned to an
older commit, report "no execution record for the current head" and stop
treating the run as verified. Absence of evidence is not a clean run.
## Read the record
3. The metadata blockquote carries the run-scope facts: execution chains,
destinations, whether anything changed since the previous profiled commit,
and when it was recorded.
4. Each job fold carries its own facts: a leading `+A −R` delta when the job
changed, a sentence naming which steps reached how many destinations, and
the tree itself. Inside a changed job, the diff fence marks `+` new and
`−` no longer recorded against the previous profiled commit.
5. In the tree, bold lineage is the PR's own workload (attributed to a workflow
step), italic lineage is runner scaffolding, and `→ name` leaves are outbound
destinations with the final dot defanged. The nested "dns + runner substrate"
fold holds resolver chatter and unattributed runner chains.
## Correlate and report
6. For every workload destination and spawned process, state whether the diff
explains it (a dependency change explains registry traffic and an install
chain). Name what the diff does not explain: unexpected hosts, install-script
activity, interpreters that have no reason to be in the chain.
7. On a comparison comment, review the delta first: what this commit added or
stopped doing at runtime, and whether the diff accounts for it.
8. The record covers the current head only. Say so if the PR has earlier pushes.
Emit exactly this shape:
```md
**Execution review of ` / `, recorded at [``]()**
> * execution chains · destinations · since ``*
<2–4 sentences: which steps ran, which destinations they reached, and what
the delta was. Recorded facts only.>
| Destination | Chain | Explained by |
| :--- | :--- | :--- |
| | | |
**Unexplained:**
**Recommendation:** , the evidence it rests on, and what a
re-run should record.
Grounding: [Execution Profile](), recorded at the kernel,
pinned to `` · correlation sources:
```
Rules that keep the shape deterministic:
- Every row and every claim cites the record or the code. No citation, no finding.
- Chains and destinations are copied from the comment. Never invent a process or
a destination, and never repair a defanged name into a live link.
- Counts you quote must equal the counts the comment renders.
- If the record is missing, pending, or stale, keep the shape, state that reason
in place of the facts, and recommend against treating the run as verified.
- The record carries no verdict. The judgment is yours, and it traces to the
record or the diff.
````
The same contract as standing repo rules — for reviewers that read `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, or a custom-instructions field. Add once; every review follows it:
```md theme={null}
# Execution record (Garnet) — reviewer instructions
Before finalizing any review of a pull request in this repository:
1. Find the PR comment containing the marker ``.
It is the kernel-recorded record of what this PR's CI executed: every
recorded execution chain and every outbound destination, per job.
2. Verify it covers the head: `` must equal the PR's
head SHA. If it doesn't, or the comment is missing or still recording, treat
the PR as having **no execution record** and say so. That is not a clean run.
3. Read the metadata blockquote for run-scope facts, then each job fold: the
`+A −R` delta, the sentence naming steps and destination counts, and the tree.
Bold lineage is the workload, italic is runner scaffolding, `→ name` leaves
are destinations.
4. Cross-check the diff. Name every destination and process the diff does not
explain, citing its chain, or state that the record is fully explained.
5. Report: what ran, the delta since the previous profiled commit, a
Destination | Chain | Explained by table, the unexplained set, and a merge or
hold recommendation. The record carries no verdict — you supply the judgment,
and never invent a chain or destination.
```
Two demo pull requests carry the same manifest-only diff and different recorded egress — useful for checking that a reviewer reads the record rather than the diff: [registry-only egress](https://github.com/garnet-labs/garnet-runtime-review-demo/pull/1) and [an install script reaching named hosts](https://github.com/garnet-labs/garnet-runtime-review-demo/pull/8).
## Custom harnesses and gates
For a reproducible decision instead of a judgment, parse the comment directly. **StampHog** — [PostHog's merge-gate agent](https://github.com/PostHog/posthog/blob/master/.github/workflows/pr-approval-agent.yml) — does exactly that in the demo repository: every static gate green, held solely on recorded egress the diff never mentions.
`garnet_runtime.py` — locates the comment, checks the pin, extracts destinations, splits by attribution. Start here, not from scratch.
The harness consuming the record end to end, posting its own decision next to Garnet's. The same harness on a [replayed upstream change](https://github.com/garnet-labs/posthog/pull/44).
Harness already locks its own output shape and gates? Keep it — carry the primitive into it instead: (1) fail closed on the three evidence states above, (2) gate on the comment's own `+A −R` delta against the previous profiled commit, (3) make every finding cite its recorded chain and destination.
Every fold's permalink has a JSON twin at the same exact selector:
```text theme={null}
https://app.garnet.ai/api/public/runs/?profile=
```
The selector is required. Wrong or missing `?profile=`, or a bare `/public/runs/` URL, returns a 404 by design — never a silent fallback. See [The public run report](/run-profile#the-public-run-report).
# Runtime verification for PR review
Source: https://docs.garnet.ai/index
Garnet records what your CI jobs ran at the kernel and every outbound connection they made, then posts the Execution Profile to the pull request.
The diff shows what changed. It doesn't show what ran.
Agents and dependencies run code you didn't write in your CI, with your secrets. Garnet records each job's execution chains — what ran, and the outbound connection each chain opened — at the kernel, on the runners you already use, and posts the record to the pull request before anyone approves.
The diff added a postinstall hook. The run reached a host the diff never names.
The **GitHub App** connects your repos · the **Garnet Action** records your workflows · the **PR comment** carries one Execution Profile per job.
Add Garnet to your CI — first Execution Profile in about five minutes.
Review agents and gates that consume the record as an input.
Garnet records the run. The policy stays yours.
# Quick start
Source: https://docs.garnet.ai/quickstart
Install the Garnet GitHub Action and get your first Execution Profile posted to a pull request in about five minutes, with no code changes required.
## What you'll get
One comment on every pull request, one fold per job, connecting each outbound destination to the process that opened it. Adding a single dependency looks like this:
The install reached the registry; the rest is the runner fetching the Action. On the next recorded commit this becomes a comparison.
The job name links to its GitHub Actions run, and **View this job's Execution Profile in Garnet →** opens the job's [public run report](/run-profile#the-public-run-report).
Pick the repos you want recorded. The App is what posts the comment.
```yaml theme={null}
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Garnet Runtime Review
uses: garnet-org/action@3d47f4a9004f7356c980a0e8d420ef5984750e3c # v2.2.0
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
# your existing build/test steps continue here
```
**Token:** [app.garnet.ai](https://app.garnet.ai) → **Settings** → **API Tokens**. Save it as the repo secret `GARNET_API_TOKEN`. It's the only secret Garnet needs — see [What Garnet sees](/run-profile#what-garnet-sees).
`pull-requests: write` is what lets Garnet post the comment. Runner support today: GitHub-hosted `ubuntu-latest`. [Pin to latest SHA](https://garnet.ai/pins)
The comment appears while jobs are still running and fills in as each finishes — one collapsed fold per recorded job.
A clean `npm install` reaches `registry.npmjs.org` and nothing else. Any other destination at install or build time is what you're looking for.
How to read the record in full: [The Execution Profile](/run-profile).
## See it live
Real recorded comments, with working public report links, on the demo repository:
Install-time egress reaches the registry and nothing else.
The record carries egress the diff never mentions.
A recorded run on a real-world repository.
Next: [The Execution Profile](/run-profile) · [Consume the Runtime Review](/consume)
## Action reference
| Input | Description | Required | Default |
| :--------------- | :----------------------------------------------------------- | :------- | :---------------------- |
| `api_token` | Garnet API token from [app.garnet.ai](https://app.garnet.ai) | Yes | — |
| `github_token` | GitHub token for PR comments | No | `${{ github.token }}` |
| `api_url` | Garnet API base URL | No | `https://api.garnet.ai` |
| `jibril_version` | Jibril sensor version (`v2.10.8` or `latest`) | No | auto |
| `debug` | Enable debug mode and upload logs as artifacts | No | `false` |
| Output | Description |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| `report_url` | Link to the run's [public run report](/run-profile#the-public-run-report). Only exact `?profile=` permalinks resolve — a bare run URL returns 404 |
| `agent_id` | Identifier for the Jibril sensor instance that recorded this run |
| `profile_result` | Reserved for the companion GitHub App and control plane — the action itself records the Execution Profile |
The action **never fails your job** — it records the run and posts the record.
| Permission | Required | Why |
| :--------------------- | :---------- | :--------------------------------------------------- |
| `contents: read` | Yes | Access workflow context and repository metadata |
| `pull-requests: write` | Recommended | Post the recorded Execution Profiles as a PR comment |
The action does not require `contents: write`, `actions: write`, or access to any repository secrets beyond the token you pass. Full model: [What Garnet sees](/run-profile#what-garnet-sees).
Floating tags like `@v2` can move after you adopt them. For supply-chain hygiene, pin to the **commit SHA** of the latest release.
The canonical SHA is always available at [garnet.ai/pins](https://garnet.ai/pins) (human-readable) and [garnet.ai/pins.txt](https://garnet.ai/pins.txt) (machine-readable). Both auto-update when a new release ships.
```yaml theme={null}
# Recommended: pinned to release commit SHA
- uses: garnet-org/action@3d47f4a9004f7356c980a0e8d420ef5984750e3c # v2.2.0
# Quick start (floating tag — tracks latest v2.x.x):
- uses: garnet-org/action@v2
```
Garnet also writes the **Garnet Execution Summary** to the GitHub Actions Job Summary automatically — the per-run, full-detail record, under three headings:
* **Workload Summary** — profile, workflow, repository, branch, commit, actor, run, and job identity.
* **Network Egress Summary** — a `Process Tree | Destinations` table, one row per distinct lineage keyed on lineage + PID + process; different PIDs never merge. Rows retain the address, every recorded name, ports, protocol, the leaf `(pid N)`, and the recorded workflow step.
* **Telemetry** — one sentence: `Network telemetry observed N unique domains, M destinations, C connections, and F flows.` Unique domains and connections pass through from the sensor; destinations and flows are derived from the record.
* The **View this job's Execution Profile in Garnet →** permalink to the run's [public run report](/run-profile#the-public-run-report).
The PR comment is the cross-job conversation surface; the Execution Summary is the per-run record, and it is written before the comment.
**Agent doesn't appear in the dashboard** — verify `GARNET_API_TOKEN` is set. Check workflow logs for the Garnet step.
**Permission denied** — Jibril attaches eBPF programs at the kernel level, which requires sudo during install. `ubuntu-latest` runners include it by default.
**No Garnet Execution Summary in the Actions run** — use the pinned SHA from [garnet.ai/pins](https://garnet.ai/pins) — the SHA must match the latest release.
**No PR comment, or the comment stays in the waiting state** — grant `pull-requests: write` in your workflow's `permissions` block. The action uses `github_token` (defaults to `${{ github.token }}`) to post comments. If the run already finished, look in the job log for the Garnet step — the sensor must start before the workload runs.
**Debug mode:**
```yaml theme={null}
- uses: garnet-org/action@3d47f4a9004f7356c980a0e8d420ef5984750e3c # v2.2.0
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
debug: true
```
# The Execution Profile
Source: https://docs.garnet.ai/run-profile
What Garnet records from your CI job, how to read the Execution Profile in the PR comment and the public run report, and how the data is handled end to end.
Every recorded job produces one **Execution Profile**. An execution chain is one path through the process tree, from the runner's root to an action — today, an outbound connection. The profile ties every chain to the commit, workflow, and job that produced it, and posts back to your PR.
## What's recorded
One record, four surfaces. [Jibril](https://jibril.garnet.ai), an eBPF sensor, runs alongside your workflow and records network and process activity at the kernel — with full lineage and timing.
| Surface | What it carries |
| :---------------------- | :---------------------------------------------------------------------- |
| **PR comment** | One comment per PR, one fold per job. Needs `pull-requests: write`. |
| **Actions Job Summary** | The Garnet Execution Summary — the per-run, full-detail record. |
| **Public run report** | A permanent permalink per job. See [below](#the-public-run-report). |
| **Dashboard** | The full interactive profile at [app.garnet.ai](https://app.garnet.ai). |
## Reading the comment
The comment records each action and the execution chain behind it.
**Execution Profiles recorded for 5 jobs, triggered by [`6e5d0d4`](https://github.com/garnet-org/runtime-review-testbed/commit/6e5d0d4cf00a92a9e1fe697efe0e41b3ae61533e)**
> *25 execution chains · 12 destinations · recorded at the kernel by Garnet · 2026-07-13 23:54:31 UTC*
▸ ci / docs-build ↗ · Run workload reached 1 destination
▸ ci / install-only ↗ · Run workload reached 1 destination
systemd
└─ hosted-compute-agent
├─ sudo
│ └─ provjobd
│ ├─ → localhost (dns resolver)
│ └─ → hosted-compute-watchdog-prod-iad-02\[.]githubapp
└─ → 140.82.113.24
View this job's Execution Profile in Garnet →
▾ ci / workload-egress ↗ · Run workload reached 5 destinations
Runner.Worker
└─ bash
└─ node
├─ dash
│ └─ node
│ ├─ dash
│ │ └─ curl
│ │ └─ → httpbin\[.]org
│ ├─ → registry.npmjs\[.]org
│ ├─ → api.garnet\[.]ai
│ └─ → github\[.]com
└─ → registry.npmjs\[.]org
▸ dns + runner substrate · 6 chains
systemd
└─ hosted-compute-agent
├─ sudo
│ └─ provjobd
│ ├─ → localhost (dns resolver)
│ └─ → hosted-compute-watchdog-prod-iad-02\[.]githubapp
└─ → 140.82.113.23
Runner.Worker
└─ bash
└─ node
├─ dash
│ └─ node
│ ├─ dash
│ │ └─ curl
│ │ └─ → localhost (dns resolver)
│ └─ → localhost (dns resolver)
└─ → localhost (dns resolver)
View this job's Execution Profile in Garnet →
A recorded comment, two folds open. workload-egress ran a workflow step that reached five destinations; lint recorded no step-attributed chain, so its row carries counts instead of a sentence.
Top to bottom:
* **Headline** — what was recorded, and the commit that triggered it.
* **Metadata line** — the run's counts, kernel provenance, and recording time, one fact per `·` segment.
* **One fold per job** — headed `workflow / job ↗`, linking to its Actions run. The rest of the row names which recorded steps reached how many destinations.
* **The tree** — only processes with recorded egress. Destinations are `→ name` leaves under the process that opened them, defanged at the final dot. Resolver chatter and unattributed runner chains sit in the nested `dns + runner substrate` fold.
* **Per-job permalink** — opens the job's [public run report](#the-public-run-report).
- A job with no egress renders one quiet line: `no outbound destinations recorded.` Before the first profile lands, the comment shows a waiting state (⏳) and fills in as jobs finish.
- The `💡 How to read this` explainer sits at the bottom, open on the first recorded result and collapsed on later updates.
- Coverage is only *N job(s) recorded on this commit* — no expected-job denominator.
- Destinations are never compressed into `×N`. A bare IP appears only when no remote name was recorded; secondary recorded names never create extra rows.
- Large comments truncate deterministically and say so: `rendered X of Y destination associations` — never silently.
- The comment omits ports, protocol, and `[pid · process]` suffixes. Those stay in the Garnet Execution Summary and the dashboard.
- Display only: hostnames are defanged (`registry.npmjs[.]org`), step names drop the runner's ordinal prefix and any unexpanded `${{ matrix.name }}` expression, and process names drop a trailing run of four or more digits (`provjobd1326539233` → `provjobd`). The record keeps every raw value.
### What changed since the last recorded commit
Once a pull request has two recorded commits, the comment stops being a snapshot and starts being a comparison against the previous profiled commit.
A recorded comparison on the demo repository. The diff added one line — a postinstall hook. The run reached a host the diff never names.
Three things change in the comment:
* **The metadata line** carries `changed since `, linked to the commit it compared against — or `no change since`, when nothing moved.
* **The job row leads with the delta** — `+1 −0` — so the left edge is the scan column, and a changed job opens by default.
* **The tree becomes a diff**, headed `@@ vs @@`. `+` is a destination or chain recorded now and not before, `−` is one recorded before and not now, unmarked lines are in both. A destination shows as both when its execution chain changed.
Only recorded commits count. A push whose jobs never recorded is not a comparison point, and the first recorded commit on a pull request has nothing to compare against, so it renders as a snapshot.
### Attribution
The tree separates your workload from the machinery around it:
| | |
| :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| **Workload** | Lineage attributed to a GitHub step below `Runner.Worker` — what your PR's code actually did. Rendered **bold**. |
| **Runner scaffolding** | The runner agent, provisioning daemons, and lineage the step rule can't attribute. Rendered *italic*, and never subtracted from the record. |
Only two exist, and the record has to prove them: *(dns resolver)* — a loopback address on port 53 — and *(instance metadata)* — `169.254.169.254`, `169.254.170.2`, `fd00:ec2::254`. Everything else renders exactly as recorded, untagged.
Typography is the machine contract for attribution: workload lineage is **bold** (``), scaffolding is *italic* (``) — see [Consume the Runtime Review](/consume). Attribution context, not trust.
## One comment per PR
One sticky comment, updated in place on every push and as each job's profile lands, pinned to the head commit it records. The Action never fails your job — it records the run.
## What Garnet sees
The only secret Garnet uses is the API token you pass it. It never reads your other secrets, and never writes to your repo.
* **Metadata, not contents** — an Execution Profile carries execution chains and outbound domains, IPs, ports, and protocols, not your source or your secrets.
* **Observe-only** — Jibril reads syscall-level events. Every eBPF program is checked by the kernel verifier before it loads and runs sandboxed.
* **No pipeline changes** — no sidecars, no proxies, no reordering of your steps.
Attaching eBPF programs requires sudo during install. `ubuntu-latest` runners include it by default. Sudo loads the sensor — it doesn't touch your build.
Store your `GARNET_API_TOKEN` in CI secrets or a vault — never commit or log it in plain text.
Garnet is **SOC 2 Type II**. Controls, subprocessors, and security documentation live in the [Trust Center](https://app.vanta.com/garnet.ai/trust).
## The public run report
Every job's permalink opens a permanent report page:
```text theme={null}
https://app.garnet.ai/public/runs/?profile=
```
The report behind the comparison above — the same record with addresses, ports, protocols, and the step each destination belongs to.
Share it in a PR thread or an incident channel to point teammates at the exact run — or use **Share** on the report page.
The URL fails closed by design:
* `?profile=` is required and exact. Wrong or missing selector → 404, never a silent fallback. The comment and the Garnet Execution Summary carry it for you.
* There is no run index. Bare `/public/runs/` — or a `?job=` query — is the same 404.
* Private, revoked, unconsented, or no-longer-public records return the same 404. Publication needs explicit consent plus backend-confirmed public repo visibility, rechecked on every visit.
The same record is machine-readable JSON at `app.garnet.ai/api/public/runs/?profile=` — same selector rules. See [Consume the Runtime Review](/consume).
### README badge
A published Execution Profile can back a **Garnet Runtime Profiled** badge — it says the run was recorded, links to the report, and carries no verdict:
```markdown theme={null}
[](https://app.garnet.ai/public/runs/?profile=)
```
Both ids are in the report's URL. A badge link without `?profile=` is the same 404 as any other bare run URL.
Next: [Consume the Runtime Review](/consume)