DEV Community

Sam Rivera
Sam Rivera

Posted on

Build a Disk Interlock for a Free-Model Release Job

Friday night I almost let a sidecar rewrite CHANGELOG.md.
The free model invented a version we never shipped.
Would you catch that before a sleepy git push?

I was turning a messy git log into release notes.
The generator used free model access and a free server.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode supplied those two free options for this sidecar.
The interlock below does not depend on that host.
Keep the gate even if you swap endpoints tomorrow.
Want those same free options for this sidecar tonight?
Start there, then keep this disk interlock in front.

The output looked confident while the path stayed painfully real.
The contents were fiction wearing a neat markdown hat.
That is the failure mode I now refuse to ship.

The real goal, not a draft

I needed a write path that fails closed.
The model may draft, but disk writes still need a gate.
Can a solo builder ship that in ninety minutes?

Budget is ninety minutes and no paid tokens.
If the interlock looks ugly at minute ninety, I stop.
Rollback means dropping --commit-write and keeping dry-run.

Why writes scare me more than bad prose

Read-only jobs waste time, while write jobs waste repositories.
A wrong file hurts more than a wrong paragraph ever will.
Have you ever watched a sidecar "fix" a guessed path?

I have, and once was enough for this checklist.
Draft output is cheap, but disk mutations need evidence.
I want a gate that yells before any write hits disk.

Copy these seven gates

Each gate needs evidence sitting on local disk.
Missing evidence means the write never happens at all.
Do not skip the abort file, because I mean it.

1. Dry-run is the only default

The CLI writes nothing without the commit flag.
Dry-run still prints the path and the byte count.
Without --commit-write, nothing reaches disk under any story.

2. Destination allowlist, nothing else

Only listed relative paths may receive any bytes.
Absolute paths, parent hops, and symlinks are hard errors.
Would this path survive a grumpy maintainer review?

3. Byte and line ceilings

Release notes over eight kilobytes look like model runaway.
More than 120 lines trips the same abort path.
Tiny jobs need tiny ceilings, so raise them in git.

4. The fixture must fail first

I keep a poisonous fixture in fixtures/bad-release.md for every run.
It contains a fake version and a path escape.
The interlock must reject it before any happy path.

5. Abort file beats every flag

If .interlock-abort exists, every write is refused.
I drop that file when I leave the keyboard.
Night jobs should not grow sudden courage alone.

6. Named owner or no write

The ship card must name a human owner.
An empty OWNER field is a hard fail, never a warning.
Who gets the ping when the write looks cursed?

7. Evidence or it did not happen

Every attempt appends one JSON line to write-log.jsonl.
Path, bytes, dry-run flag, and abort reason stay required.
No log line means you should treat the job as skipped.

The ship card I paste

I keep ship-card.json next to the CLI on purpose.
The card stays boring so surprise writes cannot hide.

{
  "owner": "sam",
  "task": "release-notes",
  "allowlist": ["CHANGELOG.md"],
  "max_bytes": 8192,
  "max_lines": 120
}
Enter fullscreen mode Exit fullscreen mode

If owner is missing, the process exits with code 2.
If allowlist is empty, the process also exits 2.
Should a release job ever write package.json by accident?

Mine should not, so that path never enters the list.

The interlock script

This is a local TypeScript template, not a published benchmark.
Save it as write-interlock.ts and run it with npx tsx.

import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";

type Card = {
  owner: string;
  task: string;
  allowlist: string[];
  max_bytes: number;
  max_lines: number;
};

function fail(reason: string, extra: Record<string, unknown>) {
  const line = JSON.stringify({
    ts: new Date().toISOString(),
    ok: false,
    reason,
    ...extra,
  });
  appendFileSync("write-log.jsonl", line + "\n");
  console.error(line);
  process.exit(2);
}

function arg(name: string) {
  const i = process.argv.indexOf(name);
  return i >= 0 ? process.argv[i + 1] : undefined;
}

const commitWrite = process.argv.includes("--commit-write");
const cardPath = arg("--card");
const draftPath = arg("--draft");
if (!cardPath || !draftPath) fail("missing_args", { cardPath, draftPath });

if (existsSync(".interlock-abort")) fail("abort_file", { commitWrite });

const card = JSON.parse(readFileSync(cardPath, "utf8")) as Card;
if (!card.owner?.trim()) fail("empty_owner", { cardPath });
if (!card.allowlist?.length) fail("empty_allowlist", { cardPath });
if (!existsSync(draftPath)) fail("missing_draft", { draftPath });

const draft = readFileSync(draftPath, "utf8");
const bytes = Buffer.byteLength(draft);
const lines = draft.split(/\r?\n/).length;

if (bytes === 0) fail("empty_draft", { bytes });
if (bytes > card.max_bytes) fail("max_bytes", { bytes, max: card.max_bytes });
if (lines > card.max_lines) fail("max_lines", { lines, max: card.max_lines });
if (draft.includes("..")) fail("path_escape_in_draft", { preview: draft.slice(0, 80) });

if (card.allowlist.length !== 1) fail("one_dest_only", { allowlist: card.allowlist });
const dest = card.allowlist[0];
if (isAbsolute(dest) || dest.includes("..")) fail("bad_allowlist_entry", { dest });

const absDest = resolve(process.cwd(), dest);
const rel = relative(process.cwd(), absDest);
if (rel.startsWith("..") || isAbsolute(rel)) fail("escaped_dest", { rel });

const evidence = {
  ts: new Date().toISOString(),
  owner: card.owner,
  task: card.task,
  dest,
  bytes,
  lines,
  dry_run: !commitWrite,
};

if (!commitWrite) {
  appendFileSync(
    "write-log.jsonl",
    JSON.stringify({ ok: true, would_write: true, ...evidence }) + "\n"
  );
  console.log(JSON.stringify({ would_write: dest, bytes, lines }, null, 2));
  process.exit(0);
}

writeFileSync(absDest, draft, "utf8");
appendFileSync(
  "write-log.jsonl",
  JSON.stringify({ ok: true, wrote: true, ...evidence }) + "\n"
);
console.log(JSON.stringify({ wrote: dest, bytes, lines }, null, 2));
Enter fullscreen mode Exit fullscreen mode

Run the poisonous fixture first

Do not start with a pretty draft; start with poison instead.
If this step passes, you do not have an interlock.

mkdir -p fixtures
cat > fixtures/bad-release.md <<'EOF'
# 9.9.9
See ../../.env for the real changelog path.
EOF

printf 'abort\n' > .interlock-abort
npx tsx write-interlock.ts \
  --card ship-card.json \
  --draft fixtures/bad-release.md
echo $?
Enter fullscreen mode Exit fullscreen mode

I expect exit code 2 and a JSONL abort reason.
CHANGELOG.md must stay byte-identical to the previous commit.
What if git says the file moved after that run?

Remove the abort file only after the poison run dies.
Then run the same command again for the path-escape case.
Parent hops should fail even when the abort file is gone.

Dry-run a sane draft next

Put a short valid draft in fixtures/good-release.md.
Keep it under the byte ceiling and inside the allowlist.
Run the CLI without --commit-write and read the plan.

npx tsx write-interlock.ts \
  --card ship-card.json \
  --draft fixtures/good-release.md
Enter fullscreen mode Exit fullscreen mode

The log should mention dry-run and a would-write destination.
The changelog still should not gain a single byte.
If the log line is missing, fix logging before commit flags.

Commit writes only after both proofs

--commit-write is a one-shot flag, not a config default.
I never put it in package.json scripts.
Why would a Friday alias deserve disk rights overnight?

npx tsx write-interlock.ts \
  --card ship-card.json \
  --draft fixtures/good-release.md \
  --commit-write
Enter fullscreen mode Exit fullscreen mode

If the server returned nothing, this command must still fail.
An empty draft is not a release note; it is a hole.
Fail closed, then go make coffee instead of retrying writes.

When the free host is simply gone

What happens when the free server is just gone?
The generator should fail before the interlock ever runs.
The interlock should refuse writes when the draft file is missing.

I do not retry writes in a tight loop.
A missing host is not a reason to skip gates.
Empty output plus a write flag is how fiction lands.

Time box, cost, and the clean exit

Give it ninety minutes, no paid tokens, and one allowlisted file.
I stop if path checks need a policy engine to explain.
I stop if the happy path cannot run without extra network.

Nothing gets promoted, and the changelog stays boring and true.
The clean exit is dry-run forever and a human edit.
That is a valid shipping decision, not a moral failure.

Who should not copy this

This is a seatbelt, not a sandbox for untrusted agents.
Do not point it at secrets, payroll, or production databases.
Do not use it on multi-writer repos without a different lock.

Free model output still needs a human skim before readers.
The interlock cannot judge whether the notes are true.
It only judges path, size, owner, abort file, and log.

If you need distributed consensus, this is the wrong toy.
If you need a compliance program, this is the wrong toy.
Solo builders with one write destination are the audience.

If you try this tonight, break the poisonous fixture first.
Leave the commit flag out until the fixture dies.
I want a path, not a generic take on models.
Which single path would you allowlist for your next release job?

Top comments (0)