GNL
Docs menu
Core · Free@gnldev/durable

Taint guard

When a tool marked untrusted: true succeeds, the run is marked tainted. Every side-effect tool call AFTER that point is governed by the ladder (off → warn → reflect → block → suspend) — nudging the model to reconsider or stopping the call outright.

What it's for / when to use it#

An agent that fetches a web page, reads an email, or opens a document is reading content an attacker may have authored — a prompt injection. The taint guard is the runtime answer: mark the tool that produced that content untrusted: true, and any SIDE-EFFECT tool call after it succeeds (sendMail, transfer, …) is caught by the ladder before it can act on injected instructions. Taint is monotonic and first-wins — once a run is tainted, it stays tainted, and the recorded source is always the first untrusted call.

The ladder#

Set the response with limits.taintedSideEffects (default warn). The levels escalate in strictness:

const'off'

No enforcement — taint is not even read on the hot path.

const'warn'

Executes as before, but writes a journaled incident naming the taint source.

const'reflect'

Delivers ONE nudge per distinct (tool, args) pair — NOT a one-shot for the whole run like the duplicate guard's 'reflect'. An identical retry AFTER the nudge EXECUTES (journaled as a 'warn' record); it does NOT escalate to a block. This is deliberately different from the duplicate guard's 'reflect'.

const'block'

Stops the call via TaintedSideEffectError.

const'suspend'

Writes a suspended record → the call lands in the Approvals flow, and the human sees the taint source in the reason.

Setup / import#

The guard lives in @gnldev/durable and is configured per run via RunLimits:

import { createGnl, TaintedSideEffectError, readRunTaint, type RunLimits } from '@gnldev/durable';

Step-by-step usage#

Mark the tool whose OUTPUT is external, attacker-authorable content with untrusted: true. When it succeeds, the run is marked tainted; a later side-effect tool throws TaintedSideEffectError under block:

// 'fetchPage' is marked untrusted: true its OUTPUT is external, attacker-authorable
// content (whatever the fetched page contains). A successful call taints the run.
const fetchPage = { execute: async (a) => fetch(a.url).then((r) => r.text()), untrusted: true };

const limits: RunLimits = { taintedSideEffects: 'block' };

try {
  // A prompt-injected instruction inside the fetched page tells the model to email an
  // attacker. 'sendMail' is a side effect firing AFTER untrusted content entered the run.
  await gnl.run('research', { runId: 'task-9', prompt: 'summarize this page', limits });
} catch (e) {
  if (e instanceof TaintedSideEffectError) {
    // e.detail.taintSource which tool call introduced the taint
    console.log('tainted side effect blocked:', e.detail.toolName, e.detail.taintSource);
  }
}

Prefer reflect when you'd rather let the model recover — but note the behavior is per-call, not per-run:

// Unlike the duplicate guard's 'reflect', the nudge here is per distinct (tool, args)
// pair. An identical retry AFTER the nudge EXECUTES (journaled as a 'warn' record) —
// it does NOT escalate to a block.
const limits: RunLimits = { taintedSideEffects: 'reflect' };

Read the taint that caused a block, or set taint dynamically from a processor that inspects a tool's output for injected instructions:

// Inspect the taint that caused a block, or set taint dynamically from a processor
// (e.g. a tool that scans its own output for injected instructions):
const taint = await readRunTaint(journal, runId);
if (taint) console.log('tainted by', taint.toolName, taint.source, taint.reason);

await markRunTainted(journal, runId, {
  toolCallId,
  toolName: 'customScan',
  source: 'processor',
  reason: 'injection pattern detected',
});

Cross-turn taint: taintScope#

By default a taint is scoped to a single run — turn 2 of a conversation starts clean even if turn 1 was tainted. Set limits.taintScope to thread (default run) to propagate taint across turns on the same threadId: once a run is tainted, every LATER run sharing that thread inherits the taint — closing the gap where turn 1 fetches a poisoned page and turn 2 acts on it in a run that never called the untrusted tool itself.

// Turn 1: fetchPage taints THIS run. Turn 2 is a SEPARATE run on the same threadId
// asking the model to act on what was fetched without taintScope: 'thread' the new
// run starts clean and the injected instruction is not gated.
const limits: RunLimits = { taintedSideEffects: 'block', taintScope: 'thread' };

await gnl.run('research', { runId: 'turn-1', threadId: 'conv-42', prompt: 'fetch this page', limits });

// Later, same conversation turn-2 inherits the taint from turn-1, so a side-effect
// tool call here also runs the ladder, even though turn-2 never called fetchPage:
await gnl.run('research', { runId: 'turn-2', threadId: 'conv-42', prompt: 'send it to my team', limits });

Taint expiry: taintLifetime#

Inherited thread taint is persistent by default — once a thread is tainted, it stays tainted forever. Set limits.taintLifetime to content-window to let the inherited taint EXPIRE for a run once the tainting content is no longer visible in that run's loaded messages (recent and recalled) and working memory is empty. A later recall step can revive it, and working memory keeps it alive. Only meaningful together with taintScope: thread — a single run's own taint never expires.

// Same thread, taint inherited from an earlier run but this run's loaded messages
// (recent + recalled) no longer include the tainting content, and working memory is
// empty: the taint EXPIRES for this run, and the ladder does not fire on it.
const limits: RunLimits = {
  taintedSideEffects: 'block',
  taintScope: 'thread',
  taintLifetime: 'content-window',
};

// If a later recall step pulls the tainting message BACK into this run's context, or
// a tool wrote it to working memory, the taint is revived / kept alive for this run.

taintGuardian: gate a sensitive tool only when tainted#

taintGuardian is a Guard factory: pass it sensitiveTools and an onTainted decision function, plus an optional otherwise fallback. When the run is tainted AND the called tool is one of sensitiveTools, onTainted runs and its decision is used; every other call falls through to otherwise (default allow). The expensive check runs ONLY where a tainted run meets a sensitive tool — never on every call.

import { taintGuardian } from '@gnldev/durable';

// The expensive judge runs ONLY when BOTH conditions hold: the run is tainted AND the
// tool being called is listed in sensitiveTools. Every other call is allowed without
// paying for the check.
const guard = taintGuardian({
  sensitiveTools: ['sendMail', 'transfer'],
  onTainted: async (call) => {
    // call.tainted holds the taint context set on EVERY GuardCall, not just here.
    return { action: 'require-approval', reason: call.tainted?.reason };
  },
  otherwise: { action: 'allow' },
});

await gnl.run('research', { runId: 'r1', prompt: 'summarize and email it', guard, limits });

Any guard — not only taintGuardian — can read this same context: GuardCall now carries an optional tainted field of type RunTaint, set whenever the run is tainted, so a hand-written guard can branch on it directly.

API reference#

typeRunLimits.taintedSideEffects

RunLimits field: 'off' | 'warn' | 'reflect' | 'block' | 'suspend'. Default 'warn'. Applies to a side-effect tool call made after untrusted content entered the run.

typeRunLimits.taintScope

RunLimits field: 'run' | 'thread'. Default 'run'. With 'thread', a taint recorded on one run is inherited by every later run sharing the same threadId.

typeRunLimits.taintLifetime

RunLimits field: 'persistent' | 'content-window'. Default 'persistent'. With 'content-window', inherited thread taint expires for a run once the tainting content leaves that run's loaded messages (recent + recalled) and working memory is empty; recall can revive it, working memory keeps it alive. Only meaningful together with taintScope: 'thread'.

classTaintedSideEffectError

Thrown under 'block'. detail: { toolName, toolCallId, taintSource: { toolCallId, toolName } }.

typeAnyTool.untrusted

AnyTool field: untrusted?: boolean. Marks a tool whose output is external/attacker-authorable content (a web fetch, an email body, a document). On success, the run is marked tainted (monotonic, first-wins, journaled).

fnmarkRunTainted

markRunTainted(journal, runId, taint): idempotent, first-wins, never throws. Called automatically for untrusted tools; exported so a processor can set taint dynamically.

fnreadRunTaint

readRunTaint(journal, runId): reads the current taint record for a run, or undefined if the run is not tainted.

typeRunTaint

RunTaint = { at, toolCallId, toolName, source: 'tool' | 'processor', reason? }.

fntaintGuardian

taintGuardian({ sensitiveTools, onTainted, otherwise? }) → Guard. Runs onTainted only when the run is tainted AND the called tool is in sensitiveTools; otherwise applies the otherwise decision (default { action: 'allow' }).

typeGuardCall.tainted

GuardCall field: tainted?: RunTaint. Set on every GuardCall when the run is tainted, so any guard — not only taintGuardian — can branch on the taint context.

Reflect here is NOT the duplicate guard's reflect
The taint guard's reflect nudges ONCE per distinct (tool, args) pair, and an identical retry AFTER the nudge EXECUTES — journaled as a 'warn' record, never escalating to a block. This is deliberately different from duplicate-guard's reflect, where an identical repeat after the nudge escalates to a block. Choose the ladder level with that distinction in mind.
Note
The default is warn, not off: behavior does not change, but every tainted side effect is named as a journaled incident with its taint source — so an operator can query it instead of it evaporating into a console line.
Related
See duplicate-guard for the sibling ladder that fires on a repeated call, human-in-loop-approvals for the Approvals flow that suspend routes into, and guard-policy for how ladders like this one compose into a policy.