granttap-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/apps/bridge/src/adapters.ts +124 -0
- package/apps/bridge/src/approval.ts +67 -0
- package/apps/bridge/src/bin/claude-hook.ts +92 -0
- package/apps/bridge/src/bin/codex-hook.ts +72 -0
- package/apps/bridge/src/bin/setup.ts +11 -0
- package/apps/bridge/src/config.ts +169 -0
- package/apps/bridge/src/install.ts +133 -0
- package/apps/mcp/src/server.ts +147 -0
- package/bin/granttap-mcp.mjs +46 -0
- package/package.json +62 -0
- package/packages/core/crypto.ts +113 -0
- package/packages/core/relay-client.ts +199 -0
- package/packages/protocol/schema.ts +199 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Serhii Ziborov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# GrantTap MCP
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/granttap-mcp)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
The public, auditable machine-side bridge for [GrantTap](https://granttap.com):
|
|
7
|
+
ask a person, send a status update, or request an approval on their iPhone or
|
|
8
|
+
Apple Watch without sending agent traffic through a model proxy.
|
|
9
|
+
|
|
10
|
+
GrantTap MCP complements the agents' native permission hooks:
|
|
11
|
+
|
|
12
|
+
- MCP is the voluntary channel: the agent calls `ask`, `ask_yes_no`, or `notify`.
|
|
13
|
+
- Hooks are the mandatory approval channel: Claude Code or Codex pauses before
|
|
14
|
+
a tool call and waits for Allow or Deny.
|
|
15
|
+
- Both channels use the same end-to-end encrypted GrantTap pairing.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
The shortest MCP-only setup is:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
codex mcp add granttap -- npx -y granttap-mcp
|
|
23
|
+
claude mcp add granttap -- npx -y granttap-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For approval hooks, install the command globally so the hook path remains
|
|
27
|
+
stable, then register both supported agents:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g granttap-mcp
|
|
31
|
+
granttap-mcp setup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The GrantTap app and desktop bridge must already be paired. The pairing lives
|
|
35
|
+
locally in `~/.granttap/machine.json`; this package reads it but never uploads
|
|
36
|
+
the secret key. Existing beta installations using `~/.nodvox/` are migrated
|
|
37
|
+
automatically.
|
|
38
|
+
|
|
39
|
+
## MCP tools
|
|
40
|
+
|
|
41
|
+
| Tool | Result |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `ask` | Sends an open question and waits for a spoken or typed reply |
|
|
44
|
+
| `ask_yes_no` | Sends a yes/no question and waits for a tap |
|
|
45
|
+
| `notify` | Sends a non-blocking status message |
|
|
46
|
+
| `setup` | Registers the Claude Code and Codex approval hooks |
|
|
47
|
+
|
|
48
|
+
The default answer timeout is three minutes. Override it with
|
|
49
|
+
`GRANTTAP_ASK_TIMEOUT_MS`.
|
|
50
|
+
|
|
51
|
+
## Security model
|
|
52
|
+
|
|
53
|
+
Message payloads are authenticated and encrypted locally with NaCl
|
|
54
|
+
public-key boxes. The relay receives only routing metadata and opaque
|
|
55
|
+
ciphertext. It has no device secret key and cannot decrypt questions,
|
|
56
|
+
commands, replies, or approvals.
|
|
57
|
+
|
|
58
|
+
This repository intentionally includes the protocol, crypto client, relay
|
|
59
|
+
client, MCP server, and agent hook adapters so that the complete public
|
|
60
|
+
machine-side trust boundary can be reviewed.
|
|
61
|
+
|
|
62
|
+
The relay still observes metadata such as room identifiers, IP addresses,
|
|
63
|
+
timing, and message sizes. Review or self-host the public
|
|
64
|
+
[GrantTap relay](https://github.com/sergii-ziborov/granttap-relay) if that
|
|
65
|
+
metadata matters to your deployment.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
Requires Node.js 20 or newer.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/sergii-ziborov/granttap-mcp.git
|
|
73
|
+
cd granttap-mcp
|
|
74
|
+
npm install
|
|
75
|
+
npm test
|
|
76
|
+
npm run typecheck
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Start the stdio server with `npm start`. Run `npm run setup` only on a machine
|
|
80
|
+
where you want the hooks installed; existing config files are backed up once
|
|
81
|
+
as `*.bak-granttap`.
|
|
82
|
+
|
|
83
|
+
## Related
|
|
84
|
+
|
|
85
|
+
- Product: [granttap.com](https://granttap.com)
|
|
86
|
+
- Relay: [sergii-ziborov/granttap-relay](https://github.com/sergii-ziborov/granttap-relay)
|
|
87
|
+
|
|
88
|
+
GrantTap is not affiliated with Anthropic or OpenAI.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent adapters — translate each agent's native permission format into our
|
|
3
|
+
* neutral ApprovalRequest, and translate our ApprovalDecision back into the
|
|
4
|
+
* shape that agent expects. The phone UI never learns which agent it approves.
|
|
5
|
+
*
|
|
6
|
+
* Both are wired end to end. The stdin payloads are nearly identical across
|
|
7
|
+
* Claude Code and Codex (tool_name / tool_input / cwd / session_id), so one
|
|
8
|
+
* mapper feeds both; only the ids and the stdout contract differ.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code — PreToolUse hook. stdout: hookSpecificOutput.permissionDecision
|
|
11
|
+
* = "allow" | "deny" | "ask". (docs: code.claude.com/docs/en/hooks)
|
|
12
|
+
* Codex — requires `[features] hooks = true`, then the PermissionRequest
|
|
13
|
+
* hook, the only one that can return BOTH allow and deny. stdout:
|
|
14
|
+
* hookSpecificOutput.decision = { behavior: "allow" | "deny", message? }.
|
|
15
|
+
* (Codex PreToolUse can only deny and only wraps `shell`.)
|
|
16
|
+
*/
|
|
17
|
+
import type { ApprovalDecision, ApprovalRequest, Risk } from "../../../packages/protocol/schema";
|
|
18
|
+
import { randomId } from "../../../packages/core/crypto";
|
|
19
|
+
|
|
20
|
+
/** Heuristic risk tag so the watch can color/rank the request at a glance. */
|
|
21
|
+
export function guessRisk(tool: string, command: string | undefined): Risk {
|
|
22
|
+
const c = (command ?? "").toLowerCase();
|
|
23
|
+
const HIGH = [
|
|
24
|
+
/\brm\s+-rf?\b/,
|
|
25
|
+
/\bsudo\b/,
|
|
26
|
+
/\bgit\s+push\b.*--force/,
|
|
27
|
+
/\bgit\s+reset\s+--hard\b/,
|
|
28
|
+
/\bcurl\b[^|]*\|\s*(sh|bash)\b/,
|
|
29
|
+
/\bnpm\s+publish\b/,
|
|
30
|
+
/\bdd\s+if=/,
|
|
31
|
+
/\b(drop|truncate)\s+table\b/,
|
|
32
|
+
/:\s*>\s*\//, // truncate a file
|
|
33
|
+
/\bchmod\s+-r\b/,
|
|
34
|
+
];
|
|
35
|
+
if (HIGH.some((re) => re.test(c))) return "high";
|
|
36
|
+
const READONLY = ["Read", "Glob", "Grep", "NotebookRead", "WebFetch", "WebSearch"];
|
|
37
|
+
if (READONLY.includes(tool)) return "low";
|
|
38
|
+
return "medium";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fields shared by the Claude and Codex hook stdin payloads. */
|
|
42
|
+
export type HookInput = {
|
|
43
|
+
session_id?: string;
|
|
44
|
+
cwd?: string;
|
|
45
|
+
tool_name?: string;
|
|
46
|
+
tool_input?: Record<string, unknown>;
|
|
47
|
+
tool_use_id?: string;
|
|
48
|
+
hook_event_name?: string;
|
|
49
|
+
permission_mode?: string;
|
|
50
|
+
transcript_path?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function extractCommand(tool: string, ti: Record<string, unknown>): string {
|
|
54
|
+
const cmd = ti["command"];
|
|
55
|
+
if (typeof cmd === "string") return cmd;
|
|
56
|
+
if (Array.isArray(cmd)) return cmd.map(String).join(" "); // Codex shell: string[]
|
|
57
|
+
const path = ti["file_path"] ?? ti["path"] ?? ti["notebook_path"];
|
|
58
|
+
if (typeof path === "string") return `${tool} ${path}`;
|
|
59
|
+
const url = ti["url"];
|
|
60
|
+
if (typeof url === "string") return `${tool} ${url}`;
|
|
61
|
+
return tool;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toRequest(agent: string, input: HookInput, idHint?: string): ApprovalRequest {
|
|
65
|
+
const tool = input.tool_name ?? "tool";
|
|
66
|
+
const command = extractCommand(tool, input.tool_input ?? {});
|
|
67
|
+
return {
|
|
68
|
+
type: "approval.request",
|
|
69
|
+
requestId: idHint ?? randomId(6),
|
|
70
|
+
agent,
|
|
71
|
+
kind: "permission",
|
|
72
|
+
tool,
|
|
73
|
+
title: shortTitle(tool, command),
|
|
74
|
+
command,
|
|
75
|
+
cwd: input.cwd,
|
|
76
|
+
sessionId: input.session_id,
|
|
77
|
+
risk: guessRisk(tool, command),
|
|
78
|
+
createdAt: Date.now(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- Claude Code
|
|
83
|
+
|
|
84
|
+
export const claudeToRequest = (input: HookInput): ApprovalRequest => toRequest("claude", input);
|
|
85
|
+
|
|
86
|
+
export function decisionToClaudeOutput(d: ApprovalDecision): unknown {
|
|
87
|
+
return {
|
|
88
|
+
hookSpecificOutput: {
|
|
89
|
+
hookEventName: "PreToolUse",
|
|
90
|
+
permissionDecision: d.decision === "allow" ? "allow" : "deny",
|
|
91
|
+
permissionDecisionReason:
|
|
92
|
+
d.note ??
|
|
93
|
+
(d.decision === "allow" ? "Approved from GrantTap" : "Denied from GrantTap"),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --------------------------------------------------------------------- Codex
|
|
99
|
+
|
|
100
|
+
export const codexToRequest = (input: HookInput): ApprovalRequest =>
|
|
101
|
+
toRequest("codex", input, input.tool_use_id);
|
|
102
|
+
|
|
103
|
+
export function decisionToCodexOutput(d: ApprovalDecision): unknown {
|
|
104
|
+
const decision =
|
|
105
|
+
d.decision === "allow"
|
|
106
|
+
? { behavior: "allow" }
|
|
107
|
+
: { behavior: "deny", message: d.note ?? "Denied from GrantTap" };
|
|
108
|
+
return {
|
|
109
|
+
hookSpecificOutput: {
|
|
110
|
+
hookEventName: "PermissionRequest",
|
|
111
|
+
decision,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ------------------------------------------------------------------ helpers
|
|
117
|
+
|
|
118
|
+
function shortTitle(tool: string, command: string): string {
|
|
119
|
+
if (command) {
|
|
120
|
+
const oneLine = command.replace(/\s+/g, " ").trim();
|
|
121
|
+
return oneLine.length > 80 ? oneLine.slice(0, 77) + "…" : oneLine;
|
|
122
|
+
}
|
|
123
|
+
return tool;
|
|
124
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core approval round-trip, agent-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Connect to the relay, send one ApprovalRequest, block until the paired phone
|
|
5
|
+
* replies with a matching ApprovalDecision (or the timeout fires — fail closed,
|
|
6
|
+
* i.e. deny). This is what makes a phone tap gate a real tool call.
|
|
7
|
+
*/
|
|
8
|
+
import type { PeerConfig } from "../../../packages/core/relay-client";
|
|
9
|
+
import { RelayClient } from "../../../packages/core/relay-client";
|
|
10
|
+
import type { ApprovalDecision, ApprovalRequest, Payload } from "../../../packages/protocol/schema";
|
|
11
|
+
|
|
12
|
+
export type RequestApprovalOpts = {
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Inject a client in tests instead of opening a socket. */
|
|
15
|
+
client?: RelayClient;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function requestApproval(
|
|
19
|
+
cfg: PeerConfig,
|
|
20
|
+
req: ApprovalRequest,
|
|
21
|
+
opts: RequestApprovalOpts = {},
|
|
22
|
+
): Promise<ApprovalDecision> {
|
|
23
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
24
|
+
const client = opts.client ?? new RelayClient(cfg);
|
|
25
|
+
const ownsClient = !opts.client;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (ownsClient) {
|
|
29
|
+
try {
|
|
30
|
+
await client.connect();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// Relay unreachable is a different animal from "phone stayed silent":
|
|
33
|
+
// hooks translate it into "ask locally" so normal desk work keeps flowing.
|
|
34
|
+
return {
|
|
35
|
+
...failClosed(req, `Relay недоступен: ${(err as Error).message}`),
|
|
36
|
+
decidedBy: "unreachable",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await client.send(req, "phone", { ttlMs: timeoutMs });
|
|
41
|
+
const decision = await client
|
|
42
|
+
.waitFor(
|
|
43
|
+
(p: Payload): p is ApprovalDecision =>
|
|
44
|
+
p.type === "approval.decision" && p.requestId === req.requestId,
|
|
45
|
+
timeoutMs,
|
|
46
|
+
)
|
|
47
|
+
.catch(() => null);
|
|
48
|
+
|
|
49
|
+
if (decision) return decision;
|
|
50
|
+
return failClosed(req, "No response from phone before timeout");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return failClosed(req, `Approval channel error: ${(err as Error).message}`);
|
|
53
|
+
} finally {
|
|
54
|
+
if (ownsClient) client.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function failClosed(req: ApprovalRequest, note: string): ApprovalDecision {
|
|
59
|
+
return {
|
|
60
|
+
type: "approval.decision",
|
|
61
|
+
requestId: req.requestId,
|
|
62
|
+
decision: "deny",
|
|
63
|
+
note,
|
|
64
|
+
decidedBy: "system",
|
|
65
|
+
decidedAt: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse hook entry point.
|
|
4
|
+
*
|
|
5
|
+
* Registered in ~/.claude/settings.json so that before Claude runs a tool, this
|
|
6
|
+
* program receives the tool call on stdin, forwards it to your phone for
|
|
7
|
+
* approval, and prints the allow/deny decision Claude expects on stdout.
|
|
8
|
+
*
|
|
9
|
+
* Reads: PreToolUse JSON on stdin
|
|
10
|
+
* Writes: { hookSpecificOutput: { permissionDecision, ... } } on stdout
|
|
11
|
+
*
|
|
12
|
+
* Fails closed: any error or timeout denies the tool call rather than letting it
|
|
13
|
+
* through unattended.
|
|
14
|
+
*/
|
|
15
|
+
import { claudeToRequest, decisionToClaudeOutput, type HookInput } from "../adapters";
|
|
16
|
+
import { requestApproval } from "../approval";
|
|
17
|
+
import { isGatingSkipped, loadConfig, machineConfigPath } from "../config";
|
|
18
|
+
|
|
19
|
+
async function readStdin(): Promise<string> {
|
|
20
|
+
const chunks: Buffer[] = [];
|
|
21
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
22
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main(): Promise<void> {
|
|
26
|
+
const raw = await readStdin();
|
|
27
|
+
let input: HookInput = {};
|
|
28
|
+
try {
|
|
29
|
+
input = JSON.parse(raw) as HookInput;
|
|
30
|
+
} catch {
|
|
31
|
+
// Unparseable input -> deny, but don't crash the agent.
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Gating paused, or this session is exempt → abstain (empty output = Claude
|
|
35
|
+
// uses its normal permission flow, exactly as if GrantTap weren't installed).
|
|
36
|
+
if (isGatingSkipped(input.session_id)) return;
|
|
37
|
+
|
|
38
|
+
let cfg;
|
|
39
|
+
try {
|
|
40
|
+
cfg = loadConfig(machineConfigPath());
|
|
41
|
+
} catch {
|
|
42
|
+
// No pairing yet: don't block the user's normal workflow — defer to Claude's
|
|
43
|
+
// own prompt by emitting "ask".
|
|
44
|
+
process.stdout.write(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
hookSpecificOutput: {
|
|
47
|
+
hookEventName: "PreToolUse",
|
|
48
|
+
permissionDecision: "ask",
|
|
49
|
+
permissionDecisionReason: "GrantTap not paired (run `npm run init`)",
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const req = claudeToRequest(input);
|
|
57
|
+
const timeoutMs = Number(
|
|
58
|
+
process.env.GRANTTAP_APPROVAL_TIMEOUT_MS ??
|
|
59
|
+
process.env.NODVOX_APPROVAL_TIMEOUT_MS ??
|
|
60
|
+
60_000,
|
|
61
|
+
);
|
|
62
|
+
const decision = await requestApproval(cfg, req, { timeoutMs });
|
|
63
|
+
|
|
64
|
+
// Relay down ≠ phone said no: hand the question back to the local prompt.
|
|
65
|
+
if (decision.decision === "deny" && decision.decidedBy === "unreachable") {
|
|
66
|
+
process.stdout.write(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
hookSpecificOutput: {
|
|
69
|
+
hookEventName: "PreToolUse",
|
|
70
|
+
permissionDecision: "ask",
|
|
71
|
+
permissionDecisionReason: "GrantTap relay недоступен — решай локально",
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
process.stdout.write(JSON.stringify(decisionToClaudeOutput(decision)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch((err) => {
|
|
82
|
+
process.stdout.write(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
hookSpecificOutput: {
|
|
85
|
+
hookEventName: "PreToolUse",
|
|
86
|
+
permissionDecision: "deny",
|
|
87
|
+
permissionDecisionReason: `GrantTap hook error: ${(err as Error).message}`,
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Codex PermissionRequest hook entry point.
|
|
4
|
+
*
|
|
5
|
+
* Requires `[features] hooks = true` in ~/.codex/config.toml. Registered as a
|
|
6
|
+
* PermissionRequest hook (the only Codex hook that can return BOTH allow and
|
|
7
|
+
* deny). Same job as the Claude hook: forward the tool call to your phone,
|
|
8
|
+
* print the decision Codex expects.
|
|
9
|
+
*
|
|
10
|
+
* Reads: PermissionRequest JSON on stdin
|
|
11
|
+
* Writes: { hookSpecificOutput: { decision: { behavior, message? } } } on stdout
|
|
12
|
+
*
|
|
13
|
+
* Fails closed on error/timeout.
|
|
14
|
+
*/
|
|
15
|
+
import { codexToRequest, decisionToCodexOutput, type HookInput } from "../adapters";
|
|
16
|
+
import { requestApproval } from "../approval";
|
|
17
|
+
import { isGatingSkipped, loadConfig, machineConfigPath } from "../config";
|
|
18
|
+
|
|
19
|
+
async function readStdin(): Promise<string> {
|
|
20
|
+
const chunks: Buffer[] = [];
|
|
21
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
22
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function denyOutput(message: string): string {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
hookSpecificOutput: {
|
|
28
|
+
hookEventName: "PermissionRequest",
|
|
29
|
+
decision: { behavior: "deny", message },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main(): Promise<void> {
|
|
35
|
+
const raw = await readStdin();
|
|
36
|
+
let input: HookInput = {};
|
|
37
|
+
try {
|
|
38
|
+
input = JSON.parse(raw) as HookInput;
|
|
39
|
+
} catch {
|
|
40
|
+
// fall through: unparseable -> deny
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Gating paused or this session exempt → stay silent (Codex uses its own flow).
|
|
44
|
+
if (isGatingSkipped(input.session_id)) return;
|
|
45
|
+
|
|
46
|
+
let cfg;
|
|
47
|
+
try {
|
|
48
|
+
cfg = loadConfig(machineConfigPath());
|
|
49
|
+
} catch {
|
|
50
|
+
process.stdout.write(denyOutput("GrantTap not paired (run `npm run init`)"));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const req = codexToRequest(input);
|
|
55
|
+
const timeoutMs = Number(
|
|
56
|
+
process.env.GRANTTAP_APPROVAL_TIMEOUT_MS ??
|
|
57
|
+
process.env.NODVOX_APPROVAL_TIMEOUT_MS ??
|
|
58
|
+
60_000,
|
|
59
|
+
);
|
|
60
|
+
const decision = await requestApproval(cfg, req, { timeoutMs });
|
|
61
|
+
|
|
62
|
+
// Relay down: stay silent — an empty hook result sends Codex back to its own
|
|
63
|
+
// approval flow, so desk work keeps going.
|
|
64
|
+
if (decision.decision === "deny" && decision.decidedBy === "unreachable") return;
|
|
65
|
+
|
|
66
|
+
process.stdout.write(JSON.stringify(decisionToCodexOutput(decision)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((err) => {
|
|
70
|
+
process.stdout.write(denyOutput(`GrantTap hook error: ${(err as Error).message}`));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { installClaudeHook, installCodexHook } from "../install";
|
|
2
|
+
|
|
3
|
+
const claude = installClaudeHook();
|
|
4
|
+
const codex = installCodexHook();
|
|
5
|
+
|
|
6
|
+
process.stdout.write(
|
|
7
|
+
[
|
|
8
|
+
`Claude Code: ${claude.status} (${claude.detail})`,
|
|
9
|
+
`Codex: ${codex.status} (${codex.detail})`,
|
|
10
|
+
].join("\n") + "\n",
|
|
11
|
+
);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device config + pairing.
|
|
3
|
+
*
|
|
4
|
+
* A "pairing" is two halves of one E2EE relationship:
|
|
5
|
+
* - machine.json stays on the computer (its secret key + the phone's pubkey)
|
|
6
|
+
* - the phone half is handed to the phone (later via QR; for now a file)
|
|
7
|
+
* Neither half ever contains the other side's secret, and the relay sees no key.
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { hostname } from "node:os";
|
|
13
|
+
import { generateKeyPair, randomId } from "../../../packages/core/crypto";
|
|
14
|
+
import type { PeerConfig } from "../../../packages/core/relay-client";
|
|
15
|
+
|
|
16
|
+
export function configDir(): string {
|
|
17
|
+
const overridden = process.env.GRANTTAP_CONFIG_DIR ?? process.env.NODVOX_CONFIG_DIR;
|
|
18
|
+
if (overridden) return overridden;
|
|
19
|
+
|
|
20
|
+
const current = join(homedir(), ".granttap");
|
|
21
|
+
const legacy = join(homedir(), ".nodvox");
|
|
22
|
+
if (!existsSync(current) && existsSync(legacy)) {
|
|
23
|
+
try {
|
|
24
|
+
// Same-volume rename keeps the existing pairing keys and their modes intact.
|
|
25
|
+
renameSync(legacy, current);
|
|
26
|
+
} catch {
|
|
27
|
+
// If migration is impossible, keep using the old directory instead of
|
|
28
|
+
// silently generating a new identity and breaking the active pairing.
|
|
29
|
+
return legacy;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function machineConfigPath(): string {
|
|
36
|
+
return join(configDir(), "machine.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------- runtime config
|
|
40
|
+
//
|
|
41
|
+
// The on/off switch and per-session exclusions the hook reads on every call.
|
|
42
|
+
// Editable from the phone (a config.set payload the monitor persists here), so
|
|
43
|
+
// you can pause gating or exempt a specific chat without touching any files.
|
|
44
|
+
|
|
45
|
+
export type RuntimeConfig = {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
excludedSessions: string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const DEFAULT_RUNTIME: RuntimeConfig = { enabled: true, excludedSessions: [] };
|
|
51
|
+
|
|
52
|
+
export function runtimeConfigPath(): string {
|
|
53
|
+
return join(configDir(), "config.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadRuntimeConfig(): RuntimeConfig {
|
|
57
|
+
try {
|
|
58
|
+
const raw = JSON.parse(readFileSync(runtimeConfigPath(), "utf8"));
|
|
59
|
+
return {
|
|
60
|
+
enabled: raw.enabled !== false,
|
|
61
|
+
excludedSessions: Array.isArray(raw.excludedSessions) ? raw.excludedSessions.map(String) : [],
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return { ...DEFAULT_RUNTIME };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveRuntimeConfig(cfg: RuntimeConfig): void {
|
|
69
|
+
mkdirSync(configDir(), { recursive: true });
|
|
70
|
+
writeFileSync(runtimeConfigPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when the hook should stay out of the way for this session. */
|
|
74
|
+
export function isGatingSkipped(sessionId: string | undefined): boolean {
|
|
75
|
+
const cfg = loadRuntimeConfig();
|
|
76
|
+
if (!cfg.enabled) return true;
|
|
77
|
+
return sessionId != null && cfg.excludedSessions.includes(sessionId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function phonePairingPath(): string {
|
|
81
|
+
return join(configDir(), "phone.pairing.json");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function saveConfig(path: string, cfg: PeerConfig): void {
|
|
85
|
+
mkdirSync(configDir(), { recursive: true });
|
|
86
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function loadConfig(path: string): PeerConfig {
|
|
90
|
+
return JSON.parse(readFileSync(path, "utf8")) as PeerConfig;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------- pairing URI
|
|
94
|
+
//
|
|
95
|
+
// The phone half of a pairing, packed into a single scannable URI. `granttap connect`
|
|
96
|
+
// renders this as a QR code in the terminal; the app scans it instead of having
|
|
97
|
+
// the user paste JSON. Keys are base64url so the URI stays query-safe.
|
|
98
|
+
|
|
99
|
+
const b64url = (s: string): string => s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
100
|
+
const unb64url = (s: string): string => {
|
|
101
|
+
const t = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
102
|
+
return t + "=".repeat((4 - (t.length % 4)) % 4);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function pairingUri(cfg: PeerConfig): string {
|
|
106
|
+
const q = new URLSearchParams({
|
|
107
|
+
v: "1",
|
|
108
|
+
u: cfg.relayUrl,
|
|
109
|
+
r: cfg.room,
|
|
110
|
+
s: b64url(cfg.mySecretKey),
|
|
111
|
+
p: b64url(cfg.peerPublicKey),
|
|
112
|
+
k: b64url(cfg.myPublicKey),
|
|
113
|
+
i: cfg.senderId,
|
|
114
|
+
});
|
|
115
|
+
return `granttap://pair?${q.toString()}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Inverse of `pairingUri` — used by tests and by any non-Apple client. */
|
|
119
|
+
export function parsePairingUri(uri: string): PeerConfig | null {
|
|
120
|
+
let q: URLSearchParams;
|
|
121
|
+
try {
|
|
122
|
+
const u = new URL(uri);
|
|
123
|
+
if (u.protocol !== "granttap:" && u.protocol !== "nodvox:") return null;
|
|
124
|
+
q = u.searchParams;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const get = (k: string) => q.get(k) ?? "";
|
|
129
|
+
if (!get("u") || !get("r") || !get("s") || !get("p")) return null;
|
|
130
|
+
return {
|
|
131
|
+
relayUrl: get("u"),
|
|
132
|
+
room: get("r"),
|
|
133
|
+
role: "phone",
|
|
134
|
+
deviceName: "phone",
|
|
135
|
+
senderId: get("i") || "phone",
|
|
136
|
+
myPublicKey: unb64url(get("k")),
|
|
137
|
+
mySecretKey: unb64url(get("s")),
|
|
138
|
+
peerPublicKey: unb64url(get("p")),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Create a fresh machine<->phone pairing. */
|
|
143
|
+
export function createPairing(relayUrl: string): { machineCfg: PeerConfig; phoneCfg: PeerConfig } {
|
|
144
|
+
const machine = generateKeyPair();
|
|
145
|
+
const phone = generateKeyPair();
|
|
146
|
+
const room = randomId(8);
|
|
147
|
+
|
|
148
|
+
const machineCfg: PeerConfig = {
|
|
149
|
+
relayUrl,
|
|
150
|
+
room,
|
|
151
|
+
role: "machine",
|
|
152
|
+
deviceName: hostname(),
|
|
153
|
+
senderId: randomId(4),
|
|
154
|
+
myPublicKey: machine.publicKey,
|
|
155
|
+
mySecretKey: machine.secretKey,
|
|
156
|
+
peerPublicKey: phone.publicKey,
|
|
157
|
+
};
|
|
158
|
+
const phoneCfg: PeerConfig = {
|
|
159
|
+
relayUrl,
|
|
160
|
+
room,
|
|
161
|
+
role: "phone",
|
|
162
|
+
deviceName: "phone",
|
|
163
|
+
senderId: randomId(4),
|
|
164
|
+
myPublicKey: phone.publicKey,
|
|
165
|
+
mySecretKey: phone.secretKey,
|
|
166
|
+
peerPublicKey: machine.publicKey,
|
|
167
|
+
};
|
|
168
|
+
return { machineCfg, phoneCfg };
|
|
169
|
+
}
|