@dev-loops/core 1.0.2-slim.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ // Canonical closing-reference primitives shared by the create-pr / edit-pr
2
+ // wrappers so both guard a body's closing reference against the branch's own
3
+ // resolved issue with one implementation. A body swap that re-points the
4
+ // reference at a different issue would otherwise pass silently, and a merge
5
+ // would then close the wrong issue — this is the fail-closed backstop against
6
+ // that data-integrity hole.
7
+
8
+ import { extractClosingIssueNumbers as extractCanonicalClosingRefs } from "../loop/issue-refinement-artifact.mjs";
9
+
10
+ // Every issue number the body's closing references name. Delegates to the ONE
11
+ // canonical body-spec parser so the closing-keyword vocabulary (close/closes/
12
+ // closed, fix/fixes/fixed, resolve/resolves/resolved, any case), the cross-repo
13
+ // `owner/repo#N` form, fenced/inline-code stripping (a `Closes #N` inside a
14
+ // ```fenced``` example or `inline code` span does not auto-close on GitHub and
15
+ // must not spoof the guard), and de-duplication stay owned in ONE place — the
16
+ // guard never re-implements them.
17
+ export function extractClosingIssueNumbers(body) {
18
+ if (!body || typeof body !== "string") return [];
19
+ return extractCanonicalClosingRefs(body);
20
+ }
21
+
22
+ // True when the body carries any closing keyword.
23
+ export function detectClosingKeyword(body) {
24
+ return extractClosingIssueNumbers(body).length > 0;
25
+ }
26
+
27
+ // The issue number from the body's first closing reference, or null when the
28
+ // body carries none. Back-compat surface (create-pr's `--issue` missing-reference
29
+ // check); the mismatch guard uses extractClosingIssueNumbers to see every one.
30
+ export function extractClosingIssueNumber(body) {
31
+ const all = extractClosingIssueNumbers(body);
32
+ return all.length > 0 ? all[0] : null;
33
+ }
34
+
35
+ // Branch slug `[<prefix>/]issue-<N>[-<slug>]` -> N. Matches the dev-loop
36
+ // worktree default branch name and the prefixed form (e.g. a `dl/`-prefixed
37
+ // slug). Returns null when the branch encodes no issue number.
38
+ const BRANCH_ISSUE_PATTERN = /(?:^|\/)issue-(\d+)(?:-|$)/u;
39
+ export function extractIssueFromBranchSlug(branch) {
40
+ if (!branch || typeof branch !== "string") return null;
41
+ const match = BRANCH_ISSUE_PATTERN.exec(branch.trim());
42
+ return match ? Number(match[1]) : null;
43
+ }
44
+
45
+ // Resolve the issue a PR is expected to close from its own facts. The branch
46
+ // slug is authoritative (it encodes the issue the loop cut the branch for);
47
+ // the PR's GitHub-derived closingIssuesReferences is the fallback. Returns null
48
+ // when neither yields an issue — a genuinely issue-less PR, which is exempt.
49
+ export function resolveExpectedIssueFromPrContext(ctx) {
50
+ if (!ctx || typeof ctx !== "object") return null;
51
+ const fromBranch = extractIssueFromBranchSlug(ctx.headRefName);
52
+ if (fromBranch !== null) return fromBranch;
53
+ const refs = Array.isArray(ctx.closingIssuesReferences) ? ctx.closingIssuesReferences : [];
54
+ for (const ref of refs) {
55
+ const n = typeof ref === "number" ? ref : Number(ref?.number);
56
+ if (Number.isInteger(n) && n > 0) return n;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Compare the body's closing reference against the branch's resolved issue.
62
+ // Returns a named refusal string when they disagree, else null. A waiver
63
+ // bypasses; an unresolved expected issue (issue-less) is exempt; a body with no
64
+ // closing reference has nothing to mislink and is exempt (only a present-and-
65
+ // disagreeing reference is refused, never a missing one).
66
+ export function resolveClosingRefMismatch({ body, expectedIssue, allowCrossIssue = false }) {
67
+ if (allowCrossIssue) return null;
68
+ if (!Number.isInteger(expectedIssue)) return null;
69
+ const closing = extractClosingIssueNumbers(body);
70
+ if (closing.length === 0) return null;
71
+ // Refuse when ANY closing reference disagrees — GitHub closes every one, so a
72
+ // correct first reference does not excuse a wrong second (a single-issue
73
+ // dev-loop PR closes only its branch's issue; a deliberate multi/cross-issue
74
+ // reference uses the waiver).
75
+ const disagreeing = closing.find((n) => n !== expectedIssue);
76
+ if (disagreeing !== undefined) {
77
+ return `CLOSING-REF-BRANCH-MISMATCH: the body closes #${disagreeing} but the branch resolves to issue #${expectedIssue} — refusing a mismatched closing reference (pass --allow-cross-issue to record a deliberate cross-issue reference)`;
78
+ }
79
+ return null;
80
+ }
@@ -10,6 +10,22 @@ import { trimmedOrNull } from "../loop/normalize.mjs";
10
10
  // acting on the gate's behalf must agree with the gate about what a submitted
11
11
  // review is.
12
12
  export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
13
+
14
+ // Copilot's COMMENTED review summary opens with a disposition header whose
15
+ // emoji is the authoritative signal: "### 🟡 Changes recommended" (findings)
16
+ // vs "### 🟢 Approval recommended" (clean). Keying on the 🟡 marker means a
17
+ // clean body that merely quotes the phrase "changes recommended" — with or
18
+ // without markdown emphasis ("No **changes recommended**", "No _changes
19
+ // recommended_") — is never a false finding. The strong no-emoji signal is
20
+ // already covered by the CHANGES_REQUESTED state.
21
+ const COPILOT_CHANGES_RECOMMENDED_MARKER = "🟡";
22
+
23
+ export function copilotReviewBodySignalsChanges(state, body) {
24
+ const normalizedState = typeof state === "string" ? state.toUpperCase() : "";
25
+ if (normalizedState === "CHANGES_REQUESTED") return true;
26
+ if (normalizedState !== "COMMENTED") return false;
27
+ return typeof body === "string" && body.includes(COPILOT_CHANGES_RECOMMENDED_MARKER);
28
+ }
13
29
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
14
30
  // `review` is a RECOGNIZED gate header that carries no draft/pre-approval
15
31
  // evidence by design. Recognizing it lets
@@ -700,6 +716,7 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
700
716
  let hasPendingReviewOnCurrentHead = false;
701
717
  let hasSubmittedReviewOnCurrentHead = false;
702
718
  let latestSubmittedReviewOnCurrentHeadAt = null;
719
+ let hasBodyFindingOnCurrentHead = false;
703
720
  let completedCopilotReviewRounds = 0;
704
721
 
705
722
  for (const review of effectiveReviews) {
@@ -722,9 +739,18 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
722
739
 
723
740
  if (SUBMITTED_REVIEW_STATES.has(state)) {
724
741
  hasSubmittedReviewOnCurrentHead = true;
725
- const submittedAt = typeof review?.submittedAt === "string" ? review.submittedAt : null;
742
+ const submittedAt = typeof review?.submittedAt === "string"
743
+ ? review.submittedAt
744
+ : (typeof review?.submitted_at === "string" ? review.submitted_at : null);
726
745
  if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
727
746
  latestSubmittedReviewOnCurrentHeadAt = submittedAt;
747
+ hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
748
+ } else if (submittedAt !== null && submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
749
+ // Equal-timestamp tie on the same head: fail toward surfacing so array
750
+ // order never silently drops a finding when two reviews share a timestamp.
751
+ hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
752
+ } else if (submittedAt === null && latestSubmittedReviewOnCurrentHeadAt === null) {
753
+ hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
728
754
  }
729
755
  }
730
756
  }
@@ -740,5 +766,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
740
766
  hasPendingReviewOnCurrentHead,
741
767
  hasSubmittedReviewOnCurrentHead,
742
768
  latestSubmittedReviewOnCurrentHeadAt,
769
+ hasBodyFindingOnCurrentHead,
743
770
  };
744
771
  }
@@ -4,6 +4,7 @@ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
4
4
  import { parseJsonText } from "./review-threads.mjs";
5
5
  import { parseRepoSlug } from "./repo-slug.mjs";
6
6
  import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
7
+ import { assertGithubWriteStubbedInTestMode } from "./test-mode-write-guard.mjs";
7
8
 
8
9
  /**
9
10
  * Core `gh issue` operations, extracted from the thin CLI wrappers under
@@ -88,6 +89,7 @@ export async function resolveCreateBody(options) {
88
89
  }
89
90
 
90
91
  export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
92
+ assertGithubWriteStubbedInTestMode(run, "issue create", { env });
91
93
  const body = await resolveCreateBody(options);
92
94
  if (typeof body !== "string" || body.trim().length === 0) {
93
95
  const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
@@ -182,6 +184,7 @@ export function buildStateChangeArgs(options) {
182
184
  }
183
185
 
184
186
  export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
187
+ assertGithubWriteStubbedInTestMode(run, "issue edit/close", { env });
185
188
  const { args, edited } = await buildEditArgs(options);
186
189
  // Skip the edit call entirely when --state is the only change requested —
187
190
  // `gh issue edit` with no field flags errors ("no changed fields").
@@ -227,6 +230,7 @@ export async function resolveCommentBody(options) {
227
230
  }
228
231
 
229
232
  export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
233
+ assertGithubWriteStubbedInTestMode(run, "issue comment", { env });
230
234
  const body = await resolveCommentBody(options);
231
235
  // ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
232
236
  // issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
@@ -83,10 +83,33 @@ export function dedupeRepoSlugOptions(options) {
83
83
  }
84
84
  return uniqueOptions;
85
85
  }
86
+ // Parses a git remote URL into an <owner/name> slug, but only for github.com
87
+ // remotes. The URI form (scheme://[user@]host[:port]/path) is checked before
88
+ // the scp form (host:path), because the scp regex also matches URIs like
89
+ // https://... — checking scp first would misparse the host out of the scheme.
90
+ function parseGitHubRemoteSlug(url) {
91
+ let host, path;
92
+ const uri = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/);
93
+ const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
94
+ if (uri) {
95
+ host = uri[1];
96
+ path = uri[2];
97
+ } else if (scp) {
98
+ host = scp[1];
99
+ path = scp[2];
100
+ } else {
101
+ return null;
102
+ }
103
+ if (host.toLowerCase() !== "github.com") return null;
104
+ const seg = path.match(/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
105
+ if (!seg) return null;
106
+ return `${seg[1]}/${seg[2]}`;
107
+ }
108
+
86
109
  /**
87
110
  * Auto-detect <owner/name> from `git remote get-url origin`.
88
111
  * Returns the slug string on success, or null when detection fails
89
- * (no origin remote, not a git repo, or unparseable URL).
112
+ * (no origin remote, not a git repo, non-github.com host, or unparseable URL).
90
113
  * Does NOT throw — callers should add their own context-specific error messages.
91
114
  */
92
115
  export function detectRepoSlug(cwd) {
@@ -96,9 +119,7 @@ export function detectRepoSlug(cwd) {
96
119
  encoding: "utf8",
97
120
  stdio: ["ignore", "pipe", "pipe"],
98
121
  }).trim();
99
- const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
100
- if (!match) return null;
101
- return `${match[1]}/${match[2]}`;
122
+ return parseGitHubRemoteSlug(url);
102
123
  } catch {
103
124
  return null;
104
125
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Fail-closed test-mode guard for GitHub WRITE helpers.
3
+ *
4
+ * A GitHub write helper (issue/PR create/edit/close/comment/merge) must never
5
+ * reach a live GitHub write path from a test. The real incident: an unstubbed
6
+ * `bun run verify` drove a dedup test's followUpDraft through
7
+ * applyFollowUpIssues -> ensureFollowUpIssue -> createIssue and FILED A REAL
8
+ * ISSUE against the repo. The DI stub seam existed; the test simply did not
9
+ * thread it, and nothing failed closed to stop the live write.
10
+ *
11
+ * This guard is TEST-MODE ONLY. In production (`NODE_ENV !== "test"`) it is a
12
+ * no-op — it never changes production write behavior (non-goal: no production
13
+ * behavior change; no general network sandbox). It only asserts, when a test is
14
+ * running, that the write is stubbed by one of the two sanctioned seams:
15
+ *
16
+ * Test mode is derived from the EXECUTING PROCESS's own env (`process.env`),
17
+ * never from the write-target `env` a caller passes in. A caller can build a
18
+ * sparse `env` (e.g. `{ GH_TOKEN }` with no `NODE_ENV`) to shape the `gh`
19
+ * child's environment; if that sparse env decided test mode, it could turn
20
+ * the guard off from under a test simply by omitting `NODE_ENV`. Reading the
21
+ * real process env instead makes that impossible: a caller-supplied env can
22
+ * never disable the guard.
23
+ *
24
+ * (a) In-process DI seam: the helper's `run`/`runChild` was replaced with a
25
+ * stub (e.g. `makeGhMock`), so it is no longer the live child-exec seam.
26
+ * (b) Process-boundary seam: a subprocess test installed a fake `gh` on PATH
27
+ * (`writeGhStub`) and attests it via `DEV_LOOPS_GH_STUB` in the child env.
28
+ *
29
+ * Anything else in test mode fails closed at the call site, before any network
30
+ * call, with an actionable error naming both seams.
31
+ */
32
+
33
+ import { runChild as liveRunChild } from "../cli/primitives.mjs";
34
+
35
+ /**
36
+ * Env var a process-boundary gh stub sets to attest that `gh` itself is a stub
37
+ * (set automatically by the `writeGhStub` test helper). Only consulted in test
38
+ * mode, so it is not a production escape hatch.
39
+ */
40
+ export const GH_STUB_ATTESTATION_ENV = "DEV_LOOPS_GH_STUB";
41
+
42
+ /**
43
+ * True when `run` is the live child-exec seam — i.e. NO in-process DI stub was
44
+ * injected. `null`/`undefined` (a helper with no run seam, e.g. the spawn-based
45
+ * create-pr path) is treated as live too, so it fails closed rather than open.
46
+ * @param {unknown} run
47
+ */
48
+ export function isLiveExecutor(run) {
49
+ return run == null || run === liveRunChild;
50
+ }
51
+
52
+ /**
53
+ * Assert a GitHub write is stubbed when running under a test. No-op unless the
54
+ * EXECUTING PROCESS is in test mode (`processEnv.NODE_ENV === "test"`) — never
55
+ * decided by the caller-supplied write-target `env`, so a sparse `env` (e.g.
56
+ * missing `NODE_ENV`) cannot bypass the guard. Throws (code
57
+ * `GH_WRITE_UNSTUBBED_IN_TEST`) when the write would reach the live path with
58
+ * neither sanctioned stub seam present.
59
+ *
60
+ * @param {unknown} run - the helper's `run`/`runChild` seam (omit for spawn-only
61
+ * helpers with no in-process seam).
62
+ * @param {string} op - human-readable op name, e.g. `"issue create"`.
63
+ * @param {{ env?: NodeJS.ProcessEnv, processEnv?: NodeJS.ProcessEnv }} [opts]
64
+ * `env` is the write-target env (consulted only for the stub attestation);
65
+ * `processEnv` (defaults to `process.env`, injectable for tests) is the
66
+ * real executing process's env and is the sole source of test-mode.
67
+ */
68
+ export function assertGithubWriteStubbedInTestMode(run, op, { env = process.env, processEnv = process.env } = {}) {
69
+ if (processEnv?.NODE_ENV !== "test") return; // production / non-test: never guards
70
+ if (!isLiveExecutor(run)) return; // (a) in-process DI stub injected
71
+ if (processEnv?.[GH_STUB_ATTESTATION_ENV] || env?.[GH_STUB_ATTESTATION_ENV]) return; // (b) process-boundary gh stub
72
+ throw Object.assign(
73
+ new Error(
74
+ `GitHub write helper "${op}" was called in test mode without an injected stub — ` +
75
+ `refusing to reach a live GitHub write path (a test must never mutate the real repo). ` +
76
+ `Inject the in-process DI seam (pass { run } / { runChild }, e.g. makeGhMock) or stub gh ` +
77
+ `at the process boundary (writeGhStub, which sets ${GH_STUB_ATTESTATION_ENV}). See issue 2216.`,
78
+ ),
79
+ { code: "GH_WRITE_UNSTUBBED_IN_TEST" },
80
+ );
81
+ }
@@ -8,9 +8,65 @@
8
8
  * Pure and side-effect free.
9
9
  */
10
10
 
11
- /** The repository these gate guards apply to. */
11
+ /**
12
+ * The dev-loops repo itself. Retained ONLY for the Pi extension's
13
+ * (`extension/post-merge-update.ts`) dev-loops-repo SELF-UPDATE scoping (`markPendingUpdate` /
14
+ * `queueIfEligible`), which is legitimately anchored to this one repo, not to whatever repo the
15
+ * harness happens to run in. Both harness guard suites — the Claude PreToolUse Bash gate
16
+ * (`decideBashGate` in `hook-decisions.mjs`) and the Pi extension's `gh pr ready`/`gh pr merge`
17
+ * guards (`post-merge-update.ts`) — now resolve the managed repo dynamically via
18
+ * `deriveInManagedRepo` below instead of comparing against this hardcoded slug, so every guard
19
+ * also applies in a dev-loops-managed consumer repo.
20
+ */
12
21
  export const TARGET_REPO_SLUG = "mfittko/dev-loops";
13
22
 
23
+ /** `.devloops` config file extensions checked (in order) to decide whether a repo root is
24
+ * dev-loops-managed. Shared by every harness that resolves `inManagedContext` from the
25
+ * filesystem (`fs.existsSync(path.join(repoRoot, \`.devloops${ext}\`))`). */
26
+ export const DEVLOOPS_CONFIG_VARIANTS = ["", ".yaml", ".yml", ".json"];
27
+
28
+ /**
29
+ * Whether the current repo counts as "managed" for guard-gating purposes: inside a
30
+ * dev-loops-managed context (a `.devloops` config exists at its root) AND, when the managed
31
+ * repo's identity resolves, the cwd repo IS that managed repo. FAIL CLOSED: inside a managed
32
+ * context whose identity can't be resolved (`managedRepoSlug` null), the guard suite still
33
+ * applies rather than silently allowing everything.
34
+ * @param {Object} [params]
35
+ * @param {boolean} [params.inManagedContext] - Whether a `.devloops` config exists at the repo root.
36
+ * @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the managed repo, or null.
37
+ * @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo, or null.
38
+ * @returns {boolean}
39
+ */
40
+ export function deriveInManagedRepo({ inManagedContext = false, managedRepoSlug = null, repoSlug = null } = {}) {
41
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
42
+ const cwdSlug = (repoSlug ?? "").trim().toLowerCase() || null;
43
+ return Boolean(inManagedContext) && (managedSlug === null || cwdSlug === managedSlug);
44
+ }
45
+
46
+ /**
47
+ * Whether an explicit `--repo`/`-R` (or `GH_REPO=`) target is PROVABLY a different repo than the
48
+ * managed one — both slugs must resolve and differ. An unresolvable managed slug never proves
49
+ * foreign-ness (fail closed: the guard stays active rather than waving an explicit flag through).
50
+ * @param {string|null} explicitRepo
51
+ * @param {string|null} managedRepoSlug
52
+ * @returns {boolean}
53
+ */
54
+ export function explicitRepoProvenForeign(explicitRepo, managedRepoSlug) {
55
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
56
+ const explicit = (explicitRepo ?? "").trim().toLowerCase() || null;
57
+ if (managedSlug === null || explicit === null) {
58
+ return false;
59
+ }
60
+ // Both sides must be clean owner/name identities before we can prove foreign: a
61
+ // managed slug that bypassed the normalizer (e.g. a hostile `acme/widgets;id` test
62
+ // double) can't be trusted to prove anything about the explicit `--repo` — fail
63
+ // closed (the managed-repo guard still applies) rather than wave it through.
64
+ if (!isCleanRepoSlug(managedSlug) || !isCleanRepoSlug(explicit)) {
65
+ return false;
66
+ }
67
+ return explicit !== managedSlug;
68
+ }
69
+
14
70
  /** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
15
71
  export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
16
72
 
@@ -21,7 +77,7 @@ export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
21
77
  * strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
22
78
  * the gate. Used by all segment-splitting sites (DRY).
23
79
  */
24
- const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
80
+ const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||&|\n|\r)\s*/;
25
81
 
26
82
  /**
27
83
  * Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
@@ -67,8 +123,32 @@ export function trimToNull(value) {
67
123
  return trimmed ? trimmed : null;
68
124
  }
69
125
 
126
+ /**
127
+ * Strict GitHub owner/name identity shape: each of the two path segments is
128
+ * `[A-Za-z0-9._-]+` — the character set GitHub itself allows in an owner or repo name. A slug
129
+ * outside this shape (a shell metacharacter, whitespace, path traversal, or an extra `/` segment)
130
+ * can never be a real GitHub identity.
131
+ */
132
+ const CLEAN_REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
133
+
134
+ /**
135
+ * Whether `slug` is a clean `owner/name` GitHub identity (see `CLEAN_REPO_SLUG_RE`). Exported so a
136
+ * sink that interpolates a repo slug into a shell command string (e.g. the Pi extension's
137
+ * `gateCommand` in `extension/post-merge-update.ts`) can defense-in-depth guard the interpolation,
138
+ * on top of `normalizeGitHubRepoSlug` already enforcing this shape at the source.
139
+ * @param {string|null|undefined} slug @returns {boolean}
140
+ */
141
+ export function isCleanRepoSlug(slug) {
142
+ return typeof slug === "string" && CLEAN_REPO_SLUG_RE.test(slug);
143
+ }
144
+
70
145
  /**
71
146
  * Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
147
+ * A hostile remote (e.g. `git@github.com:acme/widgets;id`) never yields a slug carrying the
148
+ * injected metacharacters — the extracted candidate must match `CLEAN_REPO_SLUG_RE` or this
149
+ * returns null instead, so every caller (both harnesses' managed-repo scope checks, and any sink
150
+ * that interpolates the slug into a shell command) sees either a real GitHub identity or null,
151
+ * never shell-metacharacter-bearing text.
72
152
  * @param {string} remoteUrl
73
153
  * @returns {string|null}
74
154
  */
@@ -91,7 +171,8 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
91
171
  if (!match) {
92
172
  continue;
93
173
  }
94
- return trimToNull(match[1])?.toLowerCase() ?? null;
174
+ const slug = trimToNull(match[1])?.toLowerCase() ?? null;
175
+ return isCleanRepoSlug(slug) ? slug : null;
95
176
  }
96
177
 
97
178
  return null;
@@ -486,6 +567,25 @@ export function extractRepoFlagsFromGhPrCreateSegments(command) {
486
567
  return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
487
568
  }
488
569
 
570
+ /**
571
+ * Return `{ segment, explicitRepo }` for every `gh pr merge` segment (ignoring --help/-h) —
572
+ * PreToolUse gate scope check use only, so a proven-foreign leading segment can't shield a later
573
+ * managed one. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
574
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
575
+ */
576
+ export function extractRepoFlagsFromGhPrMergeSegments(command) {
577
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "merge");
578
+ }
579
+
580
+ /**
581
+ * Return `{ segment, explicitRepo }` for every `gh pr ready` segment (ignoring --help/-h) —
582
+ * PreToolUse gate scope check use only. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
583
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
584
+ */
585
+ export function extractRepoFlagsFromGhPrReadySegments(command) {
586
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "ready");
587
+ }
588
+
489
589
  /** @param {string} command @returns {number|null} */
490
590
  export function extractPrNumberFromGhPrMerge(command) {
491
591
  return extractPrNumberFromGhPrVerb(command, "merge");
@@ -574,13 +674,22 @@ export function extractGhApiEndpointSegments(command) {
574
674
  return out;
575
675
  }
576
676
 
577
- /** The `gh api` segments whose endpoint is the target repo's URL path. Matches the absolute
578
- * slug-embedded form (`repos/mfittko/dev-loops/...`) and the bare relative form (`issues/...`),
579
- * which gh api resolves against the cwd repo — the decideBashGate call site gates the relative form
580
- * on `inTargetRepo`. */
581
- function targetGhApiPathRegex(suffix) {
582
- const slug = TARGET_REPO_SLUG.replace("/", "\\/");
583
- return new RegExp(`(?:repos/${slug}/|^)${suffix}`);
677
+ /** The `gh api` segments whose endpoint is the dev-loops-managed repo's URL path. Matches the
678
+ * absolute slug-embedded form (`repos/<managedSlug>/...`) when `managedSlug` resolves, plus the
679
+ * bare relative form (`issues/...`), which gh api resolves against the cwd repo — the
680
+ * decideBashGate call site gates the relative form on `inManagedRepo`. When `managedSlug` is null
681
+ * (the managed repo's identity could not be resolved in a managed context — AC4's fail-closed
682
+ * case), the absolute arm matches ANY owner/repo rather than a specific slug: identity is unknown,
683
+ * so an absolute write must be denied regardless of which repo it targets, not waved through for
684
+ * lack of a slug to compare against. The absolute arm fully regex-escapes a resolved slug (a `.`
685
+ * in a legitimate repo name must match literally, not as a wildcard) and matches
686
+ * case-insensitively (GitHub repo identity is case-insensitive). */
687
+ function managedGhApiPathRegex(suffix, managedSlug) {
688
+ if (!managedSlug) {
689
+ return new RegExp(`(?:repos/[^/]+/[^/]+/|^)${suffix}`, "i");
690
+ }
691
+ const slug = managedSlug.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
692
+ return new RegExp(`(?:repos/${slug}/|^)${suffix}`, "i");
584
693
  }
585
694
 
586
695
  /** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
@@ -617,15 +726,18 @@ function ghApiSegmentHasWriteMethod(segment) {
617
726
  }
618
727
 
619
728
  /**
620
- * SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the target
621
- * repo — the ad-hoc sub-issue mutation that must flow through the sanctioned `manage-sub-issues`
622
- * wrapper instead. Actor-independent (the issue's decided policy): the main agent gets no reserved
623
- * direct path to sub-issue writes. Anchored on the target repo's URL path segment AND an explicit write
624
- * method, so a `gh api` read or another repo's `sub_issues` write passes through (no false deny).
625
- * @param {string} command @returns {boolean}
729
+ * SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
730
+ * dev-loops-managed repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
731
+ * `manage-sub-issues` wrapper instead. Actor-independent (the issue's decided policy): the main
732
+ * agent gets no reserved direct path to sub-issue writes. Anchored on the managed repo's URL path
733
+ * segment AND an explicit write method, so a `gh api` read or another repo's `sub_issues` write
734
+ * passes through (no false deny).
735
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
736
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
737
+ * @returns {boolean}
626
738
  */
627
- export function commandContainsSubIssueAdHocBypass(command) {
628
- const re = targetGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`);
739
+ export function commandContainsSubIssueAdHocBypass(command, managedSlug = null) {
740
+ const re = managedGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`, managedSlug);
629
741
  return extractGhApiEndpointSegments(command).some(
630
742
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
631
743
  );
@@ -633,12 +745,15 @@ export function commandContainsSubIssueAdHocBypass(command) {
633
745
 
634
746
  /**
635
747
  * COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
636
- * `.../pulls/<n>/comments/<m>/replies` on the target repo — the ad-hoc thread reply that must flow
637
- * through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved direct reply path.
638
- * @param {string} command @returns {boolean}
748
+ * `.../pulls/<n>/comments/<m>/replies` on the dev-loops-managed repo — the ad-hoc thread reply
749
+ * that must flow through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
750
+ * direct reply path.
751
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
752
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
753
+ * @returns {boolean}
639
754
  */
640
- export function commandContainsReplyResolveBypass(command) {
641
- const re = targetGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`);
755
+ export function commandContainsReplyResolveBypass(command, managedSlug = null) {
756
+ const re = managedGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`, managedSlug);
642
757
  return extractGhApiEndpointSegments(command).some(
643
758
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
644
759
  );
@@ -657,12 +772,14 @@ export function commandContainsGraphqlResolveReviewThread(command) {
657
772
 
658
773
  /**
659
774
  * COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
660
- * `.../pulls/<n>/requested_reviewers` on the target repo — the ad-hoc Copilot review request that must
661
- * flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
662
- * @param {string} command @returns {boolean}
775
+ * `.../pulls/<n>/requested_reviewers` on the dev-loops-managed repo — the ad-hoc Copilot review
776
+ * request that must flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
777
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
778
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
779
+ * @returns {boolean}
663
780
  */
664
- export function commandContainsCopilotRequestBypass(command) {
665
- const re = targetGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`);
781
+ export function commandContainsCopilotRequestBypass(command, managedSlug = null) {
782
+ const re = managedGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`, managedSlug);
666
783
  return extractGhApiEndpointSegments(command).some(
667
784
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
668
785
  );