Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/harness-adapters/references/harness/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ Every claude spawn therefore pre-registers the directory its pane starts in befo
A second, separate dialog - "Allow external CLAUDE.md file imports?" - renders whenever a loaded CLAUDE.md chain reaches outside the project tree, which every crewmate's does through the captain's own `~/.claude/CLAUDE.md` importing `~/.claude/RTK.md`.
`--setting-sources project,local` (the minimal worker tool surface) does not suppress it either, and it gates the pane exactly like the trust dialog: cursor on "No, disable external imports", no way to move the selection from firstmate's steering plane.

`../../../bin/fm-claude-trust.sh` records `hasTrustDialogAccepted` for both the worktree and its primary checkout in `${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json`, where a home's worker account pin decides `CLAUDE_CONFIG_DIR` (`../../../docs/configuration.md` "Worker account pin"), for a ship or scout spawn; a secondmate spawn registers only its own home entry, since a secondmate home has no separate primary-checkout entry to carry import consent forward from.
`../../../bin/fm-claude-trust.sh` records `hasTrustDialogAccepted` for both the worktree and its primary checkout in `${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json`, where a home's worker account pin decides `CLAUDE_CONFIG_DIR` (`../../../docs/configuration.md` "Worker account pin"), for a ship or scout spawn.
For worktrees belonging to a sibling clone of the same repository (common when sharing a treehouse pool across homes), trust is recorded against that sibling checkout rather than the spawning home's own clone.
A secondmate spawn registers only its own home entry, since a secondmate home has no separate primary-checkout entry to carry import consent forward from.
For a ship or scout spawn, the external-imports flags (`hasClaudeMdExternalIncludesApproved`, `hasClaudeMdExternalIncludesWarningShown`) are carried forward alongside the trust flag only when the primary checkout's project entry already carries an explicit `hasClaudeMdExternalIncludesApproved===true` from a prior interactive session - the common first-spawn case is a project claude has never been asked about, so those two flags are left unwritten and the import dialog still renders, even though trust registers normally.
When the project entry instead already carries an explicit decline (`hasClaudeMdExternalIncludesApproved===false` with `hasClaudeMdExternalIncludesWarningShown===true`), the whole registration refuses - including the trust flag - rather than manufacture consent the human never gave, so that spawn wedges on the trust dialog before it would even reach the import one.
Both flags `false` is Claude Code's default entry for a project never asked, not a decline, and is treated like an absent flag: trust registers and the import dialog still renders.
Expand Down
4 changes: 3 additions & 1 deletion bin/fm-agy-trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
# dialog if one renders anyway and never counts a busy turn as ready on a path
# that was neither pre-registered here nor answered there.
#
# THE SCOPE TEST IS THE SAFETY PROPERTY and mirrors bin/fm-claude-trust.sh:
# THE SCOPE TEST IS THE SAFETY PROPERTY and mirrors bin/fm-claude-trust.sh,
# minus that script's sibling-clone acceptance (a shared-pool slot cut from
# another home's clone is refused here, and the post-launch gate covers it):
# <worktree> must be a LINKED git worktree - its own git dir, sharing
# <project>'s common dir - whose top level is exactly the resolved argument. A
# primary checkout, a worktree of an unrelated repo, a subdirectory of a
Expand Down
83 changes: 77 additions & 6 deletions bin/fm-claude-trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
#
# Usage: fm-claude-trust.sh <worktree> <project>
# fm-claude-trust.sh --secondmate-home <home> <id>
# <worktree> the isolated task worktree this spawn launches into
# <project> the primary checkout that worktree belongs to
# <worktree> the isolated task worktree this spawn launches into, which
# may be a worktree of <project> or a worktree of a sibling
# clone of the same repository (same origin URL)
# <project> the spawning home's checkout of the project: the checkout that
# worktree belongs to, or a sibling clone of the one it belongs
# to when the worktree came from a shared pool
# <home> the seeded secondmate home this spawn launches into
# <id> the secondmate id that home must already be marked for
# Prints one line naming what it registered; refuses loudly on anything else.
Expand All @@ -37,6 +41,11 @@
# second dialog's flags: a secondmate home has no separate "project" entry to
# carry consent forward from, so its registration stays trust-only.
#
# A SHARED TREEHOUSE POOL across multiple firstmate homes adds an extra case.
# A task worktree may belong to a sibling clone of the project rather than the
# spawning home's own clone, and the scope test accepts it when both clones
# carry the same origin URL, registering trust against the sibling checkout.
#
# TWO PROJECT-CONFIG ENTRIES IN WORKTREE MODE, NOT ONE. Registering both flags
# on the worktree entry alone (the original trust-only design) leaves the
# external-imports dialog showing. Verified 2026-09-06 by disassembling the
Expand All @@ -50,7 +59,8 @@
# canonicalization (`Fr`/`Se`) walks a linked worktree's `.git` file through
# its `commondir` pointer back to the PRIMARY CHECKOUT, exactly the <project>
# argument this script already receives for the worktree-mode scope test
# below. So the trust flag is registered on BOTH the worktree entry (for
# below (or, for a shared-pool slot, the sibling clone that test resolves in
# its place). So the trust flag is registered on BOTH the worktree entry (for
# trust's ancestor-walk fallback and defense in depth) and the project entry
# (the trust check's first, canonical-shaped, look); the two external-imports
# flags land on those same two entries only when the project entry already
Expand Down Expand Up @@ -82,7 +92,8 @@
# different shapes on disk.
#
# WORKTREE MODE. <worktree> must be a LINKED git worktree - its own git dir,
# sharing <project>'s common dir - whose top level is exactly the resolved
# sharing <project>'s common dir, or that of a sibling clone of the same
# repository (see below) - whose top level is exactly the resolved
# argument. Git is the ground truth, so the argument is never trusted on its
# own word: a primary checkout (git dir == common dir), a worktree of an
# unrelated repo, a subdirectory of a worktree, a plain directory, and a home
Expand All @@ -100,6 +111,26 @@
# consent-gated external-imports flags land on that resolved canonical
# checkout, never on the linked-worktree argument itself.
#
# When the worktree's common dir differs from <project>'s common dir, the
# script checks whether they are SIBLING CLONES of the same repository -
# which happens when multiple firstmate homes clone the same repo and share
# one treehouse worktree pool. A pool slot first created from home A's clone
# has its git common dir in A's clone, but the spawning firstmate at home B
# passes its own clone as <project>. One check decides "same repository":
# the two repos carry the exact same origin remote URL, compared verbatim.
# That holds for a network origin because fm-home-seed.sh clones every home's
# project from the source's own origin URL; it canonicalizes a local-path
# origin first (absolute, symlinks resolved), so a local-path origin not
# already spelled that way differs and is refused (fail closed). When it
# matches, the canonical project path for trust registration
# becomes the primary checkout that owns the worktree's common dir (the
# sibling clone), because that is exactly where Claude Code's own git-root
# canonicalization collapses every linked worktree to. An "info:" line on
# stderr names the sibling clone path. The consent-gated external-imports
# flags land on that sibling clone's project entry; if the sibling clone
# already declined external imports, the whole registration refuses with a
# message naming the sibling path, the same refusal <project> itself gets.
#
# The test is deliberately NOT a treehouse or orca path prefix. Treehouse's
# root is configurable (--root, TREEHOUSE_ROOT, config, and a relative
# in-project pool), so a prefix check would refuse legitimate roots, accept
Expand Down Expand Up @@ -273,7 +304,37 @@ if [ "$MODE" = worktree ]; then

PROJ_COMMON=$(common_dir_of "$PROJ_REAL") || true
[ -n "$PROJ_COMMON" ] || refuse "project '$PROJ_REAL' is not inside a git repository"
[ "$WT_COMMON" = "$PROJ_COMMON" ] || refuse "'$TARGET_REAL' is not a worktree of project '$PROJ_REAL'"
if [ "$WT_COMMON" = "$PROJ_COMMON" ]; then
# Same project: the worktree belongs directly to the project argument.
# This is the common case - a task worktree cut from the same checkout
# firstmate spawned from.
:
else
# The worktree's common dir is not the project's common dir, but it may
# belong to a SIBLING CLONE of the same repository. This happens when
# multiple firstmate homes clone the same repo into separate
# projects/ directories and share one treehouse pool: a pool slot was
# first created from home A's clone, so its git common dir is A's,
# but the spawning firstmate at home B passes its own clone as
# <project>. The dialog must be accepted rather than refusing this
# perfectly valid worktree.
#
# One check decides "same repository": the worktree's repo and
# <project> carry the exact same origin remote URL.
SIBLING_CANON=$(real_dir "$(dirname -- "$WT_COMMON")") || true
[ -n "$SIBLING_CANON" ] || refuse "'$TARGET_REAL' is not a worktree of project '$PROJ_REAL'"

# Verify SIBLING_CANON is actually the primary checkout that owns WT_COMMON.
SIBLING_GIT_DIR=$(git -C "$SIBLING_CANON" rev-parse --absolute-git-dir 2>/dev/null) || true
SIBLING_GIT_DIR=$(real_dir "${SIBLING_GIT_DIR:-}") || true
[ "$SIBLING_GIT_DIR" = "$WT_COMMON" ] || refuse "'$TARGET_REAL' is not a worktree of project '$PROJ_REAL'"

WT_ORIGIN=$(git -C "$TARGET_REAL" remote get-url origin 2>/dev/null) || true
PROJ_ORIGIN=$(git -C "$PROJ_REAL" remote get-url origin 2>/dev/null) || true
[ -n "$WT_ORIGIN" ] && [ "$WT_ORIGIN" = "$PROJ_ORIGIN" ] \
|| refuse "'$TARGET_REAL' is not a worktree of project '$PROJ_REAL'"
echo "info: worktree '$TARGET_REAL' belongs to sibling clone '$SIBLING_CANON' (origin match)" >&2
fi

# The external-imports flags must land on the primary checkout - its own git
# dir equals the common dir - because that is exactly the path Claude Code's
Expand All @@ -292,7 +353,17 @@ if [ "$MODE" = worktree ]; then
[ -n "$PROJ_GIT_DIR" ] || refuse "project '$PROJ_REAL' has no resolvable git directory"
PROJ_GIT_DIR=$(real_dir "$PROJ_GIT_DIR") || true
[ -n "$PROJ_GIT_DIR" ] || refuse "project '$PROJ_REAL' has an unresolvable git directory"
if [ "$PROJ_GIT_DIR" = "$PROJ_COMMON" ]; then
if [ -n "${SIBLING_CANON:-}" ]; then
# Sibling clone of the same repository: use the sibling checkout as the
# canonical project path for trust registration, because that is exactly
# the path Claude Code's own git-root canonicalization collapses every
# linked worktree to. The existing PROJ_GIT_DIR and PROJ_COMMON values
# from <project> are irrelevant here because the trust entry and the
# external-imports consent check must both look at the sibling clone -
# that is the checkout Claude Code resolves to from the worktree, and
# the only place it ever reads the external-imports flags.
PROJ_CANON=$SIBLING_CANON
elif [ "$PROJ_GIT_DIR" = "$PROJ_COMMON" ]; then
PROJ_CANON=$PROJ_REAL
else
PROJ_CANON=$(real_dir "$(dirname -- "$PROJ_COMMON")") || true
Expand Down
7 changes: 4 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4410,9 +4410,10 @@ spawn_assert_agent_worktree
# skipping the step for that kind left a standalone-clone secondmate home with
# nothing registered and a pane wedged on a dialog firstmate cannot answer.
# bin/fm-claude-trust.sh owns the structural scope test for both shapes and
# refuses anything that is neither this project's own isolated worktree nor a
# seeded secondmate home marked for this id; a refusal blocks the spawn rather
# than launching a worker that would wedge. Refusing here rather than beside the
# refuses anything that is neither an isolated worktree of this project (or of
# a sibling clone sharing its origin URL) nor a seeded secondmate home marked
# for this id; a refusal blocks the spawn rather than launching a worker that
# would wedge. Refusing here rather than beside the
# arm keeps this in the same class as the two worktree refusals just above: no
# temp root, no retired relaunch wiring and no busy record exists yet to strand,
# so the refusal names the endpoint the same way they do and leaves nothing else
Expand Down
2 changes: 1 addition & 1 deletion docs/orca-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Reinstall the CLI and rerun; [`verification/runtime-backends.md`](verification/r
- Escape is unsupported.
- Orca exposes no stable CLI version or protocol marker, so readiness is the compatibility gate rather than a version floor.
- Only the verified terminal-handle and worktree result fields are accepted; speculative response shapes are rejected.
- Orca's worktree shape is unverified against the spawn-time Claude workspace-trust check in `bin/fm-claude-trust.sh`, which refuses any path that is not a linked git worktree sharing the project's git common dir, so a claude spawn on Orca fails loudly at that check rather than launching if Orca clones instead of linking.
- Orca's worktree shape is unverified against the spawn-time Claude workspace-trust check in `bin/fm-claude-trust.sh`, which accepts only a linked git worktree, either of the project checkout or of a sibling clone of the same repository with the same origin URL; a claude spawn on Orca fails loudly at that check rather than launching if Orca clones instead of linking.

## Regression entry points

Expand Down
2 changes: 1 addition & 1 deletion docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ trusted: /private/tmp/fm-sm-trust-live-69759/fm-homes/livemate-n1
No dialog appeared, the composer was reached, and neither did the machine-scoped bypass warning, because this ran against the real config.
The lab home was deleted and the test entry was removed from the store and verified absent, with the same point-in-time caveat as the worktree arms above.

`bin/fm-spawn.sh` therefore pre-registers the directory every claude launch starts in through `bin/fm-claude-trust.sh` before launch, and `tests/fm-claude-trust.test.sh` pins both halves of the scope contract for both shapes: a fresh worktree and a seeded secondmate home are trusted, and an out-of-scope path is refused.
`bin/fm-spawn.sh` therefore pre-registers the directory every claude launch starts in through `bin/fm-claude-trust.sh` before launch, and `tests/fm-claude-trust.test.sh` pins both halves of the scope contract for all three shapes: a fresh worktree of the project, a worktree of a sibling clone, and a seeded secondmate home are trusted, and an out-of-scope path is refused.
That automated spawn case runs against a fake claude, so it asserts the store entry and the launch command and nothing more; the live arms above are what establish that the entry actually suppresses the dialog.
The composer-classification record below observes the same gate from the other side, where an untrusted worktree left Claude, Grok, and Muse unverified because the guard reads a first-launch trust dialog as an unreadable composer.

Expand Down
77 changes: 77 additions & 0 deletions tests/fm-claude-trust.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,80 @@ test_worktree_subdirectory_is_refused() {
pass "fm-claude-trust.sh: refuses a subdirectory of the worktree"
}

# A treehouse pool slot created from one home's clone is a linked worktree
# whose git common dir belongs to that clone, not the spawning home's own
# clone. When they share the same origin URL, the worktree is accepted and
# trust is registered against the sibling checkout that owns the common dir.
test_sibling_clone_same_origin_accepted() {
local rec out sibling sibling_wt
rec=$(make_case sibling-origin)
read_case "$rec"
sibling="$CASE_DIR/sibling"
git clone --quiet -- "$(git -C "$PROJ" remote get-url origin)" "$sibling"
sibling_wt="$CASE_DIR/sibling-wt"
git -C "$sibling" worktree add --quiet -b wt-sibling "$sibling_wt"
out=$(run_trust "$CONFIG" "$sibling_wt" "$PROJ")
expect_code 0 $? "a sibling clone's worktree with matching origin must be trusted: $out"
assert_contains "$out" "trusted:" "registration did not report what it trusted"
assert_contains "$out" "info:" "registration did not report the sibling match"
assert_trusted "$CONFIG/.claude.json" "$sibling_wt" "the worktree was not recorded as trusted"
assert_trusted "$CONFIG/.claude.json" "$sibling" "the sibling clone was not recorded as project root"
pass "fm-claude-trust.sh: sibling clone's worktree with same origin is accepted and trusts sibling checkout"
}

# When the sibling clone's project entry already carries an explicit decline
# of external CLAUDE.md imports, the whole registration must refuse and name
# the sibling clone path, just as it would for <project>.
test_sibling_clone_declined_external_imports_refused() {
local rec out sibling sibling_wt store
rec=$(make_case sibling-declined)
read_case "$rec"
sibling="$CASE_DIR/sibling"
git clone --quiet -- "$(git -C "$PROJ" remote get-url origin)" "$sibling"
sibling_wt="$CASE_DIR/sibling-wt"
git -C "$sibling" worktree add --quiet -b wt-sibling "$sibling_wt"
store="$CONFIG/.claude.json"
node -e '
const store = process.argv[1];
const sibling = process.argv[2];
const fs = require("node:fs");
const root = {projects: {}};
root.projects[sibling] = {
hasTrustDialogAccepted: false,
hasClaudeMdExternalIncludesApproved: false,
hasClaudeMdExternalIncludesWarningShown: true
};
fs.writeFileSync(store, JSON.stringify(root, null, 2) + "\n", {mode: 0o600});
' "$store" "$sibling"
out=$(run_trust "$CONFIG" "$sibling_wt" "$PROJ")
expect_code 1 $? "a sibling clone with declined imports must be refused: $out"
assert_contains "$out" "$sibling" "the refusal did not name the sibling clone path"
assert_contains "$out" "already declined" "the refusal did not name the declined imports"
assert_not_trusted "$CONFIG/.claude.json" "$sibling_wt" "the worktree was trusted despite the refusal"
pass "fm-claude-trust.sh: refuses a sibling clone whose project root already declined external imports"
}

# A different repository at a firstmate-home/projects path with the same
# basename as <project> has no matching origin, so the registration must
# refuse and leave no trust recorded.
test_different_repo_same_basename_refused() {
local rec out other_root other_repo other_wt
rec=$(make_case diff-basename)
read_case "$rec"
other_root="$CASE_DIR/other-home"
other_repo="$other_root/projects/$(basename "$PROJ")"
mkdir -p "$(dirname "$other_repo")"
fm_git_init_commit "$other_repo"
fm_git_add_origin "$other_repo" "$CASE_DIR/different.origin.git"
other_wt="$CASE_DIR/other-wt"
git -C "$other_repo" worktree add --quiet -b wt-other "$other_wt"
out=$(run_trust "$CONFIG" "$other_wt" "$PROJ")
expect_code 1 $? "a different repo at same basename must be refused: $out"
assert_contains "$out" "is not a worktree of project" "the refusal did not name the project mismatch"
assert_not_trusted "$CONFIG/.claude.json" "$other_wt" "a different repo's worktree was trusted"
pass "fm-claude-trust.sh: refuses a different repository at the same basename path"
}

# The write target the external-imports flags depend on is only correct when
# it names the primary checkout. When <project> is itself a linked worktree
# (a secondmate home spawned from, rather than as, the primary checkout),
Expand Down Expand Up @@ -854,6 +928,9 @@ test_non_git_directory_is_refused
test_missing_directory_is_refused
test_foreign_project_worktree_is_refused
test_worktree_subdirectory_is_refused
test_sibling_clone_same_origin_accepted
test_sibling_clone_declined_external_imports_refused
test_different_repo_same_basename_refused
test_project_argument_that_is_itself_a_worktree_resolves_to_the_primary_checkout
test_unrelated_store_content_is_preserved
test_symlinked_store_to_a_foreign_owned_target_is_refused
Expand Down
Loading