Sitelet https://github.com/brianonbased-dev/HoloScript/pull/522
Skip to content

fix(studio): P0b refuse .git landing paths + harden server git invocations - #522

Open
brianonbased-dev wants to merge 1 commit into
mainfrom
grok/studio-p0b-git-hardening-20261005
Open

brianonbased-dev wants to merge 1 commit into
mainfrom
grok/studio-p0b-git-hardening-20261005

Conversation

@brianonbased-dev

Copy link
Copy Markdown
Owner

fix(studio): P0b refuse .git landing paths + harden server git invocations

Base: main @ 5a8f4ccb8dc8db4110596a3e3f9d060bc2d804de (#521, squash-merged) · Branch: grok/studio-p0b-git-hardening-20261005 · Head: 2871a406ac

Lane: normal security (slow lane) plus a Mapping seat, same as #521. CI note: HoloCI has not re-sealed yet, so CI here is Release's labelled box run until HoloCI seals this head again.

Severity / exposure: high-severity, but pre-existing on main — #521 did not introduce it, and this branch does not depend on #521's logic beyond building on its merged tree (it needs #521's B2 landingPath resolver). While #516 keeps sign-in limited to the founder/admin allowlist, reaching these routes is Joseph-only today; this is defence in depth and multi-user readiness. Must be rebased onto main after merge if main moves (it is already based on current origin/main = 5a8f4ccb).

Problem

lib/workspace/workspaceFs.ts blocked .git by the request text only (segments[0] === '.git'). It never looked at where a path lands after symlinks.

Release's re-gate of #521 at 51938b22 reproduced the full chain with real git 2.47.3:

  1. A repo with a symlink gitalias -> .git survives git clone (git checks links out as links).
  2. POST /api/workspace/files write of gitalias/config passed the text check and fix(studio): P0 cap exception — workspace owner check (IDOR on /api/workspace/*, /api/git/*, Brittney, daemon, holodaemon) #521's symlink-landing check (the landing .git/config is inside the workspace), returned 200, and overwrote .git/config.
  3. Setting core.fsmonitor to a script there makes the next server-side git status (/api/git/status, and the existingWorkspaceImporter git calls) run that script as the Studio user. core.hooksPath / .git/hooks/* do the same on commit/checkout.

Repro artifacts: /workspace/security-4/regate-51938b22/ (the OBSERVE .git alias write | 200 line in symlink-controls.txt).

Fix — two independent guards

Guard 1: refuse .git by resolved/landing path, not text (lib/workspace/workspaceFs.ts)

  • New hasGitMetadataSegment(relativePath): true when any path segment is .git, any depth, case-insensitive and tolerant of trailing dots/spaces (so .GIT / .git. on a case-folding or Windows FS count). .gitignore, my.git.txt, .github/ and src/git/ are not matched.
  • resolveInsideWorkspace now refuses (GIT_METADATA_ERROR) when any of these has a .git segment: the lexical target, the realpathed deepest-existing ancestor, or the B2 landing path (every symlink component followed). So a gitalias -> .git symlink, a nested a/b/.git/x, or an outside link that lands in a .git are all refused before anything is written.
  • Applied to every caller of the resolver: /api/workspace/files list/read/write/move/delete/mkdir (so reads too — it was free and safe, the resolver is shared), /api/workspace/paper-opt-in, and daemon/jobs store.applyPatchesToWorkspaceBranch patch targets. validateWorkspaceRelativePath keeps a cheap early text-level .git refusal (defence in depth).
  • /api/git/tree refuses to list a .git directory named directly or reached through a symlink.
  • assertWorkspaceOwner refuses a workspace path that is or sits inside .git (so a projectPath/rootPath/workspacePath can't point at repo metadata).

Guard 2: one hardened git helper (lib/git/safeGit.ts)

runGit(args, opts) / runGitSync(args, opts) prepend hardening config (command-line -c overrides repo, global and system config and is inherited by git's own subprocesses):

  • core.fsmonitor=false — no fsmonitor hook program on status/diff/add/commit.
  • core.hooksPath=/dev/null — not a directory, so no repo hook runs on commit/checkout/clone/push.
  • protocol.ext.allow=never — ext:: remotes run an arbitrary command; the push/ship routes use the repo's own remote URL, so a repo config must not be able to re-enable it.

Deliberately omitted, kept minimal (residuals):

  • GIT_CONFIG_NOSYSTEM: system config belongs to the image, not a tenant; disabling it could drop legitimate settings (e.g. safe.directory).
  • core.sshCommand=: Studio talks to GitHub over HTTPS with a token; an empty value would only break SSH remotes. Like other repo-config program settings (credential.helper, filter/textconv drivers, gpg.program), it requires a writable .git, which Guard 1 now closes. Called out below.

Call-site table — every server git invocation

Call site Before Now through lib/git/safeGit?
app/api/git/status/route.ts (status, rev-parse, log) execFile('git') ✅ runGit
app/api/git/branch/route.ts (branch, rev-parse) execFile('git') ✅ runGit
app/api/git/commit/route.ts (add, status, commit, rev-parse) execFile('git') ✅ runGit
app/api/git/diff/route.ts (diff) execFile('git') ✅ runGit
app/api/git/blame/route.ts (blame) execFile('git') ✅ runGit
app/api/git/push/route.ts (remote get/set-url) execFile('git') ✅ runGit
app/api/git/ship/route.ts (add, status, commit, rev-parse, remote) execFile('git') ✅ runGit
app/api/workspace/import/route.ts (clone, rev-parse, ls-files) execFile('git') ✅ runGit (via its execGit)
app/api/daemon/jobs/store.ts (apply-to-branch: status, checkout, add, commit, remote…) execFileSync('git') ✅ runGitSync
lib/workspace/existingWorkspaceImporter.ts (rev-parse, status) execFile('git') ✅ runGit
lib/workspace/provisionUser.ts (read-only git -C) execFile('git') ✅ runGit
lib/workspace/founderWorkspaceBackfill.ts (config --get remote.origin.url) execFile('git') ✅ runGit
lib/workspace/templates/hooks.ts execSync('git …') in a generated hook file's source ⛔ N/A — this is a string written into a scaffolded repo's hook that runs in the user's checkout, not a Studio server call. Left as is.
app/api/holoclaw/run, agents/fleet/dispatch, quest-proof/task, portable-mind spawn/spawnSync of node/npx/tsx, never git n/a

rg for execFile/spawn/exec('git' across packages/studio/src (excluding tests) now returns only lib/git/safeGit.ts.

Tests (real git in temp dirs)

lib/workspace/__tests__/gitHardening.p0b.test.ts (6) and lib/git/__tests__/safeGit.test.ts (2).

Test On base 5a8f4ccb On fix
(a) write through gitalias -> .git refused; .git/config unchanged RED (200, overwritten) GREEN (400)
(b) direct .git/config write refused (text + landing; read/move/mkdir via symlink too) RED GREEN (400)
(c) core.fsmonitor in .git/config not run by /api/git/status (marker absent) RED (marker written) GREEN
(d) core.hooksPath pre-commit hook not run by /api/git/commit (marker absent) RED (marker written) GREEN
full-chain control: gitalias write refused and planted fsmonitor not executed by status RED GREEN
(e) normal files next to .git still work (.gitignore, my.git.txt, .github/, src/git/) GREEN (non-regression) GREEN
safeGit: hardening args precede the subcommand n/a (new) GREEN
safeGit: core.fsmonitor reads back false at runtime (real git) n/a (new) GREEN

Red-on-base was measured by checking out the touched source files at 5a8f4ccb and running the behavioral suite: 5 failed / 1 passed (the 1 is (e), which always held). Log: p0b-behavioral-on-base.log.

Negative controls (remove a guard → red; restore → green)

Control RED Which tests go red
G1 — .git landing guard neutralized (hasGitMetadataSegment -> false) 3 failed / 6 (a), (b), full-chain
G2 — hardening args removed (GIT_HARDENING_ARGS -> []) 5 failed / 8 (c), (d), full-chain, + both safeGit unit tests

The full-chain end-to-end control goes red under either guard removal, as required. Script + JSON: p0b-negative-controls.py, p0b-negative-controls.json.

Suite results (same box, same env)

Run Passed / failed Failing files
base 5a8f4ccb 6692 / 77 74
this branch 6700 / 77 74 (same set)
  • No new failures: identical failing-file and per-test failing sets (JSON diff empty). The +8 passing tests are the new ones.
  • Targeted suites (lib/workspace, api/workspace, api/git, api/daemon, lib/daemon, api/holodaemon, api/brittney, lib/brittney, lib/absorb, lib/git, plus fix(studio): P0 cap exception — invite-only sign-in (founder/admin allowlist, fail closed) #516 auth suites): 862 passed; 6 files fail to load on unbuilt @holoscript/* packages, all failing on main too.
  • One existing test updated: src/__tests__/studio-docker-runtime-tools.test.ts asserted the import route contained execFile('git'. git now runs via lib/git/safeGit, so it now asserts the route imports @/lib/git/safeGit and that the helper is where execFile('git')/execFileSync('git') live — same invariant (the serving image still needs git).
  • tsc --noEmit: 399 error TS at this head and 399 at 5a8f4ccb, no new errors in any touched file (only pre-existing TS2307 for unbuilt packages). Prettier clean on all touched files.

Residuals

  1. Other repo-config program hooks beyond fsmonitor/hooks (credential.helper, filter/textconv drivers, gpg.program, core.sshCommand, core.pager) are not each pinned on the command line. They all require a writable .git, which Guard 1 now denies, so they are not reachable through the known write path; pinning them (or GIT_CONFIG_NOSYSTEM/core.sshCommand=) is a possible hardening follow-up, left out to stay minimal.
  2. Scaffolded hook templates (lib/workspace/templates/hooks.ts) run git in the user's own checkout outside Studio's server process; unchanged here.
  3. Must be rebased onto main if main advances past 5a8f4ccb before merge.

…tions

Pre-existing on main (not introduced by #521): workspaceFs blocked `.git`
by request text only. A clone carrying `gitalias -> .git` let
POST /api/workspace/files write `gitalias/config` (200) over `.git/config`;
setting core.fsmonitor there makes the next server-side `git status` run
that program as the Studio user. Reproduced with real git 2.47.3.

Guard 1 — refuse .git by where the path LANDS, not its text
(lib/workspace/workspaceFs.ts): hasGitMetadataSegment() (any depth,
case-insensitive, trailing dot/space tolerant; `.gitignore` / `my.git.txt`
are fine). resolveInsideWorkspace now refuses when the lexical target, the
realpathed deepest existing ancestor, or the followed landing path has a
`.git` segment, so every write/move/delete/mkdir caller (files, paper-opt-in,
daemon patch targets) is covered; reads too (files read uses the same
resolver). git/tree refuses to list a `.git` (direct or via symlink).
assertWorkspaceOwner refuses a workspace path that is or sits inside `.git`.

Guard 2 — one hardened git helper (lib/git/safeGit.ts). Every server git
call runs with `-c core.fsmonitor=false -c core.hooksPath=/dev/null
-c protocol.ext.allow=never` (command-line config overrides repo/global/
system and is inherited by git subprocesses). Routed: all /api/git/* routes
(status, branch, commit, diff, blame, push, ship, tree), workspace/import
clone, daemon/jobs store apply-to-branch, existingWorkspaceImporter,
provisionUser, founderWorkspaceBackfill. (GIT_CONFIG_NOSYSTEM and
core.sshCommand deliberately omitted; see PR body.)

Tests (real git in temp dirs): lib/workspace/__tests__/gitHardening.p0b.test.ts
and lib/git/__tests__/safeGit.test.ts. Five behavioral tests are red on
5a8f4cc and green here; negative controls remove each guard and the right
tests (incl. the full end-to-end chain) go red.
@brianonbased-dev

Copy link
Copy Markdown
Owner Author

HoloCI verdict: NOT JUDGED

HoloCI queued the head commit of this pull request with the quick profile, but no gate result came back in 60 minutes, so it stopped watching.

  • Gates when it stopped: 0 done, 0 failed, 0 running, 12 queued, 0 blocked of 12
  • Commit: 2871a406accda0cb6e5ced5c4e2fc41c9fce6a56
  • Workload: ci-2871a406-muxntrn8

That is a problem with the CI fleet, not a verdict on the code: nothing here says the change is right or wrong, and it needs another run before anyone relies on it.

This branch has not been deployed

No deployments
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