In the previous article, I introduced pi-secured-setup — a pi extension that adds Guards, Scanners, and an audit trail to your AI coding agent. It ships with sensible defaults: boundary enforcement, protected path globbing, bash command classification, secret redaction, skill verification.

But every project has unique risks. A Terraform shop needs different rules than a Node.js monorepo. A team with strict compliance requirements needs different audit granularity than a solo developer.

The good news: Guards and Scanners are just pure functions, and the pipeline is pluggable. Today I’ll show you how the internals work, how to write your own, and how to test them with confidence.

How the Pipeline Works Under the Hood

ADR-0001: One Handler to Rule Them All

The entire guard system registers a single tool_call event handler on pi’s extension API. Not three handlers. Not one per module. One.

Why? Pi doesn’t guarantee handler ordering. If each Guard registered its own tool_call listener, a tool call touching both boundary and protected paths could produce two confirmation dialogs. Or worse, the dangerous command could be approved by the bash handler before the boundary handler had a chance to block it.

The single handler calls pure evaluation functions in a fixed order:

evaluateBoundary() → evaluateProtectedPaths() → classifyCommand()

First block wins. No short-circuit past a confirmation. One verdict per tool call, one audit entry.

Pure Functions, No Pi Dependency

Each Guard module exports a single function with this signature:

function evaluateBoundary(
  toolName: string,
  input: Record<string, unknown>,
  config: Config,
): GuardVerdict

Notice what’s missing: no ExtensionAPI, no ctx, no event objects. The function takes plain data and returns plain data. It has zero knowledge of pi’s existence.

This is deliberate. The pipeline orchestrator (which does depend on pi) handles all the messy stuff — UI dialogs, audit logging, blocking the tool call. The evaluation functions are pure logic, testable with a simple assert.equal().

The GuardVerdict Type

Every Guard returns one of three verdicts:

type GuardVerdict =
  | { action: "allow" }
  | { action: "block"; reason: string }
  | { action: "confirm"; message: string };
  • allow: the check passes, move to the next guard in the pipeline
  • block: the action is rejected immediately, no user interaction
  • confirm: show a dialog to the user, block if they decline

The pipeline handles the UI interaction. The Guard just says what should happen.

The Orchestrator

Here’s what guard-pipeline.ts actually does — it’s the glue between pure functions and pi’s API:

pi.on("tool_call", async (event, ctx) => {
  const config = getConfig();
  const toolName = event.toolName;
  const input = event.input as Record<string, unknown>;

  // Step 1: Boundary
  const boundaryVerdict = guards.evaluateBoundary(toolName, input, config);

  if (boundaryVerdict.action === "block") {
    auditLog("boundary.block", "warning", { ... });
    return { block: true, reason: boundaryVerdict.reason };
  }

  if (boundaryVerdict.action === "confirm") {
    const approved = await ctx.ui.confirm("🔒 Boundary Check", boundaryVerdict.message);
    if (!approved) {
      auditLog("boundary.block", "warning", { ... });
      return { block: true, reason: "User denied: outside boundary" };
    }
    auditLog("boundary.confirm", "info", { ... });
  }

  // Step 2: Protected Paths
  // ...same pattern...

  // Step 3: Bash Gate (bash tool only)
  // ...same pattern...
});

The orchestrator does four things the pure functions never touch:

  1. Gets the current config (via closure, supports hot-reload)
  2. Shows ctx.ui.confirm() dialogs when a Guard returns confirm
  3. Writes to the audit log
  4. Returns { block: true } to pi when appropriate

Anatomy of a Guard

Let’s build a real one. Say your team has a rule: never commit .env files. Not “confirm before committing” — never. The existing protected-paths Guard blocks writes to .env, but what if someone stages an .env and then runs git commit? That’s a bash command, not a file write. The boundary guard won’t catch it. The bash gate’s default patterns don’t catch it either.

Here’s the Guard:

// lib/no-dotenv-commit.ts
import type { Config } from "./config.js";
import type { GuardVerdict } from "./boundary.js";

export function evaluateNoDotenvCommit(
  toolName: string,
  input: Record<string, unknown>,
  config: Config,
): GuardVerdict {
  // Only applies to bash
  if (toolName !== "bash") return { action: "allow" };

  const command = input.command as string | undefined;
  if (!command) return { action: "allow" };

  // Match "git commit" commands
  if (!/\bgit\s+commit\b/.test(command)) return { action: "allow" };

  // Check if .env files appear in the command or common patterns
  // This is a heuristic — a full implementation would check `git diff --cached --name-only`
  const envPatterns = /\.env\b|\.env\./;
  if (envPatterns.test(command)) {
    return {
      action: "block",
      reason: "committing .env files is forbidden by policy",
    };
  }

  return { action: "allow" };
}

No pi import. No ExtensionAPI. Just a function that takes data and returns a verdict.

Plugging It In

In the entry point (extensions/security.ts), you add it to the pipeline’s evaluator object:

import { evaluateNoDotenvCommit } from "../lib/no-dotenv-commit.js";

registerGuardPipeline(
  pi,
  () => config,
  {
    evaluateBoundary,
    evaluateProtectedPaths,
    evaluateNoDotenvCommit,  // ← new
    classifyCommand,
  },
);

Then in guard-pipeline.ts, add the call in the desired position — after protected paths but before bash classification makes sense:

// Step 3: No-dotenv-commit check
if (toolName === "bash") {
  const dotenvVerdict = guards.evaluateNoDotenvCommit(toolName, input, config);
  if (dotenvVerdict.action === "block") {
    auditLog("dotenv-commit.block", "warning", { tool: "bash", command: input.command });
    if (ctx.hasUI) ctx.ui.notify(`🚫 Blocked: ${dotenvVerdict.reason}`, "warning");
    return { block: true, reason: dotenvVerdict.reason };
  }
}

Done. The Guard is in the pipeline, the orchestrator handles UI and audit, and the evaluation logic is a pure function you can test in isolation.

Anatomy of a Scanner

Scanners are different from Guards in one fundamental way: they never block. They observe data, optionally transform it, and report. The secret scanner is the canonical example — it modifies the provider payload to redact secrets, but it never prevents a tool from running.

The before_provider_request Hook

The secret scanner hooks into before_provider_request — the event that fires after pi has built the provider-specific payload but before it sends it to the LLM. The handler receives the entire payload as a plain object, can modify it, and return the modified version.

pi.on("before_provider_request", (event, _ctx) => {
  const redactions: Redaction[] = [];
  const payload = event.payload as Record<string, unknown>;

  walkAndRedact(payload, redactions);

  if (redactions.length > 0) {
    // Log and notify...
    return payload;  // Return modified payload
  }

  return undefined;  // No changes — keep original
});

Building a Custom Scanner: PII Redaction

Let’s say your organization handles customer data, and email addresses must never reach the LLM context. Here’s a scanner for that:

// lib/pii-scanner.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import type { Config } from "./config.js";
import { auditLog } from "./audit.js";

const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;

function redactEmails(value: string): { result: string; count: number } {
  let count = 0;
  const result = value.replace(EMAIL_PATTERN, (match) => {
    count++;
    return "***REDACTED:email***";
  });
  return { result, count };
}

function walkAndRedactEmails(obj: unknown, depth = 0): number {
  if (depth > 50 || obj === null || obj === undefined) return 0;

  if (typeof obj === "string") {
    const { result, count } = redactEmails(obj);
    if (count > 0) {
      // Strings are immutable in the payload, so we can't mutate in place.
      // In practice, you'd return the modified object — see walkAndRedact in
      // secret-scanner.ts for the full recursive mutation pattern.
      return count;
    }
    return 0;
  }

  let total = 0;
  if (Array.isArray(obj)) {
    for (let i = 0; i < obj.length; i++) {
      total += walkAndRedactEmails(obj[i], depth + 1);
    }
  } else if (typeof obj === "object") {
    for (const key of Object.keys(obj as Record<string, unknown>)) {
      total += walkAndRedactEmails((obj as Record<string, unknown>)[key], depth + 1);
    }
  }
  return total;
}

export function registerPiiScanner(pi: ExtensionAPI, _getConfig: () => Config): void {
  pi.on("before_provider_request", (event, _ctx) => {
    const payload = event.payload as Record<string, unknown>;
    const count = walkAndRedactEmails(payload);

    if (count > 0) {
      auditLog("pii.redacted", "warning", { patternName: "email", count });
    }

    return undefined;  // In a real implementation, return the modified payload
  });
}

Same pattern as the secret scanner: walk the payload, match patterns, replace, log. The scanner is provider-agnostic — it doesn’t know or care whether the payload is Anthropic, OpenAI, or Google format. It just finds strings and transforms them.

Writing Tests — No Mocks Needed

Here’s where the pure function architecture pays for itself. Since every Guard is a plain function that takes data and returns data, you can test it with plain Node.js assertions. No mock frameworks. No fake pi instances. No API keys.

The makeConfig Helper

Every test file starts the same way — a helper that builds a valid Config object with sensible defaults, overridable per-test:

function makeConfig(overrides: Partial<Config> = {}): Config {
  return {
    cwd: "/home/user/project",
    protectedPaths: { patterns: [], writeAction: "block", readAction: "confirm" },
    commandRules: { safe: [], moderate: [], dangerous: [], external: [] },
    allowedExternal: { paths: [] },
    audit: { maxFileSize: 10_000_000, maxFiles: 3 },
    ...overrides,
  };
}

This is the only boilerplate you’ll ever need.

A Real Test

Here’s an actual test from the suite — boundary.test.ts:

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { evaluateBoundary } from "../lib/boundary.js";
import type { Config } from "../lib/config.js";

describe("evaluateBoundary", () => {
  it("blocks write outside boundary", () => {
    const config = makeConfig();
    const result = evaluateBoundary(
      "write",
      { path: "/home/user/other-project/file.ts" },
      config,
    );
    assert.equal(result.action, "block");
  });

  it("confirms read outside boundary", () => {
    const config = makeConfig();
    const result = evaluateBoundary(
      "read",
      { path: "/home/user/other-project/file.ts" },
      config,
    );
    assert.equal(result.action, "confirm");
  });

  it("allows write outside boundary if in allowed-external", () => {
    const config = makeConfig({
      allowedExternal: { paths: ["/tmp"] },
    });
    const result = evaluateBoundary("write", { path: "/tmp/output.txt" }, config);
    assert.equal(result.action, "allow");
  });

  it("allows bash commands (ADR-0003)", () => {
    const config = makeConfig();
    const result = evaluateBoundary("bash", { command: "rm -rf /" }, config);
    assert.equal(result.action, "allow");
  });
});

Four tests. Four assertions. Zero mocks. Each test creates a config, calls the function, checks the verdict. That’s it.

Testing the Custom Guard

For our no-dotenv-commit Guard, the tests write themselves:

describe("evaluateNoDotenvCommit", () => {
  it("allows non-bash tools", () => {
    const result = evaluateNoDotenvCommit("write", { path: ".env" }, makeConfig());
    assert.equal(result.action, "allow");
  });

  it("allows git commit without .env", () => {
    const result = evaluateNoDotenvCommit(
      "bash",
      { command: "git commit -m 'fix: typo'" },
      makeConfig(),
    );
    assert.equal(result.action, "allow");
  });

  it("blocks git commit with .env reference", () => {
    const result = evaluateNoDotenvCommit(
      "bash",
      { command: "git commit .env.production -m 'add env'" },
      makeConfig(),
    );
    assert.equal(result.action, "block");
  });
});

Running the Full Suite

npm test
▶ splitCommand
  ✔ splits by pipe
  ✔ extracts subshells
  ✔ handles no pipes or subshells
  ✔ handles multiple pipes
✔ splitCommand
▶ classifySegment
  ✔ classifies ls as safe
  ✔ classifies npm as moderate
  ✔ classifies rm -rf as dangerous
  ✔ classifies curl as external
  ✔ returns null for unknown commands
✔ classifySegment
▶ evaluateBoundary
  ✔ allows bash commands (ADR-0003)
  ✔ blocks write outside boundary
  ✔ confirms read outside boundary
  ✔ allows write outside boundary if in allowed-external
  ...
▶ redactString
  ✔ redacts AWS access keys
  ✔ redacts Anthropic keys
  ✔ redacts private key headers
  ✔ redacts GitHub tokens
  ✔ skips comment lines entirely
  ...
ℹ tests 98
ℹ suites 15
ℹ pass 98
ℹ fail 0
ℹ duration_ms 171ms

98 tests in 171ms. No network calls. No API keys. No flaky waits. Pure functions mean deterministic, instant tests.

The Bug the Tests Caught

The test suite caught a real bug during development. Here’s the story.

The secret scanner has false positive mitigation — it skips lines that look like comments. The original implementation checked if a line starts with --:

// BUGGY VERSION
function isCommentLine(text: string): boolean {
  const trimmed = text.trim();
  if (trimmed.startsWith("--")) return true;  // ← catches too much
  ...
}

This worked for SQL comments (-- comment) and INI comments (--password=secret). But it also caught something it shouldn’t:

-----BEGIN RSA PRIVATE KEY-----

PEM private key headers start with -----, which starts with --. The isCommentLine function returned true, the scanner skipped the line, and private key headers were never redacted.

The test caught it immediately:

it("redacts private key headers", () => {
  const { result } = redactString("-----BEGIN RSA PRIVATE KEY-----");
  assert.ok(result.includes("***REDACTED:private-key***"));  // ← FAILED
});

The fix: require -- to be followed by a space or end-of-line, distinguishing SQL comments (-- comment) from PEM headers (-----BEGIN):

// FIXED VERSION
if (/^--(?:\s|$)/.test(trimmed)) return true;

This is exactly why you test edge cases. The bug was subtle, the fix was one line, and the test ensures it never regresses.

Practical Patterns and Pitfalls

Order Matters

Guards run in a fixed sequence. A guard that blocks should come before one that confirms, when both could apply to the same tool call. In pi-secured-setup:

boundary → protected-paths → bash-gate

Boundary is checked first because it’s the broadest filter — “is this file even in the right project?” Protected paths is second because it’s a more specific check — “is this file sensitive?” Bash gate is last because it only applies to one tool.

If you’re adding a custom guard, think about where it fits in this progression.

Don’t Over-Block

Use block for clear dangers. Use confirm for gray areas. A guard that blocks everything without recourse will get disabled — and then you have no protection at all.

The bash gate uses this principle well:

CategoryVerdictReasoning
SAFEallowNo risk, auto-approve
MODERATEallowLow risk, auto-approve but log
DANGEROUSconfirmReal risk, let the user decide
EXTERNALconfirmData leaving the machine, let the user decide
UnknownconfirmCan’t assess the risk, let the user decide

Only two categories actually block without asking: block verdicts in boundary (write outside project) and protected paths (write to .env). Everything else gives the user a choice.

Regex Performance

Keep patterns simple. The secret scanner walks every string in the entire provider payload on every turn. A pathological regex on a large payload will slow down every LLM call. Test your patterns against realistic inputs — not just matches, but also against strings that almost match.

The Depth Limit

The walkAndRedact function has a depth limit of 50 levels. This prevents infinite recursion on circular references (which shouldn’t exist in a JSON payload, but defensive coding costs nothing). If your custom scanner walks deeply nested objects, consider adding a similar guard.

Conclusion

The extension is designed to be extended. Every guard is a pure function — (toolName, input, config) → verdict — with no pi dependency. The pipeline orchestrator handles the messy stuff. Scanners follow the same philosophy: observe, transform, report. Never block.

This architecture means you can add your own security rules without understanding pi’s internals. Write a function. Write tests for it. Plug it into the pipeline. The 98 existing tests are your safety net — they ensure the core logic doesn’t break as you extend it.

The source is on GitHub. If you build a guard or scanner that others could use — a PII scanner, a PCI-DSS compliance check, a custom command classifier — I’d love to see a PR.

What guard or scanner would you write for your team? Let me know in the comments!