windows-development-sandbox 1.12.0 → 1.13.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.
Files changed (160) hide show
  1. package/README.md +1 -0
  2. package/config.example.json +11 -1
  3. package/dist/app/features/access/access.def.d.ts +190 -0
  4. package/dist/app/features/access/access.def.js +273 -0
  5. package/dist/app/features/access/access.run.d.ts +79 -0
  6. package/dist/app/features/access/access.run.js +129 -0
  7. package/dist/app/features/access/index.d.ts +2 -0
  8. package/dist/app/features/access/index.js +3 -0
  9. package/dist/app/features/agents/agent-entry.js +5 -0
  10. package/dist/app/features/agents/agents.def.js +1 -0
  11. package/dist/app/features/apps/apps.def.js +1 -0
  12. package/dist/app/features/audit/audit.def.d.ts +11 -2
  13. package/dist/app/features/audit/audit.def.js +7 -4
  14. package/dist/app/features/audit/audit.run.d.ts +3 -0
  15. package/dist/app/features/defs.d.ts +155 -3
  16. package/dist/app/features/defs.js +12 -1
  17. package/dist/app/features/files/files.def.js +1 -0
  18. package/dist/app/features/health/health.def.js +1 -0
  19. package/dist/app/features/sandbox/sandbox.def.js +3 -0
  20. package/dist/app/features/sessions/sessions.def.d.ts +36 -0
  21. package/dist/app/features/sessions/sessions.def.js +77 -9
  22. package/dist/app/features/sessions/sessions.run.d.ts +25 -2
  23. package/dist/app/features/sessions/sessions.run.js +71 -3
  24. package/dist/app/features/settings/settings.def.d.ts +8 -0
  25. package/dist/app/features/settings/settings.def.js +17 -0
  26. package/dist/app/features/web/web.def.d.ts +9 -0
  27. package/dist/app/features/web/web.def.js +26 -1
  28. package/dist/app/features/web/web.run.d.ts +3 -0
  29. package/dist/app/features/web/web.run.js +11 -3
  30. package/dist/app/kernel/access.d.ts +33 -0
  31. package/dist/app/kernel/access.js +44 -0
  32. package/dist/app/kernel/define.d.ts +14 -1
  33. package/dist/app/kernel/index.d.ts +1 -0
  34. package/dist/app/kernel/index.js +1 -0
  35. package/dist/app/kernel/rpc.d.ts +5 -2
  36. package/dist/app/registry.d.ts +4 -1
  37. package/dist/app/registry.js +24 -4
  38. package/dist/cli/commands/registry.js +2 -0
  39. package/dist/cli/commands/remote.d.ts +2 -0
  40. package/dist/cli/commands/remote.js +49 -0
  41. package/dist/cli/commands/sessions/reopen.js +3 -0
  42. package/dist/cli/commands/web.js +66 -0
  43. package/dist/cli/manifest.d.ts +63 -4
  44. package/dist/cli/manifest.js +33 -4
  45. package/dist/cli/menu/io-menu.d.ts +5 -3
  46. package/dist/cli/menu/io-menu.js +12 -5
  47. package/dist/cli/menu/remote-menu.d.ts +19 -0
  48. package/dist/cli/menu/remote-menu.js +108 -0
  49. package/dist/cli/menu/views.js +2 -0
  50. package/dist/cli/operations/handler.js +15 -1
  51. package/dist/cli/services/remote-client.d.ts +16 -0
  52. package/dist/cli/services/remote-client.js +141 -0
  53. package/dist/cli/services/sessions-service.d.ts +5 -0
  54. package/dist/cli/services/sessions-service.js +17 -1
  55. package/dist/core/config/defaults.js +11 -1
  56. package/dist/core/local-config/file.js +16 -2
  57. package/dist/core/local-config/overrides.js +7 -0
  58. package/dist/core/run/versions.js +9 -8
  59. package/dist/core/schema/config.d.ts +22 -3
  60. package/dist/core/schema/config.js +39 -2
  61. package/dist/core/schema/web.d.ts +51 -0
  62. package/dist/core/schema/web.js +46 -1
  63. package/dist/core/util/fs.d.ts +5 -0
  64. package/dist/core/util/fs.js +23 -0
  65. package/dist/core/util/index.d.ts +1 -1
  66. package/dist/core/util/index.js +1 -1
  67. package/dist/guest/daemon/daemon.js +2 -0
  68. package/dist/guest/daemon/idle-shutdown.js +2 -1
  69. package/dist/guest/daemon/sessions.d.ts +16 -2
  70. package/dist/guest/daemon/sessions.js +126 -20
  71. package/dist/guest/git/decision.js +7 -0
  72. package/dist/guest/sessions/cmdline.d.ts +14 -0
  73. package/dist/guest/sessions/cmdline.js +10 -0
  74. package/dist/guest/toolchains/setup.js +3 -0
  75. package/dist/host/audit/log.d.ts +3 -2
  76. package/dist/host/audit/log.js +3 -3
  77. package/dist/host/channel/channel.d.ts +5 -1
  78. package/dist/host/channel/channel.js +12 -0
  79. package/dist/host/guard/agent-files.js +7 -1
  80. package/dist/host/guard/commands.js +13 -1
  81. package/dist/host/guard/git-config.js +27 -7
  82. package/dist/host/guard/git-dirs.js +78 -3
  83. package/dist/host/guard/mark-of-the-web.js +3 -1
  84. package/dist/host/guard/nested-repos.js +7 -2
  85. package/dist/host/guard/store.js +1 -0
  86. package/dist/host/guard/types.d.ts +2 -0
  87. package/dist/host/launch/mappings.js +11 -1
  88. package/dist/host/launch/project-scan.js +2 -21
  89. package/dist/host/launch/stage.js +4 -0
  90. package/dist/host/life/session-ledger.js +2 -1
  91. package/dist/host/remote/access-store.d.ts +156 -0
  92. package/dist/host/remote/access-store.js +308 -0
  93. package/dist/host/remote/cert.d.ts +20 -0
  94. package/dist/host/remote/cert.js +103 -0
  95. package/dist/host/remote/door-client.d.ts +13 -0
  96. package/dist/host/remote/door-client.js +54 -0
  97. package/dist/host/remote/firewall.d.ts +44 -0
  98. package/dist/host/remote/firewall.js +118 -0
  99. package/dist/host/remote/gate.d.ts +125 -0
  100. package/dist/host/remote/gate.js +391 -0
  101. package/dist/host/remote/index.d.ts +8 -0
  102. package/dist/host/remote/index.js +10 -0
  103. package/dist/host/remote/lan.d.ts +24 -0
  104. package/dist/host/remote/lan.js +88 -0
  105. package/dist/host/remote/tls-store.d.ts +22 -0
  106. package/dist/host/remote/tls-store.js +63 -0
  107. package/dist/host/remote/windows-helper.d.ts +67 -0
  108. package/dist/host/remote/windows-helper.js +129 -0
  109. package/dist/host/web/bridge.d.ts +5 -1
  110. package/dist/host/web/bridge.js +6 -2
  111. package/dist/host/web/browser.js +1 -1
  112. package/dist/host/web/control.d.ts +4 -1
  113. package/dist/host/web/control.js +28 -4
  114. package/dist/host/web/rpc.d.ts +16 -2
  115. package/dist/host/web/rpc.js +37 -4
  116. package/dist/host/web/runner.d.ts +4 -0
  117. package/dist/host/web/runner.js +12 -0
  118. package/dist/host/web/server.d.ts +18 -2
  119. package/dist/host/web/server.js +97 -24
  120. package/dist/host/web/state.d.ts +5 -0
  121. package/dist/host/web/state.js +8 -0
  122. package/dist/runtime/agent.js +74 -3
  123. package/dist/runtime/daemon.js +205 -24
  124. package/dist/runtime/db.js +74 -3
  125. package/dist/runtime/gitguard.js +6 -0
  126. package/dist/runtime/main.js +77 -3
  127. package/dist/runtime/netproxy.js +74 -3
  128. package/dist/runtime/view.js +76 -3
  129. package/dist/selftest/app.test.js +41 -6
  130. package/dist/selftest/channel.test.js +16 -0
  131. package/dist/selftest/cmdline.test.js +16 -0
  132. package/dist/selftest/git-access.test.js +8 -0
  133. package/dist/selftest/hostile-input.test.js +12 -1
  134. package/dist/selftest/index.js +2 -0
  135. package/dist/selftest/live-daemon.test.js +25 -0
  136. package/dist/selftest/local-config.test.js +30 -0
  137. package/dist/selftest/remote.test.d.ts +2 -0
  138. package/dist/selftest/remote.test.js +536 -0
  139. package/dist/selftest/repo-guard.test.js +56 -2
  140. package/dist/selftest/resume.test.js +2 -1
  141. package/dist/selftest/sharing.test.js +9 -2
  142. package/dist/selftest/support/fixtures.d.ts +13 -0
  143. package/dist/selftest/support/web-preview.js +63 -0
  144. package/dist/selftest/web.test.js +2 -0
  145. package/dist/shared/channel/schema.d.ts +23 -0
  146. package/dist/shared/channel/schema.js +8 -0
  147. package/dist/shared/pty/protocol.d.ts +2 -0
  148. package/dist/shared/pty/protocol.js +2 -0
  149. package/dist/web/index.html +1 -1
  150. package/dist/web/main-Z2WYAE2Z.js +221 -0
  151. package/native/csharp/HostTools/Program.cs +17 -1
  152. package/native/csharp/HostTools/WebHelper.cs +499 -0
  153. package/native/scripts/get-network-profile.ps1 +19 -0
  154. package/native/scripts/set-folder-access.ps1 +11 -0
  155. package/native/scripts/set-web-firewall.ps1 +103 -0
  156. package/package.json +1 -1
  157. package/sandbox/csharp/PtyHost.cs +20 -0
  158. package/schemas/config.schema.json +65 -0
  159. package/schemas/sandbox-config.schema.json +0 -18
  160. package/dist/web/main-UD4QCXZR.js +0 -219
@@ -19,6 +19,8 @@ export const liveDaemonSuite = {
19
19
  Object.assign(session.harness, {
20
20
  arguments: ['/d', '/k', 'echo LIVE-AGENT-READY'],
21
21
  resumeArguments: ['/d', '/k', 'echo LIVE-AGENT-RESUMED'],
22
+ // The agent's own environment (where its keys live): a shell session must not get it.
23
+ environment: { LIVE_AGENT_KEY: 'sk-live-agent' },
22
24
  });
23
25
  t.writeJson(gen.sessionPath, session);
24
26
  const daemon = startDaemon(t, f, { launchPath: gen.launchPath, data, workspace });
@@ -82,6 +84,29 @@ export const liveDaemonSuite = {
82
84
  t.check(Boolean(ping?.ok) &&
83
85
  !fs.existsSync(path.join(daemon.run, 'responses', 'evil.json')) &&
84
86
  !fs.existsSync(path.join(daemon.run, 'requests', 'evil.json')), 'ping is answered; an unknown request kind gets no answer and is deleted', JSON.stringify(ping));
87
+ t.section('live: a plain shell session');
88
+ daemon.request({ kind: 'session', id: 'shell1', harness: 'shell', args: [], cwd: workspace, shell: 'cmd' });
89
+ const shell = await daemon.started('shell1');
90
+ t.check(shell?.phase === 'running' && shell.harness === 'cmd' && shell.kind === 'shell' && Boolean(shell.attach), 'a shell session runs through ptyhost and is listed by its shell, marked as a shell', JSON.stringify(shell) + '\n' + daemon.diagnostics());
91
+ if (shell?.attach) {
92
+ const shellView = await openViewer(shell.attach);
93
+ viewers.push(shellView);
94
+ // Typing before cmd has drawn its prompt can lose the keystrokes: wait for the prompt first.
95
+ await sees(t, shellView, '>');
96
+ shellView.type('echo SHELL-[%LIVE_AGENT_KEY%]-%USERNAME:~0,0%OK');
97
+ const sawShell = await sees(t, shellView, 'SHELL-[%LIVE_AGENT_KEY%]-OK');
98
+ t.check(Boolean(sawShell), "keystrokes reach the shell, and it has none of the agent's own environment", 'screen so far: ' + tail(shellView.output()) + '\n' + daemon.diagnostics());
99
+ daemon.request({ kind: 'close', id: 'close1', session: 'shell1' });
100
+ const closed = await daemon.response('close1');
101
+ const shellEnded = await t.waitFor(() => {
102
+ const s = daemon.status('shell1');
103
+ return s?.phase === 'exited' ? s : null;
104
+ }, WAIT.exit);
105
+ t.check(Boolean(closed?.ok) && shellEnded !== null && shellView.exitCode() !== null, 'close ends the shell: the sandbox answers, the status says exited and the attached client gets the exit', JSON.stringify(closed) + ' ' + JSON.stringify(daemon.status('shell1')));
106
+ daemon.request({ kind: 'close', id: 'close2', session: 'shell1' });
107
+ const again = await daemon.response('close2');
108
+ t.check(again?.ok === false, 'closing a session that has ended says it is not running', JSON.stringify(again));
109
+ }
85
110
  t.section('live: restart and exit');
86
111
  daemon.request({ kind: 'restart', id: 'restart1', harness: 'claude' });
87
112
  const sawResumed = await sees(t, viewer, 'LIVE-AGENT-RESUMED');
@@ -61,6 +61,28 @@ export const localConfigSuite = {
61
61
  await rejectedBecause({ claudeProfile: { hostDir: 'C:\\x' } }, 'claudeProfile.hostDir is not allowed', 'a project file cannot point at the Claude profile folder');
62
62
  await rejectedBecause({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed', 'a project file cannot point at host tool folders');
63
63
  await rejectedBecause({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:', 'values are type-checked (memory too small)');
64
+ // Security review 2026-10: what a repo could reach through fields that looked harmless.
65
+ const secretFile = path.join(f.shared, 'id_ed25519');
66
+ fs.writeFileSync(secretFile, 'PRIVATE KEY');
67
+ await rejectedBecause({ scripts: [secretFile] }, 'only files inside the project', 'a project file cannot name a script outside the project (it would copy that PC file into the launch folder)');
68
+ await rejectedBecause({ scripts: [path.relative(dirC, secretFile)] }, 'only files inside the project', 'a relative ..\\ script path outside the project is refused too');
69
+ fs.symlinkSync(f.shared, path.join(dirC, 'linked'), 'junction');
70
+ await rejectedBecause({ scripts: ['./linked/id_ed25519'] }, 'only files inside the project', 'a script reached through a link to a folder outside the project is refused');
71
+ fs.rmSync(path.join(dirC, 'linked'));
72
+ for (const name of [
73
+ 'AGENT_GIT_ACCESS',
74
+ 'GIT_CONFIG_COUNT',
75
+ 'ANTHROPIC_BASE_URL',
76
+ 'NODE_OPTIONS',
77
+ 'Path',
78
+ 'https_proxy',
79
+ ])
80
+ await rejectedBecause({ toolchains: { environment: { [name]: 'x' } } }, 'cannot be set by a project', 'a project file cannot set ' + name + ' in toolchains.environment');
81
+ await rejectedBecause({ sandbox: { reserveHostMb: 0 } }, 'sandbox.reserveHostMb is not allowed', 'a project file cannot lower the RAM kept for this PC');
82
+ f.writeLocal(dirC, { toolchains: { environment: { NPM_CONFIG_REGISTRY: 'https://registry.example' } } });
83
+ t.equal((await f.run({ cwd: dirC, trustConfig: true })).config.toolchains.environment.NPM_CONFIG_REGISTRY, 'https://registry.example', 'a registry mirror in toolchains.environment is still fine');
84
+ f.writeLocal(dirC, { provisioning: { copyFromHost: true } });
85
+ t.equal((await f.run({ cwd: dirC, trustConfig: true }, { ...f.baseConfig, provisioning: { ...f.baseConfig.provisioning, copyFromHost: false } })).config.provisioning.copyFromHost, false, 'a project file cannot turn on copying Node, Git and the agent from this PC when you turned it off');
64
86
  await rejectedBecause('{\n // a comment\n "readOnly": tru\n}', 'line 3', 'broken JSON is reported with its line');
65
87
  await rejectedBecause({ _comment: 'old style' }, '_comment is not allowed', 'unknown keys are rejected');
66
88
  f.writeLocal(dirC, '// comments and trailing commas are fine\n{\n "readOnly": true, /* inline */\n}\n');
@@ -109,6 +131,14 @@ export const localConfigSuite = {
109
131
  t.check(viaVariable.includes('outside the project') &&
110
132
  viaLink.includes('outside the project') &&
111
133
  (await f.run({ cwd: dirD, trustConfig: true }, withOutside(false))).config.mappings.some((m) => m.host === path.join(dirD, 'assets')), 'outside means where the folder really is (%VAR% expanded, links followed); a folder inside needs no permission');
134
+ // A second mapping of part of the project must not undo readOnly or the workspace filter.
135
+ fs.mkdirSync(path.join(dirD, 'assets'), { recursive: true });
136
+ f.writeLocal(dirD, { mappings: [{ host: '.', guest: 'C:\\Data', readOnly: false }] });
137
+ const readOnlyUser = { ...f.baseConfig, readOnly: true };
138
+ const remapped = (await f.launch({ cwd: dirD, trustConfig: true }, { config: readOnlyUser })).mappings.find((m) => m.guest.toLowerCase() === 'c:\\data');
139
+ const filtered = await t.thrownMessage(() => f.launch({ cwd: dirD, trustConfig: true }, { config: { ...f.baseConfig, workspaceFilter: { ...f.baseConfig.workspaceFilter, enabled: true } } }));
140
+ t.check(remapped?.readOnly === true && filtered.includes('workspaceFilter.enabled'), 'a project folder mapped again is read-only when the project is, and refused while the workspace filter is on', JSON.stringify(remapped) + ' / ' + filtered);
141
+ fs.rmSync(path.join(dirD, '.sandbox-config.json'));
112
142
  t.section('a project can only tighten protections');
113
143
  // Settings that would loosen a protection are listed before approval, then ignored.
114
144
  f.writeLocal(dirC, {
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './support/suite.js';
2
+ export declare const remoteSuite: Suite;
@@ -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
+ };