provenance: bind running-image checks to process start time (closes #60) - #76
Conversation
There was a problem hiding this comment.
1 issue found across 8 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/subc-control/src/lib.rs">
<violation number="1" location="crates/subc-control/src/lib.rs:495">
P2: When a Linux daemon reports `process_identity_unconfirmed`, clients built against subc-control 0.6.0 reject the entire `supervisor.provenance` response because this closed enum has no unknown-value fallback. Preserve mixed-version decoding by using a forward-compatible reason representation or negotiate/gate the new value before emitting it to older clients.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| RunningExecutableUnreadable, | ||
| SpawnedPathUnreadable, | ||
| HashFailed, | ||
| ProcessIdentityUnconfirmed, |
There was a problem hiding this comment.
P2: When a Linux daemon reports process_identity_unconfirmed, clients built against subc-control 0.6.0 reject the entire supervisor.provenance response because this closed enum has no unknown-value fallback. Preserve mixed-version decoding by using a forward-compatible reason representation or negotiate/gate the new value before emitting it to older clients.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/subc-control/src/lib.rs, line 495:
<comment>When a Linux daemon reports `process_identity_unconfirmed`, clients built against subc-control 0.6.0 reject the entire `supervisor.provenance` response because this closed enum has no unknown-value fallback. Preserve mixed-version decoding by using a forward-compatible reason representation or negotiate/gate the new value before emitting it to older clients.</comment>
<file context>
@@ -492,6 +492,7 @@ pub enum RunningImageUnavailableReason {
RunningExecutableUnreadable,
SpawnedPathUnreadable,
HashFailed,
+ ProcessIdentityUnconfirmed,
}
</file context>
|
Both findings reviewed at source. P1 is valid and I am fixing it. P2 identifies a real break, but its proposed fix contradicts a documented invariant in this repo, so I want your ruling rather than my unilateral change. P1 — valid, confirmed, being fixedThe TOCTOU is real. Taking the suggested fix — open the descriptor first, validate identity second, hash the retained descriptor. That ordering is sound in every interleaving:
The invariant that buys: recycling can only ever cost a verdict, never buy a wrong one. Fix in flight, with a test that proves descriptor retention directly — open a descriptor, replace the file at that path with different content, hash, assert the digest is the original inode's. P2 — the break is real; I verified it. The fix is your call.First, the concern is not theoretical. Old client, new payload: And the blast radius is worse than the bot states. That payload held three modules — But the suggested remedy — a forward-compatible/open reason representation — runs straight into
That is the reasoning from #31, and I argued for it there, so I am not going to quietly reverse it in a PR about something else. Where I think the policy genuinely does not reach: line 275 is about op enums, and line 291's "old peers reject unknown with a typed error" is also about op values. Both are cases where rejection is scoped to one request and the peer learns something actionable. Line 17's additive-only rule covers struct fields. Options as I see them:
I lean 3 — it fixes the recurrence without pretending to fix the current skew, and it forces the doc to say which enums are closed and why. But this is a protocol-policy decision with a documented precedent pointing the other way, so I would rather have your call than guess. Happy to implement whichever in this PR. One note on practical exposure while you decide: |
28349b0 to
de6f568
Compare
|
P1 fixed in The order inside let running = match File::open(&running_path) { ... }; // 1. pins the inode
if process_start_time(pid) != Some(expected_start_time) { // 2. validate identity
return unavailable(RunningImageUnavailableReason::ProcessIdentityUnconfirmed);
}
compare_opened_descriptor(&mut cache, &running_path, running, &spawned_from) // 3. hashes the descriptor
The new test proves descriptor retention directly rather than trying to race a PID: write content A, open a descriptor, delete the path and recreate it with content B, then hash through the retained descriptor and assert the digest is A's. Red-proved by switching the implementation back to re-opening by path — the test fails with B's digest and a Two notes for you. A vestigial parameter I left rather than removed. Gate provenance, since it bears on how much these numbers are worth. All gates for this branch ran in a worktree. A sibling flagged a case today where a linked worktree produced 19 test failures that vanished in the artifact-owner checkout — baseline-equivalent failures read as "no regression" while meaning the tests exercised nothing. My two excluded failures are not that: I reproduced both in the primary checkout on unmodified master before excluding them (#75), so they are upstream-real rather than worktree artifacts. The four new arms are mutation-proved, which is the direct defense against a vacuous test — a test that exercises nothing cannot be made to go red by breaking what it guards. |
de6f568 to
b581432
Compare
|
Rebased onto The two failures I was excluding are gone at the source rather than worked around, so the earlier attribution arithmetic is retired — there is nothing left to attribute. Moving the vectors into the crate that consumes them is the right structural fix, and stronger than what I suggested on #75. A crate reaching up into Also folded in the TOCTOU fix from the review (head The protocol question in my previous comment — new |
b581432 to
1b9472c
Compare
Linux can recycle a supervised child PID before provenance observation, so /proc/<pid>/exe may describe another process. Capture raw /proc stat start ticks at spawn and require exact equality before hashing; if the start time is missing or changed, image ownership is unconfirmable and unavailable is the only honest wire result. Open /proc/<pid>/exe before validating the start time and hash the retained descriptor, so PID reuse cannot substitute a different image between validation and hashing. CONSUMER-IMPACT: subc-control 0.7.0 — additive process_identity_unconfirmed unavailable reason; consumers must handle this new unavailable case.
1b9472c to
3aff07f
Compare
|
Rebased onto Head The version collision with #77 still stands — both bump |
There was a problem hiding this comment.
Review by execution: the descriptor-then-verify ordering is the load-bearing choice and it is correct — /proc/pid/exe opened FIRST, start-time confirmed through the live process AFTER, so a recycled pid fails the confirm and a process that dies post-confirm still hashes the pinned descriptor. The recycled-inode note on cache_key (five fields, none redundant) is the same identifier-reuse hazard closed at the second layer, and documenting it at the key site is what keeps a refactor from 'simplifying' it away. Full workspace green locally at the pre-rebase head (759/0), full matrix green on feat/probe-pid-identity at the rebased head. Merging.
Closes #60.
ExecutableIdentityProbe::observeread/proc/<pid>/exewithout confirming the PID still belonged to the process the supervisor spawned. If a child exits and the OS recycles its PID before the probe runs, that path points at an unrelated process.The dangerous outcome is not the false alarm. If the recycled process happens to be running the same binary — another
ck-subc, another copy of the same module, which is entirely plausible on a busy box — the probe returnsMatch, the strongest positive attestation the daemon makes, about a process that is not ours. An attestation that fails open is worse than one that fails loudly.Fix
Pair the PID with the process start time, which the kernel assigns at exec and a recycled PID cannot reproduce. The supervisor captures it alongside the existing spawn facts; the probe re-reads it and compares before touching the image.
When identity cannot be confirmed — recorded and current start times differ, or the value is unreadable — the probe returns a new typed
process_identity_unconfirmed, never a verdict.Precedence is load-bearing. The identity check runs strictly before the digest comparison, and its
unavailablewins. A recycled PID running a different binary makes both facts true at once, and reportingMismatchthere would assert "the running image is the wrong binary" about a process we cannot identify. An unconfirmable identity forfeits the right to make any claim about the image, in either direction.unavailableis not the cautious middle betweenMatchandMismatch— it is the only honest answer when we cannot say whose process we are looking at.Tests
Four arms, each proved load-bearing by breaking the seam it guards and showing it goes red:
unavailableMatchMatchMismatch/proccomm-field parsesplit_whitespace→18instead of424242The positive arm is the one that matters. A gate that always returns
unavailablepasses the obvious mismatch test while destroying the feature, and it looks like caution while doing it — so the test that a real, live, un-recycled process still returnsMatchis what proves the check does not manufacture false unavailables. It spawns a real process and captures its start time through the production path.The parse arm covers a trap worth naming: field 2 of
/proc/<pid>/statis the executable name in parentheses and can contain both spaces and parentheses, so a naivesplit_whitespace().nth(21)misparses for any such process and yields a permanent falseunavailable. The parser splits on the last)instead. That test is deliberately not platform-gated — it parses a string, touches no/proc, and the trap is equally wrong everywhere, so it runs on every platform's CI.Start-time values are compared exactly as read, in raw clock ticks. No conversion to wall time: every conversion introduces rounding or drift, and drift here manufactures false
unavailableresults on healthy processes.Scope
Linux only. The macOS arm compares file identity and already discards the PID, so it has no exposure to this race; Windows remains
UnsupportedPlatform. Wire surface is additive — one newRunningImageUnavailableReasonvariant,subc-control0.6.0 → 0.7.0 with the dependent cascade.subc-protocolandsubc-transportare untouched. No golden fixtures changed.CI will be red, and not from this change
Master is currently red for both platforms — the history rewrite removed
docs/team-mode/while three code paths still reference fixtures there, soagent-token-vectorsfails to compile its test target and onesubc-coretest panics. Filed as #75. Any branch cut from current master inherits it.Attribution against that baseline: 2 workspace failures, both traced to the two missing fixture files, leaving 0 attributable to this change. Everything else is green, run independently of the implementer's own report —
cargo fmt --all --check,cargo build --workspace --locked,clippy --all-targets -D warnings(excluding the uncompilable crate),check-wire-crate-versions.sh origin/master, and the full provenance suite at 15/15.I did not generate the missing fixtures to get a green gate. A fabricated conformance vector passes the tests while encoding values nobody verified, and the next reader cannot tell it from a real one.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Fixes a Linux-only PID-recycling race in running-image provenance checks (closes #60). The probe previously read
/proc/<pid>/exewithout confirming the PID still belonged to the spawned process, so a recycled PID running the same binary could produce a falseMatch; it now captures the process start time at spawn, compares it against the current process, and returns a newProcessIdentityUnconfirmedunavailable verdict when identity cannot be confirmed.MatchorMismatch./proc/<pid>/statparser splits on the last)so executable names containing spaces or parentheses parse correctly.Migration
subc-controlbumps 0.6.0 → 0.7.0 with the additiveProcessIdentityUnconfirmedreason; consumers must handle the new unavailable case.subc-client-rs(0.8.0 → 0.9.0) andsubc-core(0.8.1 → 0.9.0) bump via the dependency cascade.Written for commit 3aff07f. Summary will update on new commits.