Code review · P3 Task 28, each job's files and the work directory¶
review-code on autopilot/P3-t28, base 3a103a9, 2026-10-31. The step is P1 Task 28, the first task of milestone M8 (P2 §8), a security boundary (confinement, req. 13). The brief also gave it SEC-P3T27-03 (stage sources read off the writer thread). It adds:
packages/spectio-server/src/spectio_server/files/:confine.py(the fd-relative walk of checking-scenarios §11.1),pool.py(the file pool with a timeout),redact.py(the.command.runlexer andstrip_escapes),taskfiles.py(the allow-list, the listing states, the path checks for events);api/taskfiles.py:GET /tasks/{id}/attempts/{n}/filesand…/files/{name};api/logs.pyrewritten on the same walk and pool;- the store:
attempts.work_dir,attempts.native_id,attempts.logs(migration0006); the projector keeps each path only under a root of the run and counts the others; - the SDK:
snakemake.slurm_submittedandjob_error.aux.external_jobid; - the stage freeze reads its sources in a file pool, 10 s at most;
- the head script:
NXF_LOG_FILE,unset NXF_DEBUG,--slurm-keep-successful-logs;nf-spectio:workdirandnative_idon every task event,enableMetrics()true; - tests:
tests/test_taskfiles.py(23 tests),tests/api/test_taskfiles_api.py(18 tests, 20 cases),test_serve.py::test_boot_freeze_pool_apart, two engine tests inpackages/spectio/tests/test_bridge_nextflow.py.
The reviewers were code-reviewer and security-reviewer, independent and in parallel. Format: README.md. The rules are owned by dev-env §15.3 req. 13, architecture §13.7, api §5, §7 and §14.1, and checking-scenarios §11.1; the notes of 2026-10-31 in each are assumed under inbox #94.
| Round | Head | code-reviewer | security-reviewer |
|---|---|---|---|
| 1 | 2465328 (the step) |
changes (1 major, 5 minors, 2 simplify) | changes (1 blocker, 6 minors) |
| 2 | 297ba97 (quoted export words, arrays, whole here-document bodies) |
changes (1 major, 4 minors) | changes (1 major, 6 minors, 1 simplify) |
| 3 | f362fc5 (no link followed for a job file) |
approve (1 minor) | approve (6 minors) |
| 4 | 0fee201 (nesting limit, OSError mapping, name limits, the freeze's own pool) |
approve (4 minors) | approve (2 minors) |
| simplify | d4f41fc |
code-simplifier: changed, −13 +1 (one commit) |
The reports were relayed by the coordinator in summary. Finding IDs restart in each round, so the same ID can name different findings; this record writes the round after each ID: SEC-P3T28-04 (r1), (r2), (r3) are three reports of one problem, CR-P3T28-03 (r1), (r2), (r3) three different findings.
Test-first. The worker reports that each fix was written as a test first and seen to fail. doc-writer re-checked the last one (0fee201): with the five source files of f362fc5 put back, the five new tests (7 cases) fail, and with HEAD restored all 7 pass (P3-M8 Evidence).
Gate at d4f41fc, from the worktree, re-run by doc-writer:
uv run --locked pytest packages/spectio-server/tests/test_taskfiles.py \
packages/spectio-server/tests/api/test_taskfiles_api.py -v # 43 passed
uv run --locked pytest -q # 611 passed, 17 deselected
uv run --locked ruff check . # All checks passed!
uv run --locked ruff format --check . # 100 files already formatted
uv run --locked mypy packages # Success: no issues found in 99 source files
uv run --locked lint-imports # Contracts: 4 kept, 0 broken.
uv lock --check # Resolved 55 packages
# engine tests, Nextflow 26.10.4 (SHA-256 = NEXTFLOW_SHA256 of dev/versions.env):
uv run --locked pytest packages/spectio/tests/test_bridge_nextflow.py -m engines -v
The engine result is on P3-M8. P1 Task 28 Step 4 on slurm-mini is not run: Task 22 is not built.
CODE step=P3-t28 review=approve b/M/m=0/2/14 security=approve b/M/m=1/1/20 rounds=4 simplify=changed -13/+1 tests=pass
The counts add up the four rounds per reviewer. The three simplify findings (CR-P3T28-07 and -08 r1, SEC-P3T28-09 r2) are not counted.
Findings¶
| Id | Severity | Finding | Status |
|---|---|---|---|
| CR-P3T28-01 (r1) | major | The .command.run redaction showed the value of a quoted export or declare word and of an array assignment: export "TOKEN=abc", export 'TOKEN=abc' and arr=(sec1 sec2) were served unchanged. Req. 13 says every export value is hidden. |
fixed in 297ba97: a quote that opens the whole word after a declaring word is skipped, an array value is read to its ) and hidden whole. doc-writer probe on d4f41fc: the three give export TOKEN=«hidden» twice and arr=«hidden» |
| CR-P3T28-02 (r1) | minor | An OSError other than ENOENT, ELOOP, ENOTDIR, EACCES, EPERM or ENXIO in the task-file routes gave a 500. |
fixed in 0fee201 with SEC-P3T28-04 (r3) |
| CR-P3T28-03 (r1) | minor | test_req13_spectio_dir writes .spectio/nextflow.log but never asserts it is served; test_req13_freeze_reads_off_the_writer checks only the time and the run state. |
open: both still so at d4f41fc (test_taskfiles_api.py:281-293, :348-370). .spectio/nextflow.log is asserted by test_engine_log_from_the_run. Follow-up FU-P3-31 |
| CR-P3T28-04 (r1) | minor | The built interface differs from the plan's (Root in place of an fd, work optional, extra functions), recorded nowhere. |
recorded by the docs commit: P3-M8, Differences from the plan's interface. P1 is edited at the next /apply-findings |
| CR-P3T28-05 (r1) | minor | api §7 puts native_id on scheduler_jobs, while the code adds attempts.work_dir, native_id and logs with no §7 note; the §5 note says the screens read n "from the task's last attempt", which no route or field gives. |
fixed by the docs commit: a dated note under the api §7 table, and the §5 note now says no route gives a task's attempt numbers yet, for P1 Task 32 to add |
| CR-P3T28-06 (r1) | minor | FU-P3-30 and inbox #93 say to apply option (b) at the next change of stages/freeze.py; this step changed that file and left it. |
open, rightly outside Task 28's scope; recorded on P3-M8. FU-P3-30 keeps it |
| CR-P3T28-07 (r1) | simplify | opened_root is never used and opens its root without O_NOFOLLOW. |
fixed in d4f41fc by code-simplifier |
| CR-P3T28-08 (r1) | simplify | api.taskfiles re-exports FilePool in __all__ only for a test. |
open: left by code-simplifier (a test of kept code would change, test_taskfiles_api.py:362). FU-P3-31 |
| SEC-P3T28-01 (r1) | blocker | The fallback that hides everything from the first unreadable line showed the here-document lines before the failing line, unredacted: the container environment's export values. |
fixed in 297ba97: a here-document body that cannot be read is hidden from its first line (redact.py:206-207) |
| SEC-P3T28-02 (r1) | minor | A Nextflow job file that is a symlink is followed into the run's work directory: .command.err → .command.env, .command.log → .command.run unhidden; the listing says "not a regular file". |
fixed in f362fc5 (raised again as a major in r2) |
| SEC-P3T28-03 (r1) | minor | The lexer does not hide docker run -eTOKEN=v nor function f { TOKEN=v; }. |
open (raised again as SEC-P3T28-03 r2 and r3); probe below; FU-P3-31 |
| SEC-P3T28-04 (r1) | minor | An unexpected OSError (EIO, ESTALE, EINVAL from readlink) gives a bare 500 on both task-file routes. |
fixed in 0fee201 with SEC-P3T28-04 (r3) |
| SEC-P3T28-05 (r1) | minor | No limit on the Snakemake log paths kept per attempt; _logs removes duplicates in O(n²) on the writer thread; each listing stats every path. |
open (again as SEC-P3T28-05 r2): projector/snakemake.py _logs has no cap at d4f41fc. FU-P3-31 |
| SEC-P3T28-06 (r1) | minor | The writer still waits for the freeze, up to 10 s; with the pool busy with route reads, a freeze that times out is final. | partly fixed in 0fee201 (SEC-P3T28-06 r3): the freeze has its own pool. The 10 s bound and the permanent loss on a real stall remain, stated in dev-env §15.3 req. 13 and on P3-M7 |
| SEC-P3T28-07 (r1) | minor | strip_escapes keeps the Unicode bidi and format controls (U+202A–U+202E, U+2066–U+2069, U+200E/F). |
open (again as SEC-P3T28-08 r3); probe below; FU-P3-31 |
| CR-P3T28-02 (r2) | major | A Nextflow job file read follows a symlink into the run's work directory, so .command.err linking to another task's .command.env or a staged input serves that file; the listing reports it "Not served". |
fixed in f362fc5: read_task_file calls open_confined(root, rel) with no work root, as the Snakemake branch did; test_req13_job_file_symlink_in_work_refused, test_req13_job_file_symlink_422 |
| CR-P3T28-03 (r2) | minor | After a declaring word, an assignment whose name part is quoted is not hidden: export "TOKEN"=abc1, export TOKEN"=abc2", export 'T'='abc3'. |
open: all three served unchanged at d4f41fc (probe below). FU-P3-31 |
| CR-P3T28-04 (r2) | minor | _in_pool maps no plain OSError (as CR-P3T28-02 r1). |
fixed in 0fee201 |
| CR-P3T28-05 (r2) | minor | test_req13_freeze_reads_off_the_writer does not check the frozen run has no stages and the note "The stage sources were not read in time"; P3-M7 cites it to close SEC-P3T27-03. |
open (as CR-P3T28-03 r1, second half). FU-P3-31 |
| CR-P3T28-06 (r2) | minor | The interface differences (as CR-P3T28-04 r1), now also list_task_files(AttemptFiles). |
recorded by the docs commit, as CR-P3T28-04 (r1) |
| SEC-P3T28-02 (r2) | major | Nextflow job files follow a final symlink into the work directory (as CR-P3T28-02 r2). | fixed in f362fc5 |
| SEC-P3T28-03 (r2) | minor | Not hidden: exec env T=v, command export T=v, nohup env T=v, sudo T=v cmd, docker run -eT=v, ch-run --set-env=T=v, 2>/dev/null T=v cmd. |
open: all served unchanged at d4f41fc (probe below). FU-P3-31 |
| SEC-P3T28-04 (r2) | minor | Deep nesting in .command.run raises RecursionError, a 500; 250 nested here-documents take about 5 s per request. |
fixed in 0fee201 with SEC-P3T28-04 (r3) |
| SEC-P3T28-05 (r2) | minor | attempts.logs grows without bound from untrusted events; quadratic projection; the listing stats every path. |
open: as SEC-P3T28-05 (r1). FU-P3-31 |
| SEC-P3T28-06 (r2) | minor | A full pool or a stalled source freezes the run with no stages, for good. | partly fixed in 0fee201, as SEC-P3T28-06 (r1) |
| SEC-P3T28-07 (r2) | minor | The .spectio/ exclusion compares names case-sensitively; on a case-insensitive file system .Spectio/manifest.json passes launch_rel and check_name. |
open: taskfiles.py launch_rel and check_name compare ".spectio" exactly at d4f41fc. FU-P3-31 |
| SEC-P3T28-08 (r2) | minor | Unmapped OSErrors (EIO, ESTALE, ENAMETOOLONG, EINVAL) give a bare 500. |
fixed in 0fee201 |
| SEC-P3T28-09 (r2) | simplify | opened_root is never called and checks neither owner nor mode. |
fixed in d4f41fc, with CR-P3T28-07 (r1) |
| CR-P3T28-03 (r3) | minor | The rule "a job file is never read through a link, not even one into the work directory" is only in a code comment; checking-scenarios §11.1 describes the generic walk as following one. | fixed by the docs commit: written into the dev-env §15.3 req. 13 note of 2026-10-31 and inbox #94 rule 7 |
| SEC-P3T28-03 (r3) | minor | Values still visible: scheduler directive comments (#SBATCH --export=ALL,TOKEN=s3cret, #PBS -v, #$ -v, #BSUB -env), function f { TOKEN=s3; }, builtin export T=s3, exec env, nohup env, sudo env T=s3 cmd, -eTOKEN=s3. |
open (probe below). The plan keeps #SBATCH lines on purpose (test_req13_command_run_redacted); hiding an --export= list in them is a choice for Thomas, inbox #94 rule 8. FU-P3-31 |
| SEC-P3T28-04 (r3) | minor | (1) About 300 or more nested $(, ${, "${ or here-documents raise RecursionError; (2) EIO, ESTALE and ENAMETOOLONG reach the client as a 500 with a traceback. |
fixed in 0fee201: MAX_NESTING = 64, past it _Unreadable (hidden from that line); _in_pool maps EIO and ESTALE to 503 runner-offline, other errors to 404 log-not-found, fixed text; safe_rel refuses a component over 255 bytes or a name over 4096. test_req13_redact_deep_nesting_hidden, test_req13_deep_wrapper_served_hidden, test_req13_os_errors_mapped (3 cases), test_req13_long_log_names_dropped |
| SEC-P3T28-05 (r3) | minor | open_confined(…, work=…) follows a final link into the work directory and serves whatever it reaches, .command.env included. No product caller passes work= since f362fc5, but P1 Task 31 reuses the module for publishDir results. |
open, latent: for P1 Task 31, which must refuse the req. 13 "Not served" names at the link target. FU-P3-31 |
| SEC-P3T28-06 (r3) | minor | The freeze shares the 4 + 16-slot pool with the routes, and a Stalled freeze is permanent; 20 stalled runs can hold the writer up to 200 s. |
partly fixed in 0fee201: boot() makes FilePool(FREEZE_WORKERS=2, backlog 0) for the freeze, so route reads never take its slots (test_boot_freeze_pool_apart). Left: a freeze that stalls still holds the writer up to 10 s, and once both freeze threads are stuck a freeze is refused at once; a lost freeze is not retried. The retry option was not taken (it would call the freeze from other events, and each retry holds the writer 10 s more). Stated in the req. 13 note and on P3-M7 |
| SEC-P3T28-07 (r3) | minor | The foreign-uid tests run only as root: test_req13_foreign_uid and the device branch of test_req13_symlink_fifo_refused skip on CI (non-root). |
open: here, as root, test_req13_foreign_uid passed. CI coverage of the owner check needs a root job or a second user. FU-P3-31 |
| SEC-P3T28-08 (r3) | minor | strip_escapes keeps the bidi controls (U+202A–202E, U+2066–2069, U+200E/F, U+061C) and U+2028/2029; an 8-bit CSI (\x9b31m) leaves 31m. |
open: probe below. FU-P3-31 |
| CR-P3T28-01 (r4) | minor | The OSError fix covers the task-file routes only: the run-log route still puts the system message in its 404 and maps EIO and ESTALE to 404, not 503. The pool-error mapping exists twice. | open: logs.py:89-90 at d4f41fc. The req. 13 note now says which routes follow the rule. FU-P3-31 |
| CR-P3T28-02 (r4) | minor | Every OSError other than EIO and ESTALE becomes a silent 404 with nothing logged: EMFILE, ENFILE, ENOMEM look like a missing file. |
open: FU-P3-31 (log the errno, keep the fixed text) |
| CR-P3T28-03 (r4) | minor | In test_boot_freeze_pool_apart, pytest.raises(Stalled) on the routes' pool passes whether or not the 20 readers hold its slots, and the test depends on time.sleep(0.2). |
open: FU-P3-31 |
| CR-P3T28-04 (r4) | minor | Inbox #94 rule 3 still describes a single pool; its Assumed line says rules 1 to 6 hold as built. | fixed by the docs commit: rule 3 now gives the two pools, the 10 s writer bound and the OSError mapping. #94 is this step's own entry and Thomas has not answered it, so its context is corrected, not its options |
| SEC-P3T28-07 (r4) | minor | safe_rel measures a name with surrogatepass but does not check the file system can encode it: a lone surrogate passes, is stored, and the whole listing raises UnicodeEncodeError (a 500). Not reachable through the spool today: pydantic-core's JSON parser rejects lone surrogates [to be validated]. |
open: FU-P3-31 |
| SEC-P3T28-08 (r4) | minor | The run-log route does not follow the OSError rule of the req. 13 note: every OSError is 404 with the system message. | open: as CR-P3T28-01 (r4) |
Probes re-run by doc-writer on d4f41fc (redact_command_run on one line each, strip_escapes on the r3 input):
'export "TOKEN=abc"' -> 'export TOKEN=«hidden»'
"export 'TOKEN=abc'" -> 'export TOKEN=«hidden»'
'arr=(sec1 sec2)' -> 'arr=«hidden»'
'export "TOKEN"=abc1' -> 'export "TOKEN"=abc1'
'export TOKEN"=abc2"' -> 'export TOKEN"=abc2"'
"export 'T'='abc3'" -> "export 'T'='abc3'"
'#SBATCH --export=ALL,TOKEN=s3cret' -> '#SBATCH --export=ALL,TOKEN=s3cret'
'function f { TOKEN=s3; }' -> 'function f { TOKEN=s3; }'
'builtin export T=s3' -> 'builtin export T=s3'
'exec env T=s3 cmd' -> 'exec env T=s3 cmd'
'nohup env T=s3 cmd' -> 'nohup env T=s3 cmd'
'sudo T=s3 cmd' -> 'sudo T=s3 cmd'
'docker run -eTOKEN=s3 img' -> 'docker run -eTOKEN=s3 img'
'2>/dev/null T=s3 cmd' -> '2>/dev/null T=s3 cmd'
'ch-run --set-env=T=s3 img' -> 'ch-run --set-env=T=s3 img'
deep $( x2000: 'export A=«hidden»\n«hidden»\n'
strip: 'ared31mevilx'
The first three are the r1 fixes. The next twelve are the open redaction findings: each serves a value. The deep case is the r3 fix: hidden from the line that nests too deep, no 500.
Fixes by round¶
| Commit | Findings |
|---|---|
297ba97 fix(server): hide quoted export words, array values and whole here-document bodies |
CR-P3T28-01 (r1), SEC-P3T28-01 (r1) |
f362fc5 fix(server): never follow a link when reading a Nextflow job file |
CR-P3T28-02 (r2), SEC-P3T28-02 (r1, r2) |
0fee201 fix(server): refuse deep wrappers and long names cleanly, give the freeze its own pool |
SEC-P3T28-04 (r1, r2, r3), CR-P3T28-02 (r1), CR-P3T28-04 (r2), SEC-P3T28-08 (r2); SEC-P3T28-06 (r1, r2, r3) in part |
d4f41fc refactor(P3-t28): simplify confine by removing the unused opened_root |
CR-P3T28-07 (r1), SEC-P3T28-09 (r2) |
code-simplifier reran the step's check command before and after its change (pytest 611 passed, 17 deselected; ruff, ruff format, mypy, lint-imports, uv lock --check): the same results, apart from the time uv lock printed.
For the Progress page¶
The Task 28 section is written: P3-M8, with its findings and open questions.