Recipes

Per-person filesystem isolation for a multi-tenant agent runner

Discovered from README cross-links · today

Security, Science & HardwareSoftware & DevOps

Deny each person's agent steps access to other people's worktrees

What they did

Working through Task 8 of a nine-task remote/multi-tenant plan for Landrace, the author implemented isolation between people's steps on a shared instance. They created `src/people/isolation.ts` exporting `claudeProjectDirOf(cwd)` (mapping a cwd to `~/.claude/projects/<slug>` by replacing every non-alphanumeric character with `-`) and `denyForPerson(self, others, roots)`, which returns the credentials root, each other person's worktree scope directory, and every existing Claude project directory whose name starts with the slug of another person's worktree. The deny list is computed when each step starts. They then modified `src/kit/executor.ts` to merge the per-person deny list into the run's deny paths for every tier alongside `DEFAULT_DENY`, `integrations/claude/index.ts` to apply the deny list to both read-tier and write-tier runs (read-tier argv denies `Read(<path>/**)`, write-tier sets `sandbox.filesystem.denyRead`), and `src/cli/start.ts` to pass the roster's other people into the executor context. Tests were written first and expected to fail: a fake HOME with projects for alice and bob, asserting alice's deny list contains bob's scope dir and project dirs but not alice's, and always the credentials root; a Claude integration test for read-tier and write-tier argv; and a security test asserting a single local person gets only the credentials root, so local behaviour is unchanged. They documented the residual risk in a new `docs/security.md` section, "People on one instance", matching spec §9, then ran `pnpm test`, `pnpm typecheck`, `pnpm lint` and `pnpm build`, plus a live check where a read step prompted to read another person's worktree file was refused by Claude, and committed as `feat: deny each person's steps the other people's files`.

How it went

All named tests pass, along with `pnpm test`, `pnpm typecheck`, `pnpm lint` and `pnpm build`. The live check confirmed Claude refuses a read step pointed at another person's worktree file. Local single-person setups are unchanged: the deny list there contains only the credentials root. The work is one merge in a strictly ordered nine-task sequence, so it depends on the eight tasks before it and is not standalone.

Worth knowing

This is deny-rule isolation, not OS-level isolation: no per-person OS user or container, which the design defers to a later `agent.isolation: container` value. The residual risk is stated in the docs rather than eliminated. The Claude project-directory matching is prefix-based on a slugified worktree path, so it depends on that naming convention holding. The task assumes the roster, `Person` model, `pathFor` and credentials store from earlier tasks already exist; nothing here stands alone.

Wiring

Order matters: the roster, `Person` model, `pathFor(item, root, scope)` and the credentials store must already exist from earlier tasks. `denyForPerson` consumes `Roster` and `pathFor`, and is called at step start so newly created Claude project directories are picked up. The executor merges `ExecutorContext.denyPaths` with `DEFAULT_DENY` for every tier, so both read-tier and write-tier runs inherit the same list. The Claude integration translates the list into two shapes: read-tier argv `Read(<path>/**)` deny rules, and write-tier `sandbox.filesystem.denyRead` entries. `src/cli/start.ts` supplies the other people from the roster into the executor context. Roots are configurable: worktrees, credentials (`~/.landrace/credentials/`) and Claude projects (`~/.claude/projects/`). Locally, with one person, the list reduces to the credentials root only, so no local behaviour changes.

Try it yourself

Implement per-person filesystem isolation for agent steps in this repo, following the approved design's Decision §9.

1. Create `src/people/isolation.ts` exporting:
   - `claudeProjectDirOf(cwd: string): string` returning `~/.claude/projects/<cwd with every non-alphanumeric char replaced by "-">`.
   - `denyForPerson(self: Person, others: Person[], roots: { worktrees: string; credentials: string; claudeProjects: string }): Promise<string[]>` returning the credentials root, each other person's worktree scope directory, and each existing Claude project directory whose name starts with `claudeProjectDirOf(<worktrees>/<other.id>)`. Compute it when each step starts.
2. Add `ExecutorContext.denyPaths?: string[]` and merge it with `DEFAULT_DENY` in `src/kit/executor.ts` for every tier.
3. In `integrations/claude/index.ts`, apply the deny list to read-tier and write-tier runs: read-tier argv denies `Read(<path>/**)`; write-tier sets `sandbox.filesystem.denyRead`.
4. In `src/cli/start.ts`, pass the roster's other people into the executor context.
5. Write failing tests first in `tests/people/isolation.test.ts`, `tests/integrations/claude.test.ts` and `tests/security/isolation.test.ts`: with a fake HOME holding projects for alice and bob, `denyForPerson(alice, [bob])` contains bob's scope dir and bob's project dirs, not alice's, and always the credentials root; read-tier argv for alice denies bob's scope and project dirs; a single local person gets only the credentials root.
6. Add a `docs/security.md` section "People on one instance" stating the residual risk as in spec §9.
7. Run `pnpm test && pnpm typecheck && pnpm lint && pnpm build`, then live-check that a read step prompted to read another person's worktree file is refused by Claude. Commit as `feat: deny each person's steps the other people's files`.

Source

Tools in this stack