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_usdor below the minimum is refused withoutside guard range, and the state file is left untouched. claims ledger rows 43, 57, 72 - Tools writing your caps.
adsops_setwith aguardsblock 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_idon, 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_applyandadsops_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
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
A file, a check, and a pause.
Three mechanisms carry the weight. None of them is a prompt asking the model to behave.
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.
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.
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 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.
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 1Straight 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.