granttap-mcp 0.7.6 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +86 -21
  2. package/README.md +33 -3
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/apps/bridge/src/bin/claude-hook.ts +60 -55
  5. package/apps/bridge/src/bin/codex-hook.ts +17 -5
  6. package/apps/bridge/src/bin/codex-policy-hook.ts +30 -10
  7. package/apps/bridge/src/bin/cursor-hook.ts +53 -7
  8. package/apps/bridge/src/bin/cursor-mcp-hook.ts +97 -36
  9. package/apps/bridge/src/bin/monitor.ts +15 -1
  10. package/apps/bridge/src/bin/setup.ts +15 -1
  11. package/apps/bridge/src/capabilities/README.md +2 -0
  12. package/apps/bridge/src/config/README.md +3 -0
  13. package/apps/bridge/src/config/runtime.ts +26 -0
  14. package/apps/bridge/src/cursor-mcp-policy.ts +36 -1
  15. package/apps/bridge/src/engine/README.md +25 -0
  16. package/apps/bridge/src/engine/engine-client.ts +204 -0
  17. package/apps/bridge/src/engine/engine-declaration.ts +74 -0
  18. package/apps/bridge/src/engine/engine-health.ts +22 -0
  19. package/apps/bridge/src/engine/engine-policy-protocol.ts +262 -0
  20. package/apps/bridge/src/engine/engine-policy-types.ts +37 -0
  21. package/apps/bridge/src/engine/engine-projects.ts +68 -0
  22. package/apps/bridge/src/engine/engine-protocol.ts +265 -0
  23. package/apps/bridge/src/engine/engine-supervisor.ts +175 -0
  24. package/apps/bridge/src/install.ts +30 -4
  25. package/apps/bridge/src/machine-load/README.md +10 -0
  26. package/apps/bridge/src/machine-load/agent-load-history.ts +94 -0
  27. package/apps/bridge/src/machine-load/index.ts +130 -0
  28. package/apps/bridge/src/machine-load/loop.ts +149 -0
  29. package/apps/bridge/src/machine-load/mcp-load-cache.ts +46 -0
  30. package/apps/bridge/src/machine-load/mcp-load-refresh.ts +65 -0
  31. package/apps/bridge/src/machine-load/mcp-process-sampler.ts +69 -0
  32. package/apps/bridge/src/machine-load/process-sampler.ts +99 -0
  33. package/apps/bridge/src/machine-load/scan-cost.ts +25 -0
  34. package/apps/bridge/src/mesh/README.md +10 -0
  35. package/apps/bridge/src/mesh/binding-state.ts +87 -0
  36. package/apps/bridge/src/mesh/catalog.ts +80 -11
  37. package/apps/bridge/src/mesh/convergence.ts +7 -5
  38. package/apps/bridge/src/mesh/execution-sweep.ts +35 -0
  39. package/apps/bridge/src/mesh/identity.ts +11 -0
  40. package/apps/bridge/src/mesh/snapshot-merge.ts +29 -0
  41. package/apps/bridge/src/mesh/store-state.ts +33 -3
  42. package/apps/bridge/src/mesh/store.ts +58 -32
  43. package/apps/bridge/src/monitor-leadership.ts +139 -0
  44. package/apps/bridge/src/monitor.ts +36 -72
  45. package/apps/bridge/src/policy/README.md +23 -0
  46. package/apps/bridge/src/policy/capability-fingerprint.ts +211 -0
  47. package/apps/bridge/src/policy/codex-project-ask.ts +207 -0
  48. package/apps/bridge/src/policy/effective-action.ts +143 -0
  49. package/apps/bridge/src/project-policy/mapping.ts +156 -0
  50. package/apps/bridge/src/project-policy/runtime.ts +213 -0
  51. package/apps/bridge/src/reply/README.md +3 -0
  52. package/apps/bridge/src/reply/cursor-agent-bin.ts +46 -0
  53. package/apps/bridge/src/reply/provider-headless.ts +2 -1
  54. package/apps/bridge/src/reply/types.ts +3 -0
  55. package/apps/bridge/src/reply.ts +24 -5
  56. package/apps/bridge/src/session-keys.ts +16 -1
  57. package/apps/bridge/src/sessions/activity-helpers.ts +2 -1
  58. package/apps/bridge/src/sessions/capability-totals.ts +0 -0
  59. package/apps/bridge/src/sessions/claude.ts +1 -1
  60. package/apps/bridge/src/sessions/codex-head.ts +77 -0
  61. package/apps/bridge/src/sessions/codex.ts +22 -24
  62. package/apps/bridge/src/sessions/cursor/README.md +3 -0
  63. package/apps/bridge/src/sessions/cursor/transcripts.ts +1 -1
  64. package/apps/bridge/src/sessions/telemetry/README.md +3 -0
  65. package/apps/bridge/src/sessions/telemetry/identity.ts +1 -0
  66. package/apps/bridge/src/sessions/telemetry.ts +17 -0
  67. package/apps/bridge/src/sessions.ts +17 -5
  68. package/apps/mcp/src/http-server.ts +3 -2
  69. package/apps/mcp/src/http-service/README.md +2 -0
  70. package/apps/mcp/src/mcp-tools/README.md +3 -0
  71. package/apps/mcp/src/oauth/README.md +2 -0
  72. package/apps/mcp/src/oauth/loopback-origin.ts +16 -0
  73. package/bin/granttap-mcp.mjs +1 -1
  74. package/cursor-plugin/.cursor-plugin/plugin.json +1 -1
  75. package/cursor-plugin/LICENSE +86 -0
  76. package/cursor-plugin/README.md +5 -0
  77. package/docs/images/iphone-chat.png +0 -0
  78. package/docs/images/iphone-claude-tasks.png +0 -0
  79. package/docs/images/iphone-command-center.png +0 -0
  80. package/docs/images/iphone-mcp-usage.png +0 -0
  81. package/package.json +4 -3
  82. package/packages/protocol/messages/README.md +12 -0
  83. package/packages/protocol/messages/capabilities.ts +54 -0
  84. package/packages/protocol/messages/interaction.ts +9 -0
  85. package/packages/protocol/messages/machine.ts +29 -0
  86. package/packages/protocol/messages/mesh.ts +21 -1
  87. package/packages/protocol/messages/project-policy.ts +172 -0
  88. package/packages/protocol/schema.ts +12 -0
package/LICENSE CHANGED
@@ -1,21 +1,86 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Serhii Ziborov
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ GrantTap Commercial Source License 1.0
2
+
3
+ Copyright (c) 2026 Serhii Ziborov. All rights reserved.
4
+
5
+ This license governs the source code and software distributed from the
6
+ granttap-mcp repository and package (the "Software"), except for third-party
7
+ components that identify their own license terms.
8
+
9
+ 1. Definitions
10
+
11
+ "GrantTap" means Serhii Ziborov doing business as GrantTap.
12
+
13
+ "Authorized Access" means an active paid GrantTap subscription, an official
14
+ GrantTap trial, or a separate written commercial agreement with GrantTap.
15
+
16
+ "Authorized User" means a person or legal entity covered by Authorized Access.
17
+
18
+ 2. Source inspection and contribution
19
+
20
+ You may download, inspect, and make local modifications to the source solely to
21
+ evaluate the Software, perform security review, or prepare a contribution to
22
+ the official GrantTap repository. You may create a public fork solely for that
23
+ contribution workflow if the fork retains this license and does not publish a
24
+ release, binary, hosted service, or independently usable distribution.
25
+
26
+ These rights do not permit production use, commercial use, or use to provide a
27
+ service to another person.
28
+
29
+ 3. Authorized product use
30
+
31
+ During Authorized Access, GrantTap grants the Authorized User a limited,
32
+ non-exclusive, non-transferable, non-sublicensable license to install, execute,
33
+ and internally modify the Software on devices the Authorized User owns or
34
+ controls, solely to use the official GrantTap product and services.
35
+
36
+ When Authorized Access ends, the Authorized User must stop using the Software
37
+ except where a separate written agreement says otherwise.
38
+
39
+ 4. Restrictions
40
+
41
+ Unless GrantTap gives prior written permission, you may not:
42
+
43
+ - sell, sublicense, redistribute, or publish the Software or a derivative work;
44
+ - offer the Software or a derivative work as a hosted or managed service;
45
+ - use the Software to build or operate a competing product or service;
46
+ - use the Software as a standalone general-purpose coding-agent runtime outside
47
+ the official GrantTap product;
48
+ - bypass subscription, license, access-control, or entitlement checks;
49
+ - remove or alter copyright, license, attribution, or proprietary notices; or
50
+ - use GrantTap names or marks to imply endorsement of a modified distribution.
51
+
52
+ 5. Ownership
53
+
54
+ The Software is licensed, not sold. GrantTap retains all right, title, and
55
+ interest in the Software and its derivative works. No trademark, patent, or
56
+ other rights are granted except the limited copyright rights stated here.
57
+
58
+ 6. Third-party components
59
+
60
+ Third-party components remain governed by their respective licenses. Those
61
+ licenses and notices are not replaced by this license. See
62
+ THIRD_PARTY_NOTICES.md where distributed.
63
+
64
+ 7. Termination
65
+
66
+ This license terminates automatically if you breach its terms. On termination,
67
+ you must stop using and distributing the Software and delete copies that you
68
+ are not legally required to retain. Sections 4 through 9 survive termination.
69
+
70
+ 8. Disclaimer of warranty
71
+
72
+ THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTIES OF
73
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS
74
+ FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT.
75
+
76
+ 9. Limitation of liability
77
+
78
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, GRANTTAP AND ITS CONTRIBUTORS WILL NOT
79
+ BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR
80
+ PUNITIVE DAMAGES, OR FOR LOSS OF DATA, PROFITS, REVENUE, OR BUSINESS, ARISING
81
+ FROM OR RELATED TO THE SOFTWARE OR THIS LICENSE.
82
+
83
+ 10. Commercial terms
84
+
85
+ Authorized Access, pricing, and separate commercial licenses are available
86
+ through https://granttap.com.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/granttap-mcp)](https://www.npmjs.com/package/granttap-mcp)
4
4
  [![CI](https://github.com/sergii-ziborov/granttap-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sergii-ziborov/granttap-mcp/actions/workflows/ci.yml)
5
- [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![License](https://img.shields.io/badge/license-GrantTap%20Commercial-7c3aed.svg)](LICENSE)
6
6
 
7
7
  GrantTap is a Personal live control center for local coding agents.
8
8
 
@@ -13,6 +13,10 @@ hooks, local adapters, and TypeScript wire schemas. Agents and provider
13
13
  credentials stay on your computer. Native iPhone and Apple Watch traffic is
14
14
  end-to-end encrypted.
15
15
 
16
+ The source is public for inspection and contribution, but GrantTap MCP is
17
+ proprietary paid software rather than open source. Production use requires an
18
+ active GrantTap subscription or a separate commercial agreement.
19
+
16
20
  [Website](https://granttap.com) · [npm](https://www.npmjs.com/package/granttap-mcp) ·
17
21
  [Security model](SECURITY.md) ·
18
22
  [Relay source](https://github.com/sergii-ziborov/granttap-relay)
@@ -97,6 +101,9 @@ history stays on the device.
97
101
 
98
102
  ## Install
99
103
 
104
+ Installation and production use require Authorized Access under the
105
+ [GrantTap Commercial Source License](LICENSE).
106
+
100
107
  ```bash
101
108
  npm install -g granttap-mcp
102
109
  granttap setup
@@ -107,10 +114,17 @@ hooks, installs the background helper, configures Cursor's persistent local
107
114
  OAuth service when Cursor is present, and starts phone pairing when run in an
108
115
  interactive terminal. It ends with one exact next action.
109
116
 
117
+ Setup also declares the separately distributed GrantTap Engine, which Project
118
+ Governance needs before it can report anything. The standard locations are
119
+ searched, and `--engine <path>` points at one that lives elsewhere; the binary
120
+ is checksummed here, because that checksum is the only thing verified before it
121
+ is launched. Without an engine the rollout stays off and setup says so, rather
122
+ than leaving "Governance not reported" on the phone as the only symptom.
123
+
110
124
  The normal CLI surface is intentionally small:
111
125
 
112
126
  ```text
113
- granttap setup
127
+ granttap setup [--engine <path>]
114
128
  granttap status [--json]
115
129
  granttap connect [--relay <wss-url>]
116
130
  granttap reset [--yes]
@@ -162,11 +176,19 @@ The bounded encrypted protocol preserves:
162
176
  - visible activity, delivery state, context and token counters;
163
177
  - MCP, Skill, and CLI observations;
164
178
  - child-agent relationships;
165
- - per-capability outcome: `success`, `error`, `cancelled`, or `unknown`.
179
+ - per-capability outcome: `success`, `error`, `cancelled`, or `unknown`;
180
+ - what a call cost, where the machine can be observed while it ran.
166
181
 
167
182
  An optional bounded `errorClass` may describe an error category. Full tool
168
183
  error payloads are not copied into usage telemetry by default.
169
184
 
185
+ Cost is reported as attributed rather than measured, because that is what it
186
+ is. A call is read back from the transcript once it has finished, so it can
187
+ never be measured directly: an MCP server outlives its calls and is sampled
188
+ directly, while a built-in tool leaves nothing behind and is costed from the
189
+ samples that fall inside its own start and end. A call with no sample near it
190
+ reports nothing rather than a number borrowed from another moment.
191
+
170
192
  ## Local enforcement
171
193
 
172
194
  GrantTap can narrow later actions for an exact task only where a provider
@@ -217,4 +239,12 @@ gate on macOS, and `npm publish` enforces the same gate through
217
239
  Do not publish from a dirty checkout or before the package allowlist, tests,
218
240
  typecheck, and release checks pass.
219
241
 
242
+ ## License
243
+
244
+ GrantTap MCP is distributed under the proprietary
245
+ [GrantTap Commercial Source License 1.0](LICENSE). Public source access does not
246
+ grant open-source, redistribution, hosted-service, or competing-product rights.
247
+ Third-party dependencies retain their own terms; see
248
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
249
+
220
250
  GrantTap is not affiliated with Anthropic, OpenAI, Apple, Anysphere, or xAI.
@@ -0,0 +1,23 @@
1
+ # Third-party notices
2
+
3
+ GrantTap MCP is proprietary software distributed under the GrantTap Commercial
4
+ Source License. Third-party components retain their own licenses; the GrantTap
5
+ license does not replace or restrict those terms.
6
+
7
+ The npm package declares, rather than vendors, these direct runtime
8
+ dependencies. Their packages carry the applicable license texts:
9
+
10
+ | Component | License |
11
+ | --- | --- |
12
+ | `@modelcontextprotocol/sdk` | MIT |
13
+ | `express` | MIT |
14
+ | `qrcode` | MIT |
15
+ | `tsx` | MIT |
16
+ | `tweetnacl` | The Unlicense |
17
+ | `ws` | MIT |
18
+ | `zod` | MIT |
19
+
20
+ The unpublished Rust workspace also depends on separately licensed components,
21
+ including `mcport` (MIT), `blazingly-json` (MIT), `serde` (MIT OR Apache-2.0), and
22
+ BlindPlane crates (MIT OR Apache-2.0). Binary distributions must retain the
23
+ complete license texts required by the exact dependency versions they contain.
@@ -25,6 +25,10 @@ import {
25
25
  } from "../config";
26
26
  import { recordAttributedCall } from "../mesh/call-scope";
27
27
  import { classifyAction } from "../policy";
28
+ import {
29
+ evaluateEffectiveAction,
30
+ legacyGrantTapFlowAllowed,
31
+ } from "../policy/effective-action";
28
32
  import { protectedGrantTapAccess } from "../self-protection";
29
33
 
30
34
  async function readStdin(): Promise<string> {
@@ -35,31 +39,20 @@ async function readStdin(): Promise<string> {
35
39
 
36
40
  async function main(): Promise<void> {
37
41
  const raw = await readStdin();
38
- let input: HookInput = {};
42
+ let input: HookInput;
39
43
  try {
40
44
  input = JSON.parse(raw) as HookInput;
41
45
  } catch {
42
- process.stdout.write(
43
- JSON.stringify({
44
- hookSpecificOutput: {
45
- hookEventName: "PreToolUse",
46
- permissionDecision: "deny",
47
- permissionDecisionReason: "GrantTap received invalid hook JSON",
48
- },
49
- }),
50
- );
46
+ writePermission("deny", "GrantTap received invalid hook JSON");
51
47
  return;
52
48
  }
49
+ await handlePreToolUse(input);
50
+ }
53
51
 
52
+ async function handlePreToolUse(input: HookInput): Promise<void> {
54
53
  const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
55
54
  if (protectedAccess) {
56
- process.stdout.write(JSON.stringify({
57
- hookSpecificOutput: {
58
- hookEventName: "PreToolUse",
59
- permissionDecision: "deny",
60
- permissionDecisionReason: protectedAccess.reason,
61
- },
62
- }));
55
+ writePermission("deny", protectedAccess.reason);
63
56
  return;
64
57
  }
65
58
 
@@ -80,43 +73,42 @@ async function main(): Promise<void> {
80
73
  input.tool_name,
81
74
  input.tool_input,
82
75
  );
83
- if (blocked) {
84
- process.stdout.write(
85
- JSON.stringify({
86
- hookSpecificOutput: {
87
- hookEventName: "PreToolUse",
88
- permissionDecision: "deny",
89
- permissionDecisionReason: blocked.reason,
90
- },
91
- }),
92
- );
76
+ const projectDecision = await evaluateEffectiveAction({
77
+ provider: "claude",
78
+ sessionId: input.session_id,
79
+ cwd: input.cwd,
80
+ toolName: input.tool_name,
81
+ toolInput: input.tool_input,
82
+ legacyDenyReason: blocked?.reason,
83
+ });
84
+ if (projectDecision.effect === "deny") {
85
+ writePermission("deny", projectDecision.reason);
93
86
  return;
94
87
  }
88
+ const legacyFlow = legacyGrantTapFlowAllowed(projectDecision);
95
89
 
96
90
  // Claude already granted this chat unconditional tool access. GrantTap's
97
- // explicit MCP/skill/CLI blocks above still win, but phone approval must not
98
- // re-prompt a call Claude is intentionally running in bypass mode.
99
- if (input.permission_mode === "bypassPermissions") return;
91
+ // Project ASK/DENY is a parent boundary, so bypass mode cannot weaken it.
92
+ if (legacyFlow && input.permission_mode === "bypassPermissions") return;
100
93
 
101
94
  // Gating paused, or this session is exempt → abstain (empty output = Claude
102
95
  // uses its normal permission flow, exactly as if GrantTap weren't installed).
103
- if (isGatingSkipped(input.session_id)) return;
96
+ if (legacyFlow && isGatingSkipped(input.session_id)) return;
97
+ await continuePermissionFlow(input, legacyFlow);
98
+ }
104
99
 
100
+ async function continuePermissionFlow(input: HookInput, legacyFlow: boolean): Promise<void> {
105
101
  let cfg;
106
102
  try {
107
103
  cfg = loadConfig(machineConfigPath());
108
104
  } catch {
105
+ if (!legacyFlow) {
106
+ writePermission("deny", "Project policy requires GrantTap approval, but this computer is not paired");
107
+ return;
108
+ }
109
109
  // No pairing yet: don't block the user's normal workflow — defer to Claude's
110
110
  // own prompt by emitting "ask".
111
- process.stdout.write(
112
- JSON.stringify({
113
- hookSpecificOutput: {
114
- hookEventName: "PreToolUse",
115
- permissionDecision: "ask",
116
- permissionDecisionReason: "GrantTap not paired (run `npm run init`)",
117
- },
118
- }),
119
- );
111
+ writePermission("ask", "GrantTap not paired (run `npm run init`)");
120
112
  return;
121
113
  }
122
114
 
@@ -125,21 +117,8 @@ async function main(): Promise<void> {
125
117
  // the user never finished setting up. Once paired, this is what keeps routine
126
118
  // work independent of whether the phone is awake, reachable, or even running.
127
119
  const req = claudeToRequest(input);
128
- if (shouldAutoAcceptTool(input.session_id, req.tool, req.command)) {
129
- const level = autoAcceptLevelFor(input.session_id);
130
- const cls = classifyAction(req.tool, req.command);
131
- process.stdout.write(
132
- JSON.stringify(
133
- decisionToClaudeOutput({
134
- type: "approval.decision",
135
- requestId: req.requestId,
136
- decision: "allow",
137
- decidedBy: "auto",
138
- note: `GrantTap auto-accept (${level} / ${cls})`,
139
- decidedAt: Date.now(),
140
- }),
141
- ),
142
- );
120
+ if (legacyFlow && shouldAutoAcceptTool(input.session_id, req.tool, req.command)) {
121
+ writeAutoApproval(input, req);
143
122
  return;
144
123
  }
145
124
 
@@ -153,12 +132,38 @@ async function main(): Promise<void> {
153
132
  // Relay down or phone asleep ≠ phone said no: abstain so Claude's local flow
154
133
  // handles it (avoid flooding "решай локально" on every tool).
155
134
  if (isUnanswered(decision)) {
135
+ if (!legacyFlow) {
136
+ writePermission("deny", "Project approval was required but no decision was received");
137
+ }
156
138
  return;
157
139
  }
158
140
 
159
141
  process.stdout.write(JSON.stringify(decisionToClaudeOutput(decision)));
160
142
  }
161
143
 
144
+ function writeAutoApproval(input: HookInput, req: ReturnType<typeof claudeToRequest>): void {
145
+ const level = autoAcceptLevelFor(input.session_id);
146
+ const cls = classifyAction(req.tool, req.command);
147
+ process.stdout.write(JSON.stringify(decisionToClaudeOutput({
148
+ type: "approval.decision",
149
+ requestId: req.requestId,
150
+ decision: "allow",
151
+ decidedBy: "auto",
152
+ note: `GrantTap auto-accept (${level} / ${cls})`,
153
+ decidedAt: Date.now(),
154
+ })));
155
+ }
156
+
157
+ function writePermission(decision: "deny" | "ask", reason: string): void {
158
+ process.stdout.write(JSON.stringify({
159
+ hookSpecificOutput: {
160
+ hookEventName: "PreToolUse",
161
+ permissionDecision: decision,
162
+ permissionDecisionReason: reason,
163
+ },
164
+ }));
165
+ }
166
+
162
167
  main().catch((err) => {
163
168
  process.stdout.write(
164
169
  JSON.stringify({
@@ -25,6 +25,7 @@ import {
25
25
  } from "../config";
26
26
  import { recordAttributedCall } from "../mesh/call-scope";
27
27
  import { classifyAction } from "../policy";
28
+ import { consumeCodexProjectAsk } from "../policy/codex-project-ask";
28
29
  import { protectedGrantTapAccess } from "../self-protection";
29
30
 
30
31
  async function readStdin(): Promise<string> {
@@ -77,9 +78,15 @@ async function main(): Promise<void> {
77
78
  process.stdout.write(denyOutput(blocked.reason));
78
79
  return;
79
80
  }
81
+ const projectAsk = consumeCodexProjectAsk({
82
+ sessionId: input.session_id,
83
+ toolUseId: input.tool_use_id,
84
+ toolName: input.tool_name,
85
+ toolInput: input.tool_input,
86
+ });
80
87
 
81
88
  // Gating paused or this session exempt → stay silent (Codex uses its own flow).
82
- if (isGatingSkipped(input.session_id)) return;
89
+ if (!projectAsk && isGatingSkipped(input.session_id)) return;
83
90
 
84
91
  let cfg;
85
92
  try {
@@ -91,7 +98,7 @@ async function main(): Promise<void> {
91
98
 
92
99
  // Local policy, evaluated after pairing is confirmed — see claude-hook.
93
100
  const req = codexToRequest(input);
94
- if (shouldAutoAcceptTool(input.session_id, req.tool, req.command)) {
101
+ if (!projectAsk && shouldAutoAcceptTool(input.session_id, req.tool, req.command)) {
95
102
  const level = autoAcceptLevelFor(input.session_id);
96
103
  const cls = classifyAction(req.tool, req.command);
97
104
  process.stdout.write(
@@ -116,9 +123,14 @@ async function main(): Promise<void> {
116
123
  );
117
124
  const decision = await requestApproval(cfg, req, { timeoutMs });
118
125
 
119
- // Relay down or phone never answered: stay silent an empty hook result
120
- // sends Codex back to its own approval flow, so desk work keeps going.
121
- if (isUnanswered(decision)) return;
126
+ // Routine relay failure stays in Codex's native flow. A Project ASK cannot
127
+ // fall through to provider approval because that would weaken the parent rule.
128
+ if (isUnanswered(decision)) {
129
+ if (projectAsk) {
130
+ process.stdout.write(denyOutput("Project approval was required but no decision was received"));
131
+ }
132
+ return;
133
+ }
122
134
 
123
135
  process.stdout.write(JSON.stringify(decisionToCodexOutput(decision)));
124
136
  }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env -S npx tsx
2
- /** Deny-only Codex PreToolUse policy for exact per-chat capability switches. */
2
+ /** Codex PreToolUse: deterministic deny or one-shot Project ASK handoff. */
3
3
  import type { HookInput } from "../adapters";
4
4
  import { blockedSessionCapability, isProviderEnabled } from "../config";
5
+ import { recordCodexProjectAsk } from "../policy/codex-project-ask";
6
+ import { evaluateEffectiveAction } from "../policy/effective-action";
5
7
  import { protectedGrantTapAccess } from "../self-protection";
6
8
 
7
9
  async function readStdin(): Promise<string> {
@@ -19,13 +21,7 @@ async function main(): Promise<void> {
19
21
  }
20
22
  const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
21
23
  if (protectedAccess) {
22
- process.stdout.write(JSON.stringify({
23
- hookSpecificOutput: {
24
- hookEventName: "PreToolUse",
25
- permissionDecision: "deny",
26
- permissionDecisionReason: protectedAccess.reason,
27
- },
28
- }));
24
+ deny(protectedAccess.reason);
29
25
  return;
30
26
  }
31
27
  if (!isProviderEnabled("codex")) return;
@@ -34,12 +30,36 @@ async function main(): Promise<void> {
34
30
  input.tool_name,
35
31
  input.tool_input,
36
32
  );
37
- if (!blocked) return;
33
+ const decision = await evaluateEffectiveAction({
34
+ provider: "codex",
35
+ sessionId: input.session_id,
36
+ cwd: input.cwd,
37
+ toolName: input.tool_name,
38
+ toolInput: input.tool_input,
39
+ legacyDenyReason: blocked?.reason,
40
+ });
41
+ if (decision.effect === "deny") {
42
+ deny(decision.reason);
43
+ return;
44
+ }
45
+ if (decision.effect !== "ask") return;
46
+ const recorded = recordCodexProjectAsk({
47
+ sessionId: input.session_id,
48
+ toolUseId: input.tool_use_id,
49
+ toolName: input.tool_name,
50
+ toolInput: input.tool_input,
51
+ }, decision.reason);
52
+ if (!recorded) {
53
+ deny("Project approval could not be bound to this exact Codex call");
54
+ }
55
+ }
56
+
57
+ function deny(reason: string): void {
38
58
  process.stdout.write(JSON.stringify({
39
59
  hookSpecificOutput: {
40
60
  hookEventName: "PreToolUse",
41
61
  permissionDecision: "deny",
42
- permissionDecisionReason: blocked.reason,
62
+ permissionDecisionReason: reason,
43
63
  },
44
64
  }));
45
65
  }
@@ -15,6 +15,10 @@ import {
15
15
  machineConfigPath,
16
16
  shouldAutoAcceptCursorShell,
17
17
  } from "../config";
18
+ import {
19
+ evaluateEffectiveAction,
20
+ legacyGrantTapFlowAllowed,
21
+ } from "../policy/effective-action";
18
22
  import { cursorRootSessionId } from "../sessions/cursor";
19
23
  import { protectedGrantTapAccess } from "../self-protection";
20
24
 
@@ -48,6 +52,10 @@ async function main(): Promise<void> {
48
52
  nativeAsk("GrantTap could not read this shell call; use Cursor approval.");
49
53
  return;
50
54
  }
55
+ await handleShell(input);
56
+ }
57
+
58
+ async function handleShell(input: CursorHookInput): Promise<void> {
51
59
  const protectedAccess = protectedGrantTapAccess(
52
60
  input.tool_name, input.tool_input, input.command,
53
61
  );
@@ -66,19 +74,42 @@ async function main(): Promise<void> {
66
74
  input.tool_name ?? "Shell",
67
75
  input.tool_input ?? { command: input.command },
68
76
  );
69
- if (blocked) {
70
- deny(blocked.reason);
77
+ const projectDecision = await evaluateEffectiveAction({
78
+ provider: "cursor",
79
+ sessionId,
80
+ cwd: cursorCwd(input),
81
+ toolName: input.tool_name ?? "Shell",
82
+ toolInput: input.tool_input ?? { command: input.command },
83
+ legacyDenyReason: blocked?.reason,
84
+ });
85
+ if (projectDecision.effect === "deny") {
86
+ deny(projectDecision.reason);
87
+ return;
88
+ }
89
+ const legacyFlow = legacyGrantTapFlowAllowed(projectDecision);
90
+ if (!sessionId) {
91
+ if (!legacyFlow) deny("Project approval cannot be bound to an exact Cursor chat");
92
+ else nativeAsk("GrantTap is paused or this shell call is unscoped; use Cursor approval.");
71
93
  return;
72
94
  }
73
- if (!sessionId || isGatingSkipped(sessionId)) {
95
+ if (legacyFlow && isGatingSkipped(sessionId)) {
74
96
  nativeAsk("GrantTap is paused or this shell call is unscoped; use Cursor approval.");
75
97
  return;
76
98
  }
99
+ await continueApproval(input, sessionId, legacyFlow);
100
+ }
101
+
102
+ async function continueApproval(
103
+ input: CursorHookInput,
104
+ sessionId: string,
105
+ legacyFlow: boolean,
106
+ ): Promise<void> {
77
107
  let config;
78
108
  try {
79
109
  config = loadConfig(machineConfigPath());
80
110
  } catch {
81
- nativeAsk("GrantTap is not paired; use Cursor approval.");
111
+ if (!legacyFlow) deny("Project policy requires GrantTap approval, but this computer is not paired");
112
+ else nativeAsk("GrantTap is not paired; use Cursor approval.");
82
113
  return;
83
114
  }
84
115
  const request = cursorToRequest({
@@ -90,20 +121,35 @@ async function main(): Promise<void> {
90
121
  // Eligible levels approve locally — never wait on phone WS/APNs for an action
91
122
  // the configured level already allows.
92
123
  const level = autoAcceptLevelFor(sessionId);
93
- if (shouldAutoAcceptCursorShell(level, request.tool, request.command)) {
124
+ if (legacyFlow && shouldAutoAcceptCursorShell(level, request.tool, request.command)) {
94
125
  process.stdout.write(JSON.stringify({ permission: "allow", continue: true }));
95
126
  return;
96
127
  }
97
128
 
98
129
  const timeoutMs = Number(process.env.GRANTTAP_APPROVAL_TIMEOUT_MS ?? 60_000);
99
- const decision = await requestApproval(config, request, { timeoutMs });
130
+ let decision;
131
+ try {
132
+ decision = await requestApproval(config, request, { timeoutMs });
133
+ } catch {
134
+ if (!legacyFlow) deny("Project approval failed before a decision was recorded");
135
+ else nativeAsk("GrantTap shell hook failed; use Cursor approval.");
136
+ return;
137
+ }
100
138
  if (isUnanswered(decision)) {
101
- nativeAsk("GrantTap got no answer; use Cursor approval.");
139
+ if (!legacyFlow) deny("Project approval was required but no decision was received");
140
+ else nativeAsk("GrantTap got no answer; use Cursor approval.");
102
141
  return;
103
142
  }
104
143
  process.stdout.write(JSON.stringify(decisionToCursorOutput(decision)));
105
144
  }
106
145
 
146
+ function cursorCwd(input: CursorHookInput): string | undefined {
147
+ if (typeof input.cwd === "string" && input.cwd.trim()) return input.cwd;
148
+ return Array.isArray(input.workspace_roots) && typeof input.workspace_roots[0] === "string"
149
+ ? input.workspace_roots[0]
150
+ : undefined;
151
+ }
152
+
107
153
  main().catch(() => {
108
154
  nativeAsk("GrantTap shell hook failed; use Cursor approval.");
109
155
  process.exit(0);