DOCS Guardrails · written from the claims ledger and threat model

What AdRails guardrails do, and don't, prevent

AdRails refuses the changes it is asked to make when they break your policy: budgets outside the band, plans that were reused or expired, commits while the guard heartbeat is stale, and writes to the cap file itself. The rails are enforced in code, not prompts. It does not bound what Google delivers, and an agent with shell access as the same OS user can bypass it.

Last reviewed with AdRails 0.2.2

Prevents

Each of these is asserted by the offline selftest suite recorded in the claims ledger.

  • Budgets outside your band. A daily budget above max_daily_budget_usd or below the minimum is refused with outside guard range, and the state file is left untouched. claims ledger rows 43, 57, 72
  • Tools writing your caps. adsops_set with a guards block is an error; the guards in the state file are unchanged after every applied write. claims ledger rows 6, 52
  • Reused, changed or expired plans. With require_plan_id on, a commit must quote a fresh single-use plan ID. Missing, mismatched, replayed and expired IDs are all refused. claims ledger rows 11, 40, 66
  • Spend increases while nobody is watching. A change that adds exposure is refused while the guard heartbeat is older than max_guard_age_min; refreshing the heartbeat lets the same plan through. claims ledger rows 12, 39, 64
  • Anything Google rejects. The identical payload is sent in validate-only mode first, so a failed check means nothing lands. claims ledger rows 36, 41
  • Blind retries. An ambiguous commit failure is reconciled against live state and reported as landed or not landed, with no automatic retry. claims ledger row 78
  • The guard doing anything but pausing. It never enables, raises a budget or edits keywords; its only mutate service is the campaign status. claims ledger rows 47, 48
  • Acting on someone else's edit. A dirty or foreign-modified state file blocks both adsops_apply and adsops_set, and commits stay off until you enable them. claims ledger rows 23, 38, 75

Does NOT prevent

Disclosed in the README and deploy/GUARD.md, or recorded as residual risk in the threat model.

  • Overspend. Caps bound what AdRails submits, not what Google delivers: up to 2x the average daily budget on a given day, with reporting minutes to hours behind. It is a threshold with automatic pause attempts, not a spend ceiling. claims ledger rows 13–15
  • A guard that is not running. The check only acts when it is scheduled and the machine is awake. Sleep and API outages mean nothing is watching, while live spend can continue. claims ledger row 16
  • An agent with the same OS identity. Shell access as the same user means it can edit the policy, config, plan ledger or heartbeat, run modified code, or call Google directly with your credentials. Clean git proves a file is tracked and unchanged, not that a human approved it. claims ledger row 17 · threat model §2.2, §3
  • Prompt injection or a bad-but-permitted change. An injected agent can obtain its own plan ID and make any mutation you allow. A plan ID proves neither human review nor benign intent. threat model §2.1
  • Harmful negatives. A wrong negative keyword suppresses traffic you wanted, negatives are append-only here, and they are never counted as exposure increases. claims ledger row 18 · threat model §2.11
  • Atomicity or exactly-once execution. Multi-service changes are not atomic, an ambiguous outcome can stay unknown for a while, and validation failures still consume the plan ID. threat model §2.6, §3
  • Other writers. Local locks cannot control other hosts, another person in the Ads UI, or a direct API client outside AdRails. threat model §2.5, §3
  • Disclosure to your model provider. Self-hosting does not stop the advertising data AdRails returns from reaching your MCP client or model provider. threat model §3
  • Your account and your spend. You own both. Keep platform-side billing limits as the real ceiling. claims ledger row 19
Off by default, and worth turning on. In the current tools the policy file is optional, require_plan_id defaults to false, and max_guard_age_min defaults to off. Most of the protections above only exist once you write a policy and switch those on. Requiring the policy file and failing closed on exposure increases are listed as Phase 0 fixes in the threat model, not shipped defaults. threat model §2.1–§2.5, §5 item 1
How the caps work

A file, a check, and a pause.

Three mechanisms carry the weight. None of them is a prompt asking the model to behave.

01

A policy file the tools cannot write

Caps, end date, daily and lifetime pause thresholds, enable permission and protected terms live in one policy file per campaign. AdRails only reads it: no tool writes caps, and adsops_set refuses guards and allow_enable outright. If the state file also carries caps, the policy file wins and adsops_status says which one is in force.

02

Google validates before any commit

Every write is planned as a diff against live state, bound to a single-use, expiring plan ID, then submitted to Google in validate-only mode first. The real commit follows only if that passes, and a commit that fails ambiguously is reconciled against live state rather than retried.

03

The guard can only pause

A scheduled, pause-only worker compares spend with the numbers you wrote and issues a pause when a daily or lifetime threshold is crossed. It cannot enable a campaign, raise a budget or edit a keyword, and a stale heartbeat blocks spend-increasing changes instead of silently permitting them.

The privilege boundary

The open limitation, stated plainly.

Everything above is a logical boundary inside one process. It is not yet a filesystem or OS boundary.

Today

If the agent runs as the same OS user that runs the executor, it can edit the policy, config, plan ledger or heartbeat, import modified code, or skip AdRails and call the Google Ads API directly with credentials it can read. Git cleanliness does not close this: adsops_set commits the agent's own changes. The threat model rates this Critical and lists it as residual risk, not as something the rails already solve.

threat model §2.2 · claims ledger row 17

The planned hardened install

Run the executor under a separate principal whose credentials the agent cannot read, with administrator-owned policy, config and code mounted read-only, and protected storage for the plan ledger and heartbeat. Restrict the Ads account's own permissions and keep direct credential access out of the agent's environment. Agent root or admin access defeats local enforcement in any design. This is a Phase 0 fix with acceptance tests, not a shipped feature.

threat model §2.2 fix · §5 item 1
FAQ

Straight answers.

What do AdRails guardrails actually prevent?

AdRails refuses the changes it is asked to make when they break your policy: daily budgets outside the min/max band, a plan ID that was reused, changed or expired, a commit while the guard heartbeat is stale if the change adds spend exposure, and any AdRails tool attempt to write the policy file's caps. Google's validate-only check runs first, so nothing lands if it fails.

Does AdRails stop an agent from overspending?

No. AdRails bounds what it submits and issues pause attempts at your daily and lifetime thresholds. It does not bound what Google delivers: Google can deliver up to 2x the average daily budget on a given day, and the spend figures AdRails reads are minutes to hours behind delivery. It is a threshold with automatic pause attempts, not a spend ceiling.

Can an agent bypass AdRails?

Yes, in the default install. An agent with shell access as the same OS user can edit the policy, config, plan ledger or heartbeat, run modified code, or call Google directly with the same credentials. Clean git proves a file is tracked and unchanged, not that anyone approved it. The planned hardening is to run the executor under a separate principal whose credentials the agent cannot read, with administrator-owned read-only policy, config and code.

Can AdRails tools change my caps?

No. Caps live in a policy file that no AdRails tool writes, and adsops_set refuses guards and allow_enable outright. In the current tools the policy file is optional and the strict checks (require_plan_id, max_guard_age_min) are off by default, so you have to turn them on. Requiring the policy file is a listed Phase 0 fix in the threat model, not yet a default.

Read the rails.
Then decide.

Open source, Apache-2.0. Your keys, your caps, and the limits written down.