Module 10 · Safety, Evaluation & Capstone ⏱ 19 min

Guardrails: Validating Inputs and Outputs

By the end of this lesson you will be able to:
  • Explain why guardrails wrap a model call with code rather than relying on the prompt
  • Implement an input allowlist that fails fast before a model call is made
  • Validate a model response with a schema check and refusal detection

A model asked nicely to behave will usually behave — usually. Guardrails are the programmatic checks that enforce your policy around a model call regardless of what the model feels like doing. The shift in posture is the whole point: you stop asking the model to be safe and you make the pipeline safe, with code you control and can test on every request.

A guardrail runs in two places. An input guardrail inspects the request before the model is called — is this topic allowed, is the input short enough, does it carry data you refuse to process? An output guardrail inspects the response after the model returns — did it parse into the shape downstream code expects, did it refuse, did it leak something it should not? Both are deterministic, both run on every request, and neither trusts the model to police itself.

flowchart LR
  R["incoming request"] --> IG["input guardrail<br/>(allowlist + limits)"]
  IG -->|"allowed"| M["model call"]
  M --> OG["output guardrail<br/>(schema + refusal)"]
  OG -->|"valid"| A["act on result"]
  IG -.->|"rejected"| X["block early"]
  OG -.->|"invalid"| X
  style M fill:#3776ab,color:#fff
  style X fill:#b91c1c,color:#fff
Guardrails wrap the model. Input checks run first and can block before a single token is generated; output checks validate the result before you act on it.

Input: allow what you expect, not block what you fear

The safer default for an input check is an allowlist: explicitly list the topics, commands, or document types your application handles, and reject everything else. A blocklist — listing forbidden things — fails the same way a keyword injection filter does: it knows about the bad it has seen and nothing about the bad it has not. An allowlist inverts that: unknown is rejected by default, and adding a new capability becomes a deliberate, reviewable change.

Input checks also fail fast. If a request is off-topic or oversized, you learn that for the cost of a substring test, not a model call. That is cheaper, faster, and it stops abuse before a single token is generated.

flowchart TD
  B["blocklist<br/>block known-bad"] --> J["unknown threat?"]
  J -->|"passes through"| L["leak"]
  AL["allowlist<br/>permit known-good"] --> K["unknown topic?"]
  K -->|"rejected by default"| S["safe"]
  style L fill:#b91c1c,color:#fff
  style S fill:#1e7f3d,color:#fff
A blocklist admits anything it does not recognise; an allowlist rejects anything it does not recognise. For untrusted input, the allowlist default-deny is the safer posture.
An allowlist check rejects any topic it does not explicitly permit. Press Run.
function isAllowedTopic(topic, allowed) {
  return allowed.indexOf(topic.toLowerCase()) !== -1;
}

const allowed = ['weather', 'sports', 'news'];
console.log(isAllowedTopic('Weather', allowed));
console.log(isAllowedTopic('finances', allowed));
console.log(isAllowedTopic('news', allowed));

Output: check the shape, not just the words

A model that returns a sentence where your code expects a JSON object will crash the line that reads result.summary. The fix is a schema check: after parsing the response, confirm it has the required keys with the right kinds of value before you trust a single field. This turns a vague 'the model misbehaved' into a precise, testable failure: this response is missing the sentiment field.

Schema validation is where deterministic code does the heavy lifting that prompting cannot reliably do. You can ask the model to return JSON until you are blue in the face; the line that actually guarantees it is the one that rejects anything that does not match the schema.

flowchart LR
  T["raw model text"] --> P["JSON.parse"]
  P -->|"parses"| V["schema check<br/>(required keys + types)"]
  P -.->|"throws"| R["reject not JSON"]
  V -->|"ok"| O["typed object<br/>safe to use"]
  V -.->|"fail"| R2["reject wrong shape"]
  style O fill:#1e7f3d,color:#fff
  style R fill:#b91c1c,color:#fff
  style R2 fill:#b91c1c,color:#fff
Output validation is two gates: parse, then check the shape. Either failure rejects the response before downstream code touches it.
A hand-rolled schema check confirms the response is a non-null object with the required keys. Run it.
function validateSchema(obj, schema) {
  if (obj === null || typeof obj !== 'object') return false;
  for (const key of schema.required) {
    if (!(key in obj)) return false;
  }
  return true;
}

const schema = { required: ['summary', 'sentiment'] };
console.log(validateSchema({ summary: 'good quarter', sentiment: 'positive' }, schema));
console.log(validateSchema({ summary: 'good quarter' }, schema));
console.log(validateSchema(null, schema));

Detecting refusals

Even a well-behaved model refuses sometimes — the request was unsafe, or it hit a policy, or it simply did not know. A pipeline that feeds the model's refusal straight to a downstream tool hands that tool a paragraph of apology instead of the data it needs. Refusal detection scans the output for the stock phrases models use to decline — 'I cannot help with that', 'as an AI, I cannot' — and routes those responses to a fallback or a retry instead of forwarding them.

Like input filtering, this is a substring check run case-insensitively over a list of known phrases. It is not perfect, because a model can refuse in novel words, but it catches the common cases deterministically and stops a refusal from being mistaken for a successful answer.

Refusal detection scans for stock decline phrases, case-insensitively. Run it.
function refusalDetected(text) {
  const t = text.toLowerCase();
  const phrases = ['i cannot', 'i am not able to', 'sorry, i', 'as an ai'];
  return phrases.some((p) => t.indexOf(p) !== -1);
}

console.log(refusalDetected('As an AI, I cannot help with that.'));
console.log(refusalDetected('The capital of France is Paris.'));

When a check fails: block, retry, or fall back

When a guardrail trips, the pipeline has three honest options. Block the request and return a safe canned response — the right call for policy violations. Retry with a repaired prompt — useful when the model returned malformed JSON and a nudge might fix it. Fall back to a simpler, non-model path — a deterministic lookup or a default that is always correct.

The wrong option is to silently pass the bad output along, because then the guardrail exists only on paper. Decide the failure mode deliberately, log it, and make sure the downstream tool never receives something the guardrail just rejected.

Exercise

What does this print? The refusal detector lowercases the text and looks for any decline phrase as a substring.

function refusalDetected(text) {
  const t = text.toLowerCase();
  const phrases = ['i cannot', 'i am not able to', 'sorry, i', 'as an ai'];
  return phrases.some((p) => t.indexOf(p) !== -1);
}
const out = refusalDetected('As an AI, I cannot help with that.');
console.log(out);
Exercise

Write refusalDetected(text) that returns true if text contains any refusal phrase, case-insensitively. Treat these as refusal phrases: i cannot, i am not able to, sorry, i, and as an ai. Return false otherwise.

function refusalDetected(text) {
  // return true if any refusal phrase is present (case-insensitive)
}
Exercise

This hasSummary check is meant to confirm a model response carries a real summary. But typeof null === 'object' in JavaScript, so a null response slips through, and so does an empty object that lacks the key. Fix it so it returns true only when obj is a non-null object whose summary is a non-empty string.

function hasSummary(obj) {
  return typeof obj === 'object';
}
Exercise

Write validateOutput(obj, requiredKeys) that returns true only when obj is a non-null object and every key in requiredKeys is present with a value that is neither null nor an empty string. Return false otherwise.

function validateOutput(obj, requiredKeys) {
  // true only if obj is a non-null object with every required key present and non-empty
}
Exercise

You want to stop a request from reaching the model at all if the user is asking about a topic your app does not support. Is that an input guardrail or an output guardrail, and when does it run?

Recap

  • Guardrails are programmatic checks that wrap a model call — input before, output after — enforcing policy with code rather than by asking the model nicely.
  • An input guardrail leans on an allowlist (permit known-good, reject everything else by default) and fails fast before spending a model call.
  • An output guardrail runs a schema check so downstream code can trust the response's shape, plus refusal detection so a polite decline is not mistaken for a real answer.
  • Mind the typeof null === 'object' trap: validate not null AND is object AND has required keys.
  • When a check fails, choose deliberately — block, retry, or fall back — and never silently forward what the guardrail rejected.
  • All of this is deterministic string and structure handling, testable without a model and runnable on every request.

Next you will turn these per-request checks into a measurable evaluation harness, so you can prove run over run that the pipeline behaves the way you intend.

Checkpoint quiz

Why is an allowlist considered a safer default than a blocklist for validating untrusted input?

A schema check begins with if (typeof obj === 'object'). A model returns nothing, so obj is null. What does the check do, and why is it wrong?

Go deeper — technical resources