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.
Files changed (39) hide show
  1. package/README.md +30 -9
  2. package/SECURITY.md +43 -0
  3. package/apps/bridge/src/bin/claude-hook.ts +24 -0
  4. package/apps/bridge/src/bin/codex-hook.ts +16 -0
  5. package/apps/bridge/src/bin/codex-policy-hook.ts +13 -1
  6. package/apps/bridge/src/bin/cursor-hook.ts +17 -8
  7. package/apps/bridge/src/bin/cursor-mcp-hook.ts +20 -0
  8. package/apps/bridge/src/install.ts +19 -21
  9. package/apps/bridge/src/launchd-safety.ts +53 -0
  10. package/apps/bridge/src/mesh/README.md +33 -7
  11. package/apps/bridge/src/mesh/call-scope.ts +135 -0
  12. package/apps/bridge/src/mesh/capability.ts +135 -0
  13. package/apps/bridge/src/mesh/capsule.ts +2 -0
  14. package/apps/bridge/src/mesh/catalog.ts +42 -4
  15. package/apps/bridge/src/mesh/convergence.ts +82 -0
  16. package/apps/bridge/src/mesh/identity.ts +10 -4
  17. package/apps/bridge/src/mesh/readiness.ts +89 -0
  18. package/apps/bridge/src/mesh/runtime-dependencies.ts +37 -0
  19. package/apps/bridge/src/mesh/runtime-handoff.ts +167 -0
  20. package/apps/bridge/src/mesh/runtime.ts +19 -144
  21. package/apps/bridge/src/mesh/scoped-view.ts +73 -0
  22. package/apps/bridge/src/mesh/store-state.ts +60 -0
  23. package/apps/bridge/src/mesh/store-support.ts +15 -0
  24. package/apps/bridge/src/mesh/store.ts +56 -75
  25. package/apps/bridge/src/mesh/task-state.ts +106 -0
  26. package/apps/bridge/src/mesh/worktree.ts +13 -0
  27. package/apps/bridge/src/self-protection.ts +149 -0
  28. package/apps/mcp/src/create-server.ts +1 -1
  29. package/apps/mcp/src/http-service/installer.ts +3 -0
  30. package/apps/mcp/src/http-service/snapshot.ts +3 -2
  31. package/apps/mcp/src/mcp-tools/README.md +9 -3
  32. package/apps/mcp/src/mcp-tools/interaction.ts +94 -17
  33. package/apps/mcp/src/mcp-tools/mesh-resource.ts +49 -16
  34. package/apps/mcp/src/mcp-tools/relay.ts +29 -15
  35. package/apps/mcp/src/mesh-server.ts +1 -1
  36. package/package.json +3 -3
  37. package/packages/protocol/messages/approvals.ts +3 -0
  38. package/packages/protocol/messages/interaction.ts +4 -0
  39. 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 can read the compact `granttap://mesh/current` MCP resource and publish
49
- bounded progress, dependency, question, answer, claim, conflict, and completion
50
- events through the existing `notify` tool. Full transcripts and hidden
51
- reasoning are never mesh payloads. Claims have TTLs; a colliding claim is
52
- rejected before it is recorded so agents can choose different work or contact
53
- the owner before escalating to Needs You.
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 and Grok Build use the same provider-neutral schema and discovery model;
60
- unsupported remote-start paths fail closed instead of claiming parity.
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
- process.stdout.write(JSON.stringify({
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
- /** Absolute pin for this machine LaunchAgent must never drift to granttap-mcp + Cursor helpers. */
303
- export const DEFAULT_PINNED_MONITOR_BIN = "/Users/serhiirihgt/dev/nodvox/bin/granttap.mjs";
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 : DEFAULT_PINNED_MONITOR_BIN;
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 : DEFAULT_PINNED_MONITOR_ROOT;
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 already runs the nodvox monitor without Cursor/mcp clobber. */
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
- const hasNodvoxBin =
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
- hasNodvoxBin &&
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 nodvox-pinned LaunchAgent with granttap-mcp / Cursor helpers.
368
- * - When `/Users/serhiirihgt/dev/nodvox/bin/granttap.mjs` exists, pin to it (not this package's bin).
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 pinBin = pinnedMonitorBin();
387
- const usePin = existsSync(pinBin);
388
- const executable = usePin ? pinBin : join(repoRoot, "bin", "granttap-mcp.mjs");
389
- const workingDirectory = usePin
390
- ? pinnedMonitorRoot()
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. 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.
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
- The read-only MCP resource `granttap://mesh/current` exposes compact local state
17
- to an agent. Structured events use the existing `notify` tool so the public MCP
18
- tool allowlist remains exactly `connect`, `notify`, `ask_yes_no`, and `ask`.
19
- Setup and global provider configuration remain CLI-only.
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
+ }