Skip to content

fix(studio): verbatim-symlink install executes bytes outside the substrate root — add a realpath containment walk - #506

Merged
KnockOutEZ merged 3 commits into
studio-handoff-corefrom
sd-173-fix-studio-verbatim-symlink-inst
Aug 27, 2026
Merged

fix(studio): verbatim-symlink install executes bytes outside the substrate root — add a realpath containment walk#506
KnockOutEZ merged 3 commits into
studio-handoff-corefrom
sd-173-fix-studio-verbatim-symlink-inst

Conversation

@KnockOutEZ

@KnockOutEZ KnockOutEZ commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Closes KnockOutEZ/wigolo-studio-run#173. PX0 exit-2, SEC-2/K2 MED — blocking, because it falsifies a shipped security claim.

The defect

install() copies with verbatimSymlinks, which is required (883d94a0: it is what keeps a bundle's relative framework links working). It equally carries a source-authored absolute symlink across intact — and every containment check was a string check on a path that never touched the filesystem:

  • the manifest string is bin/run — no .., not absolute, so the manifest rule passes;
  • isInside compared resolve() output, so the record rule passes;
  • the acquire-time probe used existsSync, which follows the link, so verification passes.

defaultLaunch then spawns join(path, executable), the OS follows the link, and the bytes that run live somewhere else on the machine.

Base-vs-tip differential

Driving the real acquireSubstrate / readSubstrateRecord / defaultLaunch from dist/, against a bundle whose bin/run is ln -s <elsewhere>/payload.sh:

OLD (7ed4bc61)

acquire.outcome      : acquired
record present       : true
spawn target         : .../data/substrate/1.2.3/bin/run
  resolves to        : .../outside/payload.sh
  OUTSIDE the root?  : YES — executes from outside
defaultLaunch spawned: true
PAYLOAD EXECUTED     : YES (executed)

NEW

acquire.outcome      : failed
acquire.detail       : the desktop component contains a link to something outside the directory it installs into
acquire.error        : link escapes its directory: bin/run -> .../outside/payload.sh
record present       : false
spawn target         : <none — nothing to spawn>
defaultLaunch spawned: false
PAYLOAD EXECUTED     : no

The 883d94a0 behaviour is untouched — a framework bundle reachable only through two relative links still installs, still reads back, and still launches after the install source is deleted:

acquire.outcome        : acquired
Versions/Current link  : A
Resources link         : Versions/Current/Resources
spawn target resolves  : .../substrate/7.7.7/Frameworks/E.framework/Versions/A/Resources/run
  inside the root?     : true
EXECUTABLE RAN         : YES (launched)

What changed

  • acquireSubstrate walks the installed tree and refuses any link that leaves it, before the executable probe that cannot see past it. Judged by where a link resolves, not how it is spelt — an absolute target is refused outright, a relative one is refused when it resolves out. Symlinked directories are judged as links, never descended into, so a link cycle cannot walk forever.
  • isInside realpaths both sides. A symlinked directory inside ~/.wigolo/substrate/ no longer satisfies containment (the exit review's LOW SPA-shell heuristic produces false positives on short static HTML pages #3), and the macOS /var/private/var prefix still does — resolving one side only would decline every legitimate record on the platform the desktop component targets.
  • readSubstrateRecord resolves the spawn target itself, so a link swapped in after acquisition reads as absent rather than as a launchable component.

The two false claims

  • substrate-acquire.ts:133-139 claimed no shape of record.json could name a program elsewhere on the machine. That was false while it compared strings — the escape does not need a record.json edit at all. Restated as what makes it true now: the filesystem answers containment, not the text.
  • substrate-acquire.test.ts:342-344 asserted "no link in the installed tree may address anything outside the directory it was installed into" against a fixture whose links are relative by construction. Restated as what it actually establishes (the copy did not rewrite them), with the containment claim moved to the negative arms that hold it up.

Tests

Five new arms, all red against 7ed4bc61 and green here: an absolute link at the executable; an escaping link elsewhere in the tree while the executable is a genuine file (a walk, not a second probe on the one named path); a relative link that climbs out (so a fix that merely banned the leading separator would not pass); a record whose directory is a link out of the root; a record whose executable resolves outside. Plus an anti-false-decline arm for the linked-prefix case.

Suites: npm test green, npx tsc --noEmit clean, npm run gate:studio exit 0.

…cks accept

The install copies symlinks verbatim, which carries a source-authored ABSOLUTE
link across intact, and every containment check was a string check on a path
that never touched the filesystem. Five arms reproduce it: an absolute link at
the executable, an escaping link elsewhere in the tree, a relative link that
climbs out, a record whose directory is a link out of the root, and a record
whose executable resolves outside. All five are red against today's module.

The positive framework arms stay as they were, plus an anti-false-decline arm
for the /var -> /private/var prefix the platform actually uses.
…strings

verbatimSymlinks copies a link's target string unchanged. That is what keeps a
bundle's relative framework links working, and equally what carries a
source-authored ABSOLUTE link across intact — while every containment check was
a string check that never touched the disk: the manifest string 'bin/run' has no
'..' and no leading separator, isInside compared resolve() output, and the
acquire probe used existsSync, which FOLLOWS the link. defaultLaunch then spawned
the joined path and the OS ran bytes from outside the substrate root.

- acquire walks the installed tree and refuses any link that leaves it, judged by
  where it RESOLVES rather than how it is spelt, before the executable probe that
  cannot see past it. Symlinked directories are judged, not descended.
- isInside realpaths both sides, so a symlinked directory inside the root no
  longer satisfies containment and the /var -> /private/var prefix still does.
- readSubstrateRecord resolves the spawn target itself, so a link swapped in
  after acquisition reads as absent rather than as a launchable component.

The docstring claim that no shape of record.json could name a program elsewhere
on the machine was false while it compared strings; it is restated as what makes
it true now.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20b65180-e3da-45b9-9e63-f5aa19df08b8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Mutating the absolute-target refusal out of the walk left the file green: for a
link that RESOLVES, the absolute rule and the resolve rule reach the same verdict
and either alone suffices — two mechanisms keyed on one decision.

They part on a DANGLING absolute link. It resolves to nothing, so the walk falls
back to judging it lexically, and a target spelt inside today's substrate root
passes — leaving an absolute path baked into the installed tree that the
substrate follows the moment anything appears there, and that points elsewhere
the moment the data dir moves. The new arm plants exactly that and reds without
the rule.

Four mutants, each killed by a named test: realpath only one side of isInside
(11 reds, including the anti-false-decline arm), drop the absolute refusal (1),
remove the walk (4), remove the spawn-target resolve in readSubstrateRecord (1).
All reverted by reverse-edit.
@KnockOutEZ
KnockOutEZ merged commit f67e9f1 into studio-handoff-core Aug 27, 2026
37 of 38 checks passed
@KnockOutEZ
KnockOutEZ deleted the sd-173-fix-studio-verbatim-symlink-inst branch August 27, 2026 23:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant