@stigmer/runner 3.1.0 → 3.1.2

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 (111) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  6. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +19 -1
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.d.ts +5 -0
  11. package/dist/activities/execute-cursor/index.js +102 -22
  12. package/dist/activities/execute-cursor/index.js.map +1 -1
  13. package/dist/activities/execute-cursor/message-translator.js +10 -1
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  16. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  17. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  18. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  19. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  20. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  21. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  22. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  23. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  24. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  25. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  26. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/index.js +59 -2
  28. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  30. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  31. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/setup.js +22 -16
  33. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  34. package/dist/config.d.ts +7 -0
  35. package/dist/config.js +9 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/middleware/approval-gate.js +36 -6
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/runner-manager.d.ts +2 -0
  40. package/dist/runner-manager.js +2 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.d.ts +2 -0
  43. package/dist/runner.js +2 -1
  44. package/dist/runner.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +32 -0
  46. package/dist/shared/artifact-storage.js +74 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/implement-plan-prompt.d.ts +42 -0
  49. package/dist/shared/implement-plan-prompt.js +66 -0
  50. package/dist/shared/implement-plan-prompt.js.map +1 -0
  51. package/dist/shared/plan-mode-prompt.d.ts +30 -0
  52. package/dist/shared/plan-mode-prompt.js +51 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -0
  54. package/dist/shared/status.d.ts +11 -0
  55. package/dist/shared/status.js +12 -2
  56. package/dist/shared/status.js.map +1 -1
  57. package/dist/shared/tool-row.d.ts +30 -1
  58. package/dist/shared/tool-row.js +53 -4
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/session-root.d.ts +35 -0
  61. package/dist/shared/workspace/session-root.js +53 -0
  62. package/dist/shared/workspace/session-root.js.map +1 -0
  63. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  64. package/dist/shared/workspace/workspace-lock.js +187 -0
  65. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  66. package/package.json +7 -5
  67. package/src/__tests__/config.test.ts +8 -0
  68. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  70. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  71. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +125 -2
  72. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  75. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  76. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  77. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  78. package/src/activities/execute-cursor/hook-script.ts +19 -1
  79. package/src/activities/execute-cursor/index.ts +114 -26
  80. package/src/activities/execute-cursor/message-translator.ts +9 -1
  81. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  82. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  83. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  84. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  85. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  86. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  87. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  88. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +88 -0
  89. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  90. package/src/activities/execute-deep-agent/index.ts +70 -2
  91. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  92. package/src/activities/execute-deep-agent/setup.ts +25 -17
  93. package/src/config.ts +17 -0
  94. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  95. package/src/middleware/approval-gate.ts +39 -7
  96. package/src/runner-manager.ts +6 -1
  97. package/src/runner.ts +6 -1
  98. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  99. package/src/shared/__tests__/implement-plan-prompt.test.ts +48 -0
  100. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  101. package/src/shared/__tests__/status.test.ts +7 -2
  102. package/src/shared/__tests__/tool-row.test.ts +136 -1
  103. package/src/shared/artifact-storage.ts +83 -1
  104. package/src/shared/implement-plan-prompt.ts +73 -0
  105. package/src/shared/plan-mode-prompt.ts +51 -0
  106. package/src/shared/status.ts +11 -1
  107. package/src/shared/tool-row.ts +58 -5
  108. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  109. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  110. package/src/shared/workspace/session-root.ts +60 -0
  111. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -1025,6 +1025,34 @@ describe("reconcileDeniedToolCalls — authoritative hook input overlay", () =>
1025
1025
  expect(tc.approvalContentDigest).not.toBe("");
1026
1026
  });
1027
1027
 
1028
+ it("does NOT overlay a secret-like write's content (DD-26 #2 defensive guard)", async () => {
1029
+ // Normally unreachable — the hook hard-blocks a secret write and records no
1030
+ // ledger entry — but if a hook classify failure fell one through with content,
1031
+ // that content must still never reach args / args_preview / the digest.
1032
+ const tc = toolCall({
1033
+ id: "c1",
1034
+ name: "write",
1035
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
1036
+ argsPreview: JSON.stringify({ path: ".env" }),
1037
+ });
1038
+ const messages = [aiMessageWith([tc])];
1039
+
1040
+ await reconcileDeniedToolCalls(
1041
+ messages,
1042
+ [{
1043
+ toolName: "Write",
1044
+ token: grantToken("write", ".env"),
1045
+ input: { file_path: ".env", content: "API_KEY=super-secret" },
1046
+ }],
1047
+ undefined,
1048
+ rootBackend(),
1049
+ );
1050
+
1051
+ expect(args(tc).content).toBeUndefined(); // content never overlaid
1052
+ expect(tc.argsPreview).not.toContain("super-secret");
1053
+ expect(tc.approvalContentDigest).toBe(""); // digest not stamped from secret content
1054
+ });
1055
+
1028
1056
  it("stamps the captured edit old/new strings and a digest", async () => {
1029
1057
  const tc = toolCall({
1030
1058
  id: "c1",
@@ -403,10 +403,11 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
403
403
  });
404
404
 
405
405
  it("keeps gating a write to a GITIGNORED path (the snapshot cannot revert it)", () => {
406
- // .env-style ignored paths are invisible to the git snapshot, so capture
407
- // mode must still gate them for explicit approval.
408
- const h = setup({ captureMode: true, gitignored: ["secret.txt"] });
409
- expect(h.decide(hookWrite("secret.txt")).permission).toBe("deny");
406
+ // A NON-secret gitignored path is invisible to the git snapshot, so capture
407
+ // mode must still gate it for explicit approval. (A secret-like gitignored
408
+ // write is hard-blocked instead see the deny-gate secret cases below.)
409
+ const h = setup({ captureMode: true, gitignored: ["ignored.txt"] });
410
+ expect(h.decide(hookWrite("ignored.txt")).permission).toBe("deny");
410
411
  expect(h.ledger().map((e) => e.toolName)).toContain("Write");
411
412
  // A non-ignored sibling still flows.
412
413
  expect(h.decide(hookWrite("normal.txt")).permission).toBe("allow");
@@ -418,6 +419,64 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
418
419
  });
419
420
  });
420
421
 
422
+ // Deny-gate secret hard-block (DD-26 #2): with no capture substrate for a write
423
+ // (capture off — the classic deny-gate — or captureIgnored off in a git-no-
424
+ // storage workspace) a secret-like WRITE must NOT surface its content for
425
+ // approval. The hook hard-blocks it with the security message and records NO
426
+ // ledger entry, so it never becomes an approvable WAITING row; a non-secret
427
+ // write still deny-gates, and a delete (content-less) stays gated.
428
+ describe("deny-gate secret hard-block (DD-26 #2)", () => {
429
+ it("hard-blocks a secret-like write and records NO ledger entry", () => {
430
+ const h = setup({}); // captureMode off — the classic deny-gate
431
+ const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
432
+ expect(dec.permission).toBe("deny");
433
+ expect(dec.raw).toContain("blocked for security");
434
+ // SECRET_BLOCKED, not APPROVAL_REQUIRED: the model is told to move on.
435
+ expect(dec.raw.toLowerCase()).toContain("nothing was written");
436
+ expect(dec.raw).not.toContain("submitted to the user for approval");
437
+ expect(h.ledger()).toEqual([]); // never recorded → never approvable
438
+ });
439
+
440
+ it("hard-blocks a secret-like edit (path-fragment match)", () => {
441
+ const h = setup({});
442
+ const dec = h.decide(hookEdit(".ssh/id_rsa"));
443
+ expect(dec.permission).toBe("deny");
444
+ expect(dec.raw).toContain("blocked for security");
445
+ expect(h.ledger()).toEqual([]);
446
+ });
447
+
448
+ it("still deny-gates a NON-secret write and records its content for approval", () => {
449
+ const h = setup({});
450
+ const dec = h.decide(hookWrite("notes.md", "hello"));
451
+ expect(dec.permission).toBe("deny");
452
+ expect(dec.raw).toContain("submitted to the user for approval"); // APPROVAL_REQUIRED
453
+ expect(h.ledger().map((e) => e.toolName)).toContain("Write");
454
+ });
455
+
456
+ it("a write-category lease does NOT bypass the secret block", () => {
457
+ const h = setup({ leasedCategories: ["write"] });
458
+ expect(h.decide(hookWrite("notes.md", "x")).permission).toBe("allow"); // non-secret rides the lease
459
+ const dec = h.decide(hookWrite(".env", "SECRET")); // secret is still hard-blocked
460
+ expect(dec.permission).toBe("deny");
461
+ expect(dec.raw).toContain("blocked for security");
462
+ expect(h.ledger()).toEqual([]);
463
+ });
464
+
465
+ it("hard-blocks a secret write in a git workspace with captureIgnored off (no storage)", () => {
466
+ const h = setup({ captureMode: true, captureIgnored: false, gitignored: [".env"] });
467
+ const dec = h.decide(hookWrite(".env", "API_KEY=abc"));
468
+ expect(dec.permission).toBe("deny");
469
+ expect(dec.raw).toContain("blocked for security");
470
+ expect(h.ledger()).toEqual([]);
471
+ });
472
+
473
+ it("does NOT hard-block a secret DELETE (no content; stays deny-gated)", () => {
474
+ const h = setup({});
475
+ expect(h.decide(hookDelete(".env")).permission).toBe("deny");
476
+ expect(h.ledger().map((e) => e.toolName)).toContain("Delete");
477
+ });
478
+ });
479
+
421
480
  // CAS parity (DD-18): with captureIgnored on, a non-secret gitignored write no
422
481
  // longer stays on the deny-gate — the hook stages its pre-turn bytes into the
423
482
  // cas-observations sidecar and ALLOWS it (apply-then-review), while a secret-
@@ -249,9 +249,15 @@ describe("provisionCursorWorkspace", () => {
249
249
  expect(existsSync(join(workspaceRoot, ".git"))).toBe(false);
250
250
  }, GIT_TEST_TIMEOUT_MS);
251
251
 
252
- it("falls back to the workspace root when there are no workspace entries", async () => {
252
+ it("gives a session with no workspace entries its own empty per-session directory", async () => {
253
+ // SessionSpec.workspace_entries: "When empty, the session uses an empty
254
+ // workspace directory." The shared root would leak other sessions' files
255
+ // into a "new" session (and falsely serialize unrelated sessions under
256
+ // the workspace turn lock), so no-entry sessions get sessions/{sessionId}.
253
257
  const workspaceRoot = join(tmpRoot, "empty-workspace");
254
258
  mkdirSync(workspaceRoot, { recursive: true });
259
+ // Leftovers from other sessions at the shared root must not be visible.
260
+ writeFileSync(join(workspaceRoot, "other-session-leftover.md"), "not yours");
255
261
 
256
262
  const dirs = await provisionCursorWorkspace(
257
263
  makeConfig(workspaceRoot),
@@ -260,7 +266,35 @@ describe("provisionCursorWorkspace", () => {
260
266
  "test-session-empty",
261
267
  );
262
268
 
263
- expect(dirs).toEqual([workspaceRoot]);
269
+ expect(dirs).toEqual([join(workspaceRoot, "sessions", "test-session-empty")]);
270
+ expect(existsSync(dirs[0])).toBe(true);
271
+ expect(existsSync(join(dirs[0], "other-session-leftover.md"))).toBe(false);
272
+ }, GIT_TEST_TIMEOUT_MS);
273
+
274
+ it("resolves the same per-session directory on every turn of a no-entry session", async () => {
275
+ const workspaceRoot = join(tmpRoot, "empty-workspace-stable");
276
+ mkdirSync(workspaceRoot, { recursive: true });
277
+ const config = makeConfig(workspaceRoot);
278
+
279
+ const turn1 = await provisionCursorWorkspace(config, emptySession(), {}, "stable-session");
280
+ writeFileSync(join(turn1[0], "notes.md"), "turn 1 output");
281
+ const turn2 = await provisionCursorWorkspace(config, emptySession(), {}, "stable-session");
282
+
283
+ expect(turn2).toEqual(turn1);
284
+ expect(readFileSync(join(turn2[0], "notes.md"), "utf-8")).toBe("turn 1 output");
285
+ }, GIT_TEST_TIMEOUT_MS);
286
+
287
+ it("isolates distinct no-entry sessions from each other", async () => {
288
+ const workspaceRoot = join(tmpRoot, "empty-workspace-isolated");
289
+ mkdirSync(workspaceRoot, { recursive: true });
290
+ const config = makeConfig(workspaceRoot);
291
+
292
+ const [dirA] = await provisionCursorWorkspace(config, emptySession(), {}, "session-a");
293
+ const [dirB] = await provisionCursorWorkspace(config, emptySession(), {}, "session-b");
294
+
295
+ expect(dirA).not.toBe(dirB);
296
+ writeFileSync(join(dirA, "a.md"), "session a");
297
+ expect(existsSync(join(dirB, "a.md"))).toBe(false);
264
298
  }, GIT_TEST_TIMEOUT_MS);
265
299
 
266
300
  it("mounts a local-path workspace entry without cloning", async () => {
@@ -1,78 +1,160 @@
1
1
  /**
2
2
  * Resolves execution attachments into the platform-managed directory.
3
3
  *
4
- * Attachments are files provided as inputs to the agent execution.
5
- * In local mode, they are read directly from localPath. The files are
6
- * placed under .stigmer/inputs/ using the platform mount pattern.
4
+ * Attachments are files provided as inputs to the agent execution. Each is
5
+ * materialized under the session platform dir's `inputs/` subdirectory and
6
+ * surfaced to the agent as `.stigmer/inputs/{filename}` through the workspace
7
+ * `.stigmer` symlink (see stigmer-link.ts), which this resolver guarantees —
8
+ * an agent with attachments but no skills still gets the link.
9
+ *
10
+ * Content sources, in order:
11
+ * - `localPath` (local mode only): read straight off the caller's disk — the
12
+ * CLI fast path, no storage round-trip.
13
+ * - `storageKey`: downloaded via {@link ArtifactStorage} — the universal path;
14
+ * the backend's create pipeline requires a storage key on every attachment
15
+ * it accepts, and in local mode the storage reads directly off disk.
16
+ *
17
+ * Placement is always `inputs/{filename}` — the platform namespace this
18
+ * harness can surface in the workspace. An attachment's `mountPath` is
19
+ * honored by convention, not mechanism: the standard mounts (e.g. the
20
+ * approved plan at `.stigmer/inputs/plan.md`) resolve to exactly this
21
+ * placement, and the prompt's `<input_files>` section plus any path-derived
22
+ * directives are built from the RESOLVED paths, so prompt and filesystem can
23
+ * never disagree.
24
+ *
25
+ * Error model: fail-hard, matching the native harness's attachment injector.
26
+ * Attachments are explicit user inputs — an execution that silently runs
27
+ * without one produces silently incorrect results (the "plan file wasn't
28
+ * found" class of failure). Any attachment that cannot be materialized aborts
29
+ * the execution with an actionable error.
7
30
  */
8
31
 
9
- import { mkdir, copyFile } from "node:fs/promises";
32
+ import { mkdir, copyFile, writeFile } from "node:fs/promises";
10
33
  import { join, basename } from "node:path";
11
34
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
35
+ import type { ArtifactStorage } from "../../shared/artifact-storage.js";
12
36
  import { getPlatformDir } from "../../shared/workspace/platform-dir.js";
37
+ import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "./stigmer-link.js";
13
38
 
14
- const STIGMER_LOCAL_STATE_DIR = ".stigmer";
15
39
  const INPUTS_SUBDIR = "inputs";
16
40
 
17
41
  export interface ResolvedAttachment {
18
42
  filename: string;
43
+ /** Workspace-relative path the agent reads (`.stigmer/inputs/{filename}`). */
19
44
  relativePath: string;
20
45
  }
21
46
 
47
+ export interface AttachmentResolverOptions {
48
+ sessionId: string;
49
+ primaryWorkspaceDir: string;
50
+ mode: "local" | "cloud";
51
+ /**
52
+ * Artifact storage for `storageKey` downloads. `undefined` when the runner
53
+ * could not build a store (proxy misconfig) — a storage-backed attachment
54
+ * then fails with an actionable error rather than a silent skip.
55
+ */
56
+ storage: ArtifactStorage | undefined;
57
+ }
58
+
59
+ export class AttachmentResolutionError extends Error {
60
+ readonly attachmentFilename: string;
61
+ readonly reason: string;
62
+
63
+ constructor(attachmentFilename: string, reason: string) {
64
+ super(`Attachment '${attachmentFilename}': ${reason}`);
65
+ this.name = "AttachmentResolutionError";
66
+ this.attachmentFilename = attachmentFilename;
67
+ this.reason = reason;
68
+ }
69
+ }
70
+
22
71
  /**
23
- * Resolve attachments by copying them to the platform-managed directory.
24
- *
25
- * In local mode: copies from localPath to .stigmer/inputs/{filename}
26
- * Returns relative paths for prompt injection.
72
+ * Materialize all attachments under `.stigmer/inputs/` and return their
73
+ * workspace-relative paths for prompt injection. Throws
74
+ * {@link AttachmentResolutionError} on the first attachment that cannot be
75
+ * materialized (fail-hard see module doc).
27
76
  */
28
77
  export async function resolveAttachments(
29
78
  attachments: Attachment[],
30
- sessionId: string,
31
- primaryWorkspaceDir: string,
32
- mode: "local" | "cloud",
79
+ options: AttachmentResolverOptions,
33
80
  ): Promise<ResolvedAttachment[]> {
34
81
  if (attachments.length === 0) return [];
35
82
 
36
- const platformDir = getPlatformDir(sessionId);
83
+ const platformDir = getPlatformDir(options.sessionId);
37
84
  const inputsDir = join(platformDir, INPUTS_SUBDIR);
38
85
  await mkdir(inputsDir, { recursive: true });
39
86
 
40
- const results: ResolvedAttachment[] = [];
87
+ // The symlink is what makes `inputs/` visible in the workspace; without it
88
+ // every resolved path below would dangle (the skill resolver also ensures
89
+ // it, but only when the agent has skills).
90
+ await ensureStigmerSymlink(options.primaryWorkspaceDir, platformDir);
41
91
 
92
+ const results: ResolvedAttachment[] = [];
42
93
  for (const attachment of attachments) {
43
- try {
44
- const resolved = await resolveAttachment(attachment, inputsDir, mode);
45
- if (resolved) results.push(resolved);
46
- } catch (err) {
47
- console.warn(
48
- `Failed to resolve attachment ${attachment.filename}: ${err instanceof Error ? err.message : err}`,
49
- );
50
- }
94
+ results.push(await resolveAttachment(attachment, inputsDir, options));
51
95
  }
52
96
 
97
+ console.log(
98
+ `[attachment-resolver] resolved ${results.length} attachment(s): ` +
99
+ results.map((r) => r.relativePath).join(", "),
100
+ );
101
+
53
102
  return results;
54
103
  }
55
104
 
56
105
  async function resolveAttachment(
57
106
  attachment: Attachment,
58
107
  inputsDir: string,
59
- mode: "local" | "cloud",
60
- ): Promise<ResolvedAttachment | null> {
61
- if (mode === "local" && attachment.localPath) {
108
+ options: AttachmentResolverOptions,
109
+ ): Promise<ResolvedAttachment> {
110
+ // Local-mode fast path: the file is already on this machine's disk.
111
+ if (options.mode === "local" && attachment.localPath) {
62
112
  const filename = attachment.filename || basename(attachment.localPath);
63
- const destPath = join(inputsDir, filename);
64
- await copyFile(attachment.localPath, destPath);
65
-
113
+ try {
114
+ await copyFile(attachment.localPath, join(inputsDir, filename));
115
+ } catch (err) {
116
+ throw new AttachmentResolutionError(
117
+ attachment.filename,
118
+ `failed to copy local file '${attachment.localPath}': ` +
119
+ `${err instanceof Error ? err.message : String(err)}`,
120
+ );
121
+ }
66
122
  return {
67
123
  filename,
68
124
  relativePath: join(STIGMER_LOCAL_STATE_DIR, INPUTS_SUBDIR, filename),
69
125
  };
70
126
  }
71
127
 
72
- // Cloud mode: download from storage_key (not yet implemented for cursor-runner)
73
- console.warn(
74
- `Attachment ${attachment.filename}: cloud storage download not yet implemented for cursor-runner`,
75
- );
76
- return null;
77
- }
128
+ // Universal path: download the uploaded content by storage key.
129
+ if (!attachment.storageKey) {
130
+ throw new AttachmentResolutionError(
131
+ attachment.filename,
132
+ "missing storageKey — cannot download attachment from storage",
133
+ );
134
+ }
135
+ if (!options.storage) {
136
+ throw new AttachmentResolutionError(
137
+ attachment.filename,
138
+ `artifact storage is unavailable, so this attachment ` +
139
+ `(key: ${attachment.storageKey}) cannot be downloaded`,
140
+ );
141
+ }
78
142
 
143
+ const filename = attachment.filename || basename(attachment.storageKey);
144
+ let content: Buffer;
145
+ try {
146
+ content = await options.storage.download(attachment.storageKey);
147
+ } catch (err) {
148
+ throw new AttachmentResolutionError(
149
+ attachment.filename,
150
+ `failed to download from storage (key: ${attachment.storageKey}): ` +
151
+ `${err instanceof Error ? err.message : String(err)}`,
152
+ );
153
+ }
154
+ await writeFile(join(inputsDir, filename), content);
155
+
156
+ return {
157
+ filename,
158
+ relativePath: join(STIGMER_LOCAL_STATE_DIR, INPUTS_SUBDIR, filename),
159
+ };
160
+ }
@@ -143,6 +143,46 @@ function regexLiteral(re: RegExp): string {
143
143
  return `new RegExp(${JSON.stringify(re.source)},${JSON.stringify(re.flags)})`;
144
144
  }
145
145
 
146
+ /**
147
+ * The JS lines that define `isSecret(p)` inside a hook-embedded script — a
148
+ * byte-identical mirror of {@link isSecretLikePath} generated from the SAME
149
+ * {@link SECRET_BASENAME_PATTERNS}/{@link SECRET_PATH_PATTERNS} arrays.
150
+ *
151
+ * ONE definition, so every hook-side classifier (the capture-mode staging script
152
+ * and the deny-gate classify-only script) agrees with the runner-side classifier
153
+ * by construction — the "no bash-regex, no divergence" invariant this module owns.
154
+ * Authored as single-quoted-bash strings, so the JS must contain no single quotes.
155
+ */
156
+ function secretClassifierFragment(): string[] {
157
+ const base = SECRET_BASENAME_PATTERNS.map(regexLiteral).join(",");
158
+ const pathPatterns = SECRET_PATH_PATTERNS.map(regexLiteral).join(",");
159
+ return [
160
+ `const baseName=(p)=>{const n=p.replace(/\\\\/g,"/");const i=n.lastIndexOf("/");return i>=0?n.slice(i+1):n;};`,
161
+ `const BASE=[${base}];const PATHP=[${pathPatterns}];`,
162
+ `const isSecret=(p)=>{if(!p)return true;const nm=p.replace(/\\\\/g,"/");const b=baseName(nm).toLowerCase();for(const r of BASE){if(r.test(b))return true;}const lp=nm.toLowerCase();for(const r of PATHP){if(r.test(lp))return true;}return false;};`,
163
+ ];
164
+ }
165
+
166
+ /**
167
+ * Build a standalone Node.js script that CLASSIFIES a single path as secret-like
168
+ * without staging anything — the deny-gate (no-capture-substrate) counterpart of
169
+ * {@link buildObservationStagingScript}.
170
+ *
171
+ * Invoked as `printf %s "$SALIENT" | node -e '<this>'` — the path rides stdin (no
172
+ * argv escaping). Prints `secret` for a secret-like path, `ok` otherwise. Empty
173
+ * input classifies as `secret` (fail-closed, matching {@link isSecretLikePath}).
174
+ * The Cursor hook uses this to hard-block a secret write on the deny-gate so its
175
+ * content never reaches an approval (DD-26 follow-up #2); it shares the classifier
176
+ * fragment above, so its verdict equals the runner's {@link isSecretLikePath}.
177
+ */
178
+ export function buildSecretClassifyScript(): string {
179
+ return [
180
+ `let salient="";try{salient=require("fs").readFileSync(0,"utf8");}catch(e){salient="";}`,
181
+ ...secretClassifierFragment(),
182
+ `process.stdout.write(isSecret(salient)?"secret":"ok");`,
183
+ ].join("");
184
+ }
185
+
146
186
  /**
147
187
  * Build the standalone Node.js script the hook runs to observe a single
148
188
  * gitignored write — the disk-backed mirror of `CasCaptureFilesystemBackend`'s
@@ -165,8 +205,6 @@ function regexLiteral(re: RegExp): string {
165
205
  * single-quoted-bash string, so the JS must contain no single quotes.
166
206
  */
167
207
  export function buildObservationStagingScript(): string {
168
- const base = SECRET_BASENAME_PATTERNS.map(regexLiteral).join(",");
169
- const pathPatterns = SECRET_PATH_PATTERNS.map(regexLiteral).join(",");
170
208
  const metaSuffix = JSON.stringify(META_SUFFIX);
171
209
  const blobSuffix = JSON.stringify(BLOB_SUFFIX);
172
210
  return [
@@ -182,9 +220,7 @@ export function buildObservationStagingScript(): string {
182
220
  `let rel=path.relative(wsRoot,abs).split(path.sep).join("/");`,
183
221
  `if(!rel||rel===".."||rel.startsWith("../")){process.stdout.write("error");process.exit(0);}`,
184
222
  // --- secret classifier: byte-identical mirror of isSecretLikePath ---
185
- `const baseName=(p)=>{const n=p.replace(/\\\\/g,"/");const i=n.lastIndexOf("/");return i>=0?n.slice(i+1):n;};`,
186
- `const BASE=[${base}];const PATHP=[${pathPatterns}];`,
187
- `const isSecret=(p)=>{if(!p)return true;const nm=p.replace(/\\\\/g,"/");const b=baseName(nm).toLowerCase();for(const r of BASE){if(r.test(b))return true;}const lp=nm.toLowerCase();for(const r of PATHP){if(r.test(lp))return true;}return false;};`,
223
+ ...secretClassifierFragment(),
188
224
  `const key=crypto.createHash("sha256").update(rel,"utf8").digest("hex");`,
189
225
  `const metaPath=path.join(obsDir,key+${metaSuffix});const blobPath=path.join(obsDir,key+${blobSuffix});`,
190
226
  `try{fs.mkdirSync(obsDir,{recursive:true});}catch(e){}`,
@@ -92,7 +92,7 @@ import {
92
92
  EDIT_NEW_FIELDS,
93
93
  WRITE_CONTENT_FIELDS,
94
94
  } from "../../shared/file-tools.js";
95
- import { buildObservationStagingScript, CAS_OBSERVATIONS_DIRNAME } from "./cas-observations.js";
95
+ import { buildObservationStagingScript, buildSecretClassifyScript, CAS_OBSERVATIONS_DIRNAME } from "./cas-observations.js";
96
96
 
97
97
  // Shown to the model when the gate denies a tool call. It must NOT teach the
98
98
  // model to ask for permission in prose or to "stop and wait" — that framing
@@ -275,6 +275,7 @@ export function generateHookScript(activePointerPath: string, workspaceRoot = ""
275
275
  const categoryCaseArms = buildCategoryCaseArms();
276
276
  const nodeIdentityScript = buildNodeIdentityScript();
277
277
  const observationStagingScript = buildObservationStagingScript();
278
+ const secretClassifyScript = buildSecretClassifyScript();
278
279
  const nodeBin = process.execPath;
279
280
  return `#!/bin/bash
280
281
  # Stigmer HITL approval hook for Cursor (preToolUse + beforeMCPExecution).
@@ -569,6 +570,23 @@ if [ -n "$CATEGORY" ]; then
569
570
  exit 0
570
571
  fi
571
572
  fi
573
+ # Deny-gate secret hard-block (DD-26 #2): a secret-like WRITE that reaches the
574
+ # deny-gate (no capture substrate for it — capture off, or captureIgnored off in
575
+ # a git-no-storage workspace) must not surface its content for approval. Classify
576
+ # byte-identically to isSecretLikePath (no staging) and, on a match, hard-block
577
+ # with the security message: the agent continues, nothing is written, and NO
578
+ # content is recorded to the ledger. Placed before the grant/lease checks so a
579
+ # write-category lease can never bypass it; only writes are content-bearing
580
+ # (deletes stay gated). Fail-open on a classify error — the write falls to the
581
+ # normal deny-gate and the runner-side backstop still withholds any secret
582
+ # content before persist.
583
+ if [ "$CATEGORY" = "write" ] && [ -n "$SALIENT" ]; then
584
+ SECRET_RESULT=$(printf '%s' "$SALIENT" | ELECTRON_RUN_AS_NODE=1 "$NODE_BIN" -e '${secretClassifyScript}' 2>/dev/null || echo ok)
585
+ if [ "$SECRET_RESULT" = "secret" ]; then
586
+ echo '{"permission":"deny","agent_message":"${SECRET_BLOCKED_AGENT_MESSAGE}","user_message":"Blocked for security: this file matches a secret-like path and was not written."}'
587
+ exit 0
588
+ fi
589
+ fi
572
590
  # Reinvocation grant: allow when the CONTENT-exact token (a file edit approved
573
591
  # earlier with this exact content) OR the COARSE token (a shell/delete, or the
574
592
  # content-less degrade) is in approvedGrantTokens. A sibling edit to the same