From 94d90a390a951baed0f5cdde98002947fd5b9612 Mon Sep 17 00:00:00 2001 From: Rangezi <46404232+Rangezi@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:57:01 +0000 Subject: [PATCH 1/2] fix(bin): read Claude Code's default external-imports flags as never asked, not declined (#4378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fm-claude-trust.sh refused the whole trust registration whenever the project-root entry carried hasClaudeMdExternalIncludesApproved === false, on the premise that Claude Code writes that value only on an explicit "No, disable". Claude Code's default project entry carries Approved and WarningShown both false before the dialog is ever shown, so every such project refused every spawn. Only Approved === false with WarningShown === true — the pair the dialog writes on a decline — now counts as a decline. false/false behaves like an absent flag: trust is registered and no import consent is manufactured. New case test_project_root_entry_default_import_flags_are_not_a_decline fails on b182d0f with the refusal and passes with the fix; tests/fm-claude-trust.test.sh 31/31, bin/fm-lint.sh clean with pinned ShellCheck 0.11.0 and actionlint 1.7.12. Co-Authored-By: Claude Opus 5 (1M context) --- bin/fm-claude-trust.sh | 21 ++++++++++++++------- tests/fm-claude-trust.test.sh | 27 +++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/bin/fm-claude-trust.sh b/bin/fm-claude-trust.sh index 07762cf9a0a..14a1afda55d 100755 --- a/bin/fm-claude-trust.sh +++ b/bin/fm-claude-trust.sh @@ -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 @@ -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 @@ -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). diff --git a/tests/fm-claude-trust.test.sh b/tests/fm-claude-trust.test.sh index c040682516d..3bf6edbcbee 100755 --- a/tests/fm-claude-trust.test.sh +++ b/tests/fm-claude-trust.test.sh @@ -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 @@ -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" < Date: Sun, 13 Sep 2026 19:04:12 +0000 Subject: [PATCH 2/2] no-mistakes(review): Correct harness doc's external-imports decline predicate --- .agents/skills/harness-adapters/references/harness/claude.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/harness-adapters/references/harness/claude.md b/.agents/skills/harness-adapters/references/harness/claude.md index f29720ca411..d2929c8edf6 100644 --- a/.agents/skills/harness-adapters/references/harness/claude.md +++ b/.agents/skills/harness-adapters/references/harness/claude.md @@ -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.