Commit5a53dbd1Recorded25 Aug 2026Repositoryfolio

F3a: review fix round -- real-path containment, author validation, and the byte-vs-string record

Message

Adversarial review verdict at 4c4c1e6: PASS-WITH-FIXES. All three landed:

MEDIUM (reviewer's finding, reproduced by the author before fixing): require-contained-path was lexical, and a DIRECTORY symlink inside the root walked straight out of it -- the transaction published into a foreign directory while holding this root's lock, which breaks the one property the check exists for. Containment now resolves the REAL path (realpath of the target's parent under realpath of the root -- the parent is where the publish rename lands), the header states the true boundary instead of the old too-strong claim, and the gate carries the boundary as three arms in a-symlink-boundary: directory symlink REFUSED with the resolved path named; ordinary in-root subdirectory still works (the fix's stand-down control); leaf file symlink admitted -- rename replaces the symlink in-root, outside target untouched by hash.

LOW: author is validated at both journal doors (nonempty string, no newlines) symmetric with update-text -- a non-string author used to die as a raw "string-length: expected string" at a trust boundary A0 will expose to MCP.

NOTE (leader ruled: author's call, taken as a record not a test): revision.sgl's read-file-bytes rule is re-measured as behaviourally invisible on this toolchain (reviewer plant P4: full battery green over the string-path swap; pathological non-UTF-8 file hashes identically both ways). The invariant deliberately has no test -- one that cannot fail today is decoration -- and the dated comment is the record that any future divergence is new, not inherited.

Probe battery: 22 arms, 22 green.

Changed
 src/folio/doc/revision.sgl    |  9 +++++++++
 src/folio/doc/transaction.sgl | 59 +++++++++++++++++++++++++++++++++++++++++++++++++----------
 test/f3a-txn-probe.sgl        | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/gate-f3a.sh              |  6 +++---
 4 files changed, 117 insertions(+), 13 deletions(-)
Diff
src/folio/doc/revision.sglmodified
@@ -107,6 +107,15 @@
107
;;; directly instead of depending on a decoder's happening not to alter
108
;;; anything. A decoder that started normalising would otherwise change the
109
;;; meaning of every stored revision without a line of this file changing.
+110
;;;
+111
;;; RE-MEASURED 2026-08-25 (F3a adversarial review, plant P4): the swap to
+112
;;; the string path is BEHAVIOURALLY INVISIBLE on this toolchain -- the
+113
;;; full F3a battery stays green over it, and a pathological file (BOM +
+114
;;; lone 0xFF + NUL + high bytes) hashes identically both ways. So this
+115
;;; invariant deliberately has no test: one that cannot fail today would
+116
;;; be decoration. It stands as future-proofing; if a toolchain change
+117
;;; ever makes the paths diverge, this comment records that the
+118
;;; divergence is NEW, not inherited.
119
(define (revision-of-file path)
120
(: string? -> string?)
121
(revision-of-bytes (read-file-bytes path)))
src/folio/doc/transaction.sglmodified
@@ -516,18 +516,37 @@
516
;; path outside it would be mutated under a lock that excludes nobody who
517
;; writes there -- the serialization claim would silently not cover the
518
;; write. A relative path resolves against the root (which also gives
−519
;; callers the natural slug spelling). The check is LEXICAL
−520
;; (path-normalize resolves `..` but not symlinks); a symlink inside the
−521
;; root pointing out is replaced by the publish rename rather than
−522
;; followed, so the damage a symlink can cause stays in-root.
+519
;; callers the natural slug spelling).
+520
;;
+521
;; CONTAINMENT IS DECIDED ON THE REAL PATH, NOT THE SPELLING. The first
+522
;; shape of this check was lexical (path-normalize), and the F3a
+523
;; adversarial reviewer demonstrated a DIRECTORY symlink inside the root
+524
;; walking straight out of it -- the transaction published into a foreign
+525
;; directory while holding this root's lock (reproduced by the author
+526
;; before fixing: an outside file mutated through root/link/victim.sgd).
+527
;; The write domain is where the publish RENAME lands, which is the
+528
;; target's PARENT DIRECTORY after symlink resolution -- so that is what
+529
;; is required to sit under the resolved root.
+530
;;
+531
;; A LEAF-file symlink is deliberately still admitted: rename(2) replaces
+532
;; the symlink itself with the published regular file, so the write lands
+533
;; in-root and the symlink's outside target is untouched -- the reviewer
+534
;; verified this boundary and the gate carries both arms (directory
+535
;; symlink refused; leaf symlink contained).
536
(define (require-contained-path coord path)
−524
(let* ((root (path-normalize (coordinator-root coord)))
−525
(p (path-normalize (if (path-absolute? path) path (path-join root path)))))
−526
(unless (string-starts-with? p (string-append root "/"))
+537
(let* ((root-real (or (realpath (coordinator-root coord))
+538
(error (format "transaction: the coordinator's root ~a no longer resolves"
+539
(coordinator-root coord)))))
+540
(given (if (path-absolute? path) path (path-join root-real path)))
+541
(parent-real (realpath (path-dirname given))))
+542
(unless parent-real
+543
(error (format "transaction: the directory of ~a does not exist" given)))
+544
(unless (or (equal? parent-real root-real)
+545
(string-starts-with? parent-real (string-append root-real "/")))
546
(error (format
−528
"transaction: ~a is not under the coordinator's root ~a -- the held lock serializes only this root's writers; use that root's own coordinator"
−529
p root)))
−530
p))
+547
"transaction: ~a resolves into ~a, which is not under the coordinator's root ~a -- the held lock serializes only this root's writers; use that root's own coordinator"
+548
given parent-real root-real)))
+549
(path-join parent-real (path-basename given))))
550
551
(define (run-transaction coord path expected-revision
552
stage rebase-allowed? rebase-precheck
@@ -821,6 +840,24 @@
840
;; stance is conservative refusal; richer journal bodies are F3b's, with
841
;; a real escaping story. (id/timestamp/author/commit are safe already:
842
;; they travel inside escaped string literals and are grammar-validated.)
+843
;; `author` travels inside an escaped string literal, so it cannot inject
+844
;; -- but a non-string author used to fall through to escape-string-literal
+845
;; and die as "string-length: expected string", a raw internal error at a
+846
;; trust boundary that A0 will expose to MCP callers (reviewer LOW,
+847
;; 2026-08-25). Validate at the door, symmetric with every other journal
+848
;; argument; newlines are refused because an author is a name, and a
+849
;; multi-line "name" in a single-line argument group is a manuscript
+850
;; formatting decision this layer must not improvise.
+851
(define (require-valid-author who author)
+852
(when author
+853
(unless (and (string? author) (> (string-length author) 0))
+854
(error (string-append who ": author must be #f or a nonempty string")))
+855
(let loop ((i 0))
+856
(when (< i (string-length author))
+857
(when (memv (string-ref author i) '(#\newline #\return))
+858
(error (string-append who ": author may not contain newlines")))
+859
(loop (+ i 1))))))
+860
861
(define (require-safe-journal-text who text)
862
(when text
863
(unless (string? text)
@@ -855,6 +892,7 @@
892
(when (and update-id (not (valid-node-id? update-id)))
893
(error "folio-transition-status!: update-id must use flat node-id syntax"))
894
(require-safe-journal-text "folio-transition-status!" update-text)
+895
(require-valid-author "folio-transition-status!" author)
896
(let ((stage
897
(lambda (d source rev)
898
(stage-status-transition d source rev path node-id new-status
@@ -1062,6 +1100,7 @@
1100
(when (and update-id (not (valid-node-id? update-id)))
1101
(error "folio-append-update!: update-id must use flat node-id syntax"))
1102
(require-safe-journal-text "folio-append-update!" update-text)
+1103
(require-valid-author "folio-append-update!" author)
1104
(let ((stage
1105
(lambda (d source rev)
1106
(stage-update-append d source rev node-id timestamp author commit
test/f3a-txn-probe.sglmodified
@@ -441,6 +441,62 @@
441
(rev-of note)
442
expected-old-value: 'low))))
443
+444
;; ---------------------------------------------------------------------------
+445
;; a-symlink-boundary -- containment is decided on the REAL path. The
+446
;; adversarial reviewer demonstrated a directory symlink walking the lexical
+447
;; check straight out of the root (finding: MEDIUM, 2026-08-25); this arm is
+448
;; that escape pinned shut, with the two companions that show the BOUNDARY:
+449
;; an ordinary in-root subdirectory still works (the stand-down control for
+450
;; the fix), and a LEAF file symlink is still admitted -- rename replaces the
+451
;; symlink in-root and the outside target is untouched.
+452
;; ---------------------------------------------------------------------------
+453
(arm "a-symlink-boundary"
+454
(lambda ()
+455
(let* ((outside-dir (path-join (path-dirname root) "f3a-outside"))
+456
(victim (path-join outside-dir "victim.sgd")))
+457
(unless (directory-exists? outside-dir) (make-directory outside-dir))
+458
(write-file-bytes victim (bytes-of note))
+459
;; (a) DIRECTORY symlink: root/esc -> outside; the spelled path is
+460
;; in-root, the real write domain is not. Must refuse, and the outside
+461
;; file must not move.
+462
(let ((linkdir (path-join root "esc")))
+463
(make-symlink outside-dir linkdir)
+464
(let* ((victim-before (revision-of-file victim))
+465
(raised (guard (e (#t (if (exception? e) (exception-message e) "raised")))
+466
(folio-update-field! coord (path-join root "esc/victim.sgd")
+467
"t-alpha" 'priority 'low
+468
(revision-of-file victim))
+469
#f)))
+470
(assert-true "directory-symlink path refuses" (string? raised))
+471
(assert-true "refusal names the resolved containment rule"
+472
(string-contains? raised "not under the coordinator's root"))
+473
(assert-equal "the outside file did not move"
+474
(revision-of-file victim) victim-before)))
+475
;; (b) stand-down: an ordinary in-root SUBDIRECTORY transacts fine.
+476
(let ((subdir (path-join root "sub")))
+477
(unless (directory-exists? subdir) (make-directory subdir))
+478
(write-file-bytes (path-join subdir "subnote.sgd") (bytes-of note))
+479
(assert-result "an ordinary in-root subdirectory still works"
+480
(folio-update-field! coord (path-join subdir "subnote.sgd")
+481
"t-alpha" 'priority 'low
+482
(revision-of-file (path-join subdir "subnote.sgd"))
+483
expected-old-value: 'high)))
+484
;; (c) LEAF file symlink: admitted; the publish rename replaces the
+485
;; symlink with a regular in-root file and the outside target is
+486
;; untouched.
+487
(let ((leaf (path-join root "leaf.sgd"))
+488
(victim-before (revision-of-file victim)))
+489
(make-symlink victim leaf)
+490
(assert-result "leaf-symlink transaction publishes"
+491
(folio-update-field! coord leaf "t-alpha" 'priority 'low
+492
(revision-of-file leaf)
+493
expected-old-value: 'high))
+494
(assert-equal "the symlink's outside target is untouched"
+495
(revision-of-file victim) victim-before)
+496
(assert-true "the in-root path is now a regular file (the symlink was replaced)"
+497
(and (file-exists? leaf)
+498
(not (equal? (realpath leaf) (realpath victim)))))))))
+499
500
;; ---------------------------------------------------------------------------
501
;; a-journal-injection -- the journal body is spliced verbatim; hostile text
502
;; must refuse BEFORE any staging, and the note must not move.
test/gate-f3a.shmodified
@@ -113,8 +113,8 @@ if want 2; then
113
else
114
fail "arm2: probe battery exited non-zero"
115
fi
−116
grep -q "F3A_PROBE_DONE arms=21 green=21 red=0" "$work/probe.log" \
−117
|| { fail "arm2: expected 21/21 green; got: $(grep F3A_PROBE_DONE "$work/probe.log" || echo 'no done marker')"; \
+116
grep -q "F3A_PROBE_DONE arms=22 green=22 red=0" "$work/probe.log" \
+117
|| { fail "arm2: expected 22/22 green; got: $(grep F3A_PROBE_DONE "$work/probe.log" || echo 'no done marker')"; \
118
grep "_RED" "$work/probe.log" >&2 || true; }
119
arm_summary 2
120
fi
@@ -316,7 +316,7 @@ fi
316
# ---------------------------------------------------------------------------
317
echo "---"
318
if [ "$fails" -eq 0 ]; then
−319
echo "F3A_GATE_GREEN arms_run=[$only_arms] probe_arms=21 sab_arms=8 contention_rounds=5 sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo unknown)"
+319
echo "F3A_GATE_GREEN arms_run=[$only_arms] probe_arms=22 sab_arms=8 contention_rounds=5 sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo unknown)"
320
exit 0
321
else
322
echo "F3A_GATE_RED failures=$fails arms_run=[$only_arms]"