windows-development-sandbox 1.12.0 → 1.13.2
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/README.md +1 -0
- package/config.example.json +11 -1
- package/dist/app/features/access/access.def.d.ts +190 -0
- package/dist/app/features/access/access.def.js +273 -0
- package/dist/app/features/access/access.run.d.ts +79 -0
- package/dist/app/features/access/access.run.js +129 -0
- package/dist/app/features/access/index.d.ts +2 -0
- package/dist/app/features/access/index.js +3 -0
- package/dist/app/features/accounts/accounts.def.d.ts +1 -0
- package/dist/app/features/accounts/accounts.def.js +12 -3
- package/dist/app/features/accounts/accounts.run.d.ts +1 -0
- package/dist/app/features/accounts/accounts.run.js +20 -11
- package/dist/app/features/agents/agent-entry.js +5 -0
- package/dist/app/features/agents/agents.def.js +1 -0
- package/dist/app/features/apps/apps.def.js +1 -0
- package/dist/app/features/audit/audit.def.d.ts +11 -2
- package/dist/app/features/audit/audit.def.js +7 -4
- package/dist/app/features/audit/audit.run.d.ts +3 -0
- package/dist/app/features/defs.d.ts +156 -3
- package/dist/app/features/defs.js +12 -1
- package/dist/app/features/files/files.def.js +1 -0
- package/dist/app/features/health/health.def.js +1 -0
- package/dist/app/features/sandbox/sandbox.def.js +3 -0
- package/dist/app/features/sessions/sessions.def.d.ts +36 -0
- package/dist/app/features/sessions/sessions.def.js +77 -9
- package/dist/app/features/sessions/sessions.run.d.ts +25 -2
- package/dist/app/features/sessions/sessions.run.js +71 -3
- package/dist/app/features/settings/settings-file.d.ts +7 -0
- package/dist/app/features/settings/settings-file.js +19 -2
- package/dist/app/features/settings/settings-rows.js +9 -1
- package/dist/app/features/settings/settings.def.d.ts +8 -0
- package/dist/app/features/settings/settings.def.js +17 -0
- package/dist/app/features/web/web.def.d.ts +9 -0
- package/dist/app/features/web/web.def.js +26 -1
- package/dist/app/features/web/web.run.d.ts +3 -0
- package/dist/app/features/web/web.run.js +11 -3
- package/dist/app/kernel/access.d.ts +33 -0
- package/dist/app/kernel/access.js +44 -0
- package/dist/app/kernel/define.d.ts +14 -1
- package/dist/app/kernel/index.d.ts +1 -0
- package/dist/app/kernel/index.js +1 -0
- package/dist/app/kernel/rpc.d.ts +5 -2
- package/dist/app/registry.d.ts +4 -1
- package/dist/app/registry.js +24 -4
- package/dist/bin/agent-sandbox.js +7 -1
- package/dist/bin/chunk-IL7BXJF2.js +7 -0
- package/dist/bin/chunk-K4SU3MHJ.js +10432 -0
- package/dist/bin/chunk-VENWSGX2.js +78437 -0
- package/dist/bin/claude-host.js +10 -4
- package/dist/bin/claude-sbx.js +8 -2
- package/dist/bin/codex-sbx.js +8 -2
- package/dist/bin/gemini-sbx.js +8 -2
- package/dist/bin/sandbox.js +7 -2
- package/dist/bin/sbx-hide.js +8 -2
- package/dist/bin/sbx-show.js +8 -2
- package/dist/cli/commands/launch/prepare.js +24 -7
- package/dist/cli/commands/launch/running.js +4 -0
- package/dist/cli/commands/registry.js +2 -0
- package/dist/cli/commands/remote.d.ts +2 -0
- package/dist/cli/commands/remote.js +49 -0
- package/dist/cli/commands/sessions/reopen.js +3 -0
- package/dist/cli/commands/storage/offer.js +1 -1
- package/dist/cli/commands/web.js +66 -0
- package/dist/cli/manifest.d.ts +63 -4
- package/dist/cli/manifest.js +33 -4
- package/dist/cli/menu/io-menu.d.ts +5 -3
- package/dist/cli/menu/io-menu.js +12 -5
- package/dist/cli/menu/live.d.ts +29 -0
- package/dist/cli/menu/live.js +92 -21
- package/dist/cli/menu/menu.d.ts +2 -1
- package/dist/cli/menu/menu.js +16 -2
- package/dist/cli/menu/pages/list-page.d.ts +5 -0
- package/dist/cli/menu/pages/list-page.js +19 -0
- package/dist/cli/menu/remote-menu.d.ts +19 -0
- package/dist/cli/menu/remote-menu.js +108 -0
- package/dist/cli/menu/shell.d.ts +2 -0
- package/dist/cli/menu/shell.js +1 -1
- package/dist/cli/menu/views.js +2 -0
- package/dist/cli/operations/handler.js +15 -1
- package/dist/cli/services/accounts-service.d.ts +7 -0
- package/dist/cli/services/accounts-service.js +20 -0
- package/dist/cli/services/remote-client.d.ts +16 -0
- package/dist/cli/services/remote-client.js +141 -0
- package/dist/cli/services/sessions-service.d.ts +5 -0
- package/dist/cli/services/sessions-service.js +17 -1
- package/dist/cli/tui/app.d.ts +4 -0
- package/dist/cli/tui/app.js +5 -0
- package/dist/cli/tui/terminal.d.ts +6 -0
- package/dist/cli/tui/terminal.js +8 -1
- package/dist/cli/validate.js +8 -4
- package/dist/core/config/defaults.js +11 -1
- package/dist/core/local-config/file.js +16 -2
- package/dist/core/local-config/overrides.js +7 -0
- package/dist/core/package-info.js +1 -1
- package/dist/core/run/versions.js +9 -8
- package/dist/core/schema/config.d.ts +22 -3
- package/dist/core/schema/config.js +39 -2
- package/dist/core/schema/web.d.ts +51 -0
- package/dist/core/schema/web.js +46 -1
- package/dist/core/util/fs.d.ts +5 -0
- package/dist/core/util/fs.js +23 -0
- package/dist/core/util/index.d.ts +1 -1
- package/dist/core/util/index.js +1 -1
- package/dist/guest/daemon/daemon.js +2 -0
- package/dist/guest/daemon/idle-shutdown.js +2 -1
- package/dist/guest/daemon/sessions.d.ts +16 -2
- package/dist/guest/daemon/sessions.js +126 -20
- package/dist/guest/git/decision.js +7 -0
- package/dist/guest/sessions/cmdline.d.ts +14 -0
- package/dist/guest/sessions/cmdline.js +10 -0
- package/dist/guest/toolchains/setup.js +3 -0
- package/dist/host/audit/log.d.ts +3 -2
- package/dist/host/audit/log.js +3 -3
- package/dist/host/channel/channel.d.ts +5 -1
- package/dist/host/channel/channel.js +12 -0
- package/dist/host/guard/agent-files.js +7 -1
- package/dist/host/guard/commands.js +13 -1
- package/dist/host/guard/git-config.js +27 -7
- package/dist/host/guard/git-dirs.js +78 -3
- package/dist/host/guard/mark-of-the-web.js +3 -1
- package/dist/host/guard/nested-repos.js +7 -2
- package/dist/host/guard/store.js +1 -0
- package/dist/host/guard/types.d.ts +2 -0
- package/dist/host/launch/mappings.js +11 -1
- package/dist/host/launch/project-scan.js +2 -21
- package/dist/host/launch/stage.js +4 -0
- package/dist/host/life/session-ledger.js +2 -1
- package/dist/host/remote/access-store.d.ts +156 -0
- package/dist/host/remote/access-store.js +308 -0
- package/dist/host/remote/cert.d.ts +20 -0
- package/dist/host/remote/cert.js +103 -0
- package/dist/host/remote/door-client.d.ts +13 -0
- package/dist/host/remote/door-client.js +54 -0
- package/dist/host/remote/firewall.d.ts +44 -0
- package/dist/host/remote/firewall.js +118 -0
- package/dist/host/remote/gate.d.ts +125 -0
- package/dist/host/remote/gate.js +391 -0
- package/dist/host/remote/index.d.ts +8 -0
- package/dist/host/remote/index.js +10 -0
- package/dist/host/remote/lan.d.ts +24 -0
- package/dist/host/remote/lan.js +88 -0
- package/dist/host/remote/tls-store.d.ts +22 -0
- package/dist/host/remote/tls-store.js +63 -0
- package/dist/host/remote/windows-helper.d.ts +67 -0
- package/dist/host/remote/windows-helper.js +129 -0
- package/dist/host/sandbox/platform.d.ts +2 -0
- package/dist/host/sandbox/platform.js +19 -4
- package/dist/host/storage/contained/state.d.ts +3 -2
- package/dist/host/storage/contained/state.js +5 -2
- package/dist/host/web/bridge.d.ts +5 -1
- package/dist/host/web/bridge.js +6 -2
- package/dist/host/web/browser.js +1 -1
- package/dist/host/web/control.d.ts +4 -1
- package/dist/host/web/control.js +28 -4
- package/dist/host/web/rpc.d.ts +16 -2
- package/dist/host/web/rpc.js +37 -4
- package/dist/host/web/runner.d.ts +4 -0
- package/dist/host/web/runner.js +12 -0
- package/dist/host/web/server.d.ts +18 -2
- package/dist/host/web/server.js +97 -24
- package/dist/host/web/state.d.ts +5 -0
- package/dist/host/web/state.js +8 -0
- package/dist/runtime/agent.js +74 -3
- package/dist/runtime/daemon.js +205 -24
- package/dist/runtime/db.js +74 -3
- package/dist/runtime/gitguard.js +6 -0
- package/dist/runtime/main.js +77 -3
- package/dist/runtime/netproxy.js +74 -3
- package/dist/runtime/view.js +76 -3
- package/dist/selftest/app.test.js +41 -6
- package/dist/selftest/channel.test.js +16 -0
- package/dist/selftest/cmdline.test.js +16 -0
- package/dist/selftest/git-access.test.js +8 -0
- package/dist/selftest/hostile-input.test.js +12 -1
- package/dist/selftest/index.js +2 -0
- package/dist/selftest/layout.test.js +10 -1
- package/dist/selftest/live-daemon.test.js +25 -0
- package/dist/selftest/local-config.test.js +30 -0
- package/dist/selftest/menu-perf.test.js +198 -12
- package/dist/selftest/menu.test.js +4 -0
- package/dist/selftest/remote.test.d.ts +2 -0
- package/dist/selftest/remote.test.js +536 -0
- package/dist/selftest/repo-guard.test.js +56 -2
- package/dist/selftest/resume.test.js +2 -1
- package/dist/selftest/sharing.test.js +9 -2
- package/dist/selftest/support/fixtures.d.ts +13 -0
- package/dist/selftest/support/web-preview.js +63 -0
- package/dist/selftest/web.test.js +2 -0
- package/dist/shared/channel/schema.d.ts +23 -0
- package/dist/shared/channel/schema.js +8 -0
- package/dist/shared/pty/protocol.d.ts +2 -0
- package/dist/shared/pty/protocol.js +2 -0
- package/dist/web/index.html +1 -1
- package/dist/web/main-NBQ3LF4E.js +221 -0
- package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/Program.cs +17 -1
- package/native/csharp/HostTools/WebHelper.cs +499 -0
- package/native/scripts/get-network-profile.ps1 +19 -0
- package/native/scripts/set-folder-access.ps1 +11 -0
- package/native/scripts/set-web-firewall.ps1 +103 -0
- package/package.json +1 -1
- package/sandbox/csharp/PtyHost.cs +20 -0
- package/schemas/config.schema.json +65 -0
- package/schemas/sandbox-config.schema.json +0 -18
- package/dist/web/main-UD4QCXZR.js +0 -219
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// Remote access (docs/design/remote-access.md): the access list (one-time codes, sealed token hashes, expiry,
|
|
2
|
+
// revocation), roles failing closed on every RPC call, Windows sign-in with a fake helper (bound cookies, refusals),
|
|
3
|
+
// device sign-in on the LAN listener (cookies, limits, what each listener accepts), the LAN address and peer rules, the
|
|
4
|
+
// certificate, the firewall script's checks (dry run), the server identity check, and that a repo cannot turn any of
|
|
5
|
+
// it on.
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import net from 'node:net';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import WebSocket from 'ws';
|
|
13
|
+
import { OPERATIONS, QUERIES } from '../app/features/defs.js';
|
|
14
|
+
import { controlSettingAccess } from '../app/features/settings/index.js';
|
|
15
|
+
import { accessNeeded, allows, OWNER } from '../app/kernel/index.js';
|
|
16
|
+
import { createRpcHost } from '../app/registry.js';
|
|
17
|
+
import { DEFAULTS } from '../core/config/index.js';
|
|
18
|
+
import { normalizeOverrides } from '../core/local-config/overrides.js';
|
|
19
|
+
import { AuditLog } from '../host/audit/index.js';
|
|
20
|
+
import { AccessStore, addRuleRequest, applyFirewall, checkFirewall, chooseLanAddress, CODE_MAX_WRONG, deviceName, FIREWALL_RULE_NAME, isPrivateIPv4, knockProblem, lanPeerProblem, makeCertificate, networkProblem, normalizeCode, parseDoorAnswer, parseHelperLine, RemoteGate, removeRuleRequest, spawnRunner, TlsStore, } from '../host/remote/index.js';
|
|
21
|
+
import { SecretStore } from '../host/secrets/index.js';
|
|
22
|
+
import { WebControl, WebServer, WebStore } from '../host/web/index.js';
|
|
23
|
+
import { CSRF_HEADER, CSRF_VALUE } from '../shared/web/index.js';
|
|
24
|
+
const TOKEN = 'b'.repeat(64);
|
|
25
|
+
const OWNER_SID = 'S-1-5-21-1-2-3-1001';
|
|
26
|
+
const BOB_SID = 'S-1-5-21-1-2-3-1002';
|
|
27
|
+
const MALLORY_SID = 'S-1-5-21-1-2-3-1003';
|
|
28
|
+
const BOB = { sid: BOB_SID, user: 'PC\\bob' };
|
|
29
|
+
const DAY = 86_400_000;
|
|
30
|
+
/** A Windows helper stand-in: whoever the test says is at the other end of the next connection. */
|
|
31
|
+
class FakeHelper {
|
|
32
|
+
current = null;
|
|
33
|
+
names = { bob: BOB, alice: { sid: OWNER_SID, user: 'PC\\alice' } };
|
|
34
|
+
peer() {
|
|
35
|
+
return Promise.resolve(this.current);
|
|
36
|
+
}
|
|
37
|
+
lookup(name) {
|
|
38
|
+
return Promise.resolve(this.names[name.toLowerCase().replace(/^pc\\/, '')] ?? null);
|
|
39
|
+
}
|
|
40
|
+
onKnock() { }
|
|
41
|
+
onProblem() { }
|
|
42
|
+
stop() { }
|
|
43
|
+
}
|
|
44
|
+
const backend = {
|
|
45
|
+
info: () => Promise.resolve({
|
|
46
|
+
sandbox: 'stopped',
|
|
47
|
+
owner: null,
|
|
48
|
+
canUse: false,
|
|
49
|
+
harnesses: [],
|
|
50
|
+
defaultHarness: 'claude',
|
|
51
|
+
projects: [],
|
|
52
|
+
}),
|
|
53
|
+
sessions: () => Promise.resolve([]),
|
|
54
|
+
endpoint: () => null,
|
|
55
|
+
};
|
|
56
|
+
/** One request on its own connection (no keep-alive: the peer check is per connection). */
|
|
57
|
+
function send(port, method, url, headers = {}, body) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const req = http.request({ host: '127.0.0.1', port, method, path: url, agent: false, headers: { Host: '127.0.0.1:' + port, ...headers } }, (res) => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
res.on('data', (c) => chunks.push(c));
|
|
62
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
|
|
63
|
+
});
|
|
64
|
+
req.on('error', reject);
|
|
65
|
+
req.end(body);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function sameSite(port) {
|
|
69
|
+
return { Origin: 'http://127.0.0.1:' + port, [CSRF_HEADER]: CSRF_VALUE, 'Content-Type': 'application/json' };
|
|
70
|
+
}
|
|
71
|
+
function remoteSettings(over = {}) {
|
|
72
|
+
return { ...structuredClone(DEFAULTS.web.remote), ...over };
|
|
73
|
+
}
|
|
74
|
+
/** A fake host: one control operation, one owner operation, one query. */
|
|
75
|
+
function fakeHost() {
|
|
76
|
+
const ran = [];
|
|
77
|
+
const need = (id) => id === 'test.work'
|
|
78
|
+
? 'control'
|
|
79
|
+
: id === 'test.admin'
|
|
80
|
+
? 'owner'
|
|
81
|
+
: id === 'test.read'
|
|
82
|
+
? 'view'
|
|
83
|
+
: undefined;
|
|
84
|
+
return {
|
|
85
|
+
ran,
|
|
86
|
+
host: {
|
|
87
|
+
kind: (id) => (id === 'test.read' ? 'query' : need(id) ? 'operation' : undefined),
|
|
88
|
+
liveMs: () => undefined,
|
|
89
|
+
need,
|
|
90
|
+
run: async (id) => {
|
|
91
|
+
ran.push(id);
|
|
92
|
+
return { ok: true };
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Opens an RPC socket, sends one call, returns the first answer for it (or the close code). */
|
|
98
|
+
async function rpcCall(port, cookie, op) {
|
|
99
|
+
const ws = new WebSocket('ws://127.0.0.1:' + port + '/api/rpc', {
|
|
100
|
+
headers: { Cookie: cookie, Origin: 'http://127.0.0.1:' + port },
|
|
101
|
+
});
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const timer = setTimeout(() => resolve({ t: 'timeout' }), 4000);
|
|
104
|
+
ws.on('open', () => ws.send(JSON.stringify({ t: 'call', id: 'c1', op, input: {} })));
|
|
105
|
+
ws.on('message', (data) => {
|
|
106
|
+
const m = JSON.parse(data.toString());
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolve({ t: m.t, message: m.error?.message });
|
|
109
|
+
ws.close();
|
|
110
|
+
});
|
|
111
|
+
ws.on('unexpected-response', (_req, res) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
resolve({ closed: res.statusCode });
|
|
114
|
+
});
|
|
115
|
+
ws.on('error', () => undefined);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** A fake request on the LAN listener (a TLS socket from `address`). */
|
|
119
|
+
function lanRequest(address, headers = {}, method = 'POST') {
|
|
120
|
+
return {
|
|
121
|
+
method,
|
|
122
|
+
headers: {
|
|
123
|
+
host: '192.168.1.20:47801',
|
|
124
|
+
origin: 'https://192.168.1.20:47801',
|
|
125
|
+
[CSRF_HEADER]: CSRF_VALUE,
|
|
126
|
+
...headers,
|
|
127
|
+
},
|
|
128
|
+
socket: { encrypted: true, remoteAddress: address, localAddress: '192.168.1.20' },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function routeIO(body, out) {
|
|
132
|
+
return {
|
|
133
|
+
body: () => Promise.resolve(body),
|
|
134
|
+
json: (status, b, headers = {}) => (out.last = { status, body: b, headers }),
|
|
135
|
+
requireSameSite: () => undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const IFACES = {
|
|
139
|
+
'Wi-Fi': [
|
|
140
|
+
{ address: '192.168.1.20', netmask: '255.255.255.0', family: 'IPv4', mac: '', internal: false, cidr: null },
|
|
141
|
+
],
|
|
142
|
+
'vEthernet (Default Switch)': [
|
|
143
|
+
{ address: '172.30.80.1', netmask: '255.255.240.0', family: 'IPv4', mac: '', internal: false, cidr: null },
|
|
144
|
+
],
|
|
145
|
+
Ethernet: [{ address: '8.8.4.4', netmask: '255.255.255.0', family: 'IPv4', mac: '', internal: false, cidr: null }],
|
|
146
|
+
};
|
|
147
|
+
export const remoteSuite = {
|
|
148
|
+
name: 'remote',
|
|
149
|
+
title: 'remote access: codes, devices, Windows sign-in, roles, LAN rules, certificate, firewall checks',
|
|
150
|
+
run: async (t) => {
|
|
151
|
+
t.section('defaults and repo files');
|
|
152
|
+
const r = DEFAULTS.web.remote;
|
|
153
|
+
t.check(!r.windowsSignIn && r.users.length === 0 && !r.lan.enabled && r.rememberDays === 30 && r.lan.address === '', 'everything remote is off by default (loopback and the token only, as before)');
|
|
154
|
+
const repo = t.dir('repo');
|
|
155
|
+
t.check((await t.thrownMessage(() => normalizeOverrides({ web: { remote: { lan: { enabled: true } } } }, repo, 'x'))) !==
|
|
156
|
+
'' && (await t.thrownMessage(() => normalizeOverrides({ web: { enabled: true } }, repo, 'x'))) !== '', "a repo's .sandbox-config.json cannot turn on the web UI, LAN mode or Windows sign-in");
|
|
157
|
+
t.section('the access list: one-time codes and sealed hashes');
|
|
158
|
+
let clock = Date.parse('2026-10-01T00:00:00Z');
|
|
159
|
+
const dir = t.dir('web');
|
|
160
|
+
const store = new AccessStore(dir, SecretStore.memory(), { now: () => clock });
|
|
161
|
+
t.check(!store.codePending() && store.redeem('ABCD-EFGH', 'x', '192.168.1.5', 30).ok === false, 'no code: nothing to redeem');
|
|
162
|
+
const { code } = store.newDeviceCode('control');
|
|
163
|
+
t.check(/^[0-9A-HJKMNP-TV-Z]{8}$/.test(code), 'a code is 8 characters people can read and type');
|
|
164
|
+
t.check(normalizeCode(code.slice(0, 4).toLowerCase() + '-' + code.slice(4)) === code, 'lower case and a dash are fine');
|
|
165
|
+
t.check(normalizeCode('O0IL1234') === '00111234' && normalizeCode('ABC') === null && normalizeCode(42) === null, 'O, I and L read as 0 and 1; junk is no code');
|
|
166
|
+
const fileText = fs.readFileSync(path.join(dir, 'access.json'), 'utf8');
|
|
167
|
+
t.check(!fileText.includes(code) && /"sealed": "mem1:/.test(fileText), 'only a sealed list is written; the code itself is not in it');
|
|
168
|
+
const wrong = store.redeem('ZZZZ-ZZZZ', 'Phone', '192.168.1.5', 30);
|
|
169
|
+
t.check(!wrong.ok && wrong.reason === 'wrong' && store.codePending(), 'a wrong code is refused and the code stays');
|
|
170
|
+
const paired = store.redeem(code, 'Phone', '192.168.1.5', 30);
|
|
171
|
+
t.check(paired.ok && paired.device.role === 'control', 'the right code gives a device credential with the chosen role');
|
|
172
|
+
const credential = paired.ok ? paired.credential : '';
|
|
173
|
+
t.check(!store.codePending() && !store.redeem(code, 'Again', '192.168.1.6', 30).ok, 'a code works once');
|
|
174
|
+
const token = credential.split('.')[1] ?? '';
|
|
175
|
+
const sealed = SecretStore.memory().openJson(JSON.parse(fs.readFileSync(path.join(dir, 'access.json'), 'utf8')).sealed);
|
|
176
|
+
t.check(!JSON.stringify(sealed).includes(token) &&
|
|
177
|
+
sealed.devices[0]?.tokenHash === crypto.createHash('sha256').update(token).digest('hex'), 'only the SHA-256 of the device token is kept');
|
|
178
|
+
t.check(store.device(credential)?.name === 'Phone', 'the credential finds its device');
|
|
179
|
+
t.check(store.device(credential.replace(/.$/, (c) => (c === '0' ? '1' : '0'))) === null && store.device('junk') === null, 'a changed or malformed credential finds nothing');
|
|
180
|
+
store.newDeviceCode('view');
|
|
181
|
+
clock += 6 * 60_000;
|
|
182
|
+
const late = store.redeem('ABCD-EFGH', 'x', '192.168.1.5', 30);
|
|
183
|
+
t.check(!late.ok && !store.codePending(), 'a code runs out after 5 minutes');
|
|
184
|
+
const second = store.newDeviceCode('view');
|
|
185
|
+
for (let i = 0; i < CODE_MAX_WRONG; i++)
|
|
186
|
+
store.redeem('ZZZZ-ZZZY', 'x', '192.168.1.' + i, 30);
|
|
187
|
+
t.check(!store.codePending() && store.read().codeVoided && !store.redeem(second.code, 'x', '192.168.1.9', 30).ok, 'a code is void after ' + CODE_MAX_WRONG + ' wrong tries, and the PC is told');
|
|
188
|
+
clock += 31 * DAY;
|
|
189
|
+
t.check(store.device(credential) === null, 'a device is signed out after rememberDays (fixed, not renewed by use)');
|
|
190
|
+
const c2 = store.newDeviceCode('view');
|
|
191
|
+
const d2 = store.redeem(c2.code, 'Tablet', '192.168.1.7', 30);
|
|
192
|
+
const cred2 = d2.ok ? d2.credential : '';
|
|
193
|
+
t.check(d2.ok && store.revoke(d2.device.id) && store.device(cred2) === null, 'revoking a device ends it');
|
|
194
|
+
const c3 = store.newDeviceCode('view');
|
|
195
|
+
const d3 = store.redeem(c3.code, 'Laptop', '192.168.1.8', 30);
|
|
196
|
+
const win = store.issueWindows(BOB_SID, 'PC\\bob', 30);
|
|
197
|
+
store.newDeviceCode('view');
|
|
198
|
+
store.revokeAll();
|
|
199
|
+
t.check(d3.ok &&
|
|
200
|
+
store.device(d3.credential) === null &&
|
|
201
|
+
store.windowsGrant(win) === null &&
|
|
202
|
+
!store.codePending() &&
|
|
203
|
+
store.read().epoch === 1, 'revoke all ends every device and Windows sign-in, voids the pending code and moves the epoch');
|
|
204
|
+
const foreign = new AccessStore(dir, SecretStore.dpapi());
|
|
205
|
+
t.check(foreign.read().devices.length === 0, 'a list sealed by another box lets nobody in');
|
|
206
|
+
t.section('roles fail closed');
|
|
207
|
+
t.check(accessNeeded({ kind: 'query' }, {}) === 'view' && accessNeeded({ kind: 'operation' }, {}) === 'owner', 'without a word: a query needs view, an operation needs owner');
|
|
208
|
+
const control = OPERATIONS.filter((d) => accessNeeded(d, {}) === 'control')
|
|
209
|
+
.map((d) => d.id)
|
|
210
|
+
.sort();
|
|
211
|
+
t.equal(control.join(), [
|
|
212
|
+
'agents.start',
|
|
213
|
+
'apps.request',
|
|
214
|
+
'health.check',
|
|
215
|
+
'sandbox.hideWindow',
|
|
216
|
+
'sandbox.showWindow',
|
|
217
|
+
'sandbox.stop',
|
|
218
|
+
'sessions.close',
|
|
219
|
+
'sessions.restart',
|
|
220
|
+
].join(), 'only an explicit short list of operations is open to control');
|
|
221
|
+
t.check(OPERATIONS.filter((d) => d.id.startsWith('access.')).every((d) => accessNeeded(d, {}) === 'owner') &&
|
|
222
|
+
QUERIES.filter((d) => d.id === 'access.status' || d.id === 'files.browse').every((d) => accessNeeded(d, {}) === 'owner'), "remote access, its list and the folder picker are the owner's");
|
|
223
|
+
t.check(controlSettingAccess({ key: 'attach.auto' }) === 'control' &&
|
|
224
|
+
[
|
|
225
|
+
'web.remote.lan.enabled',
|
|
226
|
+
'web.enabled',
|
|
227
|
+
'mappings',
|
|
228
|
+
'secrets.strip',
|
|
229
|
+
'network.blockPrivateRanges',
|
|
230
|
+
'audit.enabled',
|
|
231
|
+
'sharing.otherUsers',
|
|
232
|
+
'storage.root',
|
|
233
|
+
'harnesses.claude.environment',
|
|
234
|
+
'attach',
|
|
235
|
+
].every((key) => controlSettingAccess({ key }) === 'owner') &&
|
|
236
|
+
controlSettingAccess({ key: 'attach.auto', scope: 'project' }) === 'owner', 'control may change only harmless settings in the user file; never web.*, folders, secrets, network, audit or sharing');
|
|
237
|
+
const viewer = { kind: 'device', id: 'x', name: 'x', role: 'view' };
|
|
238
|
+
const controller = { kind: 'windows', user: 'bob', role: 'control' };
|
|
239
|
+
t.check(allows(OWNER, 'owner') &&
|
|
240
|
+
!allows(controller, 'owner') &&
|
|
241
|
+
allows(controller, 'control') &&
|
|
242
|
+
!allows(viewer, 'control') &&
|
|
243
|
+
allows(viewer, 'view'), 'owner > control > view');
|
|
244
|
+
const realHost = createRpcHost({});
|
|
245
|
+
t.check(realHost.need('settings.set', { key: 'web.remote.users' }) === 'owner' &&
|
|
246
|
+
realHost.need('sandbox.status', {}) === 'view', 'the RPC host says what each call needs');
|
|
247
|
+
t.equal(await t.thrownMessage(() => realHost.run('settings.set', { key: 'web.remote.users', value: [] }, undefined, controller)), 'Only the owner of this PC can do that, at the PC.', 'createRpcHost refuses a call the role does not reach, before it runs');
|
|
248
|
+
t.section('Windows sign-in (fake helper): the pipe, bound cookies, roles on every call');
|
|
249
|
+
const helper = new FakeHelper();
|
|
250
|
+
let settings = remoteSettings({ windowsSignIn: true, users: [{ name: 'bob', role: 'view' }] });
|
|
251
|
+
const audit = [];
|
|
252
|
+
const gate = new RemoteGate({
|
|
253
|
+
store: new AccessStore(t.dir('web2'), SecretStore.memory()),
|
|
254
|
+
settings: () => settings,
|
|
255
|
+
ownerSid: OWNER_SID,
|
|
256
|
+
helper,
|
|
257
|
+
audit: (e) => audit.push(e),
|
|
258
|
+
port: () => 0,
|
|
259
|
+
});
|
|
260
|
+
const knock = (k) => gate.knock({ sid: BOB_SID, user: 'PC\\bob', integrity: 0x2000, appContainer: false, ...k });
|
|
261
|
+
t.check((await knock({ integrity: 0x1000 })).startsWith('refused') &&
|
|
262
|
+
(await knock({ appContainer: true })).startsWith('refused') &&
|
|
263
|
+
(await knock({ sid: OWNER_SID })).startsWith('refused') &&
|
|
264
|
+
(await knock({ sid: 'S-1-5-18' })).startsWith('refused') &&
|
|
265
|
+
(await knock({ sid: MALLORY_SID, user: 'PC\\mallory' })).startsWith('refused'), 'low integrity, AppContainers, services, the owner and users not on the list are refused');
|
|
266
|
+
settings = remoteSettings({ windowsSignIn: false, users: [{ name: 'bob', role: 'view' }] });
|
|
267
|
+
t.check((await knock({})).includes('Windows sign-in is off'), 'with web.remote.windowsSignIn off everyone is refused');
|
|
268
|
+
settings = remoteSettings({ windowsSignIn: true, users: [{ name: 'bob', role: 'view' }] });
|
|
269
|
+
const answer = await knock({});
|
|
270
|
+
const once = /^ok ([0-9a-f]{64}) \d+ view$/.exec(answer)?.[1] ?? '';
|
|
271
|
+
t.check(once !== '', 'a listed user gets a one-time link with their role');
|
|
272
|
+
t.check(audit.some((a) => a.outcome === 'refused' && a.who.includes('mallory')), 'refused knocks are in the audit log');
|
|
273
|
+
const { host, ran } = fakeHost();
|
|
274
|
+
const server = new WebServer({ port: 0, token: TOKEN, backend, staticRoot: t.dir('static'), rpc: host, gate });
|
|
275
|
+
await server.start();
|
|
276
|
+
try {
|
|
277
|
+
const port = server.port;
|
|
278
|
+
helper.current = { sid: MALLORY_SID, user: 'PC\\mallory' };
|
|
279
|
+
const stolen = await send(port, 'POST', '/api/login/once', sameSite(port), JSON.stringify({ token: once }));
|
|
280
|
+
t.check(stolen.status === 403, 'the one-time link only works for the Windows user it was made for');
|
|
281
|
+
const again = await knock({});
|
|
282
|
+
const once2 = /^ok ([0-9a-f]{64})/.exec(again)?.[1] ?? '';
|
|
283
|
+
helper.current = BOB;
|
|
284
|
+
const login = await send(port, 'POST', '/api/login/once', sameSite(port), JSON.stringify({ token: once2 }));
|
|
285
|
+
const setCookie = (login.headers['set-cookie'] ?? [])[0] ?? '';
|
|
286
|
+
t.check(login.status === 200 && /^sbxwin=[0-9a-f]{16}\.[0-9a-f]{64};/.test(setCookie), 'Bob signs in with the link');
|
|
287
|
+
t.check(/HttpOnly/.test(setCookie) &&
|
|
288
|
+
/SameSite=Strict/.test(setCookie) &&
|
|
289
|
+
/Max-Age=2592000/.test(setCookie) &&
|
|
290
|
+
!/Secure/.test(setCookie), 'his cookie: HttpOnly, SameSite=Strict, remembered 30 days (no Secure on plain loopback)');
|
|
291
|
+
const reuse = await send(port, 'POST', '/api/login/once', sameSite(port), JSON.stringify({ token: once2 }));
|
|
292
|
+
t.check(reuse.status === 401, 'the link works once');
|
|
293
|
+
const cookie = setCookie.split(';')[0];
|
|
294
|
+
const me = await send(port, 'GET', '/api/me', { Cookie: cookie });
|
|
295
|
+
t.check(me.status === 200 && JSON.parse(me.body).access === 'view', '/api/me says view');
|
|
296
|
+
helper.current = { sid: MALLORY_SID, user: 'PC\\mallory' };
|
|
297
|
+
t.check((await send(port, 'GET', '/api/me', { Cookie: cookie })).status === 401, "Bob's cookie is refused from another Windows user's connection");
|
|
298
|
+
helper.current = BOB;
|
|
299
|
+
const read = await rpcCall(port, cookie, 'test.read');
|
|
300
|
+
const work = await rpcCall(port, cookie, 'test.work');
|
|
301
|
+
const admin = await rpcCall(port, cookie, 'test.admin');
|
|
302
|
+
t.check(read.t === 'result', 'view: a query runs');
|
|
303
|
+
t.check(work.t === 'error' && work.message === 'Your access is view only.' && admin.t === 'error', 'view: operations are refused');
|
|
304
|
+
t.check(!ran.includes('test.work') && !ran.includes('test.admin'), 'a refused call never reaches the host');
|
|
305
|
+
t.check(audit.some((a) => a.what === 'test.work' && a.outcome === 'refused' && a.who.includes('bob')), 'refusals are written to the audit log with who did it');
|
|
306
|
+
t.check((await send(port, 'POST', '/api/stop', { ...sameSite(port), Cookie: cookie }, '{}')).status === 403, 'only the owner stops the web UI');
|
|
307
|
+
settings = remoteSettings({ windowsSignIn: true, users: [{ name: 'bob', role: 'control' }] });
|
|
308
|
+
const work2 = await rpcCall(port, cookie, 'test.work');
|
|
309
|
+
const admin2 = await rpcCall(port, cookie, 'test.admin');
|
|
310
|
+
t.check(work2.t === 'result' && admin2.t === 'error', 'control: control operations run, owner operations do not');
|
|
311
|
+
t.check(audit.some((a) => a.what === 'test.work' && a.outcome === 'ok'), 'what a remote user did is in the audit log');
|
|
312
|
+
// An open socket ends with the access.
|
|
313
|
+
const ws = new WebSocket('ws://127.0.0.1:' + port + '/api/rpc', {
|
|
314
|
+
headers: { Cookie: cookie, Origin: 'http://127.0.0.1:' + port },
|
|
315
|
+
});
|
|
316
|
+
let closedWith = 0;
|
|
317
|
+
ws.on('close', (c) => (closedWith = c));
|
|
318
|
+
ws.on('error', () => undefined);
|
|
319
|
+
await t.waitFor(() => ws.readyState === WebSocket.OPEN, 3000);
|
|
320
|
+
settings = remoteSettings({ windowsSignIn: true, users: [] });
|
|
321
|
+
await gate.recheck();
|
|
322
|
+
await t.waitFor(() => closedWith !== 0, 3000);
|
|
323
|
+
t.check(closedWith === 4001, 'taking a user off the list closes their open pages');
|
|
324
|
+
t.check((await send(port, 'GET', '/api/me', { Cookie: cookie })).status === 401, '...and their cookie no longer works');
|
|
325
|
+
helper.current = { sid: OWNER_SID, user: 'PC\\alice' };
|
|
326
|
+
const ownerLogin = await send(port, 'POST', '/api/login', sameSite(port), JSON.stringify({ token: TOKEN }));
|
|
327
|
+
const ownerCookie = ((ownerLogin.headers['set-cookie'] ?? [])[0] ?? '').split(';')[0];
|
|
328
|
+
t.check((await send(port, 'GET', '/api/me', { Cookie: ownerCookie })).status === 200, "the owner's token cookie works from the owner's connections");
|
|
329
|
+
helper.current = BOB;
|
|
330
|
+
t.check((await send(port, 'GET', '/api/me', { Cookie: ownerCookie })).status === 401, "the owner's cookie is refused from another user's connection (a cookie taken through another port is useless)");
|
|
331
|
+
helper.current = { sid: OWNER_SID, user: 'PC\\alice' };
|
|
332
|
+
t.check((await rpcCall(port, ownerCookie, 'test.admin')).t === 'result', 'the owner may do everything');
|
|
333
|
+
t.section('the server proves who it is (security review #10)');
|
|
334
|
+
const webDir = t.dir('webstate');
|
|
335
|
+
const webStore = new WebStore(webDir, () => undefined);
|
|
336
|
+
webStore.write({ pid: process.pid, port, token: TOKEN, startedAt: new Date().toISOString() });
|
|
337
|
+
t.check((await new WebControl(webStore).status()).running, 'our server answers the challenge with the token');
|
|
338
|
+
const squatter = http.createServer((_q, res) => res.end('{"ok":true}'));
|
|
339
|
+
await new Promise((resolve) => squatter.listen(0, '127.0.0.1', () => resolve()));
|
|
340
|
+
webStore.write({
|
|
341
|
+
pid: process.pid,
|
|
342
|
+
port: squatter.address().port,
|
|
343
|
+
token: TOKEN,
|
|
344
|
+
startedAt: new Date().toISOString(),
|
|
345
|
+
});
|
|
346
|
+
t.check(!(await new WebControl(webStore).status()).running, 'another program on the port is not taken for the web UI (no token link for it)');
|
|
347
|
+
squatter.close();
|
|
348
|
+
const worker = await send(port, 'GET', '/sw.js', { 'Service-Worker': 'script' });
|
|
349
|
+
t.includes(worker.body, 'unregister', 'a service worker someone else registered on this origin is told to go');
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await server.stop();
|
|
353
|
+
}
|
|
354
|
+
t.section('devices on the LAN listener (routes on a fake TLS request)');
|
|
355
|
+
const lanStore = new AccessStore(t.dir('web3'), SecretStore.memory());
|
|
356
|
+
const lanAudit = [];
|
|
357
|
+
const lanGate = new RemoteGate({
|
|
358
|
+
store: lanStore,
|
|
359
|
+
settings: () => remoteSettings({ lan: { enabled: true, port: 47801, address: '192.168.1.20' } }),
|
|
360
|
+
ownerSid: OWNER_SID,
|
|
361
|
+
helper: null,
|
|
362
|
+
audit: (e) => lanAudit.push(e),
|
|
363
|
+
port: () => 47800,
|
|
364
|
+
});
|
|
365
|
+
const out = {};
|
|
366
|
+
t.check((await lanGate.route(lanRequest('192.168.1.50'), '/api/pair', routeIO({ code: 'ABCD-EFGH' }, out))) &&
|
|
367
|
+
out.last?.status === 409, 'with no code pending, a device is told to ask on the PC (not counted as a wrong try)');
|
|
368
|
+
const lanCode = lanStore.newDeviceCode('view').code;
|
|
369
|
+
for (let i = 0; i < 5; i++)
|
|
370
|
+
await lanGate.route(lanRequest('192.168.1.66'), '/api/pair', routeIO({ code: 'ZZZZ-ZZZZ' }, out));
|
|
371
|
+
await lanGate.route(lanRequest('192.168.1.66'), '/api/pair', routeIO({ code: lanCode }, out));
|
|
372
|
+
t.check(out.last?.status === 429, 'five wrong codes a minute from one address: that address waits');
|
|
373
|
+
await lanGate.route(lanRequest('192.168.1.50'), '/api/pair', routeIO({ code: lanCode, name: 'Josh\x1b[31m phone' }, out));
|
|
374
|
+
const devCookie = String(out.last?.headers['Set-Cookie'] ?? '');
|
|
375
|
+
t.check(out.last?.status === 200, 'another address with the right code is let in');
|
|
376
|
+
t.check(/^__Host-sbxdev=[0-9a-f]{16}\.[0-9a-f]{64}; Path=\/; HttpOnly; SameSite=Strict; Max-Age=2592000; Secure$/.test(devCookie), 'the device cookie: __Host-, Secure, HttpOnly, SameSite=Strict, the remember time');
|
|
377
|
+
t.equal(lanStore.read().devices[0]?.name, 'Josh phone', 'a device name loses escape sequences');
|
|
378
|
+
t.check(deviceName('') === 'A device' && deviceName('x'.repeat(99)).length === 40, 'device names: a default, at most 40 characters');
|
|
379
|
+
t.check(lanAudit.some((a) => a.what === 'allow device' && a.outcome === 'ok'), 'allowing a device is in the audit log');
|
|
380
|
+
const devValue = devCookie.split(';')[0];
|
|
381
|
+
const lanCaller = await lanGate.caller(lanRequest('192.168.1.50', { cookie: devValue }, 'GET'), false);
|
|
382
|
+
t.check(lanCaller === null, 'a device credential is only taken while the LAN listener runs');
|
|
383
|
+
const loop = {
|
|
384
|
+
headers: { cookie: devValue },
|
|
385
|
+
socket: { remoteAddress: '127.0.0.1', localAddress: '127.0.0.1' },
|
|
386
|
+
};
|
|
387
|
+
t.check((await lanGate.caller(loop, false)) === null, 'the loopback listener never takes a device credential');
|
|
388
|
+
for (const p of ['/api/login', '/api/stop']) {
|
|
389
|
+
await lanGate.route(lanRequest('192.168.1.50'), p, routeIO({}, out));
|
|
390
|
+
t.check(out.last?.status === 404, 'the LAN listener has no ' + p);
|
|
391
|
+
}
|
|
392
|
+
await lanGate.route(loop, '/api/pair', routeIO({ code: 'x' }, out));
|
|
393
|
+
t.check(out.last?.status === 404, 'devices are not allowed through loopback');
|
|
394
|
+
t.check(lanGate.lanHostAllowed(undefined) === false &&
|
|
395
|
+
lanGate.lanOriginAllowed('https://192.168.1.20:47801', 'evil:1') === false, 'with no LAN listener no LAN Host is accepted');
|
|
396
|
+
t.section('LAN mode stays on the local network');
|
|
397
|
+
t.check(['10.0.0.1', '172.16.0.1', '172.31.255.1', '192.168.0.10'].every(isPrivateIPv4) &&
|
|
398
|
+
!['8.8.8.8', '172.32.0.1', '169.254.1.1', '127.0.0.1', '100.64.0.1', '::1', 'fe80::1', 'example'].some(isPrivateIPv4), 'only 10/8, 172.16/12 and 192.168/16 are private');
|
|
399
|
+
t.equal(chooseLanAddress('', IFACES).address, '192.168.1.20', 'empty setting: the private address of a real adapter (not vEthernet, not public)');
|
|
400
|
+
t.includes(await t.thrownMessage(() => chooseLanAddress('8.8.4.4', IFACES)), '8.8.4.4 is not a private address', 'a public address is refused, even one of this PC');
|
|
401
|
+
t.check((await t.thrownMessage(() => chooseLanAddress('172.30.80.1', IFACES))) !== '', "the sandbox's vEthernet address is refused");
|
|
402
|
+
t.check((await t.thrownMessage(() => chooseLanAddress('192.168.9.9', IFACES))) !== '', 'an address that is not this PC is refused');
|
|
403
|
+
const bound = { address: '192.168.1.20', netmask: '255.255.255.0', adapter: 'Wi-Fi' };
|
|
404
|
+
t.check(lanPeerProblem('192.168.1.50', '192.168.1.20', bound, IFACES) === null, 'a phone on the same network may connect');
|
|
405
|
+
t.check(lanPeerProblem('::ffff:192.168.1.50', '::ffff:192.168.1.20', bound, IFACES) === null, 'IPv4 written as IPv6 is read as IPv4');
|
|
406
|
+
t.check(lanPeerProblem('8.8.8.8', '192.168.1.20', bound, IFACES) !== null &&
|
|
407
|
+
lanPeerProblem('10.1.2.3', '192.168.1.20', bound, IFACES) !== null &&
|
|
408
|
+
lanPeerProblem('192.168.1.20', '192.168.1.20', bound, IFACES) !== null &&
|
|
409
|
+
lanPeerProblem('172.30.80.5', '192.168.1.20', bound, IFACES) !== null &&
|
|
410
|
+
lanPeerProblem('192.168.1.50', '172.30.80.1', bound, IFACES) !== null &&
|
|
411
|
+
lanPeerProblem('fe80::1', '192.168.1.20', bound, IFACES) !== null, 'public peers, other networks, this PC itself, the sandbox network and other local addresses are dropped');
|
|
412
|
+
t.check(networkProblem({ found: true, category: 'Private', name: 'Home' }, 'Home') === null &&
|
|
413
|
+
networkProblem({ found: true, category: 'Public', name: 'Cafe' }, null) !== null &&
|
|
414
|
+
networkProblem({ found: true, category: 'Private', name: 'Cafe' }, 'Home') !== null &&
|
|
415
|
+
networkProblem({ found: false, category: '', name: '' }, null) !== null, 'LAN mode runs only on a Private network, and only the one it was turned on for');
|
|
416
|
+
const own = os.networkInterfaces();
|
|
417
|
+
const real = Object.values(own)
|
|
418
|
+
.flatMap((l) => l ?? [])
|
|
419
|
+
.find((i) => i.family === 'IPv4' && !i.internal && isPrivateIPv4(i.address));
|
|
420
|
+
if (real) {
|
|
421
|
+
const lanTls = new TlsStore(t.dir('tls'), SecretStore.memory());
|
|
422
|
+
const probeGate = new RemoteGate({
|
|
423
|
+
store: new AccessStore(t.dir('web4'), SecretStore.memory()),
|
|
424
|
+
settings: () => remoteSettings({
|
|
425
|
+
lan: { enabled: true, port: 40000 + Math.floor(Math.random() * 20000), address: real.address },
|
|
426
|
+
}),
|
|
427
|
+
ownerSid: OWNER_SID,
|
|
428
|
+
helper: null,
|
|
429
|
+
audit: () => undefined,
|
|
430
|
+
tls: lanTls,
|
|
431
|
+
profile: () => Promise.resolve({ found: true, category: 'Private', name: 'Test' }),
|
|
432
|
+
port: () => 47800,
|
|
433
|
+
});
|
|
434
|
+
let served = 0;
|
|
435
|
+
await probeGate.startLan({ request: (_q, res) => (served++, res.end('x')), upgrade: () => undefined });
|
|
436
|
+
const state = probeGate.lanState();
|
|
437
|
+
t.check(state.running && state.url?.startsWith('https://' + real.address + ':') === true, 'the LAN listener binds the private address only, over HTTPS');
|
|
438
|
+
const port = Number(state.url?.split(':').pop());
|
|
439
|
+
const refused = await new Promise((resolve) => {
|
|
440
|
+
const s = net.connect({ host: real.address, port }, () => s.write('GET / HTTP/1.1\r\n\r\n'));
|
|
441
|
+
s.on('close', () => resolve(true));
|
|
442
|
+
s.on('error', () => resolve(true));
|
|
443
|
+
s.setTimeout(3000, () => resolve(false));
|
|
444
|
+
});
|
|
445
|
+
t.check(refused && served === 0, "a connection from this PC's own address is dropped before any HTTP");
|
|
446
|
+
const loopRefused = await new Promise((resolve) => {
|
|
447
|
+
const s = net.connect({ host: '127.0.0.1', port });
|
|
448
|
+
s.on('connect', () => (s.destroy(), resolve(false)));
|
|
449
|
+
s.on('error', () => resolve(true));
|
|
450
|
+
});
|
|
451
|
+
t.check(loopRefused, 'nothing listens on loopback or other addresses on the LAN port');
|
|
452
|
+
await probeGate.stop();
|
|
453
|
+
}
|
|
454
|
+
else
|
|
455
|
+
t.skip('no private address here: the live LAN listener check');
|
|
456
|
+
t.section('the certificate');
|
|
457
|
+
const cert = makeCertificate({
|
|
458
|
+
addresses: ['192.168.1.20'],
|
|
459
|
+
dnsNames: ['MYPC'],
|
|
460
|
+
now: Date.parse('2026-10-01T00:00:00Z'),
|
|
461
|
+
});
|
|
462
|
+
const x509 = new crypto.X509Certificate(cert.cert);
|
|
463
|
+
t.check(x509.subjectAltName === 'IP Address:192.168.1.20, DNS:MYPC' &&
|
|
464
|
+
!x509.ca &&
|
|
465
|
+
x509.checkIP('192.168.1.20') === '192.168.1.20', 'SAN with the IP address and the PC name; not a CA');
|
|
466
|
+
t.check(Date.parse(x509.validTo) - Date.parse('2026-10-01T00:00:00Z') <= 397 * DAY &&
|
|
467
|
+
x509.fingerprint256 === cert.fingerprint, 'valid 397 days at most; the fingerprint shown is the real one');
|
|
468
|
+
t.check(x509.checkPrivateKey(crypto.createPrivateKey(cert.key)) &&
|
|
469
|
+
x509.verify(x509.publicKey) &&
|
|
470
|
+
(x509.keyUsage ?? []).join() === '1.3.6.1.5.5.7.3.1', 'self-signed with its key, for server authentication only');
|
|
471
|
+
const tlsDir = t.dir('tls2');
|
|
472
|
+
const tls = new TlsStore(tlsDir, SecretStore.memory());
|
|
473
|
+
const first = tls.material(['192.168.1.20']);
|
|
474
|
+
const kept = tls.material(['192.168.1.21']);
|
|
475
|
+
t.check(first.fingerprint === kept.fingerprint, 'the certificate is kept (a new one would warn on every phone again)');
|
|
476
|
+
t.check(!fs.readFileSync(path.join(tlsDir, 'tls.json'), 'utf8').includes('PRIVATE KEY'), 'its private key is sealed on disk');
|
|
477
|
+
t.section('the firewall script checks everything again (dry run, nothing changes)');
|
|
478
|
+
const node = process.execPath;
|
|
479
|
+
t.check((await t.thrownMessage(() => addRuleRequest(47801, '8.8.8.8'))) !== '', 'no rule for a public address');
|
|
480
|
+
t.check((await t.thrownMessage(() => addRuleRequest(80, '192.168.1.20'))) !== '', 'no rule for a port below 1024');
|
|
481
|
+
const dry = await checkFirewall(addRuleRequest(47801, '192.168.1.20', node));
|
|
482
|
+
const addLine = dry.commands?.[1] ?? '';
|
|
483
|
+
t.check(dry.ok &&
|
|
484
|
+
addLine.includes("-DisplayName '" + FIREWALL_RULE_NAME + "'") &&
|
|
485
|
+
addLine.includes('-Profile Private') &&
|
|
486
|
+
addLine.includes('-RemoteAddress LocalSubnet') &&
|
|
487
|
+
addLine.includes('-LocalAddress 192.168.1.20') &&
|
|
488
|
+
addLine.includes('-LocalPort 47801') &&
|
|
489
|
+
addLine.includes('-Direction Inbound'), 'the rule: inbound TCP to that address and port, from the local subnet, Private networks only', JSON.stringify(dry));
|
|
490
|
+
const removeDry = await checkFirewall(removeRuleRequest());
|
|
491
|
+
t.check(removeDry.ok && removeDry.commands?.length === 1, 'removing touches only our rule');
|
|
492
|
+
const bad = async (req) => !(await checkFirewall(req)).ok;
|
|
493
|
+
const base = {
|
|
494
|
+
action: 'add',
|
|
495
|
+
ruleName: FIREWALL_RULE_NAME,
|
|
496
|
+
port: 47801,
|
|
497
|
+
localAddress: '192.168.1.20',
|
|
498
|
+
program: node,
|
|
499
|
+
};
|
|
500
|
+
t.check(await bad({ ...base, ruleName: 'Something else' }), 'the script refuses another rule name');
|
|
501
|
+
t.check(await bad({ ...base, localAddress: '8.8.8.8' }), 'the script refuses a public address');
|
|
502
|
+
t.check(await bad({ ...base, localAddress: '0.0.0.0' }), 'the script refuses 0.0.0.0');
|
|
503
|
+
t.check(await bad({ ...base, port: 445 }), 'the script refuses a low port');
|
|
504
|
+
t.check(await bad({ ...base, program: 'C:\\Windows\\System32\\cmd.exe' }), 'the script refuses a program that is not node.exe');
|
|
505
|
+
const tempNode = t.write(path.join(os.tmpdir(), 'agent-sandbox-test-' + process.pid, 'node.exe'), 'x');
|
|
506
|
+
t.check(await bad({ ...base, program: tempNode }), 'the script refuses a node.exe in a temp folder');
|
|
507
|
+
fs.rmSync(path.dirname(tempNode), { recursive: true, force: true });
|
|
508
|
+
t.check(await bad({ ...base, action: 'disable' }), 'the script refuses another action');
|
|
509
|
+
const tampered = await checkFirewall(addRuleRequest(47801, '192.168.1.20', node), (exe, args, ms) => {
|
|
510
|
+
const file = args[args.indexOf('-Json') + 1];
|
|
511
|
+
fs.writeFileSync(file, JSON.stringify({ ...base, localAddress: '192.168.1.99' }));
|
|
512
|
+
return spawnRunner(exe, args, ms);
|
|
513
|
+
});
|
|
514
|
+
t.check(!tampered.ok && tampered.message.includes('changed'), 'a request file changed after it was written is refused');
|
|
515
|
+
t.check((await t.thrownMessage(() => applyFirewall(removeRuleRequest()))).includes('self-test does not change the Windows Firewall'), 'the self-test never changes the firewall');
|
|
516
|
+
t.section('helper and door lines');
|
|
517
|
+
t.check(parseHelperLine('peer\t3\tok\t' + BOB_SID + '\tPC\\bob')?.kind === 'peer' &&
|
|
518
|
+
parseHelperLine('peer\t3\tnone\tservice session')?.kind === 'peer' &&
|
|
519
|
+
parseHelperLine('door\t1\t' + BOB_SID + '\t8192\t0\tPC\\bob')?.kind === 'door' &&
|
|
520
|
+
parseHelperLine('peer\tx\tok\tnot-a-sid\tbob') === null &&
|
|
521
|
+
parseHelperLine('door\t1\tS-1-5\t8192\t0') === null &&
|
|
522
|
+
parseHelperLine('anything') === null, "only the helper's own lines are read, SIDs checked");
|
|
523
|
+
t.check(knockProblem({ sid: BOB_SID, user: 'b', integrity: 0x2000, appContainer: false }, OWNER_SID) === null, 'a normal process of another user may knock');
|
|
524
|
+
const okAnswer = parseDoorAnswer('ok ' + 'c'.repeat(64) + ' 47800 control', 0, 'alice');
|
|
525
|
+
t.check(okAnswer.ok && okAnswer.port === 47800 && okAnswer.role === 'control', 'the door answer is read');
|
|
526
|
+
t.check(!parseDoorAnswer('', 3, 'alice').ok &&
|
|
527
|
+
!parseDoorAnswer('', 4, 'alice').ok &&
|
|
528
|
+
!parseDoorAnswer('ok short 47800 view', 0, 'alice').ok &&
|
|
529
|
+
!parseDoorAnswer('ok ' + 'c'.repeat(64) + ' 80 view', 0, 'alice').ok, 'a squatted pipe, no pipe, or a malformed answer is refused');
|
|
530
|
+
t.section('remote actions in the audit log');
|
|
531
|
+
const log = new AuditLog(t.dir('audit'), { days: 7, maxMb: 10 });
|
|
532
|
+
log.append([{ t: new Date().toISOString(), k: 'remote', p: 'sandbox.stop', a: 'ok', au: 'device Phone' }]);
|
|
533
|
+
const back = log.read({ kinds: ['remote'] });
|
|
534
|
+
t.check(back.length === 1 && back[0].au === 'device Phone' && back[0].p === 'sandbox.stop', 'a remote line is written and read back');
|
|
535
|
+
},
|
|
536
|
+
};
|
|
@@ -81,7 +81,9 @@ export const repoGuardSuite = {
|
|
|
81
81
|
t.write(path.join(repo6, '.git', 'modules', 'lib', 'hooks', 'post-checkout'), 'calc.exe');
|
|
82
82
|
fs.appendFileSync(path.join(repo6, 'tools', 'inner', '.git', 'config'), '\tfsmonitor = calc.exe\n');
|
|
83
83
|
t.write(path.join(repo6, 'other', '.git', 'hooks', 'pre-commit'), 'calc.exe');
|
|
84
|
+
t.write(path.join(repo6, 'build', '.git', 'hooks', 'pre-commit'), 'calc.exe');
|
|
84
85
|
const findings6 = repoGuard.guard(repo6, before6);
|
|
86
|
+
t.check(!fs.existsSync(path.join(repo6, 'build', '.git', 'hooks', 'pre-commit')), 'a repository made directly in build\\ (VS Code opens it too) is checked like any other');
|
|
85
87
|
t.check(!fs.existsSync(path.join(repo6, '.git', 'modules', 'lib', 'hooks', 'post-checkout')) &&
|
|
86
88
|
disabled('fsmonitor').test(fs.readFileSync(path.join(repo6, 'tools', 'inner', '.git', 'config'), 'utf8')) &&
|
|
87
89
|
!fs.existsSync(path.join(repo6, 'other', '.git', 'hooks', 'pre-commit')) &&
|
|
@@ -106,6 +108,54 @@ export const repoGuardSuite = {
|
|
|
106
108
|
t.check(findings7.filter((f) => f.kind === 'agent').length === 4 &&
|
|
107
109
|
!fs.existsSync(path.join(repo7, '.mcp.json')) &&
|
|
108
110
|
fs.existsSync(path.join(repo7, '.claude', 'settings.local.json')), 'Claude hooks, MCP servers, folder-open tasks and program paths in VS Code settings are quarantined, nothing else');
|
|
111
|
+
t.section('security review 2026-10: config spellings, hooksPath folders, broad permissions');
|
|
112
|
+
const repo8 = t.at('repo8');
|
|
113
|
+
t.write(path.join(repo8, '.git', 'config'), '[core]\n\thooksPath = .husky/_\n[alias]\n\tst = status\n');
|
|
114
|
+
t.write(path.join(repo8, '.husky', '_', 'pre-commit'), 'stub');
|
|
115
|
+
t.write(path.join(repo8, '.husky', 'pre-commit'), 'npm test');
|
|
116
|
+
t.write(path.join(repo8, '.husky', 'README.md'), 'not a hook');
|
|
117
|
+
t.write(path.join(repo8, '.claude', 'settings.local.json'), '{ "permissions": { "allow": ["Bash(npm test)"] } }');
|
|
118
|
+
const before8 = repoGuard.snapshot(repo8);
|
|
119
|
+
fs.appendFileSync(path.join(repo8, '.git', 'config'), [
|
|
120
|
+
'[filter "x]y"] smudge = calc.exe',
|
|
121
|
+
'[alias]',
|
|
122
|
+
'\tlg = ""!calc.exe',
|
|
123
|
+
'\tsh = \\',
|
|
124
|
+
'!calc.exe',
|
|
125
|
+
'\tco = checkout',
|
|
126
|
+
'[protocol]',
|
|
127
|
+
'\tallow = always',
|
|
128
|
+
'[url "ext::calc.exe "]',
|
|
129
|
+
'\tinsteadOf = https://',
|
|
130
|
+
'[submodule "lib"]',
|
|
131
|
+
'\tupdate = !calc.exe',
|
|
132
|
+
'\turl = https://example.com/lib.git',
|
|
133
|
+
'[remote "origin"]',
|
|
134
|
+
'\tvcs = evil',
|
|
135
|
+
'',
|
|
136
|
+
].join('\n'));
|
|
137
|
+
t.write(path.join(repo8, '.husky', 'pre-commit'), 'calc.exe');
|
|
138
|
+
t.write(path.join(repo8, '.husky', 'README.md'), 'edited');
|
|
139
|
+
t.write(path.join(repo8, '.claude', 'settings.local.json'), '{ "permissions": { "allow": ["Bash"] } }');
|
|
140
|
+
t.write(path.join(repo8, 'git.py'), 'calc');
|
|
141
|
+
const pathextHasPy = (process.env.PATHEXT ?? '').toLowerCase().split(';').includes('.py');
|
|
142
|
+
const found8 = repoGuard.guard(repo8, before8);
|
|
143
|
+
const config8 = readConfig(repo8);
|
|
144
|
+
t.check(disabled('smudge = calc').test(config8) &&
|
|
145
|
+
disabled('lg = ""!calc').test(config8) &&
|
|
146
|
+
disabled('!calc.exe').test(config8) &&
|
|
147
|
+
disabled('allow = always').test(config8) &&
|
|
148
|
+
disabled('insteadOf').test(config8) &&
|
|
149
|
+
disabled('update = !calc').test(config8) &&
|
|
150
|
+
disabled('vcs = evil').test(config8) &&
|
|
151
|
+
/^\tco = checkout$/m.test(config8) &&
|
|
152
|
+
/^\turl = https:\/\/example.com\/lib.git$/m.test(config8), 'git config: a ] in a quoted subsection, quoted or continued "!" aliases, [protocol], [url], submodule "!" updates and remote vcs helpers are disabled; plain lines stay', config8);
|
|
153
|
+
t.check(!fs.existsSync(path.join(repo8, '.husky', 'pre-commit')) &&
|
|
154
|
+
fs.existsSync(path.join(repo8, '.husky', '_', 'pre-commit')) &&
|
|
155
|
+
fs.existsSync(path.join(repo8, '.husky', 'README.md')) &&
|
|
156
|
+
found8.some((x) => x.kind === 'hook' && x.detail.includes('core.hooksPath')), "a changed hook in the folder the repo's own core.hooksPath points at (husky) is quarantined; other files there stay", JSON.stringify(found8));
|
|
157
|
+
t.check(found8.some((x) => x.kind === 'agent' && x.detail.includes('settings.local.json')), 'a whole-tool permission ("Bash") added to Claude settings is quarantined', JSON.stringify(found8));
|
|
158
|
+
t.check(!pathextHasPy || !fs.existsSync(path.join(repo8, 'git.py')), 'a planted command with an extension from PATHEXT (.py) is quarantined' + (pathextHasPy ? '' : ' (no .PY here)'));
|
|
109
159
|
t.section('programs from the sandbox are marked');
|
|
110
160
|
const marked = t.at('marked');
|
|
111
161
|
t.write(path.join(marked, 'setup.ps1'), 'calc');
|
|
@@ -115,10 +165,14 @@ export const repoGuardSuite = {
|
|
|
115
165
|
!markFromSandbox(path.join(marked, 'src', 'app.ts')) &&
|
|
116
166
|
!isMarked(path.join(marked, 'src', 'app.ts')) &&
|
|
117
167
|
fs.readFileSync(path.join(marked, 'setup.ps1'), 'utf8') === 'calc', 'a script gets the "from the internet" mark (the file itself is never written); source files are left alone');
|
|
118
|
-
const
|
|
168
|
+
const notes = [];
|
|
169
|
+
const stop = watchAndMark([marked], (n) => notes.push(n));
|
|
119
170
|
t.write(path.join(marked, 'bin', 'tool.exe'), 'MZ');
|
|
120
171
|
const live = await t.waitFor(() => isMarked(path.join(marked, 'bin', 'tool.exe')), 5000);
|
|
121
|
-
stop();
|
|
122
172
|
t.check(Boolean(live), 'while it watches, a program that appears is marked within a second or two');
|
|
173
|
+
// Writing the mark fires a change event for the same file: it must not be marked (and reported) again and again.
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
175
|
+
stop();
|
|
176
|
+
t.check(notes.length === 1, 'a marked program is marked once, not again every second', String(notes.length));
|
|
123
177
|
},
|
|
124
178
|
};
|