edge-ai-client-ts 1.0.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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- package/package.json +79 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `messages` subcommand — list recent LLM conversation messages (operator-scoped).
|
|
3
|
+
* Mirrors `edge-client/src/cli/messages.rs`.
|
|
4
|
+
*/
|
|
5
|
+
import { loadSettings, loadDeviceSecret } from '../config/settings.js';
|
|
6
|
+
export async function listMessages(limit) {
|
|
7
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
8
|
+
console.error('ec-ts messages: invalid --limit');
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
const settings = loadSettings();
|
|
12
|
+
if (!settings.owner_operator_id) {
|
|
13
|
+
console.error('ec-ts messages: not enrolled');
|
|
14
|
+
return 2;
|
|
15
|
+
}
|
|
16
|
+
const secret = loadDeviceSecret();
|
|
17
|
+
if (!secret) {
|
|
18
|
+
console.error('ec-ts messages: device secret missing');
|
|
19
|
+
return 3;
|
|
20
|
+
}
|
|
21
|
+
const httpUrl = settings.server.ws_url
|
|
22
|
+
.replace(/^wss:/, 'https:')
|
|
23
|
+
.replace(/^ws:/, 'http:')
|
|
24
|
+
.replace(/\/edge$/, '')
|
|
25
|
+
.replace(/\/$/, '');
|
|
26
|
+
const url = `${httpUrl}/api/messages/recent?operator_id=${encodeURIComponent(settings.owner_operator_id)}&limit=${limit}`;
|
|
27
|
+
let resp;
|
|
28
|
+
try {
|
|
29
|
+
resp = await fetch(url, { headers: { 'X-Device-Secret': secret } });
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
console.error(`ec-ts messages: network error: ${String(e)}`);
|
|
33
|
+
return 4;
|
|
34
|
+
}
|
|
35
|
+
if (!resp.ok) {
|
|
36
|
+
console.error(`ec-ts messages: HTTP ${resp.status}`);
|
|
37
|
+
return 5;
|
|
38
|
+
}
|
|
39
|
+
let body;
|
|
40
|
+
try {
|
|
41
|
+
body = (await resp.json());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
console.error('ec-ts messages: non-JSON response');
|
|
45
|
+
return 6;
|
|
46
|
+
}
|
|
47
|
+
const items = body.items ?? [];
|
|
48
|
+
if (items.length === 0) {
|
|
49
|
+
console.log('(no recent messages)');
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
console.log(`Recent messages (${items.length}):`);
|
|
53
|
+
for (const m of items) {
|
|
54
|
+
const ts = new Date(m.timestamp).toISOString();
|
|
55
|
+
const preview = m.content.length > 80 ? m.content.slice(0, 77) + '...' : m.content;
|
|
56
|
+
console.log(` [${ts}] ${m.agent_id} (${m.role}): ${preview}`);
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
package/dist/cli/run.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run` subcommand — start the edge-client daemon.
|
|
3
|
+
* Mirrors `edge-client/src/cli/mod.rs::Command::Run`.
|
|
4
|
+
*/
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
import { loadSettings, configDir, } from '../config/settings.js';
|
|
8
|
+
import { EdgeWsClient } from '../network/ws-client.js';
|
|
9
|
+
import { SessionManager } from '../session/manager.js';
|
|
10
|
+
import { DiskBufferQueue } from '../buffer/disk-queue.js';
|
|
11
|
+
import { FileLogger } from '../logging/file-logger.js';
|
|
12
|
+
/**
|
|
13
|
+
* Start the daemon and block until SIGINT/SIGTERM.
|
|
14
|
+
* Returns the process exit code.
|
|
15
|
+
*/
|
|
16
|
+
export async function runDaemon() {
|
|
17
|
+
let settings;
|
|
18
|
+
try {
|
|
19
|
+
settings = loadSettings();
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.error(`ec-ts: failed to load settings: ${String(e)}`);
|
|
23
|
+
console.error('run `ec-ts enroll --key <KEY>` once to generate the default config, then re-run.');
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
const logDir = path.join(configDir(), 'logs');
|
|
27
|
+
const logger = new FileLogger(logDir);
|
|
28
|
+
await logger.info(`starting daemon (machine_id=${settings.machine_id})`);
|
|
29
|
+
// Set up disk-backed buffer queue for offline resilience.
|
|
30
|
+
const bufferKey = settings.buffer.key
|
|
31
|
+
? Buffer.from(settings.buffer.key, 'hex')
|
|
32
|
+
: Buffer.alloc(32); // zero key if not configured (dev only)
|
|
33
|
+
const buffer = new DiskBufferQueue({
|
|
34
|
+
dir: settings.buffer.dump_dir ?? './buffer-dump',
|
|
35
|
+
key: bufferKey,
|
|
36
|
+
maxBytes: settings.buffer.max_memory_mb * 1024 * 1024,
|
|
37
|
+
});
|
|
38
|
+
// Session manager owns PTY/SFTP/pi processes.
|
|
39
|
+
const sessionManager = new SessionManager({
|
|
40
|
+
machineId: settings.machine_id,
|
|
41
|
+
shellEnabled: settings.shell.enabled,
|
|
42
|
+
});
|
|
43
|
+
// WS client to the relay. Route inbound messages to the session manager
|
|
44
|
+
// (live) or to the disk buffer (offline → drain on reconnect).
|
|
45
|
+
const ws = new EdgeWsClient({ settings });
|
|
46
|
+
ws.on('inbound', (msg) => {
|
|
47
|
+
void (async () => {
|
|
48
|
+
try {
|
|
49
|
+
if (msg && typeof msg === 'object' && 'type' in msg) {
|
|
50
|
+
const m = msg;
|
|
51
|
+
if (m.type === 'extension_ui_response' && typeof m.msg_id === 'string') {
|
|
52
|
+
// Forward HITL responses to the right agent's pi session.
|
|
53
|
+
const agentId = msg.agent_id;
|
|
54
|
+
if (typeof agentId === 'string') {
|
|
55
|
+
await sessionManager.forwardExtensionUIResponse(agentId, m.msg_id, msg);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Persist to the offline buffer (best-effort, never blocks the WS loop).
|
|
61
|
+
const buf = Buffer.from(JSON.stringify(msg), 'utf8');
|
|
62
|
+
await buffer.append({
|
|
63
|
+
msg_id: typeof msg.msg_id === 'string' ? msg.msg_id : crypto.randomUUID(),
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
bytes: buf,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
await logger.warn(`inbound handler failed: ${String(e)}`);
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
});
|
|
73
|
+
ws.on('online', () => void logger.info('relay online'));
|
|
74
|
+
ws.on('offline', () => void logger.warn('relay offline'));
|
|
75
|
+
ws.on('error', (err) => {
|
|
76
|
+
void logger.error(`ws error: ${err?.message ?? 'null (no detail)'}`);
|
|
77
|
+
});
|
|
78
|
+
// Graceful shutdown on SIGINT/SIGTERM.
|
|
79
|
+
const shutdown = async (signal) => {
|
|
80
|
+
await logger.info(`shutdown signal received (${signal})`);
|
|
81
|
+
try {
|
|
82
|
+
ws.stop();
|
|
83
|
+
await sessionManager.closeAll();
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
await logger.error(`shutdown error: ${String(e)}`);
|
|
87
|
+
}
|
|
88
|
+
process.exit(0);
|
|
89
|
+
};
|
|
90
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
91
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
92
|
+
// Fire-and-forget start — the EdgeWsClient's internal reconnect logic
|
|
93
|
+
// handles retries on failure with exponential backoff. The daemon blocks
|
|
94
|
+
// forever regardless of connect success/failure; SIGINT/SIGTERM exits cleanly.
|
|
95
|
+
//
|
|
96
|
+
// We do NOT `await ws.start()` (which throws on first connect failure)
|
|
97
|
+
// because that would kill the daemon before the reconnect timer gets a
|
|
98
|
+
// chance to fire. Instead we let start() run in the background, log the
|
|
99
|
+
// outcome, and block on the forever-promise below.
|
|
100
|
+
void ws.start()
|
|
101
|
+
.then(() => void logger.info(`daemon connected (ws=${settings.server.ws_url}, host=${os.hostname()})`))
|
|
102
|
+
.catch(async (e) => {
|
|
103
|
+
// First-attempt failure — logged but NOT fatal. The reconnect timer
|
|
104
|
+
// inside EdgeWsClient.start() will keep retrying.
|
|
105
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
106
|
+
await logger.error(`initial connection failed (will retry): ${msg}`);
|
|
107
|
+
console.error(`ec-ts: initial connection failed (will retry): ${msg}`);
|
|
108
|
+
});
|
|
109
|
+
// Block forever; SIGINT/SIGTERM handler exits cleanly.
|
|
110
|
+
await new Promise(() => { });
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function status(): Promise<number>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `status` subcommand — show daemon + relay contact status.
|
|
3
|
+
* Mirrors `edge-client/src/cli/status.rs`.
|
|
4
|
+
*
|
|
5
|
+
* Reads local settings + tries a quick HTTP HEAD against the relay to verify
|
|
6
|
+
* reachability. Does NOT require the daemon to be running.
|
|
7
|
+
*/
|
|
8
|
+
import { loadSettings } from '../config/settings.js';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
export async function status() {
|
|
11
|
+
let settings;
|
|
12
|
+
try {
|
|
13
|
+
settings = loadSettings();
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
console.error(`ec-ts status: failed to load settings: ${String(e)}`);
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
// Derive HTTP probe URL from ws_url.
|
|
20
|
+
const httpUrl = settings.server.ws_url
|
|
21
|
+
.replace(/^wss:/, 'https:')
|
|
22
|
+
.replace(/^ws:/, 'http:')
|
|
23
|
+
.replace(/\/edge$/, '')
|
|
24
|
+
.replace(/\/$/, '');
|
|
25
|
+
console.log('ec-ts status:');
|
|
26
|
+
console.log(` machine_id: ${settings.machine_id}`);
|
|
27
|
+
console.log(` operator_id: ${settings.owner_operator_id ?? '(not enrolled)'}`);
|
|
28
|
+
console.log(` relay: ${settings.server.ws_url}`);
|
|
29
|
+
// Probe relay reachability (short timeout — best effort).
|
|
30
|
+
const t0 = Date.now();
|
|
31
|
+
let reachable = false;
|
|
32
|
+
let httpStatus = -1;
|
|
33
|
+
try {
|
|
34
|
+
const ctl = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => ctl.abort(), 3_000);
|
|
36
|
+
const resp = await fetch(httpUrl + '/api/health', {
|
|
37
|
+
method: 'GET',
|
|
38
|
+
signal: ctl.signal,
|
|
39
|
+
});
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
httpStatus = resp.status;
|
|
42
|
+
reachable = resp.ok;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
reachable = false;
|
|
46
|
+
}
|
|
47
|
+
const rttMs = Date.now() - t0;
|
|
48
|
+
console.log(` relay reach: ${reachable ? 'OK' : 'unreachable'} (HTTP ${httpStatus}, ${rttMs} ms)`);
|
|
49
|
+
// System info (matches Rust StatusArgs output).
|
|
50
|
+
console.log(` platform: ${process.platform}`);
|
|
51
|
+
console.log(` arch: ${process.arch}`);
|
|
52
|
+
console.log(` hostname: ${os.hostname()}`);
|
|
53
|
+
console.log(` node: ${process.version}`);
|
|
54
|
+
console.log(` pid: ${process.pid}`);
|
|
55
|
+
return reachable ? 0 : 2;
|
|
56
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `whoami` subcommand — show this edge node's local identity.
|
|
3
|
+
* Mirrors `edge-client/src/cli/whoami.rs`.
|
|
4
|
+
*
|
|
5
|
+
* Local-only — does NOT call the relay. Shows operator_id, machine_id,
|
|
6
|
+
* ws_url, configured key hint + id.
|
|
7
|
+
*/
|
|
8
|
+
import { loadSettings, loadDeviceSecret } from '../config/settings.js';
|
|
9
|
+
/** Print the local identity. */
|
|
10
|
+
export async function whoami() {
|
|
11
|
+
let settings;
|
|
12
|
+
try {
|
|
13
|
+
settings = loadSettings();
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
console.error(`ec-ts whoami: failed to load settings: ${String(e)}`);
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
// Key hint = first 4 + last 4 chars of device_secret (if present), so the user
|
|
20
|
+
// can distinguish multiple enrollments without leaking the full secret.
|
|
21
|
+
const secret = loadDeviceSecret();
|
|
22
|
+
let keyHint = '(not set)';
|
|
23
|
+
if (secret && secret.length >= 8) {
|
|
24
|
+
keyHint = `${secret.slice(0, 4)}...${secret.slice(-4)}`;
|
|
25
|
+
}
|
|
26
|
+
else if (secret) {
|
|
27
|
+
keyHint = `${secret.length} chars`;
|
|
28
|
+
}
|
|
29
|
+
console.log('ec-ts local identity:');
|
|
30
|
+
console.log(` operator_id: ${settings.owner_operator_id ?? '(not enrolled)'}`);
|
|
31
|
+
console.log(` machine_id: ${settings.machine_id}`);
|
|
32
|
+
console.log(` agent_id: ${settings.agent.default_agent_id}`);
|
|
33
|
+
console.log(` relay (ws): ${settings.server.ws_url}`);
|
|
34
|
+
if (settings.server.ws_url_lan)
|
|
35
|
+
console.log(` relay (lan): ${settings.server.ws_url_lan}`);
|
|
36
|
+
if (settings.server.ws_url_wan)
|
|
37
|
+
console.log(` relay (wan): ${settings.server.ws_url_wan}`);
|
|
38
|
+
console.log(` api key hint: ${keyHint}`);
|
|
39
|
+
console.log(` shell enabled: ${settings.shell.enabled}`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGT-03 pre-execution command gate — **config schema** portion.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the `CmdGateConfig` struct + defaults + env-resolve logic from
|
|
5
|
+
* `edge-client/src/session/cmd_gate.rs`. The runtime **decision** logic
|
|
6
|
+
* (`GateDecision`, `evaluate`) lives in `src/session/cmd-gate.ts` (Phase 2)
|
|
7
|
+
* and imports the types defined here.
|
|
8
|
+
*
|
|
9
|
+
* This split mirrors the Rust layout where `config/settings.rs` references
|
|
10
|
+
* `session::cmd_gate::CmdGateConfig` for the schema, while the `evaluate`
|
|
11
|
+
* decision function lives in the same Rust file but is purely session-side.
|
|
12
|
+
* In TS we separate config (here) from session logic to keep the dependency
|
|
13
|
+
* graph clean: `config/` must not import from `session/`.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Gate enforcement mode. Serialized as lowercase in TOML (mirrors Rust's
|
|
17
|
+
* `#[serde(rename_all = "lowercase")]`).
|
|
18
|
+
*
|
|
19
|
+
* - `off` — Legacy: never gate, never inspect.
|
|
20
|
+
* - `warn` — Gate: every decision is Allow or RequireApproval; denylist hits
|
|
21
|
+
* are surfaced for approval but never auto-denied.
|
|
22
|
+
* - `block` — Strict (default): denylist hits → Deny; always_approve hits →
|
|
23
|
+
* RequireApproval; everything else → Allow.
|
|
24
|
+
*/
|
|
25
|
+
export type CmdGateMode = 'off' | 'warn' | 'block';
|
|
26
|
+
/**
|
|
27
|
+
* Configurable gate policy. Field names EXACTLY match the Rust struct.
|
|
28
|
+
*/
|
|
29
|
+
export interface CmdGateConfig {
|
|
30
|
+
readonly mode: CmdGateMode;
|
|
31
|
+
/** Tools that are always safe (read-only introspection). */
|
|
32
|
+
readonly allowlist: readonly string[];
|
|
33
|
+
/** Tool names that require operator approval (mutating/exec-class). */
|
|
34
|
+
readonly always_approve: readonly string[];
|
|
35
|
+
/** Substring patterns that force Deny (block) or RequireApproval (warn). */
|
|
36
|
+
readonly denylist: readonly string[];
|
|
37
|
+
/** Seconds to wait for operator approval before aborting pi. */
|
|
38
|
+
readonly timeout_s: number;
|
|
39
|
+
}
|
|
40
|
+
/** Default allowlist — read-only introspection tools. */
|
|
41
|
+
export declare function defaultAllowlist(): string[];
|
|
42
|
+
/** Default always-approve list — mutating/exec-class tools. */
|
|
43
|
+
export declare function defaultAlwaysApprove(): string[];
|
|
44
|
+
/** Default denylist — destructive substring patterns. */
|
|
45
|
+
export declare function defaultDenylist(): string[];
|
|
46
|
+
/** Default timeout for operator approval (seconds). */
|
|
47
|
+
export declare const DEFAULT_CMD_GATE_TIMEOUT_S = 60;
|
|
48
|
+
/** Construct the default CmdGateConfig (safe defaults: block mode). */
|
|
49
|
+
export declare function defaultCmdGateConfig(): CmdGateConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Parse a comma-separated string into a trimmed, non-empty string list.
|
|
52
|
+
* Mirrors Rust's `parse_csv`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseCsv(raw: string): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the gate config from env overrides. Env wins over the config value.
|
|
57
|
+
*
|
|
58
|
+
* Env names (mirrors Rust exactly):
|
|
59
|
+
* - `EDGE_CMD_GATE_MODE` = `off` | `warn` | `block`
|
|
60
|
+
* - `EDGE_CMD_GATE_TIMEOUT_S` = seconds
|
|
61
|
+
* - `EDGE_CMD_GATE_ALLOWLIST` = comma-separated tool names (replaces)
|
|
62
|
+
* - `EDGE_CMD_GATE_APPROVE` = comma-separated tool names (replaces)
|
|
63
|
+
* - `EDGE_CMD_GATE_DENYLIST` = comma-separated patterns (replaces)
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveCmdGateConfig(env: Record<string, string | undefined>, config: CmdGateConfig): CmdGateConfig;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGT-03 pre-execution command gate — **config schema** portion.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the `CmdGateConfig` struct + defaults + env-resolve logic from
|
|
5
|
+
* `edge-client/src/session/cmd_gate.rs`. The runtime **decision** logic
|
|
6
|
+
* (`GateDecision`, `evaluate`) lives in `src/session/cmd-gate.ts` (Phase 2)
|
|
7
|
+
* and imports the types defined here.
|
|
8
|
+
*
|
|
9
|
+
* This split mirrors the Rust layout where `config/settings.rs` references
|
|
10
|
+
* `session::cmd_gate::CmdGateConfig` for the schema, while the `evaluate`
|
|
11
|
+
* decision function lives in the same Rust file but is purely session-side.
|
|
12
|
+
* In TS we separate config (here) from session logic to keep the dependency
|
|
13
|
+
* graph clean: `config/` must not import from `session/`.
|
|
14
|
+
*/
|
|
15
|
+
/** Default allowlist — read-only introspection tools. */
|
|
16
|
+
export function defaultAllowlist() {
|
|
17
|
+
return [
|
|
18
|
+
'read',
|
|
19
|
+
'read_file',
|
|
20
|
+
'cat',
|
|
21
|
+
'ls',
|
|
22
|
+
'list_directory',
|
|
23
|
+
'find',
|
|
24
|
+
'grep',
|
|
25
|
+
'search',
|
|
26
|
+
'git_log',
|
|
27
|
+
'git_diff',
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
/** Default always-approve list — mutating/exec-class tools. */
|
|
31
|
+
export function defaultAlwaysApprove() {
|
|
32
|
+
return [
|
|
33
|
+
'bash',
|
|
34
|
+
'exec',
|
|
35
|
+
'run_command',
|
|
36
|
+
'shell_exec',
|
|
37
|
+
'process',
|
|
38
|
+
'write',
|
|
39
|
+
'edit',
|
|
40
|
+
'delete',
|
|
41
|
+
'move',
|
|
42
|
+
'rename',
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
/** Default denylist — destructive substring patterns. */
|
|
46
|
+
export function defaultDenylist() {
|
|
47
|
+
return [
|
|
48
|
+
// Unix destructive
|
|
49
|
+
'rm -rf',
|
|
50
|
+
'rm -fr',
|
|
51
|
+
'rm -f /',
|
|
52
|
+
':(){ :|:& };:', // fork bomb
|
|
53
|
+
'dd if=/dev/zero',
|
|
54
|
+
'dd if=/dev/urandom',
|
|
55
|
+
'mkfs.',
|
|
56
|
+
'mkfs ',
|
|
57
|
+
'shutdown',
|
|
58
|
+
'reboot',
|
|
59
|
+
'halt',
|
|
60
|
+
'poweroff',
|
|
61
|
+
// Windows destructive
|
|
62
|
+
'format c:',
|
|
63
|
+
'format d:',
|
|
64
|
+
'reg delete',
|
|
65
|
+
'bcdedit',
|
|
66
|
+
'rd /s /q',
|
|
67
|
+
'del /f /s /q',
|
|
68
|
+
// Privilege escalation
|
|
69
|
+
'sudo su',
|
|
70
|
+
'sudo -i',
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
/** Default timeout for operator approval (seconds). */
|
|
74
|
+
export const DEFAULT_CMD_GATE_TIMEOUT_S = 60;
|
|
75
|
+
/** Construct the default CmdGateConfig (safe defaults: block mode). */
|
|
76
|
+
export function defaultCmdGateConfig() {
|
|
77
|
+
return {
|
|
78
|
+
mode: 'block',
|
|
79
|
+
allowlist: defaultAllowlist(),
|
|
80
|
+
always_approve: defaultAlwaysApprove(),
|
|
81
|
+
denylist: defaultDenylist(),
|
|
82
|
+
timeout_s: DEFAULT_CMD_GATE_TIMEOUT_S,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse a comma-separated string into a trimmed, non-empty string list.
|
|
87
|
+
* Mirrors Rust's `parse_csv`.
|
|
88
|
+
*/
|
|
89
|
+
export function parseCsv(raw) {
|
|
90
|
+
return raw
|
|
91
|
+
.split(',')
|
|
92
|
+
.map((p) => p.trim())
|
|
93
|
+
.filter((p) => p.length > 0);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the gate config from env overrides. Env wins over the config value.
|
|
97
|
+
*
|
|
98
|
+
* Env names (mirrors Rust exactly):
|
|
99
|
+
* - `EDGE_CMD_GATE_MODE` = `off` | `warn` | `block`
|
|
100
|
+
* - `EDGE_CMD_GATE_TIMEOUT_S` = seconds
|
|
101
|
+
* - `EDGE_CMD_GATE_ALLOWLIST` = comma-separated tool names (replaces)
|
|
102
|
+
* - `EDGE_CMD_GATE_APPROVE` = comma-separated tool names (replaces)
|
|
103
|
+
* - `EDGE_CMD_GATE_DENYLIST` = comma-separated patterns (replaces)
|
|
104
|
+
*/
|
|
105
|
+
export function resolveCmdGateConfig(env, config) {
|
|
106
|
+
const modeEnv = env['EDGE_CMD_GATE_MODE'];
|
|
107
|
+
let mode = config.mode;
|
|
108
|
+
if (modeEnv === 'off' || modeEnv === 'warn' || modeEnv === 'block') {
|
|
109
|
+
mode = modeEnv;
|
|
110
|
+
}
|
|
111
|
+
const timeoutEnv = env['EDGE_CMD_GATE_TIMEOUT_S'];
|
|
112
|
+
let timeout_s = config.timeout_s;
|
|
113
|
+
if (timeoutEnv !== undefined) {
|
|
114
|
+
const parsed = Number.parseInt(timeoutEnv.trim(), 10);
|
|
115
|
+
if (Number.isFinite(parsed)) {
|
|
116
|
+
timeout_s = parsed;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const allowlistEnv = env['EDGE_CMD_GATE_ALLOWLIST'];
|
|
120
|
+
const allowlist = allowlistEnv !== undefined ? parseCsv(allowlistEnv) : [...config.allowlist];
|
|
121
|
+
const alwaysApproveEnv = env['EDGE_CMD_GATE_APPROVE'];
|
|
122
|
+
const always_approve = alwaysApproveEnv !== undefined
|
|
123
|
+
? parseCsv(alwaysApproveEnv)
|
|
124
|
+
: [...config.always_approve];
|
|
125
|
+
const denylistEnv = env['EDGE_CMD_GATE_DENYLIST'];
|
|
126
|
+
const denylist = denylistEnv !== undefined ? parseCsv(denylistEnv) : [...config.denylist];
|
|
127
|
+
return { mode, allowlist, always_approve, denylist, timeout_s };
|
|
128
|
+
}
|