fix(mcp): refuse a probe temp declared inside the sealed runtime parent (#8747) - #9108
fix(mcp): refuse a probe temp declared inside the sealed runtime parent (#8747)#9108pepmach wants to merge 1 commit into
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Probe-only scope inverts the failure signal: a sealed-temp spec now probes green while runtime still hands the child the unwritable temp. Watch
Suggestions
[DESIGN-REVIEWED] a580122 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS ~200 lines of UNC/mapped-drive/link-chain refusal guard the check's own resolution on Windows — the one platform where nothing seals Not justified as shipped
What this change shipsIntent: FIX — a probe reported green while handing the probed server a temp dir sealed read-only, whenever the spec declared
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] a580122 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. The sole candidate — a POSIX [OPUS-REVIEWED] a580122 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/mcp_discovery.py:2107 -- preserving lowercase False positive or not applicable? A repository writer can comment: |
4b14323 to
586d3fa
Compare
586d3fa to
673c46c
Compare
cixuuz
left a comment
There was a problem hiding this comment.
Two security issues remain:
-
path_within_sealed_runtime_parent()callsrealpath()/filesystem identity probing on spec-controlled input before rejecting remote Windows spellings. A UNC/device path can therefore initiate SMB or other remote resolution during a local containment check. Reject remote/device forms before any resolution and use no-follow/local identity checks. -
In the managed-temp rewrite,
key in _CANONICAL_TEMP_KEYSis case-sensitive even though spec env keys are explicitly treated case-insensitively on Windows. A declaredtmpdir/Tempcan survive the rewrite. Normalize every comparison withkey.upper().
iamwhatever
left a comment
There was a problem hiding this comment.
There are comments about security related, please handle it. Thanks
673c46c to
233fc5f
Compare
233fc5f to
3ec046b
Compare
e340b57 to
e665f78
Compare
e665f78 to
eecb6f8
Compare
|
Closing the mapped-drive residual disclosed earlier — now in The pre-resolution guard now classifies drive roots before any filesystem access: a drive-lettered spec path is checked through With this, all three legs of the remote-access surface are covered pre-resolution: declared UNC/device spellings, link chains targeting them, and mapped drive roots. |
eecb6f8 to
802ec54
Compare
An MCP spec may declare its own temp key, and the discovery probe honored it by suppressing its managed allocation entirely. When the declared path resolves inside <data home>/run -- the runtime parent BOTH sandbox backends seal read-only -- the probed child received a TMPDIR it could not write: the same Bun/Koffi extraction failure #8653 fixed for the managed path, and silent, because a server that never touches temp still answers the handshake and the probe reports it green. Carving the declared path out of the seal is not the alternative: spec env is untrusted config text and extra_writable_dirs is validated for self-derived scratch only, so a validator that merely refused guarded paths would approve any other subtree under run. The declaration is therefore REFUSED and the managed temp takes over -- allocated before the wrap and carved out, exactly as an undeclared probe already is. That keeps the operator's storage intent, since a path under run named the data-home volume and so does the managed root, and it is announced with a WARNING naming the seal and the key it overrode rather than failing quietly. Refused as a whole: tempfile consults TMPDIR before TMP, so honoring a surviving sibling key would leave writability depending on which key the spec happened to spell. sandbox.py owns the seal, so it answers the question: path_within_sealed_runtime_parent tests both the lexical and the canonical spelling (a supported symlinked data home is seen independently by path-based rules). When the allocation itself fails, the refusal still stands and every temp key is stripped, so the child inherits the ambient temp instead of a directory already known to be read-only. Spelling alone is not enough to answer it, and neither is spelling in the wrong order. realpath runs on the ORIGINAL declaration, before any lexical pass: normalizing first collapses `..` lexically and so deletes the very symlink that `..` was climbing out of, which let a <symlink-into-run>/../tmp declaration read as outside the seal while the child's libc resolves symlink-first and lands back inside it. And on case-insensitive APFS <data home>/RUN and <data home>/run are ONE directory, which realpath does not fold either -- it walks with lstat/readlink, neither of which canonicalizes the case of an existing component -- so a differently-cased declaration passed a purely lexical predicate, the probe honored it, and the child got the sealed TMPDIR anyway. The predicate therefore asks the filesystem: it compares (st_dev, st_ino) against the sealed parent while climbing the declared path's ancestors, which also catches firmlink and normalization aliases. A declared temp usually does not exist yet, so the walk simply climbs until a component stats -- the deepest EXISTING ancestor carries the identity, and nothing below a sealed directory can climb back out of it because the path arrives resolved and normalized. Only identity is compared, never a folded spelling, so a case-SENSITIVE filesystem answers exactly as before: there RUN is a different directory, it does not exist, and nothing seals it. An unstat-able sealed parent yields the lexical answer rather than a folded guess -- the parent is primed and created a few lines above, so reaching that needs a deletion race, and folding there would mis-refuse a genuinely distinct RUN directory for no gain. Asking the filesystem is right, but it must not be the FIRST thing asked. The declaration is untrusted config text, and on Windows resolution IS access: realpath opens the path (GetFinalPathNameByHandle) and stat follows it, so a declared UNC path made this local containment check open an SMB connection to a host the config author chose -- stalling the caller for the SMB timeout when that host is dead, and reaching drivers and volume aliases for a device spelling (\\?\, \\.\) that no containment rule can reason about. A remote or device spelling is therefore refused from the RAW declaration, before realpath and before any stat, matched lexically on the two-leading-separator shape in both separator directions so \\server\share, //server/share, and the \\?\UNC\ long form all answer the same. Refused on every platform deliberately: the same config text is read on whichever OS the gateway runs, and True is the honest answer as well as the safe one, since containment cannot be established locally and the caller's response to True -- stop honoring the path, fall back to the managed temp -- is what an unverifiable declaration warrants. The identity walk that remains stats no-follow (lstat, never stat), so a planted final-component symlink cannot redirect it off-host either; nothing is lost, because symlink resolution already happened in the canonical spelling the same predicate compares. The rewrite that carries the refusal out matched temp keys case-SENSITIVELY, which handed the sealed path back through the door the refusal had just closed. Detection is case-insensitive on purpose -- Windows env keys are case-insensitive and the sanitized spec preserves the author's spelling -- so a spec declaring tmpdir was correctly SEEN, correctly refused, and then left its own key sitting in the child's env beside the managed TMPDIR, which on Windows is the same variable. Every comparison now normalizes: the managed branch drops every case spelling of a temp key before merging its triple, and the honored branch keeps the spec's EXACT spelling while pruning the ambient siblings -- upper-casing alone is not enough there, since a declared tmpdir and an ambient TMPDIR both fold to one name and both would survive, leaving writability to whichever the child's lookup reached first. Refusing the declared spelling leaves one indirection open, and closing it needs the same no-follow discipline one level down. An ordinary-looking local path can BE a symlink (or a Windows junction / reparse point) whose target is a share: it clears the lexical test, and realpath then follows it, so the SMB connection happens inside the containment check exactly as it would for a declared UNC path, with the guard never seeing it. The path is therefore resolved here instead, component by component, with os.readlink -- which returns the link's OWN contents and opens nothing -- and each target is tested BEFORE the walk descends into it, so a remote target is judged without ever being reached. The walk CHASES rather than inspecting only the declared path's own components, because a first hop whose target is local would otherwise clear the check and hand realpath a chain that ends on a share. `..` is applied during the walk rather than collapsed up front, for the same reason the caller already documents. Bounded at 40 hops, matching Linux's own ELOOP ceiling: reading links ourselves means the kernel is not detecting cycles for us, and the bound fails CLOSED, which is the answer every other unverifiable declaration already gets here. A component that cannot be read at all carries no redirection and is walked through, since a declared temp usually does not exist yet. Each target is put through BOTH remote tests the declared path gets -- the UNC/device spelling and the mapped-drive ROOT below -- because the indirection changes which one applies: a local-looking declaration can point at `Z:\tmp`, whose drive root only the second question recognises, and descending into it would hand realpath a chain ending on the share. security/paths.py was checked first for a resolver to reuse. Its bounded _resolved_forms_bounded is not one: it wraps realpath (which follows) on a thread pool, and skips resolution only when the INPUT is already UNC on Windows -- which is the lexical test above, not the indirection past it. One spelling is left that the lexical test cannot see, and it is the common enterprise one: a mapped network drive. `Z:\tmp` carries no two-leading-separator prefix, and when it is not a symlink the chain scan finds nothing to judge -- yet if `Z:` is a mapped SMB share then realpath opens it and makes the round-trip anyway. No string test separates `Z:` from a local disk, so the OS is asked instead, about the ROOT: GetDriveTypeW reports a mapped drive and a UNC root alike as remote, and asking about the root touches no file on the volume, which is what makes it safe to ask before deciding whether the path may be resolved at all. Reused rather than rebuilt -- windows_acl.volume_is_local already carries that binding and already allowlists the local drive kinds instead of denylisting the remote one, so DRIVE_NO_ROOT_DIR and DRIVE_UNKNOWN come back as not-local for free. Refused unless PROVABLY local, which follows the seal's own semantics rather than being caution for its own sake: a root the OS cannot mount or cannot classify is not <data home>/run, so declining it gives up nothing. The volume_is_local import sits at module scope: windows_acl imports cleanly on every platform (its wintypes aliases have POSIX fallbacks) and platform_compat already pulls it into this module's graph, so hoisting it costs nothing. The Windows API call sits behind a sys.platform gate in a one-line probe, which is both the platform seam and the test seam: off Windows the probe answers None, the check is a no-op, and substituting it lets the classification logic -- root derivation, the refusal, the fail-closed default -- run on a POSIX CI runner. The two sentinels are deliberately not interchangeable: False means the volume was classified and is not local, None means there was no volume to classify, and only the second is a pass. Drive DETECTION uses Windows path semantics on every platform (ntpath.splitdrive), because whether the text names a drive is a question about the spelling rather than about the running OS; it decides nothing on its own, since a driveless path never reaches the probe. Verified live against an isolated pod (seed connections-two) through POST /api/mcp/custom + POST /api/mcp/probe, with three servers reporting their own TMPDIR facts as tool names. Before: write_EROFS on a declared run/mcp-tmp and mkdir_EROFS on a declared run/custom-tmp. After: both probe on a managed run/mcp-tmp/probe-*/tmp with write_ok, while parentwrite_EROFS shows the seal itself is unchanged. Closes #8747
802ec54 to
a580122
Compare
|
@pepmach Thanks for this one. The audit found no duplicate or superseding work, so please keep the PR and finish it rather than narrowing or splitting it. What is already on What still blocks it, not scope: the audit read Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
|
@bolichen97 Ack — matches the plan here: the PR stays whole (case-alias identity + the review-round hardenings), nothing being split out. Predecessor overlap you found is the already-merged groundwork, nothing to coordinate. |
The security findings behind this request are in: remote/device path spellings, symlink chains and mapped drives are refused before any filesystem resolution, and a case-variant temp key can no longer defeat the sealed-temp refusal (details with citations: #9108 (comment), #issuecomment-5578364406, #issuecomment-5579746570). Current head a580122 is green. Dismissing the stale request so re-review can be picked up; re-requesting review.
What
A spec-declared MCP temp key (
TMPDIR/TMP/TEMP) suppressed the probe's managed allocation entirely. When the declared path resolves inside<data home>/run— the runtime parent both sandbox backends seal read-only — the probed child got a temp dir it could not write, silently: a server that never touches temp still answers the handshake, so the probe reported green.The declaration is now refused when it lands inside that seal, and the managed probe temp takes over (allocated before the wrap and carved out, exactly as an undeclared probe already is), with a
WARNINGnaming the seal and the key it overrode.Carving the declared path out of the seal was rejected as the remedy: spec
envis untrusted config text andextra_writable_dirsis validated for self-derived scratch only, so a validator that merely refused guarded paths would approve any other subtree underrun. This is option (a) from triage — the smaller surface, matching how the managed path works after #8746 — plus the loud diagnostic from option (b).Details:
sandbox.path_within_sealed_runtime_parent— the seal's own module answers the containment question, testing both the lexical and the canonical spelling (a supported symlinked data home is seen independently by path-based rules), and then asking the filesystem itself:(st_dev, st_ino)is compared against the sealed parent while climbing the declared path's ancestors, so a case, firmlink, or normalization alias of the seal cannot walk past the predicate. Resolution order is load-bearing:realpathruns on the original declaration, before any lexical pass, because normalizing first collapses..lexically and so deletes the very symlink that..was climbing out of — a<symlink-into-run>/../tmpdeclaration then read as outside the seal while the child's libc resolves symlink-first and lands back inside it. On case-insensitive APFS<data home>/RUNand<data home>/runare likewise one directory thatrealpathdoes not fold (it walks withlstat/readlink, neither of which canonicalizes case), which a purely lexical predicate answered "not sealed" for. A declared temp usually does not exist yet, so the walk climbs until a component stats — the deepest existing ancestor carries the identity, and nothing below a sealed directory can climb back out because the path arrives resolved and normalized. Only identity is compared, never a folded spelling, so a case-sensitive filesystem answers exactly as before; an unstat-able sealed parent yields the lexical answer rather than a folded guess, since the parent is primed and created a few lines above and folding there would mis-refuse a genuinely distinctRUNdirectory for no gain.tempfileconsultsTMPDIRbeforeTMP, so honoring a surviving sibling key would leave writability depending on which key the spec happened to spell.realpathand before anystat. On Windows resolution is access:realpathopens the path (GetFinalPathNameByHandle) andstatfollows it, so a declared UNC path made this local containment check open an SMB connection to a host the config author chose (stalling the caller for the SMB timeout when that host is dead), and a device spelling (\\?\,\\.\) reaches drivers and volume aliases no containment rule can reason about. Matched lexically on the two-leading-separator shape in both separator directions, so\\server\share,//server/shareand the\\?\UNC\long form all answer alike; refused on every platform deliberately, since the same config text is read on whichever OS the gateway runs.Trueis the honest answer as well as the safe one — containment cannot be established locally, and the caller's response toTrue(stop honoring the path, fall back to the managed temp) is what an unverifiable declaration warrants.lstat, neverstat), so a planted final-component symlink cannot redirect it off-host either. Nothing is lost: symlink resolution already happened in the canonical spelling the same predicate compares, which is where a link into the seal is caught with every component resolved.tmpdirwas correctly seen, correctly refused, and then left its own key in the child's env beside the managedTMPDIR, which on Windows is the same variable. The managed branch now drops every case spelling before merging its triple; the honored branch keeps the spec's exact spelling while pruning the ambient siblings, because upper-casing alone would let a declaredtmpdirand an ambientTMPDIRboth survive and leave writability to whichever the child's lookup reached first.runnamed the data-home volume, and so does the managed root.mcp_gateway/backend.py(spawn_backend, at the "an OPERATOR-DECLARED temp wins" comment, no seal check) and the matching block ingatewayd.py— named here so the follow-up issue this defers to can point straight at them. Until that lands, a probe-green result no longer models runtime for a spec that declares a sealed temp: the probe now runs with a managed temp the runtime child will not get.Live verification (isolated pod, not unit tests)
Isolated pod on the worktree, seed
connections-two, three servers added throughPOST /api/mcp/customand probed throughPOST /api/mcp/probe. Each server ispython3 -creporting its own TMPDIR facts as tool names, so the probe response carries the evidence.Before (unmodified worktree):
status: okon the first two rows is the silence in the issue title.After (same pod, same routes, fixed code):
parentwrite_EROFSon every row shows the seal itself is unchanged — nothing was widened.Pod journal:
Pod torn down afterwards: isolated HOME nuked,
pod lsclean, live gateway untouched.Tests
Red-first: every new test fails with the corresponding source change stashed, passes with it.
test/test_mcp_discovery.py::TestProbeTempContainment::test_sealed_spec_declared_temp_falls_back_to_managed— allocation happens, the carve-out names the managed scratch, all three canonical keys point at it, and the sealed path reaches nothing.…::test_sealed_declared_temp_stripped_when_allocation_fails— no temp key survives when there is no managed dir to point at.test/test_sandbox_argv.py::TestSealedRuntimeParentPredicate— containment insiderun, non-containment outside the data home and for an empty value, and both spellings of a symlinked data home.…::test_a_symlink_climbing_back_into_the_run_parent_is_sealedbuilds a real out-of-runsymlink pointing back inside and declares<link>/../tmp— lexically that collapses to a path outside the seal, so it fails withassert Falseagainst normalize-then-resolve and passes oncerealpathruns on the original spelling.…::test_a_case_alias_of_the_run_parent_is_sealedand…::test_a_missing_leaf_under_a_case_aliased_parent_is_sealedmodel an APFS-style case-insensitive name lookup narrowly (the fold applies tostatandlstatalike, since it lives in lookup; real calls for every path outside the fixture root;realpathdeliberately left unfolded, exactly as APFS behaves) and both also fail withassert Falseagainst the lexical-only predicate.…::test_a_case_alias_outside_the_run_parent_is_still_not_sealedis the negative control — an aliased spelling that folds onto a directory outside the seal must stay honored — and it passes both before and after, so the fix is not a blanket refusal.…::test_a_real_case_alias_is_sealed_where_the_filesystem_folds_casemakes the same claim against the real filesystem and skips on a case-sensitive CI filesystem.The pre-existing
test_spec_declared_temp_suppresses_probe_containmentstill passes unchanged: it declares a temp OUTSIDE the seal, which is still honored.Red-first for the two security findings, both failing on
233fc5f33:…::TestSealedRuntimeParentPredicate::test_a_remote_or_device_declaration_is_refused_without_resolving_it— six spellings (\\host\share,//host/share,\\?\C:\tmp,\\.\PIPE\tmp,\\?\UNC\host\share, and a mixed-separator form). Each wrapsrealpath/stat/lstatin a recorder that logs any call made on a remote-shaped path and delegates everything else to the real function, so the test asserts both halves of the claim: the declaration is refused, and no resolution seam was reached on it. Sixassert Falsebefore, and against the fix the recorder stays empty. Paired with…::test_a_local_declaration_is_still_resolved, which passes before and after so the fast rejection is not a blanket refusal of config text.…::test_the_identity_walk_does_not_follow_a_final_symlink— a link whose final component points at the sealed dir. Under the followingstatthe walk answeredTrue(assert not Truebefore); underlstatit sees the link's own identity and answersFalse.…::test_a_symlink_to_the_run_parent_is_still_sealedis the companion proving no coverage was lost — the same link is still refused by the public predicate, via the canonical spelling.…::TestProbeTempContainment::test_sealed_declared_temp_refused_whatever_case_the_spec_spelled[tmpdir|Temp|TmP]— the refusal bypass itself. Before the fix all three leave the sealed path in the captured child env (assert str(sealed) not in captured_env.values()fails on.../run/custom-tmp); after it the sealed path is gone in every spelling and exactly the three canonical names remain, so no lowercase twin is left to shadow the managed triple on a case-insensitive platform.…::test_unsealed_declared_temp_honored_in_the_spec_own_spelling— the mirror case, with ambientTMPDIR/TMP/TEMPset and a declaredtmpdiroutside the seal. Before:assert ['TMPDIR', 'tmpdir'] == ['tmpdir']— the ambient key survived beside the declaration and would wintempfile's TMPDIR-before-TMP lookup. After: the declared spelling is the only temp key left, and the managed allocator is asserted never to run.Gates:
test/test_mcp_discovery.py test/test_sandbox_argv.py test/test_security_posture.py test/test_spawn_audit.py -q→ 497 passed, 6 skipped, plus one pre-existing environment failure (test_importing_this_module_does_not_pull_in_the_mint_engine, which spawns a bare interpreter without the package installed and fails identically on a pristinemainclone).flake8,isort --check-only,mypy src/kiro_crew/sandbox.py src/kiro_crew/mcp_discovery.pyclean; black-baseline gate passes on the committed scope (no unrelated reformat churn).Pattern harvest
Rule candidate: review-prompt
Pattern: lexical path containment (
==/startswith/commonpath) used as a security or writability boundary, with no filesystem-identity check — and, its sibling, normalizing a path before resolving it, which collapses..past the symlink it was climbing out of. Both make the comparison disagree with what the kernel will do: the first misses case, firmlink, and normalization aliases the filesystem considers the same directory; the second misses a link that climbs back into the guarded subtree. Round 1 of review found the second defect in the fix for the first, which is the argument that the class is worth a prompt rather than a one-off note. It also generalizes to an established precedent a few hundred lines above in the same module —assert_voice_runtime_outside_agent_workspacealready walks(st_dev, st_ino)for exactly this reason — so a reviewer prompt that flags a new lexical boundary check near a path the sandbox seals, and asks both whether an identity walk belongs beside it and in which order the path is resolved, would have caught both before review. Deliberately not a semgrep rule: lexical containment is correct and desirable in the many places that are not a trust boundary, so the signal needs the judgment a prompt has and a pattern match does not.Closes #8747