granttap-mcp 0.6.10 → 0.7.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 (62) hide show
  1. package/README.md +71 -3
  2. package/SECURITY.md +30 -0
  3. package/apps/bridge/src/bin/claude-hook.ts +14 -13
  4. package/apps/bridge/src/bin/codex-hook.ts +12 -0
  5. package/apps/bridge/src/bin/codex-policy-hook.ts +2 -1
  6. package/apps/bridge/src/bin/cursor-after-shell.ts +5 -1
  7. package/apps/bridge/src/bin/cursor-hook.ts +5 -0
  8. package/apps/bridge/src/bin/cursor-mcp-hook.ts +13 -0
  9. package/apps/bridge/src/bin/mesh-connect.ts +27 -0
  10. package/apps/bridge/src/bin/setup.ts +1 -0
  11. package/apps/bridge/src/config/runtime.ts +34 -1
  12. package/apps/bridge/src/install.ts +9 -1
  13. package/apps/bridge/src/mesh/README.md +36 -0
  14. package/apps/bridge/src/mesh/attention.ts +19 -0
  15. package/apps/bridge/src/mesh/call-scope.ts +135 -0
  16. package/apps/bridge/src/mesh/capability.ts +135 -0
  17. package/apps/bridge/src/mesh/capsule.ts +86 -0
  18. package/apps/bridge/src/mesh/catalog.ts +122 -0
  19. package/apps/bridge/src/mesh/endpoint.ts +179 -0
  20. package/apps/bridge/src/mesh/handoff.ts +37 -0
  21. package/apps/bridge/src/mesh/identity.ts +39 -0
  22. package/apps/bridge/src/mesh/local.ts +18 -0
  23. package/apps/bridge/src/mesh/readiness.ts +72 -0
  24. package/apps/bridge/src/mesh/runtime-dependencies.ts +37 -0
  25. package/apps/bridge/src/mesh/runtime-handoff.ts +167 -0
  26. package/apps/bridge/src/mesh/runtime.ts +153 -0
  27. package/apps/bridge/src/mesh/scoped-view.ts +73 -0
  28. package/apps/bridge/src/mesh/store-support.ts +23 -0
  29. package/apps/bridge/src/mesh/store.ts +297 -0
  30. package/apps/bridge/src/mesh/worktree.ts +51 -0
  31. package/apps/bridge/src/monitor.ts +68 -9
  32. package/apps/bridge/src/reply/README.md +4 -0
  33. package/apps/bridge/src/reply/provider-headless.ts +96 -0
  34. package/apps/bridge/src/reply.ts +35 -2
  35. package/apps/bridge/src/session-keys.ts +23 -1
  36. package/apps/bridge/src/sessions/common.ts +3 -5
  37. package/apps/bridge/src/sessions/grok.ts +179 -0
  38. package/apps/bridge/src/sessions.ts +11 -13
  39. package/apps/mcp/src/bin/status.ts +3 -1
  40. package/apps/mcp/src/create-server.ts +3 -1
  41. package/apps/mcp/src/mcp-tools/README.md +17 -0
  42. package/apps/mcp/src/mcp-tools/interaction.ts +160 -9
  43. package/apps/mcp/src/mcp-tools/mesh-actions.ts +224 -0
  44. package/apps/mcp/src/mcp-tools/mesh-relay.ts +66 -0
  45. package/apps/mcp/src/mcp-tools/mesh-resource.ts +64 -0
  46. package/apps/mcp/src/mcp-tools/mesh-tools.ts +103 -0
  47. package/apps/mcp/src/mesh-server.ts +13 -0
  48. package/apps/mcp/src/mesh-stdio.ts +5 -0
  49. package/apps/mcp/src/oauth/consent-page.ts +5 -2
  50. package/apps/mcp/src/provider-status.ts +19 -4
  51. package/bin/granttap-mcp.mjs +8 -1
  52. package/package.json +4 -5
  53. package/packages/core/relay-client.ts +3 -2
  54. package/packages/protocol/messages/README.md +7 -0
  55. package/packages/protocol/messages/interaction.ts +2 -1
  56. package/packages/protocol/messages/mesh-endpoint.ts +68 -0
  57. package/packages/protocol/messages/mesh.ts +250 -0
  58. package/packages/protocol/messages/primitives.ts +3 -0
  59. package/packages/protocol/messages/sessions.ts +28 -1
  60. package/packages/protocol/schema.ts +15 -1
  61. package/apps/bridge/src/sessions/copilot-capabilities.ts +0 -127
  62. package/apps/bridge/src/sessions/copilot.ts +0 -283
package/README.md CHANGED
@@ -34,11 +34,65 @@ end-to-end encrypted.
34
34
 
35
35
  - Primary: Claude Code and Codex.
36
36
  - Beta: Cursor.
37
- - Experimental where available: GitHub Copilot CLI and Grok Build.
37
+ - Experimental where available: Grok Build.
38
38
 
39
39
  GrantTap reports the depth each provider actually exposes. Visibility does not
40
40
  imply deterministic remote blocking or full mobile continuation.
41
41
 
42
+ ## Project Mesh and task handoff
43
+
44
+ Project Mesh adds stable Project and Task identity above provider-native
45
+ sessions. A Task can retain its `taskId` across multiple executions, agents,
46
+ and computers while each native session remains intact.
47
+
48
+ Agents publish bounded progress, dependency, question, answer, claim, conflict,
49
+ and completion events through the existing `notify` tool, and read state from a
50
+ Mesh resource. Full transcripts and hidden reasoning are never mesh payloads.
51
+ Claims have TTLs; a colliding claim is rejected before it is recorded so agents
52
+ can choose different work or contact the owner before escalating to Needs You.
53
+
54
+ Both directions are scoped to the calling execution. The provider hook already
55
+ runs inside the agent's own session and sees the exact call, so GrantTap
56
+ attributes every `notify` to the session that really made it and publishes the
57
+ event only for that execution — a model that learned another session's id
58
+ cannot act in its name. `granttap://mesh/current` therefore carries no Project
59
+ data: an attributed call returns this execution's opaque
60
+ `granttap://mesh/<capability>` URI, and only that URI serves its Project, Task,
61
+ owners, claims, dependencies, and relevant events. Ownership transfer stays out
62
+ of the tool surface entirely: `HANDOFF_ACCEPTED` and `HANDOFF_REJECTED` are
63
+ decided by the runtime after receipt verification.
64
+
65
+ The first executable handoff path is Claude Code ↔ Codex across linked
66
+ computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
67
+ requires local phone authorization, creates a separate target branch/worktree,
68
+ starts the target execution, and returns a receipt bound to the exact capsule.
69
+ Cursor and Grok Build use the same provider-neutral schema and discovery model;
70
+ unsupported remote-start paths fail closed instead of claiming parity.
71
+
72
+ A capsule carries facts, not files. A handoff from a checkout with uncommitted
73
+ changes is refused — "This task has uncommitted changes. Commit or checkpoint
74
+ them before moving the task." — instead of silently continuing the Task from
75
+ committed state and leaving that work behind. The destination is refused just
76
+ as explicitly when the named commit is not on its computer, or when the
77
+ capsule's own resource claims overlap another execution's; GrantTap never
78
+ pushes or fetches on its own.
79
+
80
+ ### Grok Bot as a scoped Mesh participant
81
+
82
+ Grok Bot is a persistent agent, not a coding-agent integration. The iPhone
83
+ issues a one-time encrypted Mesh Invite scoped to the Projects you select, and
84
+ the invite is redeemed on the trusted CLI:
85
+
86
+ ```bash
87
+ granttap mesh connect <one-time-invite>
88
+ ```
89
+
90
+ Grok Bot then runs `granttap internal mesh-mcp`, a separate scoped MCP server
91
+ that exposes only the twelve task-scoped Mesh operations. It cannot create
92
+ invites, change the relay, expand Project scope, or reach `setup`; revoking the
93
+ endpoint from the iPhone stops new Mesh operations immediately while local Task
94
+ history stays on the device.
95
+
42
96
  ## Install
43
97
 
44
98
  ```bash
@@ -58,6 +112,7 @@ granttap setup
58
112
  granttap status [--json]
59
113
  granttap connect [--relay <wss-url>]
60
114
  granttap reset [--yes]
115
+ granttap mesh connect <one-time-invite>
61
116
  ```
62
117
 
63
118
  `connect` reuses a valid pairing. If none exists, it creates a one-time QR.
@@ -89,6 +144,10 @@ MCP `connect` accepts no custom routing, replacement, or key-rotation input.
89
144
  Setup is CLI-only because it changes provider configuration and must not be
90
145
  available to a model through prompt injection.
91
146
 
147
+ `notify` may alternatively carry one bounded task-scoped Mesh event. This does
148
+ not add a fifth MCP tool or grant any global setup capability, and it publishes
149
+ only for the execution whose provider hook attributed the call.
150
+
92
151
  Provider-native approvals and mobile continuation require the matching local
93
152
  adapter. MCP registration alone is never reported as proof that an integration
94
153
  is ready.
@@ -141,10 +200,19 @@ npm install
141
200
  npm run typecheck
142
201
  npm test
143
202
  npm run package:allowlist
203
+ npm run test:coverage # macOS only — see below
144
204
  ```
145
205
 
206
+ `npm test` runs everywhere. Both suites run with an isolated `HOME`, so a
207
+ developer's own Claude/Codex/Cursor data can never inflate a local result: the
208
+ numbers on this machine and in CI are the same. The coverage contract is
209
+ measured on macOS because the background helper, the Cursor OAuth service, and
210
+ the installer only execute there, so a Linux percentage understates real
211
+ runtime coverage. CI runs the cross-platform suite on Linux and the coverage
212
+ gate on macOS, and `npm publish` enforces the same gate through
213
+ `prepublishOnly`.
214
+
146
215
  Do not publish from a dirty checkout or before the package allowlist, tests,
147
216
  typecheck, and release checks pass.
148
217
 
149
- GrantTap is not affiliated with Anthropic, OpenAI, Apple, Microsoft, Anysphere,
150
- GitHub, or xAI.
218
+ GrantTap is not affiliated with Anthropic, OpenAI, Apple, Anysphere, or xAI.
package/SECURITY.md CHANGED
@@ -27,6 +27,9 @@ GrantTap does not send them to the relay.
27
27
  transfer key. The relay receives the mailbox ID and ciphertext, not the key.
28
28
  - Each attached task has an independent 256-bit task key. One task key cannot
29
29
  decrypt another task.
30
+ - Project Mesh snapshots have independent project keys. Cross-computer events
31
+ remain under their Task key, and a linked destination receives either key
32
+ only through an explicit phone-mediated route.
30
33
  - Task keys travel only inside the authenticated device channel and are stored
31
34
  in device-protected storage.
32
35
  - Native phone/watch payloads remain authenticated ciphertext across the
@@ -46,6 +49,24 @@ whose independent keys were granted to it. The protection is against relay,
46
49
  database/network compromise, other pairings, and disclosure of a different
47
50
  task key—not against an endpoint already authorized for that task.
48
51
 
52
+ Project Mesh reads and writes are scoped to one execution. The MCP server
53
+ cannot see which agent called it, so the provider hook — which runs inside the
54
+ session and receives the exact arguments — records a single-use attribution for
55
+ that call, and the runtime publishes the event for that session only. A model
56
+ that learned another execution's session id, Project, or Task cannot publish in
57
+ its name, and the unscoped `granttap://mesh/current` URI carries no Project
58
+ data at all. The opaque capability returned to an attributed caller names one
59
+ Project, Task, session and provider, expires, and never authorizes ownership
60
+ transfer.
61
+
62
+ Task Capsules are strict, bounded schemas containing goal, explicit status,
63
+ repository/commit facts, changed-file names, tests, dependencies, claims,
64
+ remaining work, and explicit decisions. Unknown fields are rejected; hidden
65
+ reasoning and transcript replication are outside the contract. Handoff
66
+ acceptance includes a SHA-256 receipt over a canonical capsule and the source,
67
+ target, Task identity, and acceptance time. Stale claims expire, replayed event
68
+ IDs are ignored, and destination routing is explicit.
69
+
49
70
  ## Runtime controls
50
71
 
51
72
  Provider hooks enforce policy on the computer. Global provider deny always
@@ -57,5 +78,14 @@ background services. The public MCP server can only connect an existing or new
57
78
  phone pairing, notify, and ask bounded questions; it cannot reconfigure the
58
79
  machine or select a custom relay.
59
80
 
81
+ A Grok Bot endpoint is a separate scoped identity. Its one-time invite is
82
+ created only on the iPhone, stored on the relay as ciphertext, redeemed only by
83
+ the trusted `granttap mesh connect` CLI, and written to disk with `0600`
84
+ permissions. Its MCP server exposes only task-scoped Mesh operations bound to
85
+ one credential: every call re-checks endpoint, credential, policy revision,
86
+ expiry, actor, Project scope, and operation, and a revoked or disabled endpoint
87
+ fails closed. Invite creation, actor enablement, Project scope, and revocation
88
+ stay in the iPhone UI and the trusted CLI, never in a model-callable tool.
89
+
60
90
  Relay vulnerabilities should be reported through the
61
91
  [GrantTap relay advisory form](https://github.com/sergii-ziborov/granttap-relay/security/advisories/new).
@@ -18,10 +18,12 @@ import {
18
18
  autoAcceptLevelFor,
19
19
  blockedSessionCapability,
20
20
  isGatingSkipped,
21
+ isProviderEnabled,
21
22
  loadConfig,
22
23
  machineConfigPath,
23
24
  shouldAutoAcceptTool,
24
25
  } from "../config";
26
+ import { recordAttributedCall } from "../mesh/call-scope";
25
27
  import { classifyAction } from "../policy";
26
28
 
27
29
  async function readStdin(): Promise<string> {
@@ -48,6 +50,18 @@ async function main(): Promise<void> {
48
50
  return;
49
51
  }
50
52
 
53
+ if (!isProviderEnabled("claude")) return;
54
+
55
+ // Attribute this exact GrantTap call to the session that made it. The MCP
56
+ // server cannot see the caller, so without this a model could publish Mesh
57
+ // events in another execution's name.
58
+ recordAttributedCall({
59
+ provider: "claude",
60
+ sessionId: input.session_id,
61
+ toolName: input.tool_name,
62
+ args: input.tool_input,
63
+ });
64
+
51
65
  const blocked = blockedSessionCapability(
52
66
  input.session_id,
53
67
  input.tool_name,
@@ -69,19 +83,6 @@ async function main(): Promise<void> {
69
83
  // Claude already granted this chat unconditional tool access. GrantTap's
70
84
  // explicit MCP/skill/CLI blocks above still win, but phone approval must not
71
85
  // re-prompt a call Claude is intentionally running in bypass mode.
72
- // TEMPORARY DIAGNOSTIC: record only permission-related metadata, never the
73
- // tool input or any prompt text. Remove once the bypass field name is known.
74
- try {
75
- const { appendFileSync } = await import("node:fs");
76
- appendFileSync("/tmp/granttap-hook-probe.log", JSON.stringify({
77
- keys: Object.keys(input),
78
- permission_mode: (input as Record<string, unknown>).permission_mode,
79
- permissionMode: (input as Record<string, unknown>).permissionMode,
80
- tool_name: (input as Record<string, unknown>).tool_name,
81
- session_id: String((input as Record<string, unknown>).session_id ?? "").slice(0, 8),
82
- }) + "\n");
83
- } catch { /* diagnostics must never block a tool call */ }
84
-
85
86
  if (input.permission_mode === "bypassPermissions") return;
86
87
 
87
88
  // Gating paused, or this session is exempt → abstain (empty output = Claude
@@ -18,10 +18,12 @@ import {
18
18
  autoAcceptLevelFor,
19
19
  blockedSessionCapability,
20
20
  isGatingSkipped,
21
+ isProviderEnabled,
21
22
  loadConfig,
22
23
  machineConfigPath,
23
24
  shouldAutoAcceptTool,
24
25
  } from "../config";
26
+ import { recordAttributedCall } from "../mesh/call-scope";
25
27
  import { classifyAction } from "../policy";
26
28
 
27
29
  async function readStdin(): Promise<string> {
@@ -49,6 +51,16 @@ async function main(): Promise<void> {
49
51
  return;
50
52
  }
51
53
 
54
+ if (!isProviderEnabled("codex")) return;
55
+
56
+ // See claude-hook.ts: the MCP server trusts this attribution, never the model.
57
+ recordAttributedCall({
58
+ provider: "codex",
59
+ sessionId: input.session_id,
60
+ toolName: input.tool_name,
61
+ args: input.tool_input,
62
+ });
63
+
52
64
  const blocked = blockedSessionCapability(
53
65
  input.session_id,
54
66
  input.tool_name,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  /** Deny-only Codex PreToolUse policy for exact per-chat capability switches. */
3
3
  import type { HookInput } from "../adapters";
4
- import { blockedSessionCapability } from "../config";
4
+ import { blockedSessionCapability, isProviderEnabled } from "../config";
5
5
 
6
6
  async function readStdin(): Promise<string> {
7
7
  const chunks: Buffer[] = [];
@@ -10,6 +10,7 @@ async function readStdin(): Promise<string> {
10
10
  }
11
11
 
12
12
  async function main(): Promise<void> {
13
+ if (!isProviderEnabled("codex")) return;
13
14
  let input: HookInput;
14
15
  try {
15
16
  input = JSON.parse(await readStdin()) as HookInput;
@@ -7,7 +7,7 @@ import {
7
7
  resolvedFromOutcome,
8
8
  sendApprovalResolved,
9
9
  } from "../approval-state";
10
- import { loadConfig, machineConfigPath } from "../config";
10
+ import { isProviderEnabled, loadConfig, machineConfigPath } from "../config";
11
11
  import { cursorRootSessionId } from "../sessions/cursor";
12
12
 
13
13
  const SHELL_TOOLS = new Set([
@@ -32,6 +32,10 @@ async function readStdin(): Promise<string> {
32
32
  }
33
33
 
34
34
  async function main(): Promise<void> {
35
+ if (!isProviderEnabled("cursor")) {
36
+ process.stdout.write("{}\n");
37
+ return;
38
+ }
35
39
  const raw = await readStdin().catch(() => "");
36
40
  let input: {
37
41
  conversation_id?: string;
@@ -10,6 +10,7 @@ import {
10
10
  autoAcceptLevelFor,
11
11
  blockedSessionCapability,
12
12
  isGatingSkipped,
13
+ isProviderEnabled,
13
14
  loadConfig,
14
15
  machineConfigPath,
15
16
  shouldAutoAcceptCursorShell,
@@ -38,6 +39,10 @@ async function main(): Promise<void> {
38
39
  nativeAsk("GrantTap could not read this shell call; use Cursor approval.");
39
40
  return;
40
41
  }
42
+ if (!isProviderEnabled("cursor")) {
43
+ nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
44
+ return;
45
+ }
41
46
  const rawSessionId = input.conversation_id ?? input.session_id;
42
47
  const sessionId = cursorRootSessionId(rawSessionId) ?? rawSessionId;
43
48
  const blocked = blockedSessionCapability(
@@ -7,6 +7,7 @@ import { isUnanswered, requestApproval } from "../approval";
7
7
  import {
8
8
  blockedSessionMcpServer,
9
9
  isGatingSkipped,
10
+ isProviderEnabled,
10
11
  loadConfig,
11
12
  machineConfigPath,
12
13
  shouldAutoAcceptTool,
@@ -16,6 +17,7 @@ import {
16
17
  resolveCursorMcpServer,
17
18
  type CursorMcpHookInput,
18
19
  } from "../cursor-mcp-policy";
20
+ import { recordAttributedCall } from "../mesh/call-scope";
19
21
  import { cursorRootSessionId } from "../sessions/cursor";
20
22
 
21
23
  async function readStdin(): Promise<string> {
@@ -40,8 +42,19 @@ async function main(): Promise<void> {
40
42
  nativeAsk("GrantTap could not correlate this MCP call; use Cursor approval.");
41
43
  return;
42
44
  }
45
+ if (!isProviderEnabled("cursor")) {
46
+ nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
47
+ return;
48
+ }
43
49
  const rawSessionId = cursorConversationId(input);
44
50
  const sessionId = cursorRootSessionId(rawSessionId) ?? rawSessionId;
51
+ // See claude-hook.ts: Mesh events are published only for the calling session.
52
+ recordAttributedCall({
53
+ provider: "cursor",
54
+ sessionId,
55
+ toolName: input.tool_name,
56
+ args: input.tool_input,
57
+ });
45
58
  const server = resolveCursorMcpServer(input);
46
59
  const blocked = blockedSessionMcpServer(sessionId, server);
47
60
  if (blocked) {
@@ -0,0 +1,27 @@
1
+ import { connectGrokBotInvite } from "../mesh/endpoint";
2
+
3
+ function inviteArgument(args: string[]): string {
4
+ if (args.length === 1 && !["--help", "-h"].includes(args[0]!)) return args[0]!;
5
+ process.stderr.write("Usage: granttap mesh connect <one-time-invite>\n");
6
+ process.exit(1);
7
+ }
8
+
9
+ const invite = inviteArgument(process.argv.slice(2));
10
+ void connectGrokBotInvite(invite).then((bundle) => {
11
+ process.stdout.write([
12
+ "GrantTap Project Mesh",
13
+ "",
14
+ `Grok Bot endpoint Connected (${bundle.endpoint.displayName})`,
15
+ `Actors ${bundle.actors.length}`,
16
+ `Allowed projects ${bundle.credential.projectIds.length}`,
17
+ "",
18
+ "Configure Grok Bot to run: granttap internal mesh-mcp",
19
+ "The scoped MCP server cannot create invites, change relay, or expand Project access.",
20
+ "",
21
+ ].join("\n"));
22
+ }).catch((error: unknown) => {
23
+ process.stderr.write(`[granttap] Grok Bot connection failed: ${
24
+ error instanceof Error ? error.message : String(error)
25
+ }\n`);
26
+ process.exitCode = 1;
27
+ });
@@ -79,6 +79,7 @@ async function main(): Promise<void> {
79
79
  ? codexHook.status === "manual" ? "Needs attention" : "Needs hook trust"
80
80
  : "Not installed"}`,
81
81
  `Cursor ${before.cursor.installed ? `Beta · ${cursorReady ? "Authorize in Cursor" : "Needs repair"}` : "Not installed"}`,
82
+ `Grok Build ${installed.has("grok") ? "Ready" : "Not installed"}`,
82
83
  "",
83
84
  paired && installed.has("codex")
84
85
  ? `Next: ${CODEX_TRUST_INSTRUCTION}`
@@ -1,5 +1,9 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import type { AgentAccess } from "../../../../packages/protocol/schema";
2
+ import type {
3
+ AgentAccess,
4
+ CodingAgent,
5
+ ProviderRuntimeSettings,
6
+ } from "../../../../packages/protocol/schema";
3
7
  import {
4
8
  classifyAction,
5
9
  isAutoAcceptLevel,
@@ -19,6 +23,8 @@ export type RuntimeConfig = {
19
23
  sessionMcpDisabled: Record<string, string[]>;
20
24
  sessionSkillsDisabled: Record<string, string[]>;
21
25
  sessionShellDisabled: string[];
26
+ providerSettings: ProviderRuntimeSettings;
27
+ meshEnabled: boolean;
22
28
  };
23
29
 
24
30
  const DEFAULT_RUNTIME: RuntimeConfig = {
@@ -31,8 +37,23 @@ const DEFAULT_RUNTIME: RuntimeConfig = {
31
37
  sessionMcpDisabled: {},
32
38
  sessionSkillsDisabled: {},
33
39
  sessionShellDisabled: [],
40
+ providerSettings: { claude: true, codex: true, cursor: true, grok: true },
41
+ meshEnabled: true,
34
42
  };
35
43
 
44
+ function parseProviderSettings(raw: unknown): ProviderRuntimeSettings {
45
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
46
+ return { ...DEFAULT_RUNTIME.providerSettings };
47
+ }
48
+ const value = raw as Record<string, unknown>;
49
+ return {
50
+ claude: value.claude !== false,
51
+ codex: value.codex !== false,
52
+ cursor: value.cursor !== false,
53
+ grok: value.grok !== false,
54
+ };
55
+ }
56
+
36
57
  export function boundedIdentifier(raw: unknown, maxLength: number): string | null {
37
58
  if (typeof raw !== "string") return null;
38
59
  const value = raw.trim();
@@ -96,6 +117,8 @@ export function loadRuntimeConfig(): RuntimeConfig {
96
117
  .filter((sessionId): sessionId is string => sessionId != null),
97
118
  )]
98
119
  : [],
120
+ providerSettings: parseProviderSettings(raw.providerSettings),
121
+ meshEnabled: raw.meshEnabled !== false,
99
122
  };
100
123
  } catch {
101
124
  return {
@@ -105,10 +128,20 @@ export function loadRuntimeConfig(): RuntimeConfig {
105
128
  sessionMcpDisabled: {},
106
129
  sessionSkillsDisabled: {},
107
130
  sessionShellDisabled: [],
131
+ providerSettings: { ...DEFAULT_RUNTIME.providerSettings },
132
+ meshEnabled: true,
108
133
  };
109
134
  }
110
135
  }
111
136
 
137
+ export function isProviderEnabled(provider: CodingAgent): boolean {
138
+ return loadRuntimeConfig().providerSettings[provider];
139
+ }
140
+
141
+ export function isMeshEnabled(): boolean {
142
+ return loadRuntimeConfig().meshEnabled;
143
+ }
144
+
112
145
  export function saveRuntimeConfig(cfg: Partial<RuntimeConfig>): void {
113
146
  mkdirSync(configDir(), { recursive: true });
114
147
  const merged: RuntimeConfig = { ...loadRuntimeConfig(), ...cfg };
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
22
22
  import { delimiter, dirname, join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { spawnSync } from "node:child_process";
25
+ import type { CodingAgent } from "../../../packages/protocol/schema";
25
26
  import { configDir } from "./config";
26
27
 
27
28
  const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -54,7 +55,7 @@ export const CODEX_TRUST_INSTRUCTION =
54
55
  "Open /hooks in Codex, review and trust both GrantTap hooks, then restart Codex.";
55
56
 
56
57
  export type AgentIntegrationStatus = {
57
- agent: "codex" | "claude";
58
+ agent: CodingAgent;
58
59
  installed: boolean;
59
60
  hookConfigured: boolean;
60
61
  };
@@ -242,9 +243,16 @@ export function inspectCursorIntegration(): CursorIntegrationStatus {
242
243
  export function inspectAgentIntegrations(): AgentIntegrationStatus[] {
243
244
  const codex = process.env.GRANTTAP_CODEX_BIN ?? process.env.NODVOX_CODEX_BIN ?? "codex";
244
245
  const claude = process.env.GRANTTAP_CLAUDE_BIN ?? process.env.NODVOX_CLAUDE_BIN ?? "claude";
246
+ const cursor = process.env.GRANTTAP_CURSOR_AGENT_BIN ?? "cursor-agent";
247
+ const grok = process.env.GRANTTAP_GROK_BIN ?? "grok";
248
+ const cursorStatus = inspectCursorIntegration();
245
249
  return [
246
250
  { agent: "codex", installed: executableAvailable(codex), hookConfigured: codexHookConfigured() },
247
251
  { agent: "claude", installed: executableAvailable(claude), hookConfigured: claudeHookConfigured() },
252
+ { agent: "cursor", installed: executableAvailable(cursor), hookConfigured: cursorStatus.hookConfigured },
253
+ // Grok Build's headless session contract is direct; GrantTap does not
254
+ // claim an approval hook exists when none has been installed.
255
+ { agent: "grok", installed: executableAvailable(grok), hookConfigured: false },
248
256
  ];
249
257
  }
250
258
 
@@ -0,0 +1,36 @@
1
+ # Project Mesh runtime
2
+
3
+ This module links discovered native sessions to stable Project and Task
4
+ identities, persists bounded structured events, tracks expiring resource
5
+ claims and dependencies, and executes authorized task handoffs.
6
+
7
+ `runtime.ts` is the public runtime entry point. `catalog.ts` derives repository
8
+ identity without replacing provider-native session discovery. `store.ts`
9
+ deduplicates replayed events and expires stale claims. `capsule.ts` reads only
10
+ explicit git and task facts; transcripts and hidden reasoning are never copied.
11
+ `worktree.ts` creates a separate target branch/worktree before Claude Code or
12
+ Codex is started. The provider abstraction already admits Cursor and Grok Build
13
+ without changing the core contract, while their unsupported phase-one start
14
+ paths fail closed.
15
+
16
+ `call-scope.ts` and `capability.ts` own caller identity. The MCP server cannot
17
+ see which agent called it, so each provider hook records a single-use
18
+ attribution for the exact call it observed inside its own session, and the
19
+ runtime derives the publishing execution from that instead of from anything the
20
+ model supplied. `capability.ts` mints the opaque per-execution token that
21
+ `scoped-view.ts` answers with, so a read returns one Project rather than
22
+ everything this computer knows. Structured events use the existing `notify`
23
+ tool so the public MCP tool allowlist remains exactly `connect`, `notify`,
24
+ `ask_yes_no`, and `ask`. Setup and global provider configuration remain
25
+ CLI-only.
26
+
27
+ `readiness.ts` decides whether a handoff may leave at all. A capsule transfers
28
+ committed facts, so uncommitted work blocks the handoff instead of silently
29
+ staying behind, and the destination refuses just as explicitly when it lacks the
30
+ named commit or when the capsule's resource claims overlap another execution.
31
+
32
+ Every event is schema-bounded and sent under an independent task key; snapshots
33
+ use an independent project key. Resource claims are advisory, expire without a
34
+ heartbeat, and a colliding claim is rejected before it is recorded. The agent
35
+ may choose other work or ask the current owner; the conflict reaches the human
36
+ only after explicit `needsUser` escalation.
@@ -0,0 +1,19 @@
1
+ import type { MeshEventType } from "../../../../packages/protocol/schema";
2
+
3
+ type AttentionFacts = {
4
+ category?: unknown;
5
+ resolved?: unknown;
6
+ needsUser?: unknown;
7
+ failed?: unknown;
8
+ };
9
+
10
+ /** Only deterministic, product-approved reasons may enter the human inbox. */
11
+ export function classifyHumanAttention(type: MeshEventType, facts: AttentionFacts): boolean {
12
+ if (type === "AGENT_QUESTION") {
13
+ return ["product", "business", "security", "destructive"].includes(String(facts.category));
14
+ }
15
+ if (type === "CONFLICT") return facts.resolved !== true && facts.needsUser === true;
16
+ if (type === "TASK_BLOCKED") return facts.needsUser === true;
17
+ if (type === "HANDOFF_REJECTED") return facts.failed === true;
18
+ return false;
19
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Trusted attribution for GrantTap MCP calls.
3
+ *
4
+ * The MCP server sees a tool call but not which agent session made it, so a
5
+ * model that learns another execution's session id could otherwise publish
6
+ * events in its name. Provider hooks do know the session: they run inside it
7
+ * and receive the exact arguments. Each hook records a single-use, short-lived
8
+ * attribution keyed by those arguments, and the MCP server consumes it instead
9
+ * of trusting anything the model claimed.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { z } from "zod";
15
+ import { configDir } from "../config";
16
+
17
+ const CALL_TTL_MS = 120_000;
18
+ const MAX_RECORDS = 32;
19
+
20
+ const AttributedCall = z.object({
21
+ key: z.string().regex(/^[0-9a-f]{64}$/),
22
+ provider: z.enum(["claude", "codex", "cursor", "grok"]),
23
+ sessionId: z.string().trim().min(1).max(256),
24
+ expiresAt: z.number().positive(),
25
+ }).strict();
26
+ export type AttributedCall = z.infer<typeof AttributedCall>;
27
+
28
+ const CallFile = z.object({
29
+ version: z.literal(1),
30
+ calls: z.array(AttributedCall).max(MAX_RECORDS),
31
+ }).strict();
32
+
33
+ /** Normalize exactly like the wire schemas so hook and server agree byte for byte. */
34
+ function canonical(value: unknown): unknown {
35
+ if (typeof value === "string") return value.trim();
36
+ if (Array.isArray(value)) return value.map(canonical);
37
+ if (value && typeof value === "object") {
38
+ const out: Record<string, unknown> = {};
39
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
40
+ const item = (value as Record<string, unknown>)[key];
41
+ if (item !== undefined && item !== null) out[key] = canonical(item);
42
+ }
43
+ return out;
44
+ }
45
+ return value;
46
+ }
47
+
48
+ /** The GrantTap tool name as each provider spells it, reduced to one token. */
49
+ export function grantTapToolName(rawToolName: unknown): string | undefined {
50
+ const raw = String(rawToolName ?? "").trim();
51
+ const tool = raw.includes("__") ? raw.split("__").at(-1)! : raw;
52
+ return ["notify", "connect", "ask", "ask_yes_no"].includes(tool) ? tool : undefined;
53
+ }
54
+
55
+ export function meshCallKey(tool: string, args: unknown): string {
56
+ return createHash("sha256")
57
+ .update(JSON.stringify({ tool, args: canonical(args) }))
58
+ .digest("hex");
59
+ }
60
+
61
+ function callFilePath(): string {
62
+ return join(configDir(), "mesh-tool-calls.json");
63
+ }
64
+
65
+ function readCalls(now: number): AttributedCall[] {
66
+ try {
67
+ const parsed = CallFile.safeParse(JSON.parse(readFileSync(callFilePath(), "utf8")));
68
+ if (!parsed.success) return [];
69
+ return parsed.data.calls.filter((call) => call.expiresAt > now);
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ function writeCalls(calls: AttributedCall[]): void {
76
+ const path = callFilePath();
77
+ mkdirSync(configDir(), { recursive: true });
78
+ writeFileSync(
79
+ path,
80
+ `${JSON.stringify(CallFile.parse({ version: 1, calls: calls.slice(-MAX_RECORDS) }), null, 2)}\n`,
81
+ { mode: 0o600 },
82
+ );
83
+ chmodSync(path, 0o600);
84
+ }
85
+
86
+ /**
87
+ * Record that this provider session is about to make one GrantTap tool call.
88
+ * Hooks call this before returning their decision; it never blocks the hook.
89
+ */
90
+ export function recordAttributedCall(input: {
91
+ provider: AttributedCall["provider"];
92
+ sessionId: unknown;
93
+ toolName: unknown;
94
+ args: unknown;
95
+ now?: number;
96
+ }): void {
97
+ const tool = grantTapToolName(input.toolName);
98
+ const sessionId = String(input.sessionId ?? "").trim();
99
+ if (tool !== "notify" || !sessionId || sessionId.length > 256) return;
100
+ const now = input.now ?? Date.now();
101
+ try {
102
+ const call = AttributedCall.parse({
103
+ key: meshCallKey(tool, input.args),
104
+ provider: input.provider,
105
+ sessionId,
106
+ expiresAt: now + CALL_TTL_MS,
107
+ });
108
+ const kept = readCalls(now).filter((item) =>
109
+ item.key !== call.key || item.sessionId !== call.sessionId);
110
+ writeCalls([...kept, call]);
111
+ } catch {
112
+ // Attribution is best effort inside the hook; the MCP server fails closed.
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolve the session that actually made this call, then retire the record.
118
+ * Two live sessions claiming the same arguments are ambiguous and rejected.
119
+ */
120
+ export function consumeAttributedCall(
121
+ tool: string,
122
+ args: unknown,
123
+ now = Date.now(),
124
+ ): AttributedCall | undefined {
125
+ const key = meshCallKey(tool, args);
126
+ const live = readCalls(now);
127
+ const matches = live.filter((call) => call.key === key);
128
+ const sessions = new Set(matches.map((call) => call.sessionId));
129
+ if (matches.length === 0 || sessions.size !== 1) {
130
+ if (matches.length > 0) writeCalls(live.filter((call) => call.key !== key));
131
+ return undefined;
132
+ }
133
+ writeCalls(live.filter((call) => call.key !== key));
134
+ return matches[0];
135
+ }