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.
- package/README.md +20 -0
- package/apps/bridge/src/account-recovery/README.md +6 -4
- package/apps/bridge/src/account-recovery/offer.ts +2 -1
- package/apps/bridge/src/account-recovery/phone-link.ts +3 -10
- package/apps/bridge/src/approvals/api.ts +3 -0
- package/apps/bridge/src/approvals/index.ts +4 -1
- package/apps/bridge/src/approvals/records.ts +5 -2
- package/apps/bridge/src/bin/hooks/claude-hook.ts +28 -5
- package/apps/bridge/src/bin/hooks/codex-hook.ts +12 -4
- package/apps/bridge/src/bin/hooks/cursor-after-shell.ts +3 -1
- package/apps/bridge/src/bin/runtime/monitor.ts +2 -0
- package/apps/bridge/src/claude-questions/README.md +13 -0
- package/apps/bridge/src/claude-questions/index.ts +26 -0
- package/apps/bridge/src/config/commands.ts +14 -1
- package/apps/bridge/src/config/index.ts +1 -0
- package/apps/bridge/src/config/runtime/index.ts +14 -5
- package/apps/bridge/src/configuration-delivery/README.md +10 -0
- package/apps/bridge/src/configuration-delivery/index.ts +17 -0
- package/apps/bridge/src/engine/runtime/engine-memory.ts +7 -0
- package/apps/bridge/src/host/adapters.ts +6 -1
- package/apps/bridge/src/host/approval-presentation/README.md +10 -0
- package/apps/bridge/src/host/approval-presentation/index.ts +51 -0
- package/apps/bridge/src/install/inspect.ts +1 -0
- package/apps/bridge/src/managed-admission/README.md +14 -0
- package/apps/bridge/src/managed-admission/index.ts +30 -0
- package/apps/bridge/src/mesh/context/README.md +19 -0
- package/apps/bridge/src/mesh/context/project.ts +4 -1
- package/apps/bridge/src/mesh/context/selection.ts +31 -0
- package/apps/bridge/src/mesh/delivery/run-digest.ts +2 -1
- package/apps/bridge/src/monitor/handlers/catalog.ts +7 -3
- package/apps/bridge/src/monitor/handlers/inbound.ts +15 -7
- package/apps/bridge/src/monitor/handlers/session-commands.ts +2 -2
- package/apps/bridge/src/monitor/support/runtime.ts +14 -3
- package/apps/bridge/src/session-catalog/README.md +15 -0
- package/apps/bridge/src/session-catalog/index.ts +68 -0
- package/apps/bridge/src/session-images/README.md +14 -0
- package/apps/bridge/src/session-images/index.ts +36 -0
- package/apps/bridge/src/sessions/index.ts +1 -0
- package/apps/bridge/src/sessions/native-source.ts +35 -0
- package/apps/bridge/src/sessions/scan/claude/activity.ts +4 -2
- package/apps/bridge/src/sessions/scan/codex/README.md +16 -0
- package/apps/bridge/src/sessions/scan/codex/activity/rows.ts +3 -1
- package/apps/bridge/src/sessions/scan/codex/image/index.ts +2 -2
- package/apps/bridge/src/sessions/scan/codex/parse.ts +17 -5
- package/apps/bridge/src/sessions/support/activity-helpers.ts +10 -2
- package/apps/bridge/src/sessions/support/codex-head.ts +15 -3
- package/apps/bridge/src/task-automation/README.md +13 -0
- package/apps/bridge/src/task-automation/controller-grant.ts +33 -0
- package/apps/bridge/src/task-automation/index.ts +49 -0
- package/apps/bridge/src/transcript-history/README.md +3 -0
- package/apps/bridge/src/transcript-history/images/native.ts +57 -0
- package/apps/bridge/src/transcript-history/index.ts +6 -4
- package/apps/bridge/src/transcript-history/native-lines.ts +40 -14
- package/apps/bridge/src/transcript-history/turn-changes.ts +2 -2
- package/apps/mcp/src/connection-center/README.md +2 -0
- package/apps/mcp/src/connection-center/state.ts +1 -0
- package/apps/mcp/src/connection-center/view.js +4 -3
- package/apps/mcp/src/connection-center/widget.html +1 -1
- package/apps/mcp/src/desktop/image/index.ts +35 -3
- package/apps/mcp/src/desktop/task-activity.ts +2 -2
- package/apps/mcp/src/mcp-tools/connect/index.ts +3 -1
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/outbound-scheduler/README.md +13 -0
- package/packages/core/outbound-scheduler/index.ts +83 -0
- package/packages/core/relay-client-types.ts +2 -0
- package/packages/core/relay-client.ts +19 -18
- package/packages/protocol/messages/approval-questions/index.ts +23 -0
- package/packages/protocol/messages/approvals.ts +7 -2
- package/packages/protocol/messages/capabilities.ts +4 -0
- package/packages/protocol/messages/session-images/index.ts +24 -0
- package/packages/protocol/messages/sessions/configuration.ts +61 -0
- package/packages/protocol/messages/sessions/index.ts +2 -42
- 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`
|
|
7
|
-
machine-scoped credential
|
|
8
|
-
|
|
9
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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
|
|
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
|
|
240
|
+
export function autoAcceptLevelFor(sessionId: string | null | undefined,
|
|
241
|
+
observedProjectId?: string): AutoAcceptLevel {
|
|
239
242
|
const cfg = loadRuntimeConfig();
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|