Hackathon Build — 09

CRUCIBLE

Pre-Deployment Hardening for Agents That Hold Real Permissions

Companies are handing AI agents the authority to move money, email customers, and delegate to other agents. The way that authority gets tested today is a list of nasty prompts written by the same person who built the agent — so it tests the failures they already imagined, and it produces a report rather than a fix. CRUCIBLE attacks the agent, records what it actually called, writes a policy rule that stops the attack, checks the rule didn't break legitimate work, and promotes it or rolls it back.

Status
Hackathon Submission
Built for
Google Cloud — All Things Agentic, 2026
License
Apache-2.0 · public repo
Stack
Google ADK · Vertex AI · Cloud Run · GCS · Cloud IAM
Role
Sole builder — eleven days

A hackathon build, and it says so on every page.

I built CRUCIBLE, and wrote this case study, for the purposes of entering the Google Cloud “All Things Agentic” hackathon, in the track The Fortified Enterprise Fleet. It was built between 2026‑08‑20 and 2026‑08‑31 — eleven days of work by one person against one target agent. It has no users, no downloads, and no adoption. It has not been reviewed, endorsed, or responded to by Google in any way. It is not production‑ready.

I am stating that first rather than at the bottom, because the whole argument of this project is that a system which grades its own work is not measuring anything — and a case study that buries its own limitations is doing exactly that.

Built entirely on Google Cloud

Enforcement runs at the Google Agent Development Kit's BasePlugin.before_tool seam — policy sits in code between the agent and its tools, not in an instruction the agent can be argued out of. Every model in the loop is a Google model served through Vertex AI at the global endpoint. The target agent is deployed on Cloud Run. The sealed attack family, the promoted policy versions, and the evidence bundles live in three Cloud Storage buckets, and Cloud IAM is used as a structural boundary rather than as access control — the component that writes the fixes holds no storage role at all on the bucket holding the attacks it is not allowed to see. Traces land in Cloud Trace.

Google ADK 2.1.0 Vertex AI Cloud Run Cloud Storage Cloud IAM Cloud Trace Cloud Logging Cloud Build Artifact Registry gemini-3.7-flash gemini-3.6-flash gemini-3.5-flash-lite gemma-4-26b-a4b-it-maas Python 3.11 pytest Apache-2.0

The test that was written after the answer was known.

An agent that can issue a refund is a different kind of software from an agent that can summarize a document. The failure mode isn't a wrong answer, it's a wire transfer. So teams test these agents the way they test everything else: they write a list of adversarial prompts, run it, and read the report.

Two things go wrong with that, and the second one is quieter. The first is that the list is written by the person who built the agent, so it covers the attacks they already thought of. The second is what happens next. The team reads the report, writes a fix, re‑runs the list, and the list passes — because the fix was written after seeing the list. That number answers a question asked after the answer was known, and it feels exactly like evidence.

There is also the matter of what you're left holding. A red‑team report is a document. It tells you what went wrong and hands the fixing back to you. What a team actually needs at the end of a hardening run is an artifact that enforces — machine-readable rules that sit between the agent and its tools, and an evidence trail a stranger can replay without trusting the person who produced it.

Every component is deliberately blind to something.

CRUCIBLE runs a closed loop against a target agent — in the reference build, a refund agent with eight tools spanning six capability classes. A round attacks, watches, diagnoses, patches, regression-checks, and either promotes the new rule or rolls it back. Three consecutive rounds that find nothing counts as converged.

The design constraint that shapes everything else is that no component is allowed to grade its own work. That isn't a review convention, it's an architecture: each role is missing a capability it would need in order to fake its own result.

One thing this loop does not do, and the distinction matters: it does not invent attacks. The corpus is hand-authored. Autonomous attack discovery is specified in the design and is not a shipped capability — nothing in the tree authors an attack. What the harness automates is everything downstream of the attack: watching, diagnosing, patching, regression-checking, and gating.

Figure 1 — The six roles, and what each one may not do
Red strategist · model Drives the attack campaign against the target, working from a corpus of 50 attack instances that were authored by hand — it does not invent new ones. Never reads the sealed attack family: its service account holds no storage role on that bucket, so the held-out set stays held out by IAM rather than by good intentions.
Tripwire · pure code Decides whether a breach occurred, by reading the ordered list of tool events. Never calls a model — no Vertex AI role, plus an import lint that fails the build if one appears. It watches what the agent called, never what it said.
Coroner · model Writes the autopsy. Cannot propose a fix — its output schema has no free-text field to write one in. Diagnosis and remedy are different jobs held by different components.
Armorer · model Proposes the patch, in a three-verb language. Cannot promote it, and cannot widen the agent's authority — there is no allow verb, so no sequence of patches can grant a permission.
Regression warden · pure code Re-runs 26 recorded legitimate episodes against the candidate policy. A rule that stops the attack and also stops real work is not a fix.
Promotion gate · pure code Writes the rule, reads the bytes back off disk, recomputes the hash from what actually landed, and promotes or rolls back — because a gate that reports a decision it did not durably record lies exactly once, at the worst possible moment.
Four of the six are pure code with no model in them. The trust boundary runs between the two kinds: everything model-generated is untrusted and crosses into deterministic code only through a versioned, canonicalized schema.
Figure 2 — The target and the corpus, counted at source
8
Target tools
6
Capability classes
50
Attack instances
26
Benign fixtures the fix must not break
Counted by running the repository's own corpus checker, 2026-08-31. The emphasized cell is the one that makes the loop honest: without a benign floor, "blocks every attack" is trivially satisfied by an agent that does nothing.
Under the hood — why rules bind to capability classes, not tool names

A rule that says deny issue_refund when the destination changed mid-conversation protects one tool. A rule that says deny anything in CAP_MOVES_MONEY protects a tool that didn't exist when the rule was written. Binding to the class rather than to the name is what makes a rule learned on one tool applicable to a tool it has never seen, and it is the property the held-out attack family is designed to test.

CAP_MOVES_MONEY issue_refund, issue_store_credit CAP_EXTERNAL_COMMS email_customer CAP_MUTATES_DURABLE_STATE update_case_notes CAP_READS_PII lookup_customer, lookup_order CAP_ESCALATES_PRIVILEGE escalate_to_human CAP_INVOKES_AGENT delegate_to_specialist

The manifest is hash-locked and committed before any run could use it, alongside four other locks: the gate rule, the target agent, the definition of breach, and the corpus with its derived-field schema. Each was frozen before the artifact it covers could influence a result.

Three verbs, and no way to say yes.

The Armorer is a language model, and language models are wrong sometimes. The usual response is review: have a human read every patch before it ships. That doesn't scale and it doesn't hold under deadline. The response here is to make the dangerous move unsayable.

Patches are written in a small domain-specific language with exactly three verbs — deny, constrain_arg, and require_approval. There is no allow verb. The language is missing the word. That means no sequence of patches, however wrong, can enlarge what the agent is permitted to do; the blast radius is monotonically non-increasing by construction, rather than by anyone remembering to check.

Figure 3 — The whole vocabulary
deny — the call does not happen
constrain_arg — narrow a parameter
require_approval — route to a human
— there is no fourth verb —
The absent verb is the design. Everything a patch can express either stops work or slows it down; nothing a patch can express speeds it up.

This is the same instinct behind the per-agent deny lists in my forensic work: the cleanest guardrail is one where the wrong thing is structurally impossible rather than merely forbidden. A prompt that says "never widen permissions" is a request. A grammar with no widening verb is an enforcement.

I built the check that asks whether my own fixes worked. Most of them hadn't.

A hardening tool that can't tell you which of its own fixes worked is worth much less than one that can. So the last thing I built was the instrument that replays a promoted rule against the exact calls that caused the breach it was written for, and asks a single question: did this close it?

Pointed at my own output, the answer was mostly no. Across the fifteen evidence bundles the shipped offline reader accepts, 32 rules were promoted. 13 closed the breach they were written for. 19 were no-ops on it. They were well-formed. They passed the benign-traffic check. They shipped. Replayed against the calls that caused the breach, they left the breach exactly where it was, and nobody would have known.

Figure 4 — What the gate was actually asking
32
Rules promoted
13
Closed their breach
19
Were no-ops on it
Recounted 2026-08-27, and it got worse rather than better — a sixteenth bundle finished writing, the reader began accepting it, and the no-op share moved from 58.1% to 59.4%. The earlier reading of 14 bundles / 31 rules / 18 no-ops is superseded and is left on the record rather than overwritten. Those bundles live in a gitignored directory, so this finding is reproducible on the build machine and not from a clone.

The cause is not the Armorer being careless. The tripwire's aggregate clause groups breaches by a key that the patch language the Armorer must write in cannot express — so the model writes the closest expressible rule, the rule is valid, and it binds to the wrong thing. That is a specific, diagnosed, fixable defect in the seam between two components, and it was invisible until something asked the question directly.

The gate had been asking whether a rule was well-formed and harmless, and never whether it was a fix. A rule that blocks nothing passes both of those easily. So does its mirror image, which is the finding I expect to carry into every project after this one: a rule that over-blocks passes every gate. It blocks the attacks; the approval oracle rubber-stamps the benign cases it intercepted; the benign pass rate reads a perfect 26 of 26; the gate promotes it. Every instrument says the run went well, and the agent has been made useless with its job handed back to a human. Two criteria that ask the missing question — originating-breach closure, and attack reduction — landed on 2026-08-26 and ran enforcing in the final measurement batches. Every promotion published before that date came from a gate that never asked.

Under the hood — the failure this generalizes to

The transferable version has nothing to do with agents. A rule that over-blocks passes every gate. A require_approval rule that routes far too much to a human blocks most attacks; the approval oracle then approves the legitimate requests, so the benign pass rate reads a perfect 26/26; and the promotion gate promotes it. Every instrument says the run went well. What actually happened is the agent was made useless and a human was handed its job.

The fix has to be to the ruler, not to the rule. The benign pass rate has to carry the count of fixtures that passed only because the oracle waved through a call the policy had stopped, so that 26/26 (0 approval-masked) and 26/26 (11 approval-masked) stop printing the same string.

Why it took so long to see is the more useful half. The number was never missing from a return shape. It was being computed and then destroyed one frame lower down: when the policy returned APPROVAL_REQUIRED and the oracle approved, the replayed event recorded policy_decision = ALLOW, and the fact that the policy had stopped the call was gone. Every consumer above that line was reading a record the erasure had already flattened.

A check that cannot fail is not measuring anything.

This is the sentence the project kept re-learning, in eight distinct places, in eleven days. The gate that never asked whether a fix worked. The evidence reader that accepted damaged bundles. The ratification check that bound what a reviewer saw and nothing that bound what they decided, so an edit made after signature changed the output while the digest check stayed green. Each one passed cleanly, every day, while measuring nothing.

The practice that came out of it is cheap and I'll carry it everywhere: ship a breaker with every check. The evidence reader is tested against nine deliberately damaged bundles plus a control, with proofs that the suite catches a reader that accepts everything and one that rejects everything. The overclaim linter ships a breaker per pattern, run twice each — once against the overclaim, once against a correction note quoting the same phrase in order to retire it — because a pattern nothing exercises is a pattern that could match nothing forever.

The one I'd put on a wall

Four separate times in one day I concluded that no traces existed for the deployed agent. Three of those were the same legacy API query, run again over wider windows; the fourth was a console scoped to the wrong hour. The API I was using structurally cannot see spans written through the path the agent writes them on. The first null was correctly recorded as unverified. Running the same blind instrument twice more upgraded it to a confirmed negative.

Repeating a blind check is not a second opinion. Changing the instrument settled it on the first attempt, and the empty console window is kept in the repo as a negative control rather than deleted.

The third lesson is about how to publish. Every figure this project produces carries four labels that were pre-registered before any figure existed, so none of them could be chosen afterwards to suit a result: the sample size is written k = 1 with "no stability estimate" permanently attached; the benign floor is reported as a bound — a clean 26/26 bounds the unobserved regression rate at roughly 11.5% by the rule of three, never as zero loss; the target's model tier is named every time, because a weaker target inflates the baseline and flatters the whole curve; and every attack/benign pair is labeled by what separated it, the policy or the approval oracle, because a suite the oracle separates produces headline numbers identical to one the policy separates.

That last ratio came in at 21 policy-separated to 3 oracle-separated, against a design target of 18 to 4. It's off target, it's reported rather than absorbed, and the corpus checker prints the deviation on every single run so it can't quietly become the new normal.

Google Cloud, and the parts of it doing structural work.

The interesting choice here isn't which services are in the stack. It's that two of them are load-bearing for the argument, not just for the hosting.

The Agent Development Kit provides the enforcement point. ADK's BasePlugin.before_tool callback fires between the agent deciding to call a tool and the tool running. That is where the policy engine sits: it stamps the attempt, evaluates the current policy version, and short-circuits the call if a rule denies it. The agent cannot talk its way past it, because the check is not in the agent's context — it's in the runtime, in Python, below the model.

Cloud IAM provides the blindness. The Armorer, which writes the fixes, holds no storage role at all on the bucket containing the held-out attack family. That is not a convention documented in a README; it is a 403 captured with a positive control, so the denial is demonstrated rather than asserted. A held-out set that the fixing component could technically read is not held out.

Under the hood — services, models, and configuration
  • Google ADKgoogle-adk==2.1.0, pinned, imported and load-bearing: crucible/plugin/adk.py:78 imports BasePlugin, and enforcement runs at its before_tool callback. Targets run in non-live run_async mode only, and attach asserts it: an open upstream issue reports the tool callbacks may not fire during bidirectional streaming, which would mean the policy silently doesn't run — exit 0, healthy log, no enforcement. The assertion is unconditional and stays regardless of how that issue resolves.
  • Vertex AIglobal endpoint, pinned in code and in the run descriptor. gemini-3.7-flash writes the patches, gemini-3.6-flash writes the attacks, gemini-3.5-flash-lite is both the target agent and the Coroner, and gemma-4-26b-a4b-it-maas classifies capabilities through Vertex AI Model Garden.
  • Cloud Run — service deployed --no-allow-unauthenticated, running as a dedicated service account rather than the compute default, so the agent under attack does not inherit the builder's authority. Scaling min 0 / max 20, so an idle harness costs nothing.
  • Cloud Storage — three buckets, uniform bucket-level access on and public access prevention enforced: the sealed attack family, the promoted policy versions (versioning on, 14-day retention), and the evidence bundles.
  • Cloud Trace — spans from the deployed agent, including the eight error spans from an endpoint mismatch, kept deliberately. A failure trail in the trace is worth more than a clean board.
  • Offline by construction — the evidence replay viewer opens no socket, reads no credential, and consults no environment variable. Enforced by an AST lint over the package plus a test that runs the viewer in a subprocess with the environment stripped and the socket module replaced by something that raises.

The boundary, stated rather than discovered.

Eleven days, one person, one target agent, single-sample throughout. Every rate the project produces is a replay of recorded calls: it answers would this rule have stopped these calls, and never could an attacker find another way in. The five money invariants exist and are provably firable, and not one has ever fired on live data — the money path is unobserved, not defended.

The held-out attack family was never opened, by decision, so no transfer result exists and none is claimed. The reason isn't a hedge: eleven rounds of adversarial review kept finding defects in the runner, the last of them closing on the morning of submission, and the sealed set gets exactly one attempt. Spending it against a runner whose newest defect was hours old would have produced evidence nobody could rule on.

One sealed instance of twenty-four was named verbatim in a public commit early in the build. It's redacted going forward, but a public commit is served by its hash forever, so the instance was deliberately not replaced and the leak is stated any time that instance's result comes up. The sealed set was reviewed by one person, who is also the builder. Live-run evidence is gitignored, which means the central finding above is reproducible from the harness on my machine and not from a clone of the repo.

And the boundary that matters most, which is on camera as well as in the repo: the trust root is me. I hold project Owner. No control in this system defends against the person who built it. Every correction, every withdrawn claim, and everything the project marks unverified rather than dropping is kept in a dated ledger in the repository, because the shortest route to knowing how much of a thing to trust is reading what its author has already had to take back.

The findings name their own follow-on work.

The submission deadline is a date, not a stopping point. Everything below came out of the measurement work rather than out of a roadmap meeting, which is the useful thing about building the ruler before building the feature list.

An enforceable policy, and a way to check it.

What comes out of a run is not a report. It's a policy file, a set of work items naming every breach the harness found and could not close along with the machine-checked reason the gate refused each attempted rule, and evidence of where it attacked and found nothing. Most tooling reports only the first of those, which hands you a policy and an unearned feeling of safety. The second is the actionable half, and this project makes it a first-class artifact rather than a footnote.

The single end-to-end result a stranger can verify in thirty seconds, from a clone, with no credential and no cloud project:

python scripts/w2-smoke.py

An attack lands against an empty policy and the refund executes. One rule is applied. The same attack is stopped, no tool executes, and a legitimate episode survives both policies. No model is called and nothing is billed.

Repo: github.com/emtcmca/crucible  ·  Demo: the loop in 3:34

Hackathon entry

I built CRUCIBLE, and wrote this case study, for the purposes of entering the Google Cloud “All Things Agentic” hackathon, track The Fortified Enterprise Fleet. Built between 2026‑08‑20 and 2026‑08‑31 on the Google Agent Development Kit, running on Google Cloud — Vertex AI, Cloud Run, Cloud Storage, Cloud IAM, Cloud Logging, Cloud Build, Artifact Registry, and Cloud Trace. Released under Apache-2.0. Not reviewed, endorsed, or responded to by Google in any way.

← promptsmith Writing →