{"version":3,"file":"guard.mjs","names":[],"sources":["../../../../../../../ai/src/guard/guard.ts"],"sourcesContent":["import { extractUserText } from \"../middleware/utils/extract-user-text\";\nimport { forTool } from \"../middleware/helpers/for-tool\";\nimport type { AgentMiddleware } from \"../contracts/middleware/middleware.contract\";\nimport type {\n  MiddlewareToolContext,\n  MiddlewareTripContext,\n} from \"../contracts/middleware/middleware-context.type\";\nimport type { ModelResponse } from \"../contracts/model.contract\";\nimport type {\n  GuardOptions,\n  GuardrailDetector,\n  GuardrailEscalation,\n  GuardrailMatch,\n  GuardrailPhase,\n  GuardrailVerdict,\n} from \"./contracts\";\nimport { GuardrailViolationError } from \"./errors\";\n\n/** Default middleware name when the caller supplies none. */\nconst DEFAULT_NAME = \"guardrail\";\n\n/**\n * The `ctx.state` key under which a guard records its `flag` verdicts. The\n * value is an append-only array of {@link FlagRecord}, namespaced by the\n * middleware name so two guards on the same agent never collide and a\n * downstream observer (panoptic, the caller) can read the annotations\n * post-run.\n */\nfunction flagsKey(name: string): string {\n  return `${name}.flags`;\n}\n\n/**\n * One flagged match recorded into `ctx.state`. Mirrors the\n * {@link GuardrailVerdict} `flag` shape plus the phase it fired at, so an\n * observer can reconstruct *what* tripped *where* without re-running the\n * detector.\n */\nexport interface FlagRecord {\n  /** The detector that produced the flag. */\n  readonly detector: string;\n  /** Where the detector was running. */\n  readonly phase: GuardrailPhase;\n  /** The detector's human-readable reason. */\n  readonly reason: string;\n  /** The matches the detector recorded. */\n  readonly matches: readonly GuardrailMatch[];\n}\n\n/**\n * Append a `flag` record onto the namespaced `ctx.state` array, creating it\n * on first write. Never throws — recording is best-effort annotation.\n */\nfunction recordFlag(\n  ctx: MiddlewareTripContext,\n  name: string,\n  record: FlagRecord,\n): void {\n  const key = flagsKey(name);\n  const existing = ctx.state.get(key);\n  const flags = Array.isArray(existing) ? (existing as FlagRecord[]) : [];\n\n  flags.push(record);\n  ctx.state.set(key, flags);\n}\n\n/**\n * The outcome of folding a phase's detector array — what the hook should do\n * with the inspected text once every detector has had its say.\n *\n * - `allow`  — no detector objected; the hook continues untouched.\n * - `redact` — a detector returned rewritten `text`; the hook substitutes it\n *   (output / tool phases only — see {@link runDetectors}).\n * - `block`  — a detector rejected; the hook throws a\n *   {@link GuardrailViolationError} carrying `reason` / `matches` / `escalate`.\n *\n * `flag` verdicts never reach this type — they are recorded into `ctx.state`\n * as a side effect inside {@link runDetectors} and do not short-circuit the\n * fold, so a flagged-but-otherwise-clean run resolves to `allow`.\n */\ntype PhaseOutcome =\n  | { readonly type: \"allow\" }\n  | { readonly type: \"redact\"; readonly text: string }\n  | {\n      readonly type: \"block\";\n      readonly reason: string;\n      readonly matches?: readonly GuardrailMatch[];\n      readonly escalate: boolean;\n    };\n\n/**\n * Run a phase's detector array over `text`, in registration order, and fold\n * the verdicts into a single {@link PhaseOutcome}.\n *\n * **Short-circuit.** The first non-`allow`/non-`flag` verdict (a `redact` or\n * `block`) decides the outcome and stops the fold — outer detectors never run\n * after one objects, matching the install-array ordering. `flag` verdicts are\n * recorded into `ctx.state` and the fold continues (allow-but-annotate).\n *\n * **Phase-aware redact downgrade.** A `redact` verdict is only honoured where\n * the seam supports rewrite-and-continue:\n * - `\"output\"` — `trip.after` may return a replacement `ModelResponse`, so the\n *   rewritten text is threaded out.\n * - `\"input\"`  — the core `trip.before` hook can only short-circuit (return a\n *   response) or throw; it has **no** rewrite-and-continue seam, so an input\n *   `redact` is downgraded to a `block` rather than silently passing the\n *   un-redacted prompt through. (Documented on {@link GuardOptions.input}.)\n * - `\"tool\"`   — silently rewriting tool arguments changes the call's\n *   side-effects unpredictably, so a tool `redact` is downgraded to a `block`\n *   (`tool-arg-redaction-unsupported`) rather than mutating what the tool runs.\n *\n * **Fail-open on detector fault.** A detector's `check()` rejecting is an\n * infrastructure fault, not a content violation — it is recorded as a `flag`\n * (`<detector>.error`) and the fold continues, so a moderation-API outage does\n * not abort every agent run.\n */\nasync function runDetectors(\n  detectors: readonly GuardrailDetector[],\n  text: string,\n  phase: GuardrailPhase,\n  ctx: MiddlewareTripContext,\n  name: string,\n): Promise<PhaseOutcome> {\n  for (const detector of detectors) {\n    let verdict: GuardrailVerdict;\n\n    try {\n      verdict = await detector.check(text, { phase, ctx });\n    } catch (error) {\n      // Infra fault — fail open: record and continue, never abort the run.\n      recordFlag(ctx, name, {\n        detector: detector.name,\n        phase,\n        reason: `detector \"${detector.name}\" threw: ${\n          error instanceof Error ? error.message : String(error)\n        }`,\n        matches: [],\n      });\n\n      continue;\n    }\n\n    if (verdict.type === \"allow\") {\n      continue;\n    }\n\n    if (verdict.type === \"flag\") {\n      recordFlag(ctx, name, {\n        detector: detector.name,\n        phase,\n        reason: verdict.reason,\n        matches: verdict.matches,\n      });\n\n      continue;\n    }\n\n    if (verdict.type === \"redact\") {\n      if (phase === \"output\") {\n        return { type: \"redact\", text: verdict.text };\n      }\n\n      // Input / tool phases have no safe rewrite-and-continue seam — downgrade\n      // to a block so the un-redacted text is never threaded through.\n      const reason =\n        phase === \"tool\"\n          ? \"tool-arg-redaction-unsupported\"\n          : verdict.reason;\n\n      return {\n        type: \"block\",\n        reason,\n        matches: verdict.matches,\n        escalate: false,\n      };\n    }\n\n    // verdict.type === \"block\"\n    return {\n      type: \"block\",\n      reason: verdict.reason,\n      matches: verdict.matches,\n      escalate: verdict.escalate ?? false,\n    };\n  }\n\n  return { type: \"allow\" };\n}\n\n/**\n * Realize a `block` outcome: fire the escalation seam (when the verdict asked\n * for it) and throw the typed {@link GuardrailViolationError} on `result.error`.\n * Never returns — always throws.\n *\n * The core `GuardrailViolationError.phase` is typed `\"input\" | \"output\"`; this\n * package widens the surfaced `phase` with `\"tool\"` (a source-compatible third\n * value), so the construction site asserts the wider value through the options\n * shape the error already accepts at runtime.\n */\nasync function block(\n  outcome: Extract<PhaseOutcome, { type: \"block\" }>,\n  phase: GuardrailPhase,\n  ctx: MiddlewareTripContext,\n  name: string,\n  escalation: GuardrailEscalation | undefined,\n): Promise<never> {\n  if (outcome.escalate) {\n    await escalation?.onBlock?.({\n      phase,\n      reason: outcome.reason,\n      matches: outcome.matches,\n      ctx,\n    });\n  }\n\n  throw new GuardrailViolationError(\n    `guardrail \"${name}\" rejected ${phase} — ${outcome.reason}`,\n    {\n      // `phase` is widened to include \"tool\"; the error carries it verbatim.\n      phase: phase as \"input\" | \"output\",\n      reason: outcome.reason,\n      guardrail: name,\n    },\n  );\n}\n\n/**\n * Build the composed **guardrail middleware** (surfaced as\n * `ai.guardrail(options)`) — one {@link AgentMiddleware} that runs the\n * configured detectors at three hook points and maps each\n * {@link GuardrailVerdict} onto the pipeline's throw / return / record\n * mechanics:\n *\n * - **`input`** detectors run at `trip.before` over the outbound prompt\n *   (`extractUserText(ctx.messages)`). `block` / `flag` only — the core\n *   `trip.before` seam cannot rewrite-and-continue, so a `redact` verdict here\n *   is downgraded to a `block`.\n * - **`output`** detectors run at `trip.after` over `response.content`. Full\n *   `allow` / `redact` / `block` / `flag` support — a `redact` returns a\n *   replacement `ModelResponse` with the rewritten `content`.\n * - **`tool`** detectors run at `tool.before` over `JSON.stringify(toolArgs)`.\n *   `block` / `flag`; a `redact` is downgraded to a `block`\n *   (`tool-arg-redaction-unsupported`). Scoped to `toolNames` via the core\n *   `forTool(toolNames, mw)` helper when set.\n *\n * **Verdict → action.** Detectors run in registration order; the first\n * `redact` / `block` short-circuits the phase. `block` throws a\n * {@link GuardrailViolationError} on `result.error` (never out of the\n * pipeline); `flag` records the match into `ctx.state` under `<name>.flags`\n * and continues; a `{ type: \"block\", escalate: true }` verdict awaits\n * `escalation.onBlock` before throwing. A detector that *throws* is treated as\n * an infra fault and fails open (recorded as a flag, run continues).\n *\n * @param options - The {@link GuardOptions}: per-phase detector arrays,\n *   optional `toolNames` scope, `escalation` seam, and `name` override.\n * @returns One {@link AgentMiddleware} to pass into `ai.agent({ middleware: [...] })`.\n *\n * @example\n * const policy = ai.guardrail({\n *   name: \"compliance\",\n *   input: [ai.guardrail.injection({ onMatch: \"block\" })],\n *   output: [ai.guardrail.pii({ onMatch: \"redact\", mask: \"[REDACTED:{label}]\" })],\n *   tool: [ai.guardrail.pii({ onMatch: \"block\" })],\n *   toolNames: [\"send_email\"],\n *   escalation: { async onBlock(e) { await reviewQueue.enqueue(e); } },\n * });\n *\n * const agent = ai.agent({ model, tools: [sendEmail], middleware: [policy] });\n */\nexport function guard(options: GuardOptions): AgentMiddleware {\n  const name = options.name ?? DEFAULT_NAME;\n  const input = options.input ?? [];\n  const output = options.output ?? [];\n  const tool = options.tool ?? [];\n  const escalation = options.escalation;\n\n  const middleware: AgentMiddleware = {\n    name,\n    trip: {\n      async before(ctx: MiddlewareTripContext): Promise<void> {\n        if (input.length === 0) {\n          return;\n        }\n\n        const prompt = extractUserText(ctx.messages);\n\n        if (!prompt) {\n          return;\n        }\n\n        const outcome = await runDetectors(input, prompt, \"input\", ctx, name);\n\n        if (outcome.type === \"block\") {\n          await block(outcome, \"input\", ctx, name, escalation);\n        }\n\n        // `allow` (incl. any recorded flags) and a downgraded-but-impossible\n        // input `redact` (already mapped to block above) fall through — the\n        // real model call proceeds with the un-mutated prompt.\n      },\n      async after(\n        ctx: MiddlewareTripContext,\n        response: ModelResponse,\n      ): Promise<void | ModelResponse> {\n        if (output.length === 0 || !response.content) {\n          return;\n        }\n\n        const outcome = await runDetectors(\n          output,\n          response.content,\n          \"output\",\n          ctx,\n          name,\n        );\n\n        if (outcome.type === \"block\") {\n          await block(outcome, \"output\", ctx, name, escalation);\n        }\n\n        if (outcome.type === \"redact\") {\n          // `trip.after` may return a replacement response — thread the\n          // rewritten content back so the caller never sees the original.\n          return { ...response, content: outcome.text };\n        }\n\n        return;\n      },\n    },\n  };\n\n  // Only declare the `tool` hook map when there are tool detectors — an empty\n  // `tool` array would otherwise make `forTool` scoping a no-op cost.\n  if (tool.length > 0) {\n    middleware.tool = {\n      async before(ctx: MiddlewareToolContext): Promise<void> {\n        const args = JSON.stringify(ctx.request.input);\n\n        if (!args) {\n          return;\n        }\n\n        const outcome = await runDetectors(tool, args, \"tool\", ctx, name);\n\n        if (outcome.type === \"block\") {\n          await block(outcome, \"tool\", ctx, name, escalation);\n        }\n\n        // A tool `redact` is downgraded to `block` inside `runDetectors`, so\n        // `redact` is unreachable here; `allow`/`flag` fall through and the\n        // real tool dispatch proceeds.\n      },\n    };\n  }\n\n  // Scope the `tool` hooks to the named tools when requested — `forTool`\n  // leaves `trip` hooks untouched, so input/output detectors still fire for\n  // every trip regardless of which tool is being dispatched.\n  if (options.toolNames !== undefined && middleware.tool) {\n    return forTool(options.toolNames, middleware);\n  }\n\n  return middleware;\n}\n"],"mappings":"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"}