opencode-workflow-guard 1.1.5 → 1.3.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/README.md +6 -3
- package/package.json +1 -1
- package/src/lib/audit.ts +110 -0
- package/src/lib/review.ts +45 -0
- package/src/lib/state.ts +210 -0
- package/src/lib/types.ts +75 -0
- package/src/lib/utils.ts +179 -0
- package/src/lib/verify.ts +169 -0
- package/src/lib/worktree.ts +216 -0
- package/src/policies/boundary.ts +248 -0
- package/src/policies/changelog.ts +80 -0
- package/src/policies/completion.ts +90 -0
- package/src/policies/destructive.ts +76 -0
- package/src/policies/docs.ts +53 -0
- package/src/policies/git.ts +288 -0
- package/src/policies/interpreter.ts +28 -0
- package/src/policies/mcp.ts +25 -0
- package/src/policies/secrets.ts +133 -0
- package/src/policies/shell-safety.ts +128 -0
- package/src/policies/tamper.ts +86 -0
- package/src/policies/todo.ts +106 -0
- package/src/workflow-guard-ui.ts +59 -3
- package/src/workflow-guard.ts +416 -2450
|
@@ -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,90 @@
|
|
|
1
|
+
import { sessionVerifyResults, lastVerify, sessionMutationTimestamps, lastMutationTimestamp } from "../lib/state.ts";
|
|
2
|
+
import type { VerifyResult } from "../lib/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claims-vs-evidence check (Policy 24): when the assistant's final text
|
|
6
|
+
* asserts that work is complete or that tests pass, compare the claim against
|
|
7
|
+
* recorded verification evidence. This is observability, not gating - the
|
|
8
|
+
* hook audits mismatches so a confident wrap-up cannot silently contradict a
|
|
9
|
+
* failing or stale verification state.
|
|
10
|
+
*
|
|
11
|
+
* Heuristic by design: natural-language claims are matched on explicit
|
|
12
|
+
* completion/verification phrases. Only high-signal assertions
|
|
13
|
+
* ("all tests pass", "done", "verified", "green build") count; casual usage
|
|
14
|
+
* of the same words ("testing the fix", "it passed earlier") should not trip
|
|
15
|
+
* the detector on multi-sentence responses without assertion framing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const COMPLETION_CLAIM_RE =
|
|
19
|
+
/\b(?:all\s+(?:tests?|checks?)\s+(?:pass(?:es|ed|ing)?|green|succeed(?:ed|ing)?)|tests?\s+(?:are\s+)?(?:all\s+)?(?:pass(?:ing|ed)?|green)|build\s+(?:is\s+)?green|(?:successfully\s+)?verif(?:ied|ication\s+(?:passed|succeeded|is\s+clean))|(?:work|task|implementation)\s+is\s+(?:complete|done|finished)|everything\s+(?:is\s+)?(?:done|complete|works)|(?:all|every)\s+(?:task|item)s?\s+(?:is|are)\s+(?:done|complete(?:d)?))\b/i;
|
|
20
|
+
|
|
21
|
+
const VERIFICATION_FRESH_WINDOW_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
export interface CompletionClaimCheck {
|
|
24
|
+
claimsCompletion: boolean;
|
|
25
|
+
claim?: string;
|
|
26
|
+
evidenceState?: "fresh-pass" | "stale-pass" | "failing" | "missing";
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function checkCompletionClaims(
|
|
31
|
+
text: string,
|
|
32
|
+
context?: { sessionID?: string },
|
|
33
|
+
): CompletionClaimCheck {
|
|
34
|
+
if (!text) return { claimsCompletion: false };
|
|
35
|
+
|
|
36
|
+
const match = text.match(COMPLETION_CLAIM_RE);
|
|
37
|
+
if (!match) return { claimsCompletion: false };
|
|
38
|
+
|
|
39
|
+
// Session-scoped evidence wins when the session has its own entry OR has
|
|
40
|
+
// recorded mutations (evidence was invalidated); otherwise fall back to the
|
|
41
|
+
// most recent global result.
|
|
42
|
+
const sessionHadEvidence =
|
|
43
|
+
context?.sessionID !== undefined &&
|
|
44
|
+
(sessionVerifyResults.has(context.sessionID) ||
|
|
45
|
+
(sessionMutationTimestamps.get(context.sessionID) ?? 0) > 0);
|
|
46
|
+
const verifyResult: VerifyResult | undefined = context?.sessionID
|
|
47
|
+
? (sessionVerifyResults.get(context.sessionID) ??
|
|
48
|
+
(sessionHadEvidence ? undefined : lastVerify))
|
|
49
|
+
: lastVerify;
|
|
50
|
+
|
|
51
|
+
let evidenceState: CompletionClaimCheck["evidenceState"];
|
|
52
|
+
const mutationTimestamp = context?.sessionID
|
|
53
|
+
? (sessionMutationTimestamps.get(context.sessionID) ?? 0)
|
|
54
|
+
: lastMutationTimestamp;
|
|
55
|
+
if (!verifyResult) {
|
|
56
|
+
// A session-scoped lookup that was invalidated by a later mutation is
|
|
57
|
+
// stale, not merely missing - the agent verified and then changed code.
|
|
58
|
+
evidenceState =
|
|
59
|
+
context?.sessionID && mutationTimestamp > 0 ? "stale-pass" : "missing";
|
|
60
|
+
} else if (!verifyResult.passed) {
|
|
61
|
+
evidenceState = "failing";
|
|
62
|
+
} else if (
|
|
63
|
+
verifyResult.timestamp < mutationTimestamp ||
|
|
64
|
+
Date.now() - verifyResult.timestamp > VERIFICATION_FRESH_WINDOW_MS
|
|
65
|
+
) {
|
|
66
|
+
evidenceState = "stale-pass";
|
|
67
|
+
} else {
|
|
68
|
+
evidenceState = "fresh-pass";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (evidenceState === "fresh-pass") {
|
|
72
|
+
return { claimsCompletion: true, claim: match[0], evidenceState };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reasonByState = {
|
|
76
|
+
missing:
|
|
77
|
+
"the response asserts completion/verification, but no verification evidence has been recorded in this session.",
|
|
78
|
+
failing: `the response asserts completion, but the latest verification (${verifyResult?.command}) FAILED.`,
|
|
79
|
+
"stale-pass":
|
|
80
|
+
"the response asserts completion, but the last passing verification is stale - mutations were recorded after it.",
|
|
81
|
+
"fresh-pass": "",
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
claimsCompletion: true,
|
|
86
|
+
claim: match[0],
|
|
87
|
+
evidenceState,
|
|
88
|
+
reason: reasonByState[evidenceState],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -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
|
+
}
|