Use and extend a vetted write script

Day 2: Loops and your environment · Exercise 4 of 4 · 15 min

Run the team's write wrapper against a local fixture, add the one guardrail it is missing with a test that fails before it and passes after, and point at the exact line where a write leaves the agent's reach.

Setup

Every command on this page runs from inside day2-work, the copy of your lane at the course root; the shipped sandbox/ is never edited. Paste one block from the course root. It makes the copy if it is missing and leaves your shell in day2-work with the wrapper, its self-test and the fixture in place, and puts the starter settings example at .claude/settings.json, the shared file a team commits, unless Exercise 3's is already there. The lane ships a settings.json with no permission rules, and the example replaces that one.

Swift lane
mkdir -p work && { [ -d day2-work ] || cp -R sandbox/swift day2-work; } && cd day2-work && cp -R ../day-2-loops-and-your-environment/starter/scripts ../day-2-loops-and-your-environment/starter/fixtures . && chmod +x scripts/*.sh && mkdir -p .claude && { grep -q '"permissions"' .claude/settings.json || cp ../day-2-loops-and-your-environment/starter/settings.example.json .claude/settings.json; } && ls scripts fixtures/local-repo .claude
Kotlin lane
mkdir -p work && { [ -d day2-work ] || cp -R sandbox/kotlin day2-work; } && cd day2-work && cp -R ../day-2-loops-and-your-environment/starter/scripts ../day-2-loops-and-your-environment/starter/fixtures . && chmod +x scripts/*.sh && mkdir -p .claude && { grep -q '"permissions"' .claude/settings.json || cp ../day-2-loops-and-your-environment/starter/settings.example.json .claude/settings.json; } && ls scripts fixtures/local-repo .claude

It lists the four files under scripts, the fixture, and the settings.json under .claude. From here both lanes run identical commands. The wrapper has no network path out of the box: a send appends to a file under fixtures/local-repo, laid out like the REST path repos/{owner}/{repo}/pulls/{pull_number}/comments.

Steps

  1. Read the wrapper's header before you run it. It states the five properties a reviewer checks and names two places: read_token, the one function that reads the credential, and ALLOWED_ACTIONS, the action list. That header is your map; skip a full read.

    sed -n '5,18p' scripts/post-pr-comment.sh
  2. Run the self-test and confirm it is green before you change anything. A test suite you did not watch pass is not a baseline.

    bash scripts/post-pr-comment.test.sh

    The whole output is one line, and the exit status agrees with it, so the command can gate a runner:

    PASS 7 checks
  3. Dry-run the wrapper against the fixture. Read the DRY RUN line it prints: that line, plus the body under it, is the entire contract between the agent and the outside world.

    bash scripts/post-pr-comment.sh --dry-run --repo local-repo --pr 1 --body "smoke test"
    DRY RUN: would POST repos/local-repo/pulls/1/comments
    smoke test

    Now run it against a repository nobody approved, and watch it agree to that too.

    bash scripts/post-pr-comment.sh --dry-run --repo not-on-the-list --pr 1 --body "smoke test"
    DRY RUN: would POST repos/not-on-the-list/pulls/1/comments
    smoke test

    The allowlist exists. One command says how far it gets:

    grep -n 'ALLOWED_REPOS' scripts/post-pr-comment.sh

    One hit: the line that reads allowlist.txt into a variable. Nothing ever consults it. The wrapper declares an allowlist and never checks it, a defect that survives review because the file looks like it already has the guardrail.

  4. Write the failing test first, in scripts/post-pr-comment.test.sh, in the block marked for this exercise and above the final if [ "$failures" -eq 0 ]; then. Assert that a repository outside ALLOWED_REPOS is refused: a non-zero exit, a message naming the repository, and a refusal word in that message (not allowed or refus), because the DRY RUN line already names the repository. Copy check 4 as the pattern, and call the wrapper with --dry-run, as step 3 did, so the check never reaches the confirmation prompt. Change no existing check. Then run the self-test and watch it go red. With the reference check in place it prints:

    FAIL a repository outside the allowlist should exit non-zero and name it (exit 0, got: DRY RUN: would POST repos/not-on-the-list/pulls/1/comments
    x)
    FAILED 1 of 8 checks

    Red first is the only evidence that your check can fail, and a check that cannot fail is what this wrapper already had.

  5. Start Claude Code in day2-work with claude and paste this prompt in full.

    Read scripts/post-pr-comment.sh and scripts/post-pr-comment.test.sh. Make the failing check 8 in the test file pass: the script must refuse any repository not listed in ALLOWED_REPOS, exit non-zero, and print a message saying the repository is not allowed and naming it. Change nothing outside scripts/, and do not change any existing test. When you are done, run bash scripts/post-pr-comment.test.sh and show me the full output.
  6. Run the self-test yourself, not the agent's account of it, and confirm the new case passes with the seven old ones:

    bash scripts/post-pr-comment.test.sh
    PASS 8 checks

    Then run the two dry runs from step 3 again. local-repo still prints its endpoint and body; not-on-the-list now prints a refusal naming itself and exits non-zero. The reference guardrail's refusal:

    post-pr-comment: repository not allowed: not-on-the-list (allowed: local-repo)
  7. Open day2-work/.claude/settings.json. It is not the personal .claude/settings.local.json, which a “Yes, and don't ask again” approval writes at the root of the git repository, or beside .claude/settings.json outside one. Encode the split there in two rules: the wrapper is allowed by path, the raw command it wraps is denied.

    {
      "permissions": {
        "deny": [
          "Bash(gh pr comment:*)",
          "Bash(gh api:*)"
        ],
        "allow": [
          "Bash(bash scripts/post-pr-comment.sh:*)"
        ]
      }
    }

    Write the rule against the command you actually type. Every run on this page is bash scripts/post-pr-comment.sh; Lesson 4 writes the pair for ./scripts/post-pr-comment.sh, a different command string.

    Add those entries to the lists already in the file rather than replacing it. Deny is evaluated before allow and the first match wins regardless of specificity, so the two raw commands, as the agent writes them, are refused while the wrapper runs. Both denials are scoped: a bare "Bash" in the deny list removes the tool altogether, including the command that runs the wrapper.

  8. Write a three-sentence paragraph in ../work/e4.md, in your own words: where the write left the agent's reach, what the agent saw, and what it never saw. If the third is hard, open the read_token function.

If you see this instead

bash: scripts/post-pr-comment.test.sh: No such file or directory. Your shell is not in day2-work, which a new terminal tab does, or the setup block was never pasted. From the course root, cd day2-work. Paste the setup block only if ls scripts finds nothing there: a second paste copies the starter scripts over your edits.

The self-test hangs with no output, or prints PASS 8 checks before any guardrail exists. Your check calls the wrapper without --dry-run, so it stopped at the confirmation prompt, which $(...) hides. Press Ctrl-C and add --dry-run.

The self-test still says PASS 7 checks after you added your check. Your check is below the final if [ "$failures" -eq 0 ]; then block, which prints the verdict and exits before reaching it. Move it above that line, into the block marked for this exercise.

The self-test still says FAILED 1 of 8 checks after the agent's change, and the FAIL line reports exit 0 with a DRY RUN endpoint in it. The new guardrail landed after the DRY_RUN branch, which prints and exits before the allowlist is ever consulted. A guardrail that only holds on a real send is not a guardrail: the refusal has to happen before the dry run branches.

Self-check

Run this, from inside day2-work

bash scripts/post-pr-comment.test.sh; t=$?; bash scripts/post-pr-comment.sh --dry-run --repo not-on-the-list --pr 1 --body probe >/dev/null 2>&1; g=$?; bash scripts/post-pr-comment.sh --dry-run --repo local --pr 1 --body probe >/dev/null 2>&1; s=$?; if [ "$t" -eq 0 ] && [ "$g" -ne 0 ] && [ "$s" -ne 0 ]; then echo "GUARDRAIL OK"; else echo "GUARDRAIL MISSING (self-test $t, refusal $g, near-miss $s)"; fi

It runs the suite, asks the wrapper to write somewhere nobody approved and then to local, a near miss that is a substring of local-repo, and prints one verdict from the three exit statuses. Right after the setup block, before your change:

PASS 7 checks
GUARDRAIL MISSING (self-test 0, refusal 0, near-miss 0)

When you are finished it prints PASS 8 checks and GUARDRAIL OK. The GUARDRAIL line is the one that matters: a suite is green either because the guardrail works or because nothing asked it to.

Done when: the check prints GUARDRAIL OK, the self-test reports PASS 8 checks with all seven original checks still in the file, .claude/settings.json carries the deny and allow rules, and ../work/e4.md holds your paragraph.

Reference answers

Open these after your own check and run exist. The shapes below are not the only ones that pass.

The reference test case

Added in the marked block, above the final verdict:

# 8. A repository outside allowlist.txt is refused by name.
out="$(bash "$WRAPPER" --dry-run --repo not-on-the-list --pr 1 --body x 2>&1)"; rc=$?
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'not-on-the-list' && printf '%s' "$out" | grep -Eq 'not allowed|refus'; then pass
else fail "a repository outside the allowlist should exit non-zero and name it (exit $rc, got: $out)"; fi

Three assertions in one check, and all are deliberate. The non-zero exit is what a caller can act on; the repository name in the message is what a person reading a log can act on. The name alone proves nothing, because the DRY RUN line already contains it, so the check also asks for the word of a refusal. The check uses --dry-run on purpose, because a guardrail that a dry run can walk past is a guardrail that never sees the argument it was written for.

The reference guardrail

In scripts/post-pr-comment.sh, replacing the marked comment block — after the action allowlist, before ENDPOINT is built and before the DRY_RUN branch:

# Guardrail: the repository allowlist.
case $'\n'"$ALLOWED_REPOS"$'\n' in
  *$'\n'"$REPO"$'\n'*) : ;;
  *) die "repository not allowed: $REPO (allowed: $(echo $ALLOWED_REPOS))" "$EX_REFUSED" ;;
esac

The newlines around both sides are what make it a line-by-line match rather than a substring match, so a repository called local or repo, which is a substring of local-repo, is refused rather than waved through. $EX_REFUSED is the wrapper's existing exit status for a refusal, which is why check 4 and check 8 can report the same kind of failure in the same way. Placement is the part most runs get wrong: above ENDPOINT, so that neither the dry run nor a real send can reach the network path without passing the allowlist.

Why the allowlist is the guardrail that matters

The wrapper's header lists five properties, and with yours there are six; only two of them decide anything. A dry run shows you what would happen, which is worth a great deal at the moment a person is reading the screen and nothing at all at three in the morning in the fourth iteration of a runner. The confirmation prompt has the same shape: it is an excellent control while somebody is there to answer it, and --yes exists because often nobody is.

The action allowlist and the repository allowlist are different. They decide the same way whether or not anyone is reading, they decide before the credential is ever read, and their decision does not depend on how convincingly the caller asked. That is the property you want from the thing standing between an unattended loop and other people's pull requests. The log line is the third that survives an empty room, and it is evidence rather than control: it tells you afterwards what left, which is what you will want on the morning you find out something did.

Reset

Every Day 2 exercise resets the same way: quit Claude Code, then from inside day2-work run the line for your lane. It replaces the copy with a fresh one from the shipped lane.

Swift lane
cd .. && rm -rf day2-work && cp -R sandbox/swift day2-work && cd day2-work
Kotlin lane
cd .. && rm -rf day2-work && cp -R sandbox/kotlin day2-work && cd day2-work

It leaves ../work alone, so your paragraph outlives it. A fresh copy is the shipped lane and nothing else: scripts/, fixtures/ and the .claude/settings.json holding your two rules are gone, so copy what you want into ../work first, and re-run this page's setup block before you continue.

Sources