@dev-loops/core 1.0.2 → 1.0.4-pre.0
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.
- package/package.json +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -8,9 +8,65 @@
|
|
|
8
8
|
* Pure and side-effect free.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
/**
|
|
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*(
|
|
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
|
-
|
|
174
|
+
const slug = trimToNull(match[1])?.toLowerCase() ?? null;
|
|
175
|
+
return isCleanRepoSlug(slug) ? slug : null;
|
|
95
176
|
}
|
|
96
177
|
|
|
97
178
|
return null;
|
|
@@ -154,6 +235,34 @@ function shellSegments(command) {
|
|
|
154
235
|
*/
|
|
155
236
|
const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
|
|
156
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Leading prefix a code-verification/build command may carry before its real executable: the
|
|
240
|
+
* shared `SHELL_EXEC_PREFIX` (env assignments, `command`/`env`/`exec` wrapper words, binary path)
|
|
241
|
+
* plus `nice`/`timeout` process wrappers, scoped to this classifier only so the sibling `gh`/`git`
|
|
242
|
+
* classifiers above are not broadened by wrapper forms they never need to tolerate. Covers bare
|
|
243
|
+
* `nice`, `nice -n <N>`, bare `timeout <duration>`, and `timeout` carrying `-s <sig>`/`-k <dur>`/
|
|
244
|
+
* `--signal=<sig>`/`--kill-after=<dur>`/`--preserve-status`/`--foreground` before the duration —
|
|
245
|
+
* the wrapper forms the coordinator's daily verify/build commands are routinely run behind
|
|
246
|
+
* (`timeout 600 bun run verify`, `nice -n 10 bun run verify`). Not a full flag parser: other
|
|
247
|
+
* `timeout`/`nice` flags are a known, deliberately uncovered ceiling.
|
|
248
|
+
*
|
|
249
|
+
* The `env` wrapper word additionally tolerates zero-or-more trailing `NAME=value` assignments
|
|
250
|
+
* before the real executable (`env CI=1 bun run verify`, `env CI=1 FOO=bar npm test`) — the common
|
|
251
|
+
* everyday `env VAR=value ... cmd` CI-invocation shape, on top of the bare-leading-assignment form
|
|
252
|
+
* (`CI=1 bun run verify`) already covered by the shared assignment run at the front of this prefix.
|
|
253
|
+
* It also tolerates the common `env` OPTION forms (mixed freely with `NAME=value` assignments, in
|
|
254
|
+
* any order/count): `-i`/`--ignore-environment`, `-u <NAME>`/`--unset=<NAME>`, `-C <dir>`/
|
|
255
|
+
* `--chdir=<dir>`, `-S <str>`/`--split-string=<str>`, a bare `-`, and `--` — so
|
|
256
|
+
* `env -u DEVLOOPS_COORDINATOR_READONLY bun run verify`, `env -i bun run verify`, and
|
|
257
|
+
* `env -u FOO CI=1 npm test` all match. Closes the cheap classifier gap where an `env` flag (rather
|
|
258
|
+
* than a `NAME=value` assignment) reached the executable unclassified. Not a full `env` flag parser:
|
|
259
|
+
* any other/exotic `env` option is a known, deliberately uncovered ceiling (documented, not chased).
|
|
260
|
+
* `command`/`exec` do not get the same trailing-assignment/option tolerance — no known daily
|
|
261
|
+
* invocation shape needs it, and adding it would only widen the pattern without a use case.
|
|
262
|
+
*/
|
|
263
|
+
const VERIFY_EXEC_PREFIX =
|
|
264
|
+
"(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:env(?:\\s+(?:[A-Za-z_][A-Za-z0-9_]*=\\S*|-i|--ignore-environment|-u\\s+\\S+|--unset=\\S+|-C\\s+\\S+|--chdir=\\S+|-S\\s+\\S+|--split-string=\\S+|--|-))*\\s+|(?:command|exec)\\s+|nice(?:\\s+-n\\s+\\S+)?\\s+|timeout(?:\\s+(?:-s\\s+\\S+|-k\\s+\\S+|--signal=\\S+|--kill-after=\\S+|--preserve-status|--foreground))*\\s+\\S+\\s+)*(?:\\S*/)?";
|
|
265
|
+
|
|
157
266
|
/**
|
|
158
267
|
* Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
|
|
159
268
|
* Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
|
|
@@ -486,6 +595,25 @@ export function extractRepoFlagsFromGhPrCreateSegments(command) {
|
|
|
486
595
|
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
|
|
487
596
|
}
|
|
488
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Return `{ segment, explicitRepo }` for every `gh pr merge` segment (ignoring --help/-h) —
|
|
600
|
+
* PreToolUse gate scope check use only, so a proven-foreign leading segment can't shield a later
|
|
601
|
+
* managed one. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
602
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
603
|
+
*/
|
|
604
|
+
export function extractRepoFlagsFromGhPrMergeSegments(command) {
|
|
605
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "merge");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Return `{ segment, explicitRepo }` for every `gh pr ready` segment (ignoring --help/-h) —
|
|
610
|
+
* PreToolUse gate scope check use only. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
611
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
612
|
+
*/
|
|
613
|
+
export function extractRepoFlagsFromGhPrReadySegments(command) {
|
|
614
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "ready");
|
|
615
|
+
}
|
|
616
|
+
|
|
489
617
|
/** @param {string} command @returns {number|null} */
|
|
490
618
|
export function extractPrNumberFromGhPrMerge(command) {
|
|
491
619
|
return extractPrNumberFromGhPrVerb(command, "merge");
|
|
@@ -574,13 +702,22 @@ export function extractGhApiEndpointSegments(command) {
|
|
|
574
702
|
return out;
|
|
575
703
|
}
|
|
576
704
|
|
|
577
|
-
/** The `gh api` segments whose endpoint is the
|
|
578
|
-
* slug-embedded form (`repos
|
|
579
|
-
* which gh api resolves against the cwd repo — the
|
|
580
|
-
* on `
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
705
|
+
/** The `gh api` segments whose endpoint is the dev-loops-managed repo's URL path. Matches the
|
|
706
|
+
* absolute slug-embedded form (`repos/<managedSlug>/...`) when `managedSlug` resolves, plus the
|
|
707
|
+
* bare relative form (`issues/...`), which gh api resolves against the cwd repo — the
|
|
708
|
+
* decideBashGate call site gates the relative form on `inManagedRepo`. When `managedSlug` is null
|
|
709
|
+
* (the managed repo's identity could not be resolved in a managed context — AC4's fail-closed
|
|
710
|
+
* case), the absolute arm matches ANY owner/repo rather than a specific slug: identity is unknown,
|
|
711
|
+
* so an absolute write must be denied regardless of which repo it targets, not waved through for
|
|
712
|
+
* lack of a slug to compare against. The absolute arm fully regex-escapes a resolved slug (a `.`
|
|
713
|
+
* in a legitimate repo name must match literally, not as a wildcard) and matches
|
|
714
|
+
* case-insensitively (GitHub repo identity is case-insensitive). */
|
|
715
|
+
function managedGhApiPathRegex(suffix, managedSlug) {
|
|
716
|
+
if (!managedSlug) {
|
|
717
|
+
return new RegExp(`(?:repos/[^/]+/[^/]+/|^)${suffix}`, "i");
|
|
718
|
+
}
|
|
719
|
+
const slug = managedSlug.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
720
|
+
return new RegExp(`(?:repos/${slug}/|^)${suffix}`, "i");
|
|
584
721
|
}
|
|
585
722
|
|
|
586
723
|
/** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
|
|
@@ -617,15 +754,18 @@ function ghApiSegmentHasWriteMethod(segment) {
|
|
|
617
754
|
}
|
|
618
755
|
|
|
619
756
|
/**
|
|
620
|
-
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
|
|
621
|
-
* repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
|
|
622
|
-
* wrapper instead. Actor-independent (the issue's decided policy): the main
|
|
623
|
-
* direct path to sub-issue writes. Anchored on the
|
|
624
|
-
* method, so a `gh api` read or another repo's `sub_issues` write
|
|
625
|
-
*
|
|
757
|
+
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
|
|
758
|
+
* dev-loops-managed repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
|
|
759
|
+
* `manage-sub-issues` wrapper instead. Actor-independent (the issue's decided policy): the main
|
|
760
|
+
* agent gets no reserved direct path to sub-issue writes. Anchored on the managed repo's URL path
|
|
761
|
+
* segment AND an explicit write method, so a `gh api` read or another repo's `sub_issues` write
|
|
762
|
+
* passes through (no false deny).
|
|
763
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
764
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
765
|
+
* @returns {boolean}
|
|
626
766
|
*/
|
|
627
|
-
export function commandContainsSubIssueAdHocBypass(command) {
|
|
628
|
-
const re =
|
|
767
|
+
export function commandContainsSubIssueAdHocBypass(command, managedSlug = null) {
|
|
768
|
+
const re = managedGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`, managedSlug);
|
|
629
769
|
return extractGhApiEndpointSegments(command).some(
|
|
630
770
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
631
771
|
);
|
|
@@ -633,12 +773,15 @@ export function commandContainsSubIssueAdHocBypass(command) {
|
|
|
633
773
|
|
|
634
774
|
/**
|
|
635
775
|
* COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
|
|
636
|
-
* `.../pulls/<n>/comments/<m>/replies` on the
|
|
637
|
-
* through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
|
|
638
|
-
*
|
|
776
|
+
* `.../pulls/<n>/comments/<m>/replies` on the dev-loops-managed repo — the ad-hoc thread reply
|
|
777
|
+
* that must flow through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
|
|
778
|
+
* direct reply path.
|
|
779
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
780
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
781
|
+
* @returns {boolean}
|
|
639
782
|
*/
|
|
640
|
-
export function commandContainsReplyResolveBypass(command) {
|
|
641
|
-
const re =
|
|
783
|
+
export function commandContainsReplyResolveBypass(command, managedSlug = null) {
|
|
784
|
+
const re = managedGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`, managedSlug);
|
|
642
785
|
return extractGhApiEndpointSegments(command).some(
|
|
643
786
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
644
787
|
);
|
|
@@ -657,12 +800,14 @@ export function commandContainsGraphqlResolveReviewThread(command) {
|
|
|
657
800
|
|
|
658
801
|
/**
|
|
659
802
|
* COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
|
|
660
|
-
* `.../pulls/<n>/requested_reviewers` on the
|
|
661
|
-
* flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
662
|
-
* @param {string} command @
|
|
803
|
+
* `.../pulls/<n>/requested_reviewers` on the dev-loops-managed repo — the ad-hoc Copilot review
|
|
804
|
+
* request that must flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
805
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
806
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
807
|
+
* @returns {boolean}
|
|
663
808
|
*/
|
|
664
|
-
export function commandContainsCopilotRequestBypass(command) {
|
|
665
|
-
const re =
|
|
809
|
+
export function commandContainsCopilotRequestBypass(command, managedSlug = null) {
|
|
810
|
+
const re = managedGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`, managedSlug);
|
|
666
811
|
return extractGhApiEndpointSegments(command).some(
|
|
667
812
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
668
813
|
);
|
|
@@ -685,29 +830,197 @@ export function commandContainsCopilotSummonComment(command) {
|
|
|
685
830
|
}
|
|
686
831
|
|
|
687
832
|
/**
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
833
|
+
* Blank the CONTENTS of quoted string literals ('...' and "...") to a space, EXCEPT a quote that is
|
|
834
|
+
* the payload of an executable-code flag — a short-flag cluster CONTAINING `c` ANYWHERE in the
|
|
835
|
+
* cluster (`sh -c '…'`, `bash -c '…'`, `bash -lc '…'`, `bash -ec '…'`, `bash -cl '…'`, `bash -ci '…'`,
|
|
836
|
+
* `bash -cx '…'`) — bash treats ANY `-c`-containing short-flag cluster as command-execution
|
|
837
|
+
* regardless of where `c` sits in the cluster, not only one that ENDS in `c` — optionally followed by
|
|
838
|
+
* a `--` terminator (`bash -c -- '…'`), a long `--command` flag, or `eval` — that payload is REAL
|
|
839
|
+
* shell syntax to be executed, not inert data, so blanking it would hide an actual poll-loop
|
|
840
|
+
* construct wrapped in one of these forms. A real poll loop's structural tokens
|
|
841
|
+
* (`while`/`until`/`for`/`do`/`sleep`/`done`, a `[ -f … ]` file test) are UNQUOTED shell syntax; a
|
|
842
|
+
* quoted issue body, `--body` payload, or quoted example that merely mentions them carries them
|
|
843
|
+
* INSIDE quotes as inert data. Blanking those quoted contents is what lets the poll-loop matchers key
|
|
844
|
+
* on an actual loop CONSTRUCT rather than the token sequence appearing anywhere in a command
|
|
845
|
+
* (`gh issue create --body "while … sleep … done"` must not be flagged). The `s` (dotAll) flag lets
|
|
846
|
+
* `.` match a newline too, so a MULTI-LINE quoted `--body` (a real issue body commonly spans lines) is
|
|
847
|
+
* stripped in full, not just its first line.
|
|
848
|
+
* A quoted string that itself contains a command substitution (`$(...)`) or a backtick (`` `...` ``)
|
|
849
|
+
* is executable code, not inert data — its inner command runs regardless of the surrounding quotes.
|
|
850
|
+
* Blanking it would hide a real poll loop such as `while [ "$(gh pr view 5)" != MERGED ]; do sleep 5;
|
|
851
|
+
* done` that the gh/loop-state ban already denies, so such a quoted literal is PRESERVED (fail-closed
|
|
852
|
+
* direction) ahead of the `-c`/`--command`/`eval` exemption check below.
|
|
853
|
+
*
|
|
854
|
+
* The `-c`-cluster/`--command`/`eval` exemption is intentionally coarse in the fail-closed direction:
|
|
855
|
+
* it looks only for a `c` anywhere in a preceding short-flag cluster (or `--command`/`eval`), not for
|
|
856
|
+
* a shell-interpreter anchor. A non-shell command carrying `-c` (e.g. `grep -ci '<loop text>'`,
|
|
857
|
+
* `wc -c`) may therefore have its quoted argument preserved too and get over-denied — an accepted
|
|
858
|
+
* benign false positive, because tightening the exemption to a shell-interpreter anchor would risk a
|
|
859
|
+
* fail-open (missing a real `sh -c` poll loop), the worse direction.
|
|
860
|
+
*
|
|
861
|
+
* ponytail: blanks balanced quote pairs only, with the command-substitution/backtick preserve rule
|
|
862
|
+
* and the `-c`-cluster/`--command`/`eval` exemption above — no full shell tokenizer (mismatched/
|
|
863
|
+
* partial quotes and other exec-wrapper flags stay out of scope). Accepted ceiling: a deliberately
|
|
864
|
+
* quoted structural keyword (e.g. `"sleep"`) placed inside a real UNQUOTED loop is blanked like any
|
|
865
|
+
* other quoted literal and can therefore evade the ban — accepted as a deliberate-evasion class, not
|
|
866
|
+
* a natural shape a genuine poll loop takes.
|
|
867
|
+
* @param {string} command @returns {string}
|
|
868
|
+
*/
|
|
869
|
+
function stripQuotedLiterals(command) {
|
|
870
|
+
return command.replace(/(['"])((?:(?!\1).)*)\1/gs, (match, _quote, inner, offset, full) => {
|
|
871
|
+
// A quoted string containing a command substitution ($()) or backtick is executable code, not
|
|
872
|
+
// inert data — its command runs regardless of the surrounding quotes. Blanking it would hide a
|
|
873
|
+
// real poll loop such as `while [ "$(gh pr view 5)" != MERGED ]; do sleep 5; done` that the
|
|
874
|
+
// gh/loop-state ban already denied. Preserve it (fail-closed direction).
|
|
875
|
+
if (/\$\(|`/.test(inner)) {
|
|
876
|
+
return match;
|
|
877
|
+
}
|
|
878
|
+
const before = full.slice(0, offset);
|
|
879
|
+
if (/(?:^|\s)(?:-[A-Za-z]*c[A-Za-z]*(?:\s+--)?|--command|eval)\s*$/.test(before)) {
|
|
880
|
+
return match; // executable -c/eval payload — leave the real shell syntax intact
|
|
881
|
+
}
|
|
882
|
+
return " ";
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Whether COMMAND is (or contains) a sleep-poll loop over `gh`/`loop-state` — a `while`/`until`/
|
|
888
|
+
* `for` loop whose body contains both a `sleep` and a `gh` or `loop-state` call. Checked on the
|
|
889
|
+
* WHOLE command (not per-segment): the loop body is `;`-delimited, so a per-segment split would
|
|
890
|
+
* separate the loop head from its `sleep`/`gh` body calls and miss the pattern. `gh` must be a
|
|
891
|
+
* standalone token (not `grep gh-notes`), and `loop-state` must sit at a command-head position
|
|
892
|
+
* (not a substring inside `grep loop-state x`). Quoted literals are blanked first (see
|
|
893
|
+
* `stripQuotedLiterals`) so a quoted body/example that merely contains the tokens is not flagged.
|
|
894
|
+
* @param {string} command @returns {boolean}
|
|
895
|
+
*/
|
|
896
|
+
export function commandIsSleepPollLoop(command) {
|
|
897
|
+
const whole = stripQuotedLiterals(command.trim());
|
|
898
|
+
return (
|
|
899
|
+
/(?:while|until|for)\b/i.test(whole) &&
|
|
900
|
+
/\bsleep\b/.test(whole) &&
|
|
901
|
+
/\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Whether COMMAND is (or contains) a bare FILE-MARKER poll loop: a `while`/`until`/`for` loop that
|
|
907
|
+
* repeatedly tests for a file's existence/type/permission (`[ -f … ]`, `[[ -e … ]]`, `test -f …`,
|
|
908
|
+
* `[ -r … ]`, `[ -L … ]`) and sleeps, with NO gated wait-tool. This is the orphan pattern under
|
|
909
|
+
* Claude Code — a `<tasks>/<id>.done` sentinel Claude Code never writes (completion arrives via
|
|
910
|
+
* async notification), so the loop never exits and, once backgrounded, orphans with no async wake to
|
|
911
|
+
* reap it. Distinct from `commandIsSleepPollLoop`, which keys on a `gh`/`loop-state` CALL: this keys
|
|
912
|
+
* on any bash FILE-test operator (existence, type, or permission — `efsdrwxugkOGLNShb`; string/numeric
|
|
913
|
+
* tests like `-z`/`-n`/`-t` are deliberately excluded, as those never test a marker FILE), catching a
|
|
914
|
+
* marker poll that calls no gh/loop-state at all. Verb-independent (while/until/for). Quoted literals
|
|
915
|
+
* are blanked first (see `stripQuotedLiterals`) so a quoted example or `--body` payload that merely
|
|
916
|
+
* contains the tokens is NOT flagged.
|
|
917
|
+
* @param {string} command @returns {boolean}
|
|
918
|
+
*/
|
|
919
|
+
export function commandIsFileMarkerPollLoop(command) {
|
|
920
|
+
const whole = stripQuotedLiterals(command.trim());
|
|
921
|
+
return (
|
|
922
|
+
/(?:while|until|for)\b/i.test(whole) &&
|
|
923
|
+
/\bsleep\b/.test(whole) &&
|
|
924
|
+
/(?:\[\[?|\btest\b)\s+(?:!\s+)?-[a-hkprsuwxGLNOS]\b/.test(whole)
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Whether COMMAND contains a bare `&` backgrounding control operator — not `&&` (logical AND) and
|
|
930
|
+
* not a redirection (`2>&1`, `>&2`, `&>file`, `&>>file`). A coarse whole-string check (no shell
|
|
931
|
+
* parse): redirection forms are stripped first, then any surviving lone `&` (not immediately
|
|
932
|
+
* preceded or followed by another `&`) counts.
|
|
933
|
+
* @param {string} command @returns {boolean}
|
|
934
|
+
*/
|
|
935
|
+
function commandHasBareBackgroundOperator(command) {
|
|
936
|
+
const withoutRedir = command
|
|
937
|
+
.replace(/\d*>&\d*-?/g, " ") // 2>&1, 1>&2, >&2, >&-
|
|
938
|
+
.replace(/&>>?/g, " "); // &>file, &>>file
|
|
939
|
+
return /(?<!&)&(?!&)/.test(withoutRedir);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* The wait/probe helper FAMILY: the Copilot/CI wait tools that MUST run as a bounded FOREGROUND
|
|
944
|
+
* probe — the `.mjs` helpers (`probe-copilot-review`, `wait-pr-checks`, `detect-copilot-loop-state`,
|
|
945
|
+
* `run-watch-cycle`, `probe-ci-status` — the sanctioned `ci-status`/`watch-ci` CI-status wait,
|
|
946
|
+
* skills/dev-loop/SKILL.md's "PR checks/status" entry), `gh run watch`, and the
|
|
947
|
+
* `dev-loops`/`dev-loops-run` `watch-cycle`/`watch-ci`/`watch-initial`/`gate probe-copilot` CLI
|
|
948
|
+
* verbs. A coarse ANYWHERE-in-the-string substring/family match (deliberately NOT exec-position
|
|
949
|
+
* anchored) — see `commandContainsDetachedWaitTool`'s JSDoc for the fail-closed rationale.
|
|
950
|
+
*/
|
|
951
|
+
const WAIT_PROBE_FAMILY_RE = new RegExp(
|
|
952
|
+
[
|
|
953
|
+
"probe-copilot-review\\.mjs",
|
|
954
|
+
"wait-pr-checks\\.mjs",
|
|
955
|
+
"detect-copilot-loop-state\\.mjs",
|
|
956
|
+
"run-watch-cycle\\.mjs",
|
|
957
|
+
"probe-ci-status\\.mjs",
|
|
958
|
+
"gh\\s+run\\s+watch",
|
|
959
|
+
"watch-cycle",
|
|
960
|
+
"watch-ci",
|
|
961
|
+
"watch-initial",
|
|
962
|
+
"probe-copilot",
|
|
963
|
+
].join("|"),
|
|
964
|
+
"i",
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* COPILOT-FOLLOWUP-WAIT-TOOLS: a banned detached/polling wait. This is a UNION of three
|
|
969
|
+
* independent deny conditions — NOT one big AND (an AND-condition here would inadvertently
|
|
970
|
+
* narrow the unconditional detach-wrapper ban below to "detach AND family reference", wrongly
|
|
971
|
+
* allowing a family-less `nohup node build.mjs &`):
|
|
972
|
+
*
|
|
973
|
+
* (1) `nohup`/`disown`/`tmux new-session`/`screen -dm` anywhere in a command segment — denied
|
|
974
|
+
* UNCONDITIONALLY, with NO wait/probe-family requirement.
|
|
975
|
+
* (2) A `while`/`until`/`for` sleep-poll loop over `gh`/`loop-state` (`commandIsSleepPollLoop`) —
|
|
976
|
+
* denied UNCONDITIONALLY — it IS the backgrounding signal.
|
|
977
|
+
* (3) OPTION-C, prevention-only scope: a bare `&` background (`commandHasBareBackgroundOperator`,
|
|
978
|
+
* including a `timeout …`/`env …`/`sh -c` wrapper of it) that ALSO references the wait/probe
|
|
979
|
+
* FAMILY anywhere in the command string (`WAIT_PROBE_FAMILY_RE`) — a coarse substring/family
|
|
980
|
+
* match, deliberately NOT exec-position anchored.
|
|
981
|
+
*
|
|
982
|
+
* Because (3)'s family match is coarse (anywhere in the string, not the executed token), NO wrapper
|
|
983
|
+
* can hide the reference from it: `timeout N … &`, `env … &`, `sh -c '… &'`, or a node loader flag
|
|
984
|
+
* (`node -r ./loader.mjs …/probe-copilot-review.mjs &`, `--require`/`--loader`/`--import`) all still
|
|
985
|
+
* carry the family token in the backgrounded command text, so all are denied. This trades precision
|
|
986
|
+
* for guaranteed coverage: a background command that merely MENTIONS a family name as an unrelated
|
|
987
|
+
* argument (`echo "see probe-copilot-review.mjs" &`) is also denied — a benign false positive,
|
|
988
|
+
* sanctioned by the issue's non-goals (this is a prevention gate, not an exec-position parser; a
|
|
989
|
+
* denied benign command simply falls back to the sanctioned foreground path). The precise
|
|
990
|
+
* exec-position parser this replaced (and the SubagentStop background-shell reaper it fed) is
|
|
991
|
+
* deferred to a follow-up safety-net effort.
|
|
992
|
+
*
|
|
993
|
+
* Actor-independent at the decideBashGate call site: the coordinator/main agent — not only a
|
|
994
|
+
* subagent — is the actor that leaves these orphaned under Claude Code (no async wake to join a
|
|
995
|
+
* backgrounded wait), so the gate catches its backgrounding too.
|
|
692
996
|
* @param {string} command @returns {boolean}
|
|
693
997
|
*/
|
|
694
998
|
export function commandContainsDetachedWaitTool(command) {
|
|
695
999
|
const whole = command.trim();
|
|
696
|
-
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
return true;
|
|
702
|
-
}
|
|
703
|
-
return shellSegments(command).some((segment) => {
|
|
704
|
-
// `nohup`/`disown` only detach when they head a command (segment start, or right after a shell
|
|
705
|
-
// operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
|
|
1000
|
+
|
|
1001
|
+
// (1) Unconditional detach-wrapper ban — no wait/probe-family requirement.
|
|
1002
|
+
const hasDetachWrapper = shellSegments(command).some((segment) => {
|
|
1003
|
+
// `nohup`/`disown` only detach when they head a command (segment start, or right after a
|
|
1004
|
+
// shell operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
|
|
706
1005
|
if (/(?:^|[;&|])\s*(?:nohup|disown)\b/.test(segment)) return true;
|
|
707
1006
|
if (/^tmux\s+new-session\b/i.test(segment)) return true;
|
|
708
1007
|
if (/^screen\s+-dm/i.test(segment)) return true;
|
|
709
1008
|
return false;
|
|
710
1009
|
});
|
|
1010
|
+
if (hasDetachWrapper) {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// (2) Unconditional sleep-poll-loop ban — a gh/loop-state poll OR a bare file-marker poll.
|
|
1015
|
+
if (commandIsSleepPollLoop(whole) || commandIsFileMarkerPollLoop(whole)) {
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// (3) OPTION-C: bare-`&` background AND a wait/probe family reference.
|
|
1020
|
+
if (!commandHasBareBackgroundOperator(whole)) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
return WAIT_PROBE_FAMILY_RE.test(whole);
|
|
711
1024
|
}
|
|
712
1025
|
|
|
713
1026
|
/** Build a `node`/`python`/`python3` command-head matcher (env/wrapper/path prefix tolerated). */
|
|
@@ -758,3 +1071,44 @@ export function commandContainsInlineInterpreter(command) {
|
|
|
758
1071
|
return false;
|
|
759
1072
|
});
|
|
760
1073
|
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* A package-manager `test`/`verify`/`build` script/task invocation, the `run` keyword optional
|
|
1077
|
+
* (`npm test`, `npm run test`, `bun run verify`, `yarn build`, `pnpm run build`, ...). Anchored on
|
|
1078
|
+
* the executable HEAD (env-assignment/wrapper/path/nice/timeout prefix tolerated via
|
|
1079
|
+
* `VERIFY_EXEC_PREFIX`) so a path that merely contains the word "test" (`cat test/foo.test.mjs`)
|
|
1080
|
+
* never matches — the head token must literally be one of these four package-manager binaries.
|
|
1081
|
+
*
|
|
1082
|
+
* Tolerates a run of binary flags between the binary and `run`/the script name (`bun --bun run
|
|
1083
|
+
* verify`), and a `:`-namespaced sub-script (`test:extension`, `test:core`, `verify:docs`, ...) —
|
|
1084
|
+
* the tail is `(?:[:\s]|$)` rather than `(?:\s|$)` so `npm run test:unit` / `yarn test:ci` match
|
|
1085
|
+
* while `npm run build-docs` (hyphen form, a genuinely different script name) still does not.
|
|
1086
|
+
*/
|
|
1087
|
+
const PACKAGE_MANAGER_VERIFY_RUN_RE = new RegExp(
|
|
1088
|
+
`^${VERIFY_EXEC_PREFIX}(?:bun|npm|yarn|pnpm)(?:\\s+--\\S+)*\\s+(?:run\\s+)?(?:test|verify|build)(?:[:\\s]|$)`,
|
|
1089
|
+
"i",
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* `vitest` run directly (any args: `vitest`, `vitest run`, `vitest --coverage`), or via the
|
|
1094
|
+
* `npx`/`bunx` package-runner (`npx vitest run`, `bunx vitest`) or `bun`'s `x` subcommand
|
|
1095
|
+
* (`bun x vitest`).
|
|
1096
|
+
*/
|
|
1097
|
+
const VITEST_RE = new RegExp(`^${VERIFY_EXEC_PREFIX}(?:(?:npx|bunx)\\s+|bun\\s+x\\s+)?vitest(?:\\s|$)`, "i");
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* COORDINATOR-VERIFY-DELEGATION: whether `command` contains a known code-verification/
|
|
1101
|
+
* build entrypoint in ANY shell segment — `bun test`/`bun run verify`/`bun run build`, `vitest`,
|
|
1102
|
+
* `npm test`/`npm run test`/`npm run build`, and the `yarn`/`pnpm` `test`/`build` equivalents
|
|
1103
|
+
* (with or without the `run` keyword). PreToolUse gate use only: the dev-loop COORDINATOR must
|
|
1104
|
+
* delegate these to a fresh WORKER subagent instead of running them inline; a worker subagent may
|
|
1105
|
+
* run them freely (the actor scoping lives in `decideBashGate`, not here).
|
|
1106
|
+
*
|
|
1107
|
+
* Compact orchestration commands the coordinator MAY still run inline never match — their head
|
|
1108
|
+
* token is not a package-manager binary or `vitest` (`dev-loops queue list`, `gh pr checks --json
|
|
1109
|
+
* --jq`, `detect-checkpoint-evidence`, `git log --oneline -1`, `git status --short`).
|
|
1110
|
+
* @param {string} command @returns {boolean}
|
|
1111
|
+
*/
|
|
1112
|
+
export function commandContainsCodeVerificationEntrypoint(command) {
|
|
1113
|
+
return shellSegments(command).some((segment) => PACKAGE_MANAGER_VERIFY_RUN_RE.test(segment) || VITEST_RE.test(segment));
|
|
1114
|
+
}
|