@kylecheng3146/agent-ops 0.0.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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/SECURITY.md +22 -0
  4. package/dist/packages/cli/src/args.js +322 -0
  5. package/dist/packages/cli/src/bin.js +290 -0
  6. package/dist/packages/cli/src/cli.js +80 -0
  7. package/dist/packages/cli/src/commands/config.js +8 -0
  8. package/dist/packages/cli/src/commands/doctor.js +32 -0
  9. package/dist/packages/cli/src/commands/index.js +16 -0
  10. package/dist/packages/cli/src/commands/init.js +65 -0
  11. package/dist/packages/cli/src/commands/review.js +71 -0
  12. package/dist/packages/cli/src/commands/task.js +141 -0
  13. package/dist/packages/cli/src/commands/trust.js +48 -0
  14. package/dist/packages/cli/src/commands/uninstall.js +58 -0
  15. package/dist/packages/cli/src/commands/update.js +58 -0
  16. package/dist/packages/cli/src/commands/verify.js +108 -0
  17. package/dist/packages/cli/src/output.js +53 -0
  18. package/dist/packages/cli/src/plan-output.js +28 -0
  19. package/dist/packages/cli/src/wizard.js +75 -0
  20. package/dist/runtime/src/adapters/claude/config.js +109 -0
  21. package/dist/runtime/src/adapters/claude/events.js +8 -0
  22. package/dist/runtime/src/adapters/claude/input.js +38 -0
  23. package/dist/runtime/src/adapters/claude/output.js +30 -0
  24. package/dist/runtime/src/adapters/codex/config.js +93 -0
  25. package/dist/runtime/src/adapters/codex/events.js +8 -0
  26. package/dist/runtime/src/adapters/codex/input.js +41 -0
  27. package/dist/runtime/src/adapters/codex/output.js +24 -0
  28. package/dist/runtime/src/config/explain.js +34 -0
  29. package/dist/runtime/src/config/load.js +25 -0
  30. package/dist/runtime/src/config/merge.js +170 -0
  31. package/dist/runtime/src/config/migrate.js +61 -0
  32. package/dist/runtime/src/contracts.js +1 -0
  33. package/dist/runtime/src/discovery/go.js +145 -0
  34. package/dist/runtime/src/discovery/index.js +40 -0
  35. package/dist/runtime/src/discovery/make.js +162 -0
  36. package/dist/runtime/src/discovery/node.js +175 -0
  37. package/dist/runtime/src/discovery/python.js +163 -0
  38. package/dist/runtime/src/discovery/rust.js +159 -0
  39. package/dist/runtime/src/discovery/types.js +1 -0
  40. package/dist/runtime/src/fs/hash.js +19 -0
  41. package/dist/runtime/src/fs/managed-block.js +90 -0
  42. package/dist/runtime/src/fs/manifest.js +24 -0
  43. package/dist/runtime/src/fs/mutation-worker.js +185 -0
  44. package/dist/runtime/src/fs/paths.js +96 -0
  45. package/dist/runtime/src/fs/transaction.js +498 -0
  46. package/dist/runtime/src/guardrails/destructive.js +207 -0
  47. package/dist/runtime/src/guardrails/evaluate.js +9 -0
  48. package/dist/runtime/src/guardrails/exceptions.js +49 -0
  49. package/dist/runtime/src/guardrails/secrets.js +97 -0
  50. package/dist/runtime/src/guardrails/types.js +9 -0
  51. package/dist/runtime/src/hooks/dispatch.js +78 -0
  52. package/dist/runtime/src/hooks/events.js +1 -0
  53. package/dist/runtime/src/hooks/hook-entry.js +19 -0
  54. package/dist/runtime/src/hooks/normalize.js +59 -0
  55. package/dist/runtime/src/hooks/output.js +12 -0
  56. package/dist/runtime/src/hooks/shell.js +138 -0
  57. package/dist/runtime/src/hooks/stop-verify.js +70 -0
  58. package/dist/runtime/src/install/apply.js +70 -0
  59. package/dist/runtime/src/install/doctor.js +196 -0
  60. package/dist/runtime/src/install/harness.js +87 -0
  61. package/dist/runtime/src/install/ownership.js +84 -0
  62. package/dist/runtime/src/install/plan.js +257 -0
  63. package/dist/runtime/src/install/profiles.js +28 -0
  64. package/dist/runtime/src/install/types.js +1 -0
  65. package/dist/runtime/src/install/uninstall.js +206 -0
  66. package/dist/runtime/src/install/update.js +123 -0
  67. package/dist/runtime/src/logging/local-log.js +158 -0
  68. package/dist/runtime/src/registry/npm.js +141 -0
  69. package/dist/runtime/src/review/claude-runner.js +4 -0
  70. package/dist/runtime/src/review/codex-runner.js +4 -0
  71. package/dist/runtime/src/review/packet.js +10 -0
  72. package/dist/runtime/src/review/result.js +24 -0
  73. package/dist/runtime/src/review/roles.js +3 -0
  74. package/dist/runtime/src/review/runner.js +45 -0
  75. package/dist/runtime/src/schema/validate.js +584 -0
  76. package/dist/runtime/src/security/permissions.js +654 -0
  77. package/dist/runtime/src/security/redact.js +41 -0
  78. package/dist/runtime/src/security/trust.js +209 -0
  79. package/dist/runtime/src/task/render.js +43 -0
  80. package/dist/runtime/src/task/service.js +235 -0
  81. package/dist/runtime/src/task/store.js +265 -0
  82. package/dist/runtime/src/verify/change-surface.js +86 -0
  83. package/dist/runtime/src/verify/evidence.js +89 -0
  84. package/dist/runtime/src/verify/fingerprint.js +67 -0
  85. package/dist/runtime/src/verify/scope.js +69 -0
  86. package/dist/runtime/src/verify/service.js +217 -0
  87. package/dist/runtime/src/verify/spawn.js +326 -0
  88. package/dist/runtime/src/verify/test-count.js +148 -0
  89. package/docs/en/spec/README.md +13 -0
  90. package/docs/en/spec/acceptance-and-evidence.md +21 -0
  91. package/docs/en/spec/delegation.md +21 -0
  92. package/docs/en/spec/guardrails.md +21 -0
  93. package/docs/en/spec/harness-adapters.md +21 -0
  94. package/docs/en/spec/judgment.md +21 -0
  95. package/docs/en/spec/loop-engineering.md +23 -0
  96. package/docs/en/spec/maintenance.md +21 -0
  97. package/docs/en/spec/review.md +21 -0
  98. package/docs/en/spec/troubleshooting.md +21 -0
  99. package/docs/zh-TW/spec/README.md +13 -0
  100. package/docs/zh-TW/spec/acceptance-and-evidence.md +23 -0
  101. package/docs/zh-TW/spec/delegation.md +23 -0
  102. package/docs/zh-TW/spec/guardrails.md +23 -0
  103. package/docs/zh-TW/spec/harness-adapters.md +23 -0
  104. package/docs/zh-TW/spec/judgment.md +23 -0
  105. package/docs/zh-TW/spec/loop-engineering.md +23 -0
  106. package/docs/zh-TW/spec/maintenance.md +23 -0
  107. package/docs/zh-TW/spec/review.md +23 -0
  108. package/docs/zh-TW/spec/troubleshooting.md +23 -0
  109. package/package.json +41 -0
  110. package/schemas/config.schema.json +231 -0
  111. package/schemas/evidence.schema.json +115 -0
  112. package/schemas/manifest.schema.json +116 -0
  113. package/schemas/task.schema.json +59 -0
  114. package/templates/common/AGENTS.block.md +3 -0
  115. package/templates/common/CLAUDE.block.md +3 -0
@@ -0,0 +1,498 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { sha256 } from "./hash.js";
7
+ import { AgentOpsError, resolveContainedPath } from "./paths.js";
8
+ export { AgentOpsError } from "./paths.js";
9
+ const MUTATION_WORKER_PATH = fileURLToPath(new URL("./mutation-worker.js", import.meta.url));
10
+ function isMissing(error) {
11
+ return (typeof error === "object" &&
12
+ error !== null &&
13
+ "code" in error &&
14
+ error.code === "ENOENT");
15
+ }
16
+ function normalizedPath(path) {
17
+ return process.platform === "win32" ? path.toLowerCase() : path;
18
+ }
19
+ async function captureParentGuard(parent) {
20
+ const [canonicalPath, status] = await Promise.all([
21
+ realpath(parent),
22
+ stat(parent, { bigint: true })
23
+ ]);
24
+ if (!status.isDirectory() ||
25
+ normalizedPath(canonicalPath) !== normalizedPath(parent)) {
26
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Destination directory changed before mutation: ${parent}`);
27
+ }
28
+ return {
29
+ expectedParentPath: canonicalPath,
30
+ parentDevice: status.dev.toString(),
31
+ parentInode: status.ino.toString()
32
+ };
33
+ }
34
+ async function runAnchoredMutation(targetPath, action, expectedHash, content, mode) {
35
+ const parent = dirname(targetPath);
36
+ const guard = await captureParentGuard(parent);
37
+ const request = {
38
+ action,
39
+ targetName: basename(targetPath),
40
+ expectedHash,
41
+ ...guard,
42
+ ...(mode === undefined ? {} : { mode })
43
+ };
44
+ const encodedRequest = Buffer.from(JSON.stringify(request), "utf8").toString("base64url");
45
+ await new Promise((resolvePromise, rejectPromise) => {
46
+ const child = spawn(process.execPath, [MUTATION_WORKER_PATH, encodedRequest], {
47
+ cwd: parent,
48
+ shell: false,
49
+ stdio: ["pipe", "ignore", "pipe"]
50
+ });
51
+ let standardError = "";
52
+ let settled = false;
53
+ const rejectOnce = (error) => {
54
+ if (!settled) {
55
+ settled = true;
56
+ rejectPromise(error);
57
+ }
58
+ };
59
+ child.stderr.setEncoding("utf8");
60
+ child.stderr.on("data", (chunk) => {
61
+ if (standardError.length < 4096) {
62
+ standardError += chunk.slice(0, 4096 - standardError.length);
63
+ }
64
+ });
65
+ child.once("error", (error) => {
66
+ const code = typeof error === "object" &&
67
+ error !== null &&
68
+ "code" in error &&
69
+ ["ENOENT", "ENOTDIR"].includes(String(error.code))
70
+ ? "PRECONDITION_CHANGED"
71
+ : "ANCHORED_MUTATION_FAILED";
72
+ rejectOnce(new AgentOpsError(code, "Unable to start an anchored file mutation.", {
73
+ cause: error
74
+ }));
75
+ });
76
+ child.once("close", (exitCode) => {
77
+ if (settled) {
78
+ return;
79
+ }
80
+ settled = true;
81
+ if (exitCode === 0) {
82
+ resolvePromise();
83
+ return;
84
+ }
85
+ const preconditionChanged = exitCode === 10 ||
86
+ standardError.trim() === "PRECONDITION_CHANGED";
87
+ rejectPromise(new AgentOpsError(preconditionChanged
88
+ ? "PRECONDITION_CHANGED"
89
+ : "ANCHORED_MUTATION_FAILED", preconditionChanged
90
+ ? "The target changed before anchored mutation."
91
+ : "The anchored file mutation failed."));
92
+ });
93
+ child.stdin.on("error", () => undefined);
94
+ child.stdin.end(action === "write" ? content : undefined);
95
+ });
96
+ }
97
+ async function atomicWrite(targetPath, content, mode, expectedHash) {
98
+ await runAnchoredMutation(targetPath, "write", expectedHash, content, mode);
99
+ }
100
+ async function atomicRemove(targetPath, expectedHash) {
101
+ await runAnchoredMutation(targetPath, "remove", expectedHash);
102
+ }
103
+ async function atomicMakeDirectory(targetPath, mode) {
104
+ await runAnchoredMutation(targetPath, "mkdir", null, undefined, mode);
105
+ }
106
+ async function atomicRemoveDirectory(targetPath) {
107
+ await runAnchoredMutation(targetPath, "rmdir", null);
108
+ }
109
+ function isRecord(value) {
110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
+ }
112
+ function hasOnlyKeys(value, allowed) {
113
+ const allowedKeys = new Set(allowed);
114
+ return Object.keys(value).every((key) => allowedKeys.has(key));
115
+ }
116
+ function isExpectedHash(value) {
117
+ return (value === null ||
118
+ (typeof value === "string" && /^[a-f0-9]{64}$/.test(value)));
119
+ }
120
+ function assertTransactionPlan(plan) {
121
+ if (!isRecord(plan) ||
122
+ !hasOnlyKeys(plan, ["operations"]) ||
123
+ !Array.isArray(plan.operations)) {
124
+ throw new AgentOpsError("INVALID_TRANSACTION_PLAN", "Transaction plan must contain only an operations array.");
125
+ }
126
+ for (let index = 0; index < plan.operations.length; index += 1) {
127
+ if (!Object.hasOwn(plan.operations, index)) {
128
+ throw new AgentOpsError("INVALID_TRANSACTION_PLAN", "Transaction operations must be a dense array.");
129
+ }
130
+ const operation = plan.operations[index];
131
+ if (!isRecord(operation) ||
132
+ typeof operation.path !== "string" ||
133
+ !isExpectedHash(operation.expectedHash)) {
134
+ throw new AgentOpsError("INVALID_TRANSACTION_PLAN", `Invalid transaction operation at index ${index}.`);
135
+ }
136
+ if (operation.kind === "write") {
137
+ if (typeof operation.content !== "string" ||
138
+ !hasOnlyKeys(operation, ["kind", "path", "content", "expectedHash"])) {
139
+ throw new AgentOpsError("INVALID_TRANSACTION_PLAN", `Invalid write operation at index ${index}.`);
140
+ }
141
+ continue;
142
+ }
143
+ if (operation.kind !== "remove" ||
144
+ !hasOnlyKeys(operation, ["kind", "path", "expectedHash"])) {
145
+ throw new AgentOpsError("INVALID_TRANSACTION_PLAN", `Invalid remove operation at index ${index}.`);
146
+ }
147
+ }
148
+ }
149
+ async function createRecoveryDirectory(root) {
150
+ const recoveryDirectory = join(root, `.agent-ops-backup-${randomUUID()}`);
151
+ await atomicMakeDirectory(recoveryDirectory, 0o700);
152
+ return recoveryDirectory;
153
+ }
154
+ async function createBackup(snapshot, recoveryDirectory) {
155
+ if (!snapshot.existed || snapshot.content === null) {
156
+ return null;
157
+ }
158
+ const backupPath = join(recoveryDirectory, `${basename(snapshot.targetPath)}-${randomUUID()}`);
159
+ await atomicWrite(backupPath, snapshot.content, 0o600, null);
160
+ return backupPath;
161
+ }
162
+ async function snapshotOperation(root, operation) {
163
+ const targetPath = await resolveContainedPath(root, operation.path);
164
+ try {
165
+ const status = await lstat(targetPath);
166
+ if (!status.isFile()) {
167
+ throw new AgentOpsError("UNSUPPORTED_FILE_TYPE", `Managed target must be a regular file: ${operation.path}`);
168
+ }
169
+ const content = await readFile(targetPath);
170
+ return {
171
+ operation,
172
+ targetPath,
173
+ existed: true,
174
+ content,
175
+ mode: status.mode & 0o777,
176
+ actualHash: sha256(content),
177
+ device: status.dev.toString(),
178
+ inode: status.ino.toString(),
179
+ backupPath: null,
180
+ createdDirectories: []
181
+ };
182
+ }
183
+ catch (error) {
184
+ if (!isMissing(error)) {
185
+ throw error;
186
+ }
187
+ return {
188
+ operation,
189
+ targetPath,
190
+ existed: false,
191
+ content: null,
192
+ mode: 0o600,
193
+ actualHash: null,
194
+ device: null,
195
+ inode: null,
196
+ backupPath: null,
197
+ createdDirectories: []
198
+ };
199
+ }
200
+ }
201
+ async function currentHash(path) {
202
+ try {
203
+ const status = await lstat(path);
204
+ if (!status.isFile()) {
205
+ throw new AgentOpsError("PRECONDITION_CHANGED", `A managed path is no longer a regular file: ${path}`);
206
+ }
207
+ return sha256(await readFile(path));
208
+ }
209
+ catch (error) {
210
+ if (isMissing(error)) {
211
+ return null;
212
+ }
213
+ throw error;
214
+ }
215
+ }
216
+ async function currentIdentity(path) {
217
+ try {
218
+ const status = await lstat(path, { bigint: true });
219
+ if (!status.isFile() || status.isSymbolicLink()) {
220
+ throw new AgentOpsError("PRECONDITION_CHANGED", `A managed path is no longer a regular file: ${path}`);
221
+ }
222
+ return {
223
+ device: status.dev.toString(),
224
+ inode: status.ino.toString()
225
+ };
226
+ }
227
+ catch (error) {
228
+ if (isMissing(error)) {
229
+ return null;
230
+ }
231
+ throw error;
232
+ }
233
+ }
234
+ async function assertSnapshotIdentity(snapshot, path) {
235
+ if (!snapshot.existed) {
236
+ return;
237
+ }
238
+ const identity = await currentIdentity(path);
239
+ if (identity === null ||
240
+ identity.device !== snapshot.device ||
241
+ identity.inode !== snapshot.inode) {
242
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Target identity changed for ${snapshot.operation.path}.`);
243
+ }
244
+ }
245
+ async function assertMutationBoundary(root, snapshot) {
246
+ try {
247
+ const resolved = await resolveContainedPath(root, snapshot.operation.path);
248
+ await assertSnapshotIdentity(snapshot, resolved);
249
+ if ((await currentHash(resolved)) !== snapshot.actualHash) {
250
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Precondition changed for ${snapshot.operation.path}.`);
251
+ }
252
+ }
253
+ catch (error) {
254
+ if (error instanceof AgentOpsError &&
255
+ error.code === "PRECONDITION_CHANGED") {
256
+ throw error;
257
+ }
258
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Target safety changed for ${snapshot.operation.path}.`, { cause: error });
259
+ }
260
+ }
261
+ async function ensureParentDirectories(root, snapshot) {
262
+ const parent = dirname(snapshot.targetPath);
263
+ const fromRoot = relative(root, parent);
264
+ if (fromRoot === "" ||
265
+ fromRoot === ".." ||
266
+ fromRoot.startsWith(`..${sep}`)) {
267
+ return;
268
+ }
269
+ let current = root;
270
+ for (const segment of fromRoot.split(sep)) {
271
+ current = join(current, segment);
272
+ try {
273
+ const status = await lstat(current);
274
+ if (!status.isDirectory() || status.isSymbolicLink()) {
275
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Managed parent is not a stable directory: ${snapshot.operation.path}`);
276
+ }
277
+ }
278
+ catch (error) {
279
+ if (!isMissing(error)) {
280
+ throw error;
281
+ }
282
+ try {
283
+ await atomicMakeDirectory(current, 0o700);
284
+ snapshot.createdDirectories.push(current);
285
+ }
286
+ catch (mkdirError) {
287
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Managed parent changed while it was created: ${snapshot.operation.path}`, { cause: mkdirError });
288
+ }
289
+ }
290
+ }
291
+ }
292
+ function desiredHash(snapshot) {
293
+ return snapshot.operation.kind === "write"
294
+ ? sha256(snapshot.operation.content)
295
+ : null;
296
+ }
297
+ async function removeCreatedDirectories(snapshot) {
298
+ for (const directory of [...snapshot.createdDirectories].reverse()) {
299
+ await atomicRemoveDirectory(directory);
300
+ }
301
+ }
302
+ async function rollback(root, snapshots) {
303
+ for (const snapshot of [...snapshots].reverse()) {
304
+ const resolved = await resolveContainedPath(root, snapshot.operation.path);
305
+ const observedHash = await currentHash(resolved);
306
+ if (observedHash === snapshot.actualHash) {
307
+ await removeCreatedDirectories(snapshot);
308
+ continue;
309
+ }
310
+ if (observedHash !== desiredHash(snapshot)) {
311
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Cannot overwrite a concurrent change during rollback: ${snapshot.operation.path}`);
312
+ }
313
+ if (snapshot.existed && snapshot.content !== null) {
314
+ await atomicWrite(resolved, snapshot.content, snapshot.mode, desiredHash(snapshot));
315
+ }
316
+ else {
317
+ const expectedHash = desiredHash(snapshot);
318
+ if (expectedHash === null) {
319
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Rollback target disappeared before removal: ${snapshot.operation.path}`);
320
+ }
321
+ await atomicRemove(resolved, expectedHash);
322
+ await removeCreatedDirectories(snapshot);
323
+ }
324
+ }
325
+ }
326
+ async function removeRecoveryDirectory(recoveryDirectory, snapshots) {
327
+ if (recoveryDirectory === null) {
328
+ return;
329
+ }
330
+ const cleanupProbe = join(dirname(recoveryDirectory), `.agent-ops-cleanup-probe-${randomUUID()}`);
331
+ await atomicMakeDirectory(cleanupProbe, 0o700);
332
+ await atomicRemoveDirectory(cleanupProbe);
333
+ for (const snapshot of snapshots) {
334
+ if (snapshot.backupPath !== null && snapshot.actualHash !== null) {
335
+ await atomicRemove(snapshot.backupPath, snapshot.actualHash);
336
+ }
337
+ }
338
+ await atomicRemoveDirectory(recoveryDirectory);
339
+ }
340
+ async function recoveryPaths(snapshots) {
341
+ const retained = [];
342
+ for (const snapshot of snapshots) {
343
+ if (snapshot.backupPath === null) {
344
+ continue;
345
+ }
346
+ try {
347
+ const status = await lstat(snapshot.backupPath);
348
+ if (status.isFile() && !status.isSymbolicLink()) {
349
+ retained.push(snapshot.backupPath);
350
+ }
351
+ }
352
+ catch (error) {
353
+ if (!isMissing(error)) {
354
+ retained.push(snapshot.backupPath);
355
+ }
356
+ }
357
+ }
358
+ return retained;
359
+ }
360
+ async function cleanupPreparedDirectories(root, snapshot) {
361
+ if (snapshot.createdDirectories.length === 0) {
362
+ return;
363
+ }
364
+ try {
365
+ const resolved = await resolveContainedPath(root, snapshot.operation.path);
366
+ const identity = await currentIdentity(resolved);
367
+ const identityMatches = snapshot.existed
368
+ ? identity !== null &&
369
+ identity.device === snapshot.device &&
370
+ identity.inode === snapshot.inode
371
+ : identity === null;
372
+ if (identityMatches &&
373
+ (await currentHash(resolved)) === snapshot.actualHash) {
374
+ await removeCreatedDirectories(snapshot);
375
+ }
376
+ }
377
+ catch {
378
+ // A concurrent change owns the path now. Leaving empty directories is safer
379
+ // than deleting through a path whose identity can no longer be proven.
380
+ }
381
+ }
382
+ export class FileTransaction {
383
+ #root;
384
+ #options;
385
+ constructor(root, options = {}) {
386
+ this.#root = root;
387
+ this.#options = options;
388
+ }
389
+ async apply(plan, validate = async () => undefined) {
390
+ assertTransactionPlan(plan);
391
+ const root = await realpath(resolve(this.#root));
392
+ const snapshots = [];
393
+ const ownership = new Set();
394
+ for (const operation of plan.operations) {
395
+ const snapshot = await snapshotOperation(root, operation);
396
+ const ownershipKey = snapshot.targetPath.toLowerCase();
397
+ if (ownership.has(ownershipKey)) {
398
+ throw new AgentOpsError("DUPLICATE_OPERATION", `A transaction may manage a path only once: ${operation.path}`);
399
+ }
400
+ ownership.add(ownershipKey);
401
+ if (snapshot.actualHash !== operation.expectedHash) {
402
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Precondition changed for ${operation.path}.`);
403
+ }
404
+ snapshots.push(snapshot);
405
+ }
406
+ const applied = [];
407
+ let recoveryDirectory = null;
408
+ try {
409
+ for (const [index, snapshot] of snapshots.entries()) {
410
+ const isNoOp = snapshot.operation.kind === "write"
411
+ ? snapshot.actualHash === sha256(snapshot.operation.content)
412
+ : !snapshot.existed;
413
+ if (isNoOp) {
414
+ continue;
415
+ }
416
+ if (snapshot.existed && recoveryDirectory === null) {
417
+ recoveryDirectory = await createRecoveryDirectory(root);
418
+ }
419
+ snapshot.backupPath =
420
+ recoveryDirectory === null
421
+ ? null
422
+ : await createBackup(snapshot, recoveryDirectory);
423
+ await assertMutationBoundary(root, snapshot);
424
+ if (snapshot.operation.kind === "write") {
425
+ await ensureParentDirectories(root, snapshot);
426
+ await assertMutationBoundary(root, snapshot);
427
+ }
428
+ try {
429
+ await this.#options.beforeReplace?.({
430
+ index,
431
+ targetPath: snapshot.targetPath,
432
+ backupPath: snapshot.backupPath
433
+ });
434
+ }
435
+ catch (error) {
436
+ await cleanupPreparedDirectories(root, snapshot);
437
+ throw error;
438
+ }
439
+ applied.push(snapshot);
440
+ try {
441
+ if (snapshot.operation.kind === "write") {
442
+ await atomicWrite(snapshot.targetPath, snapshot.operation.content, snapshot.existed ? snapshot.mode : 0o600, snapshot.actualHash);
443
+ }
444
+ else {
445
+ if (snapshot.actualHash === null) {
446
+ throw new AgentOpsError("PRECONDITION_CHANGED", `Remove target disappeared before mutation: ${snapshot.operation.path}`);
447
+ }
448
+ await atomicRemove(snapshot.targetPath, snapshot.actualHash);
449
+ }
450
+ }
451
+ catch (error) {
452
+ if (error instanceof AgentOpsError &&
453
+ error.code === "PRECONDITION_CHANGED") {
454
+ applied.pop();
455
+ await cleanupPreparedDirectories(root, snapshot);
456
+ }
457
+ throw error;
458
+ }
459
+ }
460
+ await validate();
461
+ }
462
+ catch (error) {
463
+ try {
464
+ await rollback(root, applied);
465
+ }
466
+ catch (rollbackError) {
467
+ const retainedRecoveryPaths = await recoveryPaths(snapshots);
468
+ throw new AgentOpsError("ROLLBACK_FAILED", "The transaction failed and rollback was incomplete.", {
469
+ cause: rollbackError,
470
+ recoveryPaths: retainedRecoveryPaths
471
+ });
472
+ }
473
+ try {
474
+ await removeRecoveryDirectory(recoveryDirectory, snapshots);
475
+ }
476
+ catch (cleanupError) {
477
+ throw new AgentOpsError("ROLLED_BACK_CLEANUP_FAILED", "The transaction was rolled back, but recovery cleanup failed.", {
478
+ cause: cleanupError,
479
+ recoveryPaths: await recoveryPaths(snapshots)
480
+ });
481
+ }
482
+ if (error instanceof AgentOpsError &&
483
+ error.code === "PRECONDITION_CHANGED") {
484
+ throw error;
485
+ }
486
+ throw new AgentOpsError("TRANSACTION_FAILED", "The transaction failed and all applied operations were rolled back.", { cause: error });
487
+ }
488
+ try {
489
+ await removeRecoveryDirectory(recoveryDirectory, snapshots);
490
+ }
491
+ catch (cleanupError) {
492
+ throw new AgentOpsError("COMMITTED_CLEANUP_FAILED", "The transaction committed, but recovery cleanup failed.", {
493
+ cause: cleanupError,
494
+ recoveryPaths: await recoveryPaths(snapshots)
495
+ });
496
+ }
497
+ }
498
+ }
@@ -0,0 +1,207 @@
1
+ import { GUARDRAIL_RULE_IDS } from "./types.js";
2
+ const AMBIGUOUS_TARGET = /(?:^|[^\\])(?:\$\{?|\*|\?|\[)/;
3
+ const PUSH_LONG_OPTIONS_WITH_VALUES = new Set([
4
+ "--exec",
5
+ "--push-option",
6
+ "--receive-pack",
7
+ "--recurse-submodules",
8
+ "--repo"
9
+ ]);
10
+ const WINDOWS_ENVIRONMENT_TARGET = /%[^%\s]+%/;
11
+ const TILDE_TARGET = /^~[A-Za-z0-9._-]*(?:[\\/]|$)/;
12
+ const WINDOWS_ROOT = /^[A-Za-z]:\/?$/;
13
+ function executableName(command) {
14
+ return command.split(/[\\/]/).at(-1)?.replace(/\.exe$/i, "").toLowerCase() ?? "";
15
+ }
16
+ function isShortFlag(argument, flag, caseInsensitive = false) {
17
+ const flags = argument.slice(1);
18
+ return (argument.startsWith("-") &&
19
+ !argument.startsWith("--") &&
20
+ (caseInsensitive
21
+ ? flags.toLowerCase().includes(flag.toLowerCase())
22
+ : flags.includes(flag)));
23
+ }
24
+ function hasAmbiguousExpansion(target) {
25
+ return (AMBIGUOUS_TARGET.test(target) ||
26
+ WINDOWS_ENVIRONMENT_TARGET.test(target) ||
27
+ TILDE_TARGET.test(target));
28
+ }
29
+ function normalizeTarget(target) {
30
+ if (target.length === 0) {
31
+ return target;
32
+ }
33
+ const portable = target.replace(/\\/g, "/");
34
+ const drive = /^([A-Za-z]:)(?:\/|$)/.exec(portable)?.[1];
35
+ const absolute = drive !== undefined || portable.startsWith("/");
36
+ const remainder = drive === undefined
37
+ ? portable.replace(/^\/+/, "")
38
+ : portable.slice(drive.length).replace(/^\/+/, "");
39
+ const segments = [];
40
+ for (const segment of remainder.split("/")) {
41
+ if (segment.length === 0 || segment === ".") {
42
+ continue;
43
+ }
44
+ if (segment === "..") {
45
+ if (segments.length > 0 && segments.at(-1) !== "..") {
46
+ segments.pop();
47
+ }
48
+ else if (!absolute) {
49
+ segments.push(segment);
50
+ }
51
+ continue;
52
+ }
53
+ segments.push(segment);
54
+ }
55
+ const normalized = segments.join("/");
56
+ if (drive !== undefined) {
57
+ return normalized.length === 0 ? `${drive}/` : `${drive}/${normalized}`;
58
+ }
59
+ if (absolute) {
60
+ return normalized.length === 0 ? "/" : `/${normalized}`;
61
+ }
62
+ return normalized.length === 0 ? "." : normalized;
63
+ }
64
+ function isBroadTarget(target) {
65
+ const normalized = normalizeTarget(target);
66
+ return (normalized === "/" ||
67
+ normalized === "." ||
68
+ normalized === ".." ||
69
+ normalized === "~" ||
70
+ WINDOWS_ROOT.test(normalized));
71
+ }
72
+ function removalDecision(command, args) {
73
+ const isRm = command === "rm";
74
+ const isPowerShellRemove = command === "remove-item";
75
+ const isWindowsRemove = command === "rd" || command === "rmdir";
76
+ if (!isRm && !isPowerShellRemove && !isWindowsRemove) {
77
+ return { action: "allow" };
78
+ }
79
+ const recursive = args.some((argument) => isRm
80
+ ? argument === "--recursive" || isShortFlag(argument, "r", true)
81
+ : isPowerShellRemove
82
+ ? argument.toLowerCase() === "-recurse"
83
+ : argument.toLowerCase() === "/s");
84
+ if (!recursive) {
85
+ return { action: "allow" };
86
+ }
87
+ const targets = args.filter((argument) => {
88
+ if (argument === "--") {
89
+ return false;
90
+ }
91
+ if (isWindowsRemove) {
92
+ return !argument.startsWith("/");
93
+ }
94
+ return !argument.startsWith("-");
95
+ });
96
+ if (targets.some(isBroadTarget)) {
97
+ return {
98
+ action: "block",
99
+ ruleId: GUARDRAIL_RULE_IDS.broadDelete,
100
+ reason: "Recursive deletion of a broad filesystem target is blocked.",
101
+ saferAlternative: "Delete an explicitly named project-relative path after reviewing its resolved value."
102
+ };
103
+ }
104
+ if (targets.some(hasAmbiguousExpansion)) {
105
+ return {
106
+ action: "warn",
107
+ ruleId: GUARDRAIL_RULE_IDS.ambiguousTarget,
108
+ reason: "The recursive deletion target contains unresolved environment or glob expansion."
109
+ };
110
+ }
111
+ return { action: "allow" };
112
+ }
113
+ function gitSubcommand(args) {
114
+ for (let index = 0; index < args.length; index += 1) {
115
+ const argument = args[index] ?? "";
116
+ if (argument === "-C" || argument === "-c") {
117
+ index += 1;
118
+ continue;
119
+ }
120
+ if (argument.startsWith("-")) {
121
+ continue;
122
+ }
123
+ return { name: argument, args: args.slice(index + 1) };
124
+ }
125
+ return { name: undefined, args: [] };
126
+ }
127
+ function hasDestructivePushArgument(args) {
128
+ let repositorySupplied = false;
129
+ const operands = [];
130
+ let optionsEnded = false;
131
+ for (let index = 0; index < args.length; index += 1) {
132
+ const argument = args[index] ?? "";
133
+ if (optionsEnded) {
134
+ operands.push(argument);
135
+ continue;
136
+ }
137
+ if (argument === "--") {
138
+ optionsEnded = true;
139
+ continue;
140
+ }
141
+ if (argument.startsWith("--")) {
142
+ const equalsIndex = argument.indexOf("=");
143
+ const option = equalsIndex === -1 ? argument : argument.slice(0, equalsIndex);
144
+ if (option === "--force" ||
145
+ option === "--force-with-lease" ||
146
+ option === "--mirror") {
147
+ return true;
148
+ }
149
+ if (PUSH_LONG_OPTIONS_WITH_VALUES.has(option)) {
150
+ if (option === "--repo") {
151
+ repositorySupplied = true;
152
+ }
153
+ if (equalsIndex === -1) {
154
+ index += 1;
155
+ }
156
+ }
157
+ continue;
158
+ }
159
+ if (argument.startsWith("-") && argument !== "-") {
160
+ const flags = argument.slice(1);
161
+ for (let flagIndex = 0; flagIndex < flags.length; flagIndex += 1) {
162
+ const flag = flags[flagIndex];
163
+ if (flag === "f") {
164
+ return true;
165
+ }
166
+ if (flag === "o") {
167
+ if (flagIndex === flags.length - 1) {
168
+ index += 1;
169
+ }
170
+ break;
171
+ }
172
+ }
173
+ continue;
174
+ }
175
+ operands.push(argument);
176
+ }
177
+ const refspecs = repositorySupplied ? operands : operands.slice(1);
178
+ return refspecs.some((argument) => argument.startsWith("+") && argument.length > 1);
179
+ }
180
+ function gitDecision(args) {
181
+ const subcommand = gitSubcommand(args);
182
+ if (subcommand.name === "reset" && subcommand.args.includes("--hard")) {
183
+ return {
184
+ action: "block",
185
+ ruleId: GUARDRAIL_RULE_IDS.reset,
186
+ reason: "A destructive Git hard reset can discard uncommitted work.",
187
+ saferAlternative: "Create a backup branch or use git revert instead of git reset --hard."
188
+ };
189
+ }
190
+ if (subcommand.name === "push" &&
191
+ hasDestructivePushArgument(subcommand.args)) {
192
+ return {
193
+ action: "block",
194
+ ruleId: GUARDRAIL_RULE_IDS.forcePush,
195
+ reason: "A forced Git push can overwrite shared remote history.",
196
+ saferAlternative: "Push without force, or use a bounded reviewed exception for the exact scope."
197
+ };
198
+ }
199
+ return { action: "allow" };
200
+ }
201
+ export function evaluateDestructiveCommand(input) {
202
+ const command = executableName(input.command);
203
+ if (command === "git") {
204
+ return gitDecision(input.args);
205
+ }
206
+ return removalDecision(command, input.args);
207
+ }