Self's finding: R-I20 changed digest_artifact's bare-file shape, but three
stored values are compared against a fresh recompute forever after they are
sealed -- expected.artifact_sha256 (IdunnExpectedIncarnationRecord),
ArtifactReceipt::sha256 (SealedRelease, in host.rs's verify_artifacts), and
HostWorkloadObservation::executable_sha256. The first Idunn upgrade past
R-I20 would have refused every already-installed and already-running
workload sealed before it.
The ruling asked for an explicit version tag recorded with the stored
value. That channel does not exist here: expected.artifact_sha256 and
ArtifactReceipt::sha256 are both validated to a bare `sha256-<64 lower hex>`
shape with no room for one -- IdunnExpectedIncarnationRecord's validator
lives in the pinned cultnet-rs git dependency, a separate repo this
worktree has no authority to widen, and ArtifactReceipt::sha256's own local
validator enforces the identical shape. There has only ever been one prior
shape (raw_sha256 of the file's bytes alone, nothing else), so
verify_artifact_digest recomputes under the current algorithm first and
falls back to that exact legacy shape -- the same effect as reading an
implicit version, without a tag this worktree cannot add. New seals always
call digest_artifact directly, so they always use the current shape;
nothing here ever rewrites a stored value to match.
host.rs's start(), observe() and verify_artifacts() now go through
verify_artifact_digest instead of comparing digest_artifact's output to the
stored string directly. start() carries expected.artifact_sha256 forward
into the returned observation rather than a fresh recompute, so a
legacy-shaped receipt is echoed, not silently upgraded to a value it was
never sealed against.
Checked whether frozen_source_sha256 (the frozen-source digest,
observe_frozen's stored-value comparison against FrozenSourceReceipt) has
the same exposure: yes. Its algorithm changed twice before this batch
touched anything -- R-I9 added mode and gid, R-I18 added xattrs -- and a
FrozenSourceReceipt is persisted in DeploymentTransaction.frozen_source
between the freeze() step and the step that calls observe_frozen, each a
separate advance_sealing call that can cross a restart. The window is
narrower than the artifact side (one in-flight Sealing-phase transaction,
not every installed workload), but this cut's own batches 6 through 8
already crossed it twice. verify_frozen_source_digest applies the same
answer against both prior shapes, which are reproduced verbatim in
frozen_source_sha256_legacy from git history (f873b54^ and f873b54).
Tests: verify_artifact_digest_accepts_a_receipt_sealed_under_the_pre_r_i20_shape
and verify_frozen_source_digest_accepts_both_shapes_that_predate_this_cuts_sixth_batch
construct a stored value the old code would have produced and confirm the
new code still accepts it, confirm the current-algorithm-only comparison
they replace would have refused it, and confirm a real content tamper is
still caught. verify_artifacts_accepts_a_receipt_sealed_under_the_pre_r_i20_shape
exercises the same thing through host.rs's own verify_artifacts. Manually
mutated (fallback removed, then restored) on Yggdrasil; see the verification
report.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The answerer thread's `while let Some(frame) = client.receive_once()? { ...;
return Ok(()); }` always returns on its first (and only reachable) iteration,
so clippy::never_loop was right that it never loops -- the outer `for` is
what actually retries across polls. Rewritten as `if let`.
With that gone, cargo clippy --all-targets has nothing left to scope the
lint workflow around, so .github/workflows/lint.yml runs it again. Test
code is exactly where a disallowed_methods bypass would hide, and it was
the one place the workflow's own lint couldn't reach.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
unrelated code
Measured on Yggdrasil against both this batch and its parent, a46b997:
`cargo clippy --all-targets` already fails on `clippy::never_loop` in
host_actuator.rs's own test code, which this batch never touched and is
not chartered to fix. `cargo clippy` without --all-targets still analyzes
the library crate, which is where frozen_symlink_port and every other
disallowed-method call site actually live, so R-I23's coverage is
unaffected; only test-only code outside the lib goes unchecked by this
job.
Also verified directly on Yggdrasil: with this scoping, `cargo clippy
--all-features -- -D clippy::disallowed_methods` passes clean on
c667171, and fails as intended when Soul's exact bypass spellings are
reintroduced (`candidate.canonicalize()?` and `fs::canonicalize(&candidate)?`
at drivers.rs:6219, and `fs::read_link(symlink_path)?` in
open_frozen_symlink_frame) -- each one reported as
"use of a disallowed method" pointing at the port, not silently passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
them for real
Measured on Yggdrasil against a2b8057, both real container-level facts,
not code-under-test bugs:
- digest_artifact_differs_when_only_a_directorys_extended_attribute_differs
set the xattr on the artifact root itself. digest_tree(root, root, ...)
never hashes the root's own metadata, only entries it reads out of some
directory's read_dir (the same fact the pre-existing directory-mode/gid
tests already build around), so the two digests were identical. Moved
the xattr onto a child directory, matching those tests' own shape.
- digest_artifact_differs_when_only_a_symlinks_extended_attribute_differs
used the `user` namespace. `lsetxattr(..., "user.*", ...)` on a symlink
fails EPERM even as root: the kernel restricts `user` to regular files
and directories regardless of privilege. Switched to
`security.capability`, the same setuid-equivalent value the
frozen-source xattr tests already use, which has no such restriction.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
digest's xattr and single-file blind spots
Soul pass 8 found the artifact digest's single-file branch
(digest_artifact) was a content-only sha256 -- no mode, gid or xattr --
and it is the branch host.rs actually takes for an installed executable,
immediately before launch and while the process is running. All three
existing digest_artifact tests wrapped the file in a directory so
digest_tree ran instead, which is exactly why the fast path's own
blindness survived three fix batches untested.
- R-I20: extract digest_file_entry, the one place a file entry is hashed
for the artifact digest, and delegate both digest_tree's file arm and
digest_artifact's bare-file branch to it. Rewrite the three wrapped-file
tests to call digest_artifact on a bare file, matching the shape
production uses.
- R-I21: give the artifact digest an xattr term on all three arms
(dir/file/link), matching hash_frozen_source_tree's own coverage --
the asymmetry ran the wrong way, since the artifact is the binary that
gets exec'd and re-verified before every launch, unlike the frozen
source's 0555 root-owned tree nothing executes. Shared the framing
writer (hash_xattr_list) between hash_frozen_source_xattrs and the new
digest_tree_xattrs so the two terms can't drift apart; the artifact
side reads xattrs through a platform-conditional reader since artifact
digesting (unlike frozen-source digesting) runs on every target
platform this daemon ships for. Add fixture tests for the dir, file and
symlink arms.
- R-I22: hash_xattr_list's framing was pinned by nothing -- all 193 tests
stayed green while dropping the value length prefix, the count prefix,
the name NUL terminator, folding names to lowercase, or hashing names
only and dropping values entirely. Add five tests that call
hash_xattr_list directly with hand-constructed attribute lists chosen
so each specific framing removed is exactly what makes two different
lists collide, not just assertions that some digest changed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compile error
Soul pass 8 proved the FrozenSymlinkFs port (R-I14) was an honour system
one level up from the counter it replaced: restoring the S5-1 cubic bug
as `candidate.canonicalize()?` in resolve_frozen_source_symlink's Normal
arm left all 193 tests green, and open_frozen_symlink_frame was still
calling fs::read_link directly, proving the convention leaks.
- Add `read_link` to FrozenSymlinkFs and route open_frozen_symlink_frame
and validate_frozen_source_symlink's root resolution through it.
- Move every function the resolver can reach into a `frozen_symlink_port`
submodule with `#![deny(clippy::disallowed_methods)]`; drivers.rs
blanket-allows the lint at the top of the file since most of the crate
has legitimate, unrelated reasons to call these methods (host.rs and
provisioning.rs get the same blanket allow). Everything the module
exports is re-exported so every existing caller and test keeps its
bare name.
- Add clippy.toml listing Path::canonicalize, fs::canonicalize,
fs::symlink_metadata and fs::read_link as disallowed, each with a
reason pointing at the port.
- Add .github/workflows/lint.yml: this repo had no CI surface at all
before this batch, so "denied in CI" had nowhere to attach.
- Update validate_frozen_source_symlinks_resolves_a_long_component_chain_
with_a_linear_call_count's formula and doc: read_link and the
now-ported root canonicalize add two calls per resolution
(components + 4, not + 2), and the doc no longer claims the count
can't catch its own regression -- R-I23 is what catches it now (R-I24).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
validate_frozen_source_symlink_accepts_a_legitimate_in_root_parent_reference
still called resolve_frozen_source_symlink with the pre-port two-argument
signature. Missed in the R-I14 commit; the crate did not compile.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
R-I15 (F2): delete digest_artifact_differs_across_a_symlink_target_boundary_shift
rather than repair it. Soul pass 7 confirmed it cannot fail: the link name is
already NUL-terminated before the target in digest_tree, so two entries whose
(name, target) pairs differ always diverge there regardless of the (missing)
trailing terminator the test claimed to be pinning. The collision class is
real in principle and unreachable in practice -- a NUL cannot appear in a real
symlink target or filename. Left a note in its place and corrected the
production comment that pointed at it.
R-I16 (F4): digest_tree (the artifact digest, re-derived against the sealed
receipt immediately before launch) hashed only an executable-bit boolean for
files and nothing at all for directories. Widened to the full mode & 0o7777
plus the owning gid, for directories as well as files, matching
hash_frozen_source_tree's own R-I9 treatment. Added digest_tree_mode/
digest_tree_gid (replacing digest_tree_is_executable) and one test per new
term: file setuid, file gid, directory mode (setgid), directory gid.
R-I17 (F5): the validator's mode mask was pinned only for setuid-on-a-file.
Added validate_frozen_source_refuses_a_{setgid,sticky}_file and
_a_{setuid,setgid,sticky}_directory so narrowing either mask (0o7777 ->
0o5777 file, 0o7777 -> 0o3777 directory) cannot pass with a green suite.
R-I18 (F3): a real security.capability grant (cap_setuid=ep) on a root-owned
0555 tree validated ACCEPT and re-hashed unchanged -- a grant with no setuid
bit for the mode term to see. hash_frozen_source_tree now hashes every
extended attribute (name and value, sorted by name) on every entry via a new
read_sorted_xattrs helper built on listxattr/getxattr directly (libc is
already a normal dependency). This is detection, not prevention:
no-new-privileges on Idunn's own deployment path is what prevents a file
capability from being exercised; this digest term is what makes
observe_frozen's "this is the tree we froze" verdict correct for xattrs.
Pinned with a general user.-namespace test and a direct reproduction of
Soul's security.capability finding, both noting the root precondition.
R-I19 (F6): nix_group_or_skip never skipped -- it returned Ok(1)
unconditionally, and chown(2) never consults /etc/group, so its own
reasoning was already moot. Renamed to second_gid_for_chown_fixture, since
the documented "bail loudly, don't silently skip" behaviour is the one
actually wanted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul pass 7 (F1) restored the S5-1 bug -- a per-component canonicalize() --
without instrumenting it, and the call-count pin measured no regression at
all. record_frozen_symlink_probe_call() was an honour system: it only fired
where a call site remembered to invoke it by hand.
Put canonicalize() and symlink_metadata() behind a narrow FrozenSymlinkFs
port. The one production implementation (StdFrozenSymlinkFs) charges the
counter inside the same method that makes the syscall, so a reintroduced
call written the way the surrounding code is now written cannot bypass the
count. resolve_frozen_source_symlink and open_frozen_symlink_frame are
generic over the port instead of calling Path::canonicalize/
fs::symlink_metadata directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul pass 6 (F6) found two gaps outside the fifth batch's own diff:
digest_tree -- the artifact digest, separate from the frozen-source
digest R-I3 fixed -- still had exactly the same blindness (no
executable-bit term, no terminator after a symlink target), and
observe_frozen, the tamper check itself, had no test of its own at all.
- digest_tree now hashes an executable-bit boolean for files (mirroring
R-I3/S5-4's fix to hash_frozen_source_tree) and a trailing terminator
after a symlink target. The executable-bit term is pinned by a test
that goes red when it is removed (verified by hand mutation). The
terminator is deliberately NOT pinned by a test: constructing a real
encoding collision requires a NUL byte embedded inside a symlink
target, to reproduce the NUL that is always partway through the next
entry's own "dir\0"/"file\0"/"link\0" tag, and no legitimate fixture
can produce that -- std::os::unix::fs::symlink rejects an interior NUL
(CString::new fails), and symlink(2) itself takes a NUL-terminated C
string. A two-name "boundary shift" fixture (link "a"->"bc" vs link
"ab"->"c") does not collide either, since the relative-name field
already carries its own terminator, unaffected by this fix -- tried,
confirmed not to go red under the term's removal, and removed rather
than kept as a test that doesn't test what it claims. The fix is kept
anyway: it costs nothing and matches the file arm's already
self-delimiting shape.
- observe_frozen gets an end-to-end test against a real receipt, per
Soul's note that building a CompiledDeploymentPlan was out of its own
budget. It goes through GitSourceDriver::freeze_exact for a real frozen
tree and compile_deployment_plan (the same constructor production code
uses) for a minimal but real TargetDeclaration/OperatorBinding TOML
pair -- no runtime state, dependency graph, or route, since
observe_frozen never reads past plan.source and plan.recipe_blob once
receipt.validate_against accepts the plan. Confirms observe_frozen both
accepts an intact receipt and refuses one tampered with after freezing.
The tampered file is a second file in the tree, not the recipe itself:
tampering the recipe file is independently caught by observe_frozen's
own recipe-bytes-equality check, which would have kept this test green
even with the digest re-check deleted -- confirmed by hand mutation
before correcting the fixture.
183 passed / 0 failed / 2 ignored (180 after the prior commit in this
batch, +3 new tests here). Every new/changed test run under its own hand
mutation on the final code; each that is pinned went red, and the one
deliberately unpinned case (the symlink terminator) is explained above
and in the code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul pass 6 confirmed the containment argument and five of seven rulings,
but found R-I1's timing pin measured a clamp artifact (33x, not the real
1.63x) and R-I3's digest coverage stopped short of setuid/setgid/sticky
and gid. This lands the deterministic replacement and the privilege fix,
plus two smaller corrections pass 6 asked for.
- R-I8: replace the wall-clock ratio pin on the long-component-chain
resolver test with a deterministic call count. A test-only thread-local
counter (zero cost outside cfg(test)) counts every canonicalize()/
symlink_metadata() call the resolver makes; the test now asserts an
exact formula (components + 2) instead of a clock reading that measured
198.9ms for a clean revision under three concurrent Yggdrasil suites,
and a 1.63x true separation that Hands' original 11ms/359ms pair
misreported as 33x by straddling the test's own clamp boundary.
- R-I9: validate_frozen_source_tree widens its mode mask from 0o777 to
0o7777, so a root-owned setuid/setgid/sticky file or directory is
refused outright instead of masked down to a legal 0o444/0o555. The
frozen-source digest (hash_frozen_source_tree) now hashes each entry's
full mode and owning gid, for directories as well as files, replacing
the executable-bit-only boolean the tamper check relied on. New tests:
a direct validator refusal test, plus one digest test per newly-covered
term (file setuid, file gid, directory mode, directory gid).
- R-I10: the two-link-cycle fixture now asserts on the traversal budget's
own ceiling message, not merely is_err(), so a refusal from some other
guard cannot pass for the budget doing its job.
- R-I11: softens the claim that the symlink-target length guard "refuses
exactly what the kernel refuses" to what is actually true -- it bounds
the buffer at PATH_MAX, and a filesystem may refuse less (an ext4 slow
symlink is bounded by block size). Doc only.
- R-I12: records at the loop-tail containment check, by hand-mutation,
that it is the one that must survive -- deleting it alone turns the
bare-parent-reference test red, while the Normal arm's own check can be
deleted with the full suite and a 20-shape escape sweep still green.
Also records the two mutants Soul found genuinely equivalent. No
deletion.
R-I9's third part -- add nosuid to the bind mount -- is not implemented.
Verified by hand against a real Docker CLI: `--mount type=bind,...,nosuid`
is rejected ("invalid field 'nosuid'") and so is `-v host:dst:nosuid`
("invalid mode: nosuid"). Docker exposes no bind-mount-level nosuid flag
through either the modern or legacy CLI syntax. The container already
carries `--security-opt no-new-privileges`, which is the standard Docker
mitigation for exactly this class of finding and is arguably stronger
(process-wide, not per-mount) than the requested mount flag would have
been. Flagged for Self.
175 passed / 0 failed / 2 ignored baseline; 180 passed / 0 failed / 2
ignored after this commit (+5 new tests). Every new/changed test run
under its own hand mutation on the final code; each went red. See the
batch report for the full list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OperatorBinding's target field only allows lowercase ASCII (require_id),
and the ".GIT" hostile-fixture label was going in uppercase. Caught by
actually running the new HTTPS rig test on Yggdrasil.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SourcePort::resolve has one production caller and, before this, zero
tests: every existing fixture in this suite approximates it with a
file:// origin or a hand-copied fetch invocation. This stands up a real
TLS git-http-backend origin instead -- a self-signed certificate the
driver's own Git identity trusts through $HOME/.gitconfig's
http.sslCAInfo, a minimal CGI-over-HTTPS Python bridge (no web server in
eureka-verify-rust speaks smart HTTP directly), and a validated
OperatorBinding whose origin is a real https://127.0.0.1:<port>/
origin.git. A clean revision resolves; moving refs/heads/main onto either
hostile tree this suite already builds for freeze_exact (duplicate-named
trees, a .GIT look-alike) is refused by resolve()'s own
transfer.fsckObjects=true fetch.
Soul's original pass-5 rig (soul-idunn05-notes.md, probe5_https2.rs,
s5-https-run.sh) lived in a session scratchpad that did not survive past
that session -- the same "green history, empty repository" failure
SKILL.md already documents from the QUIC cut. No matching session, no
leftover Yggdrasil work directory, and no local scratch trace were found,
so this rebuilds the rig from the map's description rather than reusing
Soul's files.
Also drops open_frozen_symlink_frame's now-dead canonical_root parameter,
left unused by the previous commit's containment-check deletions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's fifth pass on Cut 1 could not falsify the fourth batch's mechanism;
what it left was a bill of seven rulings. This lands six of them (R-I6,
the HTTPS resolve() rig, follows separately):
R-I1: delete the dead per-component canonicalize() in
open_frozen_symlink_frame's Normal-step match (frame.resolved is already
canonical there), and pin the O(k^3) term can't come back with a
component-count timing test.
R-I2: two hand-checked fixtures for the traversal budget, the resolver's
only termination guarantee -- a two-link cycle and 41 distinct links, both
must refuse.
R-I3: digest coverage tests for the symlink target and the executable bit,
the two terms hash_frozen_source_tree already hashes but nothing had
pinned.
R-I4: fix materialize_tree_raw's symlink-target guard off-by-one (<=
PATH_MAX admitted a 4096-byte target the kernel's own symlink() refuses
with ENAMETOOLONG; now < PATH_MAX, refusing exactly what the kernel
refuses) and pin it against a real kernel boundary.
R-I5: declare the 40-traversal ceiling (matches the kernel's own ELOOP
limit) and name the link and count reached in its error, a deliberate
narrow departure from "keep S6's behaviour exactly".
R-I7: drop FrozenSymlinkFrame's symlink_path Option (only ever
constructed Some) and its backwards doc comment; delete two of the three
"redundant" containment checks Soul proved redundant by deletion. The
third -- the unconditional check at the bottom of the resolver's loop --
turns out load-bearing once the other two are gone: a symlink whose whole
target is ".." at the root has no later Normal step to trip that arm's
own check, so nothing else catches the escape. Traced by hand, pinned
with a new test, and kept rather than deleted as a documented deviation
from the map's "delete all three" reading. Also deletes the 189-line
resolve_style_fetch_then_freeze_exact_refuses_hostile_trees test (its
lone assertion sits in an Ok(_) arm pass 4 showed is never taken, and
freeze_exact_refuses_already_local_hostile_objects_via_its_own_fsck
already runs the same fixtures plus four more through a harder path),
and collapses the hash_object/literal_tree/dotgit_lookalike/git_owned/
chown_to_source_identity boilerplate the three surviving hostile-tree
fixture tests duplicated into module-level test helpers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul could not falsify any claim the fourth batch made. What it found instead
is that the S4-1 fix reached the exponent and not the per-component cost, and
that the line responsible is dead code: the value is already canonical, so
deleting four lines removes a cubic term and leaves the suite green.
The other half of the bill is coverage. The traversal budget is the only thing
that terminates a symlink cycle and nothing tests it; cargo-mutants missed two
mutants on that single line. The digest that serves as the tamper check is
blind to symlink targets and to the executable bit, both of which the
validator permits to vary. S4-6 was reported landed and has no test at all.
Seven rulings for the fifth batch. R-I5 departs narrowly from my earlier
"keep S6's behaviour exactly" and says so rather than letting it pass as
drift: the 40-traversal ceiling matches the kernel's own ELOOP limit and is
kept, but it must be declared and the error must name the link.
FU-Heimdall-Gitlink cleared upstream on its own. Heimdall froze end to end,
945 entries, zero mismatches. The ops follow-up closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Soul's fourth pass found the symlink resolver newly exponential
(resolve_frozen_source_symlink recursed per path component and
re-resolved every shared prefix from scratch, so an n-link
self-doubling chain cost 2^n real filesystem resolutions), the
streamed hash unpinned, three of the five S5 guard tests not killing
their own guard, all three S1 fsck guards unpinned against
already-local objects, no test proving a legitimate in-root ".."
freezes, and the symlink writer still buffering a whole blob for a
120000 entry.
S4-1: resolve_frozen_source_symlink is now an explicit iterative walk
(a work-stack of frames standing in for the old call stack) with
per-symlink memoisation and one shared traversal budget charged per
distinct symlink read, not per nesting depth. Soul's n=20 doubling
chain (a0 -> ., ak -> a{k-1}/a{k-1}) now resolves in milliseconds
instead of 43.7 s, and freezes rather than refusing: nothing in the
chain dangles or escapes the root, matching S6.
S4-2: two trees differing only past the first 64 KiB chunk now assert
different digests, killing the zero-length-buffer and first-chunk-only
mutants cargo-mutants found.
S4-3: the duplicate-path, ensure_frozen_directory containment, and
create_dir guards each get a fixture that only that guard refuses --
calling git_tree_entries directly (before any fsck runs) for the
duplicate path, and pre-seeding created_dirs with state a second,
independent bookkeeping pass could produce for the other two.
S4-4: Soul's eleven already-local hostile fixtures (dup-trees, six
.git look-alikes, an unsorted tree, mode 120000 on a tree, mode 040000
on a blob, a zero-padded mode) land as tests against freeze_exact's
own explicit fsck. A second test proves freeze_exact's own fetch fsck
flag is what refuses a hostile revision that was never local, by
asserting which context wraps the error.
resolve()'s own fetch fsck flag is still unpinned: exercising it needs
a real HTTPS origin (OperatorBinding::validate requires one), and
url.insteadOf does not work around that -- `git remote get-url` expands
the rewrite, so it can never satisfy ensure_checkout's own
origin-equality check. Confirmed by hand against a local git; a real
smart-HTTPS harness like Soul's S1 rig is what closing this needs.
Recorded as a gap, not faked with a broken test.
S4-5: a legitimate in-root ".." link (two hops up from a nested
directory, back to a file at the root) now has a test proving it
resolves and freezes, not just that an escaping "..' refuses.
S4-6: materialize_tree_raw refuses a 120000 entry whose blob exceeds
the platform's PATH_MAX before reading it, for both the streamed-blob
and the shares-content-with-a-regular-file cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
git_at runs plain `git` with no `-c safe.directory=*`. By the time the
hostile commit is built, the origin repo is already chowned to the
unprivileged fixture identity, so root's plain git refuses it as dubious
ownership. Use the same safe.directory-scoped helper the duplicate-tree
fixture above already uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The test was moving `main` to the hostile commit before the first checkout
existed, so the initial (also fsck-guarded) clone refused it, never
exercising the fetch line S1 actually changed. Establish the checkout on the
good root commit first, then advance `main`, matching the real scenario: an
already-fetched deploy target whose admitted ref later moves to a hostile
commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
frozen_source_rejects_a_symlink_that_escapes_the_transaction_tree asserted
against harden_frozen_source, which no longer resolves symlink chains (S6
moved that to validate_frozen_source_symlinks, against the tree's final
published path). Point it at the right function.
resolve_then_freeze_refuses_hostile_trees_through_the_production_path could
not go through the literal SourcePort::resolve()/freeze() trait methods:
OperatorBinding::validate() requires an https:// origin unconditionally, and
this suite has no HTTPS Git fixture. Replaced with a fetch that reproduces
resolve()'s exact invocation (this fix's -c transfer.fsckObjects=true
included) against the checkout, the same approximation Soul's own probe used,
followed by the real freeze_exact as defense in depth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
S1: guard resolve()'s admitted-ref fetch with transfer.fsckObjects=true (it
had none), and have freeze_exact additionally run an explicit `git fsck`
against the selected revision so objects that were already local -- not only
freshly transferred ones -- get checked too. Refusal now happens through the
production path (resolve() then freeze()), not only a hand-built fetch.
S5: behavioural tests for six guards Soul's third pass found unpinned: root
ownership in harden_frozen_source, an exact duplicate leaf path, the
ensure_frozen_directory root-escape check on its own, the writer's pass-1
create_dir semantics, and copy_artifact's containment check. (The sixth,
case-sensitive ancestor alias, goes away under S7.)
S6: symlinks are now judged against the tree's real, final published path,
not `.partial`, the temporary name the tree is built under before the atomic
rename that publishes it. A dangling link whose target stays inside the root,
resolving every component that exists, is accepted; one that escapes is
refused, dangling or not. harden_frozen_source_tree defers symlink chain
resolution; the new validate_frozen_source_symlinks walk runs once, right
after the rename, and validate_frozen_source_symlink (used by observe_frozen
too) now shares the same dangling-tolerant resolver, so freeze-time and
observe-time agree.
S7: delete the case-folding collision/alias refusal in
refuse_conflicting_tree_entries. The host is case-sensitive ext4, the check
was inconsistent (leaf paths only, plain to_lowercase), and .git look-alikes
are now refused by git fsck itself (S1), independent of case. The exact-name
alias case (a leaf sharing a root with a tree recursing into it) stays
refused at write time by ensure_frozen_directory, which never writes through
an existing entry.
S9: hash_frozen_source_tree streams each file through the hasher in 64 KiB
chunks instead of fs::read-ing it whole, so peak RSS no longer scales with
the largest file in the tree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Better to have nothing than a harness that punishes refactoring
(operator ruling, 2026-09-22). Remove tools/verify-mutations/cut1.psd1
(428 lines), the only file in that directory. The harness that ran it,
eureka-mutations.ps1, was already deleted from the Eureka skill
(37c80e4).
docs/verify-transaction-cut.md still names the suite and its entry
ids; that file is Self's campaign record and is left untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's second pass on the Cut 1 fix batch found a new regression (a
duplicate/aliased tree name lets the raw freeze write outside its root) and
three pre-existing gaps: two redundant hardening passes with no pin, a
lexical (not chain-resolving) symlink escape check, and unpinned Docker argv
combinations. F1's perf finding (40-80x slower than the old git archive path)
is addressed here too.
F2/F3 (write-outside-root, two required layers):
- git_tree_entries now calls refuse_conflicting_tree_entries, which refuses
a tree where two leaf paths collide case-insensitively, or where one leaf
path is a case-folded ancestor of another (the exact shape
`git hash-object --literally` lets through: a symlink and a subtree
sharing one name). The exact-revision fetch and both clone paths
(ensure_checkout, materialize_gitlink_raw) enable transfer.fsckObjects.
- The writer itself never trusts that check alone. ensure_frozen_directory
is the only way materialize_tree_raw obtains a directory to write into: a
path must already be a directory this freeze created (tracked in
created_dirs, shared across the superproject and every Gitlink), or must
not exist at all; anything else is refused rather than traversed.
Symlinks are written in a dedicated last pass, after every directory and
regular file the tree needs already exists.
F4: freeze_exact now calls harden_frozen_source once, last, instead of
harden then a separate validate_frozen_source pass over the same tree.
validate_frozen_source stays as the read-only re-check observe_frozen uses
on an already-hardened tree it did not just write.
F5: validate_frozen_source_symlink resolves the whole symlink chain with
fs::canonicalize instead of lexically counting ".." components, which
undercounts a chain that loops back through another symlink (D -> .,
L -> D/D/.../../../../etc/passwd). copy_artifact (the one place besides
freeze that reads through a possibly-attacker-controlled tree) gets the same
whole-chain resolution: it now takes the artifact's containment root and
refuses a source that resolves outside it, closing the gap where an
intermediate symlink in a runner's own build output could be followed.
F1: materialize_tree_raw bulk-fetches every blob a tree needs in one
`git fetch --stdin` round trip (Git 2.36+, no argv-length limit) instead of
letting a blob:none partial clone lazily fetch each one as `cat-file --batch`
reads it. Blobs stream straight to their destination file instead of being
held in a BTreeMap for the whole tree. read_blobs is replaced by
stream_blobs, which drains stderr on a dedicated thread and waits on the
child on every return path, including an error from the per-object
callback (F9).
F6: pins four previously-unpinned Docker argv/environment combinations
(N1: --read-only survives a Named network plus a cache mount; N2: a secret
mount stays read-only alongside a plain environment entry; N3: the source
stamp is never dropped alongside a secret and a plain environment entry;
N5: an LFS pointer file sets contains_lfs_pointers).
Tests: 142 to 152. cut1.psd1 grows by 12 entries (5 for F1-F5, 4 for F6's
N1/N2/N3/N5, and one recording the F2 fsck flag as not yet reached -- see
below); all 28 non-comment entries in the suite now kill.
Discrepancy: cut1-fix-f2-no-fsck-on-fetch could not be pinned. Every tree
this crate's own tools can build that fsck's duplicateEntries check rejects
also has a literal duplicate path or a case-folded ancestor collision that
refuse_conflicting_tree_entries rejects first, unconditionally, before the
fetch's fsck result can matter. The flag stays as defense in depth (fsck's
other structural checks are broader than this crate's own parser), recorded
as not yet reached in cut1.psd1 rather than removed or falsely claimed
unreachable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Self reviewed the F2 survey finding (Eve's gitlink under Ghostlight carries
`packages/*/dist/** text eol=lf`) and ruled it is not a real dependency on an
archive transform: Git stores text blobs as LF, `eol=lf` checks them out as
LF, so no conversion happens. Self verified this directly at Ghostlight's
pinned Eve gitlink revision `672c0c1e9fc828b63752345385727b06c50491d0` — all
26 files under `packages/*/dist/` hash identically between `git archive` and
`git hash-object --no-filters`, and none contains a CR. Stopping for the
survey was correct; the ruling is to proceed.
Survey table (repo, attributes found, verdict), recorded here as ruled:
| Repo (deploy target) | Binding source | .gitattributes findings | Verdict |
|---|---|---|---|
| Ghostlight | idunn/yggdrasil/bindings/ghostlight.toml.in | none | clean |
| Ghostlight's gitlink vendor/eve -> Eve | same binding | packages/*/dist/** text eol=lf | transform present, but a no-op on stored LF content (Self's evidence above) |
| Odin | idunn/yggdrasil/bindings/odin.toml.in | none | clean |
| CodexConnector | idunn/yggdrasil/bindings/codex-connector.toml.in | none | clean |
| Muninn (raven-muninn) | idunn/yggdrasil/bindings/raven-muninn.toml.in | none | clean |
| Heimdall | deployment/idunn-bindings/heimdall.toml.template (a second, previously unlisted binding directory, installed live 2026-09-06) | none | clean |
| Heimdall's gitlink -> CultLib | same binding | * text=auto only (F12) | clean |
`freeze_exact` now writes every tree entry raw from the object store:
`git ls-tree -r -z` for the manifest, `git cat-file --batch` for content, with
no `git archive` anywhere in the path and so no attribute-driven transform of
any kind. Modes and symlinks are preserved exactly as the tree records them.
Gitlinks are frozen the same raw way, through the same primitive, at their
own path. LFS content is never fetched; `freeze_exact` now also returns
whether any materialized file is an LFS pointer (recorded in the frozen
result's own return value — not yet plumbed into `FrozenSourceReceipt`, which
has no field for it; that is follow-up scope, not this fix).
Deleted: `git_archive_into`, `materialize_gitlink_archive`, and the
now-unused `tar_program` field — the archive-and-extract path they built has
no remaining caller. `GitSourceDriver::new` no longer takes a tar program.
Tests:
- `freeze_exact_is_byte_exact_across_every_attribute_transform`: a fixture
with `eol=crlf`, `export-ignore`, `export-subst`, `ident`, a symlink, the
executable bit, and a Gitlink. Every frozen file's bytes are asserted equal
to `git cat-file blob` at the selected revision.
- `freeze_exact_matches_archive_when_untransformed_and_diverges_when_transformed`:
the deploy-equivalence test Self's ruling asked for. An `eol=lf` file over
already-LF content matches today's independently-reconstructed `git archive`
tree exactly; a second file with `eol=crlf` over the same LF content
diverges from it, so the equivalence check has teeth rather than trivially
passing for every fixture.
- `exact_git_blobs_become_root_owned_immutable_source_without_git_metadata`
(renamed from the git-archive-era test) now exercises `materialize_tree_raw`.
- `tools/verify-mutations/cut1.psd1`: `cut1-fix-freeze-skips-gitlinks`
retargeted at the new call, and a new `cut1-fix-f2-revert-to-git-archive`
entry that reverts `materialize_tree_raw` to a `git archive | tar` pipeline;
it must die against the byte-exact test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's pass on Cuts 0-1 found the argv-lowering test pinned only the
default runner path (13 of 14 mutants survived), freeze fetching the
same revision twice, an untested Gitlink loop, dead StepPort/StepOutcome
code, and a stale guide.md line. This lands Self's rulings for all of
it except F2 (the byte-exact freeze): the F2 survey found that Eve, a
Gitlink of the Ghostlight deploy target, depends on an archive
transform (`.gitattributes` eol=lf under packages/*/dist/**), so per
ruling 1 the byte-exact rewrite is withheld and reported instead.
- F1: `docker_run_args`/`ContainerSpec::for_step` are now pinned across
every network branch (none/bridge/named), the full CPU quota range
(100/150/250/333/800), cache root present/absent, and secret mounts,
with dedicated fixtures for the cache-root and secret-file validation
calls so a mutant that skips either is caught.
- F3: `freeze` fetches the exact revision once, through `freeze_exact`,
instead of fetching before it and again inside it.
- F5: `freeze_exact_materializes_gitlinks` pins that the Gitlink loop
actually runs.
- F7: deletes the dead `StepPort`/`StepOutcome` types; `run_step` never
had a caller, and `docker()` is the one Docker spawn path.
- Stamp override: `ContainerSpec::for_step` now refuses a step
environment name that collides with the Idunn source stamp, so the
invariant holds at the one place every runner's environment is built,
not only at deploy's separate `admit` check.
- docs/guide.md:11 no longer claims the retired generation still runs
on Yggdrasil.
F8 (the `--cap-drop` negative check) is a reporting fix, not a code
change: `rg -n '"--cap-drop"' src/` now returns 4 hits (1 real site
plus 3 test fixtures, one of which this batch adds), never the "1 hit"
the map's verification text describes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes the driver-private Docker argv assembly in run_in_workspace and
base_run_args and the OperatorBinding/CompiledDeploymentPlan coupling inside
GitSourceDriver::freeze's fetch-and-archive core. Both become one shared
primitive apiece, the shape Cut 4's verify worker will reuse instead of
growing a second copy:
- ContainerSpec + docker_run_args(spec, workspace, working_directory, argv):
a plain, pure lowering to the exact `docker run` argv. ContainerSpec::for_step
is the one constructor (a DockerRunnerBinding, a step's required
environment, and the source stamp); it does the cache-root and secret
validation against the filesystem before docker_run_args ever runs.
run_in_workspace and materialize_external_input both build a ContainerSpec
and call docker_run_args now; base_run_args is gone (rg '"--cap-drop"'
src/ has exactly one hit).
- ExactSource {origin, checkout, gitlinks, recipe_path} plus
GitSourceDriver::freeze_exact(&ExactSource, revision, transaction_id, root),
which fetches, archives and hardens an exact revision with no
OperatorBinding or compiled plan. freeze() now derives an ExactSource from
the binding, keeps its own verify_exact_source pre-check against the
durable resolution, and delegates the archive to freeze_exact.
- A StepPort trait (DockerRunnerDriver::run_step) that runs a lowered step
and returns exit status plus a bounded stderr tail instead of bailing.
Not yet reached from materialize (Cut 4 wires it into the verify worker);
deploy keeps DockerRunnerDriver::docker's existing bail-on-failure error
text unchanged.
materialize_external_input's curl step now goes through the same lowering,
which adds a harmless --workdir /workspace and one inert IDUNN_EXTERNAL_INPUT_ID
env var to its argv; curl's behavior (an absolute --output path) is
unaffected. materialize_gitlink_archive's cache-directory name changed from
the operator binding's target label to a hash of the checkout path, since
ExactSource carries no target field; this is bookkeeping only; a UUID
already made every gitlink checkout unique.
Adds three unit tests: container_spec_lowers_to_exact_docker_argv pins the
full docker run argv; required_environment_is_the_only_environment pins F6
(a binding environment entry the step does not require never leaks in);
freeze_exact_is_byte_exact_and_recipe_checked and its negative twin
freeze_exact_rejects_a_recipe_the_archive_transforms (an export-subst
recipe, which git archive substitutes and git cat-file does not) exercise
freeze_exact directly, with no OperatorBinding in sight.
tools/verify-mutations/cut1.psd1 covers all five rules with a revert or
loosening mutant apiece, run through the Eureka harness in the stopgap
container. Verified on Yggdrasil at this commit: cargo test --lib is 130
passed, 0 failed, 2 ignored (the P1 baseline of 126/2 plus these four new
tests), and every mutation entry was killed by its own test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes the two stale "Current implementation boundary" sections in
docs/deployment-authority.md and docs/authority-map.md; both described a
pre-drivers.rs era and were wrong (drivers.rs has driven Git, runners,
systemd, nginx, brakes, CultCache and CultMesh for weeks). The still-true
second paragraph of the deployment-authority.md section (private plan state
is never published as Odin topology) moves to the end of "Typed deployment
state", reworded present tense.
Fixes README.md's stale claims: the CLI has five commands (cancel and
validate were missing), and docs/guide.md plus deploy/legacy/ describe a
generation that no longer runs anywhere, not "still what runs on yggdrasil".
Documents present truth in "Candidate promotion": advance_sealing resolves,
freezes, runs every recipe step and installs before the deployment brake is
consulted at all. The brake only gates the move into Starting, the first
Verse-visible change.
Docs only, no build required.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>