examedix-reach 0.1.0 → 0.1.1
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/DISCLOSURE +3 -1
- package/README.md +19 -7
- package/bin/reach.js +216 -75
- package/package.json +1 -1
package/DISCLOSURE
CHANGED
|
@@ -17,8 +17,10 @@ Reach includes security controls including:
|
|
|
17
17
|
- no inbound workstation listener;
|
|
18
18
|
- local filesystem boundaries;
|
|
19
19
|
- blocking of credential stores, private keys and common secret files;
|
|
20
|
-
- Power Mode disabled by default
|
|
20
|
+
- Power Mode disabled by default unless explicitly authorized in a local Windows consent dialog;
|
|
21
21
|
- audit metadata with sensitive values redacted;
|
|
22
22
|
- Windows DPAPI protection for the local device credential.
|
|
23
23
|
|
|
24
24
|
Unauthorized access, credential theft, covert surveillance, malware deployment, destructive misuse, or bypassing security controls is prohibited by the Reach Terms of Service.
|
|
25
|
+
|
|
26
|
+
Explicit local consent is collected before pairing/agent startup, stored on the computer, and may be changed with the power-mode command. A missing or inconsistent consent record fails closed. Power Mode runs with the permissions of the logged-in Windows user and is not a security sandbox.
|
package/README.md
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
# ExamediX Reach
|
|
2
2
|
|
|
3
|
-
ExamediX Reach
|
|
3
|
+
ExamediX Reach connects ChatGPT to Windows computers you own or are authorized to administer.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Requires Windows 10/11 and Node.js 20 or newer.
|
|
8
8
|
|
|
9
9
|
```powershell
|
|
10
|
-
npx examedix-reach@latest remote
|
|
10
|
+
npx --yes examedix-reach@latest remote
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
On first run Reach
|
|
13
|
+
On the first run, Reach asks for **explicit local permission** to enable Power Mode, **before** starting the agent and pairing the computer. **No** is selected by default. Choose **No** for restricted operation, or **Yes** if you knowingly want ChatGPT to run PowerShell and Windows programs with your Windows user permissions. These commands may change or delete files, install software and use network services. Power Mode is not a sandbox.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Reach then displays a pairing code and opens the account page. Sign in or create an account, pair your computer, and keep the agent process running while you need access through ChatGPT.
|
|
16
|
+
|
|
17
|
+
## Change Power Mode permission
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
npx --yes examedix-reach@latest power-mode
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This shows the local consent dialog again. After changing permission, **stop and restart the running Reach agent**.
|
|
24
|
+
|
|
25
|
+
The choice is stored locally in `%LOCALAPPDATA%\ExamediX\ReachData` and is reused on future starts when its consent record and agent configuration agree. A missing or inconsistent record never silently enables Power Mode.
|
|
26
|
+
|
|
27
|
+
Additional commands:
|
|
16
28
|
|
|
17
29
|
```powershell
|
|
18
30
|
npx examedix-reach@latest setup
|
|
@@ -20,7 +32,7 @@ npx examedix-reach@latest doctor
|
|
|
20
32
|
npx examedix-reach@latest --version
|
|
21
33
|
```
|
|
22
34
|
|
|
23
|
-
Reach
|
|
35
|
+
Reach uses an outbound TLS connection; no inbound Windows port is required.
|
|
24
36
|
|
|
25
37
|
Security and privacy: https://examedix.ch/reach/security
|
|
26
|
-
Support: https://examedix.ch/reach/support
|
|
38
|
+
Support: https://examedix.ch/reach/support
|
package/bin/reach.js
CHANGED
|
@@ -2,44 +2,53 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
// Keep this version in sync with package.json before publishing.
|
|
10
|
+
const VERSION = "0.1.1";
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const agent = path.resolve(here, "..", "dist", "agent.js");
|
|
13
|
+
const command = (process.argv[2] || "remote").toLowerCase();
|
|
14
|
+
const root = process.env.REACH_ROOT || (
|
|
15
|
+
process.platform === "win32" && process.env.LOCALAPPDATA
|
|
16
|
+
? path.join(process.env.LOCALAPPDATA, "ExamediX", "ReachData")
|
|
17
|
+
: path.join(os.homedir(), ".examedix-reach")
|
|
16
18
|
);
|
|
19
|
+
const configFile = path.join(root, "config.json");
|
|
20
|
+
const consentFile = path.join(root, "power-mode-consent.json");
|
|
17
21
|
|
|
18
|
-
function openBrowser(url){
|
|
19
|
-
try{
|
|
20
|
-
if(process.platform==="win32"){
|
|
21
|
-
const p=spawn("cmd.exe",["/d","/c","start","",url],{
|
|
22
|
+
function openBrowser(url) {
|
|
23
|
+
try {
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
const p = spawn("cmd.exe", ["/d", "/c", "start", "", url], {
|
|
26
|
+
detached: true, stdio: "ignore", windowsHide: true
|
|
27
|
+
});
|
|
28
|
+
p.unref();
|
|
29
|
+
} else if (process.platform === "darwin") {
|
|
30
|
+
const p = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
22
31
|
p.unref();
|
|
23
|
-
} else if(process.platform==="darwin"){
|
|
24
|
-
const p=spawn("open",[url],{detached:true,stdio:"ignore"}); p.unref();
|
|
25
32
|
} else {
|
|
26
|
-
const p=spawn("xdg-open",[url],{detached:true,stdio:"ignore"});
|
|
33
|
+
const p = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
34
|
+
p.unref();
|
|
27
35
|
}
|
|
28
|
-
}catch{}
|
|
36
|
+
} catch {}
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
function printHelp(){
|
|
39
|
+
function printHelp() {
|
|
32
40
|
console.log(`ExamediX Reach ${VERSION}
|
|
33
41
|
|
|
34
42
|
Usage:
|
|
35
|
-
examedix-reach remote
|
|
36
|
-
examedix-reach setup
|
|
37
|
-
examedix-reach
|
|
38
|
-
examedix-reach
|
|
43
|
+
examedix-reach remote Connect this computer to Reach (default)
|
|
44
|
+
examedix-reach setup Create/show a pairing code, then exit
|
|
45
|
+
examedix-reach power-mode Review or change local Power Mode permission
|
|
46
|
+
examedix-reach doctor Check local prerequisites and Reach service
|
|
47
|
+
examedix-reach --version Show version
|
|
39
48
|
|
|
40
49
|
Examples:
|
|
41
50
|
npx examedix-reach@latest remote
|
|
42
|
-
npx examedix-reach@latest
|
|
51
|
+
npx examedix-reach@latest power-mode
|
|
43
52
|
|
|
44
53
|
Local data:
|
|
45
54
|
${root}
|
|
@@ -49,70 +58,202 @@ Reach:
|
|
|
49
58
|
`);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
async function doctor(){
|
|
53
|
-
const result={};
|
|
54
|
-
result.node=process.version;
|
|
55
|
-
result.platform
|
|
56
|
-
result.root=root;
|
|
57
|
-
result.deviceFile=fs.existsSync(path.join(root,"device.json"));
|
|
58
|
-
result.agentFile=fs.existsSync(agent);
|
|
59
|
-
try{
|
|
60
|
-
const r=await fetch("https://examedix.ch/reach/health",{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
async function doctor() {
|
|
62
|
+
const result = {};
|
|
63
|
+
result.node = process.version;
|
|
64
|
+
result.platform = `${process.platform} ${os.release()}`;
|
|
65
|
+
result.root = root;
|
|
66
|
+
result.deviceFile = fs.existsSync(path.join(root, "device.json"));
|
|
67
|
+
result.agentFile = fs.existsSync(agent);
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch("https://examedix.ch/reach/health", {
|
|
70
|
+
signal: AbortSignal.timeout(10000)
|
|
71
|
+
});
|
|
72
|
+
result.service = r.ok ? "online" : `http-${r.status}`;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
result.service = "unreachable";
|
|
75
|
+
result.error = e.message;
|
|
76
|
+
}
|
|
77
|
+
console.log(JSON.stringify(result, null, 2));
|
|
78
|
+
process.exit(result.agentFile && result.service === "online" ? 0 : 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function windowsOnly() {
|
|
82
|
+
if (process.platform !== "win32") {
|
|
83
|
+
throw new Error("ExamediX Reach currently supports Windows only.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readObject(file) {
|
|
88
|
+
if (!fs.existsSync(file)) return {};
|
|
89
|
+
const obj = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
90
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
91
|
+
throw new Error(`Invalid JSON object in ${file}; refusing to overwrite it.`);
|
|
92
|
+
}
|
|
93
|
+
return obj;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Atomic replace to avoid a truncated configuration on interrupted writes.
|
|
97
|
+
function atomicWriteJson(file, data) {
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
100
|
+
try {
|
|
101
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, {
|
|
102
|
+
encoding: "utf8", flag: "wx", mode: 0o600
|
|
103
|
+
});
|
|
104
|
+
fs.renameSync(tmp, file);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readPriorConsent() {
|
|
112
|
+
const record = readObject(consentFile);
|
|
113
|
+
return typeof record.enabled === "boolean" ? record.enabled : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function setPowerMode(enabled, recordConsent = true) {
|
|
117
|
+
// Preserve all other agent settings rather than replacing config.json.
|
|
118
|
+
const cfg = readObject(configFile);
|
|
119
|
+
cfg.allowArbitraryExecution = enabled;
|
|
120
|
+
atomicWriteJson(configFile, cfg);
|
|
121
|
+
if (recordConsent) {
|
|
122
|
+
atomicWriteJson(consentFile, {
|
|
123
|
+
enabled,
|
|
124
|
+
recordedAt: new Date().toISOString(),
|
|
125
|
+
source: "examedix-reach-cli-explicit-local-consent"
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Exit code 10 means the person clicked Yes; 20 means No.
|
|
131
|
+
// Other exit statuses mean the Windows dialog could not be shown.
|
|
132
|
+
function askWindowsDialog() {
|
|
133
|
+
const script = String.raw`
|
|
134
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
135
|
+
$body = 'Power Mode lets ChatGPT, through ExamediX Reach, run Windows programs and PowerShell commands using your Windows account permissions.' + [Environment]::NewLine + [Environment]::NewLine + 'Commands may change or delete files, install software, and access network services.' + [Environment]::NewLine + [Environment]::NewLine + 'Enable Power Mode on this computer?' + [Environment]::NewLine + [Environment]::NewLine + 'Choose No for the safer restricted mode. You can change your choice later.'
|
|
136
|
+
$result = [System.Windows.Forms.MessageBox]::Show($body, 'ExamediX Reach - Power Mode', [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning, [System.Windows.Forms.MessageBoxDefaultButton]::Button2)
|
|
137
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::Yes) { exit 10 }
|
|
138
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::No) { exit 20 }
|
|
139
|
+
exit 1
|
|
140
|
+
`;
|
|
141
|
+
const run = spawnSync("powershell.exe", [
|
|
142
|
+
"-NoProfile", "-STA", "-NonInteractive", "-Command", script
|
|
143
|
+
], { encoding: "utf8", windowsHide: true, timeout: 300000 });
|
|
144
|
+
if (run.status === 10) return true;
|
|
145
|
+
if (run.status === 20) return false;
|
|
146
|
+
if (run.error) console.error(`Windows consent dialog unavailable: ${run.error.message}`);
|
|
147
|
+
if (run.stderr && run.stderr.trim()) console.error(run.stderr.trim());
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function askTerminalFallback() {
|
|
152
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
153
|
+
console.log("\nExamediX Reach - Power Mode");
|
|
154
|
+
console.log("Enabling Power Mode allows ChatGPT to run programs and PowerShell commands with your Windows permissions.");
|
|
155
|
+
console.log("Commands may change/delete files or use the network. Default: No.");
|
|
156
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
157
|
+
try {
|
|
158
|
+
const answer = await rl.question("Enable Power Mode? [y/N]: ");
|
|
159
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
160
|
+
} finally {
|
|
161
|
+
rl.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function askConsent() {
|
|
166
|
+
const choice = askWindowsDialog();
|
|
167
|
+
if (choice !== null) return choice;
|
|
168
|
+
return askTerminalFallback();
|
|
65
169
|
}
|
|
66
170
|
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
171
|
+
async function ensurePowerModeChoice(force = false) {
|
|
172
|
+
windowsOnly();
|
|
173
|
+
fs.mkdirSync(root, { recursive: true });
|
|
174
|
+
const prior = readPriorConsent();
|
|
175
|
+
const cfg = readObject(configFile);
|
|
176
|
+
|
|
177
|
+
if (!force && prior !== null && cfg.allowArbitraryExecution === prior) {
|
|
178
|
+
console.log(`ExamediX Reach Power Mode: ${prior ? "ON" : "OFF"} (saved choice)`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fail closed whenever we lack a valid, matching record of explicit consent.
|
|
183
|
+
if (prior === null || cfg.allowArbitraryExecution !== prior) {
|
|
184
|
+
setPowerMode(false, false);
|
|
71
185
|
}
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
186
|
+
|
|
187
|
+
const selected = await askConsent();
|
|
188
|
+
if (selected === null) {
|
|
189
|
+
setPowerMode(false, false);
|
|
190
|
+
console.log("Power Mode OFF. Consent dialog unavailable; run in an interactive Windows session to select Power Mode.");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setPowerMode(selected, true);
|
|
195
|
+
console.log(`ExamediX Reach Power Mode: ${selected ? "ON (authorized locally)" : "OFF (restricted mode)"}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runAgent(args, openOnPair = false) {
|
|
199
|
+
windowsOnly();
|
|
200
|
+
fs.mkdirSync(root, { recursive: true });
|
|
201
|
+
const child = spawn(process.execPath, [agent, "--root", root, ...args], {
|
|
202
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
203
|
+
windowsHide: false
|
|
76
204
|
});
|
|
77
|
-
let opened=false;
|
|
78
|
-
child.stdout.on("data",buf=>{
|
|
79
|
-
const text=buf.toString();
|
|
205
|
+
let opened = false;
|
|
206
|
+
child.stdout.on("data", buf => {
|
|
207
|
+
const text = buf.toString();
|
|
80
208
|
process.stdout.write(text);
|
|
81
|
-
if(openOnPair
|
|
82
|
-
opened=true;
|
|
209
|
+
if (openOnPair && !opened && text.includes("PAIRING CODE:")) {
|
|
210
|
+
opened = true;
|
|
83
211
|
console.log("\nOpening ExamediX Reach account/pairing page...");
|
|
84
212
|
openBrowser("https://examedix.ch/reach/signup");
|
|
85
213
|
}
|
|
86
214
|
});
|
|
87
|
-
child.stderr.on("data",buf=>process.stderr.write(buf));
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
process.on("
|
|
91
|
-
|
|
215
|
+
child.stderr.on("data", buf => process.stderr.write(buf));
|
|
216
|
+
child.on("error", err => { console.error(`Could not start Reach agent: ${err.message}`); process.exitCode = 1; });
|
|
217
|
+
const forward = sig => { try { child.kill(sig); } catch {} };
|
|
218
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
219
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
220
|
+
child.on("exit", code => process.exit(code ?? 0));
|
|
92
221
|
}
|
|
93
222
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
223
|
+
async function main() {
|
|
224
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
225
|
+
console.log(VERSION);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
229
|
+
printHelp();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (command === "doctor") {
|
|
233
|
+
await doctor();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (command === "power-mode") {
|
|
237
|
+
await ensurePowerModeChoice(true);
|
|
238
|
+
console.log("Restart a running Reach agent to apply the new permission.");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (command === "setup" || command === "remote") {
|
|
242
|
+
// The approval must happen before the agent starts and before pairing.
|
|
243
|
+
await ensurePowerModeChoice();
|
|
244
|
+
const first = !fs.existsSync(path.join(root, "device.json"));
|
|
245
|
+
if (command === "remote" && first) {
|
|
246
|
+
console.log("First Reach connection: a pairing code will be created.");
|
|
247
|
+
}
|
|
248
|
+
runAgent(command === "setup" ? ["--pair-only"] : [], first);
|
|
249
|
+
return;
|
|
112
250
|
}
|
|
113
|
-
runAgent([],first);
|
|
114
|
-
} else {
|
|
115
251
|
console.error(`Unknown command: ${command}\n`);
|
|
116
252
|
printHelp();
|
|
117
|
-
process.
|
|
253
|
+
process.exitCode = 2;
|
|
118
254
|
}
|
|
255
|
+
|
|
256
|
+
main().catch(err => {
|
|
257
|
+
console.error(`ExamediX Reach: ${err.message}`);
|
|
258
|
+
process.exitCode = 1;
|
|
259
|
+
});
|