You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Gate the repository a gh pr create is actually for - #49
pre-pr-gate.sh blocks gh pr create unless /pre-pr wrote a pre-pr.ok
sentinel for the current HEAD. It resolved which repository to gate from the
session's work tree only — the hook payload's cwd, mapped through session-work-tree.sh — and never inspected the command for a -R/--repo
argument. So a session working in repository A that ran gh pr create -R owner/B was asked for A's sentinel: an attestation of
checks that were never run against B, and that could not meaningfully be run
against B from A at all.
Observed
On 2026-10-16, a session whose work tree was a compiler worktree opened a config pull request. The gate demanded
Producing that file would have attested compiler for a config pull request.
The session instead created its pull requests with gh api -X POST repos/<slug>/pulls, a workaround that bypasses this gate for every repository, the session's own included — the failure mode this change
is really about.
This is not a repeat of the pre-d0abf64 worktree bug: the cwd → work tree
mapping behaved exactly as intended and correctly named the session's own
worktree git dir.
The design chosen, and why
Two designs were on the table. (a) resolve the target slug to a local clone
and check its sentinel; (b) treat the session's sentinel as inapplicable
and let the command through with an unmistakable note. This PR implements (b),
justified in the script's header comment.
(a) looks stricter but is unsatisfiable in practice: /pre-pr only ever writes $(git rev-parse --absolute-git-dir)/pre-pr.ok for the work tree it ran in,
so while the session is in A there is no way to produce B's sentinel. The gate
would block every cross-repo pull request, and the escape hatch agents already
know is gh api — which removes the gate everywhere, not just for B. Failing
open with a loud note keeps the gate's real job (the session's own repository)
intact and keeps agents on gh pr create.
The note is a non-blocking error (exit 1), the convention publish-version-gate.sh already uses where it cannot gate: stderr from a hook
that exits 0 is never shown at all, while a non-zero, non-2 exit puts the
first stderr line in the transcript and still runs the command.
pre-PR checks NOT verified for SpineEventEngine/config: 'gh pr create' names a repository other than this session's (…/worktrees/loving-gauss-55c0c5), whose pre-PR sentinel attests nothing about it.
Nothing in this session built, checked or reviewed SpineEventEngine/config, and the pre-PR
checklist cannot be run for it from here. Run /pre-pr in a session working in
that repository, or state in the pull request that the checks were not run.
What changed
Eight review rounds went into this, almost all of them into the one path that
does not gate. Two rules came out of it, and they divide the work:
Detection is as wide as possible. The exemption is as narrow as possible.
Detection is one rule: a create is detected when it starts a segment of
the command as received, or of the text from any one line onward with shell
line continuations removed. What defeats detection is always what precedes the
create, because a blanket join of continuations glues that text onto it:
# note \ : \ printf 'x\\'
gh pr \ gh pr \ gh pr create --title x
create --title x create --title x
The shell runs a create in each: a \ ending a comment does not continue the
line, nor does one escaping another \. Yet read as received no line starts
with gh pr create, and read with every continuation removed the create is
swallowed by the comment, by :, or by the printf. Starting the join at each
line in turn needs no judgement about which line-endings continue. Before this,
each such composition made the hook exit 0, gating nothing at all.
Detection may overshoot — it only gates — but never undershoot. The narrow
reading belongs to the exemption, below.
The exemption requires the request to be, as received, one line that is one
plain gh pr create for a repository of another name:
One line, one command. Anything more is gated — a second command, a subshell && (gh pr create …) the start-anchored match skips, a &> "$(gh pr create …)" whose substitution tr moves to another line. Text
the splitting discards is text this hook never examined, and each kind of it
proved able to hide a create that runs here. This also retires every
continuation subtlety at once: a multi-line create is gated, not read across
lines.
That line must carry nothing the shell would still change: $, a backtick, {, }, *, ?, [, ], ~, <, >, #, %, or a \. Each of
those was a live finding — $EXTRA holding a second -R, -R{a,b} expanding
to two options with the local one last, > -Rlog whose filename read as a
target, --title a\|b leaving a fragment that parsed as a whole command.
Its words are read as gh pr create's grammar, not scanned for anything -R-shaped: each known flag's value is consumed, so --title '--repo=other/repo' is a title. A flag in neither the value-taking
nor the boolean list is ambiguous and gates — a gh flag newer than this hook
makes the gate stricter, never looser — as does a flag left without its value
(--repo ""). The whole line is read before answering, so a repeated -R
means the last one, as gh resolves it.
The target is compared by owner/repo alone, against every URL of
every remote, fetch and push (gh falls back to the push URL, and identifies
the repository by the last of several, so a remote fetching from a local
mirror and pushing first to a backup and then to GitHub is this repository).
A URL's query and fragment are dropped, since gh reads the path alone. Hosts are left
out deliberately: gh canonicalizes them in ways a hook cannot replicate — ssh.github.com:443 → github.com, HostName aliases from the user's SSH
config, www. — and every divergence would make this repository look
foreign and buy an exemption. The cost, stated in the script: a same-named
repository on another host is gated here rather than exempted.
Percent-encoding on either side withholds the exemption, since gh decodes
URL paths and this hook does not.
No explicit target ⇒ no behaviour change, --head owner:branch included:
the session's repository is gated exactly as today, same block messages, jq-absence block and cwd-resolution rules.
Where the hook cannot be certain, it gates. The practical cost is that a
cross-repository create which is multi-line, or carries expansion or encoding,
needs this repository's sentinel — the behaviour that predates this change.
Verification
scripts/tests/test_hooks.py grows twenty-two cases following the existing PrePrGateTest conventions: every -R/--repo spelling plus a quoted value
(asserted to exit 1 both with and without a session sentinel, since it is
inapplicable either way); this repository named explicitly in four spellings
including HOST/OWNER/REPO; a fork head; echo "gh pr create -R other/repo"
still ignored; and one per review finding — a --repo inside a quoted title, a
flag-shaped option value, a commented-out target, an unreadable segment, a
local create beside a cross-repo one, a repeated -R, an unknown flag, a
continuation line (including one mid-word), a target on another host, and one
case per syntax the shell would still change — command and process
substitution, scalar and array expansion, brace expansion, an escaped
separator, redirection, and a flag left without its value, and a request
that is more than one create (second command, subshell, substitution moved by
the split, comment-swallowed create).
$ python3 -m unittest discover -s scripts/tests
...............................................................
----------------------------------------------------------------------
Ran 65 tests in 62.267s
OK
Also exercised live against the observed scenario from this worktree: a -R SpineEventEngine/config create steps aside with the note above (exit 1),
while -R SpineEventEngine/agents blocks for the missing sentinel (exit 2).
publish-version-gate.sh is untouched — #48 is open against it — and .agents/tasks/pre-pr-gate-cross-repo.md records the task per the repo's
convention.
`pre-pr-gate.sh` resolved the repository to gate from the session's work tree
alone and never looked at the command's target. A session working in repository
A that ran `gh pr create -R owner/B` was therefore asked for A's `pre-pr.ok` —
an attestation of checks that were never run against B, and that could not
meaningfully be run against B from A.
Observed on 2026-10-16: a session in a `compiler` worktree opening a `config`
pull request was told to produce
`compiler/.git/worktrees/recursing-wescoff-fcb09b/pre-pr.ok`, and worked around
the gate with `gh api -X POST repos/<slug>/pulls` instead — which bypasses the
gate for every repository, the session's own included.
The gate now reads `-R`/`--repo` out of the matched command segment, in all
four spellings, by word-splitting it with globbing off (never `eval`). A target
that matches any remote of the session's work tree — so `-R upstream/repo` from
a fork too — gates exactly as before. Any other target is outside what this
sentinel can attest, so the gate names it on the first stderr line and exits 1,
a non-blocking error, as `publish-version-gate.sh` already does where it cannot
gate. Demanding B's sentinel instead would be unsatisfiable from this session —
`/pre-pr` only ever writes the sentinel of the work tree it ran in — and would
simply send agents back to `gh api`.
Without an explicit target, including `--head owner:branch`, nothing changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review on #49 found three ways the first cut of the parser could be wrong, two
of them new bypasses. Stepping aside is the only path here that does not gate,
so everything it rests on is now conservative.
Words, not raw text. `set -- $1` re-split the segment's source, so
`gh pr create --title 'Document --repo behavior'` surfaced `--repo` as a flag
and a pull request for *this* repository skipped its sentinel. `tokenize()`
splits character by character, honouring single quotes, double quotes and
backslashes, and still evaluates nothing. A segment it cannot read — an
unterminated quote, which the split on `;&|` can itself produce out of
`--title "a|b"` — is gated rather than read as naming another repository.
Every segment, not the first. `gh pr create -R other/repo && gh pr create`
classified only the first segment and let the second, ungated create run. All
creating segments are classified now: no explicit target, this repository, or an
unreadable segment means the gate applies as usual, and it steps aside only when
no segment creates a pull request here. When it gates and another target is also
named, the note is printed after the sentinel passes — the only remaining exit
that can carry it.
Every spelling. `gh pr` accepts the attached `-Rowner/repo`, so that form is
read too; it previously fell back to gating this repository, leaving the original
defect in place for that spelling.
The host is part of the identity. `gh` takes `[HOST/]OWNER/REPO`, and dropping
the host made `enterprise.example/acme/app` compare equal to the `acme/app` of a
github.com remote. `identity_of` keeps `host/owner/repo`; a target that carries
no host is compared bare, as `gh` resolves it against this repository's host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`-R "$REPO"` reaches the hook as the literal `$REPO` — nothing here expands
command text, by design. Compared literally it matched no remote, so it took
the non-blocking exit and skipped the sentinel, even when `$REPO` named this
very repository. An unresolved target is unknown, not different: `$`, a
backtick, a glob character or a leading `~` now sends the segment down the
gating path.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A token scan cannot be safe: any option value can be made to look like `-R`.
`--title '--repo=other/repo'` was read as a target, so a pull request for THIS
repository skipped its sentinel. The scanner now consumes each known flag's
value instead of examining it, from a value-taking and a boolean list. A flag in
neither is ambiguous — the next word may be its value or a flag of its own — and
gates the segment, so a `gh` flag newer than this hook makes the gate stricter
rather than looser.
Shell comments were read as arguments: `… --body y # --repo other/repo` took the
commented-out target for a real one. `tokenize()` ends the segment at an
unquoted `#` that starts a word, as the shell does, while `fixes#49` stays a
word.
Line continuations were dropped: a `\` at end of line left `-R` on a line the
line-wise matching never saw, and the gate demanded this repository's sentinel
for someone else's pull request. They are joined before the split; a bare
newline remains a separator.
Found while fixing those: the scanner answered at the first `-R`, so it saw
neither a flag it does not know after the target nor a repeated `-R` — and `gh`
uses the LAST `-R` (`gh pr list -R SpineEventEngine/agents -R cli/cli` lists
cli/cli). `-R other/repo -R <ours>` would have been exempted while `gh` opened
the pull request here. The whole segment is read now, last `-R` winning.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported on #49: `gh pr create -R other/repo --title "$(gh pr create --title
local --body local)"` opens a pull request in THIS repository — the inner
create runs first — and then one elsewhere, while only the outer command is on
the line to classify. The cross-repository exemption therefore covered a local
create with no sentinel.
A segment carrying command or process substitution — `$(`, a backtick, `<(`,
`>(` — is now gated. Nothing here evaluates command text to find out what it
would do, so an exemption cannot rest on it.
The repeated-`-R` finding from the same review round was already fixed in
be05215, found while reading the whole segment.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reject unquoted redirections during argument parsing
scripts/pre-pr-gate.sh:141
[P2] Unquoted redirection operators are treated as ordinary words, allowing their filenames to become repository flags. If directory --repo=other exists, gh pr create --title x --body y > --repo=other/repo creates a local PR; the apparent flag is only an output filename. The parser nevertheless extracts other/repo and skips the sentinel. Reject unsupported redirections during tokenization, while preserving quoted or escaped < and > as ordinary text, and add a missing-sentinel regression.
Four more ways the parsing differed from the shell, each able to exempt a pull
request meant for this repository: `-R Spine/ma\` + `in`, an in-word
continuation the shell joins while joining with a space read `Spine/ma`;
`--title a\|b`, split at the escaped pipe into `… --title a\`, which read as a
whole command though the real one ended with a local `-R`; `$EXTRA` or
`"${extra[@]}"` carrying a second `-R`; and `-R{other/repo,ours}`, two options
with ours last.
Each is a fix in itself, but five rounds of the same shape settle the approach.
Enumerating dangerous syntax is only ever as good as one's knowledge of the
shell, and this is the one path in the hook that fails open. So the burden is
inverted: a segment is exempted only when its text cannot change under anything
the shell does after the hook sees it.
Continuations are removed as the shell removes them — the `\` and the newline,
nothing in their place — which is also what makes an in-word continuation read
as the single word the shell builds. Anything else that could change the text
gates the segment before it is parsed at all: `$`, a backtick, `{`, `}`, `*`,
`?`, `[`, `]`, `~`, `<(`, `>(`, and any `\` left once continuations are gone.
The two narrower guards this subsumes are gone with it.
The cost, stated in the header: a cross-repository create carrying any of that
syntax needs this repository's sentinel — the behaviour that predates this
change — and no exemption rests on text the hook had to guess at.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more segments that read as cross-repository creates while `gh` would open
the pull request here.
`gh pr create --title x --body y > -Rlog` had the redirection's *filename* read
as the target, and `<<< '--repo=other/repo'` its stdin. `<` and `>` join the
gating set, which makes the earlier `<(` and `>(` entries redundant.
`gh pr create -R other/repo --title x --repo ""` ends the segment with an empty
word, and capturing the tokenizer's output drops trailing newlines, so the
scanner stopped waiting for the final value and answered with the earlier `-R`
— while an empty override makes `gh` fall back to this repository. A
value-taking flag still waiting when the words run out now means the segment
was not understood, and an unexplained segment earns no exemption.
The brace-expansion finding from the same review round was already closed by
4f73d5c, which put `{` and `}` in the gating set; verified, not assumed.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two continuation cases compose into one neither reading can see:
# note \
gh pr \
create --title local --body local
The shell ends the comment at the newline and continues line two into line
three, so the create runs. Read as received, no line starts with
`gh pr create`; read with every continuation removed, all three fold into the
comment. The hook returned 0 — gating nothing — and returns 2 now.
Detection gets a third reading: each line's `#` to its end is dropped before
continuations are removed, which leaves the create in view. The `#` goes
whether or not it is quoted, because over-dropping can only add a match, and a
detection that matches too much merely gates while one that matches too little
gates nothing at all.
A variant not in the report is covered too, found while testing: a
single-quoted newline, `: '<newline>' ; gh pr \` + `create …`.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 10's composition again, with an escaped backslash ending the first
command instead of a comment:
: \\
gh pr \
create --title local --body local
`\\` is a literal backslash, so that line does not continue and the shell runs
the create. The hook returned 0, gating nothing: all three readings glued the
create onto `:`.
What defeats detection is always what PRECEDES the create — a blanket join of
continuations glues it on. So the readings are replaced by one general rule: a
create is detected when it starts a segment of the command as received, or of
the text from any one line onward with continuations removed. Starting the join
at each line in turn needs no judgement about which line-endings continue, and
finds the create in every composition found so far.
This subsumes the joined reading and the comment-stripping pass added one
commit ago, both now gone: strictly stronger, and less code. The per-line loop
runs only for a multi-line command in which the plain reading found nothing, so
the common path is untouched.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-line scan added one commit ago ran for every multi-line request in
which the plain reading found no create: an 800-line heredoc measured 4.5s
here, paid by an agent on an unrelated tool call.
Only a `\` standing immediately before a newline can make a joined reading
differ from the plain one, and only from the line that carries it. With no such
pair every suffix reads exactly as the plain reading already read it, and a
line not ending in `\` glues nothing onto itself. So the scan runs for those
lines alone: 4.5s becomes 0.035s, while a create whose very first token is
split across lines (`g\` + `h pr create`) is still detected, so the limit costs
no detection. A test holds the 800-line request under two seconds.
Caught while verifying this, before it was committed: writing the pair as
`${suffix//$continuation/}` broke detection outright. Unquoted, the pattern's
`\` escapes the newline, so only the newline was removed and the text read
`gh pr \create`. The pattern is quoted now. `bash -x` on the failing input
found it; the suite did not, having no case for that spelling, so one covers it
now.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The word splitter was quadratic: every character copied the rest of the segment
through `${rest#?}`. Measured here, a 4 KiB pull request body took 4s and a
16 KiB one about a minute — and every create reaches the splitter, so a create
for THIS repository paid that before being gated anyway. Bodies of a few
kilobytes are ordinary, so this was a real cost on ordinary work.
It is one `awk` pass now, walking the text by index: 4 KiB takes 0.141s and
16 KiB 0.165s. `awk` is already required further down to read the sentinel, so
nothing new is needed, and the pass still evaluates nothing — no expansion
runs and no command substitution in the text can execute. Behaviour is
unchanged: the suite's quoting, escape, comment, flag-shaped-value and
unterminated-quote cases all pass.
A timing test covers 4 KiB and 16 KiB bodies for a local and a cross-repository
create, since round 12's heredoc test never reaches the splitter.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gh repo set-default` records the repository `gh` works against in
`remote.<name>.gh-resolved`, and it need not be any remote's own. Verified in a
throwaway repository: with `origin` at `contributor/fork` and that key set to
`upstream/project`, no upstream remote present, `gh repo set-default --view`
reports `upstream/project`.
A `-R upstream/project` in such a checkout therefore names the checkout's own
default pull-request repository, while the identity list — built from remote
URLs alone — read it as another repository and exempted the create from its
sentinel. Non-`base` `gh-resolved` values are part of the identities now, under
the same percent-encoding guard; `base` means the remote itself, which its URLs
already cover.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With `extglob` enabled and a matching path, `@(OWNER)/repo` is a glob that
expands to `OWNER/repo` — confirmed with `bash -O extglob -c 'echo
@(Spine)/agents'`, which printed `Spine/agents`. The hook compared the literal
`@(…)` text, so such a target read as another repository and was exempted even
where it expands to this one.
Parentheses join the characters that withhold the exemption. That is the rule
from the one-plain-line commit — the line must carry nothing the shell would
still change — applied to a form it had missed; `!(…)` and `+(…)` fall under the
same guard. Exploitability is low, since `extglob` is off by default in
non-interactive shells and the expansion needs a matching path, and the cost is
only that a cross-repository create whose text contains parentheses needs this
repository's sentinel.
Reported by Copilot on #49.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The blind spot
pre-pr-gate.shblocksgh pr createunless/pre-prwrote apre-pr.oksentinel for the current HEAD. It resolved which repository to gate from the
session's work tree only — the hook payload's
cwd, mapped throughsession-work-tree.sh— and never inspected the command for a-R/--repoargument. So a session working in repository A that ran
gh pr create -R owner/Bwas asked for A's sentinel: an attestation ofchecks that were never run against B, and that could not meaningfully be run
against B from A at all.
Observed
On 2026-10-16, a session whose work tree was a
compilerworktree opened aconfigpull request. The gate demandedProducing that file would have attested
compilerfor aconfigpull request.The session instead created its pull requests with
gh api -X POST repos/<slug>/pulls, a workaround that bypasses this gate forevery repository, the session's own included — the failure mode this change
is really about.
This is not a repeat of the pre-
d0abf64worktree bug: thecwd→ work treemapping behaved exactly as intended and correctly named the session's own
worktree git dir.
The design chosen, and why
Two designs were on the table. (a) resolve the target slug to a local clone
and check its sentinel; (b) treat the session's sentinel as inapplicable
and let the command through with an unmistakable note. This PR implements (b),
justified in the script's header comment.
(a) looks stricter but is unsatisfiable in practice:
/pre-pronly ever writes$(git rev-parse --absolute-git-dir)/pre-pr.okfor the work tree it ran in,so while the session is in A there is no way to produce B's sentinel. The gate
would block every cross-repo pull request, and the escape hatch agents already
know is
gh api— which removes the gate everywhere, not just for B. Failingopen with a loud note keeps the gate's real job (the session's own repository)
intact and keeps agents on
gh pr create.The note is a non-blocking error (
exit 1), the conventionpublish-version-gate.shalready uses where it cannot gate: stderr from a hookthat exits
0is never shown at all, while a non-zero, non-2exit puts thefirst stderr line in the transcript and still runs the command.
What changed
Eight review rounds went into this, almost all of them into the one path that
does not gate. Two rules came out of it, and they divide the work:
Detection is one rule: a create is detected when it starts a segment of
the command as received, or of the text from any one line onward with shell
line continuations removed. What defeats detection is always what precedes the
create, because a blanket join of continuations glues that text onto it:
The shell runs a create in each: a
\ending a comment does not continue theline, nor does one escaping another
\. Yet read as received no line startswith
gh pr create, and read with every continuation removed the create isswallowed by the comment, by
:, or by theprintf. Starting the join at eachline in turn needs no judgement about which line-endings continue. Before this,
each such composition made the hook exit 0, gating nothing at all.
Detection may overshoot — it only gates — but never undershoot. The narrow
reading belongs to the exemption, below.
The exemption requires the request to be, as received, one line that is one
plain
gh pr createfor a repository of another name:&& (gh pr create …)the start-anchored match skips, a&> "$(gh pr create …)"whose substitutiontrmoves to another line. Textthe splitting discards is text this hook never examined, and each kind of it
proved able to hide a create that runs here. This also retires every
continuation subtlety at once: a multi-line create is gated, not read across
lines.
$, a backtick,{,},*,?,[,],~,<,>,#,%, or a\. Each ofthose was a live finding —
$EXTRAholding a second-R,-R{a,b}expandingto two options with the local one last,
> -Rlogwhose filename read as atarget,
--title a\|bleaving a fragment that parsed as a whole command.gh pr create's grammar, not scanned for anything-R-shaped: each known flag's value is consumed, so--title '--repo=other/repo'is a title. A flag in neither the value-takingnor the boolean list is ambiguous and gates — a
ghflag newer than this hookmakes the gate stricter, never looser — as does a flag left without its value
(
--repo ""). The whole line is read before answering, so a repeated-Rmeans the last one, as
ghresolves it.owner/repoalone, against every URL ofevery remote, fetch and push (
ghfalls back to the push URL, and identifiesthe repository by the last of several, so a remote fetching from a local
mirror and pushing first to a backup and then to GitHub is this repository).
A URL's query and fragment are dropped, since
ghreads the path alone. Hosts are leftout deliberately:
ghcanonicalizes them in ways a hook cannot replicate —ssh.github.com:443→github.com,HostNamealiases from the user's SSHconfig,
www.— and every divergence would make this repository lookforeign and buy an exemption. The cost, stated in the script: a same-named
repository on another host is gated here rather than exempted.
ghdecodesURL paths and this hook does not.
--head owner:branchincluded:the session's repository is gated exactly as today, same block messages,
jq-absence block andcwd-resolution rules.Where the hook cannot be certain, it gates. The practical cost is that a
cross-repository create which is multi-line, or carries expansion or encoding,
needs this repository's sentinel — the behaviour that predates this change.
Verification
scripts/tests/test_hooks.pygrows twenty-two cases following the existingPrePrGateTestconventions: every-R/--repospelling plus a quoted value(asserted to exit 1 both with and without a session sentinel, since it is
inapplicable either way); this repository named explicitly in four spellings
including
HOST/OWNER/REPO; a fork head;echo "gh pr create -R other/repo"still ignored; and one per review finding — a
--repoinside a quoted title, aflag-shaped option value, a commented-out target, an unreadable segment, a
local create beside a cross-repo one, a repeated
-R, an unknown flag, acontinuation line (including one mid-word), a target on another host, and one
case per syntax the shell would still change — command and process
substitution, scalar and array expansion, brace expansion, an escaped
separator, redirection, and a flag left without its value, and a request
that is more than one create (second command, subshell, substitution moved by
the split, comment-swallowed create).
Also exercised live against the observed scenario from this worktree: a
-R SpineEventEngine/configcreate steps aside with the note above (exit 1),while
-R SpineEventEngine/agentsblocks for the missing sentinel (exit 2).publish-version-gate.shis untouched — #48 is open against it — and.agents/tasks/pre-pr-gate-cross-repo.mdrecords the task per the repo'sconvention.
🤖 Generated with Claude Code