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.
- package/README.md +71 -3
- package/SECURITY.md +30 -0
- package/apps/bridge/src/bin/claude-hook.ts +14 -13
- package/apps/bridge/src/bin/codex-hook.ts +12 -0
- package/apps/bridge/src/bin/codex-policy-hook.ts +2 -1
- package/apps/bridge/src/bin/cursor-after-shell.ts +5 -1
- package/apps/bridge/src/bin/cursor-hook.ts +5 -0
- package/apps/bridge/src/bin/cursor-mcp-hook.ts +13 -0
- package/apps/bridge/src/bin/mesh-connect.ts +27 -0
- package/apps/bridge/src/bin/setup.ts +1 -0
- package/apps/bridge/src/config/runtime.ts +34 -1
- package/apps/bridge/src/install.ts +9 -1
- package/apps/bridge/src/mesh/README.md +36 -0
- package/apps/bridge/src/mesh/attention.ts +19 -0
- package/apps/bridge/src/mesh/call-scope.ts +135 -0
- package/apps/bridge/src/mesh/capability.ts +135 -0
- package/apps/bridge/src/mesh/capsule.ts +86 -0
- package/apps/bridge/src/mesh/catalog.ts +122 -0
- package/apps/bridge/src/mesh/endpoint.ts +179 -0
- package/apps/bridge/src/mesh/handoff.ts +37 -0
- package/apps/bridge/src/mesh/identity.ts +39 -0
- package/apps/bridge/src/mesh/local.ts +18 -0
- package/apps/bridge/src/mesh/readiness.ts +72 -0
- package/apps/bridge/src/mesh/runtime-dependencies.ts +37 -0
- package/apps/bridge/src/mesh/runtime-handoff.ts +167 -0
- package/apps/bridge/src/mesh/runtime.ts +153 -0
- package/apps/bridge/src/mesh/scoped-view.ts +73 -0
- package/apps/bridge/src/mesh/store-support.ts +23 -0
- package/apps/bridge/src/mesh/store.ts +297 -0
- package/apps/bridge/src/mesh/worktree.ts +51 -0
- package/apps/bridge/src/monitor.ts +68 -9
- package/apps/bridge/src/reply/README.md +4 -0
- package/apps/bridge/src/reply/provider-headless.ts +96 -0
- package/apps/bridge/src/reply.ts +35 -2
- package/apps/bridge/src/session-keys.ts +23 -1
- package/apps/bridge/src/sessions/common.ts +3 -5
- package/apps/bridge/src/sessions/grok.ts +179 -0
- package/apps/bridge/src/sessions.ts +11 -13
- package/apps/mcp/src/bin/status.ts +3 -1
- package/apps/mcp/src/create-server.ts +3 -1
- package/apps/mcp/src/mcp-tools/README.md +17 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +160 -9
- package/apps/mcp/src/mcp-tools/mesh-actions.ts +224 -0
- package/apps/mcp/src/mcp-tools/mesh-relay.ts +66 -0
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +64 -0
- package/apps/mcp/src/mcp-tools/mesh-tools.ts +103 -0
- package/apps/mcp/src/mesh-server.ts +13 -0
- package/apps/mcp/src/mesh-stdio.ts +5 -0
- package/apps/mcp/src/oauth/consent-page.ts +5 -2
- package/apps/mcp/src/provider-status.ts +19 -4
- package/bin/granttap-mcp.mjs +8 -1
- package/package.json +4 -5
- package/packages/core/relay-client.ts +3 -2
- package/packages/protocol/messages/README.md +7 -0
- package/packages/protocol/messages/interaction.ts +2 -1
- package/packages/protocol/messages/mesh-endpoint.ts +68 -0
- package/packages/protocol/messages/mesh.ts +250 -0
- package/packages/protocol/messages/primitives.ts +3 -0
- package/packages/protocol/messages/sessions.ts +28 -1
- package/packages/protocol/schema.ts +15 -1
- package/apps/bridge/src/sessions/copilot-capabilities.ts +0 -127
- 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:
|
|
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,
|
|
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 {
|
|
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:
|
|
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
|
+
}
|