Skip to content
pleach
Operate

Safety policies

SafetyContribution is structurally distinct from PromptContribution — registered policies stay inert until an operator opts in.

A safety policy is advisory by default: it annotates a call — contributing refusal / guardrail prose into the system prompt and recording the operator's stated intent on the audit row — never generates new content. The hard, seam-level effects (blocking a tool call, vetoing a synthesis) landed in v1.2 but stay behind an operator opt-in: SessionRuntimeConfig.safetyEnforcementMode defaults to "prose-only", which preserves the prose-only posture byte-for-byte; "enforce" activates structural blocking for policies that ship a concrete guard. The contract is capability-subtracting: defineSafetyPolicy({ name, appliesTo: "pre-dispatch" | "post-tool", async check(state, ctx) → { allow, reason, forceText } }), or with rewrite(prompt) → { allow, rewrittenPrompt, auditNote }. Both forms are async; both land their result on the audit row.

A SafetyContribution is the registration shape behind those policies. It looks similar to a PromptContribution on the surface but the structural model is different in five places, which is why @pleach/core ships them as separate contracts.

Plugin authors register policies (declare what they ship). Operators enable policies at runtime construction (declare what's active). Different defaults, different composition, different audit surface.

import {
  SafetyPolicyRegistry,
  composeSafetyContent,
  SAFETY_SECTION_HEADER,
  SAFETY_ENFORCEMENT_EFFECTS,
  KNOWN_REGULATORY_FRAMEWORKS,
  safetyPolicyId,
} from "@pleach/core/safety";
import type {
  SafetyContribution,
  SafetyPolicyId,
  SafetyPolicySummary,
  SafetyEnforcement,
  SafetyPolicyScope,
  SafetyGuard,
  RegulatoryFramework,
  KnownRegulatoryFramework,
} from "@pleach/core/safety";
Subpath@pleach/core/safetySourcesrc/safety/

Safety lives outside the cluster pattern. Five distinct concepts (safety, scrubbers, fabrication-detection, determinism, fingerprint), each with its own mechanism — no natural three-concept triplet. See What lives outside the cluster pattern.

Why a separate contract

Five structural differences earn the split:

DifferencePromptContributionSafetyContribution
Default stateActive when plugin is loadedInert until enabled by id at runtime construction
Composition roleAdds prose that shapes behaviorAdds gates + refusal prose, composed LAST
AuditabilityInternal to the registryPublic — runtime.safety.listActivePolicies() returns a versioned SafetyPolicySummary[]
Override semanticsmode: "replace" overridesreplace is structurally absent — the field doesn't exist on SafetyContribution
VersioningImplicitExplicit semver per policy; bumps participate in the fingerprint key

The split is what lets a non-regulated deployment of a host silently skip sector-specific disclaimer policies that a regulated deployment of the same host enables — same plugin set, different active policies.

The SafetyContribution shape

interface SafetyContribution {
  readonly id: SafetyPolicyId;          // "<plugin>.<policy-name>"
  readonly version: string;              // semver
  readonly framework?: RegulatoryFramework;
  readonly enforcement: SafetyEnforcement;
  readonly scope?: SafetyPolicyScope;    // optional callClass / family / runtimeRole filter
  readonly content: string | ((ctx: PromptContext) => string);
  readonly guards?: readonly SafetyGuard[];
}
FieldPurpose
idBranded SafetyPolicyId. core.* is reserved (throws ReservedSafetyNamespaceError); unnamespaced ids throw UnnamespacedSafetyIdError
versionSemver string. Bumps are cache-invalidating via safetyPoliciesHash
frameworkOpen string field. KNOWN_REGULATORY_FRAMEWORKS enumerates conventional values (HIPAA, GDPR, SOC2, ITAR, EAR, CWC, BWC, FDA-21CFR11, clinical-research-disclaimer) for autocomplete; plugin authors are free to pass any id (e.g. "MDR-EU", "NIST-800-53", "my-company.policy-v3"). The pre-1.1 { custom: string } form is deprecated — pass a plain string id instead
enforcementadvisory / guardrail / refusal — names the operator's stated intent. Since v1.2 the levels map to diverged seam effects (see below), gated on the "enforce" opt-in; the field is audit-surface even when runtime behavior converges
scopeWhen the policy participates — callClass, family, runtimeRole
contentThe refusal / gate / disclaimer prose; string or (PromptContext) => string
guardsThe SafetyGuard slot. Two concrete kinds ship: tool-call-precondition (ToolCallPreconditionGuardDefinition — refuses a specific tool invocation) and regex-refusal (RegexRefusalGuardDefinition — replaces the terminal answer with a policy-attributed refusal). A guard only fires under safetyEnforcementMode: "enforce"

Registering policies (plugin author)

import type { HarnessPlugin } from "@pleach/core";

const compliancePlugin: HarnessPlugin = {
  name: "compliance",

  contributeSafetyPolicies: () => [
    {
      id:          safetyPolicyId("compliance.pii-redaction"),
      version:     "1.2.0",
      framework:   "GDPR",
      enforcement: "refusal",
      scope:       { callClass: "synthesize" },
      content:     "Never include raw PII in the final response. Redact email addresses, phone numbers, and identifiers.",
    },
    {
      id:          safetyPolicyId("compliance.export-control"),
      version:     "1.0.0",
      framework:   "EAR",
      enforcement: "guardrail",
      content:     "Refuse export-controlled technical data unless the requester has presented a current authorization.",
    },
  ],
};

Registered ≠ enabled. Plugin authors ship policies; operators opt in.

Enabling policies (operator)

The registry separates register (a policy is known) from enable (the operator opted in). Enablement is config-driven — the operator passes the id list at runtime construction via SessionRuntimeConfig.enabledSafetyPolicies. The constructor calls register() for every plugin contribution and enable() for every id in the config list before the runtime serves traffic.

import { safetyPolicyId } from "@pleach/core/safety";
import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  plugins: [compliancePlugin],
  enabledSafetyPolicies: [
    safetyPolicyId("compliance.pii-redaction"),
    safetyPolicyId("compliance.export-control"),
  ],
});

Construction throws UnknownSafetyPolicyError if the config references an id no plugin registered — operator typos surface at boot, not on the first turn. A deployment that doesn't need a particular policy simply omits its id from the list. The policy stays registered (visible to runtime.safety.listAvailablePolicies()) but inert (zero effect on prompt composition).

For interactive surfaces that need to toggle policies after construction, call runtime.safety.getRegistry().enable(id) / .disable(id). enable() throws UnknownSafetyPolicyError for unknown ids; disable() is idempotent.

Two further error shapes guard the registration surface:

  • DuplicateSafetyPolicyError — two policies registered with the same id. Use a versioned suffix if both must coexist.
  • ReservedSafetyNamespaceError — a plugin tried to register core.*. The core namespace is reserved; safety policies always originate from a plugin or compliance package.

The fingerprint contract

The active policy list participates in the per-call fingerprint cache key as safetyPoliciesHash — sha256 of the canonicalized "<id>@<version>" list, sorted by id. Flipping a policy on or off invalidates the cache; bumping a policy's version invalidates the cache. Two runs with different active sets resolve to different cache buckets, so replay can't accidentally re-use a result that was captured under different safety constraints.

The policy list in the audit ledger lets a regulator query "what was active when this call fired" without re-deriving from the plugin set.

Listing active policies

const active: readonly SafetyPolicySummary[] = runtime.safety.listActivePolicies();

for (const policy of active) {
  console.log(policy.id, policy.version, policy.framework, policy.enforcement);
}

SafetyPolicySummary is intentionally a projection — id, version, framework, enforcement, enabled. The content prose is OMITTED. Audit consumers receive the metadata, not the prose; surfacing prose through the discovery surface would couple audit tools to v1.x content shape changes.

runtime.safety.listAvailablePolicies() returns the full shelf (enabled flag on each entry); runtime.safety.listActivePolicies() is the enabled subset and is the surface replay and SOC2 exports proxy. runtime.safety.getRegistry() returns the underlying SafetyPolicyRegistry when consumers need collectActiveContributions() for the raw prose.

Composition order

Safety contributions compose last — after all prompt contributions. The composed system prompt ends with the safety section. The header literal is fixed:

import { SAFETY_SECTION_HEADER } from "@pleach/core/safety";

SAFETY_SECTION_HEADER; // "## Safety Policies"

Changing the literal value is a fingerprint-cache-invalidating event. Operators and replay tools grep for this delimiter to extract the safety portion of a composed prompt.

composeSafetyContent is wired inside the prompt resolver (resolvePromptForSeam) — every seam invocation passes its registry through host.safetyRegistry?.collectActiveContributions() and appends the composer's output LAST. Consumers don't call the composer directly in normal flows; the example below is the shape the resolver uses when an audit or replay tool needs to re-derive the safety section out-of-band.

import { composeSafetyContent } from "@pleach/core/safety";

const { text, activePolicies } = composeSafetyContent(
  runtime.safety.getRegistry().collectActiveContributions(),
  { callClass: "synthesize", family: "anthropic", runtimeRole: "primary" },
);

The composer enforces three invariants:

  1. Safety last. Output is appended after every prompt contribution. The order is set by the resolver call site, not by the composer.
  2. Scope filtering. Policies with a scope participate only when callClass, family, and runtimeRole match every named field.
  3. Empty-output drop. A policy whose context-aware content function returns "" is dropped from the output without an empty separator — and is dropped from activePolicies. Enablement is independent of per-context content.

Same active set + same context produces byte-identical output. That's what keeps the safety section fingerprint-safe.

SafetyEnforcement semantics

LevelSeam effect (v1.2)Intent
advisorylog_only — prose in the prompt, policy id on the audit rowNon-binding norms; LLM is free to comply or not
guardrailblock_tool_call — additionally interposes a tool-call-precondition guard that can refuse a specific tool invocationOperational guardrails (rate-limit refusals, tool-call preconditions)
refusalblock_synthesis — additionally interposes a regex-refusal guard at synthesis timeHard policy — "no PII in the synthesis"

The mapping alone blocks nothing. Structural enforcement needs two things a policy author and an operator supply separately: the policy must ship a concrete guards entry, and the operator must set safetyEnforcementMode: "enforce". In the default "prose-only" mode, resolveSafetyEnforcementEffect collapses every level to log_only — LLM compliance stays best-effort, exactly the pre-v1.2 posture. The level still appears on every audit row because downstream replay and compliance tools need the operator's stated intent, not just the runtime effect.

Branch on the EFFECT, not the name

enforcement describes operator intent — what the audit trail records. SAFETY_ENFORCEMENT_EFFECTS is the constant that maps each level to the seam effect it commits to under "enforce". The map is the stable indirection point — it survived the v1.1 → v1.2 transition while its values changed, which is exactly why runtime code should index it (or call resolveSafetyEnforcementEffect, which also folds in the operator mode) rather than switch on the severity name.

import { SAFETY_ENFORCEMENT_EFFECTS } from "@pleach/core/safety";

const effect = SAFETY_ENFORCEMENT_EFFECTS[policy.enforcement];
if (effect === "block_synthesis") {
  /* ... */
}

Plugin code that wants to react to runtime behavior should switch on the constant, not the severity name — that way a future effect-map change (when tool-call-precondition lands and guardrail / refusal diverge from log_only) is a no-op for consumers. Audit consumers continue to filter on the severity name for operator-intent queries.

KNOWN_REGULATORY_FRAMEWORKS

framework accepts any string. The exported constant lists nine conventional ids (HIPAA, GDPR, SOC2, ITAR, EAR, CWC, BWC, FDA-21CFR11, clinical-research-disclaimer) — render them in a picker, or use the KnownRegulatoryFramework literal union as an autocomplete hint. Plugin authors are free to pass any id ("MDR-EU", "NIST-800-53", "my-company.policy-v3"); the substrate doesn't hard-code one host's regulatory vocabulary.

import {
  KNOWN_REGULATORY_FRAMEWORKS,
  type RegulatoryFramework,
} from "@pleach/core/safety";

for (const id of KNOWN_REGULATORY_FRAMEWORKS) {
  // id: KnownRegulatoryFramework
}

const myFramework: RegulatoryFramework = "my-company.policy-v3";

Structural guards

SafetyContribution.guards is the contract slot for non-prose enforcement. The slot ships today; no concrete kind is implemented yet — the SafetyGuard shape is reserved so plugin authors can declare guards now and have them activate when the runtime ships matching handlers.

interface SafetyGuard {
  readonly kind: "regex-refusal" | "tool-call-precondition";
  readonly definition: unknown; // shape varies by kind
}

tool-call-precondition is the first concrete kind on the roadmap. It lands once the prose-only shape has field experience proving the gap. Until then the slot is inert at the runtime layer — guards arrays parse and persist to the registry but the seam never reads them.

Scrubber contract (redaction at write time)

Safety policies on this page gate prompt-input behavior. Fabrication detection (/docs/fabrication-detection) inspects model output for unsupported claims. Scrubbers are a third surface: they run before event log rows persist, redacting payload fields at write time so the ledger never holds the unredacted shape.

The substrate ships a Scrubber contract. @pleach/compliance bundles four concrete implementations — SSN-US, Luhn (card-number heuristic), US-DL, and a generic KeyedRegex — covering the common PII shapes most deployments need on day one.

Host plugins register additional scrubbers through contributeScrubbers, the same registration pattern used elsewhere in the plugin contract. Registration is auditable; bypassing a registered scrubber requires an explicit opt-out that lands on the audit row.

For the contract shape, a custom-scrubber example, and the audit gate that proves redaction happened, see /docs/scrubbers.

Where to go next