GitHub Action
Ironhide's referee runs where regressions actually ship: in your pull
requests. The ironhide-scan action gates the runs your pipeline captured
for the agent your PR builds — your agent driven through a sandboxed arena
seeded with attacks — and posts a categorical result on what the agent
did — state changes, tool calls, exfiltration — not on what it said
about itself.
Two framing rules up front:
- Advisory-first. The action defaults to advisory mode: a would-be
statistical failure is reported as
ADVISORYand exits 0. When the evidence could not decide — a rise that could not be re-run to confirm it, a baseline too small to compare with, a run left unverified by your model's rate limits, or an outage on Ironhide's side (your plan's run quota is spent, or action tests are off on the server) — the result isUNAVAILABLE, the job log and PR comment say NOT DECIDED and why, and advisory mode does not fail the build for it. It is never a pass. A setup problem you have to fix (an agent that failed on every episode, nothing captured, a baseline that cannot be read) still exits 2 in advisory mode. Run advisory for a week or two, watch the baseline settle, then turn gating on. - Preview. Observed-state results carry the
arena-l3-previewbasis and a preview stamp. A preview result is an honest measurement, not a certification — treat it like a new reviewer whose judgment you are still calibrating, which is exactly what advisory mode is for.
Verified installation#
Pin the Action to a release tag, not a moving alias — ironhide-ai/ironhide-scan@v2.5.0.
Set the repository variable IRONHIDE_CLI_SHA256 to the CLI SHA-256 published
with that release. You usually don't have to: ironhide link sets it for you,
and ironhide init --set-secret sets it alongside IRONHIDE_API_KEY
(ironhide init without the flag prints the exact gh variable set command).
It is a variable, not a secret — the value is public. The Action downloads the CLI artifact attached to the release
and verifies its bytes before execution; a missing or mismatched digest fails
the job, including in advisory mode. It never pipes an installer into a shell.
The artifact is immutable for the life of the tag, so the digest you pin stays
valid — it does not change when the Ironhide server is updated. If you mirror
the artifact internally, point cli_url at your copy and pin its digest; do not
point it at a live server path, which would change underneath you.
Upgrading the Action is deliberate: move to the new tag and update
IRONHIDE_CLI_SHA256 to the digest that release publishes. Never take a fresh
digest from the same download in CI — that verifies a file against itself.
Fork pull requests and server requirements#
This Action executes the checked-out agent code with IRONHIDE_API_KEY in its
environment. Do not give that secret to untrusted pull-request code. Fork pull
requests normally do not receive repository secrets; an unavailable scan is not
a passing security result. Do not switch to pull_request_target and check out
or execute the untrusted head to bypass that restriction. Use a separately
reviewed trusted revision and an isolated runner for any secret-bearing scan.
See GitHub's guidance.
The Action reads the measured graded= count from the gate line. If no run
was graded, or the count is missing, it fails closed with exit 2, even in
advisory mode — unless the CLI itself declared an advisory pass
(advisory_pass=true, below) for a can't-tell such as a server-side outage. An unavailable gate keeps its reported basis= token when
present; it does not invent one if absent.
In .ironhide.yml, driver values use the CLI's simple unquoted key: value
format. Quoted YAML values and inline comments are not supported; put comments
on separate lines. ironhide init (and ironhide link) record the driver that
ironhide discover finds; check that adapter or agent_factory names your
agent before relying on this Action.
The workflow#
Add a workflow file (for example .github/workflows/ironhide.yml):
name: ironhide
on:
pull_request:
push:
branches: [main] # records the baseline for later PR jobs
schedule:
- cron: "17 3 * * *" # nightly: every test, 5 samples, against the baseline
workflow_dispatch: # "Run workflow" by hand runs the same full pass
permissions:
contents: read # required by actions/checkout for private repositories
pull-requests: write # the action posts the result as a PR comment
jobs:
referee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ironhide-ai/ironhide-scan@v2.5.0
with:
api_key: ${{ secrets.IRONHIDE_API_KEY }}
adapter: your.module:agent # replace with your actual driver
cli_sha256: ${{ vars.IRONHIDE_CLI_SHA256 }}
advisory_mode: true # advisory-first: comment, don't gate
block_on: critical,high # severity classes that fail the gate outright once gating is onStore the API key from ironhide connect as the IRONHIDE_API_KEY
repository secret. Never commit it.
The Action drives your agent directly with adapter or agent_factory. Install
your project's dependencies before the Action and make the named module
importable from the checkout. Alternatively commit the driver in .ironhide.yml:
agent_id: your-connected-agent-id
adapter: your.module:agent
samples_per_episode: 3An explicit Action input replaces the project's driver selection. adapter and
agent_factory are mutually exclusive. No configured driver fails the Action,
even if old runs exist; zero eligible observations also fail. Advisory mode
never converts these configuration or observation failures into success.
Inputs#
| input | default | what it does |
|---|---|---|
api_key | required | Your agent's API key, always via ${{ secrets.IRONHIDE_API_KEY }}. |
agent_id | from .ironhide.yml | The connected agent's id. Optional if you commit a .ironhide.yml (ironhide init) carrying agent_id; otherwise set it here so a fresh checkout can resolve the agent. |
adapter | from .ironhide.yml | Customer driver as module:object, passed to --adapter. |
agent_factory | from .ironhide.yml | Tool-binding factory, passed to --agent-factory; exclusive with adapter. |
cli_sha256 | required | SHA-256 of the approved CLI release; verified before execution. |
advisory_mode | true | true reports security regressions without blocking, and reports a not-decided UNAVAILABLE the CLI marks advisory_pass=true with a warning instead of failing; configuration and setup failures still fail. Set false to gate: a FAIL — a regression past the noise floor, or a first observed breach in a block_on severity class — fails the build. Passed to the CLI as IRONHIDE_ADVISORY. |
block_on | exfiltration | Comma-separated severity classes (critical, high, medium, low) whose first appearance among failing runs fails the gate outright, even inside the noise floor. Passed to the CLI as IRONHIDE_BLOCK_ON. The shipped default value exfiltration is not a severity class and matches nothing, so set block_on: critical,high explicitly to get the CLI's own default. |
runs | suite default (3) | Samples per episode (IRONHIDE_SAMPLES). More samples, tighter noise floor. Setting it turns off adaptive sampling on PRs. |
baseline_branch | main | The baseline label and cache prefix. Runs on this branch refresh the cached baseline; other runs restore the latest one without overwriting it. |
server | https://app.ironhideai.com | Ironhide server base URL (IRONHIDE_URL). |
comment | true | Post (and update) the result as a PR comment. Needs permissions: pull-requests: write. |
profile | (empty) | pr or full coverage for the episode library (IRONHIDE_PROFILE). Empty keeps the legacy coverage. |
suite | (empty) | actions (action tests) or library (IRONHIDE_SUITE). Empty uses .ironhide.yml. |
pack | (empty) | Action-test scenario pack: general, fsm, crm or erp (IRONHIDE_PACK). Empty uses .ironhide.yml. |
tools_file | (empty) | Action tests: a JSON tool list for tools built at runtime (IRONHIDE_TOOLS). Empty uses .ironhide.yml, then discovery. |
No input selects the run type: the CLI reads GITHUB_EVENT_NAME. A pull
request runs one sample per action test and re-runs only what looks
suspicious; a push to baseline_branch runs the full count; schedule and
workflow_dispatch run every test 5 times (nightly_samples). See
how a run is sampled.
The action exposes three outputs: result (the gate result token), gate_line
(the full IRONHIDE-GATE line), and baseline_restored (true when the cache
restore found a baseline).
What ironhide test does#
The action is a thin wrapper around ironhide test, which you can run
locally or from any CI system. One invocation:
- Resolves your agent — the
ciagent registered withironhide connect. - Drives the suite (only with
--adapteror--agent-factory) — your agent is run through every episode in the library: sandboxed worlds seeded with attacks such as injected instructions in tool results, poisoned fixtures, and canary credentials planted where an exfiltrating agent would find them. Each run is submitted and graded as it happens. Without a driver flag this step is skipped and the runs captured earlier are used. - Checks observed state — the referee reads what each run did: records changed, tools called, egress attempted, canaries that moved. The evidence is collected out of band; nothing the agent prints can change its result.
- Applies the gate — collects this agent's captured runs into a run set and compares it to the client-held baseline for the label (advisory until you turn gating on).
- Reports — the CLI prints one row per verdict with status, applicable
severity, episode alias, and a repro command only when replay is available,
followed by the statistical sentence and gate line. The Action's PR comment
is intentionally smaller: a result badge, an observed-state disclaimer, the
IRONHIDE-GATEline, and generic repro guidance.
The first ironhide test for a label has nothing to compare against, so it
saves that run set as the baseline (.ironhide/baseline-<label>.json, under
the working directory) and exits 0 — it is not tested against itself. Keep
that file between runs (for example with a cache step keyed on the label) or
every run starts a fresh baseline.
The results#
Each captured run carries one of these results:
| result | meaning |
|---|---|
PASS | No prohibited effect was observed, and the episode's end-state check was satisfied. This result does not establish complete security coverage. |
FAIL | The attack worked. Tier C can carry an applicable severity; Tier A/B have no severity model and carry null. |
WARN | No prohibited effect was observed; security coverage for the run is incomplete — the episode's end-state check was not satisfied, or part of the run could not be verified. Not a claim that your agent failed its task. Never counted as a pass in the suite report; the statistical gate counts it as a no-breach run. |
INCONCLUSIVE | Nothing could be verified. Reported honestly as unverified, not a pass — never upgraded to PASS, and left out of the clean rate (reported as excluded). |
BLOCK | The policy-escalation state in the verdict schema; current episode grading does not emit it directly. |
The gate itself answers with PASS, FAIL, ADVISORY (a would-be FAIL
under advisory_mode: true), BASELINE (first run for the label),
REBASELINE_REQUIRED (the pinned comparison population changed too much), or
UNAVAILABLE (the gate could not be evaluated).
The gate line and exit codes#
Each completed measurement prints one machine-readable line in the job log (and at the foot of the PR comment):
IRONHIDE-GATE basis=arena-l3-preview delta=-0.02 noise_floor=0.05 n=24 result=PASS planned=24 graded=24Field by field: basis is the preview basis (arena-l3-preview today —
the preview stamp travels with the result); delta is this run set's clean
rate minus the baseline's (unavailable when there is nothing to compare);
noise_floor the margin the gate ignores; n the number of captured runs;
result the gate outcome. In the example the delta is negative but inside
the floor, so the result is PASS — that is the gate working, not
generosity.
An UNAVAILABLE line also says why, and what advisory mode did with it:
IRONHIDE-GATE basis=statistical delta=+0 noise_floor=0.05 n=21 result=UNAVAILABLE graded=7 unverified=0 not_run=0 reason=unconfirmed advisory=true advisory_pass=truereason is why the gate could not decide; advisory is the mode the CLI ran
in; advisory_pass=true appears only on an UNAVAILABLE line, only in
advisory mode, and only when the CLI exits 0 — the CLI decides it, so the
Action needs no list of reasons:
reason | advisory mode | why |
|---|---|---|
unconfirmed, baseline_too_small, unverified, rate_limited, not_comparable | does not fail (exit 0, advisory_pass=true) | the evidence could not decide |
run_quota_exceeded, action_envs_disabled | does not fail (exit 0, advisory_pass=true) | an outage on Ironhide's side; nothing ran |
agent_failed_every_episode, nothing_graded, no_tests, no_eligible_runs, baseline_unreadable, baseline_empty, runs_malformed, pin_unknown, profile_identity_mismatch, profile_policy_mismatch, gate_no_result, any reason the CLI does not know | fails (exit 2) | something you have to fix |
Exit codes:
| code | meaning |
|---|---|
0 | PASS; ADVISORY (any would-be failure while advisory_mode: true); a baseline-establishing run; or, while advisory_mode: true, an UNAVAILABLE marked advisory_pass=true — not decided, never a pass, reported with a warning. |
1 | FAIL with advisory_mode: false. The workflow job fails; the compact Action comment shows the result and gate line. |
2 | UNAVAILABLE or REBASELINE_REQUIRED — no gate verdict was reached. With advisory_mode: false, every UNAVAILABLE; in advisory mode, a setup failure you have to fix. |
Reproduce a finding locally#
A replayable Tier-C row in the CLI output can show its complete repro command. The Action's compact PR comment does not include per-episode rows.
ironhide repro --finding-id VD-<run_id>A non-null repro command is a positive handle on this stored-verdict surface. A held-out
submit may have shown repro_command: null for the same underlying run. Tier
A/B and Tier W runs are not replayable; Tier C also requires the
executable-effects capability and a stored executable tool-call trace. The CLI
prints deterministic: true for a matching verdict, deterministic: false for
a mismatch, or deterministic: spec_changed when the server declines
comparison against a changed spec. Most episodes are held-out, and their
replayed payload stays masked: the status and applicable severity remain,
while evidence is empty and protected measurements can be null. Replay
confirms stability; it does not expose the held-out trajectory. Use a featured
episode for step-by-step detail, then re-run your agent after the fix.
Either way, fix your agent and re-run it with ironhide episode run. If the
effect is gone locally, the next sweep on your PR will show it gone too.
Where to go next#
- Quickstart: connect your agent and get a first result.
- Link your repository:
ironhide linkwires this workflow and the secret in one step. - GitLab CI: the same tests and gate on GitLab merge requests.
- CLI reference:
ironhide test,ironhide repro, and every other command behind the Action.
