windows-development-sandbox 1.1.0 → 1.4.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 (187) hide show
  1. package/README.md +3 -2
  2. package/config.example.json +38 -5
  3. package/dist/cli/args.d.ts +3 -0
  4. package/dist/cli/args.js +3 -1
  5. package/dist/cli/commands/accounts.js +2 -2
  6. package/dist/cli/commands/agent-commands.js +1 -1
  7. package/dist/cli/commands/context.js +2 -1
  8. package/dist/cli/commands/data/clean.js +6 -1
  9. package/dist/cli/commands/data/collect.js +3 -3
  10. package/dist/cli/commands/index.d.ts +1 -1
  11. package/dist/cli/commands/index.js +1 -1
  12. package/dist/cli/commands/launch.js +36 -3
  13. package/dist/cli/commands/maintenance.d.ts +2 -0
  14. package/dist/cli/commands/maintenance.js +35 -2
  15. package/dist/cli/commands/sessions.js +3 -3
  16. package/dist/cli/commands/user-config.js +1 -1
  17. package/dist/cli/index.js +6 -4
  18. package/dist/cli/manifest.js +5 -0
  19. package/dist/cli/menu/apps.d.ts +21 -0
  20. package/dist/cli/menu/apps.js +122 -0
  21. package/dist/cli/menu/command-form.d.ts +28 -0
  22. package/dist/cli/menu/command-form.js +219 -0
  23. package/dist/cli/menu/index.d.ts +26 -0
  24. package/dist/cli/menu/index.js +180 -0
  25. package/dist/cli/menu/settings/categories.d.ts +9 -0
  26. package/dist/cli/menu/settings/categories.js +28 -0
  27. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  28. package/dist/cli/menu/settings/config-file.js +71 -0
  29. package/dist/cli/menu/settings/editors.d.ts +30 -0
  30. package/dist/cli/menu/settings/editors.js +305 -0
  31. package/dist/cli/menu/settings/index.d.ts +32 -0
  32. package/dist/cli/menu/settings/index.js +136 -0
  33. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  34. package/dist/cli/menu/settings/schema-node.js +104 -0
  35. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  36. package/dist/cli/menu/several.js +102 -0
  37. package/dist/cli/tui/app.d.ts +39 -0
  38. package/dist/cli/tui/app.js +52 -0
  39. package/dist/cli/tui/dialogs.d.ts +57 -0
  40. package/dist/cli/tui/dialogs.js +162 -0
  41. package/dist/cli/tui/frame.d.ts +18 -0
  42. package/dist/cli/tui/frame.js +27 -0
  43. package/dist/cli/tui/index.d.ts +7 -0
  44. package/dist/cli/tui/index.js +8 -0
  45. package/dist/cli/tui/list-screen.d.ts +101 -0
  46. package/dist/cli/tui/list-screen.js +299 -0
  47. package/dist/cli/tui/terminal.d.ts +37 -0
  48. package/dist/cli/tui/terminal.js +89 -0
  49. package/dist/cli/tui/text.d.ts +20 -0
  50. package/dist/cli/tui/text.js +105 -0
  51. package/dist/cli/tui/theme.d.ts +45 -0
  52. package/dist/cli/tui/theme.js +30 -0
  53. package/dist/core/config.js +9 -6
  54. package/dist/core/local-config.d.ts +15 -0
  55. package/dist/core/local-config.js +63 -4
  56. package/dist/core/schema/apps.d.ts +40 -0
  57. package/dist/core/schema/apps.js +27 -0
  58. package/dist/core/schema/config.d.ts +66 -1
  59. package/dist/core/schema/config.js +18 -6
  60. package/dist/core/schema/profile.d.ts +5 -0
  61. package/dist/core/schema/profile.js +4 -2
  62. package/dist/core/schema/sandbox.d.ts +28 -0
  63. package/dist/core/schema/sandbox.js +28 -0
  64. package/dist/core/schema/toolchains.d.ts +3 -0
  65. package/dist/core/schema/toolchains.js +6 -1
  66. package/dist/core/types.d.ts +6 -0
  67. package/dist/core/util.d.ts +2 -0
  68. package/dist/core/util.js +2 -0
  69. package/dist/core/workspace.js +22 -5
  70. package/dist/guest/apps/index.d.ts +10 -0
  71. package/dist/guest/apps/index.js +206 -0
  72. package/dist/guest/apps/resolvers.d.ts +8 -0
  73. package/dist/guest/apps/resolvers.js +90 -0
  74. package/dist/guest/audit-parse.d.ts +7 -0
  75. package/dist/guest/audit-parse.js +24 -0
  76. package/dist/guest/audit.d.ts +4 -0
  77. package/dist/guest/audit.js +47 -0
  78. package/dist/guest/daemon/relays.d.ts +1 -0
  79. package/dist/guest/daemon/relays.js +18 -0
  80. package/dist/guest/daemon.js +4 -0
  81. package/dist/guest/env.d.ts +1 -0
  82. package/dist/guest/env.js +3 -1
  83. package/dist/guest/helpers.d.ts +1 -1
  84. package/dist/guest/main.js +15 -2
  85. package/dist/guest/netproxy.d.ts +1 -0
  86. package/dist/guest/netproxy.js +100 -0
  87. package/dist/guest/ptyhost.js +14 -4
  88. package/dist/guest/setup/claude.d.ts +0 -4
  89. package/dist/guest/setup/claude.js +48 -2
  90. package/dist/guest/setup/network.d.ts +9 -0
  91. package/dist/guest/setup/network.js +97 -4
  92. package/dist/guest/setup/user.js +4 -0
  93. package/dist/guest/toolchains/native/mesa.js +13 -3
  94. package/dist/guest/toolchains/plan.d.ts +3 -1
  95. package/dist/guest/toolchains/plan.js +53 -9
  96. package/dist/guest/toolchains/python.js +26 -2
  97. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  98. package/dist/guest/toolchains/vscode-profile.js +69 -0
  99. package/dist/guest/toolchains/vscode.d.ts +17 -0
  100. package/dist/guest/toolchains/vscode.js +87 -1
  101. package/dist/host/attach.js +2 -1
  102. package/dist/host/audit/collect.d.ts +34 -0
  103. package/dist/host/audit/collect.js +126 -0
  104. package/dist/host/audit/log.d.ts +46 -0
  105. package/dist/host/audit/log.js +143 -0
  106. package/dist/host/generate.d.ts +6 -0
  107. package/dist/host/generate.js +10 -0
  108. package/dist/host/launch/mappings.js +5 -1
  109. package/dist/host/launch/stage.js +1 -1
  110. package/dist/host/motw.d.ts +17 -0
  111. package/dist/host/motw.js +74 -0
  112. package/dist/host/platform.js +3 -0
  113. package/dist/host/port-forward.d.ts +8 -0
  114. package/dist/host/port-forward.js +37 -0
  115. package/dist/host/repo-guard.d.ts +10 -3
  116. package/dist/host/repo-guard.js +142 -14
  117. package/dist/host/stops.d.ts +67 -0
  118. package/dist/host/stops.js +126 -0
  119. package/dist/host/terminal.d.ts +8 -0
  120. package/dist/host/terminal.js +45 -1
  121. package/dist/host/workspace-view/index.d.ts +20 -0
  122. package/dist/host/workspace-view/index.js +53 -0
  123. package/dist/host/workspace-view/rules.d.ts +26 -0
  124. package/dist/host/workspace-view/rules.js +93 -0
  125. package/dist/host/workspace-view/view.d.ts +50 -0
  126. package/dist/host/workspace-view/view.js +281 -0
  127. package/dist/runtime/daemon.js +1319 -552
  128. package/dist/runtime/db.js +168 -13
  129. package/dist/runtime/main.js +2898 -608
  130. package/dist/runtime/netproxy.js +25936 -0
  131. package/dist/runtime/view.js +272 -23
  132. package/dist/selftest/audit.test.d.ts +2 -0
  133. package/dist/selftest/audit.test.js +57 -0
  134. package/dist/selftest/channel.test.js +7 -0
  135. package/dist/selftest/cli.test.js +47 -17
  136. package/dist/selftest/fixtures.d.ts +45 -0
  137. package/dist/selftest/fixtures.js +3 -1
  138. package/dist/selftest/generate.test.js +7 -2
  139. package/dist/selftest/index.js +5 -2
  140. package/dist/selftest/live-network.test.d.ts +2 -0
  141. package/dist/selftest/live-network.test.js +63 -0
  142. package/dist/selftest/local-config.test.js +16 -3
  143. package/dist/selftest/network.test.js +12 -0
  144. package/dist/selftest/projects.test.js +1 -1
  145. package/dist/selftest/repo-guard.test.js +59 -1
  146. package/dist/selftest/sync.test.js +50 -10
  147. package/dist/selftest/toolchains.test.js +30 -0
  148. package/dist/selftest/workspace-view.test.d.ts +2 -0
  149. package/dist/selftest/workspace-view.test.js +89 -0
  150. package/dist/shared/apps.d.ts +49 -0
  151. package/dist/shared/apps.js +114 -0
  152. package/dist/shared/netpolicy.d.ts +27 -0
  153. package/dist/shared/netpolicy.js +51 -0
  154. package/dist/shared/pty-client.d.ts +9 -4
  155. package/dist/shared/pty-client.js +139 -17
  156. package/dist/shared/rundir.d.ts +2 -0
  157. package/dist/shared/rundir.js +2 -0
  158. package/dist/shared/schema.d.ts +10 -0
  159. package/dist/shared/schema.js +2 -0
  160. package/dist/shared/system.d.ts +1 -0
  161. package/dist/shared/system.js +1 -0
  162. package/dist/sync/claude-mem.js +3 -2
  163. package/dist/sync/history.d.ts +6 -0
  164. package/dist/sync/history.js +26 -11
  165. package/dist/sync/watcher.js +78 -6
  166. package/package.json +2 -1
  167. package/sandbox/cargoshim.cs +56 -0
  168. package/sandbox/cpucap.cs +33 -0
  169. package/sandbox/cpucap.ps1 +4 -35
  170. package/sandbox/logon.cmd +6 -0
  171. package/sandbox/memlog.ps1 +34 -0
  172. package/sandbox/pagefile.cs +31 -0
  173. package/sandbox/pagefile.ps1 +21 -0
  174. package/sandbox/ptyhost.cs +105 -7
  175. package/schemas/config.schema.json +303 -2
  176. package/schemas/sandbox-config.schema.json +126 -12
  177. package/dist/cli/menu-multi.js +0 -82
  178. package/dist/cli/menu.d.ts +0 -14
  179. package/dist/cli/menu.js +0 -290
  180. package/dist/cli/settings/edit.d.ts +0 -13
  181. package/dist/cli/settings/edit.js +0 -19
  182. package/dist/cli/settings/index.d.ts +0 -8
  183. package/dist/cli/settings/index.js +0 -131
  184. package/dist/cli/settings/prompts.d.ts +0 -7
  185. package/dist/cli/settings/prompts.js +0 -48
  186. package/dist/cli/settings/schema.d.ts +0 -33
  187. package/dist/cli/settings/schema.js +0 -29
@@ -13,6 +13,7 @@ export const Sandbox = z.strictObject({
13
13
  maxMemoryMb: z.number().int().positive().describe('Upper limit for memoryMb "auto".'),
14
14
  maxMemoryPercent: z.number().int().min(10).max(100).describe('memoryMb "auto" never takes more than this share of this PC\'s total RAM (percent).'),
15
15
  cpuLimit: z.union([z.literal('none'), z.number().int().min(5).max(100)]).describe('Most of this PC\'s CPU the sandbox may use, in percent (5-100), e.g. 50 keeps half free for you; "none" = no cap (Windows Sandbox would take every processor). Covers everything this tool starts in the sandbox (agents, installs, builds, its terminal).'),
16
+ pageFileMb: z.number().int().min(0).max(262144).describe('Size of the sandbox\'s paging file in MB, created at full size when it starts so it never grows while builds run (8 GB of its disk always stays free). Windows Sandbox starts with about 1 GB: parallel builds then fail with "The paging file is too small", and Windows grows the file under load. 0 = leave Windows\' own.'),
16
17
  reserveHostMb: z.number().int().nonnegative().describe('RAM always left for this PC when memoryMb is "auto".'),
17
18
  vGpu: z.boolean().describe('Virtual GPU. Off by default: Microsoft notes it widens the sandbox attack surface.'),
18
19
  networking: z.boolean().describe('Internet access. false is the only hard network isolation Windows Sandbox offers.'),
@@ -22,6 +23,32 @@ export const Sandbox = z.strictObject({
22
23
  printerRedirection: z.boolean().describe('Printers from this PC.'),
23
24
  protectedClient: z.boolean().describe('Run the sandbox window in Protected Client mode (recommended).'),
24
25
  headless: z.boolean().describe('Hide the sandbox window (sbx-show brings it back). A headless sandbox shuts down when its last session ends.'),
26
+ keepAwake: z.boolean().describe('Keep this PC from sleeping while a sandbox runs, so agents left working overnight keep going (the screen may still turn off; closing a laptop lid still sleeps it).'),
27
+ });
28
+ const Port = z.number().int().min(1).max(65535);
29
+ const HostEntry = z.string().max(253).regex(/^(@developer|(\*\.)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+)$/, 'a host name like example.com, *.example.com, or @developer');
30
+ export const Network = z.strictObject({
31
+ blockPrivateRanges: z.boolean().describe('Block your LAN (10/8, 172.16/12, 192.168/16, SMB) from inside the sandbox.'),
32
+ allowOnly: z.array(HostEntry).describe('Only these hosts can be reached once the sandbox is set up (tools still download first). *.example.com covers its subdomains; @developer adds package registries, GitHub, toolchain downloads and the AI providers. Programs go through a proxy in the sandbox (HTTP_PROXY); anything else is blocked by its firewall. Empty = no limit.'),
33
+ blockHosts: z.array(z.string().max(253).regex(/^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/, 'a host name like telemetry.example.com')).describe('Never reachable from the sandbox, e.g. production servers or telemetry (each exact name; the sandbox\'s hosts file sends them nowhere).'),
34
+ forwardPorts: z.array(z.strictObject({
35
+ pc: Port.describe('The port on this PC (localhost only).'),
36
+ sandbox: Port.describe('The port inside the sandbox (a server there may listen on localhost only).'),
37
+ })).describe('Reach servers in the sandbox from this PC: http://localhost:<pc> here goes to <sandbox> inside, while it runs. Nothing else on your network can use them.'),
38
+ dns: z.array(z.string().regex(/^\d{1,3}(\.\d{1,3}){3}$/, 'an IPv4 address like 1.1.1.1')).describe('DNS servers for the sandbox instead of your network\'s, e.g. 1.1.1.1 and 9.9.9.9.'),
39
+ proxy: z.string().regex(/^(https?:\/\/[^\s]+)?$/, 'http://host:port').describe('An HTTP proxy the sandbox uses to reach the internet (HTTP_PROXY / HTTPS_PROXY for everything in it), e.g. http://proxy.corp:8080. Empty = none.'),
40
+ });
41
+ export const Audit = z.strictObject({
42
+ enabled: z.boolean().describe('Keep an audit log on this PC of what happened in your sandboxes: programs started (with their command lines), files changed in the project, sessions, and what the repo guard caught. The sandbox cannot change it. Read it with sandbox audit.'),
43
+ days: z.number().int().min(1).max(365).describe('Days the audit log is kept.'),
44
+ maxMb: z.number().int().min(5).max(5000).describe('Most space the audit log may take, in MB (older days go first). Days before today are compressed.'),
45
+ processes: z.boolean().describe('Record every program started in the sandbox with its command line (Windows process auditing; repeated runs are counted, not listed).'),
46
+ files: z.boolean().describe('Record the files changed in the project while the sandbox runs.'),
47
+ });
48
+ export const WorkspaceFilter = z.strictObject({
49
+ enabled: z.boolean().describe('Keep what your .gitignore lists (.env files, node_modules, build output ...) out of the sandbox. The sandbox works on a copy of the project without those files, kept in step with it both ways within about a second; what it creates that is ignored (its own node_modules, builds) stays in the sandbox. Git works as usual. Off = the project folder itself is mapped.'),
50
+ exclude: z.array(z.string().max(500)).describe('Also keep these out, as .gitignore patterns, e.g. secrets/, *.pem, data/private/.'),
51
+ include: z.array(z.string().max(500)).describe('Let these in although .gitignore lists them, e.g. .env.example, data/fixtures/.'),
25
52
  });
26
53
  export const Git = z.strictObject({ enabled: z.boolean().describe('Provide Git for Windows (and Git Bash, which Claude Code needs).'), access: GitAccess });
27
54
  export const Provisioning = z.strictObject({
@@ -30,6 +57,7 @@ export const Provisioning = z.strictObject({
30
57
  refresh: z.boolean().describe('Re-copy / re-download everything on the next launch.'),
31
58
  git: Git,
32
59
  terminal: toggle('Windows Terminal as the sandbox window (downloaded once).'),
60
+ conpty: toggle('Agent sessions use Microsoft\'s newer pseudo console (conpty.dll, downloaded once) instead of the one built into Windows: no garbled screen after resizing, real scrollback for the mouse wheel. Off = the one built into Windows.'),
33
61
  });
34
62
  export const Downloads = z.strictObject({
35
63
  requireChecksum: z.boolean().describe('Refuse any toolchain, database or tool download that has no published checksum (SHA-256/SHA-512) to verify it against. false = allow those, as before. Downloads that do have a published checksum are always verified. A project\'s .sandbox-config.json can only turn this on.'),
@@ -88,6 +88,9 @@ export declare const Toolchains: z.ZodObject<{
88
88
  }, z.core.$strict>;
89
89
  vscode: z.ZodObject<{
90
90
  enabled: z.ZodBoolean;
91
+ setup: z.ZodBoolean;
92
+ recommended: z.ZodBoolean;
93
+ extensions: z.ZodArray<z.ZodString>;
91
94
  }, z.core.$strict>;
92
95
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
93
96
  }, z.core.$strict>;
@@ -78,6 +78,11 @@ export const Toolchains = z.strictObject({
78
78
  enabled: z.boolean().describe('Mesa 3D (llvmpipe): OpenGL 4.5 in software for games and apps when there is no GPU (sandbox.vGpu off). Copy opengl32.dll and libgallium_wgl.dll from %MESA_DIR% next to the program\'s .exe.'),
79
79
  vulkan: z.boolean().describe('Also software Vulkan 1.4 (Mesa lavapipe) with the Khronos loader, registered for every program; for wgpu set WGPU_BACKEND=vulkan.'),
80
80
  }),
81
- vscode: toggle('Portable VS Code inside the sandbox (code on PATH, desktop shortcut); its settings and extensions persist.'),
81
+ vscode: z.strictObject({
82
+ enabled: z.boolean().describe('Portable VS Code inside the sandbox (code on PATH, desktop shortcut); its settings and extensions persist.'),
83
+ setup: z.boolean().describe('Point VS Code at the sandbox\'s own tools (JDK, Python, Go, compilers, .NET, Git, PowerShell 7 and Git Bash terminals) and install the language extensions for the toolchains in use.'),
84
+ recommended: z.boolean().describe('Also install the extensions a project recommends in its .vscode/extensions.json.'),
85
+ extensions: z.array(z.string().regex(/^[\w-]+\.[\w-]+$/, 'publisher.name, e.g. eamodio.gitlens')).describe('More extensions to install in every sandbox, e.g. eamodio.gitlens, ms-azuretools.vscode-docker.'),
86
+ }),
82
87
  environment: z.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'letters, digits and _ only'), z.string()).describe('Extra environment variables for the agent and every terminal, e.g. registry mirrors: {"NPM_CONFIG_REGISTRY": "https://...", "PIP_INDEX_URL": "https://..."}.'),
83
88
  });
@@ -187,4 +187,10 @@ export interface GuestSession {
187
187
  }[];
188
188
  /** sandbox.cpuLimit as a number (null = no cap); sandbox\cpucap.ps1 applies it before anything starts. */
189
189
  cpuLimitPercent?: number | null;
190
+ /** sandbox.pageFileMb (0 = Windows' own); sandbox\pagefile.ps1 applies it at logon. */
191
+ pageFileMb?: number;
192
+ /** Apps to install (catalogue ids and your own programs); missing in launch folders of older versions. */
193
+ apps?: Config['apps'];
194
+ /** audit.enabled and audit.processes: the sandbox reports the programs it starts (host keeps the log). */
195
+ auditProcesses?: boolean;
190
196
  }
@@ -34,6 +34,8 @@ export declare const isInsideOrEqual: (parent: string, child: string) => boolean
34
34
  * plain colours (SGR), so nothing written by the sandbox can drive your terminal (titles, clipboard, cursor tricks).
35
35
  */
36
36
  export declare const safeText: (text: string) => string;
37
+ /** safeText without colours either: for text you are asked to approve, which must not be able to hide parts of itself. */
38
+ export declare const plainText: (text: string) => string;
37
39
  export declare const findOnPath: (names: string[]) => string | null;
38
40
  /** True if `p` itself is a symlink or junction (lstat; never follows it). */
39
41
  export declare const isLink: (p: string) => boolean;
package/dist/core/util.js CHANGED
@@ -81,6 +81,8 @@ export const isInsideOrEqual = (parent, child) => {
81
81
  * plain colours (SGR), so nothing written by the sandbox can drive your terminal (titles, clipboard, cursor tricks).
82
82
  */
83
83
  export const safeText = (text) => text.replace(/\x1b\[[0-9;]*m|\x1b(\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[@-Z\\-_])|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (m) => (/^\x1b\[[0-9;]*m$/.test(m) ? m : ''));
84
+ /** safeText without colours either: for text you are asked to approve, which must not be able to hide parts of itself. */
85
+ export const plainText = (text) => safeText(text).replace(/\x1b\[[0-9;]*m/g, '');
84
86
  export const findOnPath = (names) => {
85
87
  const dirs = (process.env.PATH ?? process.env.Path ?? '').split(path.delimiter).filter((d) => d !== '');
86
88
  for (const name of names)
@@ -3,9 +3,9 @@ import path from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { planMirrorWorkspace } from '../shared/guest-paths.js';
5
5
  import { DATABASES } from './detect-databases.js';
6
- import { applyOverrides, describeOverrides, isTrusted, normalizeOverrides, readLocalConfig, saveTrust, trustStorePath } from './local-config.js';
6
+ import { allowedAnswer, allowRequests, allowStorePath, applyOverrides, describeOverrides, isTrusted, loosenedRequests, saveAllowed, normalizeOverrides, readLocalConfig, saveTrust, trustStorePath } from './local-config.js';
7
7
  import { assertSafeWorkspace } from './safe-paths.js';
8
- import { dirExists, expandPath, fail, fileExists } from './util.js';
8
+ import { dirExists, expandPath, fail, fileExists, plainText } from './util.js';
9
9
  /**
10
10
  * 'auto' guest workspace: C:\Workspace\<folder name>. Keeping the real folder name means Claude's per-project history
11
11
  * and claude-mem's project name match this PC instead of every project being "Workspace".
@@ -88,15 +88,32 @@ export const resolveRun = async (req, hostNodeVersion = process.version) => {
88
88
  if (!trusted && req.ask) {
89
89
  console.log('\nFound ' + local.path + ' which is not trusted yet. It would apply:');
90
90
  for (const line of summary)
91
- console.log(' ' + line);
91
+ console.log(' ' + plainText(line));
92
92
  trusted = await req.ask('Trust and use this file?');
93
93
  }
94
94
  if (!trusted)
95
- fail(local.path + ' has not been approved yet (a repo can ship this file, so it needs your OK). It would apply:\n ' + (summary.join('\n ') || '(no changes)'), 'Review it, then run again in an interactive terminal and answer y, or add --trust-config. To ignore the file: --no-local-config');
95
+ fail(local.path + ' has not been approved yet (a repo can ship this file, so it needs your OK). It would apply:\n ' + (summary.map(plainText).join('\n ') || '(no changes)'), 'Review it, then run again in an interactive terminal and answer y, or add --trust-config. To ignore the file: --no-local-config');
96
96
  saveTrust(store, local.path, local.hash);
97
97
  notes.push('Trusted ' + local.path + ' (you will only be asked again if the file changes).');
98
98
  }
99
- config = applyOverrides(config, overrides);
99
+ // A project may only tighten. What it asks for beyond that is used only if you allowed it for this project (asked
100
+ // once, interactively, never by --trust-config); otherwise it is named here instead of dropped silently.
101
+ const allowStore = allowStorePath(storageRoot);
102
+ const allowed = [];
103
+ for (const ask of loosenedRequests(config, overrides)) {
104
+ let ok = allowedAnswer(allowStore, local.path, ask.key, ask.wanted);
105
+ if (ok === null && req.ask && process.stdin.isTTY) {
106
+ ok = await req.ask('This project asks for ' + ask.key + ' = ' + JSON.stringify(ask.wanted) + ', which is looser than your settings (' + JSON.stringify(ask.yours) + '). Allow it for this project only?');
107
+ saveAllowed(allowStore, local.path, ask.key, ask.wanted, ok);
108
+ }
109
+ if (ok) {
110
+ allowed.push(ask);
111
+ notes.push('Allowed for this project: ' + ask.key + ' = ' + JSON.stringify(ask.wanted) + ' (your answer is kept in ' + allowStore + ').');
112
+ }
113
+ else
114
+ notes.push('The project asks for ' + ask.key + ' = ' + JSON.stringify(ask.wanted) + ', but your settings say ' + JSON.stringify(ask.yours) + ', so it is not used. ' + (ok === false ? 'You said no (kept in ' + allowStore + ').' : 'Start it from an interactive terminal to allow it for this project.') + ' Or set ' + ask.key + ' in your own settings (sandbox, then Settings > All projects).');
115
+ }
116
+ config = applyOverrides(allowRequests(config, allowed), overrides);
100
117
  scripts.push(...(overrides.scripts ?? []));
101
118
  localConfigPath = local.path;
102
119
  notes.push('Using ' + local.path);
@@ -0,0 +1,10 @@
1
+ import type { Config, GitAccess } from '../../core/types.js';
2
+ import { type ToolEnv } from '../toolchains/common.js';
3
+ export { appsNeedMesa } from '../../shared/apps.js';
4
+ export interface AppsOptions {
5
+ gitAccess: GitAccess;
6
+ refresh: boolean;
7
+ }
8
+ /** The program in an installed folder (breadth first, bounded: big apps have tens of thousands of files). */
9
+ export declare const findExe: (root: string, exe: string) => string | null;
10
+ export declare const setupApps: (env: ToolEnv, apps: Config["apps"], o: AppsOptions) => Promise<void>;
@@ -0,0 +1,206 @@
1
+ // Installs the apps you chose (apps.install from the catalogue, apps.custom of your own) into C:\AgentData\tools\apps,
2
+ // once each: later sandboxes find them there. Programs get a desktop and Start menu shortcut; command-line tools go on
3
+ // PATH. Installers are never run: archives are unpacked, NSIS installers opened with 7-Zip and MSIs extracted
4
+ // (msiexec /a), so nothing is registered and nothing is lost when the sandbox closes. One app failing never stops the
5
+ // others.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { appsToInstall, exeMatcher } from '../../shared/apps.js';
10
+ import { download, extractArchive, githubAsset, moveIntoPlace, stagingFolder } from '../../shared/download.js';
11
+ import { safeRemove } from '../../shared/safe-remove.js';
12
+ import { SYSTEM } from '../../shared/system.js';
13
+ import { dryRun, errMsg, execAsync, exists, log, mkdirp, powershell, warn } from '../env.js';
14
+ import { arch, tool } from '../toolchains/common.js';
15
+ import { RESOLVERS } from './resolvers.js';
16
+ export { appsNeedMesa } from '../../shared/apps.js';
17
+ const MARKER = '.agent-sandbox-app';
18
+ /** How many apps download and unpack at once (they are big: a few at a time keeps the disk and network busy). */
19
+ const PARALLEL = 3;
20
+ const sevenZip = () => tool('apps', '7zip', '7z.exe');
21
+ /** Where to download an entry from. */
22
+ const resolve = async (entry) => {
23
+ const s = entry.source;
24
+ if ('github' in s) {
25
+ const a = await githubAsset(s.github, new RegExp(s.asset), true);
26
+ return { url: a.url, fileName: a.name, sha256: a.sha256 };
27
+ }
28
+ if ('url' in s) {
29
+ const name = decodeURIComponent(new URL(s.url).pathname.split('/').pop() || entry.id);
30
+ return { url: s.url, fileName: /\.\w{2,5}$/.test(name) ? name : name + '.zip' };
31
+ }
32
+ if ('resolve' in s && RESOLVERS[s.resolve])
33
+ return RESOLVERS[s.resolve]();
34
+ throw new Error('no download source for ' + entry.name);
35
+ };
36
+ /** The program in an installed folder (breadth first, bounded: big apps have tens of thousands of files). */
37
+ export const findExe = (root, exe) => {
38
+ const match = exeMatcher(exe);
39
+ let queue = [root];
40
+ let seen = 0;
41
+ for (let depth = 0; depth < 7 && queue.length; depth++) {
42
+ const next = [];
43
+ for (const dir of queue) {
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ const hit = entries.find((e) => e.isFile() && match(e.name));
52
+ if (hit)
53
+ return path.join(dir, hit.name);
54
+ next.push(...entries.filter((e) => e.isDirectory() && !e.isSymbolicLink()).map((e) => path.join(dir, e.name)));
55
+ if ((seen += entries.length) > 50000)
56
+ return null;
57
+ }
58
+ queue = next;
59
+ }
60
+ return null;
61
+ };
62
+ /** Downloads and unpacks one entry into its folder (replaced in one step: never half there). */
63
+ const installApp = async (entry, dest) => {
64
+ const work = stagingFolder(dest);
65
+ const out = path.join(work, 'out');
66
+ try {
67
+ if ('npm' in entry.source) {
68
+ const npmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
69
+ log('Installing ' + entry.name + ' (npm) ...');
70
+ await execAsync(process.execPath, [npmCli, 'install', '--prefix', mkdirp(out), '--no-audit', '--no-fund', '--loglevel=error', entry.source.npm]);
71
+ }
72
+ else {
73
+ const d = await resolve(entry);
74
+ log('Downloading ' + entry.name + ' (' + d.fileName + ', ~' + entry.size + ' once) ...');
75
+ const file = path.join(mkdirp(work), d.fileName.replace(/[\\/:*?"<>|]/g, '_'));
76
+ await download(d.url, file, { sha256: d.sha256 ?? null });
77
+ await unpack(entry, file, out, d);
78
+ }
79
+ fs.writeFileSync(path.join(out, MARKER), JSON.stringify({ id: entry.id, at: new Date().toISOString() }));
80
+ moveIntoPlace(out, dest);
81
+ }
82
+ finally {
83
+ safeRemove(work, path.dirname(work));
84
+ }
85
+ };
86
+ const unpack = async (entry, file, out, d) => {
87
+ if (entry.id === '7zip') {
88
+ // 7zr.exe (the small standalone 7-Zip) opens the full 7-Zip's installer.
89
+ const sevenZr = path.join(path.dirname(file), '7zr.exe');
90
+ const zr = await githubAsset('ip7z/7zip', /^7zr\.exe$/);
91
+ await download(zr.url, sevenZr, zr.sha256);
92
+ await execAsync(sevenZr, ['x', '-y', '-o' + out, file], { stdio: 'ignore' });
93
+ return;
94
+ }
95
+ switch (entry.unpack ?? 'archive') {
96
+ case 'file': {
97
+ mkdirp(out);
98
+ fs.renameSync(file, path.join(out, /^\//.test(entry.exe) ? path.basename(file) : entry.exe));
99
+ return;
100
+ }
101
+ case 'nsis': {
102
+ if (!exists(sevenZip()))
103
+ throw new Error('7-Zip is needed to unpack ' + entry.name + ' and could not be installed');
104
+ const raw = path.join(path.dirname(out), 'raw');
105
+ await execAsync(sevenZip(), ['x', '-y', '-o' + raw, file], { stdio: 'ignore' });
106
+ // electron-builder installers carry the app as one more archive inside.
107
+ const inner = findExe(raw, '/^app-64\\.7z$/');
108
+ if (inner)
109
+ await execAsync(sevenZip(), ['x', '-y', '-o' + out, inner], { stdio: 'ignore' });
110
+ else
111
+ fs.renameSync(raw, out);
112
+ return;
113
+ }
114
+ case 'msi': {
115
+ // An administrative install only extracts the files: nothing is registered with Windows.
116
+ mkdirp(out);
117
+ await execAsync(SYSTEM.msiexec(), ['/a', file, '/qn', 'TARGETDIR=' + out], { stdio: 'ignore' });
118
+ return;
119
+ }
120
+ default:
121
+ await extractArchive(file, out, d.members);
122
+ }
123
+ };
124
+ /** Settings an app needs on every boot (the sandbox's registry is new each time), and files next to its program. */
125
+ const afterInstall = async (entry, exe) => {
126
+ const dir = path.dirname(exe);
127
+ if (entry.needs?.includes('mesa')) {
128
+ const mesa = tool('mesa', arch === 'arm64' ? 'arm64' : 'x64');
129
+ for (const dll of ['opengl32.dll', 'libgallium_wgl.dll', 'dxil.dll']) {
130
+ if (exists(path.join(mesa, dll)) && !exists(path.join(dir, dll)))
131
+ fs.copyFileSync(path.join(mesa, dll), path.join(dir, dll));
132
+ }
133
+ }
134
+ // Godot and Sublime keep their settings next to the program instead of in the profile, which is wiped on close.
135
+ if (entry.id === 'godot' && !exists(path.join(dir, '._sc_')))
136
+ fs.writeFileSync(path.join(dir, '._sc_'), '');
137
+ if (entry.id === 'sublime')
138
+ mkdirp(path.join(dir, 'Data'));
139
+ if (entry.id === 'sysinternals') {
140
+ for (const name of ['Process Explorer', 'Process Monitor', 'Autoruns', 'TCPView', 'PsExec', 'PsList', 'PsKill', 'Handle', 'ListDLLs', 'Sigcheck', 'Strings', 'VMMap', 'RAMMap', 'DebugView', 'Disk2vhd', 'Du', 'ZoomIt']) {
141
+ await execAsync(SYSTEM.reg(), ['add', 'HKCU\\Software\\Sysinternals\\' + name, '/v', 'EulaAccepted', '/t', 'REG_DWORD', '/d', '1', '/f'], { stdio: 'ignore' }).catch(() => undefined);
142
+ }
143
+ }
144
+ };
145
+ /** Desktop and Start menu shortcuts, all in one PowerShell call. */
146
+ const shortcuts = (apps) => {
147
+ if (!apps.length)
148
+ return;
149
+ const q = (s) => "'" + s.replace(/'/g, "''") + "'";
150
+ const desktop = path.join(os.homedir(), 'Desktop');
151
+ const start = path.join(process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Sandbox apps');
152
+ const lines = ['$w = New-Object -ComObject WScript.Shell', 'New-Item -ItemType Directory -Force -Path ' + q(start) + ' | Out-Null'];
153
+ for (const app of apps) {
154
+ for (const folder of [desktop, start]) {
155
+ const name = app.name.replace(/[\\/:*?"<>|]/g, '');
156
+ lines.push('$s = $w.CreateShortcut(' + q(path.join(folder, name + '.lnk')) + '); $s.TargetPath = ' + q(app.exe) + '; $s.WorkingDirectory = ' + q(path.dirname(app.exe)) + '; $s.Save()');
157
+ }
158
+ }
159
+ powershell(lines.join('\n'));
160
+ };
161
+ export const setupApps = async (env, apps, o) => {
162
+ const { entries, skipped } = appsToInstall(apps, o.gitAccess);
163
+ for (const s of skipped)
164
+ env.summary.push('app skipped: ' + s);
165
+ if (dryRun) {
166
+ log('(dry run) would install apps: ' + entries.map((e) => e.name).join(', '));
167
+ return;
168
+ }
169
+ const gui = [];
170
+ const installed = [];
171
+ const one = async (entry) => {
172
+ try {
173
+ const dest = tool('apps', ...entry.id.split('/'));
174
+ if (!exists(path.join(dest, MARKER)) || o.refresh)
175
+ await installApp(entry, dest);
176
+ const exe = findExe(dest, entry.exe);
177
+ if (!exe)
178
+ throw new Error(entry.exe + ' not found in the download');
179
+ await afterInstall(entry, exe);
180
+ if (entry.cli || entry.id === 'sysinternals')
181
+ env.prepend.push(path.dirname(exe));
182
+ if (!entry.cli)
183
+ gui.push({ name: entry.name, exe });
184
+ installed.push(entry.name);
185
+ }
186
+ catch (e) {
187
+ warn(entry.name + ' could not be installed and is skipped; the other apps are not affected. Reason: ' + errMsg(e));
188
+ env.summary.push(entry.name + ': FAILED');
189
+ }
190
+ };
191
+ // 7-Zip first: the NSIS apps need it to unpack.
192
+ const [first, rest] = entries[0]?.id === '7zip' ? [[entries[0]], entries.slice(1)] : [[], entries];
193
+ for (const e of first)
194
+ await one(e);
195
+ const queue = [...rest];
196
+ await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, async () => { for (let e = queue.shift(); e; e = queue.shift())
197
+ await one(e); }));
198
+ try {
199
+ shortcuts(gui);
200
+ }
201
+ catch (e) {
202
+ warn('App shortcuts could not be created: ' + errMsg(e));
203
+ }
204
+ if (installed.length)
205
+ env.summary.push('apps: ' + installed.join(', ') + ' (shortcuts on the desktop and in Start > Sandbox apps)');
206
+ };
@@ -0,0 +1,8 @@
1
+ export interface Download {
2
+ url: string;
3
+ fileName: string;
4
+ sha256?: string | null;
5
+ /** Only these paths of the archive (pgAdmin out of the PostgreSQL zip). */
6
+ members?: string[];
7
+ }
8
+ export declare const RESOLVERS: Record<string, () => Promise<Download>>;
@@ -0,0 +1,90 @@
1
+ // Where the latest version of an app is, for apps that are not a plain GitHub release: each looks it up on the
2
+ // vendor's own site (and its published checksum, where there is one). Named by AppSource.resolve in shared/apps.ts.
3
+ import { fetchJson, fetchText, githubAsset, latestGithubTag } from '../../shared/download.js';
4
+ /** The SHA-256 on the line of a SHA256SUMS-style file that names `file`. */
5
+ const sumFor = (sums, file) => sums.split(/\r?\n/).map((l) => /^([0-9a-f]{64})\s+\*?(.+)$/i.exec(l.trim())).find((m) => m && m[2].trim().endsWith(file))?.[1].toLowerCase() ?? null;
6
+ const firstHash = (text) => /\b([0-9a-f]{64})\b/i.exec(text)?.[1].toLowerCase() ?? null;
7
+ const stripV = (tag) => tag.replace(/^v/, '');
8
+ export const RESOLVERS = {
9
+ sublime: async () => {
10
+ // Sublime publishes no checksums; the download is HTTPS from sublimetext.com.
11
+ const { latest_version: build } = await fetchJson('https://www.sublimetext.com/updates/4/stable_update_check');
12
+ return { url: 'https://download.sublimetext.com/sublime_text_build_' + build + '_x64.zip', fileName: 'sublime_text_' + build + '.zip' };
13
+ },
14
+ pgadmin: async () => {
15
+ // pgAdmin is only an installer on pgadmin.org; EnterpriseDB's PostgreSQL binaries zip has a portable copy.
16
+ const versions = (await fetchJson('https://www.postgresql.org/versions.json')).filter((v) => v.supported);
17
+ const current = versions.find((v) => v.current) ?? versions[versions.length - 1];
18
+ const exact = current.major + '.' + current.latestMinor;
19
+ return { url: 'https://get.enterprisedb.com/postgresql/postgresql-' + exact + '-1-windows-x64-binaries.zip', fileName: 'postgresql-' + exact + '.zip', members: ['pgsql/pgAdmin 4'] };
20
+ },
21
+ everything: async () => {
22
+ const page = await fetchText('https://www.voidtools.com/downloads/');
23
+ const version = /Everything-([\d.]+)\.x64\.zip/.exec(page)?.[1];
24
+ if (!version)
25
+ throw new Error('could not find the Everything download on voidtools.com');
26
+ const file = 'Everything-' + version + '.x64.zip';
27
+ const sums = await fetchText('https://www.voidtools.com/Everything-' + version + '.sha256').catch(() => '');
28
+ return { url: 'https://www.voidtools.com/' + file, fileName: file, sha256: sumFor(sums, file) };
29
+ },
30
+ chrome: async () => {
31
+ const data = await fetchJson('https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json');
32
+ const hit = data.channels.Stable.downloads.chrome.find((d) => d.platform === 'win64');
33
+ if (!hit)
34
+ throw new Error('no win64 Chrome for Testing build listed');
35
+ return { url: hit.url, fileName: 'chrome-win64.zip' };
36
+ },
37
+ firefox: async () => {
38
+ const { LATEST_FIREFOX_VERSION: v } = await fetchJson('https://product-details.mozilla.org/1.0/firefox_versions.json');
39
+ const base = 'https://archive.mozilla.org/pub/firefox/releases/' + v + '/';
40
+ const sums = await fetchText(base + 'SHA256SUMS');
41
+ return { url: base + 'win64/en-US/Firefox%20Setup%20' + v + '.exe', fileName: 'firefox-setup-' + v + '.exe', sha256: sumFor(sums, 'win64/en-US/Firefox Setup ' + v + '.exe') };
42
+ },
43
+ blender: async () => {
44
+ const releases = await fetchText('https://download.blender.org/release/');
45
+ const series = [...releases.matchAll(/href="Blender(\d+)\.(\d+)\/"/g)].map((m) => [Number(m[1]), Number(m[2])]).sort((a, b) => a[0] - b[0] || a[1] - b[1]).pop();
46
+ if (!series)
47
+ throw new Error('could not list Blender releases');
48
+ const folder = 'https://download.blender.org/release/Blender' + series[0] + '.' + series[1] + '/';
49
+ const listing = await fetchText(folder);
50
+ const files = [...listing.matchAll(/href="(blender-([\d.]+)-windows-x64\.zip)"/g)].map((m) => ({ file: m[1], v: m[2].split('.').map(Number) }));
51
+ const latest = files.sort((a, b) => a.v[2] - b.v[2]).pop();
52
+ if (!latest)
53
+ throw new Error('no Windows build in ' + folder);
54
+ const sums = await fetchText(folder + 'blender-' + latest.v.join('.') + '.sha256').catch(() => '');
55
+ return { url: folder + latest.file, fileName: latest.file, sha256: sumFor(sums, latest.file) };
56
+ },
57
+ kubectl: async () => {
58
+ const v = (await fetchText('https://dl.k8s.io/release/stable.txt')).trim();
59
+ const url = 'https://dl.k8s.io/release/' + v + '/bin/windows/amd64/kubectl.exe';
60
+ return { url, fileName: 'kubectl.exe', sha256: firstHash(await fetchText(url + '.sha256')) };
61
+ },
62
+ helm: async () => {
63
+ const v = await latestGithubTag('helm/helm');
64
+ const url = 'https://get.helm.sh/helm-' + v + '-windows-amd64.zip';
65
+ return { url, fileName: 'helm-' + v + '.zip', sha256: firstHash(await fetchText(url + '.sha256sum')) };
66
+ },
67
+ terraform: async () => {
68
+ const { current_version: v } = await fetchJson('https://checkpoint-api.hashicorp.com/v1/check/terraform');
69
+ const base = 'https://releases.hashicorp.com/terraform/' + v + '/';
70
+ const file = 'terraform_' + v + '_windows_amd64.zip';
71
+ return { url: base + file, fileName: file, sha256: sumFor(await fetchText(base + 'terraform_' + v + '_SHA256SUMS'), file) };
72
+ },
73
+ mitmproxy: async () => {
74
+ // mitmproxy's GitHub releases have no files; its own download server has them (no published checksums).
75
+ const v = stripV(await latestGithubTag('mitmproxy/mitmproxy'));
76
+ return { url: 'https://downloads.mitmproxy.org/' + v + '/mitmproxy-' + v + '-windows-x86_64.zip', fileName: 'mitmproxy-' + v + '.zip' };
77
+ },
78
+ sqlite: async () => {
79
+ // The download page lists every file in a CSV comment: PRODUCT,<version>,<year>/<file>,<size>,<sha3>.
80
+ const page = await fetchText('https://sqlite.org/download.html');
81
+ const file = /PRODUCT,[^,]+,(\d{4}\/sqlite-tools-win-x64-\d+\.zip),/.exec(page)?.[1];
82
+ if (!file)
83
+ throw new Error('could not find the sqlite tools download on sqlite.org');
84
+ return { url: 'https://sqlite.org/' + file, fileName: file.split('/')[1] };
85
+ },
86
+ '7zip': async () => {
87
+ const asset = await githubAsset('ip7z/7zip', /^7z\d+-x64\.exe$/);
88
+ return { url: asset.url, fileName: asset.name, sha256: asset.sha256 };
89
+ },
90
+ };
@@ -0,0 +1,7 @@
1
+ /** Background programs of Windows itself: never what an agent ran. */
2
+ export declare const NOISE: Set<string>;
3
+ /** The events in wevtutil's XML output as audit lines, with the newest record id seen. */
4
+ export declare const parseProcessEvents: (xml: string) => {
5
+ lines: string[];
6
+ last: number;
7
+ };
@@ -0,0 +1,24 @@
1
+ // Reading Windows process-creation events (Security log, 4688, as wevtutil prints them) into audit lines. Pure, so
2
+ // the self-test can check it on the PC side.
3
+ import path from 'node:path';
4
+ /** Background programs of Windows itself: never what an agent ran. */
5
+ export const NOISE = new Set(['conhost.exe', 'svchost.exe', 'backgroundtaskhost.exe', 'runtimebroker.exe', 'wmiprvse.exe', 'dllhost.exe', 'searchprotocolhost.exe', 'searchfilterhost.exe', 'taskhostw.exe', 'sppsvc.exe', 'wermgr.exe', 'consent.exe', 'smartscreen.exe', 'mpcmdrun.exe', 'compattelrunner.exe', 'wevtutil.exe', 'audiodg.exe', 'ctfmon.exe', 'sihost.exe', 'werfault.exe', 'msmpeng.exe', 'nissrv.exe', 'usoclient.exe', 'musnotification.exe']);
6
+ const decode = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
7
+ const field = (event, name) => decode(new RegExp("<Data Name='" + name + "'>([^<]*)</Data>").exec(event)?.[1] ?? '');
8
+ /** The events in wevtutil's XML output as audit lines, with the newest record id seen. */
9
+ export const parseProcessEvents = (xml) => {
10
+ const lines = [];
11
+ let last = 0;
12
+ for (const event of xml.split('</Event>')) {
13
+ const id = Number(/<EventRecordID>(\d+)<\/EventRecordID>/.exec(event)?.[1] ?? 0);
14
+ if (!id)
15
+ continue;
16
+ last = Math.max(last, id);
17
+ const program = field(event, 'NewProcessName');
18
+ if (!program || NOISE.has(path.win32.basename(program).toLowerCase()))
19
+ continue;
20
+ const t = /SystemTime='([^']+)'/.exec(event)?.[1] ?? new Date().toISOString();
21
+ lines.push(JSON.stringify({ t, k: 'proc', p: program.slice(0, 1024), c: field(event, 'CommandLine').slice(0, 2000), pp: field(event, 'ParentProcessName').slice(0, 1024) }));
22
+ }
23
+ return { lines, last };
24
+ };
@@ -0,0 +1,4 @@
1
+ /** Turns on process-creation auditing with command lines (the registry is new every boot). */
2
+ export declare const enableProcessAudit: () => void;
3
+ /** Reads new process events every 15 s for the daemon's life (from the moment it started: older ones are boot noise). */
4
+ export declare const startProcessCollector: () => void;
@@ -0,0 +1,47 @@
1
+ // audit.processes, sandbox side: Windows records every program started (Security log, event 4688, with its command
2
+ // line); the daemon reads the new events every 15 s and leaves them in run\audit\ as small .jsonl files, which the
3
+ // host takes into its own log (and deletes). Windows' own background programs are left out. The host treats all of it
4
+ // as untrusted (it is checked line by line there).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { SYSTEM } from '../shared/system.js';
9
+ import { parseProcessEvents } from './audit-parse.js';
10
+ import { dryRun, errMsg, isWin, log, mkdirp, run, session, warn } from './env.js';
11
+ /** The "Process Creation" audit subcategory (a GUID, so it works in every Windows language). */
12
+ const PROCESS_CREATION = '{0CCE922B-69AE-11D9-BED3-505054503030}';
13
+ const MAX_PER_PASS = 2000;
14
+ const auditOn = () => Boolean(session.auditProcesses) && isWin && !dryRun;
15
+ /** Turns on process-creation auditing with command lines (the registry is new every boot). */
16
+ export const enableProcessAudit = () => {
17
+ if (!auditOn())
18
+ return;
19
+ spawnSync(path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'auditpol.exe'), ['/set', '/subcategory:' + PROCESS_CREATION, '/success:enable'], { windowsHide: true, stdio: 'ignore' });
20
+ spawnSync(SYSTEM.reg(), ['add', 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit', '/v', 'ProcessCreationIncludeCmdLine_Enabled', '/t', 'REG_DWORD', '/d', '1', '/f'], { windowsHide: true, stdio: 'ignore' });
21
+ log('Audit: programs started in the sandbox are recorded (audit.processes).');
22
+ };
23
+ const wevtutil = () => path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'wevtutil.exe');
24
+ const newestRecord = () => {
25
+ const r = spawnSync(wevtutil(), ['qe', 'Security', '/q:*[System[(EventID=4688)]]', '/f:xml', '/c:1', '/rd:true'], { encoding: 'utf8', windowsHide: true });
26
+ return parseProcessEvents(r.stdout ?? '').last;
27
+ };
28
+ /** Reads new process events every 15 s for the daemon's life (from the moment it started: older ones are boot noise). */
29
+ export const startProcessCollector = () => {
30
+ if (!auditOn())
31
+ return;
32
+ let last = newestRecord();
33
+ const pass = () => {
34
+ try {
35
+ const r = spawnSync(wevtutil(), ['qe', 'Security', '/q:*[System[(EventID=4688) and (EventRecordID>' + last + ')]]', '/f:xml', '/c:' + MAX_PER_PASS], { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
36
+ const { lines, last: newest } = parseProcessEvents(r.stdout ?? '');
37
+ if (newest)
38
+ last = newest;
39
+ if (lines.length)
40
+ fs.writeFileSync(path.join(mkdirp(run.audit), Date.now() + '-' + process.pid + '.jsonl'), lines.join('\n') + '\n');
41
+ }
42
+ catch (e) {
43
+ warn('audit: ' + errMsg(e));
44
+ }
45
+ };
46
+ setInterval(pass, 15000).unref?.();
47
+ };
@@ -0,0 +1 @@
1
+ export declare const startRelays: () => void;
@@ -0,0 +1,18 @@
1
+ // network.forwardPorts, sandbox side: one relay per forward, listening on its relay port (reachable only from the
2
+ // sandbox's own subnet, i.e. the PC: see the firewall rule in setup/network.ts) and passing each connection on to
3
+ // localhost:<sandbox port>. So a dev server that only listens on localhost is reachable from the PC too.
4
+ import net from 'node:net';
5
+ import { relayPort } from '../../shared/netpolicy.js';
6
+ import { errMsg, log, session, warn } from '../env.js';
7
+ export const startRelays = () => {
8
+ (session.network.forwardPorts ?? []).forEach((f, i) => {
9
+ const server = net.createServer((client) => {
10
+ const target = net.connect(f.sandbox, '127.0.0.1');
11
+ client.pipe(target).pipe(client);
12
+ target.on('error', () => client.destroy());
13
+ client.on('error', () => target.destroy());
14
+ });
15
+ server.on('error', (e) => warn('Port forward ' + f.pc + ' -> ' + f.sandbox + ' could not start: ' + errMsg(e)));
16
+ server.listen(relayPort(i), '0.0.0.0', () => log('Port forward: the PC\'s localhost:' + f.pc + ' reaches localhost:' + f.sandbox + ' here.'));
17
+ });
18
+ };
@@ -6,6 +6,8 @@ import fs from 'node:fs';
6
6
  import { readSession, scanRequests } from '../shared/rundir.js';
7
7
  import { saveClaudeMem } from './setup/claude.js';
8
8
  import { execCommand } from './daemon/exec.js';
9
+ import { startRelays } from './daemon/relays.js';
10
+ import { startProcessCollector } from './audit.js';
9
11
  import { prepareProject, restartSessions, startSession } from './daemon/session.js';
10
12
  import { live, respond, safely } from './daemon/state.js';
11
13
  import { dataDir, errMsg, log, run, warn } from './env.js';
@@ -50,4 +52,6 @@ const loop = () => {
50
52
  }
51
53
  };
52
54
  log('Session daemon started (pid ' + process.pid + ').');
55
+ safely('port forwards', startRelays);
56
+ safely('audit', startProcessCollector);
53
57
  loop();
@@ -18,6 +18,7 @@ export declare const run: {
18
18
  responses: string;
19
19
  sessions: string;
20
20
  projects: string;
21
+ audit: string;
21
22
  request: (id: string) => string;
22
23
  response: (id: string) => string;
23
24
  session: (id: string) => string;