fullstackgtm 0.48.0 → 0.49.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +12 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/cli/audit.js +10 -7
  8. package/dist/cli/auth.js +8 -4
  9. package/dist/cli/fix.js +20 -7
  10. package/dist/cli/help.js +10 -6
  11. package/dist/cli/market.js +180 -2
  12. package/dist/cli/plans.js +56 -5
  13. package/dist/cli/ui.js +2 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +13 -1
  16. package/dist/config.js +23 -2
  17. package/dist/connectors/prospectSources.js +4 -11
  18. package/dist/connectors/salesforce.js +12 -5
  19. package/dist/connectors/salesforceAuth.d.ts +9 -0
  20. package/dist/connectors/salesforceAuth.js +47 -5
  21. package/dist/connectors/theirstack.d.ts +0 -19
  22. package/dist/connectors/theirstack.js +3 -10
  23. package/dist/credentials.js +36 -26
  24. package/dist/index.d.ts +3 -2
  25. package/dist/index.js +3 -2
  26. package/dist/market.d.ts +1 -1
  27. package/dist/market.js +7 -127
  28. package/dist/marketClassify.d.ts +10 -0
  29. package/dist/marketClassify.js +52 -1
  30. package/dist/marketSourcing.js +7 -45
  31. package/dist/mcp.js +67 -115
  32. package/dist/planStore.d.ts +28 -1
  33. package/dist/planStore.js +195 -49
  34. package/dist/providerError.d.ts +21 -0
  35. package/dist/providerError.js +30 -0
  36. package/dist/publicHttp.d.ts +28 -0
  37. package/dist/publicHttp.js +143 -0
  38. package/dist/secureFile.d.ts +15 -0
  39. package/dist/secureFile.js +164 -0
  40. package/dist/types.d.ts +1 -1
  41. package/docs/api.md +29 -2
  42. package/docs/architecture.md +13 -2
  43. package/llms.txt +7 -0
  44. package/package.json +5 -3
  45. package/skills/fullstackgtm/SKILL.md +7 -1
  46. package/src/cli/audit.ts +10 -7
  47. package/src/cli/auth.ts +8 -4
  48. package/src/cli/fix.ts +28 -7
  49. package/src/cli/help.ts +10 -6
  50. package/src/cli/market.ts +181 -2
  51. package/src/cli/plans.ts +66 -5
  52. package/src/cli/ui.ts +1 -0
  53. package/src/cli.ts +1 -1
  54. package/src/config.ts +39 -1
  55. package/src/connectors/prospectSources.ts +4 -11
  56. package/src/connectors/salesforce.ts +12 -5
  57. package/src/connectors/salesforceAuth.ts +46 -6
  58. package/src/connectors/theirstack.ts +3 -10
  59. package/src/credentials.ts +47 -28
  60. package/src/index.ts +4 -0
  61. package/src/market.ts +7 -111
  62. package/src/marketClassify.ts +61 -1
  63. package/src/marketSourcing.ts +7 -43
  64. package/src/mcp.ts +93 -136
  65. package/src/planStore.ts +235 -57
  66. package/src/providerError.ts +37 -0
  67. package/src/publicHttp.ts +147 -0
  68. package/src/secureFile.ts +169 -0
  69. package/src/types.ts +1 -0
package/dist/mcp.js CHANGED
@@ -158,12 +158,19 @@ const toolDefinitions = [
158
158
  },
159
159
  handler: async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
160
160
  const loaded = configPath ? loadConfig(configPath) : null;
161
+ if (loaded?.config.rulePackages?.length) {
162
+ throw new Error("MCP refuses executable rulePackages from tool-call configPath. " +
163
+ "MCP plugin execution is disabled because mutable tool-call paths are not a durable code-trust boundary. " +
164
+ "Run reviewed plugins through the CLI with --config <path> --allow-plugins instead.");
165
+ }
161
166
  const policy = mergePolicy(defaultPolicy(today), loaded?.config);
162
167
  if (today)
163
168
  policy.today = today;
164
169
  if (staleDealDays !== undefined)
165
170
  policy.staleDealDays = staleDealDays;
166
- const ruleSet = await resolveConfiguredRules(loaded);
171
+ const ruleSet = await resolveConfiguredRules(loaded, undefined, {
172
+ disableRulePackages: true,
173
+ });
167
174
  const selected = rules?.length
168
175
  ? ruleSet.filter((rule) => rules.includes(rule.id))
169
176
  : ruleSet;
@@ -273,122 +280,79 @@ const toolDefinitions = [
273
280
  writesCrm: true,
274
281
  config: {
275
282
  title: "Apply Approved Patch Operations",
276
- description: "Apply explicitly approved operations from a patch plan through a provider " +
277
- "connector. Operations not listed in approvedOperationIds are never written, " +
278
- "and requires_human_* placeholders need a value override. When the plan is in " +
279
- "the local plan store (saved via `audit --save`), approvals are verified against " +
280
- "the store's HMAC approval digests — ids not approved with `plans approve` are " +
281
- "refused — and the run is recorded onto the stored plan so `plans show` and " +
283
+ description: "Apply a stored, human-approved patch plan through a provider connector. The plan, " +
284
+ "approved operation ids, and concrete placeholder values are loaded exclusively from " +
285
+ "the local plan store and verified against its HMAC approval digests. Callers cannot " +
286
+ "supply or widen approvals. Every apply run is recorded so `plans show` and " +
282
287
  "`audit-log export` include it.",
283
288
  inputSchema: strictSchema({
284
289
  provider: z.enum(["hubspot", "salesforce"]),
285
- planPath: z.string(),
286
- approvedOperationIds: z.array(z.string()).min(1),
287
- valueOverrides: z.record(z.string(), z.string()).optional(),
290
+ planId: z.string().regex(/^[\w.-]+$/),
288
291
  output: z.enum(["json", "markdown"]).optional(),
289
292
  }),
290
293
  },
291
- handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
292
- // The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
293
- // (a file straight out of the plan-store directory).
294
- const parsed = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
295
- const filePlan = isStoredPlanFile(parsed) ? parsed.plan : parsed;
296
- if (!filePlan || !Array.isArray(filePlan.operations)) {
297
- throw new Error(`${planPath} is not a patch plan (expected { id, operations: [...] }).`);
298
- }
299
- // Governance parity with CLI apply: when this plan id exists in the local
300
- // plan store, the STORE is the source of truth — the approved-id set must
301
- // come from `plans approve` (which HMAC-signed each approved op), the
302
- // signatures are re-verified here, and the run is recorded back onto the
303
- // plan so audit-log export sees MCP applies exactly like CLI applies.
294
+ handler: async ({ provider, planId, output }) => {
295
+ // The store is the only authority accepted over MCP. In particular, do
296
+ // not accept a plan body/path, approval ids, or value overrides from the
297
+ // agent call: combining proposal and approval in one call defeats the
298
+ // human approval boundary the plan lifecycle is meant to enforce.
304
299
  const store = createFilePlanStore();
305
- const stored = typeof filePlan.id === "string" && /^[\w.-]+$/.test(filePlan.id)
306
- ? await store.get(filePlan.id)
307
- : null;
308
- let plan;
309
- let effectiveOverrides;
310
- if (stored) {
311
- if (stored.status !== "approved") {
312
- throw new Error(`Plan ${filePlan.id} is ${stored.status}; approve operations first with ` +
313
- `\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`);
314
- }
315
- // Downgrade guard (same as CLI apply): an approved plan with no
316
- // signatures either predates 0.26 or had approvalDigests stripped to
317
- // skip the integrity check. Refuse rather than trust the file.
318
- if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
319
- throw new Error(`Refusing to apply plan ${filePlan.id}: it was approved without integrity signatures ` +
320
- "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
321
- `\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`);
322
- }
323
- // Never widen: every id the tool wants applied must already be
324
- // store-approved. (A subset is fine — narrowing never writes more.)
325
- const storeApproved = new Set(stored.approvedOperationIds);
326
- const unapproved = approvedOperationIds.filter((id) => !storeApproved.has(id));
327
- if (unapproved.length > 0) {
328
- throw new Error(`Refusing to apply plan ${filePlan.id}: operation(s) ${unapproved.join(", ")} were never ` +
329
- "approved in the plan store. Approve them first with " +
330
- `\`fullstackgtm plans approve ${filePlan.id} --operations <ids>\`.`);
331
- }
332
- // Integrity gate: verify against the EFFECTIVE overrides (stored ∪ tool
333
- // args), so a tool-supplied value that changes what the human approved
334
- // is treated as tamper, not a live override — what gets written must
335
- // equal what was signed.
336
- effectiveOverrides = { ...stored.valueOverrides, ...(valueOverrides ?? {}) };
337
- const verification = verifyApprovalDigests(stored.plan.operations, stored.approvedOperationIds, effectiveOverrides, stored.approvalDigests);
338
- if (!verification.ok) {
339
- const detail = verification.reason === "no_key"
340
- ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
341
- : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
342
- "If you want a different value, set it at approval (`plans approve --value <op>=<v>`) and re-approve; " +
343
- "otherwise the plan was edited after approval — review and re-approve.";
344
- throw new Error(`Refusing to apply plan ${filePlan.id}: ${detail}`);
345
- }
346
- plan = stored.plan; // apply what was signed, not what the file says now
300
+ const stored = await store.get(planId);
301
+ if (!stored) {
302
+ throw new Error(`No stored plan with id ${planId}. Save it with \`fullstackgtm audit --save\`, then approve it with \`fullstackgtm plans approve\`.`);
347
303
  }
348
- else {
349
- // External plan file, not in the store: no digests exist to verify, but
350
- // approvals must still reference real operations in the plan content.
351
- plan = filePlan;
352
- const known = new Set(plan.operations.map((operation) => operation.id));
353
- const unknown = approvedOperationIds.filter((id) => !known.has(id));
354
- if (unknown.length > 0) {
355
- throw new Error(`Plan ${planPath} has no operation(s) ${unknown.join(", ")} — approvedOperationIds ` +
356
- "must reference operations in the plan.");
357
- }
358
- effectiveOverrides = valueOverrides ?? {};
304
+ if (stored.status !== "approved") {
305
+ throw new Error(`Plan ${planId} is ${stored.status}; approve operations first with ` +
306
+ `\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
359
307
  }
360
- const run = await applyPatchPlan(await connectorFor(provider), plan, {
361
- approvedOperationIds,
362
- valueOverrides: effectiveOverrides,
363
- });
364
- // Persist the run (same data the CLI records) so runs[] and plan status
365
- // update and audit-log export includes MCP applies. External plan files
366
- // have no store entry to update — say so instead of silently dropping it.
367
- let runRecorded = false;
368
- if (stored) {
369
- await store.recordRun(plan.id, run);
370
- runRecorded = true;
308
+ if (stored.approvedOperationIds.length === 0) {
309
+ throw new Error(`Plan ${planId} has no approved operations.`);
310
+ }
311
+ if (!stored.approvalDigests) {
312
+ throw new Error(`Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
313
+ "(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
314
+ `\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
315
+ }
316
+ const verification = verifyApprovalDigests(stored.plan.operations, stored.approvedOperationIds, stored.valueOverrides, stored.approvalDigests);
317
+ if (!verification.ok) {
318
+ const detail = verification.reason === "no_key"
319
+ ? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
320
+ : `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
321
+ "The stored plan or approved values changed after approval — review and re-approve.";
322
+ throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
371
323
  }
324
+ const connector = await connectorFor(provider);
325
+ // Claim only after all preflight checks and credential resolution. The
326
+ // atomic store transition prevents a second CLI/MCP process from
327
+ // applying the same approval concurrently.
328
+ const claimed = await store.claimApply(planId, { provider, source: "mcp" });
329
+ const claimedVerification = verifyApprovalDigests(claimed.stored.plan.operations, claimed.stored.approvedOperationIds, claimed.stored.valueOverrides, claimed.stored.approvalDigests);
330
+ if (!claimedVerification.ok) {
331
+ await store.abortApplyPreflight(planId, claimed.claimId, "Approval integrity verification failed after the claim and before provider I/O.");
332
+ throw new Error(`Refusing to apply plan ${planId}: the claimed plan differs from what was approved: ` +
333
+ `${claimedVerification.tampered.join(", ") || "signing key unavailable"}.`);
334
+ }
335
+ let run;
336
+ try {
337
+ run = await applyPatchPlan(connector, claimed.stored.plan, {
338
+ approvedOperationIds: claimed.stored.approvedOperationIds,
339
+ valueOverrides: claimed.stored.valueOverrides,
340
+ });
341
+ }
342
+ catch (error) {
343
+ await store.markApplyUncertain(planId, claimed.claimId);
344
+ throw error;
345
+ }
346
+ await store.recordRun(claimed.stored.plan.id, run, claimed.claimId);
372
347
  const governance = {
373
- planSource: stored ? "store" : "external",
374
- approvalDigestsVerified: stored !== null,
375
- runRecorded,
376
- ...(stored
377
- ? {}
378
- : {
379
- note: "Plan file is not in the local plan store: approvals came from tool arguments " +
380
- "(verified against the plan content only — no approval-digest check) and the run " +
381
- "was not recorded. Use `fullstackgtm audit --save` + `plans approve` for " +
382
- "store-backed governance.",
383
- }),
348
+ planSource: "store",
349
+ approvalDigestsVerified: true,
350
+ runRecorded: true,
384
351
  };
385
352
  if (output === "markdown") {
386
353
  return content(`${formatPatchPlanRun(run)}\n\nGovernance: plan source ${governance.planSource}; ` +
387
- (runRecorded
388
- ? "approval digests verified; run recorded on the stored plan (audit-log export includes it)."
389
- : governance.note));
354
+ "approval digests verified; run recorded on the stored plan (audit-log export includes it).");
390
355
  }
391
- // Backward compatible: the run's own fields stay top-level; governance is additive.
392
356
  return content({ ...run, governance });
393
357
  },
394
358
  },
@@ -470,7 +434,7 @@ toolDefinitions.unshift({
470
434
  writesCrm,
471
435
  })),
472
436
  safety: "Only tools flagged writesCrm can reach a CRM; fullstackgtm_apply writes only operations " +
473
- "listed in approvedOperationIds and never writes requires_human_* placeholders without a value override.",
437
+ "from a stored plan whose ids and values were human-approved and HMAC-verified.",
474
438
  server: { command: "npx -y fullstackgtm-mcp" },
475
439
  cli: {
476
440
  command: "npx fullstackgtm <command> [--json]",
@@ -497,18 +461,6 @@ export async function startMcpServer() {
497
461
  const transport = new StdioServerTransport();
498
462
  await server.connect(transport);
499
463
  }
500
- /**
501
- * A plan-store file is a StoredPlan envelope ({ plan, status, runs, ... });
502
- * `audit --out` writes a bare PatchPlan. fullstackgtm_apply accepts either.
503
- */
504
- function isStoredPlanFile(value) {
505
- if (typeof value !== "object" || value === null || !("plan" in value))
506
- return false;
507
- const plan = value.plan;
508
- return (typeof plan === "object" &&
509
- plan !== null &&
510
- Array.isArray(plan.operations));
511
- }
512
464
  function loadMarketConfigOrHint(path) {
513
465
  try {
514
466
  return loadMarketConfig(path);
@@ -17,17 +17,44 @@ export type StoredPlan = {
17
17
  * file is caught instead of written. Absent on plans approved before 0.26.0.
18
18
  */
19
19
  approvalDigests?: Record<string, string>;
20
+ /** Monotonic store revision used to make lifecycle changes explicit. */
21
+ revision: number;
22
+ /** Present only while one process owns the right to apply this plan. */
23
+ applyClaim?: {
24
+ id: string;
25
+ claimedAt: string;
26
+ revision: number;
27
+ };
28
+ /** Durable journal of apply ownership. An unresolved entry must never be replayed automatically. */
29
+ applyAttempts?: ApplyAttempt[];
20
30
  runs: PatchPlanRun[];
21
31
  createdAt: string;
22
32
  updatedAt: string;
23
33
  };
34
+ export type ApplyAttempt = {
35
+ id: string;
36
+ claimedAt: string;
37
+ provider: string;
38
+ source: "cli" | "fix" | "mcp";
39
+ status: "in_progress" | "preflight_aborted" | "completed" | "uncertain";
40
+ resolvedAt?: string;
41
+ /** Deliberately generic: provider errors can contain CRM data or credentials. */
42
+ note?: string;
43
+ };
24
44
  export interface PlanStore {
25
45
  save(plan: PatchPlan): Promise<StoredPlan>;
26
46
  get(planId: string): Promise<StoredPlan | null>;
27
47
  list(status?: ApprovalStatus): Promise<StoredPlan[]>;
28
48
  approveOperations(planId: string, operationIds: string[], valueOverrides?: Record<string, unknown>): Promise<StoredPlan>;
29
49
  reject(planId: string): Promise<StoredPlan>;
30
- recordRun(planId: string, run: PatchPlanRun): Promise<StoredPlan>;
50
+ claimApply(planId: string, metadata?: Pick<ApplyAttempt, "provider" | "source">): Promise<{
51
+ stored: StoredPlan;
52
+ claimId: string;
53
+ }>;
54
+ recordRun(planId: string, run: PatchPlanRun, claimId: string): Promise<StoredPlan>;
55
+ abortApplyPreflight(planId: string, claimId: string, note: string): Promise<StoredPlan>;
56
+ markApplyUncertain(planId: string, claimId: string): Promise<StoredPlan>;
57
+ recoverApply(planId: string): Promise<StoredPlan>;
31
58
  }
32
59
  /**
33
60
  * Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
package/dist/planStore.js CHANGED
@@ -1,7 +1,9 @@
1
- import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, mkdirSync, openSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
5
  import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.js";
6
+ import { assertNoSymlinkComponents, readSecureRegularFile, UnsafeManagedPathError } from "./secureFile.js";
5
7
  /**
6
8
  * Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
7
9
  * per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
@@ -9,6 +11,11 @@ import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.js";
9
11
  */
10
12
  export function createFilePlanStore(directory) {
11
13
  const dir = directory ?? join(credentialsDir(), "plans");
14
+ function ensurePlanDirectory() {
15
+ assertNoSymlinkComponents(dir);
16
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ assertNoSymlinkComponents(dir);
18
+ }
12
19
  function pathFor(planId) {
13
20
  if (!/^[\w.-]+$/.test(planId))
14
21
  throw new Error(`Invalid plan id: ${planId}`);
@@ -16,9 +23,12 @@ export function createFilePlanStore(directory) {
16
23
  }
17
24
  function read(planId) {
18
25
  try {
19
- return JSON.parse(readFileSync(pathFor(planId), "utf8"));
26
+ assertNoSymlinkComponents(dir);
27
+ return JSON.parse(readSecureRegularFile(pathFor(planId)));
20
28
  }
21
- catch {
29
+ catch (error) {
30
+ if (error instanceof UnsafeManagedPathError)
31
+ throw error;
22
32
  return null;
23
33
  }
24
34
  }
@@ -29,14 +39,18 @@ export function createFilePlanStore(directory) {
29
39
  // directory world-readable.
30
40
  if (!directory)
31
41
  ensureSecureHomeDir();
32
- mkdirSync(dir, { recursive: true, mode: 0o700 });
42
+ ensurePlanDirectory();
33
43
  try {
34
44
  chmodSync(dir, 0o700);
35
45
  }
36
46
  catch {
37
47
  // Non-POSIX filesystems ignore chmod.
38
48
  }
39
- const next = { ...stored, updatedAt: new Date().toISOString() };
49
+ const next = {
50
+ ...stored,
51
+ revision: (stored.revision ?? 0) + 1,
52
+ updatedAt: new Date().toISOString(),
53
+ };
40
54
  writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
41
55
  return next;
42
56
  }
@@ -46,23 +60,56 @@ export function createFilePlanStore(directory) {
46
60
  throw new Error(`No stored plan with id ${planId}.`);
47
61
  return stored;
48
62
  }
63
+ function withPlanLock(planId, action) {
64
+ ensurePlanDirectory();
65
+ const lockPath = `${pathFor(planId)}.lock`;
66
+ let fd;
67
+ try {
68
+ fd = openSync(lockPath, "wx", 0o600);
69
+ }
70
+ catch (error) {
71
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
72
+ if (code === "EEXIST") {
73
+ throw new Error(`Plan ${planId} is being updated by another process; retry after it finishes.`);
74
+ }
75
+ throw error;
76
+ }
77
+ try {
78
+ return action();
79
+ }
80
+ finally {
81
+ closeSync(fd);
82
+ try {
83
+ unlinkSync(lockPath);
84
+ }
85
+ catch { /* a missing lock is already released */ }
86
+ }
87
+ }
49
88
  return {
50
89
  async save(plan) {
51
- const now = new Date().toISOString();
52
- return write({
53
- plan,
54
- status: plan.status,
55
- approvedOperationIds: [],
56
- valueOverrides: {},
57
- runs: [],
58
- createdAt: now,
59
- updatedAt: now,
90
+ return withPlanLock(plan.id, () => {
91
+ const existing = read(plan.id);
92
+ if (existing && ["approved", "applying", "applied"].includes(existing.status)) {
93
+ throw new Error(`Plan ${plan.id} is already ${existing.status}; refusing to replace its approval or apply history.`);
94
+ }
95
+ const now = new Date().toISOString();
96
+ return write({
97
+ plan,
98
+ status: plan.status,
99
+ approvedOperationIds: [],
100
+ valueOverrides: {},
101
+ revision: 0,
102
+ runs: [],
103
+ createdAt: now,
104
+ updatedAt: now,
105
+ });
60
106
  });
61
107
  },
62
108
  async get(planId) {
63
109
  return read(planId);
64
110
  },
65
111
  async list(status) {
112
+ assertNoSymlinkComponents(dir);
66
113
  let entries = [];
67
114
  try {
68
115
  entries = readdirSync(dir);
@@ -78,46 +125,145 @@ export function createFilePlanStore(directory) {
78
125
  return status ? plans.filter((stored) => stored.status === status) : plans;
79
126
  },
80
127
  async approveOperations(planId, operationIds, valueOverrides = {}) {
81
- const stored = mustRead(planId);
82
- if (stored.status === "applied") {
83
- throw new Error(`Plan ${planId} has already been applied.`);
84
- }
85
- if (stored.status === "rejected") {
86
- throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
87
- }
88
- const known = new Set(stored.plan.operations.map((operation) => operation.id));
89
- for (const operationId of operationIds) {
90
- if (!known.has(operationId)) {
91
- throw new Error(`Plan ${planId} has no operation ${operationId}.`);
128
+ return withPlanLock(planId, () => {
129
+ const stored = mustRead(planId);
130
+ if (stored.status === "applied") {
131
+ throw new Error(`Plan ${planId} has already been applied.`);
92
132
  }
93
- }
94
- const approvedOperationIds = Array.from(new Set([...stored.approvedOperationIds, ...operationIds]));
95
- const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
96
- // Bind the approval to the operation content so apply can detect a
97
- // post-approval edit. Recompute over ALL approved ops (a later approve
98
- // call may add overrides that change an earlier op's resolved value).
99
- const approvalDigests = computeApprovalDigests(stored.plan.operations, approvedOperationIds, mergedOverrides, loadOrCreateSigningKey());
100
- return write({
101
- ...stored,
102
- status: "approved",
103
- approvedOperationIds,
104
- valueOverrides: mergedOverrides,
105
- approvalDigests,
133
+ if (stored.status === "rejected") {
134
+ throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
135
+ }
136
+ if (stored.status === "applying" || stored.applyClaim) {
137
+ throw new Error(`Plan ${planId} is applying; approvals are frozen until the attempt is resolved.`);
138
+ }
139
+ const known = new Set(stored.plan.operations.map((operation) => operation.id));
140
+ for (const operationId of operationIds) {
141
+ if (!known.has(operationId)) {
142
+ throw new Error(`Plan ${planId} has no operation ${operationId}.`);
143
+ }
144
+ }
145
+ const approvedOperationIds = Array.from(new Set([...stored.approvedOperationIds, ...operationIds]));
146
+ const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
147
+ // Bind the approval to the operation content so apply can detect a
148
+ // post-approval edit. Recompute over ALL approved ops (a later approve
149
+ // call may add overrides that change an earlier op's resolved value).
150
+ const approvalDigests = computeApprovalDigests(stored.plan.operations, approvedOperationIds, mergedOverrides, loadOrCreateSigningKey());
151
+ return write({
152
+ ...stored,
153
+ status: "approved",
154
+ approvedOperationIds,
155
+ valueOverrides: mergedOverrides,
156
+ approvalDigests,
157
+ });
106
158
  });
107
159
  },
108
160
  async reject(planId) {
109
- const stored = mustRead(planId);
110
- if (stored.status === "applied") {
111
- throw new Error(`Plan ${planId} has already been applied.`);
112
- }
113
- return write({ ...stored, status: "rejected", approvedOperationIds: [] });
161
+ return withPlanLock(planId, () => {
162
+ const stored = mustRead(planId);
163
+ if (stored.status === "applied") {
164
+ throw new Error(`Plan ${planId} has already been applied.`);
165
+ }
166
+ if (stored.status === "applying" || stored.applyClaim) {
167
+ throw new Error(`Plan ${planId} is applying and cannot be rejected until the attempt is resolved.`);
168
+ }
169
+ return write({ ...stored, status: "rejected", approvedOperationIds: [], applyClaim: undefined });
170
+ });
171
+ },
172
+ async claimApply(planId, metadata) {
173
+ return withPlanLock(planId, () => {
174
+ const stored = mustRead(planId);
175
+ if (stored.status === "applying" || stored.applyClaim) {
176
+ throw new Error(`Plan ${planId} is already applying; inspect its recorded attempts before any retry.`);
177
+ }
178
+ if (stored.status !== "approved") {
179
+ throw new Error(`Plan ${planId} is ${stored.status}; only an approved plan can be claimed for apply.`);
180
+ }
181
+ const claimId = randomUUID();
182
+ const claimedAt = new Date().toISOString();
183
+ const claimed = write({
184
+ ...stored,
185
+ status: "applying",
186
+ applyClaim: {
187
+ id: claimId,
188
+ claimedAt,
189
+ revision: stored.revision ?? 0,
190
+ },
191
+ applyAttempts: [...(stored.applyAttempts ?? []), {
192
+ id: claimId,
193
+ claimedAt,
194
+ provider: metadata?.provider ?? "unknown",
195
+ source: metadata?.source ?? "cli",
196
+ status: "in_progress",
197
+ }],
198
+ });
199
+ return { stored: claimed, claimId };
200
+ });
114
201
  },
115
- async recordRun(planId, run) {
116
- const stored = mustRead(planId);
117
- return write({
118
- ...stored,
119
- status: run.status === "applied" || run.status === "partial" ? "applied" : stored.status,
120
- runs: [...stored.runs, run],
202
+ async recordRun(planId, run, claimId) {
203
+ return withPlanLock(planId, () => {
204
+ const stored = mustRead(planId);
205
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
206
+ throw new Error(`Refusing to record run for plan ${planId}: apply claim is missing or does not match.`);
207
+ }
208
+ return write({
209
+ ...stored,
210
+ status: run.status === "applied" || run.status === "partial" ? "applied" : "approved",
211
+ applyClaim: undefined,
212
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === claimId
213
+ ? { ...attempt, status: "completed", resolvedAt: new Date().toISOString() }
214
+ : attempt),
215
+ runs: [...stored.runs, run],
216
+ });
217
+ });
218
+ },
219
+ async abortApplyPreflight(planId, claimId, note) {
220
+ return withPlanLock(planId, () => {
221
+ const stored = mustRead(planId);
222
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
223
+ throw new Error(`Refusing to abort preflight for plan ${planId}: apply claim is missing or does not match.`);
224
+ }
225
+ return write({
226
+ ...stored,
227
+ status: "approved",
228
+ applyClaim: undefined,
229
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === claimId
230
+ ? { ...attempt, status: "preflight_aborted", resolvedAt: new Date().toISOString(), note }
231
+ : attempt),
232
+ });
233
+ });
234
+ },
235
+ async markApplyUncertain(planId, claimId) {
236
+ return withPlanLock(planId, () => {
237
+ const stored = mustRead(planId);
238
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId)
239
+ return stored;
240
+ return write({
241
+ ...stored,
242
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === claimId
243
+ ? { ...attempt, status: "uncertain", note: "Apply exited without a completed run; some provider writes may have landed." }
244
+ : attempt),
245
+ });
246
+ });
247
+ },
248
+ async recoverApply(planId) {
249
+ return withPlanLock(planId, () => {
250
+ const stored = mustRead(planId);
251
+ if (stored.status !== "applying" || !stored.applyClaim) {
252
+ throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
253
+ }
254
+ // Recovery is deliberately privilege-decaying: discard the old approval.
255
+ // An operator must inspect the provider, then explicitly approve a fresh attempt.
256
+ return write({
257
+ ...stored,
258
+ status: "needs_approval",
259
+ applyClaim: undefined,
260
+ approvedOperationIds: [],
261
+ valueOverrides: {},
262
+ approvalDigests: undefined,
263
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === stored.applyClaim.id
264
+ ? { ...attempt, status: "uncertain", resolvedAt: new Date().toISOString(), note: "Operator acknowledged uncertain provider state; approval cleared before any retry." }
265
+ : attempt),
266
+ });
121
267
  });
122
268
  },
123
269
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A persistence-safe provider failure. Third-party response bodies are
3
+ * deliberately excluded: they can reflect credentials, filters, or PII and
4
+ * errors are rendered in terminals, MCP responses, and scheduled-run files.
5
+ */
6
+ export declare class ProviderHttpError extends Error {
7
+ readonly code = "PROVIDER_HTTP_ERROR";
8
+ readonly provider: string;
9
+ readonly operation: string;
10
+ readonly status: number;
11
+ readonly retryable: boolean;
12
+ constructor(provider: string, operation: string, status: number, retryable?: boolean);
13
+ toJSON(): {
14
+ code: string;
15
+ provider: string;
16
+ operation: string;
17
+ status: number;
18
+ retryable: boolean;
19
+ message: string;
20
+ };
21
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A persistence-safe provider failure. Third-party response bodies are
3
+ * deliberately excluded: they can reflect credentials, filters, or PII and
4
+ * errors are rendered in terminals, MCP responses, and scheduled-run files.
5
+ */
6
+ export class ProviderHttpError extends Error {
7
+ code = "PROVIDER_HTTP_ERROR";
8
+ provider;
9
+ operation;
10
+ status;
11
+ retryable;
12
+ constructor(provider, operation, status, retryable = status === 429 || status >= 500) {
13
+ super(`${provider} ${operation} failed: HTTP ${status}.`);
14
+ this.name = "ProviderHttpError";
15
+ this.provider = provider;
16
+ this.operation = operation;
17
+ this.status = status;
18
+ this.retryable = retryable;
19
+ }
20
+ toJSON() {
21
+ return {
22
+ code: this.code,
23
+ provider: this.provider,
24
+ operation: this.operation,
25
+ status: this.status,
26
+ retryable: this.retryable,
27
+ message: this.message,
28
+ };
29
+ }
30
+ }