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
3 changes: 2 additions & 1 deletion .agents/skills/harness-adapters/references/harness/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ A second, separate dialog - "Allow external CLAUDE.md file imports?" - renders w

`../../../bin/fm-claude-trust.sh` records `hasTrustDialogAccepted` for both the worktree and its primary checkout in `${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json` 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.
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 (`===false`), 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.
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.
The why-two-entries mechanism and the consent-gating logic live in the script's own header comment, which is the one owner for that contract; the fact worth repeating here is that `../../../bin/fm-spawn.sh` refuses the spawn when the trust flag fails to land, rather than launching a worker that would wedge on that dialog.

Never try to answer either dialog with a key.
Expand Down
21 changes: 14 additions & 7 deletions bin/fm-claude-trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,18 @@
# THAT SAME PROJECT ENTRY IS ALSO THE LAUNCHING HUMAN'S OWN INTERACTIVE
# CONFIG, though, so this registration must never overwrite a decision the
# human already made there. If the project entry already carries
# hasClaudeMdExternalIncludesApproved===false - Claude Code only ever writes
# that on an explicit "No, disable" answer - the whole registration refuses
# hasClaudeMdExternalIncludesApproved===false WITH
# hasClaudeMdExternalIncludesWarningShown===true - the pair Claude Code writes
# on an explicit "No, disable" answer - the whole registration refuses
# rather than flipping it, because doing so would grant every future
# interactive session in that checkout silent external-file inclusion the
# human declined, permanently and without being asked. The worktree entry is
# left unwritten too: the spawn wedges on the dialog, which is the honest
# outcome given a standing decline, not registered trust with a stripped
# consent record.
# consent record. Approved===false with WarningShown false or absent is NOT
# that decision: Claude Code's default project entry carries both flags as
# false before the dialog was ever shown, so that pair means "never asked" and
# is treated like an absent flag - trust registered, no import consent.
#
# THE SCOPE TEST IS THE SAFETY PROPERTY, and it is STRUCTURAL rather than a
# path policy. Each mode has its own, because the two directories have entirely
Expand Down Expand Up @@ -392,7 +396,7 @@ fi
# The two external-imports flags (worktree mode only) are gated separately
# from the trust flag, because they are a CONSENT grant, not a pre-approval
# this script is allowed to manufacture. Claude Code only ever writes
# hasClaudeMdExternalIncludesApproved itself, on an explicit interactive
# hasClaudeMdExternalIncludesApproved===true itself, on an explicit interactive
# answer; this script's own job is to keep a worker from wedging on a dialog,
# never to answer that dialog on the human's behalf. So the import flags land
# on the project entry - the only place the imports check ever reads (see the
Expand Down Expand Up @@ -443,14 +447,17 @@ const flagsLanded = (projects, key, flags) =>
// The project entry is the launching user's OWN interactive config, not a
// throwaway worktree, so a spawn must never silently reverse a decision the
// human already recorded there. hasClaudeMdExternalIncludesApproved===false
// is exactly that decision (Claude Code only ever writes it on an explicit
// "No, disable" answer); flipping it to true would grant every future
// together with hasClaudeMdExternalIncludesWarningShown===true is exactly that
// decision (the dialog's "No, disable" answer writes that pair; Claude Code's
// default project entry carries Approved===false with WarningShown===false,
// which means never asked, not declined); flipping it to true would grant every future
// interactive session in that checkout silent external-file inclusion the
// human declined. Refuse the whole registration instead of overriding it -
// the worktree entry is not written either, so the spawn wedges on the
// dialog rather than the human's consent being spent without being asked.
const declinedExternalImports = (projects, key) =>
projects?.[key]?.hasClaudeMdExternalIncludesApproved === false;
projects?.[key]?.hasClaudeMdExternalIncludesApproved === false &&
projects?.[key]?.hasClaudeMdExternalIncludesWarningShown === true;
// True only on an explicit prior "Yes, allow" answer - the sole state this
// script may treat as standing consent to refresh. Absent, or any other
// value, is NOT consent (see the block comment above this script's node call).
Expand Down
27 changes: 25 additions & 2 deletions tests/fm-claude-trust.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ JSON
pass "fm-claude-trust.sh: preserves unrelated keys on the project-root entry"
}

# hasClaudeMdExternalIncludesApproved===false on the project-root entry is a
# human's explicit "No, disable" answer, recorded in the SAME store their own
# hasClaudeMdExternalIncludesApproved===false with WarningShown===true on the
# project-root entry is a human's explicit "No, disable" answer, recorded in the SAME store their own
# interactive sessions read. A spawn must never flip that to true on their
# behalf: doing so would grant every later interactive session in that
# checkout silent external-file inclusion the human declined. The whole
Expand All @@ -264,6 +264,28 @@ JSON
pass "fm-claude-trust.sh: refuses to override a project's declined external-imports consent"
}

# Claude Code's own default project entry carries BOTH external-imports flags as
# false before the dialog was ever shown; answering the dialog either way sets
# hasClaudeMdExternalIncludesWarningShown to true. So false/false is "never
# asked", not "No, disable": it must be treated like an absent flag - trust
# registered, no import consent manufactured - rather than refused.
test_project_root_entry_default_import_flags_are_not_a_decline() {
local rec store out
rec=$(make_case project-default-flags)
read_case "$rec"
store="$CONFIG/.claude.json"
cat > "$store" <<JSON
{"hasCompletedOnboarding":true,"projects":{"$PROJ":{"allowedTools":[],"mcpContextUris":[],"mcpServers":{},"enabledMcpjsonServers":[],"disabledMcpjsonServers":[],"hasTrustDialogAccepted":false,"hasClaudeMdExternalIncludesApproved":false,"hasClaudeMdExternalIncludesWarningShown":false}}}
JSON
out=$(run_trust "$CONFIG" "$WT" "$PROJ")
expect_code 0 $? "a never-asked default entry must not be refused as a decline: $out"
assert_trust_only_no_import_consent "$store" "$WT" \
"the worktree entry either lost trust or gained unearned import consent"
assert_trust_only_no_import_consent "$store" "$PROJ" \
"the project-root entry either lost trust or gained import consent it was never asked for"
pass "fm-claude-trust.sh: a never-asked default external-imports pair is not treated as a decline"
}

test_registration_is_idempotent() {
local rec out count
rec=$(make_case idempotent)
Expand Down Expand Up @@ -796,6 +818,7 @@ test_fresh_worktree_also_trusts_the_project_root_without_import_consent
test_registration_carries_forward_existing_import_consent
test_project_root_entry_preserves_other_keys
test_project_root_entry_declined_external_imports_is_not_overridden
test_project_root_entry_default_import_flags_are_not_a_decline
test_registration_is_idempotent
test_primary_checkout_is_refused
test_cdpath_cannot_defeat_the_primary_checkout_refusal
Expand Down
Loading