SECURITY & CONTROLS AdRails 0.8.0

Let agents touch ad spend — with rails on the money

AdRails is an open-source MCP server that gives an AI agent a gated path to Google Ads: deny-by-default roles, HMAC-signed human approval for risky plans, a hash-linked audit chain, one-command rollback, drift detection and an aggregate spend cap. It runs on your machine with your credentials, not as a hosted proxy.

Last reviewed 2026-11-06 with AdRails 0.8.0

RAILS ON THE MONEY

Why do AI agents need rails before they touch ad spend?

An agent holding a Google Ads credential can raise a budget in one tool call. The rails turn that call into a diff you can read, an approval you can sign, and a commit you can undo.

One door, no side stairs

AdRails routes every account change through one sequence: a committed desired-state file, then adsops_plan for a read-only diff, then adsops_apply — validate-only first — committing with a single-use, expiring plan_id. There is no second write path.

Spend caps live in a policy file the tools cannot write. The guard fails closed on exposure increases when its heartbeat is stale, retries are never blind, every tool call is metered, and secrets are scrubbed before anything is written. selftest: 2,704 checks, ALL PASS 2026-11-06

Most controls are off until you enable them

RBAC, approvals and fleet caps are configuration, not defaults. The audit chain is the exception: it records mutating calls from the first run. selftest: auditchain

A sane order is read-only diagnostics first, then RBAC, then approvals for exposure-increasing plans, then an aggregate cap across accounts. Read the tool surface before you grant anything: all 66 MCP tools, and how hosted agent platforms compare.

ACCESS CONTROL

Who is allowed to call which tool?

Role-based access per tool, deny-by-default once enabled. A refused call has zero side effects — the agent learns it was blocked and nothing else changes.

Five roles, assigned per principal

  • viewer. Read-only reach: diagnostics, audits, history, briefs.
  • operator. Agents that propose: plan a change, request approval, prepare patches.
  • approver. The human who records decisions on signed tokens.
  • guard. The spend guard, which can pause a campaign and never enables one.
  • admin. Configuration and the enterprise tooling.

adsops_whoami reports the server identity, the principal, its roles, the approval mode, the audit chain path and whether a guard heartbeat is configured. It is the first tool a reviewer should run. selftest: rbac, enterprise_tools

Why it matters more than a prompt

Instructions in a system prompt are a request. RBAC is a check. Once enabled, a tool call outside the principal's roles is denied at the server, so a prompt-injected agent talking itself into adsops_apply gets a refusal instead of a budget change. selftest: rbac

Refusals are still appended to the audit chain, so a reviewer can see what the agent tried, not just what it achieved.

01

Deny by default

Ungranted tools are refused, not warned about. selftest: rbac

02

Zero side effects

A refused call writes nothing to the account, the state file or the plan. selftest: rbac

03

Per-tool, not per-server

An agent can read diagnostics without ever holding an apply. selftest: rbac

04

Identity you can query

Roles and mode are reported by a tool, not buried in config. selftest: enterprise_tools

HUMAN APPROVAL

What happens when an agent wants to raise a budget?

An agent can request that a plan be approved. It cannot approve one — and it never holds the signing secret.

The queue

The agent calls adsops_approval_request for a plan_id. The approver receives HMAC-SHA256-signed approve and reject tokens that expire — 24 hours by default. adsops_approval_decide records the decision, and forged, expired, reused or cross-plan tokens are refused. The secret is never written to disk and the queue file is 0600. selftest: approvals, enterprise_tools

Modes are off, exposure (risky changes only) and all. adsops_approvals lists requests newest first, filtered by status.

Example config

"rbac": { "enabled": true, "principals": {

"agent:claude-code": ["operator"],

"human:ops-lead": ["approver", "guard"],

"agent:auditor": ["viewer"] } },

"approvals": { "mode": "exposure" },

"fleet_caps": { ... }

Abbreviated example: set the approval mode, then assign roles per principal. Everything except the audit chain ships off.

AUDIT CHAIN

Can you prove what the agent actually did?

Every mutating call — including refused ones — is appended to a SHA-256 hash-linked chain with secrets redacted.

Tamper-evident by construction

  • Hash-linked entries. Each mutating call is appended as a SHA-256 hash-linked entry with secrets redacted. selftest: auditchain
  • Verification catches edits. adsops_audit_verify returns ok, the entry count and the first bad entry if an entry was edited, deleted or reordered. selftest: auditchain
  • Truncation is detectable. Publish the head digest it returns; cutting the tail later stops matching. selftest: auditchain
  • Refusals are recorded. A blocked call lands in the chain next to successful ones, so attempts are visible. selftest: auditchain

Reading it on a Monday

adsops_audit_tail returns the last N entries, newest first, secrets redacted. Put it next to spend in the same daily review: the change log and the cost line are more useful together than apart.

The audit chain is the one enterprise control on by default. Everything else is opt-in config.

ROLLBACK & DRIFT

What if the change was wrong — or nobody made it through AdRails at all?

Two different problems: undo a change the rails allowed, and find a change the rails never saw.

One-command rollback

adsops_rollback rebuilds the desired state as of any earlier git commit. Policy caps, account and campaign IDs are never reverted, and negatives are never removed — the rail is add-only. The rebuilt state then goes through plan and apply like any other change, so nothing skips validation, and commit refs are argument-injection-safe. selftest: rollback

adsops_history shows every change to the state file, newest first, with the reason recorded at commit time.

Drift detection

adsops_drift reads Google Ads change history for the last 30 days and flags edits made outside AdRails: the web UI, external scripts, auto-applied recommendations. Budget, status and bidding changes are highlighted first, because those are the ones that move money. selftest: change_history

An account is only as controlled as its least-governed write path. Drift tells you where the other paths are.

AGGREGATE CAPS

Is there a ceiling across every account, not just per campaign?

Per-campaign caps are not a budget. adsops_fleet checks one aggregate cap across every configured account and campaign — and assumes platforms may deliver up to 2x the daily budget.

What the cap assumes

  • 2x delivery. The aggregate check assumes platforms may deliver up to twice the daily budget, so the ceiling holds on a bad day rather than an average one. selftest: fleet
  • Lower exposure always passes. Proposals that reduce exposure are never blocked by the cap. selftest: fleet
  • No cross-currency sums. The fleet view never sums across currencies. selftest: fleet
  • Reallocation cannot raise the total. adsops_realloc_suggest moves budget from weak to budget-limited campaigns inside each policy's caps; the total never rises. selftest: budget_realloc

Paired with the guard

adsops_guard pauses a campaign when a daily or lifetime cap is crossed, and never enables anything — pausing is always permitted. When the guard heartbeat is stale, exposure increases fail closed instead of proceeding. selftest: 2,704 checks, ALL PASS 2026-11-06

The 2x assumption is an input to the cap, not a guarantee. AdRails does not bound what Google delivers.

SUPPLY CHAIN

How do you verify the code that touches the account?

A reviewer should not have to trust an install. adsops_sbom emits a CycloneDX 1.5 SBOM and a SHA-256 manifest of every package file, so the running code can be matched against the released code.

What you get in the manifest

  • CycloneDX 1.5 SBOM. Machine-readable bill of materials for the install. selftest: sbom
  • Per-file SHA-256. A hash for every package file, so a locally edited file stops matching. selftest: sbom
  • Apache-2.0 source. Read it, or diff it against the manifest.
  • Offline selftest. python3 -m adsops --selftest runs 2,704 checks with no network. selftest: 2,704 checks, ALL PASS 2026-11-06

Why a small dependency surface helps

AdRails runs on Python 3.11+ and the standard library, with one optional google-auth import. That is a short list to review, and a short list to keep current — most supply-chain reviews on agent tooling drown in transitive packages.

Reviewers can pair the SBOM with the audit chain: one proves which code ran, the other records what it did. Source on GitHub.

YOUR MACHINE, YOUR CREDENTIALS

Does AdRails sit between you and Google as a hosted proxy?

No. The MCP server runs on your machine with your credentials. Nothing is proxied through a service we operate.

Where it runs

  • Local process. Open-source Apache-2.0 MCP server on your host, under your OS user. selftest: enterprise_tools
  • Not a hosted proxy. Your Google Ads credentials stay inside the process you started. selftest: enterprise_tools
  • Client-agnostic. Works with Claude Code, Claude Desktop, Cursor, Grok CLI and other MCP clients; 82 tools. selftest: 2,704 checks, ALL PASS 2026-11-06
  • PII stays home. Offline and enhanced conversion payloads are normalised and SHA-256 hashed locally; raw PII never leaves the machine. selftest: offline_conv
  • SSRF-safe fetcher. Resolves hosts and refuses private, loopback, link-local and reserved addresses on every redirect hop. selftest: aeo_tools

The policy question is yours to close

Whether a hosted third-party proxy is acceptable under your Google Ads API terms is a question for your own review with Google — AdRails is not that architecture. The process runs where your credentials already live, your reviewer can read the source, and the selftest runs offline.

Platform today: Google Ads. ChatGPT Ads is supported since AdRails 0.5.0: read paths, spend reads, the guard and validation are verified on a live account; the first live writes are still pending.

HONEST LIMITS

What does AdRails not prevent?

Two limits are documented on purpose. A controls page with no "does not prevent" column is a sales page, not a security page.

Prevents

  • Un-granted tool calls. With RBAC on, a call outside the principal's roles is refused with zero side effects. selftest: rbac
  • Unapproved risky plans. Exposure changes wait for an HMAC-signed human decision; forged, expired, reused or cross-plan tokens are refused. selftest: approvals
  • Silent history edits. Edited, deleted or reordered audit entries are detected, and a published head digest catches truncation. selftest: auditchain
  • Aggregate overspend. The fleet cap assumes 2x delivery, and changes that lower exposure always pass. selftest: fleet
  • Unnoticed outside edits. Change history flags budget, status and bidding edits made in the UI, by scripts or by auto-applied recommendations. selftest: change_history
  • Negative removal on rollback. Rollback rebuilds state as of an earlier commit and never removes negatives. selftest: rollback

Does not prevent

  • Platform 2x delivery. AdRails does not bound what Google delivers; a daily budget can be spent up to 2x. The aggregate cap assumes it rather than preventing it. selftest: fleet
  • Shell-level bypass. An agent with shell access as the same OS user can bypass local rails unless you run the hardened install under a separate OS user. The privilege-boundary item is still open in the threat model.
  • Your own policy choices. Approvals ship off. Leaving them off is a decision the rails will respect, quietly.

If an agent has shell access on the same host, use the hardened install with a separate OS user, and treat the approval secret and the Google Ads credential as separate privileges held by separate people.

START

Where should a security review start?

Run the selftest offline, run adsops_whoami, then enable one control at a time and watch the chain fill up.

Free open-source core, Apache-2.0, no subscriptions. Optional paid services in sats via Lightning (L402) are planned, not live.

FAQ

Straight answers.

Can an AI agent change budgets without a human approving?

With approvals mode set to exposure or all, an agent can only request. It calls adsops_approval_request for a plan_id, and an approver records a decision using an HMAC-SHA256-signed token that expires (24 hours by default). Forged, expired, reused or cross-plan tokens are refused. Approvals ship off by default, so turn the mode on before an agent holds credentials.

What happens if someone edits the audit log?

Every mutating call — including refused ones — is appended to a SHA-256 hash-linked chain with secrets redacted. adsops_audit_verify detects edited, deleted or reordered entries and returns the first bad entry plus a head digest. Publish that head digest somewhere outside the machine and later truncation becomes detectable as well.

Does AdRails store or proxy my Google Ads credentials?

No hosted proxy. The MCP server runs on your machine with your credentials, on Python 3.11+ with the standard library and one optional google-auth import. Approval signing secrets are never written to disk and the approval queue file is 0600. Offline conversion payloads are normalised and SHA-256 hashed locally, so raw PII never leaves the machine.

What does AdRails not protect against?

Two documented limits. AdRails cannot bound what Google delivers: a daily budget may be spent up to 2x, which the aggregate cap assumes. And an agent with shell access as the same OS user can bypass local rails unless you run the hardened install under a separate OS user — the privilege-boundary item is still open in the threat model.

Does the audit chain record calls that were refused?

Yes. Refused calls land in the chain alongside successful ones, so a reviewer sees attempted actions next to completed ones. The chain is the only enterprise control on by default; RBAC, approvals and fleet caps are opt-in config. adsops_audit_tail returns the last N entries, newest first, with secrets redacted.

How do I verify the code that is actually running?

adsops_sbom emits a CycloneDX 1.5 SBOM plus a SHA-256 manifest of every package file, so the running install can be matched against the released code. The source is Apache-2.0, and python3 -m adsops --selftest runs 2,704 checks offline with no network access, so the review does not depend on our servers.