@stigmer/runner 3.0.9-dev.20260615153829 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/__test-utils__/approval-contract/types.d.ts +174 -0
- package/dist/__test-utils__/approval-contract/types.js +24 -0
- package/dist/__test-utils__/approval-contract/types.js.map +1 -0
- package/dist/activities/call-agent-status.d.ts +19 -1
- package/dist/activities/call-agent-status.js +64 -5
- package/dist/activities/call-agent-status.js.map +1 -1
- package/dist/activities/call-llm.js +19 -53
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/classify-tool-approvals.d.ts +24 -0
- package/dist/activities/classify-tool-approvals.js +69 -17
- package/dist/activities/classify-tool-approvals.js.map +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +11 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +140 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +130 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.d.ts +18 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +123 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -0
- package/dist/activities/execute-cursor/approval-policy.d.ts +17 -61
- package/dist/activities/execute-cursor/approval-policy.js +8 -120
- package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +219 -9
- package/dist/activities/execute-cursor/approval-state.js +233 -17
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +127 -0
- package/dist/activities/execute-cursor/capture-flow.js +234 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -0
- package/dist/activities/execute-cursor/cas-observations.d.ts +96 -0
- package/dist/activities/execute-cursor/cas-observations.js +184 -0
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -0
- package/dist/activities/execute-cursor/command-provenance.d.ts +62 -0
- package/dist/activities/execute-cursor/command-provenance.js +128 -0
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -0
- package/dist/activities/execute-cursor/exact-apply.d.ts +110 -0
- package/dist/activities/execute-cursor/exact-apply.js +204 -0
- package/dist/activities/execute-cursor/exact-apply.js.map +1 -0
- package/dist/activities/execute-cursor/hook-script.d.ts +53 -24
- package/dist/activities/execute-cursor/hook-script.js +310 -47
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +8 -1
- package/dist/activities/execute-cursor/index.js +500 -64
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +258 -11
- package/dist/activities/execute-cursor/message-translator.js +836 -90
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +20 -23
- package/dist/activities/execute-cursor/prompt-builder.js +74 -15
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +17 -0
- package/dist/activities/execute-cursor/workspace-setup.js +212 -33
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.d.ts +19 -0
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js +143 -0
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js.map +1 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +88 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +81 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -0
- package/dist/activities/execute-deep-agent/approval-file-change.d.ts +47 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js +68 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -0
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +8 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js +7 -7
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +42 -0
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +47 -0
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.d.ts +79 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.js +112 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.js.map +1 -0
- package/dist/activities/execute-deep-agent/hitl.d.ts +16 -3
- package/dist/activities/execute-deep-agent/hitl.js +11 -3
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.d.ts +2 -1
- package/dist/activities/execute-deep-agent/index.js +393 -33
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +7 -1
- package/dist/activities/execute-deep-agent/inline-publisher.js +23 -2
- package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +53 -2
- package/dist/activities/execute-deep-agent/setup.js +149 -92
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.d.ts +36 -0
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.js +56 -0
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.js.map +1 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +34 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +26 -25
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.d.ts +17 -5
- package/dist/activities/execute-deep-agent/status-builder.js +44 -3
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.js +2 -19
- package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.js +3 -15
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +25 -7
- package/dist/activities/execute-deep-agent/subagent-transformer.js +23 -7
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +30 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +29 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +32 -2
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/claimcheck/payload-codec.js +9 -5
- package/dist/claimcheck/payload-codec.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +2 -0
- package/dist/client/stigmer-client.js +2 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +85 -4
- package/dist/middleware/approval-gate.js +165 -38
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/types.d.ts +2 -5
- package/dist/shared/activity-input.d.ts +43 -0
- package/dist/shared/activity-input.js +17 -0
- package/dist/shared/activity-input.js.map +1 -0
- package/dist/shared/approval-canonicalize.d.ts +19 -0
- package/dist/shared/approval-canonicalize.js +119 -0
- package/dist/shared/approval-canonicalize.js.map +1 -0
- package/dist/shared/approval-fingerprint.d.ts +106 -0
- package/dist/shared/approval-fingerprint.js +113 -0
- package/dist/shared/approval-fingerprint.js.map +1 -0
- package/dist/shared/approval-policy.d.ts +182 -12
- package/dist/shared/approval-policy.js +213 -27
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/args-preview.d.ts +52 -0
- package/dist/shared/args-preview.js +93 -0
- package/dist/shared/args-preview.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +19 -1
- package/dist/shared/artifact-storage.js +48 -11
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/file-change.d.ts +44 -0
- package/dist/shared/file-change.js +57 -0
- package/dist/shared/file-change.js.map +1 -0
- package/dist/shared/file-tools.d.ts +107 -0
- package/dist/shared/file-tools.js +168 -0
- package/dist/shared/file-tools.js.map +1 -0
- package/dist/shared/filereview/capture.d.ts +202 -0
- package/dist/shared/filereview/capture.js +498 -0
- package/dist/shared/filereview/capture.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +190 -0
- package/dist/shared/filereview/cas-substrate.js +284 -0
- package/dist/shared/filereview/cas-substrate.js.map +1 -0
- package/dist/shared/filereview/digest.d.ts +40 -0
- package/dist/shared/filereview/digest.js +66 -0
- package/dist/shared/filereview/digest.js.map +1 -0
- package/dist/shared/filereview/events.d.ts +170 -0
- package/dist/shared/filereview/events.js +298 -0
- package/dist/shared/filereview/events.js.map +1 -0
- package/dist/shared/filereview/git-substrate.d.ts +175 -0
- package/dist/shared/filereview/git-substrate.js +439 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -0
- package/dist/shared/filereview/index.d.ts +11 -0
- package/dist/shared/filereview/index.js +12 -0
- package/dist/shared/filereview/index.js.map +1 -0
- package/dist/shared/filereview/secret-paths.d.ts +63 -0
- package/dist/shared/filereview/secret-paths.js +105 -0
- package/dist/shared/filereview/secret-paths.js.map +1 -0
- package/dist/shared/fingerprint-secret.d.ts +26 -0
- package/dist/shared/fingerprint-secret.js +47 -0
- package/dist/shared/fingerprint-secret.js.map +1 -0
- package/dist/shared/model-client.d.ts +51 -0
- package/dist/shared/model-client.js +77 -0
- package/dist/shared/model-client.js.map +1 -0
- package/dist/shared/plan-artifact.js +0 -2
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/status-offload.d.ts +83 -9
- package/dist/shared/status-offload.js +399 -79
- package/dist/shared/status-offload.js.map +1 -1
- package/dist/shared/status.js +14 -1
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-kind.d.ts +19 -0
- package/dist/shared/tool-kind.js +13 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/shared/tool-row.d.ts +88 -0
- package/dist/shared/tool-row.js +127 -0
- package/dist/shared/tool-row.js.map +1 -0
- package/dist/shared/workspace/platform-dir.d.ts +25 -0
- package/dist/shared/workspace/platform-dir.js +38 -2
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.js +56 -7
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/dist/workflows/connect-mcp-server.d.ts +50 -0
- package/dist/workflows/connect-mcp-server.js +136 -15
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +2 -2
- package/src/__test-utils__/approval-contract/contract.ts +224 -0
- package/src/__test-utils__/approval-contract/types.ts +179 -0
- package/src/__test-utils__/fake-artifact-storage.ts +72 -0
- package/src/__tests__/approval-gateway-contract.test.ts +29 -0
- package/src/__tests__/claimcheck-codec.test.ts +16 -53
- package/src/__tests__/golden-e2e.test.ts +2 -0
- package/src/__tests__/runner-token-coordinator.test.ts +3 -3
- package/src/activities/__tests__/call-agent-status.test.ts +135 -0
- package/src/activities/__tests__/call-llm.test.ts +1 -1
- package/src/activities/__tests__/classify-tool-approvals.test.ts +208 -1
- package/src/activities/__tests__/discover-mcp-server.test.ts +30 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +2 -1
- package/src/activities/call-agent-status.ts +74 -4
- package/src/activities/call-llm.ts +18 -63
- package/src/activities/classify-tool-approvals.ts +101 -19
- package/src/activities/discover-mcp-server.ts +29 -1
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +216 -0
- package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +148 -0
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +41 -9
- package/src/activities/execute-cursor/__tests__/approval-state.test.ts +292 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +68 -1
- package/src/activities/execute-cursor/__tests__/capture-flow.test.ts +1005 -0
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +187 -0
- package/src/activities/execute-cursor/__tests__/coarse-fingerprint.test.ts +97 -0
- package/src/activities/execute-cursor/__tests__/command-provenance.test.ts +240 -0
- package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +203 -0
- package/src/activities/execute-cursor/__tests__/exact-apply.test.ts +375 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +1294 -24
- package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +446 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +384 -110
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +171 -25
- package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +189 -0
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +44 -23
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +190 -10
- package/src/activities/execute-cursor/approval-policy.ts +28 -159
- package/src/activities/execute-cursor/approval-state.ts +366 -18
- package/src/activities/execute-cursor/capture-flow.ts +323 -0
- package/src/activities/execute-cursor/cas-observations.ts +204 -0
- package/src/activities/execute-cursor/command-provenance.ts +168 -0
- package/src/activities/execute-cursor/exact-apply.ts +253 -0
- package/src/activities/execute-cursor/hook-script.ts +317 -51
- package/src/activities/execute-cursor/index.ts +575 -67
- package/src/activities/execute-cursor/message-translator.ts +963 -89
- package/src/activities/execute-cursor/prompt-builder.ts +80 -14
- package/src/activities/execute-cursor/workspace-setup.ts +257 -42
- package/src/activities/execute-deep-agent/__test-utils__/gateway-substrate.ts +180 -0
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +134 -0
- package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +84 -0
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +11 -24
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +64 -0
- package/src/activities/execute-deep-agent/__tests__/cas-capture-observer.test.ts +163 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-integration.test.ts +8 -5
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +342 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +284 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +14 -36
- package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +31 -13
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +349 -0
- package/src/activities/execute-deep-agent/__tests__/stamp-flowed-rows.test.ts +119 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +91 -10
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +9 -9
- package/src/activities/execute-deep-agent/__tests__/subagent-approval-propagation.test.ts +160 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-gitignored-capture.test.ts +213 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +3 -6
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +84 -1
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +4 -1
- package/src/activities/execute-deep-agent/approval-file-change.ts +80 -0
- package/src/activities/execute-deep-agent/attachment-injector.ts +20 -11
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +66 -0
- package/src/activities/execute-deep-agent/cas-capture-observer.ts +125 -0
- package/src/activities/execute-deep-agent/hitl.ts +23 -7
- package/src/activities/execute-deep-agent/index.ts +463 -40
- package/src/activities/execute-deep-agent/inline-publisher.ts +27 -4
- package/src/activities/execute-deep-agent/setup.ts +223 -125
- package/src/activities/execute-deep-agent/stamp-flowed-rows.ts +64 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +62 -23
- package/src/activities/execute-deep-agent/status-builder.ts +63 -8
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +2 -16
- package/src/activities/execute-deep-agent/streaming.ts +3 -13
- package/src/activities/execute-deep-agent/subagent-transformer.ts +53 -13
- package/src/activities/execute-deep-agent/subagent-wiring.ts +50 -3
- package/src/activities/execute-deep-agent/v3-status-builder.ts +36 -2
- package/src/claimcheck/payload-codec.ts +8 -8
- package/src/client/stigmer-client.ts +9 -1
- package/src/middleware/__tests__/approval-gate.test.ts +488 -4
- package/src/middleware/approval-gate.ts +247 -38
- package/src/middleware/types.ts +5 -5
- package/src/shared/__tests__/activity-input.test.ts +78 -0
- package/src/shared/__tests__/approval-canonicalize.test.ts +106 -0
- package/src/shared/__tests__/approval-fingerprint.test.ts +115 -0
- package/src/shared/__tests__/approval-policy.test.ts +274 -40
- package/src/shared/__tests__/args-preview.test.ts +78 -0
- package/src/shared/__tests__/artifact-storage-extended.test.ts +62 -10
- package/src/shared/__tests__/artifact-storage.test.ts +123 -11
- package/src/shared/__tests__/file-change.test.ts +85 -0
- package/src/shared/__tests__/file-tools.test.ts +90 -0
- package/src/shared/__tests__/fingerprint-secret.test.ts +51 -0
- package/src/shared/__tests__/lease-scope-corpus.test.ts +56 -0
- package/src/shared/__tests__/model-client.test.ts +162 -0
- package/src/shared/__tests__/plan-artifact.test.ts +11 -26
- package/src/shared/__tests__/policy-source-corpus.test.ts +58 -0
- package/src/shared/__tests__/status-offload.test.ts +573 -16
- package/src/shared/__tests__/status.test.ts +4 -5
- package/src/shared/__tests__/tool-kind.test.ts +24 -1
- package/src/shared/__tests__/tool-row.test.ts +221 -0
- package/src/shared/activity-input.ts +57 -0
- package/src/shared/approval-canonicalize.ts +159 -0
- package/src/shared/approval-fingerprint.ts +148 -0
- package/src/shared/approval-policy.ts +303 -27
- package/src/shared/args-preview.ts +98 -0
- package/src/shared/artifact-storage.ts +62 -11
- package/src/shared/checkpointer/__tests__/http-saver.test.ts +1 -2
- package/src/shared/file-change.ts +64 -0
- package/src/shared/file-tools.ts +169 -0
- package/src/shared/filereview/__tests__/capture.test.ts +856 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +404 -0
- package/src/shared/filereview/__tests__/digest.test.ts +100 -0
- package/src/shared/filereview/__tests__/events.test.ts +245 -0
- package/src/shared/filereview/__tests__/git-substrate.test.ts +362 -0
- package/src/shared/filereview/__tests__/proxy-reconcile.test.ts +286 -0
- package/src/shared/filereview/__tests__/secret-paths.test.ts +121 -0
- package/src/shared/filereview/capture.ts +727 -0
- package/src/shared/filereview/cas-substrate.ts +401 -0
- package/src/shared/filereview/digest.ts +83 -0
- package/src/shared/filereview/events.ts +449 -0
- package/src/shared/filereview/git-substrate.ts +555 -0
- package/src/shared/filereview/index.ts +60 -0
- package/src/shared/filereview/secret-paths.ts +121 -0
- package/src/shared/fingerprint-secret.ts +53 -0
- package/src/shared/model-client.ts +122 -0
- package/src/shared/plan-artifact.ts +0 -2
- package/src/shared/status-offload.ts +433 -77
- package/src/shared/status.ts +13 -0
- package/src/shared/tool-kind.ts +33 -0
- package/src/shared/tool-row.ts +135 -0
- package/src/shared/workspace/platform-dir.ts +41 -2
- package/src/workflow-engine/__tests__/golden-execution.test.ts +35 -18
- package/src/workflow-engine/__tests__/tasks/try.test.ts +1 -1
- package/src/workflows/__tests__/connect-mcp-server.test.ts +304 -29
- package/src/workflows/call-agent-orchestrator.ts +53 -6
- package/src/workflows/connect-mcp-server.ts +179 -24
- package/src/workflows/types.ts +8 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* @regression file-hitl-phase0 — pins file-edit HITL fix #2 (see _projects/2026-06/20260630.01.file-change-hitl-redesign/tasks/T01_3_regression-manifest.md)
|
|
3
|
+
*
|
|
2
4
|
* Behavior tests for the generated preToolUse bash hook.
|
|
3
5
|
*
|
|
4
6
|
* These run the ACTUAL bash script the runner writes into the workspace, feeding
|
|
@@ -11,118 +13,51 @@
|
|
|
11
13
|
* Skipped automatically where bash is unavailable.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import { describe, it, expect,
|
|
15
|
-
import { execFileSync
|
|
16
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync
|
|
16
|
+
import { describe, it, expect, onTestFinished } from "vitest";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
17
19
|
import { tmpdir } from "node:os";
|
|
18
20
|
import { join } from "node:path";
|
|
19
21
|
|
|
20
22
|
import { generateHookScript } from "../hook-script.js";
|
|
21
|
-
import { buildApprovalState, grantToken,
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
import { buildApprovalState, grantToken, primaryToken, toolIdentity } from "../approval-state.js";
|
|
24
|
+
import { contentDigest } from "../../../shared/file-tools.js";
|
|
25
|
+
import {
|
|
26
|
+
setupCursorHookHarness as setup,
|
|
27
|
+
hasBash,
|
|
28
|
+
hookWrite,
|
|
29
|
+
hookEdit,
|
|
30
|
+
hookShell,
|
|
31
|
+
hookDelete,
|
|
32
|
+
hookRead,
|
|
33
|
+
hookMcp,
|
|
34
|
+
} from "../__test-utils__/cursor-hook-harness.js";
|
|
35
|
+
|
|
36
|
+
/** Decode the base64(JSON(tool_input)) the hook records on a denial. */
|
|
37
|
+
function decodeInput(b64: string | undefined): Record<string, unknown> {
|
|
38
|
+
if (!b64) throw new Error("ledger entry carried no input");
|
|
39
|
+
return JSON.parse(Buffer.from(b64, "base64").toString("utf-8")) as Record<string, unknown>;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const d = hasBash ? describe : describe.skip;
|
|
33
43
|
|
|
34
|
-
const tempDirs: string[] = [];
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
interface Harness {
|
|
40
|
-
decide(input: object): { permission: string; raw: string };
|
|
41
|
-
ledger(): Array<{ toolName: string; token: string }>;
|
|
42
|
-
resetLedger(): void;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function setup(opts: {
|
|
46
|
-
autoApproveAll?: boolean;
|
|
47
|
-
grants?: ApprovalGrant[];
|
|
48
|
-
mcpPolicies?: Record<string, McpToolPolicyEntry>;
|
|
49
|
-
noStateFile?: boolean;
|
|
50
|
-
// Process the hook treats as "the runner". Defaults to this test process,
|
|
51
|
-
// which is an ancestor of the bash child execFileSync spawns — so the scope
|
|
52
|
-
// guard sees the call as the runner's own agent and applies the gate. Pass a
|
|
53
|
-
// non-ancestor PID to exercise the foreign-client path (issue #173).
|
|
54
|
-
runnerPid?: number;
|
|
55
|
-
}): Harness {
|
|
56
|
-
const ws = mkdtempSync(join(tmpdir(), "hook-script-"));
|
|
57
|
-
tempDirs.push(ws);
|
|
58
|
-
const dir = join(ws, ".cursor", "hooks");
|
|
59
|
-
mkdirSync(dir, { recursive: true });
|
|
60
|
-
const statePath = join(dir, "state.json");
|
|
61
|
-
const ledgerPath = join(dir, "denials.jsonl");
|
|
62
|
-
const scriptPath = join(dir, "hook.sh");
|
|
63
|
-
writeFileSync(scriptPath, generateHookScript(statePath, ledgerPath, opts.runnerPid ?? process.pid), "utf-8");
|
|
64
|
-
|
|
65
|
-
if (!opts.noStateFile) {
|
|
66
|
-
const policies = new Map(
|
|
67
|
-
Object.entries(opts.mcpPolicies ?? {}).map(([name, p]) => [
|
|
68
|
-
`srv/${name}`,
|
|
69
|
-
{ toolName: name, mcpServerSlug: "srv", requiresApproval: p.requiresApproval, approvalMessage: p.message ?? "" },
|
|
70
|
-
]),
|
|
71
|
-
);
|
|
72
|
-
const state = buildApprovalState(policies, opts.autoApproveAll ?? false, opts.grants);
|
|
73
|
-
writeFileSync(statePath, JSON.stringify(state), "utf-8");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
decide(input: object) {
|
|
78
|
-
const raw = execFileSync("bash", [scriptPath], { input: JSON.stringify(input) }).toString();
|
|
79
|
-
const permission = raw.includes('"permission":"deny"') ? "deny" : raw.includes('"permission":"allow"') ? "allow" : "?";
|
|
80
|
-
return { permission, raw };
|
|
81
|
-
},
|
|
82
|
-
ledger() {
|
|
83
|
-
if (!existsSync(ledgerPath)) return [];
|
|
84
|
-
return readFileSync(ledgerPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
85
|
-
},
|
|
86
|
-
resetLedger() {
|
|
87
|
-
writeFileSync(ledgerPath, "", "utf-8");
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Real preToolUse hook-input shapes (PascalCase name, file_path/command in
|
|
93
|
-
// tool_input). These omit hook_event_name on purpose: a payload with no event
|
|
94
|
-
// must still take the built-in arm (the script only diverts to the MCP arm on an
|
|
95
|
-
// explicit beforeMCPExecution).
|
|
96
|
-
const hookWrite = (filePath: string) => ({ tool_name: "Write", tool_input: { file_path: filePath, content: "x" } });
|
|
97
|
-
const hookShell = (command: string) => ({ tool_name: "Shell", tool_input: { command, cwd: "/x", timeout: 30000 } });
|
|
98
|
-
const hookDelete = (filePath: string) => ({ tool_name: "Delete", tool_input: { file_path: filePath } });
|
|
99
|
-
const hookRead = (filePath: string) => ({ tool_name: "Read", tool_input: { file_path: filePath } });
|
|
100
|
-
|
|
101
|
-
// Real beforeMCPExecution shape (captured live): bare tool_name, tool_input as a
|
|
102
|
-
// JSON STRING, server identity, and the hook_event_name discriminator.
|
|
103
|
-
const hookMcp = (name: string, input: Record<string, unknown> = {}) => ({
|
|
104
|
-
tool_name: name,
|
|
105
|
-
tool_input: JSON.stringify(input),
|
|
106
|
-
mcp_server_name: "srv",
|
|
107
|
-
command: "npx -y srv mcp",
|
|
108
|
-
hook_event_name: "beforeMCPExecution",
|
|
109
|
-
});
|
|
110
|
-
|
|
111
44
|
d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
112
|
-
it("denies gated built-ins
|
|
45
|
+
it("denies gated built-ins and records the PRIMARY token (content-exact for a write, coarse for shell/delete)", () => {
|
|
113
46
|
const h = setup({});
|
|
114
47
|
|
|
115
|
-
for (const [input, category, salient] of [
|
|
116
|
-
|
|
117
|
-
[
|
|
118
|
-
[
|
|
48
|
+
for (const [input, category, salient, args] of [
|
|
49
|
+
// hookWrite defaults content "x"; the hook records the content-exact token.
|
|
50
|
+
[hookWrite("/x/a.txt"), "write", "/x/a.txt", { content: "x" }],
|
|
51
|
+
[hookShell("rm -rf build"), "shell", "rm -rf build", {}],
|
|
52
|
+
[hookDelete("/x/b.txt"), "delete", "/x/b.txt", {}],
|
|
119
53
|
] as const) {
|
|
120
54
|
h.resetLedger();
|
|
121
55
|
expect(h.decide(input).permission).toBe("deny");
|
|
122
56
|
const ledger = h.ledger();
|
|
123
57
|
expect(ledger).toHaveLength(1);
|
|
124
|
-
// Byte-identical to the runner's
|
|
125
|
-
|
|
58
|
+
// Byte-identical to the runner's primaryToken: content-exact when the hook
|
|
59
|
+
// can compute an edit digest (the write), else the coarse grantToken.
|
|
60
|
+
expect(ledger[0].token).toBe(primaryToken(category, salient, contentDigest(args)));
|
|
126
61
|
}
|
|
127
62
|
});
|
|
128
63
|
|
|
@@ -137,16 +72,22 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
137
72
|
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("allow");
|
|
138
73
|
});
|
|
139
74
|
|
|
140
|
-
it("
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
75
|
+
it("a run-lifetime category lease allows that category and ONLY that category", () => {
|
|
76
|
+
// "Approve all shell commands" must let later shell calls through while still
|
|
77
|
+
// gating a write — the scoped successor to the old global auto-approve-all.
|
|
78
|
+
const h = setup({ leasedCategories: ["shell"] });
|
|
79
|
+
expect(h.decide(hookShell("rm -rf build")).permission).toBe("allow");
|
|
80
|
+
const writeDecision = h.decide(hookWrite("/x/a.txt"));
|
|
81
|
+
expect(writeDecision.permission).toBe("deny");
|
|
82
|
+
// The denied, non-leased write is still recorded for the runner.
|
|
83
|
+
expect(h.ledger().map((e) => e.toolName)).toContain("Write");
|
|
148
84
|
});
|
|
149
85
|
|
|
86
|
+
// Exact-resource lease isolation ("no name-only over-grant") moved to the
|
|
87
|
+
// gateway Contract Test Kit's invariant 10, where the SAME bash hook is driven
|
|
88
|
+
// through the Cursor substrate adapter alongside the deep-agent substrate. See
|
|
89
|
+
// src/__tests__/approval-gateway-contract.test.ts.
|
|
90
|
+
|
|
150
91
|
it("fails closed (deny) when the state file is missing", () => {
|
|
151
92
|
const h = setup({ noStateFile: true });
|
|
152
93
|
expect(h.decide(hookWrite("/x/a.txt")).permission).toBe("deny");
|
|
@@ -173,6 +114,11 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
173
114
|
// and that it should continue, NOT stop and wait or ask for permission.
|
|
174
115
|
expect(res.raw).toContain("submitted to the user for approval automatically");
|
|
175
116
|
expect(res.raw).toContain("continue with the rest of the task");
|
|
117
|
+
// It must reframe the deny as the approval gate working as intended and
|
|
118
|
+
// forbid the "fix your Cursor settings" narration that the leaky MCP deny
|
|
119
|
+
// path otherwise provokes.
|
|
120
|
+
expect(res.raw.toLowerCase()).toContain("not an error");
|
|
121
|
+
expect(res.raw.toLowerCase()).toContain("cursor settings");
|
|
176
122
|
// The old propose-then-wait framing and internal sentinel must be gone.
|
|
177
123
|
expect(res.raw).not.toContain("STIGMER_APPROVAL_REQUIRED");
|
|
178
124
|
expect(res.raw).not.toContain("Stop and wait");
|
|
@@ -181,7 +127,7 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
181
127
|
it("allows a require-approval MCP tool once it has been granted (reinvocation)", () => {
|
|
182
128
|
const h = setup({
|
|
183
129
|
mcpPolicies: { click: { requiresApproval: true } },
|
|
184
|
-
grants: [{ toolName: "click", mcpServerSlug: "srv", key: "click", salient: "" }],
|
|
130
|
+
grants: [{ toolName: "click", mcpServerSlug: "srv", key: "click", salient: "", contentDigest: "", sourceToolCallId: "consent-1" }],
|
|
185
131
|
});
|
|
186
132
|
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
187
133
|
expect(h.ledger()).toEqual([]);
|
|
@@ -227,6 +173,49 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
227
173
|
});
|
|
228
174
|
});
|
|
229
175
|
|
|
176
|
+
// The hook captures the COMPLETE tool_input on every denial (base64(JSON)),
|
|
177
|
+
// so the runner can overlay the proposed change onto the gated tool call for
|
|
178
|
+
// the approval preview — the cursor analog of the native harness reading args
|
|
179
|
+
// from graph state at the interrupt. These pin the capture across taxonomies
|
|
180
|
+
// (built-in object tool_input vs. MCP JSON-string tool_input).
|
|
181
|
+
describe("captures tool_input on the denial ledger", () => {
|
|
182
|
+
it("records the full built-in write input (object tool_input)", () => {
|
|
183
|
+
const h = setup({});
|
|
184
|
+
expect(h.decide(hookWrite("/x/a.txt", "export const x = 1;\n")).permission).toBe("deny");
|
|
185
|
+
const ledger = h.ledger();
|
|
186
|
+
expect(ledger).toHaveLength(1);
|
|
187
|
+
expect(decodeInput(ledger[0].input)).toEqual({
|
|
188
|
+
file_path: "/x/a.txt",
|
|
189
|
+
content: "export const x = 1;\n",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("records an edit's old/new replacement strings", () => {
|
|
194
|
+
const h = setup({});
|
|
195
|
+
expect(h.decide(hookEdit("/x/a.txt", "alpha", "beta")).permission).toBe("deny");
|
|
196
|
+
expect(decodeInput(h.ledger()[0].input)).toEqual({
|
|
197
|
+
file_path: "/x/a.txt",
|
|
198
|
+
old_string: "alpha",
|
|
199
|
+
new_string: "beta",
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("parses and records the MCP JSON-STRING tool_input", () => {
|
|
204
|
+
// Cursor delivers MCP tool_input as a JSON string, not an object — the
|
|
205
|
+
// extractor must parse it so the captured input is the same object shape.
|
|
206
|
+
const h = setup({ mcpPolicies: { click: { requiresApproval: true } } });
|
|
207
|
+
expect(h.decide(hookMcp("click", { app: "Slack", element_index: "59" })).permission).toBe("deny");
|
|
208
|
+
expect(decodeInput(h.ledger()[0].input)).toEqual({ app: "Slack", element_index: "59" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("captures large multi-line content intact (printf, not echo/argv)", () => {
|
|
212
|
+
const content = "line\n".repeat(20_000); // ~100 KB, exercises the printf write
|
|
213
|
+
const h = setup({});
|
|
214
|
+
expect(h.decide(hookWrite("/x/big.ts", content)).permission).toBe("deny");
|
|
215
|
+
expect(decodeInput(h.ledger()[0].input).content).toBe(content);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
230
219
|
// Regression: the original grep-based extraction truncated string values at
|
|
231
220
|
// the first JSON-escaped character, so a shell command containing double
|
|
232
221
|
// quotes (e.g. `printf '%s' 'x' > "file"`) produced a ledger token that never
|
|
@@ -252,13 +241,100 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
252
241
|
it("allows the exact granted shell command even when it contains quotes", () => {
|
|
253
242
|
const command = 'printf \'%s\' \'hello-resume\' > "/x/resumed-gate.txt"';
|
|
254
243
|
const id = toolIdentity("shell", "", { command });
|
|
255
|
-
const h = setup({ grants: [{ toolName: "shell", mcpServerSlug: "", key: id.key, salient: id.salient }] });
|
|
244
|
+
const h = setup({ grants: [{ toolName: "shell", mcpServerSlug: "", key: id.key, salient: id.salient, contentDigest: "", sourceToolCallId: "consent-1" }] });
|
|
256
245
|
|
|
257
246
|
expect(h.decide(hookShell(command)).permission).toBe("allow");
|
|
258
247
|
// A different command is NOT covered by the grant -> still gated.
|
|
259
248
|
expect(h.decide(hookShell('rm -rf "/x"')).permission).toBe("deny");
|
|
260
249
|
});
|
|
261
250
|
|
|
251
|
+
// The full runner<->hook closure, per gated category and for the bytes most
|
|
252
|
+
// likely to drift: the token the RUNNER writes into approval-state.json (via
|
|
253
|
+
// buildApprovalState, exactly as index.ts does on resume) is byte-identical to
|
|
254
|
+
// the token the HOOK records when it denies the same action, and the hook's
|
|
255
|
+
// grep -qF membership check then ALLOWS the re-issued call. This is the precise
|
|
256
|
+
// production safety property — "approve once, allowed on the resumed turn" —
|
|
257
|
+
// closed end to end rather than asserted on either half alone.
|
|
258
|
+
describe("runner grant <-> hook allow closure (tricky args, every gated category)", () => {
|
|
259
|
+
const cases: Array<{
|
|
260
|
+
label: string;
|
|
261
|
+
streamName: string;
|
|
262
|
+
streamArgs: Record<string, unknown>;
|
|
263
|
+
hookInput: object;
|
|
264
|
+
otherHookInput: object;
|
|
265
|
+
}> = [
|
|
266
|
+
{
|
|
267
|
+
label: "write — path with spaces, quotes, and unicode",
|
|
268
|
+
streamName: "edit",
|
|
269
|
+
// Content matches hookWrite's default ("x") so the runner-side grant
|
|
270
|
+
// digest equals the hook-side content digest (content-exact closure).
|
|
271
|
+
streamArgs: { path: '/work/a dir/"café" notes.md', content: "x" },
|
|
272
|
+
hookInput: hookWrite('/work/a dir/"café" notes.md'),
|
|
273
|
+
otherHookInput: hookWrite("/work/other.md"),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
label: "shell — multi-line heredoc with quotes and unicode",
|
|
277
|
+
streamName: "shell",
|
|
278
|
+
streamArgs: { command: "cat > notes.md << 'EOF'\n# Notes — \"x\" café\nEOF" },
|
|
279
|
+
hookInput: hookShell("cat > notes.md << 'EOF'\n# Notes — \"x\" café\nEOF"),
|
|
280
|
+
otherHookInput: hookShell("rm -rf build"),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
label: "delete — path with quotes",
|
|
284
|
+
streamName: "delete",
|
|
285
|
+
streamArgs: { path: '/work/"old" file.tmp' },
|
|
286
|
+
hookInput: hookDelete('/work/"old" file.tmp'),
|
|
287
|
+
otherHookInput: hookDelete("/work/keep.tmp"),
|
|
288
|
+
},
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
for (const { label, streamName, streamArgs, hookInput, otherHookInput } of cases) {
|
|
292
|
+
it(`${label}: the state-file token the runner writes is the token the hook denies and then allows`, () => {
|
|
293
|
+
// 1. The hook denies the un-granted call and records its identity token.
|
|
294
|
+
const denyHarness = setup({});
|
|
295
|
+
expect(denyHarness.decide(hookInput).permission).toBe("deny");
|
|
296
|
+
const denialLedger = denyHarness.ledger();
|
|
297
|
+
expect(denialLedger).toHaveLength(1);
|
|
298
|
+
const hookDenialToken = denialLedger[0].token;
|
|
299
|
+
|
|
300
|
+
// 2. The runner mints the grant from the STREAM-side identity (the only
|
|
301
|
+
// side it sees) and writes it into the real state file via
|
|
302
|
+
// buildApprovalState — the exact path index.ts takes on resume.
|
|
303
|
+
const id = toolIdentity(streamName, "", streamArgs);
|
|
304
|
+
const state = buildApprovalState(new Map(), false, new Set(), [
|
|
305
|
+
{ toolName: streamName, mcpServerSlug: "", key: id.key, salient: id.salient, contentDigest: contentDigest(streamArgs), sourceToolCallId: "consent-1" },
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
// 3. The state file carries the byte-exact token the hook recomputed —
|
|
309
|
+
// the grep -qF membership the bash hook performs (string equality).
|
|
310
|
+
expect(state.approvedGrantTokens).toContain(hookDenialToken);
|
|
311
|
+
|
|
312
|
+
// 4. With that state file, the hook ALLOWS the re-issued (fresh-id) call
|
|
313
|
+
// and re-gates a DIFFERENT resource of the same category.
|
|
314
|
+
const grantHarness = setup({ grants: state.approvedGrants });
|
|
315
|
+
expect(grantHarness.decide(hookInput).permission).toBe("allow");
|
|
316
|
+
expect(grantHarness.decide(otherHookInput).permission).toBe("deny");
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("sibling isolation: approving one edit does NOT allow a DIFFERENT edit to the same file", () => {
|
|
322
|
+
// The exact reported bug, closed end-to-end through the real hook: approving
|
|
323
|
+
// the rename must re-gate the later `## TODO` edit to the SAME file.
|
|
324
|
+
const path = "/work/notes.md";
|
|
325
|
+
const renameArgs = { path, content: "Planton" };
|
|
326
|
+
const id = toolIdentity("edit", "", renameArgs);
|
|
327
|
+
const state = buildApprovalState(new Map(), false, new Set(), [
|
|
328
|
+
{ toolName: "edit", mcpServerSlug: "", key: id.key, salient: id.salient, contentDigest: contentDigest(renameArgs), sourceToolCallId: "consent-1" },
|
|
329
|
+
]);
|
|
330
|
+
const h = setup({ grants: state.approvedGrants });
|
|
331
|
+
|
|
332
|
+
// The approved edit, re-issued with the SAME content, is allowed.
|
|
333
|
+
expect(h.decide(hookWrite(path, "Planton")).permission).toBe("allow");
|
|
334
|
+
// A DIFFERENT edit to the SAME file is re-gated — the sibling hole is closed.
|
|
335
|
+
expect(h.decide(hookWrite(path, "## TODO")).permission).toBe("deny");
|
|
336
|
+
});
|
|
337
|
+
|
|
262
338
|
// Issue #173: the hook ships on the workspace's shared .cursor/hooks.json, so
|
|
263
339
|
// the user's own Cursor IDE (a DIFFERENT process tree) would load and run it
|
|
264
340
|
// too. The scope guard must allow any invocation that does not descend from
|
|
@@ -303,19 +379,217 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
303
379
|
});
|
|
304
380
|
});
|
|
305
381
|
|
|
382
|
+
// Capture mode (git workspaces): file mutations flow during the turn (the
|
|
383
|
+
// runner captures the whole change set with git and gates it per-file at the
|
|
384
|
+
// turn boundary), while shell/MCP and gitignored writes stay on the deny-gate.
|
|
385
|
+
describe("capture mode (git workspaces)", () => {
|
|
386
|
+
it("allows write/edit/delete to flow and records no denial", () => {
|
|
387
|
+
const h = setup({ captureMode: true });
|
|
388
|
+
expect(h.decide(hookWrite("normal.txt")).permission).toBe("allow");
|
|
389
|
+
expect(h.decide(hookEdit("normal.txt")).permission).toBe("allow");
|
|
390
|
+
expect(h.decide(hookDelete("normal.txt")).permission).toBe("allow");
|
|
391
|
+
expect(h.ledger()).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("still gates shell (it is not git-reversible)", () => {
|
|
395
|
+
const h = setup({ captureMode: true });
|
|
396
|
+
expect(h.decide(hookShell("rm -rf build")).permission).toBe("deny");
|
|
397
|
+
expect(h.ledger().map((e) => e.toolName)).toContain("Shell");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("still gates require-approval MCP tools", () => {
|
|
401
|
+
const h = setup({ captureMode: true, mcpPolicies: { click: { requiresApproval: true } } });
|
|
402
|
+
expect(h.decide(hookMcp("click")).permission).toBe("deny");
|
|
403
|
+
});
|
|
404
|
+
|
|
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");
|
|
410
|
+
expect(h.ledger().map((e) => e.toolName)).toContain("Write");
|
|
411
|
+
// A non-ignored sibling still flows.
|
|
412
|
+
expect(h.decide(hookWrite("normal.txt")).permission).toBe("allow");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("keeps gating a DELETE of a gitignored path", () => {
|
|
416
|
+
const h = setup({ captureMode: true, gitignored: ["secret.txt"] });
|
|
417
|
+
expect(h.decide(hookDelete("secret.txt")).permission).toBe("deny");
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// CAS parity (DD-18): with captureIgnored on, a non-secret gitignored write no
|
|
422
|
+
// longer stays on the deny-gate — the hook stages its pre-turn bytes into the
|
|
423
|
+
// cas-observations sidecar and ALLOWS it (apply-then-review), while a secret-
|
|
424
|
+
// like gitignored write is hard-blocked and recorded as unreviewable. The
|
|
425
|
+
// sidecar is read back through the real reader.
|
|
426
|
+
describe("capture mode + captureIgnored (gitignored CAS capture)", () => {
|
|
427
|
+
it("stages a non-secret gitignored ADD and allows it (before=null)", async () => {
|
|
428
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
429
|
+
expect(h.decide(hookWrite("app.log", "hello")).permission).toBe("allow");
|
|
430
|
+
// Flowed, not denied — it must not become a WAITING_APPROVAL row.
|
|
431
|
+
expect(h.ledger()).toEqual([]);
|
|
432
|
+
const obs = await h.observations();
|
|
433
|
+
expect(obs.secretPaths).toEqual([]);
|
|
434
|
+
expect(obs.captured).toHaveLength(1);
|
|
435
|
+
expect(obs.captured[0].path).toBe("app.log");
|
|
436
|
+
expect(obs.captured[0].before).toBeNull();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("stages a non-secret gitignored MODIFY with the true pre-turn before-bytes", async () => {
|
|
440
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
441
|
+
writeFileSync(join(h.root, "app.log"), "ORIGINAL", "utf-8");
|
|
442
|
+
expect(h.decide(hookWrite("app.log", "NEW")).permission).toBe("allow");
|
|
443
|
+
const obs = await h.observations();
|
|
444
|
+
expect(obs.captured).toHaveLength(1);
|
|
445
|
+
expect(obs.captured[0].before).not.toBeNull();
|
|
446
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("first-touch-wins: two edits to one gitignored path keep the original before", async () => {
|
|
450
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
451
|
+
writeFileSync(join(h.root, "app.log"), "ORIGINAL", "utf-8");
|
|
452
|
+
expect(h.decide(hookWrite("app.log", "FIRST")).permission).toBe("allow");
|
|
453
|
+
// Simulate the first write having applied, then a second edit this turn.
|
|
454
|
+
writeFileSync(join(h.root, "app.log"), "FIRST", "utf-8");
|
|
455
|
+
expect(h.decide(hookEdit("app.log")).permission).toBe("allow");
|
|
456
|
+
const obs = await h.observations();
|
|
457
|
+
expect(obs.captured).toHaveLength(1);
|
|
458
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("hard-blocks a secret-like gitignored write and records NO denial-ledger entry", async () => {
|
|
462
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: [".env"] });
|
|
463
|
+
const d = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
464
|
+
expect(d.permission).toBe("deny");
|
|
465
|
+
expect(d.raw).toContain("blocked for security");
|
|
466
|
+
// A secret is NOT approvable: it must never enter the denial ledger (which
|
|
467
|
+
// drives WAITING_APPROVAL) — it surfaces as DIFF_UNREVIEWABLE instead.
|
|
468
|
+
expect(h.ledger()).toEqual([]);
|
|
469
|
+
const obs = await h.observations();
|
|
470
|
+
expect(obs.captured).toEqual([]);
|
|
471
|
+
expect(obs.secretPaths).toEqual([".env"]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("captures under auto_approve_all too (capture is a turn property, not authorization)", async () => {
|
|
475
|
+
const h = setup({
|
|
476
|
+
captureMode: true,
|
|
477
|
+
captureIgnored: true,
|
|
478
|
+
autoApproveAll: true,
|
|
479
|
+
gitignored: ["*.log", ".env"],
|
|
480
|
+
});
|
|
481
|
+
// Non-secret gitignored write is staged + allowed even under the bypass...
|
|
482
|
+
expect(h.decide(hookWrite("app.log", "x")).permission).toBe("allow");
|
|
483
|
+
// ...and a secret-like one is STILL hard-blocked under the bypass.
|
|
484
|
+
expect(h.decide(hookWrite(".env", "SECRET")).permission).toBe("deny");
|
|
485
|
+
const obs = await h.observations();
|
|
486
|
+
expect(obs.captured.map((c) => c.path)).toEqual(["app.log"]);
|
|
487
|
+
expect(obs.secretPaths).toEqual([".env"]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("does not stage a git-tracked write (git captures it) — flows, no observation", async () => {
|
|
491
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
492
|
+
expect(h.decide(hookWrite("tracked.ts", "x")).permission).toBe("allow");
|
|
493
|
+
const obs = await h.observations();
|
|
494
|
+
expect(obs.captured).toEqual([]);
|
|
495
|
+
expect(obs.secretPaths).toEqual([]);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("still gates a gitignored DELETE (no CAS capture path, parity with deep-agent)", async () => {
|
|
499
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitignored: ["*.log"] });
|
|
500
|
+
expect(h.decide(hookDelete("app.log")).permission).toBe("deny");
|
|
501
|
+
const obs = await h.observations();
|
|
502
|
+
expect(obs.captured).toEqual([]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("with captureIgnored OFF, a gitignored write stays denied and stages nothing", async () => {
|
|
506
|
+
const h = setup({ captureMode: true, captureIgnored: false, gitignored: ["*.log"] });
|
|
507
|
+
expect(h.decide(hookWrite("app.log", "x")).permission).toBe("deny");
|
|
508
|
+
const obs = await h.observations();
|
|
509
|
+
expect(obs.captured).toEqual([]);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Slice 2c: a NON-git workspace has no git snapshot, so EVERY file write is
|
|
514
|
+
// CAS-staged and flowed for review (not only gitignored ones), a delete stays
|
|
515
|
+
// gated (no CAS delete-capture path, parity with the deep-agent), and shell/MCP
|
|
516
|
+
// gate as always. The workspace is deliberately NOT git-initialized.
|
|
517
|
+
describe("non-git workspace CAS capture (Slice 2c)", () => {
|
|
518
|
+
it("stages EVERY write (not just gitignored) and allows it, no denial", async () => {
|
|
519
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
520
|
+
writeFileSync(join(h.root, "notes.md"), "ORIGINAL", "utf-8");
|
|
521
|
+
expect(h.decide(hookWrite("notes.md", "NEW")).permission).toBe("allow");
|
|
522
|
+
expect(h.ledger()).toEqual([]);
|
|
523
|
+
const obs = await h.observations();
|
|
524
|
+
expect(obs.secretPaths).toEqual([]);
|
|
525
|
+
expect(obs.captured).toHaveLength(1);
|
|
526
|
+
expect(obs.captured[0].path).toBe("notes.md");
|
|
527
|
+
expect(Buffer.from(obs.captured[0].before!).toString("utf8")).toBe("ORIGINAL");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("stages an ADD (before=null) and allows it", async () => {
|
|
531
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
532
|
+
expect(h.decide(hookWrite("created.ts", "x")).permission).toBe("allow");
|
|
533
|
+
const obs = await h.observations();
|
|
534
|
+
expect(obs.captured).toHaveLength(1);
|
|
535
|
+
expect(obs.captured[0].before).toBeNull();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("hard-blocks a secret-like write and records NO denial-ledger entry", async () => {
|
|
539
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
540
|
+
const dcn = h.decide(hookWrite(".env", "API_KEY=abc"));
|
|
541
|
+
expect(dcn.permission).toBe("deny");
|
|
542
|
+
expect(dcn.raw).toContain("blocked for security");
|
|
543
|
+
expect(h.ledger()).toEqual([]);
|
|
544
|
+
const obs = await h.observations();
|
|
545
|
+
expect(obs.captured).toEqual([]);
|
|
546
|
+
expect(obs.secretPaths).toEqual([".env"]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("still gates a DELETE (deny-gate, parity with deep-agent) and stages nothing", async () => {
|
|
550
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
551
|
+
expect(h.decide(hookDelete("notes.md")).permission).toBe("deny");
|
|
552
|
+
expect(h.ledger()).toHaveLength(1);
|
|
553
|
+
const obs = await h.observations();
|
|
554
|
+
expect(obs.captured).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("still gates shell (never git-reversible, never CAS-captured)", async () => {
|
|
558
|
+
const h = setup({ captureMode: true, captureIgnored: true, gitWorkspace: false });
|
|
559
|
+
expect(h.decide(hookShell("rm -rf /")).permission).toBe("deny");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("still gates a require-approval MCP tool", async () => {
|
|
563
|
+
const h = setup({
|
|
564
|
+
captureMode: true,
|
|
565
|
+
captureIgnored: true,
|
|
566
|
+
gitWorkspace: false,
|
|
567
|
+
mcpPolicies: { click: { requiresApproval: true } },
|
|
568
|
+
});
|
|
569
|
+
expect(h.decide(hookMcp("click")).permission).toBe("deny");
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
306
573
|
it("still denies gated tools via the bash fallback when the Node binary is unavailable", () => {
|
|
307
574
|
const ws = mkdtempSync(join(tmpdir(), "hook-script-fallback-"));
|
|
308
|
-
|
|
575
|
+
onTestFinished(() => rmSync(ws, { recursive: true, force: true }));
|
|
309
576
|
const dir = join(ws, ".cursor", "hooks");
|
|
310
577
|
mkdirSync(dir, { recursive: true });
|
|
311
578
|
const statePath = join(dir, "state.json");
|
|
312
579
|
const ledgerPath = join(dir, "denials.jsonl");
|
|
580
|
+
const pointerPath = join(dir, "active.json");
|
|
313
581
|
const scriptPath = join(dir, "hook.sh");
|
|
314
|
-
// Break the baked Node path to force the grep/cut fallback
|
|
315
|
-
|
|
582
|
+
// Break the baked Node path to force the grep/cut fallback for BOTH the
|
|
583
|
+
// active-turn pointer parse and the tool identity extraction.
|
|
584
|
+
const script = generateHookScript(pointerPath)
|
|
316
585
|
.replace(`NODE_BIN="${process.execPath}"`, 'NODE_BIN="/nonexistent/node"');
|
|
317
586
|
writeFileSync(scriptPath, script, "utf-8");
|
|
318
|
-
writeFileSync(
|
|
587
|
+
writeFileSync(
|
|
588
|
+
pointerPath,
|
|
589
|
+
JSON.stringify({ stateFile: statePath, ledgerFile: ledgerPath, runnerPid: process.pid }),
|
|
590
|
+
"utf-8",
|
|
591
|
+
);
|
|
592
|
+
writeFileSync(statePath, JSON.stringify(buildApprovalState(new Map(), false, new Set())), "utf-8");
|
|
319
593
|
|
|
320
594
|
const raw = execFileSync("bash", [scriptPath], {
|
|
321
595
|
input: JSON.stringify(hookWrite("/x/a.txt")),
|