Academyby Dasow

Stop 8 of 10 · Weekly

Guardrails as code

One forty-line module every writing tool calls: allowlist, target check, append-only log, dry run by default and approval for the actions that need it, with six tests on the guardrails themselves.

Watch first · 1:13

One module, every writing tool

Stop 6 called guard and moved on. Here is what Dany and Mo put inside it. It is short on purpose: a guardrail nobody can read in one sitting is a guardrail nobody audits.

const ALLOW = {
  'board.create_ticket': { host: BOARD_HOST, project: STUDIO_PROJECT_ID, approval: false },
  'board.close_ticket':  { host: BOARD_HOST, project: STUDIO_PROJECT_ID, approval: true }
};

export async function guard({ action, target, payload, apply = false, approval = null }) {
  const rule = ALLOW[action];
  if (rule === undefined) throw new Error(`guard: ${action} is not on the allowlist`);

  const url = new URL(target);
  const targetOk = url.host === rule.host && url.pathname.includes(`/projects/${rule.project}/`);
  if (targetOk === false) throw new Error(`guard: ${target} is outside the allowed project`);

  await appendLog({ at: new Date().toISOString(), action, target, apply, payload: digest(payload) });

  if (apply === false) return { dryRun: true, action, target, payload };
  if (rule.approval === true && approval === null) throw new Error(`guard: ${action} needs an approval token`);

  return send(url, payload);
}

Read it in the order it runs. An action that is not a key in ALLOW stops immediately, before anything parses a url. The target has to match both the host and the project path, so a valid token pointed at the wrong project fails here rather than in somebody else's board. The log is written next, so a dry run leaves a trace too. Then the dry-run exit. Then approval, last, because a dry run never needs one.

digest(payload) stores a hash and the first line, not the body. The log is for answering what was attempted, not for keeping a second copy of every ticket.

The log is a chain

Each line carries the hash of the line before it. That costs four lines of code and turns the log from a file somebody could tidy into one where tidying is visible.

Field Why it is there
at, action, target What was attempted and where
apply Whether this was a dry run, so the file shows both
payload A digest and one line, never the body
prev The hash of the previous line
Write the guardrail tests
Write a node --test file that tests my guard module itself, not the tools that call it. Six tests.

An action missing from the allowlist throws and no log line is written. A target on the allowed host but a different project id throws. With apply absent, no network call happens at all: replace the sender with a spy that fails the test if it is called once. An approval-required action with a null token throws even when apply is true. A send that throws still leaves its attempt line in the log. And a log file with one line edited fails chain verification, naming the first bad line.

Use a temporary log file per test and clean it up either way.
Ask what the module misses
Here is my guard module: [paste lib/guard.mjs].

You are reviewing it as somebody who wants to get a write past it without editing this file. List every way you can find, ranked by how likely a rushed developer is to do it by accident rather than on purpose. For each, say whether the fix belongs in this module, in the calling tool or in code review, and which of my six tests would have caught it.

Do not rewrite the module. I want the list first.

Dany's review turned up two. A tool could build its own request and never call guard at all, which is now a test that greps the repository for fetch( outside lib/. And approval accepted any non-null value, which is now a token checked against an expected one.

Quick check

Try it

Report a bug or share feedback