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. 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" <