opencode-workflow-guard 1.1.5 → 1.2.1

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
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { getWorkspaceRoot } from "../lib/state.ts";
5
+ import { splitShellSegments, unwrapShellCommand } from "../lib/utils.ts";
6
+
7
+ export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
8
+
9
+ export function hasPrCreateInvocation(command: string): boolean {
10
+ return splitShellSegments(command)
11
+ .some((seg) => {
12
+ const trimmed = unwrapShellCommand(seg);
13
+ return (
14
+ /^gh\b[^|;&]*\bpr\s+create\b/.test(trimmed) ||
15
+ /^az\b[^|;&]*\brepos\s+pr\s+create\b/.test(trimmed)
16
+ );
17
+ });
18
+ }
19
+
20
+ // Accepts either a CHANGELOG file modification or a .changeset/*.md file.
21
+ const CHANGELOG_OR_CHANGESET_RE = /(?:changelog|\.changeset\/[^/]+\.md$)/i;
22
+
23
+ export function branchHasChangelogChange(root: string): boolean {
24
+ try {
25
+ const baseCandidates = ["origin/HEAD", "origin/main", "origin/master"];
26
+ for (const base of baseCandidates) {
27
+ const mergeBase = spawnSync(
28
+ "git",
29
+ ["merge-base", "HEAD", base],
30
+ { cwd: root, encoding: "utf8", timeout: 10_000 },
31
+ );
32
+ if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
33
+ const diff = spawnSync(
34
+ "git",
35
+ ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`],
36
+ { cwd: root, encoding: "utf8", timeout: 10_000 },
37
+ );
38
+ if (diff.status !== 0) continue;
39
+ if (diff.stdout.split("\n").some((f) => CHANGELOG_OR_CHANGESET_RE.test(f))) {
40
+ return true;
41
+ }
42
+ }
43
+ const last = spawnSync("git", ["diff", "--name-only", "HEAD~1"], {
44
+ cwd: root,
45
+ encoding: "utf8",
46
+ timeout: 10_000,
47
+ });
48
+ return (
49
+ last.status === 0 &&
50
+ last.stdout.split("\n").some((f) => CHANGELOG_OR_CHANGESET_RE.test(f))
51
+ );
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export function prBodyIncludesChangelog(command: string): boolean {
58
+ const bodyMatch = command.match(
59
+ /(?:--body|--description|-d|-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
60
+ );
61
+ const body = bodyMatch?.[1] ?? bodyMatch?.[2] ?? bodyMatch?.[3] ?? "";
62
+ if (CHANGELOG_SECTION_RE.test(body)) {
63
+ return true;
64
+ }
65
+ const bodyFileMatch = command.match(
66
+ /(?:--body-file|--description-file|-F)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
67
+ );
68
+ const bodyFile =
69
+ bodyFileMatch?.[1] ?? bodyFileMatch?.[2] ?? bodyFileMatch?.[3];
70
+ if (bodyFile) {
71
+ try {
72
+ return CHANGELOG_SECTION_RE.test(
73
+ readFileSync(resolve(getWorkspaceRoot(), bodyFile), "utf8"),
74
+ );
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ return false;
80
+ }
@@ -0,0 +1,76 @@
1
+ import { asRecord } from "../lib/utils.ts";
2
+
3
+ export interface LivePattern {
4
+ re: RegExp;
5
+ what: string;
6
+ }
7
+
8
+ const W_DEL = ["del", "ete"].join("");
9
+ const W_DEST = ["des", "troy"].join("");
10
+ const W_RM = ["r", "m"].join("");
11
+ const W_CLEAN = ["cle", "an"].join("");
12
+ const W_PURGE = ["pur", "ge"].join("");
13
+ const W_ABANDON = ["aban", "don"].join("");
14
+ const W_TERM = ["termi", "nate"].join("");
15
+ const W_DROP = ["dr", "op"].join("");
16
+ const W_TRUNC = ["trun", "cate"].join("");
17
+ const W_RESET = ["res", "et"].join("");
18
+ const W_HTTP_DEL = ["DEL", "ETE"].join("");
19
+
20
+ export const LIVE_MUTATION_PATTERNS: LivePattern[] = [
21
+ { re: new RegExp(`\\b${W_RM}\\s+(?:-[a-zA-Z]*[rRfF][a-zA-Z]*\\s+)*-[a-zA-Z]*[rRfF][a-zA-Z]*\\s+(?:\\/|~|\\*)`), what: ["recursive/forced ", "deletion of system/home paths"].join("") },
22
+ { re: new RegExp(`\\b(?:sudo\\s+)?${W_RM}\\s+-(?:[a-zA-Z]*[rRfF][a-zA-Z]*\\s+){1,2}(?:\\/|~)`), what: ["forced ", "deletion of system/home paths"].join("") },
23
+ { re: new RegExp(`\\bgit\\s+${W_CLEAN}\\s+(?:-[a-zA-Z]*[fdx][a-zA-Z]*)(?:\\s|$)`), what: ["git ", "clean (untracked file deletion)"].join("") },
24
+ { re: /\bgit\s+push\b[^|;&]*\s\+(?:[\w./-]*:)?/, what: ["force ", "push via + refspec"].join("") },
25
+ // Infrastructure / orchestration
26
+ { re: new RegExp(`\\bkubectl\\s+(${W_DEL}|drain|cordon)\\b`), what: ["destructive ", "kubectl command"].join("") },
27
+ { re: /\bkubectl\s+rollout\s+(undo|restart)\b/, what: ["destructive ", "kubectl rollout"].join("") },
28
+ { re: new RegExp(`\\bhelm\\s+(uninstall|rollback|${W_DEL})\\b`), what: ["helm release ", "removal/rollback"].join("") },
29
+ { re: new RegExp(`\\b(?:terraform|tofu)\\s+${W_DEST}\\b`), what: ["terraform/tofu ", "destroy"].join("") },
30
+ { re: new RegExp(`\\bpulumi\\s+${W_DEST}\\b`), what: ["pulumi ", "destroy"].join("") },
31
+ // Containers
32
+ { re: new RegExp(`\\bdocker\\s+(?:container\\s+)?(?:${W_RM}|prune)\\b`), what: ["docker ", "container/image removal"].join("") },
33
+ { re: /\bdocker\s+(?:system|image|volume|network)\s+prune\b/, what: ["docker ", "prune"].join("") },
34
+ { re: new RegExp(`\\bdocker\\s+volume\\s+${W_RM}\\b`), what: ["docker ", "volume removal"].join("") },
35
+ // Cloud CLI deletions
36
+ { re: new RegExp(`\\baz\\s+\\S+\\s+(?:${W_DEL}|${W_PURGE})\\b`), what: ["Azure ", "resource deletion"].join("") },
37
+ { re: new RegExp(`\\baz\\s+(?:devops|repos|pipelines|boards|artifacts)\\s+[\\w-]*\\s*(?:${W_DEL}|${W_ABANDON})\\b`), what: ["Azure ", "DevOps deletion"].join("") },
38
+ { re: new RegExp(`\\baws\\s+\\S+\\s+(?:${W_DEL}|${W_TERM})-?\\w*\\b`), what: ["AWS ", "resource deletion"].join("") },
39
+ { re: new RegExp(`\\bgcloud\\s+\\S+\\s+(?:${W_DEL}|${W_ABANDON})\\b`), what: ["GCP ", "resource deletion"].join("") },
40
+ // Hosted repo / PR destruction via CLI
41
+ { re: new RegExp(`\\bgh\\s+(?:repo|issue|pr|release|secret|variable)\\s+(?:${W_DEL}|close)\\b`), what: ["gh ", "destructive command"].join("") },
42
+ // Database destruction via CLI clients
43
+ { re: new RegExp(`\\b(?:psql|mysql|mariadb|mongosh|mongo|redis-cli|sqlite3)\\b[^|;&]*\\b(?:${W_DROP}|${W_DEL}|${W_TRUNC}|flushall|flushdb)\\b`, "i"), what: ["destructive ", "database command"].join("") },
44
+ { re: new RegExp(`\\b(?:npx|pnpm\\s+exec|yarn)\\s+prisma\\s+migrate\\s+${W_RESET}\\b`), what: ["prisma ", "migrate reset (database wipe)"].join("") },
45
+ { re: new RegExp(`\\bprisma\\s+migrate\\s+${W_RESET}\\b`), what: ["prisma ", "migrate reset (database wipe)"].join("") },
46
+ // Destructive remote HTTP calls
47
+ { re: new RegExp(`\\bcurl\\b(?=[^|;&]*(?:(?<!\\S)(?:-X|--request)\\s*=?\\s*${W_HTTP_DEL}))(?=[^|;&]*https?:\\/\\/(?!localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]))`), what: ["remote ", "HTTP DELETE"].join("") },
48
+ { re: /\b(?:curl|wget)\b[^;&]*\|\s*(?:bash|sh|zsh)\b/, what: ["remote ", "download piped directly to a shell"].join("") },
49
+ // Destructive git operations
50
+ { re: /\bgit\s+push\b[^|;&]*(?:--force\b|--force-with-lease\b|\s-f\b)/, what: ["force ", "push"].join("") },
51
+ ];
52
+
53
+ export function liveMutationIn(command: string): string | undefined {
54
+ for (const { re, what } of LIVE_MUTATION_PATTERNS) {
55
+ if (re.test(command)) {
56
+ return what;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export function extractEditContent(input: unknown): string[] {
63
+ const record = asRecord(input);
64
+ if (!record) return [];
65
+ const contents: string[] = [];
66
+ for (const key of ["content", "newString", "patchText", "patch", "diff"]) {
67
+ if (typeof record[key] === "string") contents.push(record[key] as string);
68
+ }
69
+ if (Array.isArray(record.changes)) {
70
+ for (const change of record.changes) {
71
+ const r = asRecord(change);
72
+ if (r && typeof r.content === "string") contents.push(r.content);
73
+ }
74
+ }
75
+ return contents;
76
+ }
@@ -0,0 +1,53 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { isDocumentationRequired } from "../lib/state.ts";
3
+
4
+ export { isDocumentationRequired };
5
+
6
+ /**
7
+ * True for user-facing documentation files: README.md (root or package
8
+ * level) and anything under a docs/ directory. Deliberately excludes
9
+ * arbitrary markdown (e.g. .changeset/*.md, CHANGELOG.md) so the
10
+ * documentation gate cannot be satisfied by a changeset fragment.
11
+ */
12
+ function isDocumentationFile(filePath: string): boolean {
13
+ const lower = filePath.toLowerCase();
14
+ if (lower === "readme.md" || lower.endsWith("/readme.md")) return true;
15
+ if (lower === "docs" || lower.startsWith("docs/") || lower.includes("/docs/")) return true;
16
+ return false;
17
+ }
18
+
19
+ export function branchHasDocumentationChange(root: string): boolean {
20
+ try {
21
+ const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
22
+ for (const base of baseCandidates) {
23
+ const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], {
24
+ cwd: root,
25
+ encoding: "utf8",
26
+ timeout: 5_000,
27
+ });
28
+ if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
29
+ const diff = spawnSync(
30
+ "git",
31
+ ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`],
32
+ { cwd: root, encoding: "utf8", timeout: 8_000 },
33
+ );
34
+ if (diff.status !== 0) continue;
35
+ const files = diff.stdout.split("\n").filter(Boolean);
36
+ if (files.some((f) => isDocumentationFile(f))) {
37
+ return true;
38
+ }
39
+ }
40
+ const last = spawnSync("git", ["diff", "--name-only", "HEAD~1"], {
41
+ cwd: root,
42
+ encoding: "utf8",
43
+ timeout: 5_000,
44
+ });
45
+ if (last.status === 0) {
46
+ const files = last.stdout.split("\n").filter(Boolean);
47
+ return files.some((f) => isDocumentationFile(f));
48
+ }
49
+ return false;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
@@ -0,0 +1,288 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import type { GitInvocation } from "../lib/types.ts";
5
+ import {
6
+ getWorkspaceRoot,
7
+ getProjectConfig,
8
+ } from "../lib/state.ts";
9
+ import {
10
+ unwrapShellWords,
11
+ splitShellSegments,
12
+ } from "../lib/utils.ts";
13
+
14
+ export const PROTECTED_BRANCHES = new Set(["main", "master"]);
15
+
16
+ export const PUSH_TO_MAIN_RE =
17
+ /\bgit\s+push\b[^|;&]*(?:^|\s)\+?[\w./-]*:(?:main|master)(?![\w./-])|\bgit\s+push\b[^|;&]*(?:\s|\/)(?:main|master)(?![\w./-])/;
18
+
19
+ export const GIT_BRANCH_CREATE_RE =
20
+ /\bgit\s+(?:checkout\s+-b|switch\s+(?:-c|--create))\b/;
21
+
22
+ const GIT_DIR_OPTION_TAKES_VALUE = new Set([
23
+ "-C",
24
+ "--git-dir",
25
+ "--work-tree",
26
+ "-c",
27
+ "--config-env",
28
+ "--namespace",
29
+ ]);
30
+ const GIT_DIR_OPTION_PREFIXED =
31
+ /^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
32
+ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
33
+ "--version",
34
+ "--help",
35
+ "--no-pager",
36
+ "-p",
37
+ "--paginate",
38
+ "--bare",
39
+ "--literal-pathspecs",
40
+ "--glob-pathspecs",
41
+ "--noglob-pathspecs",
42
+ "--icase-pathspecs",
43
+ "--no-optional-locks",
44
+ "--exec-path",
45
+ ]);
46
+
47
+ export function parseGitInvocation(command: string): GitInvocation | undefined {
48
+ const root = getWorkspaceRoot();
49
+ const tokens = unwrapShellWords(command);
50
+ if (tokens[0] !== "git") return undefined;
51
+ let i = 1;
52
+ let repoDir = root;
53
+ let sawDirOption = false;
54
+ while (i < tokens.length) {
55
+ const tok = tokens[i]!;
56
+ if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
57
+ const value = tokens[i + 1];
58
+ if (value === undefined) return { repoDir, rest: "" };
59
+ if (tok === "-C" || tok === "--git-dir") {
60
+ repoDir = resolve(root, value);
61
+ sawDirOption = true;
62
+ }
63
+ i += 2;
64
+ continue;
65
+ }
66
+ if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
67
+ if (tok.startsWith("--git-dir=")) {
68
+ repoDir = resolve(root, tok.slice("--git-dir=".length));
69
+ sawDirOption = true;
70
+ }
71
+ i += 1;
72
+ continue;
73
+ }
74
+ if (GIT_GLOBAL_BOOLEAN_OPTIONS.has(tok)) {
75
+ i += 1;
76
+ continue;
77
+ }
78
+ break;
79
+ }
80
+ if (!sawDirOption) repoDir = root;
81
+ return { repoDir, rest: tokens.slice(i).join(" ") };
82
+ }
83
+
84
+ export function normalizeGitCommands(command: string): string {
85
+ return splitShellSegments(command)
86
+ .map((segment) => {
87
+ const parsed = parseGitInvocation(segment);
88
+ return parsed ? `git ${parsed.rest}` : segment;
89
+ })
90
+ .join(" ; ");
91
+ }
92
+
93
+ export const GIT_WRITE_RE =
94
+ /\bgit\s+(commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
95
+
96
+ export function currentGitBranch(root: string): string | undefined {
97
+ const result = spawnSync("git", ["branch", "--show-current"], {
98
+ cwd: root,
99
+ encoding: "utf8",
100
+ timeout: 10_000,
101
+ });
102
+ if (result.status === 0 && result.stdout.trim()) {
103
+ return result.stdout.trim();
104
+ }
105
+ try {
106
+ const head = readFileSync(resolve(root, ".git", "HEAD"), "utf8").trim();
107
+ const match = head.match(/^ref:\s+refs\/heads\/(\S+)/);
108
+ if (match) return match[1];
109
+ } catch {}
110
+ if (result.status === 0) return "";
111
+ return undefined;
112
+ }
113
+
114
+ export function getProtectedBranches(root: string): Set<string> {
115
+ const cfg = getProjectConfig(root);
116
+ const customBranches = Array.isArray(cfg.protectedBranches)
117
+ ? cfg.protectedBranches
118
+ : [];
119
+ return new Set([...PROTECTED_BRANCHES, ...customBranches]);
120
+ }
121
+
122
+ /** True when `branch` is protected for the repository at `root`. */
123
+ export function isProtectedBranchName(branch: string, root: string): boolean {
124
+ if (!branch) return false;
125
+ return getProtectedBranches(root).has(branch);
126
+ }
127
+
128
+ function escapeRegExp(text: string): string {
129
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
130
+ }
131
+
132
+ /**
133
+ * Returns the protected branch a `git push` command targets as its destination,
134
+ * if any. Covers bare refspecs (`git push origin <branch>`) and colon forms
135
+ * (`git push origin HEAD:<branch>`, `:<branch>` deletion, `+<branch>` forced),
136
+ * for every protected branch - built-in and configured - of the repository.
137
+ */
138
+ export function pushedProtectedBranchIn(pushCommand: string, root: string): string | undefined {
139
+ for (const branch of getProtectedBranches(root)) {
140
+ const escaped = escapeRegExp(branch);
141
+ const re = new RegExp(
142
+ `(?:^|\\s)\\+?[\\w./-]*:${escaped}(?![\\w./-])` +
143
+ `|(?:\\s|\\/)${escaped}(?![\\w./-])`,
144
+ );
145
+ if (re.test(pushCommand)) return branch;
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ export function onProtectedBranch(root: string): boolean {
151
+ return isProtectedBranchName(currentGitBranch(root) ?? "", root);
152
+ }
153
+
154
+ export function branchGuardReason(): string {
155
+ return (
156
+ "Blocked: the workspace is on a protected branch. " +
157
+ "Create a feature branch first - e.g. " +
158
+ "`git switch -c feat/description` - and make all changes there, " +
159
+ "then open a PR. Direct changes on protected branches are not allowed."
160
+ );
161
+ }
162
+
163
+ export function isBranchAlreadyMergedOrClosed(
164
+ root: string,
165
+ branch: string,
166
+ ): { merged: boolean; reason?: string } {
167
+ if (!branch || PROTECTED_BRANCHES.has(branch)) {
168
+ return { merged: false };
169
+ }
170
+
171
+ for (const base of ["origin/HEAD", "origin/main", "origin/master", "main", "master"]) {
172
+ const check = spawnSync("git", ["merge-base", "--is-ancestor", branch, base], {
173
+ cwd: root,
174
+ encoding: "utf8",
175
+ timeout: 5_000,
176
+ });
177
+ if (check.status === 0) {
178
+ const unmerged = spawnSync("git", ["rev-list", `${base}..${branch}`, "--count"], {
179
+ cwd: root,
180
+ encoding: "utf8",
181
+ timeout: 5_000,
182
+ });
183
+ if (unmerged.status === 0 && unmerged.stdout.trim() === "0") {
184
+ return {
185
+ merged: true,
186
+ reason: `Branch '${branch}' is already merged into '${base}'. Create a fresh feature branch for new changes.`,
187
+ };
188
+ }
189
+ }
190
+ }
191
+
192
+ try {
193
+ const ghRes = spawnSync(
194
+ "gh",
195
+ ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,title", "--limit", "1"],
196
+ { cwd: root, encoding: "utf8", timeout: 8_000 },
197
+ );
198
+ if (ghRes.status === 0 && ghRes.stdout.trim()) {
199
+ const prs = JSON.parse(ghRes.stdout.trim());
200
+ if (Array.isArray(prs) && prs.length > 0) {
201
+ const pr = prs[0];
202
+ if (pr && (pr.state === "MERGED" || pr.state === "CLOSED")) {
203
+ return {
204
+ merged: true,
205
+ reason: `Branch '${branch}' is associated with an already ${pr.state.toLowerCase()} GitHub PR (#${pr.number}: ${pr.title ?? ""}). Create a fresh feature branch for new changes.`,
206
+ };
207
+ }
208
+ }
209
+ }
210
+ } catch {}
211
+
212
+ try {
213
+ const azRes = spawnSync(
214
+ "az",
215
+ ["repos", "pr", "list", "--source-branch", branch, "--status", "all", "--query", "[0].{id:pullRequestId, status:status, title:title}", "-o", "json"],
216
+ { cwd: root, encoding: "utf8", timeout: 8_000 },
217
+ );
218
+ if (azRes.status === 0 && azRes.stdout.trim()) {
219
+ const pr = JSON.parse(azRes.stdout.trim());
220
+ if (pr && typeof pr === "object" && (pr.status === "completed" || pr.status === "abandoned")) {
221
+ return {
222
+ merged: true,
223
+ reason: `Branch '${branch}' is associated with an already ${pr.status} Azure DevOps PR (#${pr.id}: ${pr.title ?? ""}). Create a fresh feature branch for new changes.`,
224
+ };
225
+ }
226
+ }
227
+ } catch {}
228
+
229
+ return { merged: false };
230
+ }
231
+
232
+ export function checkMergeConflicts(root: string): {
233
+ hasConflicts: boolean;
234
+ baseBranch?: string;
235
+ reason?: string;
236
+ } {
237
+ const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
238
+ for (const base of candidates) {
239
+ const mergeBaseRes = spawnSync("git", ["merge-base", "HEAD", base], {
240
+ cwd: root,
241
+ encoding: "utf8",
242
+ timeout: 5_000,
243
+ });
244
+ if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
245
+ const mergeBase = mergeBaseRes.stdout.trim();
246
+
247
+ const treeRes = spawnSync("git", ["merge-tree", mergeBase, "HEAD", base], {
248
+ cwd: root,
249
+ encoding: "utf8",
250
+ timeout: 10_000,
251
+ });
252
+ if (treeRes.status === 0 && treeRes.stdout.includes("<<<<<<<")) {
253
+ return {
254
+ hasConflicts: true,
255
+ baseBranch: base,
256
+ reason: `Branch has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
257
+ };
258
+ }
259
+ }
260
+ return { hasConflicts: false };
261
+ }
262
+
263
+ export function checkBranchBaseIsUpToDate(root: string): {
264
+ isBehind: boolean;
265
+ count?: number;
266
+ baseRef?: string;
267
+ reason?: string;
268
+ } {
269
+ for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
270
+ const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
271
+ cwd: root,
272
+ encoding: "utf8",
273
+ timeout: 5_000,
274
+ });
275
+ if (res.status === 0 && res.stdout.trim()) {
276
+ const count = parseInt(res.stdout.trim(), 10);
277
+ if (!isNaN(count) && count > 0) {
278
+ return {
279
+ isBehind: true,
280
+ count,
281
+ baseRef: base,
282
+ reason: `Local base branch is ${count} commit(s) behind remote (${base}). Run 'git pull' or 'git fetch' on main before creating a fresh feature branch to prevent upstream conflicts.`,
283
+ };
284
+ }
285
+ }
286
+ }
287
+ return { isBehind: false };
288
+ }
@@ -0,0 +1,28 @@
1
+ export function extractInterpreterPayload(segment: string): string[] {
2
+ const payloads: string[] = [];
3
+ const inlineMatch = segment.match(
4
+ /\b(?:python3?|node|perl|ruby|osascript)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
5
+ );
6
+ if (inlineMatch?.[1] || inlineMatch?.[2]) {
7
+ payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
8
+ }
9
+ const psMatch = segment.match(
10
+ /\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
11
+ );
12
+ if (psMatch?.[1]) {
13
+ try {
14
+ const buf = Buffer.from(psMatch[1], "base64");
15
+ payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
16
+ } catch {}
17
+ }
18
+ const b64PipeMatch = segment.match(
19
+ /echo\s+["']?([A-Za-z0-9+/=]{4,})["']?\s*\|\s*base64\s+(?:-[a-zA-Z]*d[a-zA-Z]*|--decode)\s*\|\s*(?:bash|sh|zsh)/i,
20
+ );
21
+ if (b64PipeMatch?.[1]) {
22
+ try {
23
+ const buf = Buffer.from(b64PipeMatch[1], "base64");
24
+ payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
25
+ } catch {}
26
+ }
27
+ return payloads;
28
+ }
@@ -0,0 +1,25 @@
1
+ export const MCP_MUTATION_VERB_RE =
2
+ /(?:^|_)(?:create|update|delete|remove|merge|push|close|edit|set|fork|trigger|cancel|rerun|add|assign|approve|complete|abandon)(?:_|$)/;
3
+ export const MCP_READ_ONLY_RE =
4
+ /(?:^|_)(?:get|list|search|show|query|describe|find|read|status|diff|log)(?:_|$)/;
5
+
6
+ export const GUARDED_MCP_SERVERS = new Set([
7
+ "github",
8
+ "gh",
9
+ "azure",
10
+ "azmcp",
11
+ "ado",
12
+ "devops",
13
+ "azuredevops",
14
+ ]);
15
+
16
+ export function mcpMutationTool(toolName: string): string | undefined {
17
+ if (!MCP_MUTATION_VERB_RE.test(toolName) || MCP_READ_ONLY_RE.test(toolName)) {
18
+ return undefined;
19
+ }
20
+ const tokens = toolName.toLowerCase().split(/[^a-z0-9]+/);
21
+ if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t))) {
22
+ return "GitHub/Azure DevOps";
23
+ }
24
+ return undefined;
25
+ }
@@ -0,0 +1,133 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+ import { getWorkspaceRoot } from "../lib/state.ts";
4
+
5
+ export const SAFE_ENV_FIXTURE_RE =
6
+ /\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
7
+
8
+ export interface SecretPattern {
9
+ re: RegExp;
10
+ what: string;
11
+ }
12
+
13
+ export const SECRET_PATTERNS: SecretPattern[] = [
14
+ { re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, what: "private key material" },
15
+ { re: /\bAKIA[0-9A-Z]{16}\b/, what: "AWS access key ID" },
16
+ { re: /\bASIA[0-9A-Z]{16}\b/, what: "AWS temporary session credential" },
17
+ { re: /\bghp_[A-Za-z0-9]{36}\b/, what: "GitHub personal access token" },
18
+ { re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/, what: "GitHub fine-grained PAT" },
19
+ { re: /\bgho_[A-Za-z0-9]{36}\b/, what: "GitHub OAuth token" },
20
+ { re: /\bghu_[A-Za-z0-9]{36}\b/, what: "GitHub user-to-server token" },
21
+ { re: /\bghs_[A-Za-z0-9]{36}\b/, what: "GitHub server-to-server token" },
22
+ { re: /\bghr_[A-Za-z0-9]{36}\b/, what: "GitHub refresh token" },
23
+ { re: /\bsk-[A-Za-z0-9]{20,}\b/, what: "OpenAI-style API key" },
24
+ { re: /\bog-[A-Za-z0-9]{20,}\b/, what: "OpenCode/legacy API key" },
25
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/, what: "Google API key" },
26
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, what: "Slack token (xox family)" },
27
+ { re: /(?:^|\s)(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)\s*=\s*\S+/, what: "AWS credential assignment" },
28
+ { re: /(?:^|\s)(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENAI_KEY)\s*=\s*\S+/, what: "LLM API key assignment" },
29
+ ];
30
+
31
+ export function secretIn(content: string): string | undefined {
32
+ for (const { re, what } of SECRET_PATTERNS) {
33
+ if (re.test(content)) return what;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ export function isSecretPath(targetPath: string): boolean {
39
+ if (!targetPath) return false;
40
+ const root = getWorkspaceRoot();
41
+ const resolved = resolve(root, targetPath);
42
+ const matches = (path: string): boolean => {
43
+ const base = basename(path).toLowerCase();
44
+ const full = path.toLowerCase();
45
+
46
+ // Safe fixtures
47
+ if (SAFE_ENV_FIXTURE_RE.test(base)) {
48
+ return false;
49
+ }
50
+
51
+ // .env files (e.g. .env, .env.local, .env.prod, .env.secret)
52
+ if (/^\.env(?:\.|$)/i.test(base)) {
53
+ return true;
54
+ }
55
+
56
+ // SSH & TLS key material
57
+ if (
58
+ /^(id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base) ||
59
+ /\.(pem|key|pkcs12|pfx|p12)$/i.test(base)
60
+ ) {
61
+ return true;
62
+ }
63
+
64
+ // Cloud / cluster / service account credentials
65
+ if (
66
+ /kubeconfig/i.test(base) ||
67
+ full.includes("/.kube/config") ||
68
+ /^(service[-_]?account|client[-_]?secret).*\.json$/i.test(base) ||
69
+ /credentials\.json$/i.test(base) ||
70
+ full.includes("/.aws/credentials") ||
71
+ full.includes("/.docker/config.json") ||
72
+ base === ".netrc" ||
73
+ base === ".git-credentials"
74
+ ) {
75
+ return true;
76
+ }
77
+
78
+ return false;
79
+ };
80
+
81
+ if (matches(resolved)) return true;
82
+ try {
83
+ return matches(realpathSync(resolved));
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Returns true specifically for environment variable files (.env, .env.local,
91
+ * etc.) that can safely present their schema (masked keys) to agents without
92
+ * exposing secret values.
93
+ */
94
+ export function isEnvFilePath(targetPath: string): boolean {
95
+ if (!targetPath) return false;
96
+ const base = basename(targetPath).toLowerCase();
97
+ if (SAFE_ENV_FIXTURE_RE.test(base)) return false;
98
+ return /^\.env(?:\.|$)/i.test(base);
99
+ }
100
+
101
+ /**
102
+ * Parses a raw .env file and returns a sanitized schema mask where variable
103
+ * names and comment structures are preserved, but sensitive values are
104
+ * redacted to '********'.
105
+ */
106
+ export function generateMaskedEnvSchema(content: string): string {
107
+ const lines = content.split("\n");
108
+ return lines
109
+ .map((line) => {
110
+ const trimmed = line.trim();
111
+ if (!trimmed || trimmed.startsWith("#")) return line;
112
+ const eqIdx = line.indexOf("=");
113
+ if (eqIdx === -1) return line;
114
+ const key = line.slice(0, eqIdx);
115
+ return `${key}=********`;
116
+ })
117
+ .join("\n");
118
+ }
119
+
120
+ export const SECRET_READ_COMMAND_RE =
121
+ /(?:^|\s)(?:cat|head|tail|less|more|grep|awk|sed|od|hexdump|strings|base64|xxd|nl|sort|uniq|view|nano|vim?)\s+[^|;&]*?(?:["']?)([\w\/.~-]*\.(?:pem|key|pfx|p12)|[\w\/.~-]*\.env(?:\.[\w-]+)*|[\w\/.~-]*id_(?:rsa|dsa|ecdsa|ed25519)[\w.-]*|[\w\/.~-]*kubeconfig[\w.-]*|[\w\/.~-]*(?:service[-_]?account|credentials|client[-_]?secret)[\w.-]*\.json)(?:["']?)/i;
122
+ export const SIMPLE_FILE_READ_COMMAND_RE =
123
+ /(?:^|\s)(?:cat|head|tail|less|more|od|hexdump|strings|base64|xxd|nl|view|nano|vim?)\s+(?:-\S+\s+)*["']?([^\s|;&"']+)/i;
124
+
125
+ export function secretFileReadIn(segment: string): string | undefined {
126
+ const match = segment.match(SECRET_READ_COMMAND_RE);
127
+ if (match?.[1] && isSecretPath(match[1])) {
128
+ return match[1];
129
+ }
130
+ const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
131
+ if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
132
+ return undefined;
133
+ }