examedix-reach 0.0.0-stage → 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/DISCLOSURE ADDED
@@ -0,0 +1,24 @@
1
+ ExamediX Reach is a legitimate remote-workstation integration for computers that the user owns or is explicitly authorized to administer.
2
+
3
+ The package can connect an authorized Windows computer to the ExamediX Reach service and expose user-approved workstation capabilities such as:
4
+ - listing, reading, creating, editing, moving, copying and deleting authorized files;
5
+ - searching authorized folders;
6
+ - reading and writing supported document formats;
7
+ - inspecting and managing processes;
8
+ - starting and interacting with user-level processes;
9
+ - running user-level PowerShell when the local user has explicitly enabled Reach Power Mode.
10
+
11
+ These capabilities are dual-use because file, process and shell operations can resemble capabilities found in remote-administration or security tools. Reach is intended only for legitimate administration of the user's own or otherwise authorized computers.
12
+
13
+ Reach includes security controls including:
14
+ - explicit device pairing and account ownership checks;
15
+ - OAuth-scoped access;
16
+ - outbound-only TLS connections from the Windows agent;
17
+ - no inbound workstation listener;
18
+ - local filesystem boundaries;
19
+ - blocking of credential stores, private keys and common secret files;
20
+ - Power Mode disabled by default for ordinary users;
21
+ - audit metadata with sensitive values redacted;
22
+ - Windows DPAPI protection for the local device credential.
23
+
24
+ Unauthorized access, credential theft, covert surveillance, malware deployment, destructive misuse, or bypassing security controls is prohibited by the Reach Terms of Service.
package/README.md CHANGED
@@ -1,3 +1,26 @@
1
- # Temporary Holding Version
1
+ # ExamediX Reach
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ExamediX Reach securely connects ChatGPT to Windows computers you own or are authorized to administer.
4
+
5
+ ## Quick start
6
+
7
+ Requirements: Windows 10/11 and Node.js 20 or newer.
8
+
9
+ ```powershell
10
+ npx examedix-reach@latest remote
11
+ ```
12
+
13
+ On first run Reach prints a pairing code and opens the Reach account page. Sign in or create an account, enter the code, and keep the process running while you want the computer reachable from ChatGPT.
14
+
15
+ Useful commands:
16
+
17
+ ```powershell
18
+ npx examedix-reach@latest setup
19
+ npx examedix-reach@latest doctor
20
+ npx examedix-reach@latest --version
21
+ ```
22
+
23
+ Reach makes an outbound TLS connection only. It does not require an inbound Windows port.
24
+
25
+ Security and privacy: https://examedix.ch/reach/security
26
+ Support: https://examedix.ch/reach/support
@@ -0,0 +1,17 @@
1
+ ExamediX Reach third-party notices
2
+
3
+ This distribution includes the Node.js runtime and open-source JavaScript dependencies.
4
+ Node.js is distributed under its project license; the complete Node license file is included as runtime/NODE-LICENSE.txt.
5
+
6
+ Direct runtime dependencies:
7
+ - ws 8.x — MIT
8
+ - exceljs 4.4.0 — MIT
9
+ - jszip 3.x — MIT OR GPL-3.0-or-later
10
+ - pdf-parse 2.4.5 — MIT
11
+ - pdf-lib 1.17.1 — MIT
12
+
13
+ Security-pinned transitive dependency:
14
+ - uuid 11.1.1 — MIT (forced through npm overrides to avoid older-version security advisories)
15
+
16
+ Transitive dependencies remain subject to their respective license terms included in the distributed node_modules packages.
17
+ ExamediX Reach does not claim ownership of third-party open-source components.
package/bin/reach.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const VERSION="0.1.0";
9
+ const here=path.dirname(fileURLToPath(import.meta.url));
10
+ const agent=path.resolve(here,"..","dist","agent.js");
11
+ const command=(process.argv[2]||"remote").toLowerCase();
12
+ const root=process.env.REACH_ROOT || (
13
+ process.platform==="win32" && process.env.LOCALAPPDATA
14
+ ? path.join(process.env.LOCALAPPDATA,"ExamediX","ReachData")
15
+ : path.join(os.homedir(),".examedix-reach")
16
+ );
17
+
18
+ function openBrowser(url){
19
+ try{
20
+ if(process.platform==="win32"){
21
+ const p=spawn("cmd.exe",["/d","/c","start","",url],{detached:true,stdio:"ignore",windowsHide:true});
22
+ p.unref();
23
+ } else if(process.platform==="darwin"){
24
+ const p=spawn("open",[url],{detached:true,stdio:"ignore"}); p.unref();
25
+ } else {
26
+ const p=spawn("xdg-open",[url],{detached:true,stdio:"ignore"}); p.unref();
27
+ }
28
+ }catch{}
29
+ }
30
+
31
+ function printHelp(){
32
+ console.log(`ExamediX Reach ${VERSION}
33
+
34
+ Usage:
35
+ examedix-reach remote Connect this computer to Reach (default)
36
+ examedix-reach setup Create/show a pairing code, then exit
37
+ examedix-reach doctor Check local prerequisites and Reach service
38
+ examedix-reach --version Show version
39
+
40
+ Examples:
41
+ npx examedix-reach@latest remote
42
+ npx examedix-reach@latest setup
43
+
44
+ Local data:
45
+ ${root}
46
+
47
+ Reach:
48
+ https://examedix.ch/reach
49
+ `);
50
+ }
51
+
52
+ async function doctor(){
53
+ const result={};
54
+ result.node=process.version;
55
+ result.platform=`${process.platform} ${os.release()}`;
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",{signal:AbortSignal.timeout(10000)});
61
+ result.service=r.ok?"online":`http-${r.status}`;
62
+ }catch(e){result.service="unreachable"; result.error=e.message;}
63
+ console.log(JSON.stringify(result,null,2));
64
+ process.exit(result.agentFile&&result.service==="online"?0:1);
65
+ }
66
+
67
+ function runAgent(args,openOnPair=false){
68
+ if(process.platform!=="win32"){
69
+ console.error("ExamediX Reach 0.1.0 currently supports Windows only.");
70
+ process.exit(1);
71
+ }
72
+ fs.mkdirSync(root,{recursive:true});
73
+ const child=spawn(process.execPath,[agent,"--root",root,...args],{
74
+ stdio:["inherit","pipe","pipe"],
75
+ windowsHide:false
76
+ });
77
+ let opened=false;
78
+ child.stdout.on("data",buf=>{
79
+ const text=buf.toString();
80
+ process.stdout.write(text);
81
+ if(openOnPair&&!opened&&text.includes("PAIRING CODE:")){
82
+ opened=true;
83
+ console.log("\nOpening ExamediX Reach account/pairing page...");
84
+ openBrowser("https://examedix.ch/reach/signup");
85
+ }
86
+ });
87
+ child.stderr.on("data",buf=>process.stderr.write(buf));
88
+ const forward=sig=>{try{child.kill(sig);}catch{}};
89
+ process.on("SIGINT",()=>forward("SIGINT"));
90
+ process.on("SIGTERM",()=>forward("SIGTERM"));
91
+ child.on("exit",code=>process.exit(code??0));
92
+ }
93
+
94
+ if(command==="--version"||command==="-v"||command==="version"){
95
+ console.log(VERSION);
96
+ process.exit(0);
97
+ }
98
+ if(command==="--help"||command==="-h"||command==="help"){
99
+ printHelp();
100
+ process.exit(0);
101
+ }
102
+ if(command==="doctor"){
103
+ await doctor();
104
+ }
105
+ if(command==="setup"){
106
+ const first=!fs.existsSync(path.join(root,"device.json"));
107
+ runAgent(["--pair-only"],first);
108
+ } else if(command==="remote"){
109
+ const first=!fs.existsSync(path.join(root,"device.json"));
110
+ if(first){
111
+ console.log("First Reach connection: a pairing code will be created.");
112
+ }
113
+ runAgent([],first);
114
+ } else {
115
+ console.error(`Unknown command: ${command}\n`);
116
+ printHelp();
117
+ process.exit(2);
118
+ }
package/dist/agent.js ADDED
@@ -0,0 +1,357 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import https from "node:https";
7
+ import { spawn, execFile, spawnSync } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import WebSocket from "ws";
10
+ import ExcelJS from "exceljs";
11
+ import { PDFParse } from "pdf-parse";
12
+ import JSZip from "jszip";
13
+ import { PDFDocument, StandardFonts } from "pdf-lib";
14
+
15
+ const VERSION="0.1.0";
16
+ const PAIR_ONLY=process.argv.includes("--pair-only");
17
+ if(process.argv.includes("--version")){console.log("ExamediX Reach Agent "+VERSION);process.exit(0);}
18
+ if(process.argv.includes("--help")){console.log("ExamediX Reach Agent "+VERSION+"\nUsage: ReachAgent [--version] [--help] [--pair-only] [--root <path>]");process.exit(0);}
19
+ const execFileAsync=promisify(execFile);
20
+
21
+ function excelValue(v){
22
+ if(v==null)return "";
23
+ if(v instanceof Date)return v.toISOString();
24
+ if(typeof v!=="object")return v;
25
+ if(Object.prototype.hasOwnProperty.call(v,"result"))return excelValue(v.result);
26
+ if(Array.isArray(v.richText))return v.richText.map(x=>x.text||"").join("");
27
+ if(Object.prototype.hasOwnProperty.call(v,"text"))return String(v.text??"");
28
+ if(Object.prototype.hasOwnProperty.call(v,"hyperlink"))return String(v.text??v.hyperlink??"");
29
+ if(Object.prototype.hasOwnProperty.call(v,"error"))return String(v.error??"");
30
+ return JSON.stringify(v);
31
+ }
32
+ function colIndex(s){
33
+ let n=0;
34
+ for(const ch of String(s).toUpperCase())n=n*26+(ch.charCodeAt(0)-64);
35
+ return n-1;
36
+ }
37
+ function sliceA1(rows,range){
38
+ const m=String(range).trim().match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
39
+ if(!m)throw new Error("Excel range must use A1:D100 format.");
40
+ const c1=colIndex(m[1]),r1=Number(m[2])-1,c2=colIndex(m[3]),r2=Number(m[4])-1;
41
+ return rows.slice(r1,r2+1).map(row=>Array.from({length:c2-c1+1},(_,i)=>row[c1+i]??""));
42
+ }
43
+ function decodeXmlText(s){
44
+ return String(s)
45
+ .replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"')
46
+ .replace(/&apos;/g,"'").replace(/&amp;/g,"&")
47
+ .replace(/&#(\d+);/g,(_,n)=>String.fromCodePoint(Number(n)))
48
+ .replace(/&#x([0-9a-f]+);/gi,(_,n)=>String.fromCodePoint(parseInt(n,16)));
49
+ }
50
+ async function assertReadableFile(filePath,maxBytes,label="file"){
51
+ const st=await fsp.stat(filePath);
52
+ if(!st.isFile())throw new Error("Path is not a file.");
53
+ if(st.size>maxBytes)throw new Error(`${label} exceeds the local Reach safety limit (${Math.ceil(maxBytes/1024/1024)} MB).`);
54
+ return st;
55
+ }
56
+ async function loadSafeOfficeZip(filePath){
57
+ await assertReadableFile(filePath,config.maxFileReadBytes,"Office document");
58
+ const zip=await JSZip.loadAsync(await fsp.readFile(filePath));
59
+ const entries=Object.values(zip.files);
60
+ if(entries.length>config.maxOfficeEntries)throw new Error("Office document contains too many ZIP entries.");
61
+ let total=0;
62
+ for(const e of entries){
63
+ const n=Number(e?._data?.uncompressedSize||0);
64
+ if(Number.isFinite(n)){total+=n;if(total>config.maxOfficeUncompressedBytes)throw new Error("Office document expands beyond the local Reach safety limit.");}
65
+ }
66
+ return zip;
67
+ }
68
+ async function extractDocxText(filePath){
69
+ const zip=await loadSafeOfficeZip(filePath);
70
+ const entry=zip.file("word/document.xml");
71
+ if(!entry)throw new Error("Invalid DOCX: word/document.xml is missing.");
72
+ const docXmlSize=Number(entry?._data?.uncompressedSize||0);
73
+ if(docXmlSize>config.maxFileReadBytes)throw new Error("DOCX document.xml exceeds the local Reach safety limit.");
74
+ let xml=await entry.async("string");
75
+ xml=xml.replace(/<w:tab\b[^>]*\/>/gi,"\t")
76
+ .replace(/<w:br\b[^>]*\/>/gi,"\n")
77
+ .replace(/<\/w:tc>/gi,"\t")
78
+ .replace(/<\/w:tr>/gi,"\n")
79
+ .replace(/<\/w:p>/gi,"\n")
80
+ .replace(/<[^>]+>/g,"");
81
+ return decodeXmlText(xml).replace(/\t+\n/g,"\n").replace(/\n{3,}/g,"\n\n");
82
+ }
83
+
84
+ const rootIndex=process.argv.indexOf("--root");
85
+ const rootArg=rootIndex>=0&&process.argv[rootIndex+1]?process.argv[rootIndex+1]:"";
86
+ const ROOT=path.resolve(rootArg||process.env.REACH_ROOT||path.join(os.homedir(),".examedix-reach"));
87
+ const DEVICE_FILE=path.join(ROOT,"device.json");
88
+ const CONFIG_FILE=path.join(ROOT,"config.json");
89
+ const DEFAULT_CONFIG={
90
+ allowedDirectories:[os.homedir()],
91
+ fileReadLineLimit:1000,
92
+ fileWriteLineLimit:1000,
93
+ telemetryEnabled:false,
94
+ defaultShell:"powershell.exe",
95
+ allowArbitraryExecution:false,
96
+ maxFileReadBytes:67108864,
97
+ maxImageReadBytes:20971520,
98
+ maxEditFileBytes:33554432,
99
+ maxSearchFileBytes:268435456,
100
+ maxOfficeUncompressedBytes:268435456,
101
+ maxOfficeEntries:10000,
102
+ blockedCommands:["format","diskpart","bcdedit","cipher","takeown","shutdown","restart-computer","stop-computer","remove-computer","enable-bitlocker","disable-bitlocker","clear-tpm","initialize-disk","clear-disk"],
103
+ blockedCommandFragments:[".ssh","get-credential","cmdkey","vaultcmd","credentialmanager","get-secret","secretmanagement","get-clipboard","chrome\\user data","edge\\user data","mozilla\\firefox\\profiles",".aws\\credentials",".azure",".kube\\config",".git-credentials"],
104
+ restrictedDirectories:[".ssh",".gnupg",".aws",".azure",".kube","AppData\\Local\\Google\\Chrome\\User Data","AppData\\Local\\Microsoft\\Edge\\User Data","AppData\\Roaming\\Mozilla\\Firefox\\Profiles"],
105
+ restrictedFileNames:[".env",".npmrc",".pypirc",".netrc",".git-credentials","credentials","credentials.json","login data","id_rsa","id_ed25519","id_ecdsa","id_dsa"],
106
+ restrictedExtensions:[".pem",".key",".p12",".pfx",".kdbx"]
107
+ };
108
+ const sessions=new Map(), searches=new Map(), recent=[];
109
+ let config={...DEFAULT_CONFIG};
110
+
111
+ function loadConfig(){try{config={...DEFAULT_CONFIG,...JSON.parse(fs.readFileSync(CONFIG_FILE,"utf8"))};}catch{config={...DEFAULT_CONFIG};saveConfig();}}
112
+ function saveConfig(){fs.mkdirSync(ROOT,{recursive:true});fs.writeFileSync(CONFIG_FILE,JSON.stringify(config,null,2));}
113
+ loadConfig();
114
+
115
+ function log(tool,args,result,ok=true){recent.push({ts:new Date().toISOString(),tool,args:JSON.stringify(args??{}).slice(0,500),ok});if(recent.length>200)recent.shift();return result;}
116
+ function abs(p){return path.resolve(String(p));}
117
+ function canonicalPath(p){
118
+ const requested=abs(p);
119
+ let cur=requested;
120
+ const suffix=[];
121
+ while(!fs.existsSync(cur)){
122
+ const parent=path.dirname(cur);
123
+ if(parent===cur)break;
124
+ suffix.unshift(path.basename(cur));
125
+ cur=parent;
126
+ }
127
+ let real=cur;
128
+ try{real=fs.realpathSync.native(cur);}catch{}
129
+ return path.resolve(real,...suffix);
130
+ }
131
+ function allowed(p){
132
+ const a=canonicalPath(p).toLowerCase(), roots=config.allowedDirectories||[];
133
+ if(!roots.length)return true;
134
+ return roots.some(r=>{const rr=canonicalPath(r).toLowerCase();return a===rr||a.startsWith(rr+path.sep.toLowerCase());});
135
+ }
136
+ function restrictedRoots(){return (config.restrictedDirectories||[]).map(p=>canonicalPath(path.join(os.homedir(),p)));}
137
+ function restrictedPath(p){
138
+ const a=canonicalPath(p),low=a.toLowerCase(),base=path.basename(a).toLowerCase(),ext=path.extname(a).toLowerCase();
139
+ if(restrictedRoots().some(r=>{const x=r.toLowerCase();return low===x||low.startsWith(x+path.sep.toLowerCase());}))return true;
140
+ if((config.restrictedFileNames||[]).map(x=>x.toLowerCase()).includes(base))return true;
141
+ if((config.restrictedExtensions||[]).map(x=>x.toLowerCase()).includes(ext))return true;
142
+ return false;
143
+ }
144
+ function isAncestorOfRestricted(p){
145
+ const a=canonicalPath(p).toLowerCase();
146
+ return restrictedRoots().some(r=>{const x=r.toLowerCase();return x===a||x.startsWith(a+path.sep.toLowerCase());});
147
+ }
148
+ function ensureAllowed(p){if(!allowed(p))throw new Error(`Path not allowed: ${p}`);if(restrictedPath(p))throw new Error("Reach does not access credential stores, private keys, password vaults, or other restricted authentication data.");}
149
+ function blocked(command){
150
+ const low=String(command).toLowerCase();
151
+ const exact=(config.blockedCommands||[]).find(x=>new RegExp("(^|[\\s;&|])"+String(x).replace(/[.*+?^$(){}|[\\]\\\\]/g,"\\\\$&")+"([\\s;&|]|$)","i").test(low));
152
+ if(exact)return exact;
153
+ const fragment=(config.blockedCommandFragments||[]).find(x=>low.includes(String(x).toLowerCase()));
154
+ if(fragment)return fragment;
155
+ if(/(^|[\\s"'\\/=])\.env($|[\\s"'\\/])/i.test(low))return ".env";
156
+ return null;
157
+ }
158
+ function safeUserCommand(command){
159
+ const s=String(command||"").trim();
160
+ return /^whoami(?:\s+\/\w+)?$/i.test(s) ||
161
+ /^hostname$/i.test(s) ||
162
+ /^git\s+(status|diff|log|branch|show)(?:\s+[^;&|]*)?$/i.test(s);
163
+ }
164
+ function sliceLines(text,offset=0,length=config.fileReadLineLimit){
165
+ const lines=text.replace(/\r\n/g,"\n").split("\n");
166
+ if(offset<0)return lines.slice(Math.max(0,lines.length+offset)).join("\n");
167
+ return lines.slice(offset,offset+length).join("\n");
168
+ }
169
+ async function powershell(command,timeoutMs=30000,internal=false){
170
+ if(!internal&&!config.allowArbitraryExecution&&!safeUserCommand(command))return {ok:false,message:"Arbitrary command execution is disabled in the local Reach settings. Only approved diagnostic commands are available."};
171
+ const denied=blocked(command);
172
+ if(denied)return {ok:false,message:`Command blocked by ExamediX Reach safety policy: ${denied}`};
173
+ try{const {stdout,stderr}=await execFileAsync("powershell.exe",["-NoLogo","-NoProfile","-NonInteractive","-Command",command],{windowsHide:true,timeout:Math.min(Math.max(Number(timeoutMs)||30000,1000),120000),maxBuffer:16*1024*1024,encoding:"utf8"});return {ok:true,stdout:stdout??"",stderr:stderr??""};}
174
+ catch(e){return {ok:false,stdout:e?.stdout??"",stderr:e?.stderr??"",message:e?.message??String(e),code:e?.code??null};}
175
+ }
176
+ function shellArgs(shell,command){
177
+ const s=(shell||config.defaultShell||"powershell.exe").toLowerCase();
178
+ if(s.includes("powershell")||s.endsWith("pwsh.exe"))return ["-NoLogo","-NoProfile","-Command",command];
179
+ if(s.endsWith("cmd.exe")||s==="cmd")return ["/d","/s","/c",command];
180
+ return ["-lc",command];
181
+ }
182
+ function startProc(command,shell){
183
+ if(!config.allowArbitraryExecution&&!safeUserCommand(command))throw new Error("Arbitrary process execution is disabled in the local Reach settings. Only approved diagnostic commands are available.");
184
+ const denied=blocked(command);
185
+ if(denied)throw new Error(`Command blocked by ExamediX Reach safety policy: ${denied}`);
186
+ const sh=shell||config.defaultShell||"powershell.exe";
187
+ const child=spawn(sh,shellArgs(sh,command),{windowsHide:true,stdio:["pipe","pipe","pipe"]});
188
+ const sess={pid:child.pid,command,shell:sh,started:new Date().toISOString(),lines:[],partial:"",cursor:0,done:false,exitCode:null};
189
+ const push=buf=>{const text=sess.partial+buf.toString();const parts=text.split(/\r?\n/);sess.partial=parts.pop()??"";sess.lines.push(...parts);if(sess.lines.length>200000){const drop=sess.lines.length-150000;sess.lines.splice(0,drop);sess.cursor=Math.max(0,sess.cursor-drop);}};
190
+ child.stdout.on("data",push);child.stderr.on("data",push);
191
+ child.on("exit",code=>{if(sess.partial){sess.lines.push(sess.partial);sess.partial="";}sess.done=true;sess.exitCode=code;});
192
+ sess.child=child;sessions.set(child.pid,sess);return sess;
193
+ }
194
+ function readSess(sess,offset=0,length=config.fileReadLineLimit){
195
+ if(sess.partial){/* keep partial until line ends */}
196
+ let start;
197
+ if(offset===0){start=sess.cursor;sess.cursor=Math.min(sess.lines.length,start+length);}
198
+ else if(offset<0)start=Math.max(0,sess.lines.length+offset);
199
+ else start=offset;
200
+ const lines=sess.lines.slice(start,start+length);
201
+ return {pid:sess.pid,output:lines.join("\n"),start,returned:lines.length,total:sess.lines.length,done:sess.done,exitCode:sess.exitCode};
202
+ }
203
+ async function walk(root,fn,includeHidden=false){
204
+ const stack=[root];
205
+ while(stack.length){
206
+ const dir=stack.pop(); let ents=[]; try{ents=await fsp.readdir(dir,{withFileTypes:true});}catch{continue;}
207
+ for(const e of ents){if(!includeHidden&&e.name.startsWith("."))continue;if(e.isSymbolicLink())continue;const p=path.join(dir,e.name);if(!allowed(p)||restrictedPath(p))continue;if(e.isDirectory())stack.push(p);else if(e.isFile())await fn(p);}
208
+ }
209
+ }
210
+ function wildcardToRegExp(pat){return new RegExp("^"+pat.replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*").replace(/\?/g,".")+"$","i");}
211
+ async function runSearch(s){
212
+ try{
213
+ const root=abs(s.path);ensureAllowed(root);
214
+ const patt=s.literal?null:new RegExp(s.pattern,s.ignoreCase?"i":"");
215
+ const fileRe=s.filePattern?wildcardToRegExp(s.filePattern):null;
216
+ await walk(root,async p=>{
217
+ if(s.stopped||s.results.length>=s.maxResults)return;
218
+ const name=path.basename(p);
219
+ if(s.type==="files"){
220
+ const ok=s.literal?(s.ignoreCase?name.toLowerCase().includes(s.pattern.toLowerCase()):name.includes(s.pattern)):patt.test(name);
221
+ if(ok)s.results.push({path:p});
222
+ }else{
223
+ if(fileRe&&!fileRe.test(name))return;
224
+ let txt;try{const st=await fsp.stat(p);if(st.size>config.maxSearchFileBytes)return;txt=await fsp.readFile(p,"utf8");}catch{return;}
225
+ const lines=txt.split(/\r?\n/);
226
+ for(let i=0;i<lines.length&&s.results.length<s.maxResults;i++){
227
+ const line=lines[i],ok=s.literal?(s.ignoreCase?line.toLowerCase().includes(s.pattern.toLowerCase()):line.includes(s.pattern)):patt.test(line);
228
+ if(ok){s.results.push({path:p,line:i+1,text:line.slice(0,1000)});if(s.contextLines){/* compact V1: matching line only */}}
229
+ }
230
+ }
231
+ },s.includeHidden);
232
+ s.status=s.stopped?"STOPPED":"COMPLETED";
233
+ }catch(e){s.status="ERROR";s.error=e.message;}
234
+ }
235
+
236
+ async function dispatch(tool,args={}){
237
+ try{
238
+ if(tool==="system_info")return log(tool,args,await powershell("$mem=(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory; $os=[System.Environment]::OSVersion.VersionString; [pscustomobject]@{Computer=$env:COMPUTERNAME;User=$env:USERNAME;Windows=$os;RAM_GB=[math]::Round($mem/1GB,2);PowerShell=$PSVersionTable.PSVersion.ToString();CurrentDirectory=(Get-Location).Path}|ConvertTo-Json -Compress",30000,true));
239
+ if(tool==="run_powershell")return log(tool,args,await powershell(args.command,args.timeout_ms??30000,false));
240
+ if(tool==="read_file"){
241
+ const p=abs(args.path);ensureAllowed(p);const ext=path.extname(p).toLowerCase();const st=await fsp.stat(p);if(!st.isFile())throw new Error("Path is not a file.");
242
+ if([".png",".jpg",".jpeg",".gif",".webp"].includes(ext)){if(st.size>config.maxImageReadBytes)throw new Error("Image exceeds the local Reach base64 safety limit.");const b=await fsp.readFile(p);return log(tool,args,{ok:true,kind:"base64",mime:ext===".png"?"image/png":"image/"+ext.slice(1),data:b.toString("base64")});}
243
+ if([".xlsx",".xlsm"].includes(ext)){await loadSafeOfficeZip(p);const wb=new ExcelJS.Workbook();await wb.xlsx.readFile(p);const requested=args.sheet!=null?String(args.sheet):null;const ws=requested==null?wb.worksheets[0]:(/^\d+$/.test(requested)?wb.worksheets[Number(requested)]:wb.getWorksheet(requested));if(!ws)throw new Error("Sheet not found");const sn=ws.name;let arr=[];const cols=Math.max(ws.columnCount,1);ws.eachRow({includeEmpty:true},row=>{const vals=[];for(let c=1;c<=cols;c++)vals.push(excelValue(row.getCell(c).value));arr.push(vals);});if(args.range)arr=sliceA1(arr,args.range);if(args.offset!=null)arr=arr.slice(args.offset,args.offset+(args.length??config.fileReadLineLimit));return log(tool,args,{ok:true,sheet:sn,data:arr});}
244
+ if(ext===".xls")throw new Error("Legacy .xls is not supported by the secure Reach build. Convert it to .xlsx first.");
245
+ if(ext===".pdf"){if(st.size>config.maxFileReadBytes)throw new Error("PDF exceeds the local Reach safety limit.");const parser=new PDFParse({data:await fsp.readFile(p)});try{const data=await parser.getText();return log(tool,args,{ok:true,pages:data.total,text:sliceLines(data.text,args.offset??0,args.length??config.fileReadLineLimit)});}finally{await parser.destroy();}}
246
+ if(ext===".docx"){const text=await extractDocxText(p);return log(tool,args,{ok:true,text:sliceLines(text,args.offset??0,args.length??config.fileReadLineLimit)});}
247
+ if(st.size>config.maxFileReadBytes)throw new Error("Text file exceeds the local Reach direct-read safety limit; use Search or Power Mode streaming commands for very large files.");const txt=await fsp.readFile(p,"utf8");return log(tool,args,{ok:true,text:sliceLines(txt,args.offset??0,args.length??config.fileReadLineLimit)});
248
+ }
249
+ if(tool==="read_multiple_files"){const out=[];for(const p of args.paths||[]){try{out.push({path:p,result:await dispatch("read_file",{path:p})});}catch(e){out.push({path:p,error:e.message});}}return log(tool,args,{ok:true,files:out});}
250
+ if(tool==="write_file"){
251
+ const p=abs(args.path);ensureAllowed(p);await fsp.mkdir(path.dirname(p),{recursive:true});const ext=path.extname(p).toLowerCase();
252
+ if(ext===".xlsx"){const parsed=JSON.parse(args.content);const wb=new ExcelJS.Workbook();if(Array.isArray(parsed)){const ws=wb.addWorksheet("Sheet1");for(const row of parsed)ws.addRow(Array.isArray(row)?row:[row]);}else{for(const [name,rows] of Object.entries(parsed)){const ws=wb.addWorksheet(String(name).slice(0,31)||"Sheet");for(const row of rows)ws.addRow(Array.isArray(row)?row:[row]);}}await wb.xlsx.writeFile(p);return log(tool,args,{ok:true,path:p});}
253
+ if(ext===".xls"||ext===".xlsm")throw new Error("Reach writes .xlsx only; .xls/.xlsm rewriting is disabled to avoid format or macro loss.");
254
+ if(args.mode==="append")await fsp.appendFile(p,args.content,"utf8");else await fsp.writeFile(p,args.content,"utf8");return log(tool,args,{ok:true,path:p,bytes:Buffer.byteLength(args.content)});
255
+ }
256
+ if(tool==="write_pdf"){
257
+ const src=abs(args.path),out=abs(args.outputPath||args.path);ensureAllowed(src);ensureAllowed(out);await fsp.mkdir(path.dirname(out),{recursive:true});let doc;
258
+ if(Array.isArray(args.content)){
259
+ const st=await fsp.stat(src).catch(()=>null);if(!st)throw new Error("Source PDF not found");doc=await PDFDocument.load(await fsp.readFile(src));
260
+ for(const op of args.content){
261
+ if(op.type==="delete"){for(const idx of [...op.pageIndexes].sort((a,b)=>b-a))if(idx>=0&&idx<doc.getPageCount())doc.removePage(idx);}
262
+ else if(op.type==="insert"){
263
+ if(op.sourcePdfPath){const sp=abs(op.sourcePdfPath);ensureAllowed(sp);const sd=await PDFDocument.load(await fsp.readFile(sp));const pages=await doc.copyPages(sd,sd.getPageIndices());let at=Math.max(0,Math.min(op.pageIndex,doc.getPageCount()));for(const pg of pages)doc.insertPage(at++,pg);}
264
+ else{const font=await doc.embedFont(StandardFonts.Helvetica);const page=doc.insertPage(Math.max(0,Math.min(op.pageIndex,doc.getPageCount())));const txt=String(op.markdown||"").replace(/[#*_>-]/g,"");const lines=txt.match(/.{1,90}(?:\s|$)/g)||[""];let y=page.getHeight()-50;for(const line of lines){if(y<40)break;page.drawText(line.trim(),{x:50,y,size:11,font});y-=16;}}
265
+ }
266
+ }
267
+ }else{
268
+ doc=await PDFDocument.create();const font=await doc.embedFont(StandardFonts.Helvetica);const txt=String(args.content||"").replace(/[#*_>-]/g,"");const lines=txt.match(/.{1,90}(?:\s|$)/g)||[""];let page=doc.addPage(),y=page.getHeight()-50;for(const line of lines){if(y<40){page=doc.addPage();y=page.getHeight()-50;}page.drawText(line.trim(),{x:50,y,size:11,font});y-=16;}
269
+ }
270
+ await fsp.writeFile(out,await doc.save());return log(tool,args,{ok:true,path:out,pages:doc.getPageCount()});
271
+ }
272
+ if(tool==="create_directory"){const p=abs(args.path);ensureAllowed(p);await fsp.mkdir(p,{recursive:true});return log(tool,args,{ok:true,path:p});}
273
+ if(tool==="move_file"){const s=abs(args.source),d=abs(args.destination);ensureAllowed(s);ensureAllowed(d);const st=await fsp.stat(s);if(st.isDirectory()&&isAncestorOfRestricted(s))throw new Error("Reach will not move a directory that contains restricted authentication data.");await fsp.mkdir(path.dirname(d),{recursive:true});await fsp.rename(s,d);return log(tool,args,{ok:true,source:s,destination:d});}
274
+ if(tool==="copy_file"){const s=abs(args.source),d=abs(args.destination);ensureAllowed(s);ensureAllowed(d);const st=await fsp.stat(s);if(st.isDirectory()&&isAncestorOfRestricted(s))throw new Error("Reach will not recursively copy a directory that contains restricted authentication data.");await fsp.mkdir(path.dirname(d),{recursive:true});await fsp.cp(s,d,{recursive:true,force:!!args.overwrite});return log(tool,args,{ok:true,source:s,destination:d});}
275
+ if(tool==="delete_file"){const p=abs(args.path);ensureAllowed(p);const st=await fsp.stat(p).catch(()=>null);if(st?.isDirectory()&&isAncestorOfRestricted(p))throw new Error("Reach will not delete a directory that contains restricted authentication data.");await fsp.rm(p,{recursive:true,force:!!args.force});return log(tool,args,{ok:true,path:p});}
276
+ if(tool==="list_directory"){const root=abs(args.path);ensureAllowed(root);const depth=Math.max(1,Math.min(args.depth??2,20));const rows=[];async function rec(dir,d){let ents=await fsp.readdir(dir,{withFileTypes:true});for(const e of ents){if(e.isSymbolicLink())continue;const p=path.join(dir,e.name);if(!allowed(p)||restrictedPath(p))continue;rows.push({type:e.isDirectory()?"dir":"file",path:path.relative(root,p)||".",fullPath:p});if(e.isDirectory()&&d<depth)await rec(p,d+1);}}await rec(root,1);return log(tool,args,{ok:true,root,items:rows});}
277
+ if(tool==="get_file_info"){const p=abs(args.path);ensureAllowed(p);const st=await fsp.stat(p);const out={path:p,size:st.size,created:st.birthtime.toISOString(),modified:st.mtime.toISOString(),isDirectory:st.isDirectory(),isFile:st.isFile()};if(st.isFile()&&st.size<50*1024*1024){try{const t=await fsp.readFile(p,"utf8");out.lineCount=t.split(/\r?\n/).length;}catch{}}return log(tool,args,{ok:true,info:out});}
278
+ if(tool==="edit_block"){const p=abs(args.file_path);ensureAllowed(p);const ext=path.extname(p).toLowerCase();if([".docx",".xlsx",".xls",".xlsm",".pdf"].includes(ext))throw new Error("Binary Office/PDF files require their dedicated read/write tools; generic edit_block is disabled to prevent corruption.");const st=await fsp.stat(p);if(st.size>config.maxEditFileBytes)throw new Error("Text file exceeds the local Reach edit safety limit.");let txt=await fsp.readFile(p,"utf8");const old=String(args.old_string),neu=String(args.new_string);let count=0,pos=0;while((pos=txt.indexOf(old,pos))!==-1){count++;pos+=old.length;}const exp=args.expected_replacements??1;if(count!==exp)throw new Error(`Expected ${exp} replacements, found ${count}`);txt=txt.split(old).join(neu);await fsp.writeFile(p,txt,"utf8");return log(tool,args,{ok:true,replacements:count,path:p});}
279
+ if(tool==="start_process"){const s=startProc(args.command,args.shell);return log(tool,args,{ok:true,pid:s.pid,status:"RUNNING",command:s.command,shell:s.shell});}
280
+ if(tool==="read_process_output"){const s=sessions.get(Number(args.pid));if(!s)throw new Error("Session not found");return log(tool,args,{ok:true,...readSess(s,args.offset??0,args.length??config.fileReadLineLimit)});}
281
+ if(tool==="interact_with_process"){const s=sessions.get(Number(args.pid));if(!s)throw new Error("Session not found");s.child.stdin.write(String(args.input)+(String(args.input).endsWith("\n")?"":"\n"));await new Promise(r=>setTimeout(r,Math.min(args.timeout_ms??500,3000)));return log(tool,args,{ok:true,...readSess(s,0,args.length??config.fileReadLineLimit)});}
282
+ if(tool==="force_terminate"){const s=sessions.get(Number(args.pid));if(s&&!s.done)s.child.kill("SIGKILL");return log(tool,args,{ok:true,pid:Number(args.pid)});}
283
+ if(tool==="list_sessions")return log(tool,args,{ok:true,sessions:[...sessions.values()].map(s=>({pid:s.pid,command:s.command,shell:s.shell,started:s.started,done:s.done,exitCode:s.exitCode,lines:s.lines.length}))});
284
+ if(tool==="list_processes"){const r=await powershell("Get-Process | Select-Object Id,ProcessName,CPU,WorkingSet64,Path | ConvertTo-Json -Depth 2 -Compress",30000,true);return log(tool,args,r);}
285
+ if(tool==="kill_process"){const r=await powershell(`Stop-Process -Id ${Number(args.pid)} -Force -ErrorAction Stop; 'OK'`,30000,true);return log(tool,args,r);}
286
+ if(tool==="start_search"){const id="search_"+crypto.randomUUID();const s={id,path:args.path,pattern:args.pattern,type:args.searchType??"files",filePattern:args.filePattern,ignoreCase:args.ignoreCase!==false,literal:!!args.literalSearch,includeHidden:!!args.includeHidden,contextLines:args.contextLines??0,maxResults:Math.min(args.maxResults??1000,100000),results:[],status:"RUNNING",stopped:false,created:new Date().toISOString()};searches.set(id,s);runSearch(s);return log(tool,args,{ok:true,sessionId:id,status:s.status});}
287
+ if(tool==="get_more_search_results"){const s=searches.get(args.sessionId);if(!s)throw new Error("Search not found");const off=args.offset??0,len=args.length??100;const start=off<0?Math.max(0,s.results.length+off):off;return log(tool,args,{ok:true,status:s.status,error:s.error??null,total:s.results.length,results:s.results.slice(start,start+len)});}
288
+ if(tool==="stop_search"){const s=searches.get(args.sessionId);if(!s)throw new Error("Search not found");s.stopped=true;s.status="STOPPING";return log(tool,args,{ok:true,sessionId:s.id});}
289
+ if(tool==="list_searches")return log(tool,args,{ok:true,searches:[...searches.values()].map(s=>({id:s.id,status:s.status,path:s.path,pattern:s.pattern,type:s.type,total:s.results.length,created:s.created}))});
290
+
291
+ throw new Error(`Unknown tool: ${tool}`);
292
+ }catch(e){return log(tool,args,{ok:false,message:e?.message??String(e)},false);}
293
+ }
294
+
295
+ function postJson(url,obj){
296
+ return new Promise((resolve,reject)=>{
297
+ const u=new URL(url),data=Buffer.from(JSON.stringify(obj));
298
+ const r=https.request({hostname:u.hostname,port:443,path:u.pathname+u.search,method:"POST",headers:{"content-type":"application/json","content-length":data.length}},res=>{
299
+ const chunks=[];res.on("data",c=>chunks.push(c));res.on("end",()=>{try{const x=JSON.parse(Buffer.concat(chunks).toString("utf8"));if(res.statusCode>=200&&res.statusCode<300)resolve(x);else reject(new Error(x.error||("HTTP "+res.statusCode)));}catch(e){reject(e);}});
300
+ });r.on("error",reject);r.write(data);r.end();
301
+ });
302
+ }
303
+ function dpapiProtect(secret){
304
+ const script="Add-Type -AssemblyName System.Security;$s=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($s);$e=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);[Convert]::ToBase64String($e)";
305
+ const r=spawnSync("powershell.exe",["-NoLogo","-NoProfile","-NonInteractive","-Command",script],{input:String(secret),encoding:"utf8",windowsHide:true,maxBuffer:1024*1024});
306
+ if(r.status!==0)throw new Error("Could not protect local device credential with Windows DPAPI.");
307
+ return String(r.stdout).trim();
308
+ }
309
+ function dpapiUnprotect(ciphertext){
310
+ const script="Add-Type -AssemblyName System.Security;$s=[Console]::In.ReadToEnd();$e=[Convert]::FromBase64String($s.Trim());$b=[System.Security.Cryptography.ProtectedData]::Unprotect($e,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);[Text.Encoding]::UTF8.GetString($b)";
311
+ const r=spawnSync("powershell.exe",["-NoLogo","-NoProfile","-NonInteractive","-Command",script],{input:String(ciphertext),encoding:"utf8",windowsHide:true,maxBuffer:1024*1024});
312
+ if(r.status!==0)throw new Error("Could not decrypt the local Reach device credential for this Windows user.");
313
+ return String(r.stdout).trim();
314
+ }
315
+ function persistDevice(d){
316
+ const stored={...d};
317
+ delete stored.agent_token;
318
+ fs.writeFileSync(DEVICE_FILE,JSON.stringify(stored,null,2));
319
+ }
320
+ async function loadOrCreateDevice(){
321
+ if(fs.existsSync(DEVICE_FILE)){
322
+ let d;
323
+ try{d=JSON.parse(fs.readFileSync(DEVICE_FILE,"utf8"));}catch{throw new Error("Reach device identity file is invalid JSON.");}
324
+ if(d.agent_token_dpapi){
325
+ return {...d,agent_token:dpapiUnprotect(d.agent_token_dpapi)};
326
+ }
327
+ if(d.agent_token){
328
+ const token=d.agent_token;
329
+ const migrated={...d,agent_token_dpapi:dpapiProtect(token)};
330
+ delete migrated.agent_token;
331
+ persistDevice(migrated);
332
+ return {...migrated,agent_token:token};
333
+ }
334
+ throw new Error("Reach device identity is missing its local device credential.");
335
+ }
336
+ const agent_token=crypto.randomBytes(48).toString("base64url");
337
+ const pair=await postJson("https://examedix.ch/reach/api/agent/pair/start",{name:process.env.REACH_DEVICE_NAME||os.hostname(),platform:"Windows "+os.release(),version:VERSION,agent_token});
338
+ const d={device_id:pair.device_id,agent_token_dpapi:dpapiProtect(agent_token),pairing_code:pair.pairing_code,pairing_expires_at:Date.now()+pair.expires_in*1000};
339
+ persistDevice(d);
340
+ console.log("PAIRING CODE:",pair.pairing_code);
341
+ return {...d,agent_token};
342
+ }
343
+ const device=await loadOrCreateDevice();
344
+ console.log("ExamediX Reach device:",device.device_id);
345
+ if(device.pairing_code&&device.pairing_expires_at>Date.now())console.log("PAIRING CODE:",device.pairing_code);
346
+ if(PAIR_ONLY)process.exit(0);
347
+
348
+ let retry=1000;
349
+ function connect(){
350
+ const url="wss://examedix.ch/reach/agent?device_id="+encodeURIComponent(device.device_id);
351
+ const ws=new WebSocket(url,{headers:{Authorization:"Bearer "+device.agent_token,"X-Reach-Agent-Version":VERSION},handshakeTimeout:15000});
352
+ ws.on("open",()=>{retry=1000;console.log("ExamediX Reach agent connected");});
353
+ ws.on("message",async data=>{let msg;try{msg=JSON.parse(data.toString());}catch{return;}if(!msg?.id||!msg?.tool)return;const result=await dispatch(msg.tool,msg.args||{});if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({id:msg.id,result}));});
354
+ ws.on("close",()=>{setTimeout(connect,retry);retry=Math.min(retry*2,30000);});
355
+ ws.on("error",e=>console.error("ExamediX Reach connection:",e.message));
356
+ }
357
+ connect();
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
- {
2
- "name": "examedix-reach",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "examedix-reach",
3
+ "version": "0.1.0",
4
+ "description": "Securely connect ChatGPT to Windows computers you own or are authorized to administer.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "author": "ExamediX Technologies",
8
+ "homepage": "https://examedix.ch/reach",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/examedix/examedix-reach.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://examedix.ch/reach/support"
15
+ },
16
+ "engines": {
17
+ "node": ">=20.0.0"
18
+ },
19
+ "bin": {
20
+ "examedix-reach": "bin/reach.js",
21
+ "reach": "bin/reach.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "dist",
26
+ "README.md",
27
+ "THIRD-PARTY-NOTICES.txt",
28
+ "DISCLOSURE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "keywords": [
34
+ "chatgpt",
35
+ "mcp",
36
+ "windows",
37
+ "remote-work",
38
+ "file-system",
39
+ "process-management",
40
+ "workstation"
41
+ ],
42
+ "dependencies": {
43
+ "exceljs": "^4.4.0",
44
+ "jszip": "^3.10.1",
45
+ "pdf-lib": "^1.17.1",
46
+ "pdf-parse": "2.4.5",
47
+ "ws": "^8.18.3"
48
+ },
49
+ "overrides": {
50
+ "uuid": "11.1.1"
51
+ },
52
+ "os": [
53
+ "win32"
54
+ ],
55
+ "contentPolicy": {
56
+ "class": "dual-use"
57
+ }
58
+ }