Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

House rulesLesson

Protected paths

Some changes need a person to merge them, and a list of protected paths says which. If that list lives in the same repository as the changes it judges, a change can edit the list and let itself through. Terrarium reads the list from main, which an open pull request can't change.

Know protected-path lists already? Skip to In Terrarium.

Outside the box · General

The concept

A check that decides whether a change may merge reads some settings: which files are sensitive, who may approve them. If it reads them from the branch under review, the branch is judging itself. A pull request that removes a file from the sensitive list and changes that file at the same time passes, because the check sees the shortened list.

The fix is to read the settings from the branch the change merges into. That copy already went through review, and no open pull request can alter it. A change to the list itself then waits for review like any other, and takes effect only once it merges.

Two more habits keep such a check honest. It fails closed: if it can't read the list, it refuses rather than treating an empty list as "nothing is sensitive". And it counts every path the change touches, including the old path of a renamed file, so moving a file out of a protected folder still counts as touching it.

Looking inside

In Terrarium

Terrarium calls its protected paths the list. It is the paths field of one file, .claude/rules/adr-0004-human-merge.md:

.claude/rules/adr-0004-human-merge.md ↗3–26as of
3paths:
4  - "content.config.ts"
⋯  # …
17  - ".claude/rules/**"
⋯  # …
22  - "scripts/github/merge-pr.ts"
⋯  # …
25---
26# ADR-0004: Files that need a human merge

That file does two jobs. It is a path-specific rule, so when an agent reads or edits a listed file, Claude Code loads its text, which tells the agent that a person will merge the PR. And it is the list the merge script enforces. One list serves both, so the warning and the refusal can't disagree.

Agents merge their own PRs with scripts/github/merge-pr.ts. Before it waits for CI, it compares every file the PR touches, a rename's old path included, with the list as it stands on main:

scripts/github/merge-pr.ts ↗437–529as of
437function readHumanMergeGlobsFromMain(cwd: string): string[] {
438  try {
439    git(['fetch', 'origin', 'main'], cwd, { stdio: 'ignore' })
440    return rulePaths(git(['show', `origin/main:${HUMAN_MERGE_RULE}`], cwd))
441  } catch {
442    return []
443  }
444}
⋯// …
529    const humanRefusal = humanMergeRefusal(prFilePaths(files), readHumanMergeGlobsFromMain(cwd))

The script fetches main first, then reads the rule with git show, so neither the branch's copy nor a stale local one counts. Any failure along the way returns an empty list, and humanMergeRefusal refuses the merge on an empty list. The globs are matched with Node's path.matchesGlob. The rule file sits under .claude/rules/, which the list covers, so a PR that edits the list is itself merged by a person. tests/unit/merge-pr.spec.ts covers the refusals.

The same reading serves one more caller: .agents/skills/clear-backlog/scripts/list-human-only-paths.ts prints the Human-only paths a branch touches, also read from origin/main and also failing on an empty list, so a backlog run can say in its PR description that the PR waits for a person.

Why it's built this way

  • Code enforces the line. A person used to merge every PR, so risky files were named only in prose. Once agents merged on a green (Merge gate says why), code had to enforce that line. The cost: the list must also cover what keeps self-merging safe, such as the merge script, trust tiers, the Gate and the list's own file. ADR-0004 (docs/adr/0004-objective-safety-gate.md).
  • One list, one place. text repeated the list, and the copies went stale at each change. Now it exists once, as the paths of a , which Claude Code already reads to decide when to show that rule (Path-specific rules). The cost: it is the one ADR summary rule that holds a fact of its own. ADR-0029 (docs/adr/0029-instructions-load-with-the-files-they-govern.md).
  • Old and new paths. A security review found the gate checked only a renamed file's new path, so renaming a test into a docs folder skipped the tests. Now every file list feeding a decision counts both paths, and the merge script adds the old name GitHub reports. The cost: even a harmless rename out of a protected folder waits for a person. docs/agents/git-conventions.md.
  • Code matches paths. An agent reported no Human-only files for a branch that edited rules, so its PR description was wrong. Matching paths to patterns is a job for code, so a script works them out from the branch's changes and main's list. The cost: it reads the local origin/main without fetching, so a stale clone can give an old answer. .agents/skills/clear-backlog/scripts/list-human-only-paths.ts.

What to watch for

  • A new entry protects nothing until it merges. A PR that adds a path to the list is judged by main's list, and so is every other open PR until it lands.
  • Only some reasons are enforced. The script sees which files a PR touches, who opened it, from where, and its labels; other reasons a person merges, such as a change no test can observe, or a PR built from a Public request (Author trust says why it looks Trusted), are the agent's to notice (Merge gate).
  • One kind of PR skips the list. A same-repo Dependabot PR that changes only package.json and its lockfile, and moves each version within its major (a 0.x minor counts as a major), is checked by dependabotBumpRefusal instead; one from a fork, or with any other change, still goes to a person.
  • The refusal lives in the script. It holds for merges that go through merge-pr.ts, the one merge path agents are told to use.

Try it in your project

Find the check in your project that decides whether a change needs a second reviewer. Make it read its list from the target branch, not the change's own copy, make it refuse when the list can't be read, and put the list file on the list.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/terrarium-practice/human-only-list