Skip to content

fix(skills): prove an SSH skill-install destination on the host, not from the request - #19866

Closed
nwparker wants to merge 1 commit into
mainfrom
fix/ssh-skill-install-host-paths
Closed

nwparker wants to merge 1 commit into
mainfrom
fix/ssh-skill-install-host-paths

Conversation

@nwparker

@nwparker nwparker commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​235 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​234
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​87 $\color{#cf222e}{\Huge{\mathbf{−}}}$​10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​77

Refs #18273

ELI5

When you install a skill onto an SSH host, the request says "put it in worktree
worktree_1, which lives at <path>". The relay took that path at face value
and used it as the install root. The one containment check it ran was:

requireContained(workspaceDirectory, workspaceDirectory)

— a directory is always inside itself, so that assertion could never fail. Any
directory the request named became the destination on the host.

Now the host decides. It refuses places that obviously aren't a workspace, and
for a worktree it asks Git whether the directory is really a checkout.

What changed

1. Structural rules, in resolveSkillInstallDestination (both kinds).
The workspace directory may not be a filesystem root, may not be the host's home
directory, and may not contain the home directory. A checkout under the home
tree — the normal case — still installs. This replaces the vacuous call; the
plan's Step 1 "contain under home" was deliberately not used, because the
plan's own STOP condition rules it out: "any subdirectory of home" still permits
~/.ssh and ~/.gnupg.

2. A Git proof, for worktree destinations, on the relay.
git -C <candidate> rev-parse --show-toplevel must name the candidate itself.
Only a checkout root answers with itself, so ~/.ssh, ~/.gnupg, /etc and a
path merely inside a checkout are all rejected as
skill-install-workspace-not-found. --show-toplevel is Git 1.7-era, so no
capability probe or fallback is needed.

The relay has no worktree catalog to look an id up in — an Orca worktree id is
client metadata, and splitWorktreeIdForFilesystem derives the path from the
id
, so it is exactly as caller-controlled as workspace.path. Verification
against the host's own filesystem is the only authority available here.

Mixed-version: path stays required on the schema and old clients keep
sending it. It is now a hint the host verifies rather than authority it grants —
no new required field, no new stream opcode
(docs/reference/remote-wire-compatibility.md Rules 1–2).

Tests

  • src/main/skills/skill-install-destinations.test.ts — home directory, a
    directory containing home, and a filesystem root all throw
    skill-install-destination-escape; a real workspace under the home tree still
    resolves.
  • src/relay/skill-install-workspace-authority.test.ts — a full staged-upload
    install through the relay handler against three claimed paths: ~/.ssh
    (refused), a real git init checkout (installed, SKILL.md written under
    .agents/skills/), and a subdirectory of that checkout (refused).

Mutation results (5/5 killed)

mutation killed by
return the caller's workspace with no host check (the bug) refuses a client path that is not a checkout on this host, refuses a path merely inside a checkout rather than its root
accept any path inside a repo, not just the root refuses a path merely inside a checkout rather than its root
restore the vacuous containment call the 3 structural cases
drop only the home-directory rejection rejects the execution host home directory as a workspace, rejects a directory that contains the execution host home
flip containment so real workspaces are refused still accepts a real workspace directory that sits under the home tree + 1

Known residual — folder workspaces

A folder workspace is not necessarily a Git repository, so the Git proof
cannot apply to it without breaking legitimate plain-folder workspaces, and the
relay has no other host-side registry to check it against. Folder destinations
therefore get the structural rules only. Blast radius is bounded — the install
root always has .agents/skills/<validated-name> appended, so this creates a
directory rather than overwriting a named file — but a caller can still place
one in any writable directory on the host. Closing it properly needs a host-side
workspace registry (the relay learning which folder roots a session actually
opened), which is larger than this change and worth its own issue. Flagging
rather than shipping a guess.

Gates

pnpm tc, pnpm test src/relay src/main/skills … (2327 passed), oxlint,
pnpm run check:code-quality:changed, pnpm run check:max-lines-ratchet,
pnpm format.

…from the request

The relay's `authority()` returned the RPC `workspace` object verbatim when the
id matched, caller `path` included, so whatever directory a request named
became the install root on the execution host. The only containment check was
`requireContained(workspaceDirectory, workspaceDirectory)`, true by
construction and therefore asserting nothing (threat model TM-04/TM-11).

Two checks replace it, both answered by the machine that owns the filesystem:

- `resolveSkillInstallDestination` refuses a workspace directory that is a
  filesystem root, is the host's home directory, or contains it. A checkout
  under the home tree — the normal case — still installs.
- For a worktree destination the relay now asks Git: `rev-parse
  --show-toplevel` inside the candidate must name the candidate itself, so only
  a real checkout root is accepted. `~/.ssh`, `~/.gnupg` and `/etc` are not.

Mixed-version safe: `path` stays on the schema and old clients keep sending it,
but it is a hint the host verifies rather than authority it grants. No new
required field and no new stream opcode.

Refs #18273

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Structural workspace rules — requireContained(dir, dir) (a no-op) replaced by requireWorkspaceDirectory, which rejects a filesystem root, the home tree itself, and any directory that contains the home tree, for both worktree and folder destinations on every host.
  • Host-side Git proof — the relay's authority().resolveWorktree now gates on isHostGitWorktreeRoot (git -C <candidate> rev-parse --show-toplevel must name the candidate), so ~/.ssh, a non-checkout directory, and a path merely inside a checkout are refused as skill-install-workspace-not-found.
  • Tests — structural escape cases in skill-install-destinations.test.ts plus an end-to-end relay test covering refuse/install/refuse-nested against a real git init checkout, with the mutation table verifying each rule is load-bearing.

The fix is well-scoped and sound: the threat-model references (TM-04/TM-11) are accurate, --show-toplevel is Git 1.7-era so no capability probe is needed, and the shared resolveSkillInstallDestination change does not regress the local authority (legitimate worktrees sit under home and pass; WSL UNC paths resolve to a different root and also pass). The wire contract is unchanged — path stays required on .strict() schemas and becomes a host-verified hint, which old clients keep sending harmlessly.

ℹ️ Worktree proof verifies "a checkout root", not "the requested worktree"

The Git proof establishes that the candidate is some checkout root on the host, not that it is the worktree the session actually opened. Any git checkout the SSH user owns is accepted as a destination. This is bounded (the install root always has .agents/skills/<validated-name>/ appended) and stays within the same user's authority, so it is a precision limitation rather than an escalation — and it is the exact symmetric of the folder-workspace residual already documented in the PR body, which the author deferred to a host-side workspace registry. No change requested; noting it only so the follow-up issue covers worktrees as well as folders.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The change validates local skill-install workspace destinations against filesystem roots and the execution host home directory. It adds Git-based validation for SSH relay destinations so only checkout roots are accepted. New tests cover rejected plain directories, accepted checkout roots, rejected nested checkout paths, and valid local workspaces.

Merge Risk: 🟡 Moderate · up to 8468e

Workspace installation may still target a WSL home directory or a directory substituted after Git validation. These destination-boundary issues should be resolved before merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: host-side verification of SSH skill-install destinations.
Description check ✅ Passed The description is substantive and covers the problem, implementation, compatibility impact, residual folder-workspace limitation, linked issue, tests, and quality gates. It omits several template hea…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: b6231cb1-007a-4698-907d-510e9d676c4f

📥 Commits

Reviewing files that changed from the base of the PR and between 2bf298d and 8468ec9.

📒 Files selected for processing (5)
  • src/main/skills/skill-install-destinations.test.ts
  • src/main/skills/skill-install-destinations.ts
  • src/relay/skill-install-handler.ts
  • src/relay/skill-install-workspace-authority.test.ts
  • src/relay/skill-install-workspace-proof.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.

'skill-install-workspace-unavailable'
)
requireContained(workspaceDirectory, workspaceDirectory)
requireWorkspaceDirectory(homeDirectory, workspaceDirectory)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the producer contract for WSL workspace identities and the filesystem consumer.
ast-grep outline src/main src/relay --items all --type function \
  --match 'resolveWorktree|resolveFolderWorkspace|resolveWsl|createWslSkillInstallFilesystem'

rg -n -C 8 --type ts \
  '\b(resolveWorktree|resolveFolderWorkspace|resolveWsl|wslDistro|createWslSkillInstallFilesystem)\b' \
  src/main src/relay

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- destination implementation ---'
sed -n '1,180p' src/main/skills/skill-install-destinations.ts

printf '%s\n' '--- workspace resolver definitions ---'
rg -n -l --type ts 'resolveWorktree|resolveFolderWorkspace|type SkillInstallDestinationAuthority|interface SkillInstallDestinationAuthority' src/main src/relay \
  | head -80

Repository: stablyai/orca

Length of output: 9001


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- skill-install authority wiring ---'
rg -n -C 12 --type ts \
  'resolveSkillInstallDestination|resolveWorktree:|resolveFolderWorkspace:|resolveWsl:' \
  src/main/skills src/relay

printf '%s\n' '--- workspace identity construction ---'
rg -n -C 8 --type ts \
  'wslDistro:\s|wslDistro\?|path:\s|resolveWorktree\s*\(|resolveFolderWorkspace\s*\(' \
  src/main/skills src/relay src/main/project-groups src/main/runtime \
  | head -500

Repository: stablyai/orca

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- all production authority call sites ---'
rg -n -C 10 --type ts \
  'SkillInstallDestinationAuthority|resolveWorktree:|resolveFolderWorkspace:|resolveWsl:' \
  src/main src/relay \
  | rg -v '(\.test\.ts|__tests__)' \
  | head -500

printf '%s\n' '--- WSL workspace producers ---'
rg -n -C 12 --type ts \
  'wslDistro.*workspace|workspace.*wslDistro|wslDistro.*path|path.*wslDistro|parseWslUncPath|wslHookRelayConnectionId' \
  src/main src/relay \
  | head -500

Repository: stablyai/orca

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- runtime authority ---'
sed -n '131,188p' src/main/runtime/runtime-skill-install-authority.ts

printf '%s\n' '--- WSL path and home helpers ---'
rg -n -C 8 --type ts 'function getWslHome|getWslHome\s*=|export .*getWslHome|parseWslUncPath' \
  src/main src/shared \
  | head -300

printf '%s\n' '--- WSL filesystem consumer ---'
fd -i 'skill-wsl-install-filesystem' src/main/skills --type f -x sh -c 'cat -n "$1"' sh {}

Repository: stablyai/orca

Length of output: 48531


Path Traversal

Reachability: External
Exploitability: Moderate
CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Reachability path
● Entry
  src/main/skills/skill-install-destinations.test.ts:130
│
▼
● Sink
  src/main/skills/skill-install-destinations.ts

Validate WSL workspaces against the WSL home directory.

When workspace.wslDistro is set, resolve its WSL home with resolveWsl and pass that home to requireWorkspaceDirectory. Keep workspaceDirectory as the installation target. Add a test that rejects a WSL workspace equal to its distro home.

if (workspace?.kind !== 'worktree' || workspace.id !== id) {
return null
}
return (await isHostGitWorktreeRoot(workspace.path)) ? workspace : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,140p' src/relay/skill-install-workspace-proof.ts
sed -n '1,150p' src/main/skills/skill-install-destinations.ts
sed -n '1,150p' src/relay/skill-install-workspace-authority.test.ts
rg -n "requireWorkspaceDirectory|workspaceDirectory|install.*workspace|symlink|realpath" src/main/skills src/relay/skill-install-workspace-authority.test.ts

Repository: stablyai/orca

Length of output: 42447


🏁 Script executed:

sed -n '1,125p' src/main/skills/skill-install-service.ts
sed -n '1,125p' src/main/skills/skill-install-request-service.ts
sed -n '1,125p' src/main/skills/skill-install-filesystem.ts
sed -n '1,220p' src/main/skills/skill-install-destinations.test.ts

Repository: stablyai/orca

Length of output: 21414


Authorization Bypass

Reachability: External
Exploitability: Difficult
CWE: CWE-367 — Time-of-check Time-of-use (TOCTOU) Race Condition

Bind the destination to the Git-proven path.

requireDirectory rejects symlink entries, but it does not prevent replacement with a regular directory after Git validation. Return the canonical path from isHostGitWorktreeRoot and use it as workspace.path. Add a regression test for this replacement race.

@nwparker

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate of #18357, which is the stronger version of this fix — it proves the destination against the host's own git worktree list rather than rev-parse --show-toplevel, uses the relay git env and capability cache, and handles folder ids distinctly. Review notes left there instead. Nothing here needs to land.

@nwparker nwparker closed this Sep 10, 2026
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