Use and extend a vetted write script
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.
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
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
-
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, andALLOWED_ACTIONS, the action list. That header is your map; skip a full read.sed -n '5,18p' scripts/post-pr-comment.sh -
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.shThe whole output is one line, and the exit status agrees with it, so the command can gate a runner:
PASS 7 checks -
Dry-run the wrapper against the fixture. Read the
DRY RUNline 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 testNow 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 testThe allowlist exists. One command says how far it gets:
grep -n 'ALLOWED_REPOS' scripts/post-pr-comment.shOne hit: the line that reads
allowlist.txtinto 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. -
Write the failing test first, in
scripts/post-pr-comment.test.sh, in the block marked for this exercise and above the finalif [ "$failures" -eq 0 ]; then. Assert that a repository outsideALLOWED_REPOSis refused: a non-zero exit, a message naming the repository, and a refusal word in that message (not allowedorrefus), because theDRY RUNline 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 checksRed first is the only evidence that your check can fail, and a check that cannot fail is what this wrapper already had.
-
Start Claude Code in
day2-workwithclaudeand 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. -
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.shPASS 8 checksThen run the two dry runs from step 3 again.
local-repostill prints its endpoint and body;not-on-the-listnow 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) -
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.jsonoutside 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. -
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 theread_tokenfunction.
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.
cd .. && rm -rf day2-work && cp -R sandbox/swift day2-work && cd day2-work
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
enterprise-12—gh pr comment <number> --body "<text>"is the CLI form of the write this wrapper stands in front of. From gh pr comment, accessed 2026-09-22.enterprise-13,enterprise-14— the REST endpointPOST /repos/{owner}/{repo}/pulls/{pull_number}/comments, and thegh apicall that reaches it with the same fields. From Create a review comment for a pull request and Pull request comments, accessed 2026-09-22.cc-core-21,enterprise-07— a permission rule isTool(pattern), as inBash(gh pr view *).cc-core-22— rules are evaluated deny, then ask, then allow, and the first match wins regardless of specificity.enterprise-09— a bareBashin the deny list removes the tool entirely, where a scoped rule blocks only what it matches. All from Permissions, accessed 2026-09-22.enterprise-11—.claude/settings.jsonis the shared, committed file;.claude/settings.local.jsonis personal and not shared.cc-core-39— “Yes, and don't ask again” saves the rule to.claude/settings.local.jsonat the root of the git repository, and outside one beside.claude/settings.json. From Settings and Permissions, accessed 2026-09-22.cc-core-32— the version checked against, from the Claude Code changelog, 2026-09-22.- Terminal output here was recorded on 2026-09-23 in a fresh copy made by this page's setup block.