Skip to main content

Lab 8 answers

Q1. Why 100% instead of 80%, and what breaks if you average them?

Because accuracy is a rate and a breach is an event. "We block 90% of injections" describes a system an attacker can simply retry against, and attackers do retry — the tenth attempt costs them nothing. There is no meaningful sense in which 90% containment is 90% as good as containment.

Averaging the two into one health score destroys both. A strong accuracy number would pay for a breach: 12/12 on classification and 10/11 on containment averages to something comfortably green, and the thing you needed to know is gone. Worse, the blended number is not actionable in either direction — you cannot tell whether a drop means the classifier regressed or the boundary failed, and those have completely different responses.

Keep them as two gates with two exit paths, which is why eval:redteam prints attacks and controls as separate lines and exits on either.

Q2. What defeats a strip-the-tag fix?

Two obvious inputs:

  1. </customer_mess</customer_message>age> — stripping the inner occurrence splices the outer fragments into a valid closing tag. The classic sanitizer bug, and it applies to any single-pass replace.
  2. Any encoding the strip does not know about: a Cyrillic look-alike, a zero-width joiner inside the tag name, &lt;/customer_message&gt; when something downstream decodes entities. inj-09 in the corpus is this attack in miniature.

Stripping known-bad strings is a blocklist, and a blocklist requires you to have anticipated every variant. Escaping the one character that can begin a tag is closer to a whitelist: after < becomes &lt;, no arrangement of remaining characters constructs a tag, because the construction primitive is gone. You do not need to enumerate the attacks, which is the property that makes it hold against attacks nobody has invented yet.

Same reasoning as parameterized SQL. The lesson people take from SQL injection is usually "escape quotes"; the lesson that transfers is "never build a structured document by concatenating untrusted strings into it." This is that lesson wearing a new costume, and people who would never concatenate SQL do it to prompts without noticing.

Q3. Why return the corrected resolution rather than the original plus a flag?

The argument for the flag is real and worth stating. Returning the model's actual output preserves an audit trail, avoids the service silently rewriting something a human might want to see, and keeps the guardrail's effect visible rather than baked in. In a system where the caller is another engineer reading carefully, it is arguably the better design.

This repo does not do it because of what happens on a Tuesday six months from now. A consumer that must remember to check meta.guardrails.authority_allowed before acting on resolution.recommended_action will eventually not. Somebody will write if (r.recommended_action === "issue_refund") refund(r.refund_amount_usd) because that reads like the obvious thing, and the flag will sit in a field nobody destructured. Make the value in the obvious place the safe one.

Note the design does not throw the original away — the violations are listed in meta.guardrails.authority_violations, and the corrected reasoning is prefixed with what happened. The audit trail survives; it just is not the thing you have to opt out of.

Q4. Why read the trace rather than the reasoning?

Because reasoning is the model's prose about what the tools returned, and the trace is what they actually returned. Those differ under exactly the conditions you are defending against.

Three concrete ways prose diverges from trace: the model transcribes $450 as $45; it summarizes "refunds in the last 30 days: $450" as "no significant recent refund activity"; or — under a persuasive message — it reports the figure the customer asserted rather than the one the lookup produced. None of these is exotic. The first is a typo, the second is compression, and only the third is an attack.

The general principle: a control must read its inputs from a source the thing it is controlling cannot rewrite. The model authors reasoning. It does not author tracerecord() does, before the model ever sees the text. That is what makes the trace admissible and the prose not.

This also explains why ToolCallRecord keeps the raw object rather than only the redacted string. The checks need real numbers.

Q5. What breaks if you escape before redacting?

The card number stops being detectable. redactPII looks for 13–19 digits with optional spaces or hyphens between them and then Luhn-checks the result. Escape first and any < in the surrounding JSON becomes &lt;, which introduces characters into the digit run's neighbourhood; more importantly, the general form of the bug is that escaping rewrites the string the detector was written to match.

The failure is silent and it is the worst kind: the card number flows to the model, into the request logs, and into anything downstream that persists a transcript, while redactions: [] reports confidently that there was nothing to redact. You get a compliance artifact asserting the opposite of the truth.

Rule of thumb: detect on the rawest form you have, transform afterwards. Any sanitization pipeline has an order and the order is load-bearing.

Q6. What does the favourable result establish?

Less than it looks like, and it is worth being precise because this is the moment where security work usually starts lying to itself.

What it establishes: five consecutive clean red-team runs after the change, against a case that demonstrably flipped before it. If the pre-fix pass rate on inj-10 was around 50%, five clean runs is roughly a 3% result by chance — suggestive, not proof, and enough to prefer the hardened prompt over the original. And the accuracy check found no cost: 11/12 and 12/12 against a 10/12 baseline, all inside the set's ordinary 10–12 band.

What it does not establish: that inj-10 is fixed. Five runs cannot distinguish "always holds" from "holds 95% of the time," and the difference matters enormously for a control. Nor does it establish anything about the attack family — one buried-instruction payload now fails, and the next phrasing is untested. And the accuracy result is a non-regression, not an improvement; the +1 and +2 deltas against baseline are inside the noise, and reporting them as a gain would be exactly the error eval:quick prints a warning about.

The claim worth writing down: "The hardened prompt closed the one buried-instruction payload we have, across five runs, at no measurable accuracy cost. We have not established a rate, and the attack family is not covered." That is a sentence you can defend in a review, and it is considerably less satisfying than "we fixed prompt injection," which is the sentence people write instead.

Notice also which layer actually earned trust here. The structural fixes — escaping, enforceAuthority — are proofs: $900 > $200 every time, and no arrangement of characters constructs a tag. The prompt fix is a probability. Rank your defences accordingly, and never let a probability be the only thing between an attacker and money.

Q7. What else should you check after editing the frozen role text?

The cache. TRIAGE_ROLE sits inside the block carrying cache_control, so editing it invalidates the cached prefix for every request — the next call pays a cache write, which costs more than no caching at all. That is fine once and expensive if it happens on every deploy.

Concretely, after a prompt edit: make two identical calls and confirm cache_read_input_tokens is non-zero on the second (npm run smoke does this), and confirm the prefix is still over the ~1024-token minimum via /v1/estimate. A prompt edit that pushed the prefix below the minimum would silently stop caching with no error at all — the failure mode Lab 5 is built around.

Two other things worth a look, both cheap: the drafter and resolver roles were not hardened in this lab, so the boundary is stated in one role and not the other two — decide whether that is deliberate. And re-run npm run eval:models if you care about the cheap tiers, because a longer, more demanding system prompt does not necessarily land the same way on a smaller model, and Lab 7 established that the cheap tier's confidence will not tell you when it has stopped coping.

Extension notes

The corpus is only useful while something in it still fails. A green gate is a statement about your imagination as much as about your defences, and the honest move is to keep an unfixed case in the file rather than to enjoy the clean run.

The productive directions are the layers with no deterministic control behind them. pickModel reads the same untrusted text and routes on keywords, so a high-stakes message written without high-stakes vocabulary routes itself down to the tier that loses the safety case — and as Lab 7 Q4 notes, that attack does not need an attacker, just a polite customer. And summary is free text that a human reads, that nothing scores, and that redactPII does not run over on the way out.

If you write a case that lands, resist fixing it immediately. Sit with it long enough to ask what class of control would close it, because the answer is often "none of the ones I have," and that is the finding.