zback 1.0.0 → 1.3.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,14 @@
1
+ ZBack is an opt-in remote shell administration tool for devices owned or explicitly authorized by the operator. It supports sending a shell command to a local Node.js agent through a WebSocket relay using a private, high-entropy session capability and the Model Context Protocol (MCP).
2
+
3
+ Security-sensitive functionality:
4
+ - A user manually starts the ZBack command and approves remote shell administration on the first run. The decision is stored in a local user-scoped file; subsequent commands do not require repeated prompts.
5
+ - A background Node.js worker may persist after the invoking terminal detaches. It can be stopped with `zback --stop`.
6
+ - Remote shell commands execute with the operating-system permissions of the account that launched the agent.
7
+ - The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
8
+ - The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
9
+ - A randomly generated credential stored with restrictive file permissions stabilizes the session identifier across reconnects. The identifier is a bearer credential and must be kept secret.
10
+ - The operator can inspect activity with `zback`, `zback --status`, `zback --logs`, and rotate the credential with `zback --reset-key` after stopping the agent.
11
+
12
+ The package has no install-time scripts, no hidden persistence or system service installation, no credential collection, no code obfuscation, and no automatic execution on installation. Remote shell access is a powerful capability and should only be enabled on systems the operator is authorized to manage.
13
+
14
+ The separately deployed ZBack backend should be exposed via HTTPS/WSS, behind a TLS-terminating reverse proxy. Public access does not imply authorization to use another person's session. Access is controlled by possession of the private session ID.
package/README.md CHANGED
@@ -1,31 +1,69 @@
1
- # zback (CLI global)
1
+ # ZBack CLI
2
2
 
3
- Agente CLI para **Node.js 22 o superior**, sin dependencias npm externas. Se conecta a tu backend por WebSocket cifrado, enseña un ID temporal y solicita **autorización presencial para cada comando**.
3
+ ZBack is a deliberately opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
4
4
 
5
- ## Instalar
5
+ > **Security:** ZBack grants remote shell access with your local user privileges to anyone who possesses its private session ID. Treat that ID as a password. Do not publish it, paste it in issue reports, or share screenshots containing it. Do not run ZBack as root or as an account with unnecessary permissions.
6
6
 
7
- ```bash
8
- npm install -g .
9
- zback --help
7
+ ## Install and start
8
+
9
+ ```sh
10
+ npm install -g zback@1.2.0
10
11
  zback
11
12
  ```
12
13
 
13
- Extrae este ZIP antes de ejecutar `npm install -g .` dentro de su directorio.
14
+ On the first run, ZBack explains the remote-shell capability and asks for authorization once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
15
+
16
+ The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
17
+
18
+ ```sh
19
+ zback --url wss://your-domain.example/ws
20
+ ```
21
+
22
+ For development only:
23
+
24
+ ```sh
25
+ zback --url ws://127.0.0.1:8787/ws
26
+ ```
27
+
28
+ The client and backend must both be **version 1.2.0** because the connection handshake changed. The backend must be deployed before a remote client can connect.
29
+
30
+ ## Terminal and process lifecycle
14
31
 
15
- Opciones:
32
+ | Command | Behavior |
33
+ | --- | --- |
34
+ | `zback` | Start if needed and attach the terminal to a live event display |
35
+ | `zback --background` | Start the agent without keeping a live terminal display |
36
+ | `zback --status` | Show connection status, process ID and private session ID |
37
+ | `zback --logs` | Display recent connection and command activity |
38
+ | `zback --follow` | Attach to live events from the existing background agent |
39
+ | `zback --stop` | Stop the background agent |
40
+ | `zback --reset-key` | Change the device identity after the agent is stopped |
16
41
 
17
- ```bash
18
- zback --url wss://zb.soymaycol.icu/ws
19
- zback --cwd /ruta/a/proyecto --timeout 15000 --max-output 32768
20
- zback --no-reconnect
42
+ `Ctrl+C` while viewing events **detaches the display**, but the agent stays running. Use `zback --stop` to disconnect. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
43
+
44
+ After a device restart, ZBack does **not** silently install an autostart service. Launch it again when needed. Android/Termux process and battery restrictions can still terminate background tasks; a Node.js program cannot override the operating system's lifecycle.
45
+
46
+ ## Configuration
47
+
48
+ ```sh
49
+ zback --cwd ./workspace --timeout 300000 --max-output 524288
50
+ zback --url wss://your-domain.example/ws --background
21
51
  ```
22
52
 
23
- Para pruebas locales: `zback --url ws://127.0.0.1:8787/ws`.
53
+ The client validates TLS on remote connections. Only loopback addresses are allowed for unencrypted `ws://`. Server resource protection and command timeouts are operational safety controls, not per-user paid quotas.
54
+
55
+ Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback` on Windows. To use a custom location, set `ZBACK_STATE_DIR`. `agent.key` and the local state files must not be shared. Use `zback --reset-key` to invalidate a previously disclosed session ID.
56
+
57
+ ## MCP
24
58
 
25
- - Escribe `ACEPTO` para iniciar la sesión.
26
- - Verás un **ID nuevo por cada conexión**. No es una contraseña creada por el usuario, pero funciona como credencial temporal: mantenlo privado.
27
- - Cuando el MCP pida ejecutar un comando, verás su contenido completo y tendrás que escribir `s` para autorizarlo. Pulsar Enter, responder `n` o agotar el tiempo lo rechaza.
28
- - Ctrl+C termina la conexión; desconectarse invalida el ID.
29
- - Conexiones públicas `ws://` sin TLS están bloqueadas.
59
+ The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. The client prints a **private** session ID for passing to these tools. Only grant your own session ID to trusted applications. MCP clients, LLMs and tools that receive the ID can perform any command your operating-system account can perform.
30
60
 
31
- **Advertencia:** revisar antes de aprobar es fundamental: cualquier comando autorizado se ejecuta con los permisos de tu usuario. El backend no hace que un comando sea confiable.
61
+ ## Security disclosure and review
62
+
63
+ The npm package declares its remote administration capabilities as dual-use software in `contentPolicy` and the root `DISCLOSURE` file. It does not attempt to conceal shell execution, evade security scanning or override npm's decisions. Review the source before running, and follow npm's official manual appeal process if a publication is flagged.
64
+
65
+ ## Tests
66
+
67
+ ```sh
68
+ npm test
69
+ ```
package/bin/zback.js CHANGED
@@ -1,175 +1,219 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync, realpathSync, statSync } from 'node:fs';
3
+ import { closeSync, existsSync, openSync, readSync, realpathSync, statSync, watchFile, unwatchFile } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { stdin, stdout } from 'node:process';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { connectAgent, execute } from '../src/agent.js';
9
+ import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
8
10
 
11
+ const ENTRY = fileURLToPath(import.meta.url);
12
+ const VERSION = '1.2.0';
9
13
  const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
10
14
 
11
15
  function help() {
12
- console.log(`ZBack 1.0.0 - WebSocket agent (Node.js >=22)
16
+ console.log(`ZBack ${VERSION} | Authorized remote shell administration
13
17
 
14
- Uso: zback [opciones]
18
+ Usage:
19
+ zback Start the agent and display live events
20
+ zback --background Start without attaching the terminal
21
+ zback --status Show agent status, PID and private session ID
22
+ zback --logs Show recent agent events
23
+ zback --follow Follow live events from a running agent
24
+ zback --stop Stop the agent
25
+ zback --reset-key Rotate local agent identity while stopped
15
26
 
16
- --url <wss://.../ws> WebSocket de destino (default: ${DEFAULT_URL})
17
- --cwd <directorio> Directorio desde el cual ejecutar comandos
18
- --timeout <ms> Máximo tiempo de ejecución por comando (1000-300000)
19
- --max-output <bytes> Límite combinado de stdout+stderr (1024-262144)
20
- --no-reconnect Desactivar reconexión automática
21
- --help Mostrar esta ayuda
27
+ Options:
28
+ --url <wss://.../ws> Server endpoint (default: ${DEFAULT_URL})
29
+ --cwd <directory> Working directory for commands
30
+ --timeout <ms> Command timeout (1000-3600000)
31
+ --max-output <bytes> Captured output (1024-786432)
32
+ --no-reconnect Stop after a connection is lost
33
+ --help Display this help
22
34
 
23
- Se solicita permiso al inicio y cada vez que un comando remoto llega.
24
- No hay API keys ni ejecución sin confirmación.`);
35
+ ZBack runs shell commands with your account privileges. Initial interactive
36
+ consent is required once, not for every command. Keep the session ID private.
37
+ Ctrl+C detaches the live display; the agent continues running.
38
+ Use --stop to actually disconnect the agent.`);
25
39
  }
26
40
 
27
41
  export function parseArgs(args) {
28
- const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 30000, maxOutput: 65536, reconnect: true };
29
- const set = new Set(['--url', '--cwd', '--timeout', '--max-output']);
42
+ const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 120000, maxOutput: 262144, reconnect: true, background: false };
43
+ const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
44
+ let action = 'start';
30
45
  for (let i = 0; i < args.length; i++) {
31
46
  const flag = args[i];
32
47
  if (flag === '--help' || flag === '-h') return { help: true };
48
+ if (flag === '--worker') return { worker: true };
49
+ if (['--status', '--stop', '--logs', '--follow', '--reset-key'].includes(flag)) {
50
+ if (action !== 'start') throw new Error('Choose only one action');
51
+ action = flag.slice(2);
52
+ continue;
53
+ }
54
+ if (flag === '--background' || flag === '--detach') { options.background = true; continue; }
33
55
  if (flag === '--no-reconnect') { options.reconnect = false; continue; }
34
- if (!set.has(flag) || i + 1 >= args.length) throw new Error(`Argumento desconocido o incompleto: ${flag}`);
56
+ if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
35
57
  const value = args[++i];
36
58
  if (flag === '--url') options.url = value;
37
59
  if (flag === '--cwd') options.cwd = resolve(value);
38
60
  if (flag === '--timeout') options.timeout = Number(value);
39
61
  if (flag === '--max-output') options.maxOutput = Number(value);
40
62
  }
41
- if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 300000) throw new Error('--timeout debe estar entre 1000 y 300000 ms');
42
- if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 262144) throw new Error('--max-output debe estar entre 1024 y 262144 bytes');
43
- if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd debe ser un directorio existente');
63
+ if (action !== 'start') return { action };
64
+ if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 3600000) throw new Error('--timeout must be between 1000 and 3600000 ms');
65
+ if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 786432) throw new Error('--max-output must be between 1024 and 786432 bytes');
66
+ if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
44
67
  const url = new URL(options.url);
45
- if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
46
- throw new Error('Solo se permite wss:// público o ws:// local. Usa TLS para conexiones remotas.');
47
- }
48
- if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('URL inválida: usa una dirección WebSocket terminada en /ws sin credenciales ni parámetros');
49
- return options;
68
+ if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new Error('Use wss:// for remote connections or ws:// on localhost');
69
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected an endpoint ending in /ws without credentials in the URL');
70
+ return { ...options, action };
50
71
  }
51
72
 
52
- export function execute(command, cwd, timeoutMs, maxOutput) {
53
- return new Promise(resolveResult => {
54
- let finished = false, timedOut = false, truncated = false;
55
- const chunks = { stdout: [], stderr: [] };
56
- let collected = 0;
57
- let child;
58
- try {
59
- child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
60
- } catch (err) {
61
- resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
62
- return;
63
- }
64
- const add = (kind, chunk) => {
65
- const remaining = maxOutput - collected;
66
- if (remaining <= 0) { truncated = true; return; }
67
- if (chunk.length > remaining) truncated = true;
68
- const part = chunk.subarray(0, remaining);
69
- collected += part.length;
70
- chunks[kind].push(part);
71
- };
72
- child.stdout.on('data', b => add('stdout', b));
73
- child.stderr.on('data', b => add('stderr', b));
74
- const timer = setTimeout(() => {
75
- timedOut = true;
73
+ async function confirmOnce() {
74
+ if (hasConsent()) return true;
75
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
76
+ console.log('ZBack permits remote shell commands through a private session credential.');
77
+ console.log('These commands run as your current user. Do not share your session ID.');
78
+ const rl = createInterface({ input: stdin, output: stdout });
79
+ try {
80
+ const answer = await rl.question('Authorize remote shell administration on this device? [y/N] ');
81
+ if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
82
+ saveJson(consentFile, { approved: true, at: new Date().toISOString() });
83
+ return true;
84
+ } finally { rl.close(); }
85
+ }
86
+
87
+ async function worker() {
88
+ if (!hasConsent()) throw new Error('Shell access is not authorized. Run zback interactively first');
89
+ const settings = readJson(settingsFile);
90
+ if (!settings || !settings.url || !settings.cwd) throw new Error('Missing agent settings');
91
+ const started = process.env.ZBACK_WORKER_NONCE;
92
+ if (!started) throw new Error('Worker must be launched through zback');
93
+ const key = agentKey();
94
+ const mark = data => {
95
+ const previous = readJson(stateFile);
96
+ if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
97
+ saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
98
+ };
99
+ mark({ status: 'connecting', id: null });
100
+ try { await connectAgent(settings, mark, key); }
101
+ finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null }); }
102
+ }
103
+
104
+ function showStatus() {
105
+ const state = activeSession();
106
+ if (!state) { console.log('Status: stopped'); return false; }
107
+ console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}\nUpdated: ${state.updated || 'unknown'}`);
108
+ return true;
109
+ }
110
+
111
+ function recentLog() {
112
+ if (!existsSync(logFile)) { console.log('No agent logs yet'); return; }
113
+ const fd = openSync(logFile, 'r');
114
+ try {
115
+ const size = statSync(logFile).size;
116
+ const readSize = Math.min(size, 12000);
117
+ const buffer = Buffer.alloc(readSize);
118
+ readSync(fd, buffer, 0, readSize, size - readSize);
119
+ console.log(buffer.toString('utf8').replace(/^.*?\n/, size > readSize ? '' : '$&').trimEnd());
120
+ } finally { closeSync(fd); }
121
+ }
122
+
123
+ async function follow() {
124
+ if (!stdin.isTTY || !stdout.isTTY) return;
125
+ console.log('Live agent events. Ctrl+C detaches; the agent stays active.');
126
+ recentLog();
127
+ let position = existsSync(logFile) ? statSync(logFile).size : 0;
128
+ await new Promise(resolve => {
129
+ const readNew = () => {
76
130
  try {
77
- if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
78
- else process.kill(-child.pid, 'SIGKILL');
79
- } catch { try { child.kill('SIGKILL'); } catch {} }
80
- }, timeoutMs);
81
- const finish = (code, message = '') => {
82
- if (finished) return;
83
- finished = true;
84
- clearTimeout(timer);
85
- resolveResult({ ok: code === 0 && !timedOut, code: Number.isInteger(code) ? code : null,
86
- stdout: Buffer.concat(chunks.stdout).toString('utf8'),
87
- stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
88
- truncated, timedOut });
131
+ if (!existsSync(logFile)) return;
132
+ const size = statSync(logFile).size;
133
+ if (size < position) position = 0;
134
+ if (size === position) return;
135
+ const fd = openSync(logFile, 'r');
136
+ try {
137
+ const bytes = Math.min(size - position, 512 * 1024);
138
+ const buffer = Buffer.alloc(bytes);
139
+ readSync(fd, buffer, 0, bytes, position);
140
+ position += bytes;
141
+ stdout.write(buffer.toString('utf8'));
142
+ } finally { closeSync(fd); }
143
+ } catch (error) { console.error(`Log monitor: ${error.message}`); }
89
144
  };
90
- child.once('error', error => finish(null, `\n${error.message}`));
91
- child.once('close', code => finish(code));
145
+ const stop = () => { unwatchFile(logFile, readNew); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); console.log('\nDetached. Agent continues in the background.'); resolve(); };
146
+ watchFile(logFile, { interval: 400 }, readNew);
147
+ process.once('SIGINT', stop);
148
+ process.once('SIGTERM', stop);
149
+ readNew();
92
150
  });
93
151
  }
94
152
 
95
- export async function main(args = process.argv.slice(2)) {
96
- let options;
97
- try { options = parseArgs(args); }
98
- catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 2; return; }
99
- if (options.help) return help();
100
- if (!stdin.isTTY || !stdout.isTTY) {
101
- console.error('Error: se requiere una terminal interactiva para confirmar comandos.');
102
- process.exitCode = 2; return;
153
+ async function start(options) {
154
+ const existing = activeSession();
155
+ if (existing) {
156
+ console.log(existing.id ? `Session ID: ${existing.id}` : `ZBack is ${existing.status} (PID ${existing.pid}).`);
157
+ if (!options.background) await follow();
158
+ return;
103
159
  }
104
- console.log('\nZBack - acceso remoto a tu shell');
105
- console.log('ADVERTENCIA: ejecutar comandos ajenos puede borrar archivos, modificar el sistema o comprometer información.');
106
- console.log('Conéctate solo a tu propio servidor. Revisa cada comando antes de aprobarlo.');
107
- console.log(`Servidor: ${options.url}`);
108
- console.log(`Directorio: ${options.cwd}`);
109
- const rl = createInterface({ input: stdin, output: stdout });
110
- const consent = await rl.question('Escribe ACEPTO para habilitar solicitudes remotas: ');
111
- if (consent.trim() !== 'ACEPTO') { console.log('Cancelado.'); rl.close(); return; }
112
-
113
- let ws, active = false, stopped = false, retries = 0;
114
- let queue = Promise.resolve();
115
- const wait = ms => new Promise(resolveWait => setTimeout(resolveWait, ms));
116
- const shutdown = () => { stopped = true; try { ws?.close(); } catch {} rl.close(); };
117
- process.once('SIGINT', shutdown);
118
- process.once('SIGTERM', shutdown);
119
-
120
- while (!stopped) {
121
- try {
122
- ws = new WebSocket(options.url);
123
- await new Promise((accept, reject) => {
124
- ws.addEventListener('open', accept, { once: true });
125
- ws.addEventListener('error', () => reject(new Error('Conexión fallida')), { once: true });
126
- });
127
- active = true;
128
- retries = 0;
129
- console.log('\nConectado. Esperando solicitudes...');
130
- const closed = new Promise(resolveClosed => ws.addEventListener('close', resolveClosed, { once: true }));
131
- ws.addEventListener('message', event => {
132
- let message;
133
- try { message = JSON.parse(event.data); } catch { return; }
134
- if (message.type === 'welcome') {
135
- console.log(`\nID temporal: ${message.id}`);
136
- console.log('Comparte el ID solo con el cliente MCP autorizado. No se mostrará en el backend.');
137
- }
138
- if (message.type === 'command' && typeof message.requestId === 'string') {
139
- queue = queue.then(async () => {
140
- const command = message.command;
141
- if (typeof command !== 'string' || !command.trim() || command.length > 1000 || command.includes('\0')) return;
142
- console.log(`\nSolicitud de ejecución recibida:\n ${JSON.stringify(command)}`);
143
- let approval;
144
- try {
145
- approval = await rl.question('¿Autorizar este comando? [s/N]: ', { signal: AbortSignal.timeout(Math.min(message.approvalTimeoutMs ?? 60000, 300000)) });
146
- } catch { approval = ''; }
147
- let result;
148
- if (approval.trim().toLowerCase() !== 's') {
149
- result = { ok: false, rejected: true, code: null, stdout: '', stderr: 'Rechazado por la persona conectada.' };
150
- console.log('Comando rechazado.');
151
- } else {
152
- const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) ? message.timeoutMs : options.timeout);
153
- console.log('Ejecutando...');
154
- result = await execute(command, options.cwd, timeout, options.maxOutput);
155
- console.log(result.ok ? 'Comando completado.' : 'Comando terminado con error o límite.');
156
- }
157
- if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
158
- }).catch(error => console.error(`Error al procesar solicitud: ${error.message}`));
159
- }
160
- });
161
- await closed;
162
- console.log('\nDesconectado. El ID anterior ya no es válido.');
163
- } catch (error) { console.error(`Conexión: ${error.message}`); }
164
- active = false;
165
- if (!options.reconnect || stopped) break;
166
- const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
167
- console.log(`Reconectando en ${Math.ceil(delay / 1000)} s...`);
168
- await wait(delay);
160
+ if (!(await confirmOnce())) { console.log('Authorization declined'); return; }
161
+ initStateDir();
162
+ agentKey();
163
+ saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
164
+ const fd = openSync(logFile, 'a', 0o600);
165
+ const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
166
+ let child;
167
+ try {
168
+ child = spawn(process.execPath, [ENTRY, '--worker'], {
169
+ detached: true, windowsHide: true,
170
+ stdio: ['ignore', fd, fd],
171
+ env: { ...process.env, ZBACK_WORKER_NONCE: started }
172
+ });
173
+ child.unref();
174
+ } finally { closeSync(fd); }
175
+ if (!child?.pid) throw new Error('Unable to start background agent');
176
+ let connected = false;
177
+ for (let i = 0; i < 60; i++) {
178
+ await new Promise(resolve => setTimeout(resolve, 100));
179
+ const state = readJson(stateFile);
180
+ if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
181
+ console.log(`Session ID: ${state.id}`);
182
+ connected = true;
183
+ break;
184
+ }
185
+ if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(`Agent exited unexpectedly. Inspect logs with zback --logs`);
169
186
  }
170
- shutdown();
187
+ if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; use zback --status for current state.`);
188
+ if (!options.background) await follow();
189
+ }
190
+
191
+ function stop() {
192
+ const state = activeSession();
193
+ if (!state) { console.log('Agent is already stopped'); return; }
194
+ process.kill(state.pid, 'SIGTERM');
195
+ console.log(`Stop requested for agent PID ${state.pid}`);
196
+ }
197
+
198
+ function rotate() {
199
+ if (activeSession()) throw new Error('Stop the agent using zback --stop before rotating its credential');
200
+ resetKey();
201
+ console.log('Agent credential rotated. A new session ID will be assigned when it connects.');
202
+ }
203
+
204
+ export { execute };
205
+ export async function main(args = process.argv.slice(2)) {
206
+ try {
207
+ const options = parseArgs(args);
208
+ if (options.help) return help();
209
+ if (options.worker) return worker();
210
+ if (options.action === 'status') return showStatus();
211
+ if (options.action === 'stop') return stop();
212
+ if (options.action === 'logs') return recentLog();
213
+ if (options.action === 'follow') { showStatus(); return follow(); }
214
+ if (options.action === 'reset-key') return rotate();
215
+ return await start(options);
216
+ } catch (error) { console.error(`ZBack error: ${error.message}`); process.exitCode = 1; }
171
217
  }
172
218
 
173
- if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => {
174
- console.error(error.message); process.exitCode = 1;
175
- });
219
+ if (process.argv[1] && realpathSync(process.argv[1]) === ENTRY) main();
package/package.json CHANGED
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "1.0.0",
4
- "description": "Consent-based remote shell agent via WebSocket and MCP",
3
+ "version": "1.3.0",
4
+ "description": "Opt-in remote shell administration with a persistent WebSocket agent, audit logs and a private session credential",
5
5
  "type": "module",
6
- "bin": { "zback": "./bin/zback.js" },
7
- "engines": { "node": ">=22.0.0" },
8
- "scripts": { "test": "node --test" },
9
- "license": "MIT"
6
+ "bin": {
7
+ "zback": "bin/zback.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "contentPolicy": {
16
+ "class": "dual-use"
17
+ },
18
+ "keywords": ["remote-administration", "websocket", "cli", "mcp", "opt-in"],
19
+ "files": ["bin/", "src/", "README.md", "DISCLOSURE", "LICENSE"],
20
+ "license": "MIT",
21
+ "author": "SoyMaycol"
10
22
  }
package/src/agent.js ADDED
@@ -0,0 +1,143 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const children = new Set();
4
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
5
+ const log = message => console.log(`[${new Date().toISOString()}] ${message}`);
6
+
7
+ function killTree(child) {
8
+ if (!child?.pid) return;
9
+ try {
10
+ if (process.platform === 'win32') {
11
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
12
+ killer.unref();
13
+ } else process.kill(-child.pid, 'SIGKILL');
14
+ } catch { try { child.kill('SIGKILL'); } catch {} }
15
+ }
16
+
17
+ export function killRunningCommands() { for (const child of children) killTree(child); }
18
+
19
+ export function execute(command, cwd, timeoutMs, maxOutput) {
20
+ return new Promise(resolveResult => {
21
+ let finished = false, timedOut = false, truncated = false;
22
+ const chunks = { stdout: [], stderr: [] };
23
+ let collected = 0;
24
+ let child;
25
+ try {
26
+ child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
27
+ children.add(child);
28
+ } catch (err) { resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) }); return; }
29
+ const add = (kind, chunk) => {
30
+ const remaining = maxOutput - collected;
31
+ if (remaining <= 0) { truncated = true; return; }
32
+ if (chunk.length > remaining) truncated = true;
33
+ chunks[kind].push(chunk.subarray(0, remaining));
34
+ collected += Math.min(chunk.length, remaining);
35
+ };
36
+ child.stdout.on('data', b => add('stdout', b));
37
+ child.stderr.on('data', b => add('stderr', b));
38
+ const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
39
+ const finish = (code, message = '') => {
40
+ if (finished) return;
41
+ finished = true;
42
+ children.delete(child);
43
+ clearTimeout(timer);
44
+ resolveResult({
45
+ ok: code === 0 && !timedOut,
46
+ code: Number.isInteger(code) ? code : null,
47
+ stdout: Buffer.concat(chunks.stdout).toString('utf8'),
48
+ stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
49
+ truncated, timedOut
50
+ });
51
+ };
52
+ child.once('error', error => finish(null, `\n${error.message}`));
53
+ child.once('close', code => finish(code));
54
+ });
55
+ }
56
+
57
+ export async function connectAgent(options, onState, token) {
58
+ let stopped = false;
59
+ let retries = 0;
60
+ let socket;
61
+ let cancelDelay;
62
+ let currentState = '';
63
+ const update = (status, data = {}) => {
64
+ onState({ status, ...data });
65
+ if (currentState !== status) { log(`Agent status: ${status}`); currentState = status; }
66
+ };
67
+ const shutdown = () => {
68
+ stopped = true;
69
+ if (cancelDelay) cancelDelay();
70
+ try { socket?.close(1000, 'Agent stopped'); } catch {}
71
+ killRunningCommands();
72
+ };
73
+ process.once('SIGINT', shutdown);
74
+ process.once('SIGTERM', shutdown);
75
+ try {
76
+ while (!stopped) {
77
+ try {
78
+ update(retries ? 'reconnecting' : 'connecting');
79
+ const current = new WebSocket(options.url);
80
+ socket = current;
81
+ await new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Connection timed out')); }, 15000);
83
+ const success = () => { clearTimeout(timer); resolve(); };
84
+ const failure = () => { clearTimeout(timer); reject(new Error('Could not reach backend')); };
85
+ current.addEventListener('open', success, { once: true });
86
+ current.addEventListener('error', failure, { once: true });
87
+ });
88
+ if (stopped) break;
89
+ current.send(JSON.stringify({ type: 'hello', protocolVersion: 2, token }));
90
+ await new Promise((resolve, reject) => {
91
+ let welcomed = false;
92
+ const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Server handshake timed out')); }, 10000);
93
+ let queue = Promise.resolve();
94
+ const messageListener = event => {
95
+ let message;
96
+ try { message = JSON.parse(event.data); }
97
+ catch { log('Ignored invalid server message'); return; }
98
+ if (message.type === 'welcome' && typeof message.id === 'string') {
99
+ if (welcomed) return;
100
+ welcomed = true;
101
+ clearTimeout(timer);
102
+ retries = 0;
103
+ update('connected', { id: message.id });
104
+ log('Connected and ready for authorized requests');
105
+ return;
106
+ }
107
+ if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
108
+ queue = queue.then(async () => {
109
+ if (stopped || current.readyState !== WebSocket.OPEN) return;
110
+ const command = message.command;
111
+ if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
112
+ const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
113
+ log(`Running authorized request ${message.requestId}`);
114
+ const result = await execute(command, options.cwd, timeout, options.maxOutput);
115
+ log(`Request ${message.requestId}: ${result.timedOut ? 'timed out' : result.ok ? 'completed' : 'failed'}`);
116
+ if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
117
+ }).catch(error => log(`Request processing error: ${error.message}`));
118
+ };
119
+ current.addEventListener('message', messageListener);
120
+ current.addEventListener('close', event => {
121
+ clearTimeout(timer);
122
+ if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
123
+ else { log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
124
+ }, { once: true });
125
+ current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
126
+ });
127
+ } catch (error) { if (!stopped) log(`Connection error: ${error.message}`); }
128
+ killRunningCommands();
129
+ if (stopped || !options.reconnect) break;
130
+ update('reconnecting', { id: null });
131
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
132
+ log(`Retrying connection in ${Math.ceil(delay / 1000)}s`);
133
+ await new Promise(resolve => {
134
+ const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
135
+ cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
136
+ });
137
+ }
138
+ } finally {
139
+ process.removeListener('SIGINT', shutdown);
140
+ process.removeListener('SIGTERM', shutdown);
141
+ update('stopped', { id: null });
142
+ }
143
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,62 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export const stateDir = process.env.ZBACK_STATE_DIR || join(process.env.XDG_STATE_HOME || (process.platform === 'win32' ? process.env.LOCALAPPDATA || homedir() : join(homedir(), '.local', 'state')), 'zback');
7
+ export const consentFile = join(stateDir, 'consent.json');
8
+ export const stateFile = join(stateDir, 'session.json');
9
+ export const settingsFile = join(stateDir, 'settings.json');
10
+ export const agentKeyFile = join(stateDir, 'agent.key');
11
+ export const logFile = join(stateDir, 'zback.log');
12
+
13
+ export function initStateDir() { mkdirSync(stateDir, { recursive: true, mode: 0o700 }); }
14
+ export function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
15
+ export function saveJson(file, data) {
16
+ initStateDir();
17
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
18
+ try {
19
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
20
+ renameSync(tmp, file);
21
+ } finally { try { unlinkSync(tmp); } catch {} }
22
+ }
23
+ export function agentKey() {
24
+ initStateDir();
25
+ if (!existsSync(agentKeyFile)) {
26
+ const key = randomBytes(32).toString('base64url');
27
+ try {
28
+ const fd = openSync(agentKeyFile, 'wx', 0o600);
29
+ try { writeFileSync(fd, key + '\n'); } finally { closeSync(fd); }
30
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
31
+ }
32
+ const key = readFileSync(agentKeyFile, 'utf8').trim();
33
+ if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid agent credential file; run zback --reset-key while stopped');
34
+ return key;
35
+ }
36
+ export function resetKey() {
37
+ try { unlinkSync(agentKeyFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
38
+ return agentKey();
39
+ }
40
+ export function hasConsent() { return readJson(consentFile)?.approved === true; }
41
+ export function alive(pid) {
42
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
43
+ try {
44
+ process.kill(pid, 0);
45
+ if (process.platform === 'linux') {
46
+ try {
47
+ if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
48
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
49
+ if (!cmd.includes('--worker') || !cmd.includes('zback.js')) return false;
50
+ } catch {}
51
+ }
52
+ return true;
53
+ } catch (e) { return e.code === 'EPERM'; }
54
+ }
55
+ export function activeSession() {
56
+ const data = readJson(stateFile);
57
+ return data && data.status !== 'stopped' && alive(data.pid) ? data : null;
58
+ }
59
+ export function sameSession(pid, started) {
60
+ const data = readJson(stateFile);
61
+ return data?.pid === pid && data?.started === started;
62
+ }
@@ -1,28 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { parseArgs, execute } from '../bin/zback.js';
4
-
5
- test('default secure endpoint', () => {
6
- const args = parseArgs([]);
7
- assert.equal(args.url, 'wss://zb.soymaycol.icu/ws');
8
- assert.equal(args.reconnect, true);
9
- });
10
-
11
- test('unsafe remote ws URL blocked', () => {
12
- assert.throws(() => parseArgs(['--url', 'ws://example.com/ws']), /wss/);
13
- assert.equal(parseArgs(['--url', 'ws://127.0.0.1:8787/ws']).timeout, 30000);
14
- assert.throws(() => parseArgs(['--timeout', '0']), /timeout/);
15
- });
16
-
17
- test('subprocess respects success and returned output', async () => {
18
- const res = await execute('node -p "3*7"', process.cwd(), 5000, 1024);
19
- assert.equal(res.ok, true);
20
- assert.equal(res.stdout.trim(), '21');
21
- });
22
-
23
- test('subprocess output limit is enforced', async () => {
24
- const res = await execute('node -e "process.stdout.write(\'a\'.repeat(2500))"', process.cwd(), 5000, 1024);
25
- assert.equal(res.ok, true);
26
- assert.equal(res.stdout.length, 1024);
27
- assert.equal(res.truncated, true);
28
- });