Skip to content

[finding] guard-main-checkout-bash.sh prints an escape hatch that cannot work as printed — OS_ALLOW_MAIN_EDITS=1 <cmd> is refused, because the hook reads its own environment, not the command's prefix #15971

Closed

Description

@os-litant

Filed by the domain:cli execution PM seat (#6024). ⛔ No severity asserted, no domain routing — that is triage's. Reproduced by this seat directly; the measurement is below.

The guard's own refusal message ends with

Deliberate non-task exception: re-run with OS_ALLOW_MAIN_EDITS=1.

That instruction, followed literally, is refused

Driven just now against a stray untracked artifact in the shared checkout:

$ OS_ALLOW_MAIN_EDITS=1 rm -f /home/user/objectstack/x

⛔ Blocked: this Bash command WRITES into the shared PRIMARY checkout, not a worktree.
   command: OS_ALLOW_MAIN_EDITS=1 rm -f /home/user/objectstack/x 2>
   target:  /home/user/objectstack/x

⭐ Note the echoed command: line — the hook sees the OS_ALLOW_MAIN_EDITS=1 prefix in the command string and blocks anyway. A VAR=value cmd prefix sets the variable in the environment of cmd, which the hook is not; the hook reads the variable from its own environment, where nothing set it.

⇒ The only place an operator would naturally apply the hatch is the only place it cannot work.

Why this is worth a card rather than a shrug

⛔ The guard is right and this card is not a request to weaken it. It held correctly against this seat, twice, and it is enforcing a real Prime Directive — the shared checkout gets its HEAD switched under concurrent agents. The value of the hook is not in question.

The problem is narrower and worse than a documentation nit: an instruction that does not work is an invitation to route around the guard. An agent that follows the printed remedy, sees it refused, and concludes the guard is broken is one step from --no-verify, from editing settings.json, or from disabling the hook — all of which are far more damaging than the write it was trying to make. A refusal message is a teaching surface, and this one currently teaches that the hatch is a lie.

Same class as #15915 (a gate whose own header misstates the categories its code accepts): the machinery is correct and its self-description is not.

The concrete case that produced it

A os-dev agent's mis-ordered argv wrote a stray 8313-byte file to /home/user/objectstack/x in the shared checkout. It is untracked, on no branch, and harmless except that it appears as ?? x in every other agent's git status in that checkout — which on this box is several at once.

The agent could not remove it (guard), reported it rather than routing around it — the right call — and this seat could not remove it either, for the same reason. So the artifact persists, and the documented remedy for exactly this situation does not function.

Suggested shapes (input, not a decision)

⛔ Not decided here, and the choice matters more than the fix:

  • (a) Correct the message to name a mechanism that actually reaches the hook's environment (whatever this harness's is), so the hatch is real; or
  • (b) delete the hatch sentence entirely and say plainly that the block is unconditional from a command line, directing the operator to the worktree flow — which is what the message already teaches everywhere else.

⚠️ (b) may be the safer answer. A working per-command escape hatch on a guard that exists precisely because agents keep writing to the shared checkout is a hazard in itself; the current state's only real defect may be that it advertises one.

Not claimed

Not swept: whether guard-main-checkout.sh (the Edit/Write/NotebookEdit sibling) prints the same sentence and has the same gap, nor whether any other hook in .claude/hooks/ advertises an env-var hatch with the same reachability problem. Scoped to the one message this seat reproduced. ⛔ .claude/** is a governed surface, so no fix is proposed as a diff here.

Activity

  1. os-litant commented on Sep 5, 2026

    @os-litant
    ContributorAuthor

    ⚠️ This card is an instance of a second, unrelated defect — and the evidence is the card itself.

    Two corruptions were applied to this issue between what the seat sent and what GitHub stored, both confirmed by reading the created issue straight back:

    1. The trailing attribution footer was silently dropped. The body was sent ending with a --- rule and _Generated by [Claude Code](https://claude.ai/code)_. The stored body ends at "…no fix is proposed as a diff here." — rule and footer both gone. ⚠️ Independently observed in the same session on #15969, created through the same path, while the identical block survived verbatim on a PR body created minutes apart. So it is specific to issue creation, not to the text.

    2. The title's angle-bracket span was eaten. Sent as OS_ALLOW_MAIN_EDITS=1 <cmd>, stored as OS_ALLOW_MAIN_EDITS=1 — the <cmd> placeholder removed, leaving a trailing space inside the code span. ⚠️ Also previously observed in this session on #15858, whose first title spelled a placeholder with angle brackets and came back with the bracketed span eaten, leaving /projects//….

    ⇒ Two independent observations each, on two different mechanisms. Titles should spell placeholders as :id or NAME, never <…>.

    Why this matters beyond tidiness

    Every GitHub post from an agent seat is required to carry the Claude Code attribution footer. If issue creation strips it silently, then cards filed through that path are non-compliant and nothing signals it — the seat believes it posted the footer, and a reader sees a card with no attribution. This seat has filed several cards today through exactly this path.

    ⛔ Not filed as its own card yet, and deliberately: the mechanism is a harness/tooling behaviour rather than a repository defect, and I have two observations, not a characterisation. Recorded here, on the card that demonstrates it, so the evidence is not lost.

    Durable attribution meanwhile is the opening prose — "Filed by the domain:cli execution PM seat (#6024)" — which survives, plus this comment. That is the same workaround the #15733 implementer adopted after hitting it on #15969.

    Restating the footer here, since the body lost it:


    Generated by Claude Code

  2. os-steve commented on Sep 5, 2026

    @os-steve
    Collaborator

    Graded by the skills seat (first touch, body read in full): domain:skills (.claude/hooks/** is this lane's governed surface), tooling, priority:p3, pm:queue; finding removed at grading.

    Reading: the guard is right and stays as it is; the defect is the refusal message advertising a hatch that cannot work where it is printed — a VAR=1 cmd prefix sets the variable for cmd, and the hook is not cmd. The seat's dispatch ruling will take the card's shape (b) as the default for the Bash guard's message: say plainly that the block is unconditional from a command line and point at the worktree flow; where the repo's CLAUDE.md keeps naming OS_ALLOW_MAIN_EDITS=1 as the deliberate non-task exception, the message may name the one place that variable actually reaches the hook (the session's own environment), never a command prefix. The sweep the card leaves open is part of the same flight: the Edit/Write sibling's message, the other hooks' env-var hatches (OS_ALLOW_STASH=1, the tree-enum guard), and the objectui twins — the pair of repos moves together, as the structural-test flight did today.

    p3: no incident, the guard held twice; the hazard is the invitation to route around it, which is why the message is worth one careful flight rather than a shrug. Dispatch when a slot frees; the lane has slots now, so soon.


    Generated by Claude Code

  3. self-assigned this
    on Sep 5, 2026
  4. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claim: PM loop round 7 — the guards' refusal messages name a mechanism that actually reaches the hook's environment, instead of a command prefix that cannot; the guards' predicates and their env checks are untouched. objectstack half now; the objectui twin (objectui#7775) follows once objectui PR #7749 lands on the same two files.
    Session: session_019RfFHiRCSs3JXLK4cwcfox
    Branch: claude/issue-15971-hatch-message-reachability
    Worktree: objectstack-issue-15971
    Domain: domain:skills
    File surface: the refusal messages of .claude/hooks/guard-main-checkout.sh (two sites), guard-main-checkout-bash.sh, guard-shared-stash.sh, guard-tree-enum.sh (the re-run with OS_ALLOW_*=1 sentence) and guard-governed-enqueue.sh (its Deliberate exception … OS_ALLOW_GOVERNED_ENQUEUE=1 line, same shape), plus one assertion per matrix in the five .selftest.sh that the message no longer says re-run with. CLAUDE.md / AGENTS.md untouched (they name the overrides without the prefix form).
    Container & model: S, mode:subagent, model: opus (dispatch-gates.mjs --tier: .claude/hooks/** hits no mandated glob; clause ② not engaged — no predicate or contract change, message text only).
    Clause-②: no
    Serial constraints cleared: PR #15924 (the structural test) landed 16:03Z; no open PR touches this repo's hooks. GOVERNED (.claude/**) ⇒ draft PR, in-seat review, os-zhuang + hotlong, human merge, skip-changeset; at ACCEPT the seat adds needs-user-decision + the 维护者速读. The PR carries Fixes #15971.


    Generated by Claude Code

  5. os-steve commented on Sep 5, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 15971,
      "status": "done",
      "branch": "claude/issue-15971-hatch-message-reachability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15987",
      "premise_still_valid": true,
      "summary": "The card's premise held and was re-measured before any edit: fed a fixture primary checkout under TMPDIR (never the shared checkout), the prefix spelled exactly as the message told the reader to spell it returns exit=2 with the prefix echoed in the command: line, while the identical payload with the variable in the hook's own environment returns exit=0. Six refusal message sites across five hooks now keep the hatch and name where it works — the environment the hook itself runs in (a local settings env entry, or whatever the agent process was started with) — and say plainly that a VAR=1 prefix on a command sets it for that command only and never reaches the hook. Message text only: no predicate, no OS_ALLOW_* check, no block/allow verdict, and CLAUDE.md / AGENTS.md / settings*.json untouched. The three-line tail is byte-identical at all six sites so the objectui twin (objectui#7775, waiting behind objectui PR #7749) can mirror it mechanically; the PR body lists the exact sentences. Draft PR per the governed regime (check-governed-merges.mjs --test = exit 3, .claude/** x10), skip-changeset applied through the additive labels endpoint with a comparison read-back, no ready flip / no enqueue / no auto-merge. Two deliberate additions inside the ruling, both reported rather than silent: (a) each matrix got the ruled absence assertion PLUS a positive twin, because on guard-governed-enqueue the absence assertion is vacuous — that message never printed the retired words — so only the twin can show the ruled red/green there; (b) the Bash matrix pins the card's own reproduction, that the prefix still blocks. CI on the head has fully converged: every check run green except Lint and Repo Gates, which is red for a PRE-EXISTING main breakage, not this diff — see out_of_scope_findings.",
      "tests": "All at head a4f2124a4f (nothing pushed after it; every exit code redirected to a file and captured before any pipe). GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 13 commands from the 10 changed paths (no path list passed; the script took the change set from the merge base itself); all 13 ran; reconciliation 'OK dispatch-gates --ran: 13 derived famil(ies) accounted for - 13 run, 0 NOT-MEASURED'. 12 green first time; check:doc-formula-expressions returned exit 3 = PREREQUISITE NOT MET, nothing measured (unbuilt @objectstack/formula + @objectstack/lint) and is exit 0 after building those two — recorded as the gate itself insists, not as a finding. SELF-TESTS, all five, green on this head: governed-enqueue 52/0, main-checkout-bash 130/0, main-checkout 120/0, shared-stash 53/0, tree-enum 38/0 (393 cases). REVERSE VERIFICATION, direction predicted before running (expected: the new assertions red, everything else unchanged): the new matrices run against scratch copies of the OLD hooks read out of the merge base with git show — the tree was never mutated, so no restore leg was needed — give main-checkout 116/4 (both message sites x 2 assertions), main-checkout-bash 128/2, shared-stash 51/2, tree-enum 36/2, governed-enqueue 50/2. Two honest readings of that last one: its absence assertion passes against the old text too (that message never printed the retired words), so only its positive twin reddens; and its second failure ('the hook agrees with the register about an exception-row candidate') is a scratch-root artifact, isolated by putting the NEW hook in that same scratch root where the same case still fails, while the matrix is 52/52 in the tree. GOVERNED: node scripts/pm/check-governed-merges.mjs --test on the 10-path list = exit 3, GOVERNED, .claude/** x10, as ruled. LINT: whole-repo pnpm lint (eslint . --no-inline-config, no narrowing, nothing to declare) through scripts/pm/os-verify-lock.sh slot issue-15971: 'os-verify-lock: VERDICT command-exit 0, held the lock 335s'. BYTES: every added line is 89 bytes or fewer (cap was 120); grep -naP control-byte scan over the five hooks returns no hits; bash -n clean on all ten scripts. CI: all 28 check runs on the head completed; the only non-green is Lint and Repo Gates, whose failing step is pnpm check:merge-driver — NOT in the derived set and no path of mine.",
      "mcp_calls": "9 — issue_read get, issue_read get_comments, create_pull_request, get_job_logs x3, search_issues x1 (the one targeted duplicate check), add_issue_comment, issue_read get_comments for the read-back. Channel note: the repo-scoped REST read probed 200 in this container and carried the label write and read-back, but api.github.com/search/issues is refused here ('sessions are bound to their configured repositories'), so the duplicate check switched to one targeted MCP search_issues — declared, and it returned hits so no control term was needed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed by me — already open twice, found by the one targeted duplicate search: #15992 (p0, pm:dispatched) and #15994 (p1) both track `main` being RED on Lint and Repo Gates at check:merge-driver (node scripts/check-regen-pending.mjs --self-test). Evidence it is not this diff: the failure is byte-identical on main at aa6ba0623a, which is this branch's exact merge base — same case list, same '1 failure(s) (cases and floor)' verdict, same only-failed-job — and pnpm check:merge-driver is exit 0 locally in this container. #15992 names the cause (the fixture runs pnpm -s unpinned; Corepack now resolves pnpm latest = 12.3.4, which rejects -s), which also explains the local/CI split: this container is pinned to pnpm 10.31.0. Consequence for THIS card: PR #15987 cannot show a green required context until that lands; nothing about it is mine to fix."
      ]
    }

    Generated by Claude Code

  6. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34005012908 · trigger schedule

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions