granttap-mcp 0.8.41 → 0.8.47

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 (76) hide show
  1. package/README.md +20 -0
  2. package/apps/bridge/src/account-recovery/README.md +6 -4
  3. package/apps/bridge/src/account-recovery/offer.ts +2 -1
  4. package/apps/bridge/src/account-recovery/phone-link.ts +3 -10
  5. package/apps/bridge/src/approvals/api.ts +3 -0
  6. package/apps/bridge/src/approvals/index.ts +4 -1
  7. package/apps/bridge/src/approvals/records.ts +5 -2
  8. package/apps/bridge/src/bin/hooks/claude-hook.ts +28 -5
  9. package/apps/bridge/src/bin/hooks/codex-hook.ts +12 -4
  10. package/apps/bridge/src/bin/hooks/cursor-after-shell.ts +3 -1
  11. package/apps/bridge/src/bin/runtime/monitor.ts +2 -0
  12. package/apps/bridge/src/claude-questions/README.md +13 -0
  13. package/apps/bridge/src/claude-questions/index.ts +26 -0
  14. package/apps/bridge/src/config/commands.ts +14 -1
  15. package/apps/bridge/src/config/index.ts +1 -0
  16. package/apps/bridge/src/config/runtime/index.ts +14 -5
  17. package/apps/bridge/src/configuration-delivery/README.md +10 -0
  18. package/apps/bridge/src/configuration-delivery/index.ts +17 -0
  19. package/apps/bridge/src/engine/runtime/engine-memory.ts +7 -0
  20. package/apps/bridge/src/host/adapters.ts +6 -1
  21. package/apps/bridge/src/host/approval-presentation/README.md +10 -0
  22. package/apps/bridge/src/host/approval-presentation/index.ts +51 -0
  23. package/apps/bridge/src/install/inspect.ts +1 -0
  24. package/apps/bridge/src/managed-admission/README.md +14 -0
  25. package/apps/bridge/src/managed-admission/index.ts +30 -0
  26. package/apps/bridge/src/mesh/context/README.md +19 -0
  27. package/apps/bridge/src/mesh/context/project.ts +4 -1
  28. package/apps/bridge/src/mesh/context/selection.ts +31 -0
  29. package/apps/bridge/src/mesh/delivery/run-digest.ts +2 -1
  30. package/apps/bridge/src/monitor/handlers/catalog.ts +7 -3
  31. package/apps/bridge/src/monitor/handlers/inbound.ts +15 -7
  32. package/apps/bridge/src/monitor/handlers/session-commands.ts +2 -2
  33. package/apps/bridge/src/monitor/support/runtime.ts +14 -3
  34. package/apps/bridge/src/session-catalog/README.md +15 -0
  35. package/apps/bridge/src/session-catalog/index.ts +68 -0
  36. package/apps/bridge/src/session-images/README.md +14 -0
  37. package/apps/bridge/src/session-images/index.ts +36 -0
  38. package/apps/bridge/src/sessions/index.ts +1 -0
  39. package/apps/bridge/src/sessions/native-source.ts +35 -0
  40. package/apps/bridge/src/sessions/scan/claude/activity.ts +4 -2
  41. package/apps/bridge/src/sessions/scan/codex/README.md +16 -0
  42. package/apps/bridge/src/sessions/scan/codex/activity/rows.ts +3 -1
  43. package/apps/bridge/src/sessions/scan/codex/image/index.ts +2 -2
  44. package/apps/bridge/src/sessions/scan/codex/parse.ts +17 -5
  45. package/apps/bridge/src/sessions/support/activity-helpers.ts +10 -2
  46. package/apps/bridge/src/sessions/support/codex-head.ts +15 -3
  47. package/apps/bridge/src/task-automation/README.md +13 -0
  48. package/apps/bridge/src/task-automation/controller-grant.ts +33 -0
  49. package/apps/bridge/src/task-automation/index.ts +49 -0
  50. package/apps/bridge/src/transcript-history/README.md +3 -0
  51. package/apps/bridge/src/transcript-history/images/native.ts +57 -0
  52. package/apps/bridge/src/transcript-history/index.ts +6 -4
  53. package/apps/bridge/src/transcript-history/native-lines.ts +40 -14
  54. package/apps/bridge/src/transcript-history/turn-changes.ts +2 -2
  55. package/apps/mcp/src/connection-center/README.md +2 -0
  56. package/apps/mcp/src/connection-center/state.ts +1 -0
  57. package/apps/mcp/src/connection-center/view.js +4 -3
  58. package/apps/mcp/src/connection-center/widget.html +1 -1
  59. package/apps/mcp/src/desktop/image/index.ts +35 -3
  60. package/apps/mcp/src/desktop/task-activity.ts +2 -2
  61. package/apps/mcp/src/mcp-tools/connect/index.ts +3 -1
  62. package/cursor-plugin/README.md +1 -1
  63. package/cursor-plugin/mcp.json +1 -1
  64. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  65. package/package.json +1 -1
  66. package/packages/core/outbound-scheduler/README.md +13 -0
  67. package/packages/core/outbound-scheduler/index.ts +83 -0
  68. package/packages/core/relay-client-types.ts +2 -0
  69. package/packages/core/relay-client.ts +19 -18
  70. package/packages/protocol/messages/approval-questions/index.ts +23 -0
  71. package/packages/protocol/messages/approvals.ts +7 -2
  72. package/packages/protocol/messages/capabilities.ts +4 -0
  73. package/packages/protocol/messages/session-images/index.ts +24 -0
  74. package/packages/protocol/messages/sessions/configuration.ts +61 -0
  75. package/packages/protocol/messages/sessions/index.ts +2 -42
  76. package/packages/protocol/schema.ts +6 -0
package/README.md CHANGED
@@ -17,6 +17,12 @@ adapters, and wire contracts. It powers Project Mesh, Governance, and observed
17
17
  usage while agents and provider credentials stay on your computer. Controller
18
18
  traffic is end-to-end encrypted.
19
19
 
20
+ Known chats are read from their published native identities, without another
21
+ walk through every provider log. The local helper retains a private metadata
22
+ index so a restart can send current heads of the saved Now chats before a fresh
23
+ discovery scan. The Apple clients keep downloaded conversations, pictures, and
24
+ Mesh data locally for offline use; clearing that cache preserves the account login.
25
+
20
26
  The MCP runtime, plugins, and CLI are open source under the MIT License.
21
27
  The Apple apps, Engine, relay, and website are separately licensed commercial
22
28
  products or source. Their licenses do not change this repository's MIT grant.
@@ -328,6 +334,20 @@ invites, change the relay, expand Project scope, or reach `setup`; revoking the
328
334
  endpoint from the iPhone stops new Mesh operations immediately while local Task
329
335
  history stays on the device.
330
336
 
337
+ ## Decisions and Claude questions
338
+
339
+ Tap an approval card to inspect the exact tool parameters and configure Auto
340
+ allow for that chat. Apply waits for a receipt from the owning computer; an
341
+ unconfirmed command is never shown as applied. The grant remains tied to the
342
+ chat, Project, and current policy revision. Global DENY, paused automation, and
343
+ new governance revisions still take precedence.
344
+
345
+ Claude Code `AskUserQuestion` is delivered as a question form, including all
346
+ options and descriptions, multiple selections, and an Other answer. Complete
347
+ answers return through the same encrypted durable channel with Claude's original
348
+ question array. Questions always need a human answer; Auto allow cannot answer
349
+ them. A lost connection keeps Claude's local question flow available.
350
+
331
351
  ## Install
332
352
 
333
353
  The local runtime and plugins are available under the [MIT License](LICENSE).
@@ -3,10 +3,12 @@
3
3
  `link.ts` persists the local Mac's opt-in account machine credential in its
4
4
  private GrantTap config directory. The Mac app can enroll through its existing
5
5
  authorized loopback connection; the provider MCP consent flow can enroll after
6
- a verified passkey assertion. `phone-link.ts` lets a QR-paired phone grant a
7
- machine-scoped credential through the existing encrypted device room after
8
- passkey sign-in. The Mac verifies it against the account service before saving
9
- and never receives the phone's account session token. The credential lets
6
+ a verified passkey assertion. `phone-link.ts` accepts a legacy QR room's
7
+ machine-scoped credential only when it matches the Mac's existing account
8
+ machine, or when the Mac has no account machine yet. It cannot revoke or
9
+ replace a machine enrolled through passkey. The Mac verifies the credential
10
+ against the account service before saving and never receives the phone's
11
+ account session token. The credential lets
10
12
  `poller.ts` answer
11
13
  short-lived, account-authorized phone requests. `offer.ts` creates a new phone
12
14
  controller key in the current room and seals its pairing half to the phone's
@@ -30,7 +30,8 @@ export function createRecoveryOffer(request: RecoveryRequest, machineId: string)
30
30
  const extra = machineCfg.extraPeerPublicKeys ?? [];
31
31
  if (extra.length >= 16) throw new Error("This computer has reached its controller device limit.");
32
32
  const keys = generateKeyPair();
33
- phoneCfg = { ...phoneCfg, senderId: randomId(8), myPublicKey: keys.publicKey,
33
+ phoneCfg = { ...phoneCfg, peerPublicKey: machineCfg.myPublicKey,
34
+ senderId: randomId(8), myPublicKey: keys.publicKey,
34
35
  mySecretKey: keys.secretKey };
35
36
  machineCfg = { ...machineCfg, extraPeerPublicKeys: [...extra, keys.publicKey] };
36
37
  }
@@ -1,13 +1,14 @@
1
1
  import type { AccountMachineLink } from "../../../../packages/protocol/schema";
2
2
  import { loadAccountLink, saveAccountLink } from "./link";
3
3
 
4
- /** A trusted QR room can grant only a machine credential, never an account session. */
4
+ /** A trusted QR room can enroll an unlinked Mac, never replace its machine identity. */
5
5
  export async function acceptPhoneAccountLink(
6
6
  link: AccountMachineLink, origin: string, request: typeof fetch = fetch,
7
7
  ): Promise<boolean> {
8
8
  if (new URL(origin).protocol !== "https:") return false;
9
9
  const current = loadAccountLink();
10
- if (current && current.accountId !== link.accountId) return false;
10
+ if (current && (current.accountId !== link.accountId
11
+ || current.machineId !== link.machineId)) return false;
11
12
  const response = await request(`${origin}/api/account/machine/identity`, {
12
13
  headers: { authorization: `Bearer ${link.machineToken}`, accept: "application/json" },
13
14
  signal: AbortSignal.timeout(8_000), redirect: "error",
@@ -15,14 +16,6 @@ export async function acceptPhoneAccountLink(
15
16
  if (!response.ok) return false;
16
17
  const identity = await response.json() as { accountId?: string; machineId?: string };
17
18
  if (identity.accountId !== link.accountId || identity.machineId !== link.machineId) return false;
18
- if (current && current.machineId !== link.machineId) {
19
- const revoked = await request(`${origin}/api/account/machine/self`, {
20
- method: "DELETE",
21
- headers: { authorization: `Bearer ${current.machineToken}`, accept: "application/json" },
22
- signal: AbortSignal.timeout(8_000), redirect: "error",
23
- });
24
- if (!revoked.ok && revoked.status !== 401) return false;
25
- }
26
19
  return saveAccountLink({ accountId: link.accountId,
27
20
  machineId: link.machineId, machineToken: link.machineToken });
28
21
  }
@@ -1,3 +1,4 @@
1
+ import { questionAnswers } from "../../../../packages/protocol/messages/approval-questions";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import type { RelayClient } from "../../../../packages/core/relay-client";
3
4
  import {
@@ -86,6 +87,8 @@ export function acceptApprovalDecision(
86
87
  if (requestSessionId !== decisionSessionId) {
87
88
  return { matched: false, newlyResolved: false };
88
89
  }
90
+ if (record.request.kind === "question" && decision.decision === "allow"
91
+ && !questionAnswers(record.request.questions, decision.note)) return { matched: false, newlyResolved: false };
89
92
  const existingOutcome = recordOutcome(record);
90
93
  if (existingOutcome) return acceptance(record, existingOutcome, false);
91
94
  if (record.state !== "pending") return { matched: false, newlyResolved: false };
@@ -21,6 +21,7 @@ import {
21
21
  import { primeSessionKeys, sendSessionPayload } from "../host/session-keys";
22
22
  import { controllerPeerGate } from "../pairing/controllers";
23
23
  import { applyNetworkRoute } from "../device-network/settings";
24
+ import { managedAdmissionOptions } from "../managed-admission";
24
25
 
25
26
  /**
26
27
  * Nobody answered — relay unreachable, or the phone never responded before the
@@ -46,7 +47,9 @@ export async function requestApproval(
46
47
  opts: RequestApprovalOpts = {},
47
48
  ): Promise<ApprovalDecision> {
48
49
  const timeoutMs = opts.timeoutMs ?? 60_000;
49
- const client = opts.client ?? new RelayClient(applyNetworkRoute(cfg), { peerAllowed: controllerPeerGate(cfg) });
50
+ const routed = applyNetworkRoute(cfg);
51
+ const client = opts.client ?? new RelayClient(routed, {
52
+ ...managedAdmissionOptions(routed), peerAllowed: controllerPeerGate(cfg) });
50
53
  const ownsClient = !opts.client;
51
54
  let cancelDecisionWait = () => {};
52
55
  const registration = registerPendingApproval(req);
@@ -39,7 +39,7 @@ export function requestDigest(requestId: string): string {
39
39
  }
40
40
 
41
41
  export function requestFingerprint(request: ApprovalRequest): string {
42
- const stable = JSON.stringify([
42
+ const fields: unknown[] = [
43
43
  request.type,
44
44
  request.requestId,
45
45
  request.agent,
@@ -50,7 +50,10 @@ export function requestFingerprint(request: ApprovalRequest): string {
50
50
  request.cwd ?? null,
51
51
  normalizedSessionId(request.sessionId) ?? null,
52
52
  request.risk,
53
- ]);
53
+ ];
54
+ if (request.inputFingerprint || request.details) fields.push(request.inputFingerprint ?? request.details);
55
+ if (request.questions) fields.push(request.questions);
56
+ const stable = JSON.stringify(fields);
54
57
  return createHash("sha256").update(stable).digest("hex");
55
58
  }
56
59
 
@@ -15,6 +15,8 @@
15
15
  import { recordProjectDecision } from "../../policy/decision-log";
16
16
  import { claudeToRequest, decisionToClaudeOutput, type HookInput } from "../../host/adapters";
17
17
  import { isUnanswered, requestApproval } from "../../approvals";
18
+ import { questionRequest, claudeQuestionOutput } from "../../claude-questions";
19
+ import { permitsProjectAskAutomation } from "../../task-automation";
18
20
  import {
19
21
  autoAcceptLevelFor,
20
22
  blockedSessionCapability,
@@ -100,15 +102,17 @@ async function handlePreToolUse(input: HookInput): Promise<void> {
100
102
 
101
103
  // Claude already granted this chat unconditional tool access. GrantTap's
102
104
  // Project ASK/DENY is a parent boundary, so bypass mode cannot weaken it.
103
- if (legacyFlow && input.permission_mode === "bypassPermissions") return;
105
+ if (legacyFlow && input.permission_mode === "bypassPermissions" && input.tool_name !== "AskUserQuestion") return;
104
106
 
105
107
  // Gating paused, or this session is exempt → abstain (empty output = Claude
106
108
  // uses its normal permission flow, exactly as if GrantTap weren't installed).
107
109
  if (legacyFlow && isGatingSkipped(input.session_id)) return;
108
- await continuePermissionFlow(input, legacyFlow);
110
+ await continuePermissionFlow(input, legacyFlow,
111
+ permitsProjectAskAutomation(projectDecision, input.session_id), projectDecision);
109
112
  }
110
113
 
111
- async function continuePermissionFlow(input: HookInput, legacyFlow: boolean): Promise<void> {
114
+ async function continuePermissionFlow(input: HookInput, legacyFlow: boolean, taskAutomation = false,
115
+ scope: { projectId?: string; policy_revision?: number } = {}): Promise<void> {
112
116
  let cfg;
113
117
  try {
114
118
  cfg = loadConfig(machineConfigPath());
@@ -127,8 +131,12 @@ async function continuePermissionFlow(input: HookInput, legacyFlow: boolean): Pr
127
131
  // keep Claude's own prompts rather than be silently auto-allowed by a product
128
132
  // the user never finished setting up. Once paired, this is what keeps routine
129
133
  // work independent of whether the phone is awake, reachable, or even running.
130
- const req = claudeToRequest(input);
131
- if (legacyFlow && shouldAutoAcceptTool(input.session_id, req.tool, req.command)) {
134
+ if (input.tool_name === "AskUserQuestion") {
135
+ await answerNativeQuestion(input, cfg, scope);
136
+ return;
137
+ }
138
+ const req = { ...claudeToRequest(input), projectId: scope.projectId, policyRevision: scope.policy_revision };
139
+ if ((legacyFlow || taskAutomation) && shouldAutoAcceptTool(input.session_id, req.tool, req.command, scope.projectId)) {
132
140
  writeAutoApproval(input, req);
133
141
  return;
134
142
  }
@@ -152,6 +160,21 @@ async function continuePermissionFlow(input: HookInput, legacyFlow: boolean): Pr
152
160
  process.stdout.write(JSON.stringify(decisionToClaudeOutput(decision)));
153
161
  }
154
162
 
163
+ async function answerNativeQuestion(input: HookInput, config: Parameters<typeof requestApproval>[0],
164
+ scope: { projectId?: string; policy_revision?: number }): Promise<void> {
165
+ const request = questionRequest(input);
166
+ if (!request) return; // Unsupported native shape keeps Claude's question UI.
167
+ request.projectId = scope.projectId;
168
+ request.policyRevision = scope.policy_revision;
169
+ const decision = await requestApproval(config, request, {
170
+ timeoutMs: Math.min(180_000, Number(process.env.GRANTTAP_APPROVAL_TIMEOUT_MS ?? 120_000)),
171
+ });
172
+ if (isUnanswered(decision)) return;
173
+ const output = claudeQuestionOutput(input, decision, request);
174
+ if (output) process.stdout.write(JSON.stringify(output));
175
+ else if (decision.decision === "deny") writePermission("deny", "The user declined to answer this question");
176
+ }
177
+
155
178
  function writeAutoApproval(input: HookInput, req: ReturnType<typeof claudeToRequest>): void {
156
179
  const level = autoAcceptLevelFor(input.session_id);
157
180
  const cls = classifyAction(req.tool, req.command);
@@ -26,6 +26,8 @@ import {
26
26
  import { recordAttributedCall } from "../../mesh/identity/call-scope";
27
27
  import { classifyAction } from "../../policy";
28
28
  import { consumeCodexProjectAsk } from "../../policy/codex-project-ask";
29
+ import { evaluateEffectiveAction, legacyGrantTapFlowAllowed } from "../../policy/effective-action";
30
+ import { permitsProjectAskAutomation } from "../../task-automation";
29
31
  import { protectedGrantTapAccess } from "../../host/self-protection";
30
32
 
31
33
  async function readStdin(): Promise<string> {
@@ -85,8 +87,14 @@ async function main(): Promise<void> {
85
87
  toolInput: input.tool_input,
86
88
  });
87
89
 
90
+ const policy = await evaluateEffectiveAction({ provider: "codex", sessionId: input.session_id,
91
+ cwd: input.cwd, toolName: input.tool_name, toolInput: input.tool_input });
92
+ if (policy.effect === "deny") { process.stdout.write(denyOutput(policy.reason)); return; }
93
+ const governedAsk = !!projectAsk || !legacyGrantTapFlowAllowed(policy);
94
+ const chatAutomation = permitsProjectAskAutomation(policy, input.session_id);
95
+
88
96
  // Gating paused or this session exempt → stay silent (Codex uses its own flow).
89
- if (!projectAsk && isGatingSkipped(input.session_id)) return;
97
+ if (!governedAsk && isGatingSkipped(input.session_id)) return;
90
98
 
91
99
  let cfg;
92
100
  try {
@@ -97,8 +105,8 @@ async function main(): Promise<void> {
97
105
  }
98
106
 
99
107
  // Local policy, evaluated after pairing is confirmed — see claude-hook.
100
- const req = codexToRequest(input);
101
- if (!projectAsk && shouldAutoAcceptTool(input.session_id, req.tool, req.command)) {
108
+ const req = { ...codexToRequest(input), projectId: policy.projectId, policyRevision: policy.policy_revision };
109
+ if ((!governedAsk || chatAutomation) && shouldAutoAcceptTool(input.session_id, req.tool, req.command, policy.projectId)) {
102
110
  const level = autoAcceptLevelFor(input.session_id);
103
111
  const cls = classifyAction(req.tool, req.command);
104
112
  process.stdout.write(
@@ -126,7 +134,7 @@ async function main(): Promise<void> {
126
134
  // Routine relay failure stays in Codex's native flow. A Project ASK cannot
127
135
  // fall through to provider approval because that would weaken the parent rule.
128
136
  if (isUnanswered(decision)) {
129
- if (projectAsk) {
137
+ if (governedAsk) {
130
138
  process.stdout.write(denyOutput("Project approval was required but no decision was received"));
131
139
  }
132
140
  return;
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  /** Cursor afterShellExecution: retire the exact chat's orphaned phone card. */
3
3
  import { RelayClient } from "../../../../../packages/core/relay-client";
4
+ import { managedAdmissionOptions } from "../../managed-admission";
4
5
  import {
5
6
  markApprovalTerminal,
6
7
  pendingApprovalRegistrations,
@@ -78,7 +79,8 @@ async function main(): Promise<void> {
78
79
  if (cancelled.length > 0) {
79
80
  try {
80
81
  const config = loadConfig(machineConfigPath());
81
- const client = new RelayClient(applyNetworkRoute(config), { peerAllowed: controllerPeerGate(config) });
82
+ const routed = applyNetworkRoute(config);
83
+ const client = new RelayClient(routed, { ...managedAdmissionOptions(routed), peerAllowed: controllerPeerGate(config) });
82
84
  await client.connect();
83
85
  try {
84
86
  for (const { request, accepted } of cancelled) {
@@ -9,6 +9,7 @@ import { startSessionMonitor } from "../../monitor";
9
9
  import { controllerPeerGate } from "../../pairing/controllers";
10
10
  import { applyNetworkRoute } from "../../device-network/settings";
11
11
  import { startEndpointPublisher } from "../../device-network/directory";
12
+ import { managedAdmissionOptions } from "../../managed-admission";
12
13
 
13
14
  let client: RelayClient;
14
15
  let monitor: ReturnType<typeof startSessionMonitor>;
@@ -19,6 +20,7 @@ let stopEndpointPublisher = () => {};
19
20
  try {
20
21
  const cfg = applyNetworkRoute(loadConfig(machineConfigPath()));
21
22
  client = new RelayClient(cfg, {
23
+ ...managedAdmissionOptions(cfg),
22
24
  autoReconnect: true,
23
25
  replayPath: join(configDir(), `replay-${cfg.room}.json`),
24
26
  peerAllowed: controllerPeerGate(cfg),
@@ -0,0 +1,13 @@
1
+ # Claude questions
2
+
3
+ Public entry point: `index.ts`. Handles Claude Code `AskUserQuestion` through the
4
+ existing encrypted, durable approval channel. All original questions, options,
5
+ descriptions, multiple selections and custom answers stay attached to the exact
6
+ native chat and call. A permission Allow without complete answers is rejected.
7
+ Auto allow never invents an answer. Transport timeout leaves Claude's local
8
+ question UI available. Successful answers return the original `questions` and
9
+ an `answers` mapping using Claude's documented PreToolUse `updatedInput` contract.
10
+
11
+ Tests: `tests/`. Engineering contract: the Apple repository `AGENTS.md`.
12
+
13
+ License: MIT License; see the repository LICENSE.
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ApprovalDecision, ApprovalRequest } from "../../../../packages/protocol/schema";
3
+ import { ApprovalQuestions, questionAnswers } from "../../../../packages/protocol/messages/approval-questions";
4
+ import { claudeToRequest, type HookInput } from "../host/adapters";
5
+
6
+ export function questionRequest(input: HookInput): ApprovalRequest | undefined {
7
+ if (input.tool_name !== "AskUserQuestion") return undefined;
8
+ const questions = ApprovalQuestions.safeParse(input.tool_input?.questions);
9
+ if (!questions.success) return undefined;
10
+ const base = claudeToRequest(input);
11
+ if (input.tool_use_id) base.requestId = "claude-question-" + createHash("sha256")
12
+ .update(JSON.stringify([input.session_id, input.tool_use_id, input.tool_input])).digest("hex").slice(0, 24);
13
+ return { ...base, kind: "question", questions: questions.data,
14
+ title: questions.data[0]!.question, risk: "low", command: undefined };
15
+ }
16
+
17
+ /** Preserve the original tool input; supply only the human's explicit answers. */
18
+ export function claudeQuestionOutput(input: HookInput, decision: ApprovalDecision,
19
+ request = questionRequest(input)) {
20
+ if (!request || decision.decision !== "allow" || decision.requestId !== request.requestId
21
+ || decision.sessionId !== request.sessionId) return undefined;
22
+ const answers = questionAnswers(request.questions, decision.note);
23
+ if (!answers) return undefined;
24
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow",
25
+ updatedInput: { ...input.tool_input, questions: input.tool_input!.questions, answers } } };
26
+ }
@@ -6,6 +6,8 @@ import { configDir } from "./runtime/paths";
6
6
  import { loadRuntimeConfig, saveRuntimeConfig, type RuntimeConfig } from "./runtime";
7
7
  import { writePrivateFile } from "./access/write-private";
8
8
  import { acceptInstanceEpoch } from "../host/instance-epoch";
9
+ import { applyControllerGrant, type GrantRejection } from "../task-automation/controller-grant";
10
+ import { revokeTaskAutomation } from "../task-automation";
9
11
 
10
12
  const CLOCK_SKEW_MS = 2 * 60_000;
11
13
  const MAX_COMMANDS = 256;
@@ -13,7 +15,7 @@ const MAX_COMMANDS = 256;
13
15
  export type ConfigCommandResult =
14
16
  | { kind: "applied"; revision: number }
15
17
  | { kind: "replay"; revision: number }
16
- | { kind: "rejected"; reason: "expired" | "conflict" | "digest_mismatch" | "legacy_blocked" | "stale_instance" };
18
+ | { kind: "rejected"; reason: "expired" | "conflict" | "digest_mismatch" | "legacy_blocked" | "stale_instance" | GrantRejection };
17
19
 
18
20
  type CommandRecord = {
19
21
  operationId: string;
@@ -146,6 +148,17 @@ export function applyConfigSet(
146
148
  if (!acceptInstanceEpoch(message.instanceEpoch, state.requireFreshCommands)) {
147
149
  return { kind: "rejected", reason: "stale_instance" };
148
150
  }
151
+ const session = message.autoAcceptSession;
152
+ if (session?.authorizeProjectAsk) {
153
+ if (!message.operationId || !message.expiresAt || !message.instanceEpoch) {
154
+ return { kind: "rejected", reason: "legacy_blocked" };
155
+ }
156
+ const reason = applyControllerGrant(session, now);
157
+ if (reason) return { kind: "rejected", reason };
158
+ } else if (session) {
159
+ revokeTaskAutomation(session.sessionId);
160
+ }
161
+ if (message.excludeSession) revokeTaskAutomation(message.excludeSession);
149
162
  applyRuntimePatch(message);
150
163
  state.revision += 1;
151
164
  if (message.operationId) {
@@ -1,6 +1,7 @@
1
1
  /** Public entry point for local bridge configuration and pairing. */
2
2
  export * from "./access/capability-policy";
3
3
  export * from "./access/pairing";
4
+ export { writePrivateFile } from "./access/write-private";
4
5
  export * from "./runtime/paths";
5
6
  export * from "./runtime";
6
7
  export {
@@ -19,6 +19,8 @@ import {
19
19
  type CortexProjectConfig,
20
20
  } from "../../cortex/config";
21
21
  import { configDir, runtimeConfigPath } from "./paths";
22
+ import { taskAutomationLevel, taskAutomationProject } from "../../task-automation";
23
+ import { governedRevision } from "../../policy/governed-projects";
22
24
 
23
25
  export type RuntimeConfig = {
24
26
  enabled: boolean;
@@ -213,7 +215,7 @@ export function saveRuntimeConfig(cfg: Partial<RuntimeConfig>): void {
213
215
  writePrivateFile(runtimeConfigPath(), JSON.stringify(merged, null, 2) + "\n");
214
216
  }
215
217
 
216
- function projectIdForSession(sessionId: string | null | undefined): string | null | undefined {
218
+ export function projectIdForSession(sessionId: string | null | undefined): string | null | undefined {
217
219
  if (!sessionId) return undefined;
218
220
  try {
219
221
  const loaded = readStoreState(join(configDir(), "project-mesh.json"));
@@ -235,10 +237,16 @@ function projectIdForSession(sessionId: string | null | undefined): string | nul
235
237
  }
236
238
  }
237
239
 
238
- export function autoAcceptLevelFor(sessionId: string | null | undefined): AutoAcceptLevel {
240
+ export function autoAcceptLevelFor(sessionId: string | null | undefined,
241
+ observedProjectId?: string): AutoAcceptLevel {
239
242
  const cfg = loadRuntimeConfig();
240
- const projectId = projectIdForSession(sessionId);
241
- if (projectId === null) return "ask";
243
+ const mappedProject = projectIdForSession(sessionId);
244
+ if (mappedProject === null || (observedProjectId && mappedProject && observedProjectId !== mappedProject)) return "ask";
245
+ const projectId = observedProjectId ?? mappedProject ?? taskAutomationProject(sessionId);
246
+ if (!cfg.autoAcceptPaused && projectId) {
247
+ const grant = taskAutomationLevel(sessionId ?? undefined, projectId, governedRevision(projectId) ?? 0);
248
+ if (grant) return grant;
249
+ }
242
250
  return resolveAutoAcceptLevel({
243
251
  paused: cfg.autoAcceptPaused,
244
252
  // A Mesh execution without an explicit Project level must fail closed.
@@ -255,8 +263,9 @@ export function shouldAutoAcceptTool(
255
263
  sessionId: string | null | undefined,
256
264
  tool: string,
257
265
  command: string | null | undefined,
266
+ observedProjectId?: string,
258
267
  ): boolean {
259
- const level = autoAcceptLevelFor(sessionId);
268
+ const level = autoAcceptLevelFor(sessionId, observedProjectId);
260
269
  return shouldAutoAllow(level, classifyAction(tool, command ?? undefined));
261
270
  }
262
271
 
@@ -0,0 +1,10 @@
1
+ # Configuration delivery
2
+
3
+ Public entry point: `index.ts`. Applies the existing authenticated controller
4
+ configuration command and returns its operation receipt with the effective
5
+ computer-enforced chat level. Socket delivery alone is never an applied rule.
6
+
7
+ The configuration and task-automation modules own durable admission and tests.
8
+ Engineering contract: the Apple repository `AGENTS.md`.
9
+
10
+ License: MIT License; see the repository LICENSE.
@@ -0,0 +1,17 @@
1
+ import type { ConfigSet } from "../../../../packages/protocol/schema";
2
+ import type { RelayClient } from "../../../../packages/core/relay-client";
3
+ import { autoAcceptLevelFor } from "../config";
4
+ import { handleConfigSet } from "../monitor/handlers/session-commands";
5
+
6
+ /** A receipt reports effective computer policy, independently of a slow catalog scan. */
7
+ export async function handleConfigurationCommand(client: RelayClient, message: ConfigSet): Promise<void> {
8
+ const result = handleConfigSet(message);
9
+ if (!message.operationId) return;
10
+ await client.send({ type: "config.command.result", operationId: message.operationId,
11
+ ok: result.kind !== "rejected",
12
+ ...(result.kind === "rejected" ? { reason: result.reason } : { revision: result.revision }),
13
+ sessionId: message.autoAcceptSession?.sessionId,
14
+ level: message.autoAcceptSession?.sessionId
15
+ ? autoAcceptLevelFor(message.autoAcceptSession.sessionId) : undefined,
16
+ createdAt: Date.now() }, "phone", { ttlMs: 60_000 });
17
+ }
@@ -89,6 +89,13 @@ export function recordsFromEvent(event: MeshEvent): KnowledgeRecordInput[] {
89
89
  return [{ ...base, record_id: event.eventId, category: "decision",
90
90
  content: event.payload.answer, source: "agent_report", visibility: "task" }];
91
91
  }
92
+ if (event.payload.failed && (event.eventType === "TASK_PROGRESS"
93
+ || event.eventType === "TASK_COMPLETED")) {
94
+ const content = event.payload.reason || event.payload.summary;
95
+ return content ? [{ ...base, record_id: event.eventId, category: "attempt",
96
+ content, source: "agent_report",
97
+ visibility: event.eventType === "TASK_COMPLETED" ? "project" : "task" }] : [];
98
+ }
92
99
  if (event.eventType === "TASK_COMPLETED" && event.payload.summary) {
93
100
  return [{ ...base, record_id: event.eventId, category: "result",
94
101
  content: event.payload.summary, source: "agent_report", visibility: "project" }];
@@ -15,7 +15,9 @@
15
15
  * (Codex PreToolUse can only deny and only wraps `shell`.)
16
16
  */
17
17
  import type { ApprovalDecision, ApprovalRequest, Risk } from "../../../../packages/protocol/schema";
18
+ import { createHash } from "node:crypto";
18
19
  import { randomId } from "../../../../packages/core/crypto";
20
+ import { approvalPresentation } from "./approval-presentation";
19
21
 
20
22
  /** Heuristic risk tag so the watch can color/rank the request at a glance. */
21
23
  export function guessRisk(tool: string, command: string | undefined): Risk {
@@ -64,13 +66,16 @@ function extractCommand(tool: string, ti: Record<string, unknown>): string {
64
66
  function toRequest(agent: string, input: HookInput, idHint?: string): ApprovalRequest {
65
67
  const tool = input.tool_name ?? "tool";
66
68
  const command = extractCommand(tool, input.tool_input ?? {});
69
+ const presentation = approvalPresentation(tool, input.tool_input ?? {});
67
70
  return {
68
71
  type: "approval.request",
69
72
  requestId: idHint ?? randomId(6),
70
73
  agent,
71
74
  kind: "permission",
72
75
  tool,
73
- title: shortTitle(tool, command),
76
+ title: command === tool ? presentation.title ?? shortTitle(tool, command) : shortTitle(tool, command),
77
+ details: presentation.details,
78
+ inputFingerprint: createHash("sha256").update(JSON.stringify(input.tool_input ?? {})).digest("hex"),
74
79
  command,
75
80
  cwd: input.cwd,
76
81
  sessionId: input.session_id,
@@ -0,0 +1,10 @@
1
+ # Approval presentation
2
+
3
+ Public entry: `index.ts`. Hook adapters send a bounded, credential-redacted
4
+ parameter preview with an approval. Browser batches name their operations so
5
+ the person can inspect the request before deciding. Presentation does not
6
+ alter provider input, classify an unknown operation as safe, or authorize it.
7
+
8
+ Behavior tests live in `tests/`.
9
+
10
+ License: MIT License; see the repository LICENSE.
@@ -0,0 +1,51 @@
1
+ const SECRET = /authorization|cookie|password|secret|(?:^|_)token(?:$|_)|api[_-]?key|private[_-]?key/i;
2
+
3
+ function safeText(value: string): string {
4
+ let text = value.slice(0, 4_096).replace(/\bBearer\s+[^\s"']+/gi, "Bearer [redacted]");
5
+ if (/^https?:\/\//i.test(text)) {
6
+ try {
7
+ const url = new URL(text);
8
+ url.username = "";
9
+ url.password = "";
10
+ url.hash = "";
11
+ for (const key of [...url.searchParams.keys()]) {
12
+ if (SECRET.test(key)) url.searchParams.set(key, "[redacted]");
13
+ }
14
+ text = url.toString();
15
+ } catch { /* Keep a bounded non-URL parameter visible. */ }
16
+ }
17
+ return text;
18
+ }
19
+
20
+ function redact(value: unknown, depth = 0): unknown {
21
+ if (depth > 6) return "[nested input omitted]";
22
+ if (typeof value === "string") return safeText(value);
23
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1));
24
+ if (value && typeof value === "object") {
25
+ return Object.fromEntries(Object.entries(value).slice(0, 64).map(([key, item]) =>
26
+ [key, SECRET.test(key) ? "[redacted]" : redact(item, depth + 1)]));
27
+ }
28
+ return value;
29
+ }
30
+
31
+ /** Human inspection data only: it never changes provider input or risk policy. */
32
+ export function approvalPresentation(tool: string, input: Record<string, unknown>): {
33
+ details?: string; title?: string;
34
+ } {
35
+ if (Object.keys(input).length === 0) return {};
36
+ const clean = redact(input) as Record<string, unknown>;
37
+ const encoded = JSON.stringify(clean, null, 2);
38
+ const details = encoded.length <= 16_384 ? encoded
39
+ : encoded.slice(0, 16_340) + "\n[remaining parameters omitted]";
40
+ const operations = clean.actions ?? clean.operations ?? clean.steps;
41
+ const actions = Array.isArray(operations) ? operations.slice(0, 4).flatMap(item => {
42
+ if (!item || typeof item !== "object") return [];
43
+ const action = item.action ?? item.method ?? item.type ?? item.operation;
44
+ return typeof action === "string" ? [action.slice(0, 48)] : [];
45
+ }) : [];
46
+ const description = clean.description ?? clean.purpose;
47
+ const caption = actions.length ? actions.join(" → ")
48
+ : typeof description === "string" ? description : undefined;
49
+ const name = tool.split("__").at(-1) ?? tool;
50
+ return { details, title: caption ? `${name}: ${caption}`.slice(0, 120) : undefined };
51
+ }
@@ -114,6 +114,7 @@ export const CLAUDE_MATCHERS = [
114
114
  "Write",
115
115
  "MultiEdit",
116
116
  "NotebookEdit",
117
+ "AskUserQuestion",
117
118
  "Skill",
118
119
  "mcp__.*",
119
120
  "skill__.*",
@@ -0,0 +1,14 @@
1
+ # Managed relay admission
2
+
3
+ `managedAdmissionOptions` obtains a fresh short-lived relay lease using the
4
+ saved account machine credential at each socket upgrade. The account service
5
+ chooses the stable computer identity and enforces Personal seats; QR rooms and
6
+ native agent sessions do not take additional seats. Own-relay connections never
7
+ call billing. Tickets are transport headers and are not prompt content.
8
+
9
+ Legacy free beta servers may not issue leases. Commercial relay mode requires
10
+ a valid signed lease independently, including when a modified client omits it.
11
+ Expiry/refund stops hosted delivery; local provider approval remains available,
12
+ and history, pairing keys and pending decisions are retained.
13
+
14
+ Copyright (c) 2026 Sergii Ziborov. Licensed under the MIT License in the repository root.
@@ -0,0 +1,30 @@
1
+ import type { PeerConfig, RelayClientOptions } from "../../../../packages/core/relay-client";
2
+ import { loadAccountLink } from "../account-recovery/link";
3
+
4
+ /** Account computer identity is stable across QR rooms and provider sessions. */
5
+ export function managedAdmissionOptions(config: PeerConfig,
6
+ request: typeof fetch = fetch): Pick<RelayClientOptions, "upgradeHeaders"> {
7
+ const url = new URL(config.relayUrl);
8
+ if (config.role !== "machine" || url.protocol !== "wss:" || url.hostname !== "relay.granttap.com") return {};
9
+ return { upgradeHeaders: async (): Promise<Record<string, string>> => {
10
+ const account = loadAccountLink();
11
+ if (!account) return {};
12
+ const response = await request("https://granttap.com/api/account/machine/admission", {
13
+ method: "POST", headers: { authorization: `Bearer ${account.machineToken}`,
14
+ "content-type": "application/json", accept: "application/json" },
15
+ body: JSON.stringify({ publicKey: config.myPublicKey, room: config.room }),
16
+ signal: AbortSignal.timeout(8_000), redirect: "error",
17
+ });
18
+ // Legacy free-beta deployments predate admission. A commercial relay still
19
+ // independently refuses every machine socket without a valid ticket.
20
+ if (response.status === 404 || response.status === 503) return {};
21
+ if (!response.ok) throw new Error("Managed relay access or the computer limit must be renewed in GrantTap.");
22
+ const body = await response.json() as { ticket?: unknown; expiresAt?: unknown };
23
+ if (typeof body.ticket !== "string" || body.ticket.length > 2_048
24
+ || typeof body.expiresAt !== "number" || !Number.isSafeInteger(body.expiresAt)
25
+ || body.expiresAt <= Date.now() || body.expiresAt > Date.now() + 360_000) {
26
+ throw new Error("Managed admission returned an invalid lease.");
27
+ }
28
+ return { "X-GrantTap-Admission": body.ticket };
29
+ } };
30
+ }