Trials P-59 and P-60: the read tools held to their folder, and a tarball's one redirect to codeload

Merged
DO
5d ago
+3111-17

Refs #537. Trials P-59 and P-60, both pass. They are the evidence for the design in which the review Role reads a pull request's head. That design's record, 0138, is not in this pull request.

P-60: a commit's tarball is one redirect to codeload

The Maintainer approved "Tarball, allow codeload" on 2026-11-04. P-60 records what that redirect is, with every request made without a credential against the public canary:

AskAnswer
GET /repos/{canary}/tarball/{sha}, following no redirect302 to https://codeload.github.com/{owner}/{repo}/legacy.tar.gz/{sha}, with no token in the Location
That Location, sent with no Authorization header200, application/x-gzip, 4,337 bytes, with no content-length. The tar opens with a pax global header whose comment is the whole commit sha, then one folder holding only directories and regular files
Codeload over plain HTTP301 to the same address over HTTPS, the only redirect codeload gave
Codeload with the owner and repo in capitals, or with the branch name for the sha200, the same archive
A commit the canary does not holdThe API still answers 302; codeload answers 404

What 0138 takes from this:

  • The transport may follow one redirect to codeload and must refuse a second hop.
  • It holds its own size bound, since codeload sends no content-length.
  • A head that isn't there shows up as codeload's 404.
  • The unpacker can check the pax comment against the commit it asked for.

The fixture keeps the archive's bytes in base64, so 0138's tests can unpack exactly what GitHub served.

Owed: what codeload's Location holds for a private repo read with an App's token. A live Run on a private head waits on it; the eval doesn't, because its cases' repo is public.

P-59: the read tools alone, held to their folder

The question. On a GitHub Linux runner, with bubblewrap and the subprocess scrub set as the run job sets them, is the pinned CLI (2.1.306), given only the read tools, held to its working folder?

  • Is every read outside that folder refused?
  • Do the folder's own .claude settings, CLAUDE.md or AGENTS.md take effect?
  • Does any write land?

How it ran. In the canary, run 41772353972, after the reads' fix round, with no secret and no model: a stand-in model, P-50's stub, ran on the runner itself. The CLI was installed by its checksum, as the run job installs it, and no sysctl was changed. The run before the fix round, 41756788469, also passed; it is kept in the fixture and superseded.

The configuration a review Run would be given:

--tools Read,Grep,Glob --allowedTools Read,Grep,Glob --permission-mode dontAsk --restricted --setting-sources ""
Probeengine (both flags)--restricted alone--setting-sources "" aloneneither
Read inside the folder (control)okokokok
18 calls outside: absolute, .., three links, /proc/self/cwd through links, /proc/self/root, the home directory, by Read, Grep and Globall refusedall refusedreadread
/proc/self/environ and the parent's environ, by Readrefusedrefusedrefusedrefused
The same, by Greprefusedrefusedread the CLI's environmentread the CLI's environment
Bash, Writenot toolsnot toolsnot toolsnot tools
The head's two hooks and its apiKeyHelperdid not rundid not rundid not runall three ran
The head's CLAUDE.md and AGENTS.md sent to the modelnononoyes
Verdictpasswould passfailsfails

The verdict is decided by the engine configuration alone; the other three are recorded.

What the four configurations show:

  • --restricted is what confines the reads.
  • --setting-sources "" alone holds no read. With the scrub set, the CLI forces the permission mode back to its default, and the allowed tools, named without a path, then read anywhere.
  • Either flag keeps the head's settings and instructions out.
  • A real Run's environment holds its credential, so a review Run is given both flags.

The trial's other findings:

  • With the scrub set, the CLI forces the permission mode back to its default unless the allowed tools are named, so they are named.
  • Grep does not follow a link inside the folder.
  • A smoke run on a Mac gave the same results, apart from the /proc reads, since a Mac has no /proc.

What this pull request changes. Every row below that mentions P-59 is new.

PartChange
tools/trial_p59_read_confinement.shThe trial's runner. Its pass condition was written before the run, at the top of the file. It runs only in the canary's workflow
tools/trial_p50_stub.pyWhen calls.json stands where the stub runs, it answers those calls in turn, and logs the tools it offered and the markers each result held, by name only. Without that file it behaves exactly as P-50's stub did, and its check_repo allowlist is unchanged
trials/P-59.jsonThe fixture: three pushes and their runs, the first refused by the script's own guard since OUT was under /home, the second superseded by the fix round, and the third the trial's run; that run's job, the three files as they ran, the log, and the analysis. Each file the canary ran has the same git blob as the file in tools/
tests/test_tools.pyReadConfinementTrial: a stand-in CLI that holds the reads to the folder under --restricted, and keeps the settings out under either flag, as the pinned CLI did, plus flags that make each part of the condition fail on its own. One test covers the stub's scripted mode, and one re-reads the fixture's analysis from its log
trials/P-60.jsonP-60's seven exchanges, the archive among them
Section 6 and trials/AGENTS.mdSection 6 gains P-59's and P-60's rows, and trials/AGENTS.md P-59's rules with the test that holds each. A test re-reads P-60: the one redirect, codeload's answer with no Authorization header, the tar's pax comment and its members, and no redirect from codeload over HTTPS
tools/mutants.pyP-59's 18 mutants: one for each part of the condition, one for each flag, and two for the stub

Grounding. No rule in sim/grounding.py asks this trial, so nothing is grounded.

Canary, undone. The branch trial/p-59 is deleted. The three runs stay, as P-50's and P-51's did; their logs hold no secret, and the redactor removed one line, line 93.

Removed from the fixture: exchanges[1,3,5,6,7,8,9].request.headers.authorization, and line 93 of the log in exchanges[10].response.body.

Verify

  • make verify passed locally at 710400a9. At 5dd3c9ea, the fix round's head, it passed too.
  • At 5dd3c9ea, make checks passed, and so did ReadConfinementTrial and both fixture tests.
  • python3 -m tools.mutants tools/trial_p59_read_confinement.sh tools/trial_p50_stub.py killed 19 of 19.
  • The whole run is CI's checks and its 8 mutant shards.

Reads

Argon, Muse Wonder Full, the Claude review and the security pass all read 490a36d. Every finding was fixed in 5dd3c9ea, in part where its disposition says so, apart from one not changed. The fix round was not read again.

ReviewerHeadUnread partsBlockingMajorMinorInfo
agy490a36dnone0670
muse490a36dnone0210
claude-review490a36dnone0130
claude-security490a36dnone0030
FindingSeverityCriterionWhereWhatDisposition
agy#1majorPR-3tests/test_tools.py:5272P-59 test skips calls[0] on outside-read check and passes on vacuous call listsFixed in 5dd3c9ea: the fixture test counts the 18 outside calls and holds each refused, and each part's 24 calls and 3 write calls by is True
agy#2majorPR-3tests/test_tools.py:5314P-60 HTTPS redirect loop can pass vacuously and leaves ex[3..4] and probe URLs uncheckedFixed in 5dd3c9ea: each codeload address parsed with urllib.parse, exchanges 3 and 4 checked, any 3xx counted
agy#3majorPR-3tests/test_tools.py:7973ReadConfinementTrial has no case where reading inside the working folder failsFixed in 5dd3c9ea, as the Claude review's #1: a refused inside read, with its mutant
agy#4majorPR-3tests/test_tools.py:8041List-shaped tool_result content in the stub test carries no marker, leaving list marker extraction unheldFixed in 5dd3c9ea: the stub test's list-shaped result carries a marker
agy#5majorPR-3tests/test_tools.py:8065Pre-run CLI/version refusals are untested in test_each_refusal_stops_before_anything_is_asked, and refusal runs inherit host envFixed in part in 5dd3c9ea: refusals run in an environment of their own, and a missing CLI is refused. The script refuses no version before the call; it records it and fails the verdict, as the moved-version case holds
agy#6majornonetrials/P-59.json:5Trial P-59 leaves Grep/Glob symlink paths, parent environ Grep, and Glob filename leaks untestedFixed in 5dd3c9ea: Grep and Glob through the links and of the parent's environ, an outside file named for its marker; P-59 run again
agy#7minorPR-3tests/test_tools.py:5264P-59 copied_whole check passes on a single-key dict without a tools/ entryFixed in 5dd3c9ea: both keys checked, and each blob compared with git's blob of the file on disk
agy#8minorPR-3tests/test_tools.py:5267P-59 test is named only_with_both_flags but checks only 'engine' and 'unguarded'Fixed in 5dd3c9ea: the restricted and sources configurations, each held by the fixture test
agy#9minorPR-3tests/test_tools.py:5293P-60 checks 'authorization' and 'content-length' headers case-sensitivelyFixed in 5dd3c9ea: every header name in the fixture is held to lower case
agy#10minorPR-3tests/test_tools.py:7966ReadConfinementTrial never asserts cleanup of TMPDIRFixed in 5dd3c9ea: a TMPDIR of each run's own, held empty after
agy#11minorPR-3tests/test_tools.py:7983write-lands fails no_write_tool and nothing_written only togetherFixed in 5dd3c9ea: a write that lands but says it failed fails nothing_written alone
agy#12minornonetrials/P-59.json:5Removing both flags together in "unguarded" does not show that each flag individually mattersFixed in 5dd3c9ea: each flag run alone; --setting-sources "" alone held no read
agy#13minornonetrials/P-60.json:46Anonymous codeload exchanges record request.as as person don-petry instead of anonymousNot changed: tools/check_trials.py requires a command-line read to be the Maintainer's own; each codeload call says it sent no credential
muse#1majornonetools/trial_p59_read_confinement.sh:240P-59 passes when Glob outside the folder succeeds, since the judge only looks for marker textFixed in 5dd3c9ea, as the security pass's #1
muse#2majorPR-3tools/trial_p59_read_confinement.sh:245Mutant setting P-59's inside_read to True survives: no case denies the inside readFixed in 5dd3c9ea, as the Claude review's #1
muse#3minornonetools/trial_p59_read_confinement.sh:236P-59 judges offered tools from the first request alone, so a tool added later is unrecordedFixed in 5dd3c9ea, as the Claude review's #2
claude-review#1majorPR-3tools/trial_p59_read_confinement.sh:245Three parts of P-59's pass condition are held by no test; their mutants survive ReadConfinementTrialFixed in 5dd3c9ea: a refused inside read, a Task ended early and Bash answered, each a case and a mutant
claude-review#2minorPR-1tools/trial_p50_stub.py:9The PR closes no issue, and the issue the stub names, 517, is unrelatedFixed in 5dd3c9ea: every request's tools, with a late-tool case and a mutant
claude-review#3minorPR-3tools/trial_p59_read_confinement.sh:186The /proc/self/cwd probe normalises to a path that does not exist, so it cannot show confinementFixed in 5dd3c9ea: the /proc/self/cwd probes go through links; run again, refused under --restricted and read without it
claude-review#4minorPR-3tools/trial_p59_read_confinement.sh:236read_tools_alone reads only the first request's tools, so a tool offered later in the conversation passesFixed in part in 5dd3c9ea: the stub names the Maintainer's decision of 2026-11-04. This PR refs #537, opened for the work; 0138's PR closes it
claude-security#1minorPR-3tools/trial_p59_read_confinement.sh:240P-59's verdict cannot see a Glob that lists the outside folder: a Glob escape still passesFixed in 5dd3c9ea: each outside call must be an error, and an outside file is named for its marker
claude-security#2minorPR-3tools/trial_p59_read_confinement.sh:240An outside marker that reaches the model outside a tool result is recorded but never fails the engineFixed in 5dd3c9ea: a marker sent anywhere to the model counts
claude-security#3minorPR-3tools/trial_p59_read_confinement.sh:243Two guards against a vacuous P-59 pass are held by no test: every_call_answered and inside_read mutants surviveFixed in 5dd3c9ea, as the Claude review's #1

Bots:

  • CodeQL, "Incomplete URL substring sanitization" in the P-60 test: fixed in 5dd3c9ea. Each address is parsed with urllib.parse and its host compared whole.
  • SonarCloud S5131, "Reflected XSS via file content", at tools/trial_p50_stub.py:161: not a vulnerability. The stub answers JSON, Content-Type: application/json, on 127.0.0.1, to the CLI under trial. No browser reads it, and the content is the trial's own calls, which the script writes. The gate is not required. Marking it safe in Sonar is the Maintainer's to do.
  • Codex hit its quota, and CodeRabbit skipped the PR: auto reviews are off.

šŸ¤– Generated with Claude Code