opencode-worktree-guardian 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/codex/.codex-plugin/plugin.json +31 -0
- package/codex/hooks/guardian-hook.ts +265 -0
- package/codex/hooks/hooks.json +30 -0
- package/codex/skills/worktree-guardian/SKILL.md +29 -0
- package/commands/delete-paths.md +12 -0
- package/commands/delete-worktree.md +16 -0
- package/commands/done.md +14 -0
- package/commands/finish-workflow.md +18 -0
- package/commands/finish.md +16 -0
- package/commands/hygiene.md +16 -0
- package/commands/preserve.md +14 -0
- package/commands/recover.md +14 -0
- package/commands/report.md +14 -0
- package/commands/start.md +14 -0
- package/commands/status.md +14 -0
- package/commands/unblock-finish.md +16 -0
- package/docs/adr/0001-guardian-safety-policy.md +192 -0
- package/docs/publishing.md +55 -0
- package/docs/release-checklist.md +84 -0
- package/package.json +72 -0
- package/scripts/readiness.ts +75 -0
- package/scripts/with-safe-node-temp.mjs +78 -0
- package/skills/worktree-guardian/SKILL.md +73 -0
- package/src/config.ts +87 -0
- package/src/delete-paths-apply.ts +77 -0
- package/src/delete-paths-preflight.ts +146 -0
- package/src/delete-paths.ts +1 -0
- package/src/delete-worktree-preflight.ts +25 -0
- package/src/delete-worktree-report.ts +70 -0
- package/src/delete-worktree-targets.ts +152 -0
- package/src/delete-worktree.ts +222 -0
- package/src/delete.ts +1 -0
- package/src/deletion-fingerprint.ts +59 -0
- package/src/done-primary-publish.ts +129 -0
- package/src/done-primary-snapshot.ts +79 -0
- package/src/done-reattach.ts +32 -0
- package/src/done-shared.ts +28 -0
- package/src/done.ts +80 -0
- package/src/filesystem-boundaries.ts +49 -0
- package/src/finish-dirty-files.ts +56 -0
- package/src/finish-report.ts +80 -0
- package/src/finish.ts +212 -0
- package/src/git.ts +288 -0
- package/src/guards/allowlists.ts +83 -0
- package/src/guards/classifier.ts +39 -0
- package/src/guards/destructive-classifier.ts +189 -0
- package/src/guards/git-invocation.ts +145 -0
- package/src/guards/guard-types.ts +36 -0
- package/src/guards/options.ts +15 -0
- package/src/guards/path-policy.ts +31 -0
- package/src/guards/protected-branch-policy.ts +88 -0
- package/src/guards/shell-parser.ts +126 -0
- package/src/guards/shell-prefix.ts +100 -0
- package/src/guards.ts +3 -0
- package/src/hygiene-apply.ts +230 -0
- package/src/hygiene-scan.ts +200 -0
- package/src/hygiene.ts +10 -0
- package/src/index.ts +18 -0
- package/src/lifecycle.ts +31 -0
- package/src/plugin/direct-file-routing.ts +68 -0
- package/src/plugin/event-log.ts +60 -0
- package/src/plugin/guard-context.ts +40 -0
- package/src/plugin/hook-context.ts +35 -0
- package/src/plugin/invisible-policy.ts +28 -0
- package/src/plugin/native-tool.ts +82 -0
- package/src/plugin/plan-token-cache.ts +66 -0
- package/src/plugin/readable-output-cleanup.ts +141 -0
- package/src/plugin/readable-output-status.ts +86 -0
- package/src/plugin/readable-output-values.ts +21 -0
- package/src/plugin/readable-output-workflow.ts +70 -0
- package/src/plugin/readable-output.ts +16 -0
- package/src/plugin/server.ts +257 -0
- package/src/plugin/session-routing.ts +74 -0
- package/src/plugin/slash-commands.ts +20 -0
- package/src/preserve.ts +32 -0
- package/src/recover.ts +195 -0
- package/src/report.ts +168 -0
- package/src/session/context.ts +41 -0
- package/src/session/last-safe-state.ts +27 -0
- package/src/session/worktree-binding.ts +161 -0
- package/src/start.ts +157 -0
- package/src/state.ts +197 -0
- package/src/tool-registry.ts +35 -0
- package/src/tools.ts +8 -0
- package/src/tui.ts +113 -0
- package/src/types.ts +339 -0
- package/src/unblock-finish.ts +298 -0
- package/src/workflow-candidates.ts +111 -0
- package/src/workflow.ts +84 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { gitMetadataFromError, withGitMetadata } from "./types.ts";
|
|
5
|
+
import type { ExecFileOptionsWithStringEncoding, SpawnOptionsWithoutStdio } from "node:child_process";
|
|
6
|
+
import type { GitCommandFailure, GitCommandOutput, WorktreeEntry } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export type TryGitResult =
|
|
11
|
+
| ({ readonly ok: true } & GitCommandOutput)
|
|
12
|
+
| ({ readonly ok: false; readonly error: GitCommandFailure } & GitCommandOutput);
|
|
13
|
+
|
|
14
|
+
export type GitStashEntry = { readonly name: string; readonly commit: string; readonly message: string };
|
|
15
|
+
export type GitRefEntry = { readonly name: string; readonly commit: string; readonly date: string; readonly subject: string };
|
|
16
|
+
export type GitBranchEntry = { readonly name: string; readonly commit: string };
|
|
17
|
+
export type GitCommitEntry = { readonly commit: string; readonly subject: string };
|
|
18
|
+
export type GitRecoveryCandidates = { readonly reflog: readonly (GitCommitEntry & { readonly selector: string })[]; readonly unreachable: readonly string[] };
|
|
19
|
+
type CreateSafetyRefOptions = { readonly sessionId?: unknown; readonly branch?: unknown; readonly commit?: string; readonly timestamp?: unknown };
|
|
20
|
+
type GitExecOptions = Omit<ExecFileOptionsWithStringEncoding, "encoding">;
|
|
21
|
+
type GitSpawnOptions = Omit<SpawnOptionsWithoutStdio, "stdio">;
|
|
22
|
+
|
|
23
|
+
export async function runGit(repoPath: string, args: readonly string[], options: GitExecOptions = {}): Promise<GitCommandOutput> {
|
|
24
|
+
try {
|
|
25
|
+
const { stdout, stderr } = await execFileAsync("git", ["-C", repoPath, ...args], {
|
|
26
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
...options,
|
|
29
|
+
});
|
|
30
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw withGitMetadata(error, gitMetadataFromError(args, error));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runGitNullSeparated(repoPath: string, args: readonly string[], options: GitSpawnOptions = {}): Promise<string[]> {
|
|
37
|
+
return new Promise<string[]>((resolve, reject) => {
|
|
38
|
+
const child = spawn("git", ["-C", repoPath, ...args], { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
39
|
+
const decoder = new StringDecoder("utf8");
|
|
40
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
41
|
+
const entries: string[] = [];
|
|
42
|
+
let current = "";
|
|
43
|
+
let stderr = "";
|
|
44
|
+
|
|
45
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
46
|
+
const text = decoder.write(chunk);
|
|
47
|
+
const parts = text.split("\0");
|
|
48
|
+
parts[0] = current + parts[0];
|
|
49
|
+
current = parts.pop() ?? "";
|
|
50
|
+
for (const part of parts) {
|
|
51
|
+
const entry = part.trim();
|
|
52
|
+
if (entry) entries.push(entry);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
57
|
+
stderr += stderrDecoder.write(chunk);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
61
|
+
reject(withGitMetadata(error, gitMetadataFromError(args, error, { stdout: "", stderr: stderr.trim() })));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
child.on("close", (code, signal) => {
|
|
65
|
+
const finalText = decoder.end();
|
|
66
|
+
if (finalText) current += finalText;
|
|
67
|
+
stderr += stderrDecoder.end();
|
|
68
|
+
const finalEntry = current.trim();
|
|
69
|
+
if (finalEntry) entries.push(finalEntry);
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
resolve(entries);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const error = new Error(`git ${args.join(" ")} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`);
|
|
75
|
+
reject(withGitMetadata(error, {
|
|
76
|
+
gitArgs: [...args],
|
|
77
|
+
gitStdout: "",
|
|
78
|
+
gitStderr: stderr.trim(),
|
|
79
|
+
...(typeof code === "number" ? { gitExitCode: code } : {}),
|
|
80
|
+
...(signal ? { gitSignal: signal } : {}),
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function tryGit(repoPath: string, args: readonly string[]): Promise<TryGitResult> {
|
|
87
|
+
try {
|
|
88
|
+
return { ok: true, ...(await runGit(repoPath, args)) };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const failure = withGitMetadata(error, gitMetadataFromError(args, error));
|
|
91
|
+
return { ok: false, error: failure, stdout: failure.gitStdout, stderr: failure.gitStderr };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getRepoRoot(cwd: string) {
|
|
96
|
+
return (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function getCommonGitDir(repoRoot: string) {
|
|
100
|
+
return (await runGit(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).stdout;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function getCurrentBranch(repoRoot: string) {
|
|
104
|
+
const result = await tryGit(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
105
|
+
return result.ok ? result.stdout : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function getHeadCommit(repoRoot: string) {
|
|
109
|
+
return (await runGit(repoRoot, ["rev-parse", "HEAD"])).stdout;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function getBranchCommit(repoRoot: string, branch: string) {
|
|
113
|
+
return (await runGit(repoRoot, ["rev-parse", "--verify", `refs/heads/${branch}^{commit}`])).stdout;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function getRefCommit(repoRoot: string, ref: string) {
|
|
117
|
+
return (await runGit(repoRoot, ["rev-parse", "--verify", `${ref}^{commit}`])).stdout;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function getStatusPorcelain(repoRoot: string) {
|
|
121
|
+
return (await runGit(repoRoot, ["status", "--porcelain"])).stdout;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function getDirtyFiles(repoRoot: string) {
|
|
125
|
+
const { stdout: status } = await execFileAsync("git", ["-C", repoRoot, "status", "--porcelain=v1", "--untracked-files=all", "-z"], { maxBuffer: 10 * 1024 * 1024 });
|
|
126
|
+
if (!status) return [];
|
|
127
|
+
const files: string[] = [];
|
|
128
|
+
const entries = status.split("\0").filter(Boolean);
|
|
129
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
130
|
+
const entry = entries[index];
|
|
131
|
+
const statusCode = entry.slice(0, 2);
|
|
132
|
+
const filePath = entry.slice(3);
|
|
133
|
+
if (filePath) files.push(filePath);
|
|
134
|
+
if (statusCode.includes("R") || statusCode.includes("C")) {
|
|
135
|
+
const sourcePath = entries[index + 1];
|
|
136
|
+
if (sourcePath) files.push(sourcePath);
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return [...new Set(files)];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function getIgnoredFiles(repoRoot: string) {
|
|
144
|
+
const status = (await runGit(repoRoot, ["status", "--porcelain", "--ignored"])).stdout;
|
|
145
|
+
if (!status) return [];
|
|
146
|
+
return status.split("\n").filter((line) => line.startsWith("!! ")).map((line) => line.slice(3)).filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function listStashes(repoRoot: string): Promise<GitStashEntry[]> {
|
|
150
|
+
const result = await tryGit(repoRoot, ["stash", "list", "--format=%gd%x00%H%x00%gs"]);
|
|
151
|
+
if (!result.ok || !result.stdout) return [];
|
|
152
|
+
return result.stdout.split("\n").map((line: string) => {
|
|
153
|
+
const [name, commit, message] = line.split("\0");
|
|
154
|
+
return { name, commit, message };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function listWorktrees(repoRoot: string): Promise<WorktreeEntry[]> {
|
|
159
|
+
const { stdout } = await runGit(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
160
|
+
if (!stdout) return [];
|
|
161
|
+
const entries: WorktreeEntry[] = [];
|
|
162
|
+
let current: { path: string; head?: string; branch?: string; detached?: boolean; bare?: boolean } | null = null;
|
|
163
|
+
for (const line of stdout.split("\n")) {
|
|
164
|
+
if (line.startsWith("worktree ")) {
|
|
165
|
+
if (current) entries.push(current);
|
|
166
|
+
current = { path: line.slice("worktree ".length) };
|
|
167
|
+
} else if (current && line.startsWith("HEAD ")) {
|
|
168
|
+
current.head = line.slice("HEAD ".length);
|
|
169
|
+
} else if (current && line.startsWith("branch ")) {
|
|
170
|
+
current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
171
|
+
} else if (current && line === "detached") {
|
|
172
|
+
current.detached = true;
|
|
173
|
+
} else if (current && line === "bare") {
|
|
174
|
+
current.bare = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (current) entries.push(current);
|
|
178
|
+
return entries;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function safeRefSegment(value: unknown) {
|
|
182
|
+
const segment = String(value ?? "")
|
|
183
|
+
.replace(/^refs\//, "")
|
|
184
|
+
.replace(/\.\.+/g, ".")
|
|
185
|
+
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
|
186
|
+
.replace(/^\/+|\/+$/g, "")
|
|
187
|
+
.replace(/\/+/g, "/");
|
|
188
|
+
return segment.length > 0 ? segment : "unknown";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function defaultRefTimestamp() {
|
|
192
|
+
return new Date().toISOString().replace(/[-:.]/g, "").slice(0, 15);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function safeRefTimestamp(timestamp: unknown) {
|
|
196
|
+
if (timestamp instanceof Date) return defaultRefTimestampFromDate(timestamp);
|
|
197
|
+
const stamp = safeRefSegment(timestamp);
|
|
198
|
+
return stamp === "unknown" ? defaultRefTimestamp() : stamp;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function defaultRefTimestampFromDate(timestamp: Date) {
|
|
202
|
+
return timestamp.toISOString().replace(/[-:.]/g, "").slice(0, 15);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildSafetyRef(sessionId: string, branch: string, timestamp: unknown = new Date()) {
|
|
206
|
+
const stamp = safeRefTimestamp(timestamp);
|
|
207
|
+
return `refs/opencode-guardian/${safeRefSegment(sessionId)}/${safeRefSegment(branch)}/${stamp}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function buildPreservedRef(sessionId: string, branch: string, timestamp: unknown = new Date()) {
|
|
211
|
+
const stamp = safeRefTimestamp(timestamp);
|
|
212
|
+
return `refs/opencode-guardian/preserved/${safeRefSegment(sessionId)}/${safeRefSegment(branch)}/${stamp}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function createRef(repoRoot: string, refName: string, commit = "HEAD") {
|
|
216
|
+
await runGit(repoRoot, ["update-ref", refName, commit]);
|
|
217
|
+
return refName;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function createSafetyRef(repoRoot: string, { sessionId, branch, commit = "HEAD", timestamp }: CreateSafetyRefOptions = {}) {
|
|
221
|
+
const ref = buildSafetyRef(String(sessionId ?? ""), String(branch ?? ""), timestamp);
|
|
222
|
+
await createRef(repoRoot, ref, commit);
|
|
223
|
+
return ref;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function listRefs(repoRoot: string, prefix: string): Promise<GitRefEntry[]> {
|
|
227
|
+
const result = await tryGit(repoRoot, ["for-each-ref", "--format=%(refname)%00%(objectname)%00%(committerdate:iso8601)%00%(subject)", prefix]);
|
|
228
|
+
if (!result.ok || !result.stdout) return [];
|
|
229
|
+
return result.stdout.split("\n").map((line: string) => {
|
|
230
|
+
const [name, commit, date, subject] = line.split("\0");
|
|
231
|
+
return { name, commit, date, subject };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function isAncestor(repoRoot: string, commit: string, ref: string) {
|
|
236
|
+
const result = await tryGit(repoRoot, ["merge-base", "--is-ancestor", commit, ref]);
|
|
237
|
+
return result.ok;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function listUnmergedCommits(repoRoot: string, head: string, baseRef: string): Promise<GitCommitEntry[]> {
|
|
241
|
+
const result = await runGit(repoRoot, ["log", "--format=%H%x00%s", `${baseRef}..${head}`]);
|
|
242
|
+
if (!result.stdout) return [];
|
|
243
|
+
return result.stdout.split("\n").map((line: string) => {
|
|
244
|
+
const [commit, subject] = line.split("\0");
|
|
245
|
+
return { commit, subject };
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function pushBranch(repoRoot: string, remote: string, branch: string) {
|
|
250
|
+
await runGit(repoRoot, ["push", "-u", remote, branch]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function fetchRemote(repoRoot: string, remote: string) {
|
|
254
|
+
await runGit(repoRoot, ["fetch", remote]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function removeWorktree(repoRoot: string, worktreePath: string) {
|
|
258
|
+
await runGit(repoRoot, ["worktree", "remove", worktreePath]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function deleteBranch(repoRoot: string, branch: string) {
|
|
262
|
+
await runGit(repoRoot, ["branch", "-d", "--", branch]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function abandonBranch(repoRoot: string, branch: string) {
|
|
266
|
+
await runGit(repoRoot, ["branch", "-D", "--", branch]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function listBranches(repoRoot: string): Promise<GitBranchEntry[]> {
|
|
270
|
+
const result = await tryGit(repoRoot, ["for-each-ref", "--format=%(refname:short)%00%(objectname)", "refs/heads"]);
|
|
271
|
+
if (!result.ok || !result.stdout) return [];
|
|
272
|
+
return result.stdout.split("\n").map((line: string) => {
|
|
273
|
+
const [name, commit] = line.split("\0");
|
|
274
|
+
return { name, commit };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function listRecoveryCandidates(repoRoot: string): Promise<GitRecoveryCandidates> {
|
|
279
|
+
const reflog = await tryGit(repoRoot, ["reflog", "--format=%H%x00%gd%x00%gs", "-n", "25"]);
|
|
280
|
+
const unreachable = await tryGit(repoRoot, ["fsck", "--no-reflogs", "--unreachable"]);
|
|
281
|
+
return {
|
|
282
|
+
reflog: reflog.ok && reflog.stdout ? reflog.stdout.split("\n").map((line: string) => {
|
|
283
|
+
const [commit, selector, subject] = line.split("\0");
|
|
284
|
+
return { commit, selector, subject };
|
|
285
|
+
}) : [],
|
|
286
|
+
unreachable: unreachable.stdout ? unreachable.stdout.split("\n").filter(Boolean) : [],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AllowDecision, GuardOptions } from "../types.ts";
|
|
2
|
+
import type { CommandSegment } from "./guard-types.ts";
|
|
3
|
+
import { hasAliasCapableRuntimeConfig, isForcePushToken, parseGitInvocation, pushRefspecs } from "./git-invocation.ts";
|
|
4
|
+
import { stringOption } from "./options.ts";
|
|
5
|
+
import { cdTarget, READ_ONLY_SHELL_COMMANDS, SHELL_COMMANDS, stripCommandWrappers } from "./shell-prefix.ts";
|
|
6
|
+
import { commandSegments, commandSegmentsWithSeparators, SEGMENT_BREAKS, tokenizeCommand } from "./shell-parser.ts";
|
|
7
|
+
|
|
8
|
+
export const STASH_READ_ONLY = new Set<string>(["list", "show"]);
|
|
9
|
+
const READ_ONLY_GIT_COMMANDS = new Set(["status", "diff", "log", "show", "rev-parse", "branch", "worktree", "stash", "remote", "ls-files"]);
|
|
10
|
+
const NORMAL_AGENT_GIT_COMMANDS = new Set(["add", "commit", "fetch", "push"]);
|
|
11
|
+
|
|
12
|
+
function hasUnsafeReadOnlySyntax(command: string, tokens: readonly string[]): boolean {
|
|
13
|
+
return command.includes("`") || command.includes("$(") || /[<>]/.test(command) || tokens.some((token) => SEGMENT_BREAKS.has(token));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAllowedReadOnlyGit(segment: CommandSegment): boolean {
|
|
17
|
+
const parsed = parseGitInvocation(segment);
|
|
18
|
+
if (!parsed?.subcommand || !READ_ONLY_GIT_COMMANDS.has(parsed.subcommand)) return false;
|
|
19
|
+
const { subcommand, rest } = parsed;
|
|
20
|
+
|
|
21
|
+
if (subcommand === "status") {
|
|
22
|
+
return rest.every((token) => token.startsWith("-") || token === "--");
|
|
23
|
+
}
|
|
24
|
+
if (subcommand === "branch") {
|
|
25
|
+
return rest.every((token) => token === "--list" || token === "--show-current" || token === "-a" || token === "-r" || token.startsWith("--format="));
|
|
26
|
+
}
|
|
27
|
+
if (subcommand === "worktree") {
|
|
28
|
+
return rest[0] === "list" && rest.slice(1).every((token) => token.startsWith("-"));
|
|
29
|
+
}
|
|
30
|
+
if (subcommand === "stash") {
|
|
31
|
+
const action = rest.find((token) => !token.startsWith("-")) ?? "list";
|
|
32
|
+
return STASH_READ_ONLY.has(action);
|
|
33
|
+
}
|
|
34
|
+
if (subcommand === "remote") {
|
|
35
|
+
return rest.length === 0 || rest.every((token) => token === "-v" || token === "--verbose");
|
|
36
|
+
}
|
|
37
|
+
if (subcommand === "diff") {
|
|
38
|
+
return !rest.some((token) => token === "--output" || token.startsWith("--output=") || token === "--ext-diff" || token === "--textconv");
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function classifyReadOnlyInspectionCommand(command: unknown): AllowDecision {
|
|
44
|
+
if (typeof command !== "string" || command.trim() === "") return { allowed: false, reason: "empty command" };
|
|
45
|
+
const tokens = tokenizeCommand(command);
|
|
46
|
+
if (hasUnsafeReadOnlySyntax(command, tokens)) return { allowed: false, reason: "compound or redirected shell syntax is not read-only allowlisted" };
|
|
47
|
+
const segment = commandSegments(tokens)[0] ?? [];
|
|
48
|
+
const stripped = stripCommandWrappers(segment);
|
|
49
|
+
if (stripped.length === 1 && READ_ONLY_SHELL_COMMANDS.has(stripped[0] ?? "")) return { allowed: true, reason: null };
|
|
50
|
+
if (SHELL_COMMANDS.has(stripped[0] ?? "")) return { allowed: false, reason: "shell payload execution is not read-only allowlisted" };
|
|
51
|
+
if (isAllowedReadOnlyGit(segment)) return { allowed: true, reason: null };
|
|
52
|
+
return { allowed: false, reason: "command is not read-only allowlisted" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isAllowedNormalAgentGit(segment: CommandSegment, options: GuardOptions = {}): boolean {
|
|
56
|
+
if (isAllowedReadOnlyGit(segment)) return true;
|
|
57
|
+
const parsed = parseGitInvocation(segment, options);
|
|
58
|
+
if (!parsed?.subcommand || !NORMAL_AGENT_GIT_COMMANDS.has(parsed.subcommand)) return false;
|
|
59
|
+
if (hasAliasCapableRuntimeConfig(parsed.configs)) return false;
|
|
60
|
+
|
|
61
|
+
const { subcommand, rest } = parsed;
|
|
62
|
+
if (subcommand === "commit") return !rest.some((token) => token === "--amend" || token.startsWith("--amend="));
|
|
63
|
+
if (subcommand === "push") {
|
|
64
|
+
if (rest.some(isForcePushToken)) return false;
|
|
65
|
+
if (rest.some((token) => token === "--delete" || token === "-d" || token === "--mirror")) return false;
|
|
66
|
+
return !pushRefspecs(rest).some((refspec) => refspec.startsWith("+") || refspec.startsWith(":"));
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function classifyNormalAgentGitCommand(command: unknown, options: GuardOptions = {}): AllowDecision {
|
|
72
|
+
if (typeof command !== "string" || command.trim() === "") return { allowed: false, reason: "empty command" };
|
|
73
|
+
const tokens = tokenizeCommand(command);
|
|
74
|
+
if (command.includes("`") || command.includes("$(") || /[<>|()]/.test(command)) return { allowed: false, reason: "compound shell syntax is not normal git passthrough" };
|
|
75
|
+
let effectiveCwd = stringOption(options, "cwd") ?? process.cwd();
|
|
76
|
+
for (const { segment, nextSeparator } of commandSegmentsWithSeparators(tokens)) {
|
|
77
|
+
const scopedOptions = { ...options, cwd: effectiveCwd };
|
|
78
|
+
if (!isAllowedNormalAgentGit(segment, scopedOptions)) return { allowed: false, reason: "command is not normal non-destructive git" };
|
|
79
|
+
if (nextSeparator === "||") return { allowed: false, reason: "fallback shell chains are not normal git passthrough" };
|
|
80
|
+
if (nextSeparator === ";" || nextSeparator === "&&") effectiveCwd = cdTarget(segment, effectiveCwd) ?? effectiveCwd;
|
|
81
|
+
}
|
|
82
|
+
return { allowed: true, reason: null };
|
|
83
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GuardCommandPayload, GuardDecision, GuardOptions } from "../types.ts";
|
|
2
|
+
import { isRecordLike } from "../types.ts";
|
|
3
|
+
import { classifySegment } from "./destructive-classifier.ts";
|
|
4
|
+
import type { GuardBlockDecision } from "./guard-types.ts";
|
|
5
|
+
import { stringOption } from "./options.ts";
|
|
6
|
+
import { cdTarget } from "./shell-prefix.ts";
|
|
7
|
+
import { commandSegmentsWithSeparators, findBacktickPayloads, tokenizeCommand } from "./shell-parser.ts";
|
|
8
|
+
|
|
9
|
+
export function classifyGuardCommand(command: unknown, options: GuardOptions = {}): GuardDecision {
|
|
10
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
11
|
+
return { blocked: false, reason: null, command: "", tokens: [] };
|
|
12
|
+
}
|
|
13
|
+
for (const payload of findBacktickPayloads(command)) {
|
|
14
|
+
const nested = classifyGuardCommand(payload, options);
|
|
15
|
+
if (nested.blocked) return { ...nested, reason: `backtick command substitution is blocked: ${nested.reason}` };
|
|
16
|
+
}
|
|
17
|
+
const tokens = tokenizeCommand(command);
|
|
18
|
+
let effectiveCwd = stringOption(options, "cwd") ?? process.cwd();
|
|
19
|
+
for (const { segment, nextSeparator } of commandSegmentsWithSeparators(tokens)) {
|
|
20
|
+
const scopedOptions = { ...options, cwd: effectiveCwd };
|
|
21
|
+
const result = classifySegment(segment, scopedOptions, (payload, inheritedEnvAssignments) => {
|
|
22
|
+
const nested = classifyGuardCommand(payload, { ...scopedOptions, inheritedEnvAssignments });
|
|
23
|
+
return nested.blocked ? nested : null;
|
|
24
|
+
});
|
|
25
|
+
if (result) return { ...result, tokens };
|
|
26
|
+
if (nextSeparator === ";" || nextSeparator === "&&") {
|
|
27
|
+
effectiveCwd = cdTarget(segment, effectiveCwd) ?? effectiveCwd;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { blocked: false, reason: null, command, tokens };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function extractCommandText(input: GuardCommandPayload = {}, output: GuardCommandPayload = {}): unknown {
|
|
34
|
+
const outputArgs = isRecordLike(output.args) ? output.args : {};
|
|
35
|
+
const inputArgs = isRecordLike(input.args) ? input.args : {};
|
|
36
|
+
return outputArgs.command ?? inputArgs.command ?? inputArgs.code ?? input.command ?? output.command ?? "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type { GuardBlockDecision };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { GuardOptions } from "../types.ts";
|
|
2
|
+
import { STASH_READ_ONLY } from "./allowlists.ts";
|
|
3
|
+
import type { CommandSegment, GuardBlockDecision } from "./guard-types.ts";
|
|
4
|
+
import { isForcePushToken, parseGitInvocation, pushRefspecs } from "./git-invocation.ts";
|
|
5
|
+
import { block, stringArrayOption, stringOption } from "./options.ts";
|
|
6
|
+
import { isSameOrInside, matchesKnownWorktreePath, normalizeForCompare } from "./path-policy.ts";
|
|
7
|
+
import { protectedBranchBypass } from "./protected-branch-policy.ts";
|
|
8
|
+
import { shellPayload, stripCommandWrappers } from "./shell-prefix.ts";
|
|
9
|
+
|
|
10
|
+
function hasForceCleanFlag(tokens: CommandSegment): boolean {
|
|
11
|
+
return tokens.some((token) => token === "--force" || token === "-f" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(token));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasDryRunFlag(tokens: CommandSegment): boolean {
|
|
15
|
+
return tokens.some((token) => token === "--dry-run" || token === "-n" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(token));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isCheckoutPathRestore(rest: CommandSegment): boolean {
|
|
19
|
+
return rest.includes("--") && rest.indexOf("--") < rest.length - 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRestoreDestructive(rest: CommandSegment): boolean {
|
|
23
|
+
if (rest.includes("--staged") && !rest.includes("--worktree") && rest.every((token) => token === "--staged" || token.startsWith("-"))) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return rest.includes("--worktree") || rest.some((token) => !token.startsWith("-"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecursiveForce(tokens: CommandSegment): boolean {
|
|
30
|
+
const flags = tokens.filter((token) => token.startsWith("-"));
|
|
31
|
+
return flags.some((flag) => flag.includes("r") || flag.includes("R")) && flags.some((flag) => flag.includes("f") || flag.includes("F"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function targetsRepoManagedPath(targets: readonly string[], options: GuardOptions): boolean {
|
|
35
|
+
const cwd = options.cwd ?? process.cwd();
|
|
36
|
+
const explicitProtectedRoots = [stringOption(options, "repoRoot"), stringOption(options, "worktree")]
|
|
37
|
+
.filter((root): root is string => Boolean(root))
|
|
38
|
+
.map((root) => normalizeForCompare(root, cwd));
|
|
39
|
+
const protectedRoots = explicitProtectedRoots.length > 0 ? explicitProtectedRoots : [normalizeForCompare(cwd, cwd)];
|
|
40
|
+
|
|
41
|
+
return targets.some((target) => {
|
|
42
|
+
if (!target || target.startsWith("-")) return false;
|
|
43
|
+
const resolvedTarget = normalizeForCompare(target, cwd);
|
|
44
|
+
return protectedRoots.some((root) => isSameOrInside(resolvedTarget, root));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findWorktreeAddPath(rest: CommandSegment): string | null {
|
|
49
|
+
let index = 1;
|
|
50
|
+
while (index < rest.length) {
|
|
51
|
+
const token = rest[index] ?? "";
|
|
52
|
+
if (token === "--") return rest[index + 1] ?? null;
|
|
53
|
+
if (!token.startsWith("-")) return token;
|
|
54
|
+
if (["-b", "-B", "--orphan"].includes(token)) {
|
|
55
|
+
index += 2;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (token === "--detach" || token.startsWith("--orphan=")) {
|
|
59
|
+
index += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasBranchDeleteFlag(tokens: CommandSegment): boolean {
|
|
68
|
+
return tokens.some((token) => {
|
|
69
|
+
if (token === "--delete" || token.startsWith("--delete=")) return true;
|
|
70
|
+
if (token.startsWith("--")) return false;
|
|
71
|
+
return /^-[A-Za-z]*[dD][A-Za-z]*$/.test(token);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function updateRefDeleteTarget(tokens: CommandSegment): string | null {
|
|
76
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
77
|
+
const token = tokens[index] ?? "";
|
|
78
|
+
if (token === "-d" || token === "--delete") {
|
|
79
|
+
for (let targetIndex = index + 1; targetIndex < tokens.length; targetIndex += 1) {
|
|
80
|
+
const candidate = tokens[targetIndex] ?? "";
|
|
81
|
+
if (candidate === "--") return tokens[targetIndex + 1] ?? null;
|
|
82
|
+
if (!candidate.startsWith("-")) return candidate;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (token.startsWith("--delete=")) return token.slice("--delete=".length);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasUpdateRefStdin(tokens: CommandSegment): boolean {
|
|
92
|
+
return tokens.includes("--stdin");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isBranchRefDeleteTarget(target: string | null): boolean {
|
|
96
|
+
return target === "HEAD" || target === "@" || Boolean(target?.startsWith("refs/heads/"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function classifyGit(segment: CommandSegment, options: GuardOptions = {}): GuardBlockDecision | null {
|
|
100
|
+
const parsed = parseGitInvocation(segment, options);
|
|
101
|
+
if (!parsed?.subcommand) return null;
|
|
102
|
+
const { subcommand, rest, normalized, gitCwd, workTree, configs } = parsed;
|
|
103
|
+
const bypass = protectedBranchBypass(normalized, subcommand, rest, options, gitCwd, workTree, configs);
|
|
104
|
+
if (bypass) return bypass;
|
|
105
|
+
if (subcommand === "reset" && rest.includes("--hard")) {
|
|
106
|
+
return block("git reset --hard is blocked because it can discard session work", normalized);
|
|
107
|
+
}
|
|
108
|
+
if (subcommand === "clean" && hasForceCleanFlag(rest) && !hasDryRunFlag(rest)) {
|
|
109
|
+
return block("destructive git clean variants are blocked", normalized);
|
|
110
|
+
}
|
|
111
|
+
if (subcommand === "branch" && hasBranchDeleteFlag(rest)) {
|
|
112
|
+
return block("raw git branch deletion is blocked; use guardian_delete_worktree", normalized);
|
|
113
|
+
}
|
|
114
|
+
if (subcommand === "update-ref") {
|
|
115
|
+
if (hasUpdateRefStdin(rest)) {
|
|
116
|
+
return block("raw git update-ref --stdin is blocked; use guardian_delete_worktree", normalized);
|
|
117
|
+
}
|
|
118
|
+
const deleteTarget = updateRefDeleteTarget(rest);
|
|
119
|
+
if (isBranchRefDeleteTarget(deleteTarget)) {
|
|
120
|
+
return block("raw git branch ref deletion is blocked; use guardian_delete_worktree", normalized);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (subcommand === "worktree" && ["remove", "prune"].includes(rest[0] ?? "")) {
|
|
124
|
+
return block("raw git worktree removal/prune is blocked; use guardian_delete_worktree", normalized);
|
|
125
|
+
}
|
|
126
|
+
if (subcommand === "worktree" && rest[0] === "add") {
|
|
127
|
+
const addPath = findWorktreeAddPath(rest);
|
|
128
|
+
const knownWorktreePaths = stringArrayOption(options, "knownWorktreePaths");
|
|
129
|
+
if (!addPath || !matchesKnownWorktreePath(addPath, knownWorktreePaths, options.cwd ?? process.cwd())) {
|
|
130
|
+
return block("raw git worktree add outside Guardian-owned roots is blocked; use guardian_start", normalized);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (subcommand === "restore" && isRestoreDestructive(rest)) {
|
|
134
|
+
return block("destructive git restore variants are blocked", normalized);
|
|
135
|
+
}
|
|
136
|
+
if (subcommand === "checkout" && (rest.includes("-f") || rest.includes("--force") || isCheckoutPathRestore(rest))) {
|
|
137
|
+
return block("destructive git checkout variants are blocked", normalized);
|
|
138
|
+
}
|
|
139
|
+
if (subcommand === "switch" && rest.some((token) => token === "-f" || token === "--force" || token === "--discard-changes")) {
|
|
140
|
+
return block("destructive git switch variants are blocked", normalized);
|
|
141
|
+
}
|
|
142
|
+
if (subcommand === "stash") {
|
|
143
|
+
const action = rest.find((token) => !token.startsWith("-")) ?? "push";
|
|
144
|
+
if (!STASH_READ_ONLY.has(action)) {
|
|
145
|
+
return block("mutating git stash commands are blocked", normalized);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (subcommand === "push" && (rest.some(isForcePushToken) || pushRefspecs(rest).some((refspec) => refspec.startsWith("+")))) {
|
|
149
|
+
return block("force push is blocked", normalized);
|
|
150
|
+
}
|
|
151
|
+
if (subcommand === "push" && rest.some((token) => token === "--mirror")) {
|
|
152
|
+
return block("mirror push is blocked because it can delete remote refs", normalized);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function classifySegment(
|
|
158
|
+
segment: CommandSegment,
|
|
159
|
+
options: GuardOptions,
|
|
160
|
+
classifyNestedPayload: (payload: string, inheritedEnvAssignments: readonly string[]) => { readonly reason: string | null } | null,
|
|
161
|
+
): GuardBlockDecision | null {
|
|
162
|
+
const payload = shellPayload(segment);
|
|
163
|
+
if (payload) {
|
|
164
|
+
const inheritedEnvAssignments = [...(Array.isArray(options.inheritedEnvAssignments) ? options.inheritedEnvAssignments : []), ...payload.assignments];
|
|
165
|
+
const nested = classifyNestedPayload(payload.payload, inheritedEnvAssignments);
|
|
166
|
+
if (nested) return block(`shell -c payload is blocked: ${nested.reason}`, segment);
|
|
167
|
+
}
|
|
168
|
+
const gitResult = classifyGit(segment, options);
|
|
169
|
+
if (gitResult) return gitResult;
|
|
170
|
+
const gitIndex = segment.findIndex((token) => token === "git");
|
|
171
|
+
if (gitIndex > 0) {
|
|
172
|
+
const nestedGitResult = classifyGit(segment.slice(gitIndex), options);
|
|
173
|
+
if (nestedGitResult) return nestedGitResult;
|
|
174
|
+
}
|
|
175
|
+
const stripped = stripCommandWrappers(segment);
|
|
176
|
+
if (stripped[0] === "opencode-worktree-workflow" && stripped[1] === "wt-clean" && stripped[2] === "apply") {
|
|
177
|
+
return block("opencode-worktree-workflow wt-clean apply is blocked", stripped);
|
|
178
|
+
}
|
|
179
|
+
if (stripped[0] === "rm" && isRecursiveForce(stripped.slice(1))) {
|
|
180
|
+
const targets = stripped.slice(1).filter((token) => !token.startsWith("-"));
|
|
181
|
+
if (targets.some((target) => matchesKnownWorktreePath(target, options.knownWorktreePaths ?? [], options.cwd ?? process.cwd()))) {
|
|
182
|
+
return block("rm -rf of a known worktree path is blocked", stripped);
|
|
183
|
+
}
|
|
184
|
+
if (targetsRepoManagedPath(targets, options)) {
|
|
185
|
+
return block("rm -rf inside the current repo/worktree is blocked; use guardian_delete_paths or guardian_hygiene", stripped);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|