forkoff 1.0.17 → 1.0.19
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 +11 -7
- package/README.md +77 -118
- package/dist/approval.d.ts +1 -0
- package/dist/approval.js +9 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +62 -16
- package/dist/crypto/e2eeManager.d.ts +49 -52
- package/dist/crypto/e2eeManager.js +256 -181
- package/dist/crypto/encryption.d.ts +8 -10
- package/dist/crypto/encryption.js +29 -94
- package/dist/crypto/index.d.ts +10 -0
- package/dist/crypto/index.js +22 -0
- package/dist/crypto/keyExchange.d.ts +6 -20
- package/dist/crypto/keyExchange.js +18 -110
- package/dist/crypto/keyGeneration.d.ts +2 -13
- package/dist/crypto/keyGeneration.js +14 -88
- package/dist/crypto/keyStorage.d.ts +32 -5
- package/dist/crypto/keyStorage.js +152 -8
- package/dist/crypto/sessionPersistence.d.ts +7 -13
- package/dist/crypto/sessionPersistence.js +108 -33
- package/dist/crypto/types.d.ts +24 -3
- package/dist/crypto/types.js +2 -1
- package/dist/crypto/websocketE2EE.d.ts +6 -17
- package/dist/crypto/websocketE2EE.js +21 -38
- package/dist/index.js +203 -280
- package/dist/integration.d.ts +0 -1
- package/dist/integration.js +2 -4
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +209 -1
- package/dist/server.d.ts +30 -0
- package/dist/server.js +162 -0
- package/dist/startup.js +15 -6
- package/dist/terminal.d.ts +1 -0
- package/dist/terminal.js +94 -1
- package/dist/tools/claude-process.d.ts +8 -0
- package/dist/tools/claude-process.js +199 -26
- package/dist/tools/claude-sessions.d.ts +1 -0
- package/dist/tools/claude-sessions.js +36 -10
- package/dist/tools/detector.js +11 -3
- package/dist/tools/permission-hook.js +94 -27
- package/dist/tools/permission-ipc.d.ts +1 -0
- package/dist/tools/permission-ipc.js +61 -14
- package/dist/transcript-streamer.d.ts +1 -0
- package/dist/transcript-streamer.js +18 -4
- package/dist/usage-tracker.d.ts +45 -0
- package/dist/usage-tracker.js +243 -0
- package/dist/websocket.d.ts +43 -12
- package/dist/websocket.js +418 -214
- package/package.json +5 -4
- package/dist/__tests__/cli-commands.test.d.ts +0 -6
- package/dist/__tests__/cli-commands.test.d.ts.map +0 -1
- package/dist/__tests__/cli-commands.test.js +0 -213
- package/dist/__tests__/cli-commands.test.js.map +0 -1
- package/dist/__tests__/crypto/e2e-integration.test.d.ts +0 -17
- package/dist/__tests__/crypto/e2e-integration.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/e2e-integration.test.js +0 -338
- package/dist/__tests__/crypto/e2e-integration.test.js.map +0 -1
- package/dist/__tests__/crypto/e2eeManager.test.d.ts +0 -2
- package/dist/__tests__/crypto/e2eeManager.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/e2eeManager.test.js +0 -242
- package/dist/__tests__/crypto/e2eeManager.test.js.map +0 -1
- package/dist/__tests__/crypto/encryption.test.d.ts +0 -2
- package/dist/__tests__/crypto/encryption.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/encryption.test.js +0 -116
- package/dist/__tests__/crypto/encryption.test.js.map +0 -1
- package/dist/__tests__/crypto/keyExchange.test.d.ts +0 -2
- package/dist/__tests__/crypto/keyExchange.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/keyExchange.test.js +0 -84
- package/dist/__tests__/crypto/keyExchange.test.js.map +0 -1
- package/dist/__tests__/crypto/keyGeneration.test.d.ts +0 -2
- package/dist/__tests__/crypto/keyGeneration.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/keyGeneration.test.js +0 -61
- package/dist/__tests__/crypto/keyGeneration.test.js.map +0 -1
- package/dist/__tests__/crypto/keyStorage.test.d.ts +0 -2
- package/dist/__tests__/crypto/keyStorage.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/keyStorage.test.js +0 -133
- package/dist/__tests__/crypto/keyStorage.test.js.map +0 -1
- package/dist/__tests__/crypto/websocketIntegration.test.d.ts +0 -2
- package/dist/__tests__/crypto/websocketIntegration.test.d.ts.map +0 -1
- package/dist/__tests__/crypto/websocketIntegration.test.js +0 -259
- package/dist/__tests__/crypto/websocketIntegration.test.js.map +0 -1
- package/dist/__tests__/startup.test.d.ts +0 -11
- package/dist/__tests__/startup.test.d.ts.map +0 -1
- package/dist/__tests__/startup.test.js +0 -241
- package/dist/__tests__/startup.test.js.map +0 -1
- package/dist/__tests__/tools/claude-process.test.d.ts +0 -8
- package/dist/__tests__/tools/claude-process.test.d.ts.map +0 -1
- package/dist/__tests__/tools/claude-process.test.js +0 -430
- package/dist/__tests__/tools/claude-process.test.js.map +0 -1
- package/dist/__tests__/tools/permission-hook.test.d.ts +0 -17
- package/dist/__tests__/tools/permission-hook.test.d.ts.map +0 -1
- package/dist/__tests__/tools/permission-hook.test.js +0 -616
- package/dist/__tests__/tools/permission-hook.test.js.map +0 -1
- package/dist/__tests__/tools/permission-ipc.test.d.ts +0 -11
- package/dist/__tests__/tools/permission-ipc.test.d.ts.map +0 -1
- package/dist/__tests__/tools/permission-ipc.test.js +0 -612
- package/dist/__tests__/tools/permission-ipc.test.js.map +0 -1
- package/dist/__tests__/websocket.test.d.ts +0 -13
- package/dist/__tests__/websocket.test.d.ts.map +0 -1
- package/dist/__tests__/websocket.test.js +0 -204
- package/dist/__tests__/websocket.test.js.map +0 -1
- package/dist/api.d.ts +0 -44
- package/dist/api.d.ts.map +0 -1
- package/dist/api.js +0 -76
- package/dist/api.js.map +0 -1
- package/dist/approval.d.ts.map +0 -1
- package/dist/approval.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/crypto/e2eeManager.d.ts.map +0 -1
- package/dist/crypto/e2eeManager.js.map +0 -1
- package/dist/crypto/encryption.d.ts.map +0 -1
- package/dist/crypto/encryption.js.map +0 -1
- package/dist/crypto/keyExchange.d.ts.map +0 -1
- package/dist/crypto/keyExchange.js.map +0 -1
- package/dist/crypto/keyGeneration.d.ts.map +0 -1
- package/dist/crypto/keyGeneration.js.map +0 -1
- package/dist/crypto/keyStorage.d.ts.map +0 -1
- package/dist/crypto/keyStorage.js.map +0 -1
- package/dist/crypto/sessionPersistence.d.ts.map +0 -1
- package/dist/crypto/sessionPersistence.js.map +0 -1
- package/dist/crypto/types.d.ts.map +0 -1
- package/dist/crypto/types.js.map +0 -1
- package/dist/crypto/websocketE2EE.d.ts.map +0 -1
- package/dist/crypto/websocketE2EE.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/integration.d.ts.map +0 -1
- package/dist/integration.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/startup.d.ts.map +0 -1
- package/dist/startup.js.map +0 -1
- package/dist/terminal.d.ts.map +0 -1
- package/dist/terminal.js.map +0 -1
- package/dist/tools/__tests__/claude-sessions.test.d.ts +0 -2
- package/dist/tools/__tests__/claude-sessions.test.d.ts.map +0 -1
- package/dist/tools/__tests__/claude-sessions.test.js +0 -306
- package/dist/tools/__tests__/claude-sessions.test.js.map +0 -1
- package/dist/tools/claude-hooks.d.ts.map +0 -1
- package/dist/tools/claude-hooks.js.map +0 -1
- package/dist/tools/claude-process.d.ts.map +0 -1
- package/dist/tools/claude-process.js.map +0 -1
- package/dist/tools/claude-sessions.d.ts.map +0 -1
- package/dist/tools/claude-sessions.js.map +0 -1
- package/dist/tools/detector.d.ts.map +0 -1
- package/dist/tools/detector.js.map +0 -1
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/permission-hook.d.ts.map +0 -1
- package/dist/tools/permission-hook.js.map +0 -1
- package/dist/tools/permission-ipc.d.ts.map +0 -1
- package/dist/tools/permission-ipc.js.map +0 -1
- package/dist/transcript-streamer.d.ts.map +0 -1
- package/dist/transcript-streamer.js.map +0 -1
- package/dist/websocket.d.ts.map +0 -1
- package/dist/websocket.js.map +0 -1
- package/jest.config.js +0 -18
package/LICENSE
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
proprietary property of ForkOff. The Software is provided for viewing and
|
|
5
|
-
reference purposes only.
|
|
3
|
+
Copyright (c) 2026 ForkOff
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
10
14
|
|
|
11
15
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
12
16
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
package/README.md
CHANGED
|
@@ -5,37 +5,31 @@
|
|
|
5
5
|
<h1 align="center">ForkOff CLI</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
8
|
+
<strong>Control your AI coding sessions from your phone</strong>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
|
|
17
|
-
---
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/forkoff"><img src="https://img.shields.io/npm/v/forkoff.svg" alt="npm version"></a>
|
|
13
|
+
<a href="https://github.com/Forkoff-app/forkoff-cli/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/forkoff.svg" alt="MIT License"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/forkoff"><img src="https://img.shields.io/npm/dm/forkoff.svg" alt="npm downloads"></a>
|
|
15
|
+
</p>
|
|
18
16
|
|
|
19
17
|
<p align="center">
|
|
20
|
-
<a href="https://forkoff.app">Website</a>
|
|
21
|
-
<a href="#installation">Installation</a>
|
|
22
|
-
<a href="#quick-start">Quick Start</a>
|
|
23
|
-
<a href="#commands">Commands</a>
|
|
24
|
-
<a href="#programmatic-usage">API</a>
|
|
25
|
-
<a href="#
|
|
18
|
+
<a href="https://forkoff.app">Website</a> •
|
|
19
|
+
<a href="#installation">Installation</a> •
|
|
20
|
+
<a href="#quick-start">Quick Start</a> •
|
|
21
|
+
<a href="#commands">Commands</a> •
|
|
22
|
+
<a href="#programmatic-usage">API</a> •
|
|
23
|
+
<a href="#security">Security</a>
|
|
26
24
|
</p>
|
|
27
25
|
|
|
28
26
|
---
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
ForkOff CLI connects [Claude Code](https://claude.ai/code) on your laptop to the ForkOff mobile app, giving you real-time monitoring, interactive approvals, and usage analytics from anywhere.
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- **Approve code changes** on the go
|
|
36
|
-
- **Send prompts** to Claude, Cursor, and other AI tools
|
|
37
|
-
- **Monitor progress** in real-time
|
|
38
|
-
- **Get notifications** for permission requests
|
|
30
|
+
> **Open Source** — ForkOff CLI is now MIT licensed. Contributions welcome!
|
|
31
|
+
>
|
|
32
|
+
> **Open Beta** — [Join the iOS TestFlight](https://testflight.apple.com/join/dhh5FrN7)
|
|
39
33
|
|
|
40
34
|
## Installation
|
|
41
35
|
|
|
@@ -45,24 +39,35 @@ npm install -g forkoff
|
|
|
45
39
|
|
|
46
40
|
## Quick Start
|
|
47
41
|
|
|
48
|
-
### 1. Pair with
|
|
42
|
+
### 1. Pair with your phone
|
|
49
43
|
|
|
50
44
|
```bash
|
|
51
45
|
forkoff pair
|
|
52
46
|
```
|
|
53
47
|
|
|
54
|
-
Scan the QR code with
|
|
48
|
+
Scan the QR code with the ForkOff mobile app to link your device.
|
|
55
49
|
|
|
56
|
-
### 2. Stay
|
|
50
|
+
### 2. Stay connected
|
|
57
51
|
|
|
58
52
|
```bash
|
|
59
53
|
forkoff connect
|
|
60
54
|
```
|
|
61
55
|
|
|
62
|
-
Keep this running to
|
|
56
|
+
Keep this running to stream sessions to your phone in real-time.
|
|
63
57
|
|
|
64
58
|
---
|
|
65
59
|
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **Real-time session monitoring** — See Claude Code output on your phone as it happens
|
|
63
|
+
- **Interactive approvals** — Approve or deny tool use (file edits, bash commands) from mobile
|
|
64
|
+
- **Configurable permission rules** — Auto-approve safe tools, require approval for destructive ones
|
|
65
|
+
- **End-to-end encryption** — All session data encrypted between CLI and mobile
|
|
66
|
+
- **Usage analytics** — Track token usage, session counts, and streaks across devices
|
|
67
|
+
- **Multi-device support** — Connect multiple CLI instances, analytics aggregate automatically
|
|
68
|
+
- **Auto-start** — Optionally launch on login so your phone is always connected
|
|
69
|
+
- **Direct P2P connection** — Embedded relay server, no cloud dependency for session data
|
|
70
|
+
|
|
66
71
|
## Commands
|
|
67
72
|
|
|
68
73
|
| Command | Description |
|
|
@@ -70,31 +75,18 @@ Keep this running to receive commands from your mobile app.
|
|
|
70
75
|
| `forkoff pair` | Generate QR code to pair with mobile app |
|
|
71
76
|
| `forkoff connect` | Connect and listen for commands |
|
|
72
77
|
| `forkoff status` | Check connection status |
|
|
73
|
-
| `forkoff disconnect` | Disconnect
|
|
78
|
+
| `forkoff disconnect` | Disconnect and unpair device |
|
|
74
79
|
| `forkoff config` | View/modify configuration |
|
|
75
80
|
| `forkoff startup` | Manage automatic startup on login |
|
|
76
|
-
| `forkoff
|
|
77
|
-
| `forkoff
|
|
78
|
-
| `forkoff startup --status` | Show startup registration status |
|
|
79
|
-
| `forkoff help` | Show available commands and usage |
|
|
81
|
+
| `forkoff tools` | Detect AI coding tools on your machine |
|
|
82
|
+
| `forkoff logs` | List debug log files for troubleshooting |
|
|
80
83
|
|
|
81
|
-
### Configuration
|
|
84
|
+
### Configuration
|
|
82
85
|
|
|
83
86
|
```bash
|
|
84
|
-
# Show current
|
|
85
|
-
forkoff config --
|
|
86
|
-
|
|
87
|
-
# Set custom API URL
|
|
88
|
-
forkoff config --api https://your-server.com/api
|
|
89
|
-
|
|
90
|
-
# Set custom WebSocket URL
|
|
91
|
-
forkoff config --ws wss://your-server.com
|
|
92
|
-
|
|
93
|
-
# Set device name
|
|
94
|
-
forkoff config --name "My MacBook Pro"
|
|
95
|
-
|
|
96
|
-
# Reset all configuration
|
|
97
|
-
forkoff config --reset
|
|
87
|
+
forkoff config --show # Show current config
|
|
88
|
+
forkoff config --name "My MBP" # Set device name
|
|
89
|
+
forkoff config --reset # Reset to defaults
|
|
98
90
|
```
|
|
99
91
|
|
|
100
92
|
### Global Options
|
|
@@ -102,93 +94,64 @@ forkoff config --reset
|
|
|
102
94
|
| Option | Description |
|
|
103
95
|
|--------|-------------|
|
|
104
96
|
| `-q, --quiet` | Suppress all output (for background operation) |
|
|
105
|
-
|
|
106
|
-
### Background Operation
|
|
107
|
-
|
|
108
|
-
Run ForkOff silently in the background with no console output:
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
forkoff connect --quiet
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
This is used by the automatic startup feature and is useful for running ForkOff as a background service.
|
|
97
|
+
| `--debug` | Enable debug logging to file (`~/.forkoff-cli/logs/`) |
|
|
115
98
|
|
|
116
99
|
### Automatic Startup
|
|
117
100
|
|
|
118
|
-
|
|
101
|
+
Startup is enabled by default — `forkoff pair` and `forkoff connect` register the CLI to launch on login.
|
|
119
102
|
|
|
120
|
-
- **Windows**:
|
|
121
|
-
- **macOS**:
|
|
122
|
-
|
|
123
|
-
To disable automatic startup:
|
|
103
|
+
- **Windows**: Registry key (`HKCU\...\Run`)
|
|
104
|
+
- **macOS**: launchd agent (`~/Library/LaunchAgents/app.forkoff.cli.plist`)
|
|
124
105
|
|
|
125
106
|
```bash
|
|
126
|
-
forkoff startup --disable
|
|
107
|
+
forkoff startup --disable # Disable auto-start
|
|
108
|
+
forkoff startup --enable # Re-enable
|
|
109
|
+
forkoff startup --status # Check registration
|
|
127
110
|
```
|
|
128
111
|
|
|
129
|
-
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Security
|
|
130
115
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
116
|
+
All communication between the CLI and mobile app is end-to-end encrypted. The relay server never sees plaintext session data.
|
|
117
|
+
|
|
118
|
+
| Layer | Implementation |
|
|
119
|
+
|-------|---------------|
|
|
120
|
+
| **Key exchange** | X25519 ECDH with Ed25519 identity signatures |
|
|
121
|
+
| **Encryption** | XSalsa20-Poly1305 authenticated encryption (NaCl) |
|
|
122
|
+
| **Identity** | TOFU (Trust On First Use) with key pinning |
|
|
123
|
+
| **Replay protection** | Per-peer monotonic message counters |
|
|
124
|
+
| **Key storage** | OS keychain (macOS Keychain, Windows Credential Manager, Linux libsecret) |
|
|
125
|
+
| **Enforcement** | Sensitive events (session content, approvals, files) never sent in plaintext |
|
|
134
126
|
|
|
135
|
-
|
|
127
|
+
No additional setup required — E2EE is enabled automatically when you pair.
|
|
136
128
|
|
|
137
129
|
---
|
|
138
130
|
|
|
139
131
|
## Programmatic Usage
|
|
140
132
|
|
|
141
|
-
Integrate ForkOff into your AI coding tools:
|
|
133
|
+
Integrate ForkOff into your own AI coding tools:
|
|
142
134
|
|
|
143
135
|
```typescript
|
|
144
136
|
import { createIntegration } from 'forkoff';
|
|
145
137
|
|
|
146
138
|
const forkoff = createIntegration();
|
|
147
|
-
|
|
148
|
-
// Connect to ForkOff server
|
|
149
139
|
await forkoff.connect();
|
|
150
140
|
|
|
151
|
-
// Handle incoming messages from mobile app
|
|
152
|
-
forkoff.onMessageReceived((sessionId, content, requestedBy) => {
|
|
153
|
-
console.log(`Message from ${requestedBy}: ${content}`);
|
|
154
|
-
|
|
155
|
-
// Send a response
|
|
156
|
-
forkoff.sendMessage(sessionId, 'Processing your request...');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Stream responses in real-time
|
|
160
|
-
const stream = forkoff.startStreaming(sessionId);
|
|
161
|
-
stream.write('Here is ');
|
|
162
|
-
stream.write('a streaming ');
|
|
163
|
-
stream.write('response.');
|
|
164
|
-
stream.end();
|
|
165
|
-
|
|
166
141
|
// Request approval for code changes
|
|
167
142
|
const approval = await forkoff.requestApproval(
|
|
168
|
-
sessionId,
|
|
169
|
-
|
|
170
|
-
'CODE_CHANGE',
|
|
171
|
-
'Add authentication middleware',
|
|
143
|
+
sessionId, messageId, 'CODE_CHANGE',
|
|
144
|
+
'Add auth middleware',
|
|
172
145
|
{ filePath: 'src/middleware/auth.ts', diff: '...' }
|
|
173
146
|
);
|
|
174
147
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Send terminal output
|
|
180
|
-
forkoff.sendTerminalOutput(sessionId, '> npm install\n Done', 'stdout');
|
|
181
|
-
forkoff.sendTerminalExit(sessionId, 0);
|
|
182
|
-
|
|
183
|
-
// Update device status
|
|
184
|
-
forkoff.setStatus('busy');
|
|
148
|
+
// Stream terminal output
|
|
149
|
+
forkoff.sendTerminalOutput(sessionId, '> npm install\nDone', 'stdout');
|
|
185
150
|
```
|
|
186
151
|
|
|
187
152
|
---
|
|
188
153
|
|
|
189
|
-
## Configuration
|
|
190
|
-
|
|
191
|
-
Configuration files are stored at:
|
|
154
|
+
## Configuration Files
|
|
192
155
|
|
|
193
156
|
| Platform | Location |
|
|
194
157
|
|----------|----------|
|
|
@@ -196,26 +159,22 @@ Configuration files are stored at:
|
|
|
196
159
|
| **macOS** | `~/Library/Preferences/forkoff-cli/config.json` |
|
|
197
160
|
| **Linux** | `~/.config/forkoff-cli/config.json` |
|
|
198
161
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
## Security
|
|
202
|
-
|
|
203
|
-
Your data stays yours. All communication between the ForkOff CLI and your mobile device is protected with **end-to-end encryption (E2EE)**:
|
|
204
|
-
|
|
205
|
-
- Messages, code, and commands are encrypted on-device before leaving your machine
|
|
206
|
-
- The ForkOff server never sees your plaintext data — it only relays encrypted payloads
|
|
207
|
-
- Each device pair establishes a unique encrypted channel using ephemeral key exchange
|
|
208
|
-
- Session keys are derived per-connection, so even if one session is compromised, others remain secure
|
|
162
|
+
## Development
|
|
209
163
|
|
|
210
|
-
|
|
164
|
+
```bash
|
|
165
|
+
git clone https://github.com/Forkoff-app/forkoff-cli.git
|
|
166
|
+
cd forkoff-cli
|
|
167
|
+
npm install
|
|
168
|
+
npm run dev # Run with ts-node
|
|
169
|
+
npm run build # Compile TypeScript
|
|
170
|
+
npm test # Run tests
|
|
171
|
+
```
|
|
211
172
|
|
|
212
173
|
## Requirements
|
|
213
174
|
|
|
214
175
|
- Node.js 18+
|
|
215
|
-
- ForkOff mobile app
|
|
176
|
+
- [ForkOff mobile app](https://testflight.apple.com/join/dhh5FrN7) (iOS)
|
|
216
177
|
|
|
217
|
-
|
|
178
|
+
## License
|
|
218
179
|
|
|
219
|
-
|
|
220
|
-
Made with ❤️ by the ForkOff team
|
|
221
|
-
</p>
|
|
180
|
+
[MIT](LICENSE)
|
package/dist/approval.d.ts
CHANGED
package/dist/approval.js
CHANGED
|
@@ -24,6 +24,14 @@ class ApprovalManager extends events_1.EventEmitter {
|
|
|
24
24
|
createdAt: new Date(),
|
|
25
25
|
status: 'pending',
|
|
26
26
|
};
|
|
27
|
+
// Evict oldest if at cap
|
|
28
|
+
while (this.pendingApprovals.size >= ApprovalManager.MAX_PENDING_APPROVALS) {
|
|
29
|
+
const oldestKey = this.pendingApprovals.keys().next().value;
|
|
30
|
+
if (oldestKey)
|
|
31
|
+
this.pendingApprovals.delete(oldestKey);
|
|
32
|
+
else
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
27
35
|
this.pendingApprovals.set(id, approval);
|
|
28
36
|
// Send to mobile app via WebSocket
|
|
29
37
|
websocket_1.wsClient.sendApprovalRequest({
|
|
@@ -114,6 +122,7 @@ class ApprovalManager extends events_1.EventEmitter {
|
|
|
114
122
|
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
125
|
+
ApprovalManager.MAX_PENDING_APPROVALS = 50;
|
|
117
126
|
exports.approvalManager = new ApprovalManager();
|
|
118
127
|
exports.default = exports.approvalManager;
|
|
119
128
|
//# sourceMappingURL=approval.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -23,7 +23,10 @@ declare class Config {
|
|
|
23
23
|
set startupEnabled(value: boolean | null);
|
|
24
24
|
get startupBinaryPath(): string | null;
|
|
25
25
|
set startupBinaryPath(value: string | null);
|
|
26
|
+
get relayPort(): number;
|
|
27
|
+
set relayPort(value: number);
|
|
26
28
|
get isPaired(): boolean;
|
|
29
|
+
ensureDeviceId(): string;
|
|
27
30
|
getMachineId(): string;
|
|
28
31
|
getDeviceInfo(): {
|
|
29
32
|
name: string;
|
package/dist/config.js
CHANGED
|
@@ -44,17 +44,17 @@ function isLocalUrl(url) {
|
|
|
44
44
|
try {
|
|
45
45
|
const parsed = new URL(url);
|
|
46
46
|
const host = parsed.hostname.toLowerCase();
|
|
47
|
-
|
|
48
|
-
host
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
host.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
if (host === 'localhost' || host === '127.0.0.1' ||
|
|
48
|
+
host.startsWith('192.168.') || host.startsWith('10.')) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
// Check 172.16.0.0 - 172.31.255.255
|
|
52
|
+
if (host.startsWith('172.')) {
|
|
53
|
+
const secondOctet = parseInt(host.split('.')[1], 10);
|
|
54
|
+
if (secondOctet >= 16 && secondOctet <= 31)
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
58
|
}
|
|
59
59
|
catch {
|
|
60
60
|
return false;
|
|
@@ -81,6 +81,7 @@ const defaultConfig = {
|
|
|
81
81
|
deviceName: os.hostname(),
|
|
82
82
|
apiUrl: 'https://api.forkoff.app/api',
|
|
83
83
|
wsUrl: 'wss://api.forkoff.app',
|
|
84
|
+
relayPort: 3000,
|
|
84
85
|
pairingCode: null,
|
|
85
86
|
pairedAt: null,
|
|
86
87
|
userId: null,
|
|
@@ -97,15 +98,30 @@ class Config {
|
|
|
97
98
|
? path.join(process.env.APPDATA || os.homedir(), 'forkoff-cli')
|
|
98
99
|
: path.join(os.homedir(), '.config', 'forkoff-cli');
|
|
99
100
|
if (!fs.existsSync(configDir)) {
|
|
100
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
101
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
101
102
|
}
|
|
102
103
|
return path.join(configDir, 'config.json');
|
|
103
104
|
}
|
|
104
105
|
load() {
|
|
105
106
|
try {
|
|
106
107
|
if (fs.existsSync(this.configPath)) {
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// SECURITY: Atomic symlink check — open fd then fstat to avoid TOCTOU
|
|
109
|
+
const fd = fs.openSync(this.configPath, 'r');
|
|
110
|
+
try {
|
|
111
|
+
const stat = fs.fstatSync(fd);
|
|
112
|
+
// If the file was replaced with a symlink between open and fstat,
|
|
113
|
+
// fstat returns the symlink target info. Check lstat separately.
|
|
114
|
+
const lstat = fs.lstatSync(this.configPath);
|
|
115
|
+
if (lstat.isSymbolicLink()) {
|
|
116
|
+
console.error('[Security] Symlink detected at config file, refusing to read');
|
|
117
|
+
return { ...defaultConfig };
|
|
118
|
+
}
|
|
119
|
+
const content = fs.readFileSync(fd, 'utf-8');
|
|
120
|
+
return { ...defaultConfig, ...JSON.parse(content) };
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
}
|
|
109
125
|
}
|
|
110
126
|
}
|
|
111
127
|
catch (error) {
|
|
@@ -114,7 +130,22 @@ class Config {
|
|
|
114
130
|
return { ...defaultConfig };
|
|
115
131
|
}
|
|
116
132
|
save() {
|
|
117
|
-
|
|
133
|
+
// SECURITY: Atomic write via temp file + rename to prevent TOCTOU
|
|
134
|
+
const tmpPath = this.configPath + '.tmp.' + process.pid;
|
|
135
|
+
try {
|
|
136
|
+
// Write to temp file with restrictive permissions
|
|
137
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
138
|
+
// Atomic rename (same filesystem)
|
|
139
|
+
fs.renameSync(tmpPath, this.configPath);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
// Clean up temp file on error
|
|
143
|
+
try {
|
|
144
|
+
fs.unlinkSync(tmpPath);
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
147
|
+
console.error('[Security] Failed to save config:', err.message);
|
|
148
|
+
}
|
|
118
149
|
}
|
|
119
150
|
get deviceId() {
|
|
120
151
|
return this.data.deviceId;
|
|
@@ -183,8 +214,23 @@ class Config {
|
|
|
183
214
|
this.data.startupBinaryPath = value;
|
|
184
215
|
this.save();
|
|
185
216
|
}
|
|
217
|
+
get relayPort() {
|
|
218
|
+
return this.data.relayPort;
|
|
219
|
+
}
|
|
220
|
+
set relayPort(value) {
|
|
221
|
+
this.data.relayPort = value;
|
|
222
|
+
this.save();
|
|
223
|
+
}
|
|
186
224
|
get isPaired() {
|
|
187
|
-
return !!this.
|
|
225
|
+
return !!this.deviceId && !!this.pairedAt;
|
|
226
|
+
}
|
|
227
|
+
// Ensure deviceId exists, generating one if needed
|
|
228
|
+
ensureDeviceId() {
|
|
229
|
+
if (!this.data.deviceId) {
|
|
230
|
+
this.data.deviceId = this.getMachineId();
|
|
231
|
+
this.save();
|
|
232
|
+
}
|
|
233
|
+
return this.data.deviceId;
|
|
188
234
|
}
|
|
189
235
|
// Get unique machine identifier
|
|
190
236
|
getMachineId() {
|
|
@@ -1,82 +1,79 @@
|
|
|
1
1
|
import { KeyExchangeInit, KeyExchangeAck, EncryptedMessage } from './types';
|
|
2
|
-
/**
|
|
3
|
-
* E2EE Manager for CLI
|
|
4
|
-
* Orchestrates all end-to-end encryption operations
|
|
5
|
-
*/
|
|
6
2
|
export declare class E2EEManager {
|
|
7
3
|
private deviceId;
|
|
8
|
-
private apiUrl;
|
|
9
|
-
private authToken;
|
|
10
4
|
private keyPair;
|
|
5
|
+
private signingKeyPair;
|
|
11
6
|
private initialized;
|
|
12
|
-
private
|
|
13
|
-
private
|
|
14
|
-
private
|
|
15
|
-
private
|
|
16
|
-
constructor(deviceId: string
|
|
7
|
+
private sessions;
|
|
8
|
+
private static readonly MAX_PENDING_EXCHANGES;
|
|
9
|
+
private static readonly PENDING_EXCHANGE_TTL_MS;
|
|
10
|
+
private pendingExchanges;
|
|
11
|
+
constructor(deviceId: string);
|
|
17
12
|
/**
|
|
18
|
-
* Initializes E2EE manager
|
|
19
|
-
* - Loads or generates key pair
|
|
20
|
-
* - Uploads public key to backend
|
|
13
|
+
* Initializes E2EE manager: loads or generates identity key pairs (DH + signing).
|
|
21
14
|
*/
|
|
22
15
|
initialize(): Promise<void>;
|
|
16
|
+
/** Get the public key (for uploading to server) */
|
|
17
|
+
getPublicKey(): string | null;
|
|
18
|
+
/** Get the signing public key */
|
|
19
|
+
getSigningPublicKey(): string | null;
|
|
20
|
+
/** Check if manager is initialized */
|
|
21
|
+
isInitialized(): boolean;
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
23
|
+
* Sign a key exchange payload with our Ed25519 identity key.
|
|
24
|
+
* The signed message is: "prefix:senderDeviceId:ephemeralPublicKey[:recipientDeviceId]"
|
|
25
25
|
*/
|
|
26
|
-
private
|
|
26
|
+
private signPayload;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* Verify a peer's signature on a key exchange payload.
|
|
29
|
+
* Returns true if signature is valid OR if peer has no identity key (unsigned exchange accepted with warning).
|
|
30
|
+
* Throws if the peer's identity key doesn't match TOFU record (potential MITM).
|
|
29
31
|
*/
|
|
30
|
-
private
|
|
32
|
+
private verifyPeerSignature;
|
|
31
33
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
+
* Create a key exchange initiation to send to a remote device.
|
|
35
|
+
* Generates an ephemeral key pair and signs it with our identity key.
|
|
34
36
|
*/
|
|
35
|
-
initiateKeyExchange(targetDeviceId: string): Promise<KeyExchangeInit>;
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
-
* Returns ack payload to send back via WebSocket
|
|
38
|
+
* Evict expired or excess pending key exchanges.
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
private cleanupPendingExchanges;
|
|
41
|
+
createKeyExchangeInit(targetDeviceId: string): KeyExchangeInit;
|
|
41
42
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
43
|
+
* Handle an incoming key exchange init from a remote device.
|
|
44
|
+
* Verifies the peer's identity signature (TOFU), computes shared key,
|
|
45
|
+
* and returns a signed ack.
|
|
44
46
|
*/
|
|
45
|
-
|
|
47
|
+
handleKeyExchangeInit(init: KeyExchangeInit): KeyExchangeAck;
|
|
46
48
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
+
* Handle an incoming key exchange ack from a remote device.
|
|
50
|
+
* Verifies the peer's identity signature (TOFU) and completes the key exchange.
|
|
49
51
|
*/
|
|
50
|
-
|
|
52
|
+
handleKeyExchangeAck(ack: KeyExchangeAck): void;
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
53
|
-
* Useful for auto-reconnection after network changes
|
|
54
|
+
* Attempts to restore a persisted session after reconnection.
|
|
54
55
|
*/
|
|
56
|
+
restorePersistedSession(targetDeviceId: string): Promise<boolean>;
|
|
57
|
+
/** Lists all devices with persisted sessions */
|
|
55
58
|
listPersistedDevices(): string[];
|
|
56
|
-
/**
|
|
57
|
-
* Encrypts a message for a target device
|
|
58
|
-
*/
|
|
59
|
-
encryptMessage(plaintext: string, targetDeviceId: string, sessionId: string): EncryptedMessage;
|
|
60
|
-
/**
|
|
61
|
-
* Decrypts a message from a sender device
|
|
62
|
-
*/
|
|
63
|
-
decryptMessage(encryptedMessage: EncryptedMessage, senderDeviceId: string): string;
|
|
64
|
-
/**
|
|
65
|
-
* Checks if a session key exists for a device
|
|
66
|
-
*/
|
|
59
|
+
/** Check if an encrypted session is established with a device */
|
|
67
60
|
hasSessionKey(deviceId: string): boolean;
|
|
61
|
+
/** Encrypt a message for a specific device */
|
|
62
|
+
encryptMessage(plaintext: string, recipientDeviceId: string, sessionId: string): EncryptedMessage;
|
|
63
|
+
/** Decrypt an incoming encrypted message */
|
|
64
|
+
decryptMessage(message: EncryptedMessage, senderDeviceId: string): string;
|
|
65
|
+
/** Clear session for a specific device */
|
|
66
|
+
clearSession(deviceId: string): void;
|
|
68
67
|
/**
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
isInitialized(): boolean;
|
|
72
|
-
/**
|
|
73
|
-
* Cleans up all session keys and pending exchanges
|
|
74
|
-
* @param deletePersisted - Whether to delete persisted sessions from disk (default: false)
|
|
68
|
+
* Cleans up all session keys and pending exchanges.
|
|
69
|
+
* @param deletePersisted - Whether to delete persisted sessions from disk
|
|
75
70
|
*/
|
|
76
71
|
cleanup(deletePersisted?: boolean): void;
|
|
77
|
-
/**
|
|
78
|
-
* Removes a specific persisted session
|
|
79
|
-
*/
|
|
72
|
+
/** Removes a specific persisted session */
|
|
80
73
|
removePersistedSession(targetDeviceId: string): void;
|
|
74
|
+
/** Reset TOFU trust for a peer (used on re-pair so new keys are accepted) */
|
|
75
|
+
resetPeerTrust(targetDeviceId: string): void;
|
|
76
|
+
/** Light trust reset — only clears TOFU key, preserves pending exchanges in-flight */
|
|
77
|
+
clearTrustOnly(targetDeviceId: string): void;
|
|
81
78
|
}
|
|
82
79
|
//# sourceMappingURL=e2eeManager.d.ts.map
|