granttap-mcp 0.7.0 → 0.7.6
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 +30 -9
- package/SECURITY.md +43 -0
- package/apps/bridge/src/bin/claude-hook.ts +24 -0
- package/apps/bridge/src/bin/codex-hook.ts +16 -0
- package/apps/bridge/src/bin/codex-policy-hook.ts +13 -1
- package/apps/bridge/src/bin/cursor-hook.ts +17 -8
- package/apps/bridge/src/bin/cursor-mcp-hook.ts +20 -0
- package/apps/bridge/src/install.ts +19 -21
- package/apps/bridge/src/launchd-safety.ts +53 -0
- package/apps/bridge/src/mesh/README.md +33 -7
- 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 +2 -0
- package/apps/bridge/src/mesh/catalog.ts +42 -4
- package/apps/bridge/src/mesh/convergence.ts +82 -0
- package/apps/bridge/src/mesh/identity.ts +10 -4
- package/apps/bridge/src/mesh/readiness.ts +89 -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 +19 -144
- package/apps/bridge/src/mesh/scoped-view.ts +73 -0
- package/apps/bridge/src/mesh/store-state.ts +60 -0
- package/apps/bridge/src/mesh/store-support.ts +15 -0
- package/apps/bridge/src/mesh/store.ts +56 -75
- package/apps/bridge/src/mesh/task-state.ts +106 -0
- package/apps/bridge/src/mesh/worktree.ts +13 -0
- package/apps/bridge/src/self-protection.ts +149 -0
- package/apps/mcp/src/create-server.ts +1 -1
- package/apps/mcp/src/http-service/installer.ts +3 -0
- package/apps/mcp/src/http-service/snapshot.ts +3 -2
- package/apps/mcp/src/mcp-tools/README.md +9 -3
- package/apps/mcp/src/mcp-tools/interaction.ts +94 -17
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +49 -16
- package/apps/mcp/src/mcp-tools/relay.ts +29 -15
- package/apps/mcp/src/mesh-server.ts +1 -1
- package/package.json +3 -3
- package/packages/protocol/messages/approvals.ts +3 -0
- package/packages/protocol/messages/interaction.ts +4 -0
- package/packages/protocol/messages/mesh.ts +13 -0
package/README.md
CHANGED
|
@@ -45,19 +45,39 @@ Project Mesh adds stable Project and Task identity above provider-native
|
|
|
45
45
|
sessions. A Task can retain its `taskId` across multiple executions, agents,
|
|
46
46
|
and computers while each native session remains intact.
|
|
47
47
|
|
|
48
|
-
Agents
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
|
|
55
|
+
own session and sees the exact call. GrantTap attributes every `notify` to the
|
|
56
|
+
session that really made it and publishes the event only for that execution —
|
|
57
|
+
a model that learned another session's id cannot act in its name.
|
|
58
|
+
`granttap://mesh/current` therefore carries no Project data: an attributed call returns this execution's opaque
|
|
59
|
+
`granttap://mesh/<capability>` URI, and only that URI serves its Project, Task,
|
|
60
|
+
owners, claims, dependencies, and relevant events. Ownership transfer stays out
|
|
61
|
+
of the tool surface entirely: `HANDOFF_ACCEPTED` and `HANDOFF_REJECTED` are
|
|
62
|
+
decided by the runtime after receipt verification.
|
|
54
63
|
|
|
55
64
|
The first executable handoff path is Claude Code ↔ Codex across linked
|
|
56
65
|
computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
|
|
57
66
|
requires local phone authorization, creates a separate target branch/worktree,
|
|
58
67
|
starts the target execution, and returns a receipt bound to the exact capsule.
|
|
59
|
-
Cursor
|
|
60
|
-
|
|
68
|
+
Cursor uses the same provider-neutral schema and trusted caller attribution.
|
|
69
|
+
Grok Build remains Experimental and observable where available, but does not
|
|
70
|
+
yet expose a trusted caller hook, so agent-authored scoped Mesh events are not
|
|
71
|
+
offered for it. Unsupported remote-start paths fail closed instead of claiming
|
|
72
|
+
parity.
|
|
73
|
+
|
|
74
|
+
A capsule carries facts, not files. A handoff from a checkout with uncommitted
|
|
75
|
+
changes is refused — "This task has uncommitted changes. Commit or checkpoint
|
|
76
|
+
them before moving the task." — instead of silently continuing the Task from
|
|
77
|
+
committed state and leaving that work behind. The destination is refused just
|
|
78
|
+
as explicitly when the named commit is not on its computer, or when the
|
|
79
|
+
capsule's own resource claims overlap another execution's; GrantTap never
|
|
80
|
+
pushes or fetches on its own.
|
|
61
81
|
|
|
62
82
|
### Grok Bot as a scoped Mesh participant
|
|
63
83
|
|
|
@@ -127,7 +147,8 @@ Setup is CLI-only because it changes provider configuration and must not be
|
|
|
127
147
|
available to a model through prompt injection.
|
|
128
148
|
|
|
129
149
|
`notify` may alternatively carry one bounded task-scoped Mesh event. This does
|
|
130
|
-
not add a fifth MCP tool or grant any global setup capability
|
|
150
|
+
not add a fifth MCP tool or grant any global setup capability, and it publishes
|
|
151
|
+
only for the execution whose provider hook attributed the call.
|
|
131
152
|
|
|
132
153
|
Provider-native approvals and mobile continuation require the matching local
|
|
133
154
|
adapter. MCP registration alone is never reported as proof that an integration
|
package/SECURITY.md
CHANGED
|
@@ -20,6 +20,24 @@ credential used for authenticated WebSocket and APNs registration.
|
|
|
20
20
|
Provider credentials remain in each provider's own local authentication flow.
|
|
21
21
|
GrantTap does not send them to the relay.
|
|
22
22
|
|
|
23
|
+
Installed Claude Code, Codex, and Cursor hooks deny direct agent tool calls
|
|
24
|
+
that name GrantTap's local pairing, key, or policy files, including calls that
|
|
25
|
+
would otherwise be auto-accepted or run in provider bypass mode. The denial
|
|
26
|
+
covers the config directory itself, any glob over it, and every file not on a
|
|
27
|
+
short access-mode list, so a file added later is protected the day it appears.
|
|
28
|
+
Only helper logs and bounded runtime diagnostics are agent-readable, and they
|
|
29
|
+
remain read-only. Raw Mesh state and hook-attribution records are never exposed
|
|
30
|
+
to agent tools; `workspaces/` and `worktrees/` remain normal editable agent
|
|
31
|
+
workspace. The guard denies only what it positively recognised as a path into
|
|
32
|
+
that directory, and a fault inside the guard itself is printed and allowed
|
|
33
|
+
through instead of blocking every tool call on the machine: an outage of every
|
|
34
|
+
agent is a worse failure than the file it was watching. This is
|
|
35
|
+
best-effort self-protection, not an operating-system security boundary: a
|
|
36
|
+
process running as the same macOS user can use an unobserved tool, shell
|
|
37
|
+
obfuscation, or direct filesystem access outside those hooks. Protect the user
|
|
38
|
+
account and disk; use a trusted terminal for intentional maintenance. Moving
|
|
39
|
+
remaining local secret material into Keychain is the stronger future boundary.
|
|
40
|
+
|
|
23
41
|
## Cryptographic boundary
|
|
24
42
|
|
|
25
43
|
- Every computer pairing is generated locally with unique Curve25519 keys.
|
|
@@ -49,6 +67,16 @@ whose independent keys were granted to it. The protection is against relay,
|
|
|
49
67
|
database/network compromise, other pairings, and disclosure of a different
|
|
50
68
|
task key—not against an endpoint already authorized for that task.
|
|
51
69
|
|
|
70
|
+
Project Mesh reads and writes are scoped to one execution. The MCP server
|
|
71
|
+
cannot see which agent called it, so the provider hook — which runs inside the
|
|
72
|
+
session and receives the exact arguments — records a single-use attribution for
|
|
73
|
+
that call, and the runtime publishes the event for that session only. A model
|
|
74
|
+
that learned another execution's session id, Project, or Task cannot publish in
|
|
75
|
+
its name, and the unscoped `granttap://mesh/current` URI carries no Project
|
|
76
|
+
data at all. The opaque capability returned to an attributed caller names one
|
|
77
|
+
Project, Task, session and provider, expires, and never authorizes ownership
|
|
78
|
+
transfer.
|
|
79
|
+
|
|
52
80
|
Task Capsules are strict, bounded schemas containing goal, explicit status,
|
|
53
81
|
repository/commit facts, changed-file names, tests, dependencies, claims,
|
|
54
82
|
remaining work, and explicit decisions. Unknown fields are rejected; hidden
|
|
@@ -57,6 +85,10 @@ acceptance includes a SHA-256 receipt over a canonical capsule and the source,
|
|
|
57
85
|
target, Task identity, and acceptance time. Stale claims expire, replayed event
|
|
58
86
|
IDs are ignored, and destination routing is explicit.
|
|
59
87
|
|
|
88
|
+
Repository remotes included in Project snapshots are reduced to lowercase
|
|
89
|
+
host/path form. Userinfo, passwords or tokens, query strings, fragments, and
|
|
90
|
+
local-path remotes are never persisted as `baseRemote`.
|
|
91
|
+
|
|
60
92
|
## Runtime controls
|
|
61
93
|
|
|
62
94
|
Provider hooks enforce policy on the computer. Global provider deny always
|
|
@@ -77,5 +109,16 @@ expiry, actor, Project scope, and operation, and a revoked or disabled endpoint
|
|
|
77
109
|
fails closed. Invite creation, actor enablement, Project scope, and revocation
|
|
78
110
|
stay in the iPhone UI and the trusted CLI, never in a model-callable tool.
|
|
79
111
|
|
|
112
|
+
## Local helper installation
|
|
113
|
+
|
|
114
|
+
The background helper is a user LaunchAgent, and `launchctl bootstrap` replaces
|
|
115
|
+
whatever job already holds that label. GrantTap therefore refuses to load a
|
|
116
|
+
LaunchAgent whose plist lives under the operating system's temporary directory:
|
|
117
|
+
a test, probe, or agent run with a temporary `HOME` must never evict the
|
|
118
|
+
installed helper and leave launchd restarting a program whose files were already
|
|
119
|
+
cleaned up. Only `GRANTTAP_TEST_FAKE_LAUNCHCTL=1`, which states that `launchctl`
|
|
120
|
+
on `PATH` is a stub, lifts that refusal.
|
|
121
|
+
|
|
122
|
+
|
|
80
123
|
Relay vulnerabilities should be reported through the
|
|
81
124
|
[GrantTap relay advisory form](https://github.com/sergii-ziborov/granttap-relay/security/advisories/new).
|
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
machineConfigPath,
|
|
24
24
|
shouldAutoAcceptTool,
|
|
25
25
|
} from "../config";
|
|
26
|
+
import { recordAttributedCall } from "../mesh/call-scope";
|
|
26
27
|
import { classifyAction } from "../policy";
|
|
28
|
+
import { protectedGrantTapAccess } from "../self-protection";
|
|
27
29
|
|
|
28
30
|
async function readStdin(): Promise<string> {
|
|
29
31
|
const chunks: Buffer[] = [];
|
|
@@ -49,8 +51,30 @@ async function main(): Promise<void> {
|
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
|
|
55
|
+
if (protectedAccess) {
|
|
56
|
+
process.stdout.write(JSON.stringify({
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName: "PreToolUse",
|
|
59
|
+
permissionDecision: "deny",
|
|
60
|
+
permissionDecisionReason: protectedAccess.reason,
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
if (!isProviderEnabled("claude")) return;
|
|
53
67
|
|
|
68
|
+
// Attribute this exact GrantTap call to the session that made it. The MCP
|
|
69
|
+
// server cannot see the caller, so without this a model could publish Mesh
|
|
70
|
+
// events in another execution's name.
|
|
71
|
+
recordAttributedCall({
|
|
72
|
+
provider: "claude",
|
|
73
|
+
sessionId: input.session_id,
|
|
74
|
+
toolName: input.tool_name,
|
|
75
|
+
args: input.tool_input,
|
|
76
|
+
});
|
|
77
|
+
|
|
54
78
|
const blocked = blockedSessionCapability(
|
|
55
79
|
input.session_id,
|
|
56
80
|
input.tool_name,
|
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
machineConfigPath,
|
|
24
24
|
shouldAutoAcceptTool,
|
|
25
25
|
} from "../config";
|
|
26
|
+
import { recordAttributedCall } from "../mesh/call-scope";
|
|
26
27
|
import { classifyAction } from "../policy";
|
|
28
|
+
import { protectedGrantTapAccess } from "../self-protection";
|
|
27
29
|
|
|
28
30
|
async function readStdin(): Promise<string> {
|
|
29
31
|
const chunks: Buffer[] = [];
|
|
@@ -50,8 +52,22 @@ async function main(): Promise<void> {
|
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
|
|
56
|
+
if (protectedAccess) {
|
|
57
|
+
process.stdout.write(denyOutput(protectedAccess.reason));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
if (!isProviderEnabled("codex")) return;
|
|
54
62
|
|
|
63
|
+
// See claude-hook.ts: the MCP server trusts this attribution, never the model.
|
|
64
|
+
recordAttributedCall({
|
|
65
|
+
provider: "codex",
|
|
66
|
+
sessionId: input.session_id,
|
|
67
|
+
toolName: input.tool_name,
|
|
68
|
+
args: input.tool_input,
|
|
69
|
+
});
|
|
70
|
+
|
|
55
71
|
const blocked = blockedSessionCapability(
|
|
56
72
|
input.session_id,
|
|
57
73
|
input.tool_name,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/** Deny-only Codex PreToolUse policy for exact per-chat capability switches. */
|
|
3
3
|
import type { HookInput } from "../adapters";
|
|
4
4
|
import { blockedSessionCapability, isProviderEnabled } from "../config";
|
|
5
|
+
import { protectedGrantTapAccess } from "../self-protection";
|
|
5
6
|
|
|
6
7
|
async function readStdin(): Promise<string> {
|
|
7
8
|
const chunks: Buffer[] = [];
|
|
@@ -10,13 +11,24 @@ async function readStdin(): Promise<string> {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
async function main(): Promise<void> {
|
|
13
|
-
if (!isProviderEnabled("codex")) return;
|
|
14
14
|
let input: HookInput;
|
|
15
15
|
try {
|
|
16
16
|
input = JSON.parse(await readStdin()) as HookInput;
|
|
17
17
|
} catch {
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
+
const protectedAccess = protectedGrantTapAccess(input.tool_name, input.tool_input);
|
|
21
|
+
if (protectedAccess) {
|
|
22
|
+
process.stdout.write(JSON.stringify({
|
|
23
|
+
hookSpecificOutput: {
|
|
24
|
+
hookEventName: "PreToolUse",
|
|
25
|
+
permissionDecision: "deny",
|
|
26
|
+
permissionDecisionReason: protectedAccess.reason,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!isProviderEnabled("codex")) return;
|
|
20
32
|
const blocked = blockedSessionCapability(
|
|
21
33
|
input.session_id,
|
|
22
34
|
input.tool_name,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
shouldAutoAcceptCursorShell,
|
|
17
17
|
} from "../config";
|
|
18
18
|
import { cursorRootSessionId } from "../sessions/cursor";
|
|
19
|
+
import { protectedGrantTapAccess } from "../self-protection";
|
|
19
20
|
|
|
20
21
|
async function readStdin(): Promise<string> {
|
|
21
22
|
const chunks: Buffer[] = [];
|
|
@@ -31,6 +32,14 @@ function nativeAsk(message: string): void {
|
|
|
31
32
|
}));
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function deny(message: string): void {
|
|
36
|
+
process.stdout.write(JSON.stringify({
|
|
37
|
+
permission: "deny", continue: false,
|
|
38
|
+
user_message: message, agent_message: message,
|
|
39
|
+
userMessage: message, agentMessage: message,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
async function main(): Promise<void> {
|
|
35
44
|
let input: CursorHookInput;
|
|
36
45
|
try {
|
|
@@ -39,6 +48,13 @@ async function main(): Promise<void> {
|
|
|
39
48
|
nativeAsk("GrantTap could not read this shell call; use Cursor approval.");
|
|
40
49
|
return;
|
|
41
50
|
}
|
|
51
|
+
const protectedAccess = protectedGrantTapAccess(
|
|
52
|
+
input.tool_name, input.tool_input, input.command,
|
|
53
|
+
);
|
|
54
|
+
if (protectedAccess) {
|
|
55
|
+
deny(protectedAccess.reason);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
42
58
|
if (!isProviderEnabled("cursor")) {
|
|
43
59
|
nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
|
|
44
60
|
return;
|
|
@@ -51,14 +67,7 @@ async function main(): Promise<void> {
|
|
|
51
67
|
input.tool_input ?? { command: input.command },
|
|
52
68
|
);
|
|
53
69
|
if (blocked) {
|
|
54
|
-
|
|
55
|
-
permission: "deny",
|
|
56
|
-
continue: false,
|
|
57
|
-
user_message: blocked.reason,
|
|
58
|
-
agent_message: blocked.reason,
|
|
59
|
-
userMessage: blocked.reason,
|
|
60
|
-
agentMessage: blocked.reason,
|
|
61
|
-
}));
|
|
70
|
+
deny(blocked.reason);
|
|
62
71
|
return;
|
|
63
72
|
}
|
|
64
73
|
if (!sessionId || isGatingSkipped(sessionId)) {
|
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
resolveCursorMcpServer,
|
|
18
18
|
type CursorMcpHookInput,
|
|
19
19
|
} from "../cursor-mcp-policy";
|
|
20
|
+
import { recordAttributedCall } from "../mesh/call-scope";
|
|
20
21
|
import { cursorRootSessionId } from "../sessions/cursor";
|
|
22
|
+
import { protectedGrantTapAccess } from "../self-protection";
|
|
21
23
|
|
|
22
24
|
async function readStdin(): Promise<string> {
|
|
23
25
|
const chunks: Buffer[] = [];
|
|
@@ -41,12 +43,30 @@ async function main(): Promise<void> {
|
|
|
41
43
|
nativeAsk("GrantTap could not correlate this MCP call; use Cursor approval.");
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
46
|
+
const protectedAccess = protectedGrantTapAccess(
|
|
47
|
+
input.tool_name, input.tool_input, input.command,
|
|
48
|
+
);
|
|
49
|
+
if (protectedAccess) {
|
|
50
|
+
process.stdout.write(JSON.stringify({
|
|
51
|
+
permission: "deny",
|
|
52
|
+
user_message: protectedAccess.reason,
|
|
53
|
+
agent_message: protectedAccess.reason,
|
|
54
|
+
}));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
44
57
|
if (!isProviderEnabled("cursor")) {
|
|
45
58
|
nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
|
|
46
59
|
return;
|
|
47
60
|
}
|
|
48
61
|
const rawSessionId = cursorConversationId(input);
|
|
49
62
|
const sessionId = cursorRootSessionId(rawSessionId) ?? rawSessionId;
|
|
63
|
+
// See claude-hook.ts: Mesh events are published only for the calling session.
|
|
64
|
+
recordAttributedCall({
|
|
65
|
+
provider: "cursor",
|
|
66
|
+
sessionId,
|
|
67
|
+
toolName: input.tool_name,
|
|
68
|
+
args: input.tool_input,
|
|
69
|
+
});
|
|
50
70
|
const server = resolveCursorMcpServer(input);
|
|
51
71
|
const blocked = blockedSessionMcpServer(sessionId, server);
|
|
52
72
|
if (blocked) {
|
|
@@ -23,6 +23,7 @@ import { delimiter, dirname, join } from "node:path";
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
25
|
import type { CodingAgent } from "../../../packages/protocol/schema";
|
|
26
|
+
import { refusesLiveLaunchd } from "./launchd-safety";
|
|
26
27
|
import { configDir } from "./config";
|
|
27
28
|
|
|
28
29
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
@@ -299,18 +300,15 @@ export function inspectMonitorHelper(): MonitorIntegrationStatus {
|
|
|
299
300
|
return { configured: true, running: active.status === 0 };
|
|
300
301
|
}
|
|
301
302
|
|
|
302
|
-
/**
|
|
303
|
-
export
|
|
304
|
-
export const DEFAULT_PINNED_MONITOR_ROOT = "/Users/serhiirihgt/dev/nodvox";
|
|
305
|
-
|
|
306
|
-
export function pinnedMonitorBin(): string {
|
|
303
|
+
/** Optional explicit development pin; public installs use this package by default. */
|
|
304
|
+
export function pinnedMonitorBin(): string | undefined {
|
|
307
305
|
const override = process.env.GRANTTAP_PINNED_MONITOR_BIN?.trim();
|
|
308
|
-
return override && override.length > 0 ? override :
|
|
306
|
+
return override && override.length > 0 ? override : undefined;
|
|
309
307
|
}
|
|
310
308
|
|
|
311
|
-
export function pinnedMonitorRoot(): string {
|
|
309
|
+
export function pinnedMonitorRoot(): string | undefined {
|
|
312
310
|
const override = process.env.GRANTTAP_PINNED_MONITOR_ROOT?.trim();
|
|
313
|
-
return override && override.length > 0 ? override :
|
|
311
|
+
return override && override.length > 0 ? override : undefined;
|
|
314
312
|
}
|
|
315
313
|
|
|
316
314
|
function xml(value: string): string {
|
|
@@ -326,15 +324,12 @@ export function isCursorHelperNode(nodePath: string): boolean {
|
|
|
326
324
|
return nodePath.includes("Cursor.app") || nodePath.includes("/helpers/node");
|
|
327
325
|
}
|
|
328
326
|
|
|
329
|
-
/** True when plist
|
|
327
|
+
/** True when a plist uses the caller's explicit development monitor pin. */
|
|
330
328
|
export function isNodvoxPinnedPlist(contents: string): boolean {
|
|
331
329
|
const pin = pinnedMonitorBin();
|
|
332
|
-
|
|
333
|
-
contents.includes(pin) ||
|
|
334
|
-
contents.includes("/dev/nodvox/bin/granttap.mjs") ||
|
|
335
|
-
contents.includes("/nodvox/bin/granttap.mjs");
|
|
330
|
+
if (!pin) return false;
|
|
336
331
|
return (
|
|
337
|
-
|
|
332
|
+
contents.includes(pin) &&
|
|
338
333
|
!contents.includes("granttap-mcp.mjs") &&
|
|
339
334
|
!contents.includes("Cursor.app") &&
|
|
340
335
|
!contents.includes("/helpers/node")
|
|
@@ -364,8 +359,8 @@ export function resolveMonitorNodeBin(): string | null {
|
|
|
364
359
|
* Keep task/session sync alive without a terminal or a newly opened MCP chat.
|
|
365
360
|
*
|
|
366
361
|
* Hard rules:
|
|
367
|
-
* - Never overwrite a healthy
|
|
368
|
-
* -
|
|
362
|
+
* - Never overwrite a healthy explicitly pinned LaunchAgent with package / Cursor helpers.
|
|
363
|
+
* - Development pins are opt-in through `GRANTTAP_PINNED_MONITOR_BIN`.
|
|
369
364
|
* - Never put Cursor.app helpers/node in ProgramArguments.
|
|
370
365
|
*/
|
|
371
366
|
export function installMonitorHelper(): InstallResult {
|
|
@@ -383,11 +378,12 @@ export function installMonitorHelper(): InstallResult {
|
|
|
383
378
|
}
|
|
384
379
|
}
|
|
385
380
|
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
381
|
+
const explicitPin = pinnedMonitorBin();
|
|
382
|
+
const pinBin = explicitPin && existsSync(explicitPin) ? explicitPin : undefined;
|
|
383
|
+
const usePin = pinBin != null;
|
|
384
|
+
const executable = pinBin ?? join(repoRoot, "bin", "granttap-mcp.mjs");
|
|
385
|
+
const workingDirectory = pinBin
|
|
386
|
+
? (pinnedMonitorRoot() ?? dirname(pinBin))
|
|
391
387
|
: (process.env.GRANTTAP_MONITOR_CWD ?? process.cwd());
|
|
392
388
|
const nodeBin = resolveMonitorNodeBin();
|
|
393
389
|
if (!nodeBin) {
|
|
@@ -473,6 +469,8 @@ export function installMonitorHelper(): InstallResult {
|
|
|
473
469
|
return { status: already ? "already" : "installed", detail };
|
|
474
470
|
}
|
|
475
471
|
|
|
472
|
+
const sandboxed = refusesLiveLaunchd(path);
|
|
473
|
+
if (sandboxed) return { status: "manual", detail: sandboxed };
|
|
476
474
|
const uid = process.getuid?.();
|
|
477
475
|
if (uid == null) return { status: "manual", detail: `${path}: could not determine user id` };
|
|
478
476
|
const domain = `gui/${uid}`;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A LaunchAgent is user-global: `launchctl bootstrap` replaces whatever job
|
|
3
|
+
* already holds that label. A sandboxed run — a test, a probe, anything with a
|
|
4
|
+
* temporary `HOME` or config directory — therefore evicts the real GrantTap
|
|
5
|
+
* helper and installs a job whose plist, node modules, and log file vanish with
|
|
6
|
+
* the temporary directory. launchd then keeps restarting a program it can no
|
|
7
|
+
* longer load, which reads to the user as GrantTap crashing in a loop.
|
|
8
|
+
*
|
|
9
|
+
* A real installation always writes into the user's own Library. A plist under
|
|
10
|
+
* the operating system's temporary directory is a sandbox by definition, so it
|
|
11
|
+
* never reaches the live domain.
|
|
12
|
+
*/
|
|
13
|
+
import { realpathSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { resolve, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
const TEMPORARY_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
18
|
+
|
|
19
|
+
function canonical(path: string): string {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(path);
|
|
22
|
+
} catch {
|
|
23
|
+
return resolve(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function insideTemporaryDirectory(path: string): boolean {
|
|
28
|
+
if (process.env.GRANTTAP_TEST_FAKE_LAUNCHCTL === "1") return false;
|
|
29
|
+
const target = canonical(path);
|
|
30
|
+
const roots = new Set<string>();
|
|
31
|
+
for (const root of [tmpdir(), ...TEMPORARY_ROOTS]) {
|
|
32
|
+
roots.add(resolve(root));
|
|
33
|
+
roots.add(canonical(root));
|
|
34
|
+
}
|
|
35
|
+
return [...roots].some((root) => target === root || target.startsWith(root + sep));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SANDBOXED_LAUNCH_AGENT_DETAIL =
|
|
39
|
+
"refusing to load a LaunchAgent from a temporary directory; "
|
|
40
|
+
+ "a sandboxed run must not replace the installed GrantTap helper";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether this plist may be handed to `launchctl` in the live user domain.
|
|
44
|
+
*
|
|
45
|
+
* `GRANTTAP_TEST_FAKE_LAUNCHCTL=1` states that the `launchctl` on PATH is a
|
|
46
|
+
* stub, so the whole install path can be exercised without a live domain to
|
|
47
|
+
* damage. It is an explicit, greppable opt-in; nothing in the shipped product
|
|
48
|
+
* sets it.
|
|
49
|
+
*/
|
|
50
|
+
export function refusesLiveLaunchd(path: string): string | null {
|
|
51
|
+
if (process.env.GRANTTAP_TEST_FAKE_LAUNCHCTL === "1") return null;
|
|
52
|
+
return insideTemporaryDirectory(path) ? `${path}: ${SANDBOXED_LAUNCH_AGENT_DETAIL}` : null;
|
|
53
|
+
}
|
|
@@ -9,14 +9,40 @@ identity without replacing provider-native session discovery. `store.ts`
|
|
|
9
9
|
deduplicates replayed events and expires stale claims. `capsule.ts` reads only
|
|
10
10
|
explicit git and task facts; transcripts and hidden reasoning are never copied.
|
|
11
11
|
`worktree.ts` creates a separate target branch/worktree before Claude Code or
|
|
12
|
-
Codex is started.
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Codex is started. Cursor has trusted caller attribution but no phase-one remote
|
|
13
|
+
start. Grok Build remains observable and Experimental; because it has no
|
|
14
|
+
trusted caller hook, agent-authored scoped Mesh events are not offered for it.
|
|
15
|
+
Unsupported paths fail closed.
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
`call-scope.ts` and `capability.ts` own caller identity. The MCP server cannot
|
|
18
|
+
see which agent called it, so supported provider hooks record a single-use
|
|
19
|
+
attribution for the exact call it observed inside its own session, and the
|
|
20
|
+
runtime derives the publishing execution from that instead of from anything the
|
|
21
|
+
model supplied. `capability.ts` mints the opaque per-execution token that
|
|
22
|
+
`scoped-view.ts` answers with, so a read returns one Project rather than
|
|
23
|
+
everything this computer knows. Structured events use the existing `notify`
|
|
24
|
+
tool so the public MCP tool allowlist remains exactly `connect`, `notify`,
|
|
25
|
+
`ask_yes_no`, and `ask`. Setup and global provider configuration remain
|
|
26
|
+
CLI-only.
|
|
27
|
+
|
|
28
|
+
`readiness.ts` decides whether a handoff may leave at all. A capsule transfers
|
|
29
|
+
committed facts, so uncommitted work blocks the handoff instead of silently
|
|
30
|
+
staying behind, and the destination refuses just as explicitly when it lacks the
|
|
31
|
+
named commit or when the capsule's resource claims overlap another execution.
|
|
32
|
+
"Uncommitted" is read from `git status` including untracked files, because an
|
|
33
|
+
untracked file is work no commit would carry. A probe that cannot answer
|
|
34
|
+
publishes `unknown`, and only an explicit `clean` releases the handoff.
|
|
35
|
+
|
|
36
|
+
`convergence.ts` and `task-state.ts` keep one Task identity while snapshots and
|
|
37
|
+
events arrive late, twice, and from several computers. Every writer raises the
|
|
38
|
+
Task `revision`, a merge keeps the higher one, and a tie resolves the same way
|
|
39
|
+
on every device. Ownership only moves forward: to an unowned Task, back to the
|
|
40
|
+
session that already owns it, or through a receipt from the current owner —
|
|
41
|
+
and a computer that already watched a session hand the Task on refuses that
|
|
42
|
+
session's later receipt. Completed and failed Tasks absorb events instead of
|
|
43
|
+
reopening, an execution closed by a receipt stays closed even while its native
|
|
44
|
+
session keeps reporting itself, and the local catalog may refresh a Task's
|
|
45
|
+
description but never take it back from the agent that owns it now.
|
|
20
46
|
|
|
21
47
|
Every event is schema-bounded and sent under an independent task key; snapshots
|
|
22
48
|
use an independent project key. Resource claims are advisory, expire without a
|
|
@@ -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 || tool === "connect" || !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
|
+
}
|