@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
- package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
- package/dist/activities/execute-cursor/cas-observations.js +39 -5
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +19 -1
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -0
- package/dist/activities/execute-cursor/index.js +102 -22
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.js +10 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
- package/dist/activities/execute-cursor/prompt-builder.js +38 -20
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
- package/dist/activities/execute-cursor/skill-resolver.js +3 -55
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
- package/dist/activities/execute-cursor/stigmer-link.js +73 -0
- package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
- package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
- package/dist/activities/execute-cursor/workspace-provision.js +5 -3
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +59 -2
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +22 -16
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +36 -6
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +32 -0
- package/dist/shared/artifact-storage.js +74 -1
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/implement-plan-prompt.d.ts +42 -0
- package/dist/shared/implement-plan-prompt.js +66 -0
- package/dist/shared/implement-plan-prompt.js.map +1 -0
- package/dist/shared/plan-mode-prompt.d.ts +30 -0
- package/dist/shared/plan-mode-prompt.js +51 -0
- package/dist/shared/plan-mode-prompt.js.map +1 -0
- package/dist/shared/status.d.ts +11 -0
- package/dist/shared/status.js +12 -2
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-row.d.ts +30 -1
- package/dist/shared/tool-row.js +53 -4
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/session-root.d.ts +35 -0
- package/dist/shared/workspace/session-root.js +53 -0
- package/dist/shared/workspace/session-root.js.map +1 -0
- package/dist/shared/workspace/workspace-lock.d.ts +86 -0
- package/dist/shared/workspace/workspace-lock.js +187 -0
- package/dist/shared/workspace/workspace-lock.js.map +1 -0
- package/package.json +7 -5
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +125 -2
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
- package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
- package/src/activities/execute-cursor/cas-observations.ts +41 -5
- package/src/activities/execute-cursor/hook-script.ts +19 -1
- package/src/activities/execute-cursor/index.ts +114 -26
- package/src/activities/execute-cursor/message-translator.ts +9 -1
- package/src/activities/execute-cursor/prompt-builder.ts +60 -22
- package/src/activities/execute-cursor/skill-resolver.ts +3 -59
- package/src/activities/execute-cursor/stigmer-link.ts +78 -0
- package/src/activities/execute-cursor/workspace-provision.ts +5 -3
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +88 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
- package/src/activities/execute-deep-agent/index.ts +70 -2
- package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
- package/src/activities/execute-deep-agent/setup.ts +25 -17
- package/src/config.ts +17 -0
- package/src/middleware/__tests__/approval-gate.test.ts +77 -12
- package/src/middleware/approval-gate.ts +39 -7
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +111 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +48 -0
- package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
- package/src/shared/__tests__/status.test.ts +7 -2
- package/src/shared/__tests__/tool-row.test.ts +136 -1
- package/src/shared/artifact-storage.ts +83 -1
- package/src/shared/implement-plan-prompt.ts +73 -0
- package/src/shared/plan-mode-prompt.ts +51 -0
- package/src/shared/status.ts +11 -1
- package/src/shared/tool-row.ts +58 -5
- package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
- package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
- package/src/shared/workspace/session-root.ts +60 -0
- 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
|
-
//
|
|
407
|
-
// mode must still gate
|
|
408
|
-
|
|
409
|
-
|
|
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("
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
): Promise<ResolvedAttachment
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|