Skip to content

feat(shapes): built-in ACP, model-direct and command-template solutions - #30

Merged
Ruqii merged 38 commits into
mainfrom
feat/built-in-shapes
Sep 13, 2026
Merged

Ruqii merged 38 commits into
mainfrom
feat/built-in-shapes

Conversation

@Ruqii

@Ruqii Ruqii commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three built-in solutions ("shapes") that a hand-written trap.yaml can use as its cmd:, so an agent product, a bare model, or someone else's program can be tested without a wrapper repo and without glue code:

Shape What it runs Example cmd:
tp shape acp any ACP agent (verified: claude-acp, codex-acp) tp shape acp --agent-cmd "npx -y @agentclientprotocol/claude-agent-acp@0.76.0" --agent-id claude-acp --model sonnet
tp shape direct one non-streamed call to a model API (Anthropic or OpenAI-compatible) tp shape direct --model claude-sonnet-5
tp shape cmd a one-line command template around someone else's program tp shape cmd --repo . --template "python {repo}/main.py --question {prompt}"

Each shape is an ordinary solution under the existing IO contract: the runner starts it as the solution subprocess, it reads TRAP_MANIFEST, prints the answer on stdout and exits with a fixed code. The runner, judge, grader, report.json and live sync are untouched, and the existing cost proxy meters the shapes like any other solution. tp shape is a hidden sub-app.

This is phase 1 of the solution-registry work: the building blocks only. The zero-config front end (tp run --agent claude-acp --model sonnet, a card recorded in the run's provenance, first-case confirmation) comes next, built on these interfaces.

What every shape guarantees

  • Fresh work directory per case: mkdtemp(prefix="trap-case-"), mode 0700, holding a copy of that case's inputs only. It is never under the task checkout or .trap/, and it is removed afterwards.

  • No links into the answers: a case whose inputs contain a symlink anywhere is refused with 24, and so is an acp --skill directory that contains one. A link could point at expected/, so the copy never creates or follows one. What trap cannot tell apart from ordinary files is documented as the task author's responsibility:

    • a hard link to an answer;
    • a copy of an answer.
  • The runner refuses a task that would hand a solution its answers (new, applies to every solution). tp run exits 2 before any case runs, and before any prompt or site traffic, if any of these holds:

    • something from the inputs root down to or inside a running case's inputs is a symlink;
    • an answers directory, meaning the expected root or any case's expected dir, lies inside a running case's inputs;
    • a case's answers directory is, or lies around, a case's directory;
    • a case id has a . or .. segment.

    Answers directories are resolved through links and compared by (st_dev, st_ino), so macOS case, Unicode and firmlink spellings count. The built-in shapes check again for hand-made manifests. The manifest and the IO contract are unchanged.

  • No answer leaks through the environment: the child never gets TRAP_MANIFEST, the task's manifest variable, or any variable naming the inputs root or a --scrub path. PATH HOME TMPDIR LANG SHELL TERM USER LOGNAME are never dropped. (This stops accidental discovery, not a determined same-user agent; the guide says so.)

  • No orphans: the child runs in its own process group. At --deadline (default 570 s, under trap.yaml's default 600 s timeout) the shape cancels (ACP session/cancel), then kills the whole group. SIGINT/SIGTERM do the same before the shape exits with 128+signal.

  • One exit-code table, the same for all three shapes: 0 answer · 20 refusal · 21 max_tokens · 22 max_turn_requests · 23 agent error (crash, protocol error, auth failure, a reply that is not an answer) · 24 config error (bad args, model/option not offered, unsupported input, command not found) · 124 deadline. Every failure prints one [trap] line on stderr, never a traceback. tests/test_shapes_backstop.py feeds garbage at every boundary to hold this.

ACP details (from a live probe on 2026-09-12)

  • initialize with protocolVersion: 1 and no fs/terminal capabilities. session/new gets an absolute cwd and mcpServers: [].
  • --model must exactly match a value the agent lists for its category == "model" option. For claude-acp these are aliases (sonnet, haiku, opus[1m] …), and the running values of all options are logged.
  • claude-acp isolation: _meta.claudeCode.options.settingSources = ["project"], so only the temp dir's .claude/ loads, not the runner's CLAUDE.md, skills, hooks or plugins. --skill <dir> installs into that .claude/skills/.
  • codex-acp: CODEX_CONFIG redirects to the cost proxy only under an API-key login. Under a ChatGPT login the redirect 401s, so the run goes unmetered instead.
  • Permissions: allow_once, else reject_once, else cancelled — never allow_always.
  • The answer is the agent's last message, split by messageId, not every chunk joined. A turn only counts as an answer if the agent did not report zero model use. The probe saw codex answer a 401 with end_turn and the error text as its message. Earlier messages and tool calls go to the case's stderr, which stays local.

Model-direct details

  • One non-streamed call. Anthropic gets max_tokens = 16000; OpenAI-compatible providers get no output cap. Thinking and effort stay at API defaults and are logged.
  • The request goes to <PROVIDER>_BASE_URL, so under tp run it passes through the cost proxy and is metered. This is tested end to end against a loopback vendor.
  • A case with any input file besides the prompt file is refused with 24, since model-direct sends the question only.

Changes outside src/trap/shapes/

  • src/trap/cost/providers.py: a public provider_config(name), appended.
  • src/trap/runner/leaks.py holds the answer-leak check. src/trap/runner/layout.py gains task_root, which the runner and the check share so both resolve the same roots. src/trap/errors.py holds ConfigError, moved to a leaf module and re-exported from trap.loader.errors. src/trap/cli/__init__.py runs the check first and escapes config-error text for Rich.
  • src/trap/cli/__init__.py: the hidden shape sub-app, which passes its arguments straight through, and pretty_exceptions_show_locals=False.
  • tests/conftest.py: case_capture, process_gone, and signal_handlers_unchanged helpers.
  • Docs:
    • new guide docs/guides/built-in-shapes.md;
    • ## tp shape in docs/reference/cli.md;
    • a pointer in writing-solution.md;
    • docs/manifest.json;
    • CODE_MAP.md edges cli --> shapes, shapes --> cost.

No new runtime dependencies: JSON-RPC over stdio is hand-written, and httpx was already declared.

Tests

  • Hermetic: a scripted fake ACP agent (tests/fake_acp_agent.py) and loopback HTTP vendors. No npx, no network, and nothing depends on the developer's ~/.codex, ~/.claude or API keys.
  • Gates, run locally: ruff check, ruff format --check, lint_docs.py, pyright src/ (0 errors), and pytest --cov — 1167 passed, 15 skipped at 100.00% branch coverage.

Not yet done

  • One live claude-acp case under tp run (about $0.05), to confirm answer, cost and cleanup against the real agent.
  • The claude-acp subscription path (the local login had expired at probe time) and codex's API-key metering path are still unverified.

🤖 Generated with Claude Code

Ruqii and others added 30 commits September 12, 2026 17:49
…ilt-in shapes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e's program

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t agent

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l broken-pipe path

Removes the "stdin is None" branch in close() by asserting once in __init__ and
storing self._stdin/self._stdout, instead of re-checking Popen.stdin's optionality
in three places for a case Popen(stdin=PIPE) never produces.

Replaces the object.__new__ + fake-unclosable-stdin stand-in for close()'s OSError
branch with a real one: a crashed agent's stdin read end goes away, so a notify()
afterwards hits a genuine broken pipe in _send, and close() then re-attempts the
same failing flush and must swallow the OSError from its own stdin.close() too.
The previous test's comment claiming that branch was unreachable was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…once, cancel at deadline

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The malformed-update test proved the mapping but didn't say so: content.text:
null makes MessageCollector.on_update raise, AcpConnection's reader thread (Task
3) turns that into an AcpError, and _converse maps it to AGENT_ERROR like any
other agent-side failure. Make that chain explicit in the test docstring and the
fake agent's comment so it isn't mistaken for dedicated malformed-input handling
in the collector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ends an empty answer

Two bugs from review round 1:

- _open_session and apply_config took `timeout` once and handed the full amount
  to every conn.call they made, so initialize could answer late and session/new
  (or the Nth set_config_option) still got a fresh full timeout — the deadline
  meant to bound the whole handshake/config step could be blown many times over,
  outliving the runner's own timeout and orphaning the agent. Both now treat
  timeout as a total budget: compute an end time once and pass what's left of it
  to each call.

- MessageCollector, for an agent that sends no messageId, only opened a new
  message lazily on the next chunk after a tool call. A turn that ended right on
  a tool call (no chunk after it) kept answering with the pre-tool-call text
  instead of "". Tool calls now close the running no-id message immediately via
  a shared _start_new_segment helper, which also absorbs a tool call already
  followed by another (or by nothing) without leaving a stray empty message.

Also reworded test_shapes_acp.py's section headers and comments that cited
"Ruling N" / "Task 3" / "Task 4" — process documents future readers won't have —
to describe the behaviour under test instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence

The ACP bridge's skill-install test only checked that ".claude" appeared in a
top-level directory listing, and the hints-level test only checked .is_file() —
neither would catch install_skill landing the skill at the wrong depth or with
truncated content. The fake agent now logs a full text dump of its cwd, keyed
by relative path, so both tests can assert on exact file content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etered by the proxy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reply

- --system-file is read as UTF-8 and a decode failure is a config error like
  any other unreadable file, not a bare UnicodeDecodeError traceback.
- parse_reply always returns a plain string for an OpenAI-compatible reply's
  text, even when a reasoning model (Mistral's magistral- included) sends
  content as a list of thinking/text parts instead of a string.
- a reply shaped nothing like the API (parse_reply raising AttributeError,
  TypeError or IndexError) is reported as an agent error instead of crashing.
- an OpenAI-style refusal field is read as a refusal (exit 20), not as a
  reply with no visible text (exit 23).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t code

- run_group decodes a program's output with bad bytes replaced, so a stray
  non-UTF-8 byte no longer crashes tp shape cmd and loses the answer (the
  runner reads the shape's stdout as text, so relaying raw bytes would only
  move the crash into it).
- A program exec refuses (no execute bit, bad shebang) is a config error, 24.
- A program killed by signal N exits 128 + N, as a shell reports it; -9 used
  to reach the runner as 247.
- Inputs that cannot be copied (a dangling symlink, an unreadable file) are a
  config error and the half-filled work directory is removed.
- The question is read as UTF-8 whatever the locale; one that is not valid
  UTF-8, or cannot be read, is a config error instead of a traceback.
- .DS_Store, Thumbs.db and desktop.ini are not input files, so a task folder
  opened once in Finder no longer makes tp shape direct refuse every case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ceback

Pending.wait now hands back only protocol-shaped values: a result that is not
an object reads as {}, and an error member of any shape (a bare string, a
non-integer or boolean code) becomes an ordinary AcpError. configOptions keeps
only its object entries, in session/new's reply and in a set_config_option
echo; a session/new configOptions that is present but not a list is a
protocol error (exit 23, "could not open a session"), not an agent that
offers no model. option_values tolerates a non-list options member, and
reported_no_model_use checks _meta and quota are objects and falls through to
the quota when usage carries no token counts (usage: {}).

--describe maps the same malformed replies to exit 23, and the reader thread
skips a stdout line nested too deep for the JSON parser instead of dying with
a RecursionError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nto ShapeExit codes

- An API key an HTTP header cannot carry (non-ASCII, or a control character
  such as a trailing newline) is a config error, 24, said without the key:
  httpx raised UnicodeEncodeError and h11 quotes a bad header value.
- A base URL httpx cannot use (InvalidURL, which is no HTTPError, or a
  missing/unsupported scheme) is a config error, 24.
- A reply whose choices is an object is "not in the expected format" (23):
  parsing catches LookupError, not only IndexError.
- The stop reason is read as text or None, so an unhashable one is an
  unexpected stop reason (23) instead of a TypeError in exit_for.
- tp's typer app never prints a traceback's locals, whatever typer's default:
  they can hold request headers, and the key with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One test per shape feeds garbage at each boundary it has — its arguments, the
case's manifest, question and inputs, and the program, agent or vendor on
the other side — and asserts only that the case ends in the exit table with
a [trap] line on failure and no traceback (an uncaught exception in the ACP
reader thread fails the test too). Branch coverage cannot hold this line: a
line that raises still counts as covered. Against the shapes as they were
before this fix wave, 21 of the 87 cases fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…shape exits

cmd and acp start their child in its own session, so the terminal's Ctrl-C
never reaches it, and under tp run the runner SIGKILLs the shape 0.25 s after
a Ctrl-C — so a Ctrl-C (or a TERM) used to leave the program, the agent and
the agent's own children running, and the work directory behind.

While a child's group runs (run_group for cmd; the agent's whole lifetime in
run_case and describe_agent for acp), the main thread now handles SIGINT and
SIGTERM itself: the handler SIGKILLs the group at once, then raises
SystemExit(128 + signal), so every finally on the way out still runs (the
work directory is removed) and the shape exits 130 or 143 with no traceback,
through python -m and tp shape alike. The previous handlers are always put
back. Off the main thread nothing is installed.

run_group also kills the group on any other exception unwinding through it,
and kill_group / kill_now treat EPERM like "gone": macOS answers it for a
group left with only an unreaped child, which is exactly the state an
interrupt leaves behind.

Tests signal the shape from outside (python -m trap.shapes.command and
python -m trap.shapes.acp against the hang_hard agent) and in-process, and
every shape test module now fails a test that leaves the SIGINT or SIGTERM
handler altered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to stderr

Only the last message is the answer, so when the pick is wrong the case's
stderr must show what came before it. The message collector now notes, as
they happen and in order with the permission notes: each earlier agent
message (cut to 500 characters) once a tool call or another message follows
it, and each tool call's title and status when it starts and whenever an
update changes its status. stdout is unchanged — still only the answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…b stop, exit codes as shipped

- tp shape acp needs an agent that offers a model option; it is verified with
  claude-acp and codex-acp only, and the guide no longer lists Gemini CLI or
  Cursor as agents it drives (the module and command docstrings neither).
- Claude Code's settings isolation applies only with --agent-id claude-acp;
  without it the runner's own CLAUDE.md, hooks and plugins load.
- The environment scrub prevents accidental discovery, not a determined
  agent: a same-user process can still read the shape's own environment.
- --describe runs with "a scrubbed environment, scrubbed the same way a
  case's is"; the cli reference synopsis shows --model as optional, required
  unless --describe.
- The exit-code paragraph for tp shape cmd matches what it now does (exec
  failures and bad inputs are 24, a signal-killed program is 128 + N, an
  interrupt 128 + the signal), and the guide covers the new behaviour: the
  question read as UTF-8, uncopyable inputs, interrupts, the stderr process
  log, malformed ACP replies, OS junk files and bad keys or base URLs for
  direct, and undecodable program output for cmd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integer check moved into the conditional expression that uses it, so
pyright knows the code handed to AcpError is an int. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…per exported

Both tests read CODEX_CONFIG back from the agent's environment, and the
case's env scrub has no reason to drop one the developer's shell exported,
so with CODEX_CONFIG set the ChatGPT-login test failed. They now unset it
first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… print

A reply carrying a lone UTF-16 surrogate (json.loads of a JSON escape like
"\ud800") has no UTF-8 encoding: print(reply.text) and print(outcome.answer)
raised UnicodeEncodeError straight through, an uncaught traceback and exit 1
where every path out of a shape's main() must end in a ShapeExit code.

print_answer in _case.py, used by direct and the acp bridge, tries the plain
print first and only on UnicodeEncodeError re-encodes with the bad character
replaced — the answer still prints, and the exit code still reflects the
reply's own stop reason. cmd already relays child output decoded with
errors="replace", so it needed no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mples both verified

The "answers that are not answers" paragraph overstated its case: a
non-object initialize or session/set_config_option result is read as empty
and the case carries on regardless, not a 23. Only a non-object session/new
result (no session id) or session/prompt result (no stopReason) surfaces
downstream as a failure — 23 when running a case, or, for session/new under
--describe, an empty options list and exit 0. A JSON-RPC error of any shape
and a non-list configOptions still reliably exit 23, as before.

The --agent-id row also listed gemini as an example id alongside claude-acp
and codex-acp, though the guide already says only those two are verified;
dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n reach the answers

CaseSandbox.open copied a case's inputs with shutil.copytree's default
dereferencing behaviour, so a symlink under inputs/ pointing at expected/
landed in the work directory as a regular file holding the real answer.

Copy with symlinks=True so a link is never dereferenced, then decide by
walking the work directory afterwards: any symlink found there — a file,
a directory, dangling, or pointing only inside the case's own inputs —
refuses the case (ShapeExit.CONFIG_ERROR) before any program or agent
starts, naming the links (capped) without ever printing what they point
at. inputs_dir itself being a symlink (only reachable via a hand-made
manifest) is refused the same way, checked before a work directory even
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t-file no longer leak

One copy helper (copy_tree_without_symlinks) now backs both the case's inputs and
--skill: it never creates a symlink in the destination, verifies the result with a
fail-closed walk (a directory the walk cannot read is a refusal, not a silent pass),
and gives the owner rwx on every directory it copies. The work directory itself is
chmod'd 0700 regardless of the inputs' mode, and removal (remove_tree, used by
close() and every refusal path) reclaims a missing write bit and retries instead of
leaving a trap-case-* directory behind. install_skill converts every failure to a
ShapeError so bridge.py never mislabels a bad --skill as "cannot start the agent".
--prompt-file can no longer be absolute or contain "..", which used to let the
existence check and the later read disagree about which file they meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… shape can't tell

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it, never costs the answer

A shape prints its answer after removing the case's work dir, so a cleanup
that raised threw the answer away and exited 1 with a traceback: rmtree's
error handler retried whatever call had failed, which for os.open (a 000
directory) is a TypeError, and for a 444 directory or a read-only one
holding a dangling link ended in ENOTEMPTY. The same handler chmod'd the
failing path's parent (TMPDIR, at the top) and the path itself through
os.stat — so a link in a read-only directory had its target outside the
work dir chmod'd.

remove_tree now first gives the owner rwx on the root and every real
directory under it, top-down and lstat-checked, each before it is listed —
never a symlink, never anything above the root — then runs rmtree with
every failure recorded and skipped, never retried. It never raises: what is
left is named in one "[trap] could not remove the work dir <path>: <reason>"
line on stderr. A refused case or skill is still exit 24 however its
cleanup went.

The work dir is also made 0700 as soon as copytree returns, before the copy
is walked, rather than after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r is named on stderr

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… work dir is always reported

remove_tree used os.path.lexists(root) to decide whether the work dir was
gone, but lexists returns False on any lstat error, not only "gone" — a
program that left the work dir's own parent unsearchable (its own chmod
000 ..) made the check silently agree the dir was removed. Only
FileNotFoundError from os.lstat(root) now means gone; every other lstat
error is reported.

The recording onexc callback for shutil.rmtree kept only the exception,
whose own message names just the bare entry a descriptor-based unlink
failed on ("f" for a failure on "d/f"). It now records (path, exception)
pairs, and the warning names the actual failing path.

run_group returned as soon as the program's own process exited, without
touching its process group — a program that backgrounded something (a
stray `sleep &`, output sent to /dev/null) left it running past the
shape's own exit, free to race the work dir removal that follows. The
whole group is now SIGKILLed before every return, deadline and interrupt
included them already; only the normal-exit path was missing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A solution is handed its case's inputs as a resolved path, so a case
directory that is, holds, or sits inside expected answers -- through a
symlink (inputs/c1 -> ../expected/c1, both linked to one shared folder,
inputs -> expected) or through dirs naming one directory for both --
hands every solution the answers, and no solution or built-in shape can
tell it from an ordinary directory.

answer_overlaps() compares each case's resolved inputs with its resolved
answers and the resolved expected root, by path component. TaskRunner
.refuse_answer_overlap() raises ConfigError naming the task and each
offending case with both paths; run() calls it before the first case, and
tp run calls it before a live session or site-graded run is opened, so a
refused task exits 2 with nothing started. A case linked to a folder
without the answers, and a missing expected dir, still run normally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ruqii and others added 8 commits September 13, 2026 14:41
…s such a task

writing-task.md and the dirs field state the rule, symlinks included.
built-in-shapes.md no longer leaves a case directory linked to the
answers to the task author: trap refuses that task before any case runs.
A hard link or a copy of an answer inside the inputs still is the
author's to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pe the message

The overlap check compared resolved path strings only, so on a filesystem
that ignores letter case or Unicode form (default macOS), or through a
firmlink, one directory spelled two ways passed: inputs/c1 -> ../Expected/c1,
dirs Data/ against data/, an NFD link against an NFC dir, and a
/System/Volumes/Data alias all handed the solution its answers. Directories
now also match by (st_dev, st_ino), for equality and for containment by
walking the child's parents; a stat that fails (missing, a link loop, no
search permission) matches nothing, and the path rule still applies.

Sitting inside the expected root now counts only outside the inputs root,
so dirs.expected: ./ runs, while inputs/c1 -> ../expected/c2 is still
refused. Each refused case says how its inputs overlap: are, contain or sit
inside its own answers or the expected root.

tp run refuses in the loader block, before the unanchored-provenance prompt,
any live session or site-graded run, or any case; TaskRunner.run() still
refuses for every caller. The message is escaped before Rich prints it, so
a case id such as "c[/x]" is shown as written instead of crashing the CLI.

ConfigError moves to the leaf module trap.errors, re-exported from
trap.loader.errors; the runner imports it from there, so it no longer
depends on the loader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he author

It follows links on each case directory's own path and on the inputs and
expected dirs, and compares the real directories, so a different letter
case or Unicode spelling on macOS counts. It does not look at links inside
a case directory (the built-in shapes refuse those), and a hard link or a
copy of an answer is still the author's to avoid. traptask-yaml.md states
the exact rule, including that an expected dir around inputs (./) is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot is around the inputs

The exemption for a case dir that sits inside the expected root but under
the inputs root was meant for an expected root around the inputs
(dirs.expected: ./). Where the expected root is inside the inputs root,
every case dir inside it is under the inputs root too, so the exemption
swallowed every such case: dirs {inputs: cases/, expected: cases/_answers/}
with cases/c1 -> _answers/c2, and {inputs: ./, expected: expected/} with
c1 -> expected/c2, both ran and handed c1 the answers of c2.

The exemption now also requires the expected root not to be inside (or be)
the inputs root. It only narrows the exemption, so it refuses nothing new
beyond those layouts: dirs.expected: ./ and a clean dirs.inputs: ./ task
still run. The traptask.yaml reference states the corrected rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot just its own

The overlap check compared a case's inputs with its own answers and with
the expected root, so a case whose inputs are another case's answers ran:
with dirs {inputs/, ./}, case ids c2 and inputs/c2 put inputs/c2's answers
at c2's inputs (and inputs/c2/key's inside them); the same with
{data/in/, data/} and in/c2; an inputs root linked into c2's answers put
c1's inputs inside them while only c1 was selected; and, with default dirs,
expected/c2 -> ../inputs/c1 made c1's inputs exactly c2's answers. Each
handed a solution another case's answers.

Now the inputs of every case about to run are checked against the answers
of every case the task defines, selected or not, skipped included: the same
directory or nested either way, by path and by identity on disk. Both sides
are indexed by path and identity and each directory's parents are looked up
in the other side's index, so the check stays linear in cases times path
depth, stat'ing each directory once; a directory that can't be stat'ed
matches by path alone. The refusal names the other case whose answers the
inputs would hold; the root clauses are unchanged and come last.

The docs state exactly what is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lution is handed, no answers inside it

Replace the overlap machinery in runner/task.py with runner/leaks.py. Before any
case runs, trap refuses a task when:

- a case id is empty, absolute or has a ".." component;
- a running case's inputs hold a symlink anywhere from below the inputs root down
  to and inside the case directory (the inputs root itself may be a link), or a
  directory there cannot be read;
- the expected root or any defined case's answers directory is, or sits inside, a
  directory of a running case's inputs;
- any defined case's answers directory is a running case's directory or one of
  its ancestors.

Directories are compared by (device, inode). A case directory linked to a clean
folder, a link inside a case, an unreadable directory inside a case and an id
with a ".." that stays inside are now refused; the built-in shapes keep their own
link check for hand-made manifests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or around it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d mid-walk

The leak check walked and stat'ed traptask_dir / dirs.* as written, while the
runner hands the solution and the judge those roots resolved. Resolving takes a
".." after a missing or unsearchable folder by its text, where the OS fails on
it, so with dirs.inputs "gone/../inputs/", an inputs root linked to
"gone/../stored", or dirs.expected "gone/../inputs/c1/ans/", the check saw no
inputs or no answers while the solution was handed a real folder nobody walked.
Both now resolve every root through one helper, layout.task_root.

A file or folder vanishing during the walk now refuses the task instead of
leaving the rest of that case unchecked; only a case folder that isn't there
counts as no inputs.

Docs: no case's answers directory may be or lie around a case's directory (the
expected root may), and an answer placed in the inputs isn't checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Ruqii
Ruqii merged commit c7aad93 into main Sep 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant