windows-development-sandbox 0.0.0-stage → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.sandbox-config.example.json +14 -0
- package/README.md +78 -2
- package/config.example.json +332 -0
- package/dist/api.d.ts +31 -0
- package/dist/api.js +30 -0
- package/dist/bin/agent-sandbox.d.ts +2 -0
- package/dist/bin/agent-sandbox.js +3 -0
- package/dist/bin/claude-host.d.ts +2 -0
- package/dist/bin/claude-host.js +5 -0
- package/dist/bin/claude-sbx.d.ts +2 -0
- package/dist/bin/claude-sbx.js +3 -0
- package/dist/bin/codex-sbx.d.ts +2 -0
- package/dist/bin/codex-sbx.js +3 -0
- package/dist/bin/gemini-sbx.d.ts +2 -0
- package/dist/bin/gemini-sbx.js +3 -0
- package/dist/bin/sandbox.d.ts +2 -0
- package/dist/bin/sandbox.js +4 -0
- package/dist/bin/sbx-hide.d.ts +2 -0
- package/dist/bin/sbx-hide.js +3 -0
- package/dist/bin/sbx-show.d.ts +2 -0
- package/dist/bin/sbx-show.js +3 -0
- package/dist/cli/args.d.ts +32 -0
- package/dist/cli/args.js +111 -0
- package/dist/cli/commands/accounts.d.ts +2 -0
- package/dist/cli/commands/accounts.js +52 -0
- package/dist/cli/commands/context.d.ts +21 -0
- package/dist/cli/commands/context.js +54 -0
- package/dist/cli/commands/data.d.ts +46 -0
- package/dist/cli/commands/data.js +249 -0
- package/dist/cli/commands/index.d.ts +8 -0
- package/dist/cli/commands/index.js +8 -0
- package/dist/cli/commands/launch.d.ts +5 -0
- package/dist/cli/commands/launch.js +152 -0
- package/dist/cli/commands/maintenance.d.ts +8 -0
- package/dist/cli/commands/maintenance.js +125 -0
- package/dist/cli/commands/repair.d.ts +59 -0
- package/dist/cli/commands/repair.js +332 -0
- package/dist/cli/commands/sessions.d.ts +11 -0
- package/dist/cli/commands/sessions.js +113 -0
- package/dist/cli/commands/setup.d.ts +45 -0
- package/dist/cli/commands/setup.js +152 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +96 -0
- package/dist/cli/manifest.d.ts +95 -0
- package/dist/cli/manifest.js +87 -0
- package/dist/cli/menu.d.ts +22 -0
- package/dist/cli/menu.js +187 -0
- package/dist/cli/settings.d.ts +11 -0
- package/dist/cli/settings.js +156 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.js +103 -0
- package/dist/core/detect-databases.d.ts +15 -0
- package/dist/core/detect-databases.js +147 -0
- package/dist/core/detect-pwsh.d.ts +18 -0
- package/dist/core/detect-pwsh.js +111 -0
- package/dist/core/detect-toolchains.d.ts +30 -0
- package/dist/core/detect-toolchains.js +58 -0
- package/dist/core/local-config.d.ts +12 -0
- package/dist/core/local-config.js +64 -0
- package/dist/core/schema.d.ts +604 -0
- package/dist/core/schema.js +264 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/core/types.js +1 -0
- package/dist/core/util.d.ts +53 -0
- package/dist/core/util.js +145 -0
- package/dist/core/workspace.d.ts +23 -0
- package/dist/core/workspace.js +162 -0
- package/dist/guest/daemon.d.ts +1 -0
- package/dist/guest/daemon.js +186 -0
- package/dist/guest/db.d.ts +1 -0
- package/dist/guest/db.js +60 -0
- package/dist/guest/env.d.ts +49 -0
- package/dist/guest/env.js +99 -0
- package/dist/guest/gitguard.d.ts +7 -0
- package/dist/guest/gitguard.js +60 -0
- package/dist/guest/gitrules.d.ts +2 -0
- package/dist/guest/gitrules.js +11 -0
- package/dist/guest/guide.d.ts +12 -0
- package/dist/guest/guide.js +68 -0
- package/dist/guest/main.d.ts +1 -0
- package/dist/guest/main.js +120 -0
- package/dist/guest/ptyhost.d.ts +40 -0
- package/dist/guest/ptyhost.js +94 -0
- package/dist/guest/setup/agents.d.ts +8 -0
- package/dist/guest/setup/agents.js +73 -0
- package/dist/guest/setup/claude.d.ts +21 -0
- package/dist/guest/setup/claude.js +138 -0
- package/dist/guest/setup/environment.d.ts +6 -0
- package/dist/guest/setup/environment.js +65 -0
- package/dist/guest/setup/hooks.d.ts +1 -0
- package/dist/guest/setup/hooks.js +36 -0
- package/dist/guest/setup/network.d.ts +10 -0
- package/dist/guest/setup/network.js +99 -0
- package/dist/guest/setup/policy.d.ts +3 -0
- package/dist/guest/setup/policy.js +18 -0
- package/dist/guest/setup/user.d.ts +7 -0
- package/dist/guest/setup/user.js +47 -0
- package/dist/guest/toolchains/binstall.d.ts +7 -0
- package/dist/guest/toolchains/binstall.js +55 -0
- package/dist/guest/toolchains/catalogue.d.ts +51 -0
- package/dist/guest/toolchains/catalogue.js +31 -0
- package/dist/guest/toolchains/cli.d.ts +10 -0
- package/dist/guest/toolchains/cli.js +64 -0
- package/dist/guest/toolchains/common.d.ts +37 -0
- package/dist/guest/toolchains/common.js +124 -0
- package/dist/guest/toolchains/databases.d.ts +7 -0
- package/dist/guest/toolchains/databases.js +226 -0
- package/dist/guest/toolchains/git.d.ts +3 -0
- package/dist/guest/toolchains/git.js +112 -0
- package/dist/guest/toolchains/go.d.ts +3 -0
- package/dist/guest/toolchains/go.js +33 -0
- package/dist/guest/toolchains/index.d.ts +13 -0
- package/dist/guest/toolchains/index.js +122 -0
- package/dist/guest/toolchains/java.d.ts +8 -0
- package/dist/guest/toolchains/java.js +96 -0
- package/dist/guest/toolchains/managers.d.ts +16 -0
- package/dist/guest/toolchains/managers.js +82 -0
- package/dist/guest/toolchains/msvc.d.ts +10 -0
- package/dist/guest/toolchains/msvc.js +216 -0
- package/dist/guest/toolchains/native.d.ts +12 -0
- package/dist/guest/toolchains/native.js +220 -0
- package/dist/guest/toolchains/node.d.ts +3 -0
- package/dist/guest/toolchains/node.js +132 -0
- package/dist/guest/toolchains/pwsh.d.ts +4 -0
- package/dist/guest/toolchains/pwsh.js +14 -0
- package/dist/guest/toolchains/python.d.ts +3 -0
- package/dist/guest/toolchains/python.js +47 -0
- package/dist/guest/toolchains/runtimes.d.ts +17 -0
- package/dist/guest/toolchains/runtimes.js +40 -0
- package/dist/guest/toolchains/scripting.d.ts +10 -0
- package/dist/guest/toolchains/scripting.js +137 -0
- package/dist/guest/toolchains/versions.d.ts +47 -0
- package/dist/guest/toolchains/versions.js +77 -0
- package/dist/guest/toolchains/vscode.d.ts +2 -0
- package/dist/guest/toolchains/vscode.js +53 -0
- package/dist/guest/view.d.ts +1 -0
- package/dist/guest/view.js +22 -0
- package/dist/host/accounts.d.ts +32 -0
- package/dist/host/accounts.js +119 -0
- package/dist/host/channel.d.ts +37 -0
- package/dist/host/channel.js +180 -0
- package/dist/host/claude-profile.d.ts +15 -0
- package/dist/host/claude-profile.js +103 -0
- package/dist/host/cleanup.d.ts +23 -0
- package/dist/host/cleanup.js +66 -0
- package/dist/host/generate.d.ts +15 -0
- package/dist/host/generate.js +185 -0
- package/dist/host/personalize.d.ts +10 -0
- package/dist/host/personalize.js +97 -0
- package/dist/host/platform.d.ts +17 -0
- package/dist/host/platform.js +114 -0
- package/dist/host/project-scan.d.ts +23 -0
- package/dist/host/project-scan.js +173 -0
- package/dist/host/repo-guard.d.ts +23 -0
- package/dist/host/repo-guard.js +128 -0
- package/dist/host/secrets.d.ts +14 -0
- package/dist/host/secrets.js +133 -0
- package/dist/host/terminal.d.ts +32 -0
- package/dist/host/terminal.js +127 -0
- package/dist/host/window.d.ts +4 -0
- package/dist/host/window.js +68 -0
- package/dist/runtime/daemon.js +20291 -0
- package/dist/runtime/db.js +20423 -0
- package/dist/runtime/gitguard.js +84 -0
- package/dist/runtime/main.js +22398 -0
- package/dist/runtime/view.js +19966 -0
- package/dist/selftest/cli.test.d.ts +2 -0
- package/dist/selftest/cli.test.js +30 -0
- package/dist/selftest/config.test.d.ts +3 -0
- package/dist/selftest/config.test.js +74 -0
- package/dist/selftest/data.test.d.ts +2 -0
- package/dist/selftest/data.test.js +73 -0
- package/dist/selftest/detect.test.d.ts +2 -0
- package/dist/selftest/detect.test.js +42 -0
- package/dist/selftest/fixtures.d.ts +232 -0
- package/dist/selftest/fixtures.js +63 -0
- package/dist/selftest/guest.test.d.ts +3 -0
- package/dist/selftest/guest.test.js +61 -0
- package/dist/selftest/harness.d.ts +14 -0
- package/dist/selftest/harness.js +36 -0
- package/dist/selftest/helpers.test.d.ts +2 -0
- package/dist/selftest/helpers.test.js +125 -0
- package/dist/selftest/host.test.d.ts +2 -0
- package/dist/selftest/host.test.js +71 -0
- package/dist/selftest/index.d.ts +1 -0
- package/dist/selftest/index.js +56 -0
- package/dist/selftest/live.test.d.ts +7 -0
- package/dist/selftest/live.test.js +127 -0
- package/dist/selftest/mirror.test.d.ts +3 -0
- package/dist/selftest/mirror.test.js +74 -0
- package/dist/selftest/security.test.d.ts +3 -0
- package/dist/selftest/security.test.js +143 -0
- package/dist/selftest/setup.test.d.ts +2 -0
- package/dist/selftest/setup.test.js +23 -0
- package/dist/selftest/sync.test.d.ts +3 -0
- package/dist/selftest/sync.test.js +82 -0
- package/dist/shared/download.d.ts +58 -0
- package/dist/shared/download.js +200 -0
- package/dist/shared/guest-paths.d.ts +30 -0
- package/dist/shared/guest-paths.js +61 -0
- package/dist/shared/netrules.d.ts +18 -0
- package/dist/shared/netrules.js +98 -0
- package/dist/shared/paths.d.ts +9 -0
- package/dist/shared/paths.js +22 -0
- package/dist/shared/pty-client.d.ts +86 -0
- package/dist/shared/pty-client.js +227 -0
- package/dist/shared/rundir.d.ts +30 -0
- package/dist/shared/rundir.js +86 -0
- package/dist/shared/safe-remove.d.ts +10 -0
- package/dist/shared/safe-remove.js +55 -0
- package/dist/shared/schema.d.ts +84 -0
- package/dist/shared/schema.js +51 -0
- package/dist/shared/system.d.ts +15 -0
- package/dist/shared/system.js +19 -0
- package/dist/sync/claude-mem.d.ts +8 -0
- package/dist/sync/claude-mem.js +77 -0
- package/dist/sync/history.d.ts +30 -0
- package/dist/sync/history.js +129 -0
- package/dist/sync/watcher.d.ts +13 -0
- package/dist/sync/watcher.js +116 -0
- package/hooks/post-agent/README.txt +1 -0
- package/hooks/pre-provision/README.txt +1 -0
- package/package.json +91 -4
- package/sandbox/bootstrap.ps1 +258 -0
- package/sandbox/gitshim.cs +35 -0
- package/sandbox/logon.cmd +16 -0
- package/sandbox/ptyhost.cs +603 -0
- package/sandbox/trust.cmd +35 -0
- package/sandbox/vswhere.cs +83 -0
- package/schemas/config.schema.json +1062 -0
- package/schemas/sandbox-config.schema.json +952 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Captures the look of this desktop (theme, accent colour, taskbar, Explorer options, wallpaper, mouse/keyboard,
|
|
2
|
+
// regional formats) from HKCU as a .reg file the sandbox imports at logon. Only an allow-list of values is read.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ensureDir, errMsg, fileExists, isWin, runPowerShell } from '../core/util.js';
|
|
6
|
+
const VALUES = {
|
|
7
|
+
'Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize': ['AppsUseLightTheme', 'SystemUsesLightTheme', 'EnableTransparency', 'ColorPrevalence'],
|
|
8
|
+
'Software\\Microsoft\\Windows\\DWM': ['AccentColor', 'AccentColorInactive', 'ColorizationColor', 'ColorizationAfterglow', 'ColorizationColorBalance', 'ColorPrevalence', 'EnableWindowColorization'],
|
|
9
|
+
'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Accent': ['AccentPalette', 'AccentColorMenu', 'StartColorMenu'],
|
|
10
|
+
'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced': ['TaskbarAl', 'TaskbarSi', 'TaskbarDa', 'TaskbarMn', 'TaskbarGlomLevel', 'ShowTaskViewButton', 'Hidden', 'HideFileExt', 'ShowSuperHidden', 'LaunchTo', 'NavPaneExpandToCurrentFolder', 'NavPaneShowAllFolders', 'ShowCopilotButton', 'Start_TrackDocs', 'Start_Layout'],
|
|
11
|
+
'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StuckRects3': ['Settings'],
|
|
12
|
+
'Software\\Microsoft\\Windows\\CurrentVersion\\Search': ['SearchboxTaskbarMode'],
|
|
13
|
+
'Control Panel\\Desktop': ['WallpaperStyle', 'TileWallpaper', 'MenuShowDelay', 'CursorBlinkRate', 'DragFullWindows', 'FontSmoothing', 'FontSmoothingType', 'UserPreferencesMask'],
|
|
14
|
+
'Control Panel\\Colors': ['Background', 'Hilight', 'HotTrackingColor'],
|
|
15
|
+
'Control Panel\\Mouse': ['MouseSpeed', 'MouseSensitivity', 'MouseThreshold1', 'MouseThreshold2', 'DoubleClickSpeed', 'SwapMouseButtons'],
|
|
16
|
+
'Control Panel\\Keyboard': ['KeyboardDelay', 'KeyboardSpeed'],
|
|
17
|
+
'Control Panel\\International': ['sShortDate', 'sLongDate', 'sShortTime', 'sTimeFormat', 'iFirstDayOfWeek', 'sDecimal', 'sThousand', 'sList', 'iMeasure', 'iTime', 'iTLZero'],
|
|
18
|
+
'Control Panel\\Accessibility\\StickyKeys': ['Flags'],
|
|
19
|
+
};
|
|
20
|
+
const toHex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(',');
|
|
21
|
+
const regString = (s) => '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
22
|
+
/** Formats captured values as a .reg file (UTF-16 friendly text; written as UTF-16LE with BOM). */
|
|
23
|
+
export const toRegFile = (values) => {
|
|
24
|
+
const byKey = new Map();
|
|
25
|
+
for (const v of values)
|
|
26
|
+
byKey.set(v.key, [...(byKey.get(v.key) ?? []), v]);
|
|
27
|
+
const lines = ['Windows Registry Editor Version 5.00', ''];
|
|
28
|
+
for (const [key, list] of byKey) {
|
|
29
|
+
lines.push('[HKEY_CURRENT_USER\\' + key + ']');
|
|
30
|
+
for (const v of list) {
|
|
31
|
+
const name = regString(v.name);
|
|
32
|
+
switch (v.kind) {
|
|
33
|
+
case 'DWord':
|
|
34
|
+
lines.push(name + '=dword:' + (Number(v.value) >>> 0).toString(16).padStart(8, '0'));
|
|
35
|
+
break;
|
|
36
|
+
case 'QWord':
|
|
37
|
+
lines.push(name + '=hex(b):' + toHex(Buffer.from(BigInt.asUintN(64, BigInt(v.value)).toString(16).padStart(16, '0'), 'hex').reverse()));
|
|
38
|
+
break;
|
|
39
|
+
case 'Binary':
|
|
40
|
+
lines.push(name + '=hex:' + toHex(Buffer.from(String(v.value), 'base64')));
|
|
41
|
+
break;
|
|
42
|
+
case 'ExpandString':
|
|
43
|
+
lines.push(name + '=hex(2):' + toHex(Buffer.from(String(v.value) + '\0', 'utf16le')));
|
|
44
|
+
break;
|
|
45
|
+
case 'MultiString':
|
|
46
|
+
lines.push(name + '=hex(7):' + toHex(Buffer.from(v.value.join('\0') + '\0\0', 'utf16le')));
|
|
47
|
+
break;
|
|
48
|
+
default: lines.push(name + '=' + regString(String(v.value)));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
return lines.join('\r\n');
|
|
54
|
+
};
|
|
55
|
+
const CAPTURE_SCRIPT = (spec) => [
|
|
56
|
+
'$ErrorActionPreference = "SilentlyContinue"',
|
|
57
|
+
'$spec = \'' + JSON.stringify(spec).replace(/'/g, "''") + '\' | ConvertFrom-Json',
|
|
58
|
+
'$out = @()',
|
|
59
|
+
'foreach ($p in $spec.PSObject.Properties) {',
|
|
60
|
+
' $key = Get-Item -LiteralPath ("HKCU:\\" + $p.Name)',
|
|
61
|
+
' if (-not $key) { continue }',
|
|
62
|
+
' foreach ($name in $p.Value) {',
|
|
63
|
+
' $raw = $key.GetValue($name, $null, "DoNotExpandEnvironmentNames")',
|
|
64
|
+
' if ($null -eq $raw) { continue }',
|
|
65
|
+
' $kind = $key.GetValueKind($name).ToString()',
|
|
66
|
+
' if ($kind -eq "Binary") { $raw = [Convert]::ToBase64String([byte[]]$raw) }',
|
|
67
|
+
' $out += [pscustomobject]@{ key = $p.Name; name = $name; kind = $kind; value = $raw }',
|
|
68
|
+
' }',
|
|
69
|
+
'}',
|
|
70
|
+
'$wall = (Get-ItemProperty -LiteralPath "HKCU:\\Control Panel\\Desktop").WallPaper',
|
|
71
|
+
'[pscustomobject]@{ values = $out; wallpaper = $wall } | ConvertTo-Json -Depth 5 -Compress',
|
|
72
|
+
].join('\n');
|
|
73
|
+
/** Writes personalize.reg (+ the wallpaper image) into `<launch>\personalize`. Returns a short summary. */
|
|
74
|
+
export const capturePersonalization = (launchPath) => {
|
|
75
|
+
if (!isWin)
|
|
76
|
+
return 'skipped (not Windows)';
|
|
77
|
+
try {
|
|
78
|
+
const { status, stdout } = runPowerShell(CAPTURE_SCRIPT(VALUES), true);
|
|
79
|
+
if (status !== 0)
|
|
80
|
+
return 'skipped (registry read failed)';
|
|
81
|
+
// runPowerShell has PowerShell write UTF-8 (non-ASCII date formats and wallpaper paths survive); a BOM is tolerated.
|
|
82
|
+
const parsed = JSON.parse(stdout.replace(/^/, '').trim());
|
|
83
|
+
const values = parsed.values === null ? [] : Array.isArray(parsed.values) ? parsed.values : [parsed.values];
|
|
84
|
+
const dir = path.join(launchPath, 'personalize');
|
|
85
|
+
ensureDir(dir);
|
|
86
|
+
fs.writeFileSync(path.join(dir, 'personalize.reg'), '' + toRegFile(values), 'utf16le');
|
|
87
|
+
const wallpaper = parsed.wallpaper ?? '';
|
|
88
|
+
const themesCopy = path.join(process.env.APPDATA ?? '', 'Microsoft', 'Windows', 'Themes', 'TranscodedWallpaper');
|
|
89
|
+
const source = fileExists(wallpaper) ? wallpaper : fileExists(themesCopy) ? themesCopy : null;
|
|
90
|
+
if (source)
|
|
91
|
+
fs.copyFileSync(source, path.join(dir, 'wallpaper' + (path.extname(source) || '.jpg')));
|
|
92
|
+
return values.length + ' settings' + (source ? ' + wallpaper' : '');
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
return 'skipped (' + errMsg(e) + ')';
|
|
96
|
+
}
|
|
97
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Config, HostProbe } from '../core/types.js';
|
|
2
|
+
/** What can be copied from this PC instead of downloaded inside the sandbox. */
|
|
3
|
+
export declare const probeHost: (config: Config, harnessName: string) => HostProbe;
|
|
4
|
+
/**
|
|
5
|
+
* Running INSIDE a Windows Sandbox this tool started (its launch folder and data folder are mapped in): then the CLI
|
|
6
|
+
* talks to the sandbox it is in, through C:\\AgentData and loopback, instead of looking for one on a PC.
|
|
7
|
+
*/
|
|
8
|
+
export declare const insideSandbox: () => boolean;
|
|
9
|
+
export declare const GUEST_DATA_DIR = "C:\\AgentData";
|
|
10
|
+
export declare const isSandboxRunning: () => boolean;
|
|
11
|
+
export declare const sandboxFeatureInstalled: () => boolean;
|
|
12
|
+
export declare const assertCanLaunch: () => void;
|
|
13
|
+
export declare const startSandbox: (wsbPath: string) => void;
|
|
14
|
+
/** Runs a detached copy of this CLI (used for the background history/claude-mem sync watcher). */
|
|
15
|
+
export declare const spawnDetached: (entry: string, args: string[]) => void;
|
|
16
|
+
export declare const hostClaude: (storageRoot: string, personal: boolean, args: string[]) => number;
|
|
17
|
+
export declare const doctorRows: (config: Config, harnessName: string, storageRoot: string, configPath: string | null) => [string, string][];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { SYSTEM } from '../shared/system.js';
|
|
6
|
+
import { dirExists, ensureDir, fail, fileExists, findOnPath, homeDir, isWin, realPath } from '../core/util.js';
|
|
7
|
+
const findHarnessExe = (config, harnessName) => {
|
|
8
|
+
const harness = config.harnesses[harnessName];
|
|
9
|
+
if (!harness?.hostExecutable)
|
|
10
|
+
return null;
|
|
11
|
+
const explicit = harness.hostDir && harness.hostDir !== 'auto' ? path.join(harness.hostDir, harness.hostExecutable) : null;
|
|
12
|
+
const found = explicit
|
|
13
|
+
? (fileExists(explicit) ? explicit : null)
|
|
14
|
+
: findOnPath([harness.hostExecutable]) ?? [path.join(homeDir(), '.local', 'bin', harness.hostExecutable)].find(fileExists) ?? null;
|
|
15
|
+
if (!found)
|
|
16
|
+
return null;
|
|
17
|
+
const real = realPath(found);
|
|
18
|
+
return { dir: path.dirname(real), file: path.basename(real) };
|
|
19
|
+
};
|
|
20
|
+
/** Git for Windows root (the folder with cmd\git.exe and bin\bash.exe), from git on PATH. */
|
|
21
|
+
const findGitRoot = () => {
|
|
22
|
+
const git = findOnPath(['git.exe']);
|
|
23
|
+
if (!git)
|
|
24
|
+
return null;
|
|
25
|
+
const root = path.resolve(path.dirname(realPath(git)), '..');
|
|
26
|
+
return fileExists(path.join(root, 'bin', 'bash.exe')) ? root : null;
|
|
27
|
+
};
|
|
28
|
+
/** What can be copied from this PC instead of downloaded inside the sandbox. */
|
|
29
|
+
export const probeHost = (config, harnessName) => {
|
|
30
|
+
const nodeDir = isWin ? path.dirname(process.execPath) : null;
|
|
31
|
+
const copy = config.provisioning.copyFromHost;
|
|
32
|
+
const nvmHome = process.env.NVM_HOME;
|
|
33
|
+
const nvmDir = copy && nvmHome && dirExists(nvmHome) ? realPath(nvmHome) : null;
|
|
34
|
+
const hasNode = (dir) => fileExists(path.join(dir, 'node.exe')) && fileExists(path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
35
|
+
// One source for Node: nvm's copy of this version when there is one, otherwise the Node folder itself.
|
|
36
|
+
const nvmNode = Boolean(nvmDir && hasNode(path.join(nvmDir, process.version)));
|
|
37
|
+
return {
|
|
38
|
+
nodeDir: copy && !nvmNode && nodeDir && hasNode(nodeDir) ? realPath(nodeDir) : null,
|
|
39
|
+
harnessExe: copy ? findHarnessExe(config, harnessName) : null,
|
|
40
|
+
gitDir: copy && config.provisioning.git.enabled ? findGitRoot() : null,
|
|
41
|
+
nvmDir,
|
|
42
|
+
hostNodeVersion: process.version,
|
|
43
|
+
nvmNode,
|
|
44
|
+
volta: Boolean(process.env.VOLTA_HOME),
|
|
45
|
+
pyenv: Boolean(process.env.PYENV ?? process.env.PYENV_ROOT ?? process.env.PYENV_HOME),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const SANDBOX_PROCESSES = ['WindowsSandboxClient.exe', 'WindowsSandboxRemoteSession.exe', 'WindowsSandbox.exe'];
|
|
49
|
+
/**
|
|
50
|
+
* Running INSIDE a Windows Sandbox this tool started (its launch folder and data folder are mapped in): then the CLI
|
|
51
|
+
* talks to the sandbox it is in, through C:\\AgentData and loopback, instead of looking for one on a PC.
|
|
52
|
+
*/
|
|
53
|
+
export const insideSandbox = () => isWin && !process.env.AGENT_SANDBOX_HOST_ONLY && fs.existsSync('C:\\AgentLaunch\\session.json') && fs.existsSync('C:\\AgentData\\run');
|
|
54
|
+
export const GUEST_DATA_DIR = 'C:\\AgentData';
|
|
55
|
+
export const isSandboxRunning = () => {
|
|
56
|
+
if (!isWin)
|
|
57
|
+
return false;
|
|
58
|
+
if (insideSandbox())
|
|
59
|
+
return true;
|
|
60
|
+
const result = spawnSync(SYSTEM.tasklist(), ['/NH', '/FO', 'CSV'], { encoding: 'utf8', windowsHide: true });
|
|
61
|
+
const out = (result.stdout ?? '').toLowerCase();
|
|
62
|
+
return SANDBOX_PROCESSES.some((image) => out.includes('"' + image.toLowerCase() + '"'));
|
|
63
|
+
};
|
|
64
|
+
export const sandboxFeatureInstalled = () => isWin && fileExists(path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'WindowsSandbox.exe'));
|
|
65
|
+
export const assertCanLaunch = () => {
|
|
66
|
+
if (!isWin)
|
|
67
|
+
fail('Windows Sandbox only runs on Windows.', 'Use sandbox generate to only write the files.');
|
|
68
|
+
if (!sandboxFeatureInstalled())
|
|
69
|
+
fail('Windows Sandbox is not enabled on this PC.', 'Run in an admin PowerShell, then reboot: Enable-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM -All (needs Windows Pro, Enterprise or Education)');
|
|
70
|
+
if (isSandboxRunning())
|
|
71
|
+
fail('A Windows Sandbox is already running (Windows allows one at a time).', 'Attach to it with sandbox claude -t, show it with sbx-show, or shut it down with sandbox stop.');
|
|
72
|
+
};
|
|
73
|
+
export const startSandbox = (wsbPath) => {
|
|
74
|
+
const result = spawnSync(SYSTEM.cmd(), ['/d', '/c', 'start', '""', '"' + wsbPath + '"'], { stdio: 'inherit', windowsVerbatimArguments: true });
|
|
75
|
+
if (result.error)
|
|
76
|
+
throw result.error;
|
|
77
|
+
};
|
|
78
|
+
/** Runs a detached copy of this CLI (used for the background history/claude-mem sync watcher). */
|
|
79
|
+
export const spawnDetached = (entry, args) => {
|
|
80
|
+
// cwd = the install folder: the caller's folder is the workspace, which the sandbox can write.
|
|
81
|
+
spawn(process.execPath, [entry, ...args], { cwd: path.dirname(entry), detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
82
|
+
};
|
|
83
|
+
export const hostClaude = (storageRoot, personal, args) => {
|
|
84
|
+
const exe = findOnPath(['claude.exe', 'claude.cmd']);
|
|
85
|
+
if (!exe)
|
|
86
|
+
fail('Claude Code is not installed on this PC (no claude.exe or claude.cmd on PATH).', 'Install it: npm install -g @anthropic-ai/claude-code');
|
|
87
|
+
const env = { ...process.env };
|
|
88
|
+
if (!personal) {
|
|
89
|
+
const profile = path.join(storageRoot, 'host', 'claude');
|
|
90
|
+
ensureDir(profile);
|
|
91
|
+
env.CLAUDE_CONFIG_DIR = profile;
|
|
92
|
+
console.warn('Host Claude is NOT sandboxed. Using separate profile: ' + profile + ' (pass --personal to use your normal one).');
|
|
93
|
+
}
|
|
94
|
+
const result = exe.toLowerCase().endsWith('.cmd') ? spawnSync(SYSTEM.cmd(), ['/d', '/c', exe, ...args], { stdio: 'inherit', env }) : spawnSync(exe, args, { stdio: 'inherit', env });
|
|
95
|
+
if (result.error)
|
|
96
|
+
throw result.error;
|
|
97
|
+
return result.status ?? 1;
|
|
98
|
+
};
|
|
99
|
+
export const doctorRows = (config, harnessName, storageRoot, configPath) => {
|
|
100
|
+
const probe = probeHost(config, harnessName);
|
|
101
|
+
return [
|
|
102
|
+
['Config file', configPath ?? '(none; built-in defaults; create one with: sandbox config init)'],
|
|
103
|
+
['Windows', isWin ? 'yes' : 'NO (launch needs Windows)'],
|
|
104
|
+
['Windows Sandbox feature', sandboxFeatureInstalled() ? 'enabled' : 'NOT enabled'],
|
|
105
|
+
['Sandbox running', isSandboxRunning() ? 'YES' : 'no'],
|
|
106
|
+
['Free host RAM (MB)', String(Math.floor(os.freemem() / 1048576))],
|
|
107
|
+
['Provisioning', config.provisioning.copyFromHost ? 'copy from this PC' + (config.provisioning.fallbackToInstall ? ', fall back to download' : ', no fallback') : 'download inside sandbox'],
|
|
108
|
+
['Host Node (runner)', probe.nvmNode ? 'nvm ' + probe.hostNodeVersion : probe.nodeDir ?? '(none; will download)'],
|
|
109
|
+
['Host nvm versions', probe.nvmDir ?? '(no NVM_HOME; versions will be downloaded)'],
|
|
110
|
+
['Host ' + harnessName, probe.harnessExe ? path.join(probe.harnessExe.dir, probe.harnessExe.file) : '(none; will npm install)'],
|
|
111
|
+
['Host Git for Windows', probe.gitDir ?? '(none with bash; PortableGit will be downloaded)'],
|
|
112
|
+
['Storage root', storageRoot + (dirExists(storageRoot) ? '' : ' (will be created)')],
|
|
113
|
+
];
|
|
114
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Config, DatabasePlan } from '../core/types.js';
|
|
2
|
+
import { type Database, type ProjectFile } from '../core/detect-databases.js';
|
|
3
|
+
import { type AutoToolchain } from '../core/detect-toolchains.js';
|
|
4
|
+
/** Manifest-type files in the project, up to 3 folders deep. Links are not followed (the workspace may be guest-written). */
|
|
5
|
+
export declare const readProjectFiles: (root: string, depth?: number) => ProjectFile[];
|
|
6
|
+
/** File names in the project and its direct sub-folders ("api/go.mod"), for toolchain detection. No links followed. */
|
|
7
|
+
export declare const projectEntries: (root: string) => string[];
|
|
8
|
+
/** Scripts in the project root or one folder down that set up MSVC (vcvars*.bat, vswhere): read with bounded effort. */
|
|
9
|
+
export declare const msvcHint: (root: string) => string | undefined;
|
|
10
|
+
/** Languages this PC has (their commands on PATH; Windows' Store stubs for python do not count). */
|
|
11
|
+
export declare const pcLanguages: () => AutoToolchain[];
|
|
12
|
+
/** Database services installed on this PC (names from the service control manager; nothing is started or changed). */
|
|
13
|
+
export declare const hostDatabases: () => Database[];
|
|
14
|
+
/**
|
|
15
|
+
* What to run in this sandbox: databases set to true, plus "auto" ones the project needs. SQL Server is reported (so
|
|
16
|
+
* the user hears about it) but cannot run inside Windows Sandbox.
|
|
17
|
+
*/
|
|
18
|
+
export declare const planDatabases: (config: Config["databases"], workspace: string, probe?: {
|
|
19
|
+
files?: ProjectFile[];
|
|
20
|
+
host?: Database[];
|
|
21
|
+
}) => DatabasePlan[];
|
|
22
|
+
/** Every database the sandbox can run, so `sbx-db start <name>` works for ones that did not start at boot. */
|
|
23
|
+
export declare const databaseCatalog: (config: Config["databases"]) => DatabasePlan[];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Reads a project the way detect-databases.ts needs it (only manifest/config/compose files, bounded), and looks at which
|
|
2
|
+
// database services this PC has, so `enabled: "auto"` databases are installed only for projects that use them.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { DATABASES, databasesFromServices, detectDatabases, INTERESTING_FILE } from '../core/detect-databases.js';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { AUTO_TOOLCHAINS } from '../core/detect-toolchains.js';
|
|
9
|
+
import { pcHasPwsh } from '../core/detect-pwsh.js';
|
|
10
|
+
import { isWin } from '../core/util.js';
|
|
11
|
+
import { system32 } from '../shared/system.js';
|
|
12
|
+
const SKIP = /^(node_modules|\.git|\.hg|\.svn|vendor|target|bin|obj|dist|build|out|\.next|\.nuxt|\.venv|venv|env|__pycache__|\.gradle|\.idea|\.vs|coverage|packages)$/i;
|
|
13
|
+
const MAX_FILES = 400;
|
|
14
|
+
const MAX_BYTES = 256 * 1024;
|
|
15
|
+
const MAX_FOLDERS = 2000;
|
|
16
|
+
/** Reads a file through one handle, only if that handle is a regular file of at most MAX_BYTES. */
|
|
17
|
+
const readSmallFile = (file) => {
|
|
18
|
+
let fd = null;
|
|
19
|
+
try {
|
|
20
|
+
fd = fs.openSync(file, 'r');
|
|
21
|
+
const stat = fs.fstatSync(fd);
|
|
22
|
+
if (!stat.isFile() || stat.size > MAX_BYTES)
|
|
23
|
+
return null;
|
|
24
|
+
const buffer = Buffer.alloc(stat.size);
|
|
25
|
+
fs.readSync(fd, buffer, 0, stat.size, 0);
|
|
26
|
+
return buffer.toString('utf8');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
if (fd !== null)
|
|
33
|
+
fs.closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/** Manifest-type files in the project, up to 3 folders deep. Links are not followed (the workspace may be guest-written). */
|
|
37
|
+
export const readProjectFiles = (root, depth = 3) => {
|
|
38
|
+
const files = [];
|
|
39
|
+
let folders = 0;
|
|
40
|
+
const walk = (dir, level) => {
|
|
41
|
+
if (++folders > MAX_FOLDERS)
|
|
42
|
+
return;
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (files.length >= MAX_FILES)
|
|
52
|
+
return;
|
|
53
|
+
const full = path.join(dir, entry.name);
|
|
54
|
+
if (entry.isFile() && INTERESTING_FILE.test(entry.name)) {
|
|
55
|
+
const text = readSmallFile(full);
|
|
56
|
+
if (text !== null)
|
|
57
|
+
files.push({ path: path.relative(root, full), text });
|
|
58
|
+
}
|
|
59
|
+
else if (entry.isDirectory() && level < depth && !SKIP.test(entry.name))
|
|
60
|
+
walk(full, level + 1);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(root, 0);
|
|
64
|
+
return files;
|
|
65
|
+
};
|
|
66
|
+
/** File names in the project and its direct sub-folders ("api/go.mod"), for toolchain detection. No links followed. */
|
|
67
|
+
export const projectEntries = (root) => {
|
|
68
|
+
const list = (dir) => { try {
|
|
69
|
+
return fs.readdirSync(dir, { withFileTypes: true }).slice(0, 2000);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
} };
|
|
74
|
+
const top = list(root);
|
|
75
|
+
const below = top.filter((d) => d.isDirectory() && !d.name.startsWith('.') && !SKIP.test(d.name)).slice(0, 200)
|
|
76
|
+
.flatMap((d) => list(path.join(root, d.name)).filter((f) => f.isFile()).map((f) => d.name + '/' + f.name));
|
|
77
|
+
return [...top.filter((d) => d.isFile()).map((d) => d.name), ...below];
|
|
78
|
+
};
|
|
79
|
+
/** This PC builds Rust with MSVC: rustup's default host is *-msvc (read from rustup's settings; nothing is run). */
|
|
80
|
+
const msvcRust = () => {
|
|
81
|
+
const settings = path.join(process.env.RUSTUP_HOME ?? path.join(os.homedir(), '.rustup'), 'settings.toml');
|
|
82
|
+
try {
|
|
83
|
+
return /default_host_triple\s*=\s*"[^"]*-msvc"/.test(fs.readFileSync(settings, 'utf8').slice(0, 64 * 1024));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/** Scripts in the project root or one folder down that set up MSVC (vcvars*.bat, vswhere): read with bounded effort. */
|
|
90
|
+
export const msvcHint = (root) => {
|
|
91
|
+
const scripts = projectEntries(root).filter((e) => /\.(ps1|cmd|bat|psm1)$/i.test(e)).slice(0, 60);
|
|
92
|
+
for (const rel of scripts) {
|
|
93
|
+
let text = '';
|
|
94
|
+
try {
|
|
95
|
+
const file = path.join(root, rel);
|
|
96
|
+
if (fs.statSync(file).size <= 256 * 1024)
|
|
97
|
+
text = fs.readFileSync(file, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (/vcvars(all|64|32)?\.bat|vswhere(\.exe)?/i.test(text))
|
|
103
|
+
return rel + ' sets up MSVC';
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
};
|
|
107
|
+
/** Languages this PC has (their commands on PATH; Windows' Store stubs for python do not count). */
|
|
108
|
+
export const pcLanguages = () => {
|
|
109
|
+
const dirs = (process.env.PATH ?? '').split(path.delimiter).filter((d) => d && !/WindowsApps/i.test(d));
|
|
110
|
+
const has = (...names) => dirs.some((d) => names.some((n) => fs.existsSync(path.join(d, n))));
|
|
111
|
+
const found = {
|
|
112
|
+
python: has('python.exe', 'py.exe'), rust: has('cargo.exe', 'rustc.exe'), cpp: has('gcc.exe', 'g++.exe', 'cl.exe', 'clang.exe'),
|
|
113
|
+
msvc: msvcRust(), pwsh: pcHasPwsh(), dotnet: has('dotnet.exe'), go: has('go.exe'), java: has('java.exe', 'javac.exe'), ruby: has('ruby.exe'), php: has('php.exe'), deno: has('deno.exe'),
|
|
114
|
+
};
|
|
115
|
+
return AUTO_TOOLCHAINS.filter((t) => found[t]);
|
|
116
|
+
};
|
|
117
|
+
/** Database services installed on this PC (names from the service control manager; nothing is started or changed). */
|
|
118
|
+
export const hostDatabases = () => {
|
|
119
|
+
if (!isWin)
|
|
120
|
+
return [];
|
|
121
|
+
const result = spawnSync(system32('sc.exe'), ['query', 'type=', 'service', 'state=', 'all'], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
|
|
122
|
+
const names = [...(result.stdout ?? '').matchAll(/^SERVICE_NAME:\s*(.+)$/gm)].map((m) => m[1].trim());
|
|
123
|
+
return databasesFromServices(names);
|
|
124
|
+
};
|
|
125
|
+
/** The version a compose file pins (postgres:16-alpine -> 16, mongo:7.0 -> 7.0), if any. */
|
|
126
|
+
const pinnedVersion = (files, image) => {
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
const match = image.exec(file.text);
|
|
129
|
+
if (match?.[1] && /^\d+(\.\d+)?/.test(match[1]))
|
|
130
|
+
return /^\d+(\.\d+)?/.exec(match[1])[0];
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* What to run in this sandbox: databases set to true, plus "auto" ones the project needs. SQL Server is reported (so
|
|
136
|
+
* the user hears about it) but cannot run inside Windows Sandbox.
|
|
137
|
+
*/
|
|
138
|
+
export const planDatabases = (config, workspace, probe = {}) => {
|
|
139
|
+
const autos = DATABASES.filter((db) => config[db].enabled === 'auto');
|
|
140
|
+
const files = autos.length ? probe.files ?? readProjectFiles(workspace) : [];
|
|
141
|
+
const detected = autos.length ? detectDatabases(files, probe.host ?? hostDatabases) : [];
|
|
142
|
+
const plan = databasePlanner(config, files.filter((f) => /compose/i.test(f.path)));
|
|
143
|
+
return DATABASES.flatMap((database) => {
|
|
144
|
+
const setting = config[database].enabled;
|
|
145
|
+
const found = detected.find((d) => d.database === database);
|
|
146
|
+
if (setting === true)
|
|
147
|
+
return [plan(database, ['enabled in config'])];
|
|
148
|
+
if (setting === 'auto' && found)
|
|
149
|
+
return [plan(database, found.reasons)];
|
|
150
|
+
return [];
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
/** Every database the sandbox can run, so `sbx-db start <name>` works for ones that did not start at boot. */
|
|
154
|
+
export const databaseCatalog = (config) => {
|
|
155
|
+
const plan = databasePlanner(config, []);
|
|
156
|
+
return DATABASES.filter((db) => db !== 'sqlserver').map((db) => plan(db, ['started with sbx-db']));
|
|
157
|
+
};
|
|
158
|
+
/** Version (a compose file's image tag wins), port and admin UI for one database. */
|
|
159
|
+
const databasePlanner = (config, compose) => {
|
|
160
|
+
const versions = {
|
|
161
|
+
postgres: pinnedVersion(compose, /image:\s*["']?(?:[\w.-]+\/)*postgres:([\w.-]+)/i) ?? (config.postgres.version === 'latest' ? 'latest' : config.postgres.version),
|
|
162
|
+
mysql: config.mysql.version,
|
|
163
|
+
mongodb: pinnedVersion(compose, /image:\s*["']?(?:[\w.-]+\/)*mongo:([\w.-]+)/i) ?? config.mongodb.version,
|
|
164
|
+
redis: 'latest',
|
|
165
|
+
dynamodb: 'latest',
|
|
166
|
+
sqlserver: '',
|
|
167
|
+
};
|
|
168
|
+
const ports = { postgres: config.postgres.port, mysql: config.mysql.port, mongodb: config.mongodb.port, redis: config.redis.port, dynamodb: config.dynamodb.port, sqlserver: 1433 };
|
|
169
|
+
return (database, reasons) => ({
|
|
170
|
+
database, reasons, version: versions[database], port: ports[database],
|
|
171
|
+
...(database === 'dynamodb' && config.dynamodb.admin ? { adminPort: config.dynamodb.adminPort } : {}),
|
|
172
|
+
});
|
|
173
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RepoSnapshot {
|
|
2
|
+
gitDir: boolean;
|
|
3
|
+
config: string[];
|
|
4
|
+
hooks: Record<string, string>;
|
|
5
|
+
redirects: string[];
|
|
6
|
+
commands: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface RepoFinding {
|
|
9
|
+
kind: 'hook' | 'config' | 'git' | 'command';
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const snapshotRepo: (workspace: string) => RepoSnapshot;
|
|
13
|
+
/**
|
|
14
|
+
* Compares the workspace with its pre-launch snapshot and neutralises anything new that would execute on this PC.
|
|
15
|
+
* Returns what was found (and fixed). Never touches what was already there before the sandbox ran.
|
|
16
|
+
*/
|
|
17
|
+
export declare const guardRepo: (workspace: string, before: RepoSnapshot) => RepoFinding[];
|
|
18
|
+
export declare const saveRepoSnapshot: (dataPath: string, workspace: string) => void;
|
|
19
|
+
/** Re-checks the workspace of the current/last launch; findings are kept in a host-only file for `status`. */
|
|
20
|
+
export declare const checkRepo: (dataPath: string) => RepoFinding[];
|
|
21
|
+
export declare const repoFindings: (dataPath: string) => (RepoFinding & {
|
|
22
|
+
at?: string;
|
|
23
|
+
})[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Zero-click protection for the folder the sandbox shares with this PC.
|
|
2
|
+
//
|
|
3
|
+
// The agent can write the workspace. Two kinds of change there would run code on THIS PC without you doing anything
|
|
4
|
+
// unusual, so they are snapshotted before launch, re-checked every minute while the sandbox runs and when it closes,
|
|
5
|
+
// and neutralised:
|
|
6
|
+
// - git: new or changed hooks, and config that makes git run programs (core.fsmonitor, core.sshCommand, filters,
|
|
7
|
+
// include.path, ...), plus files that redirect where git reads config and hooks (commondir, config.worktree);
|
|
8
|
+
// - commands: executables dropped in the project root under the name of something you type there (node.exe,
|
|
9
|
+
// git.cmd, npm.cmd, ...), because Windows runs a program from the current folder before searching PATH.
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import _ from 'lodash';
|
|
14
|
+
import { dirExists, fileExists, isLink, tryReadJson, writeJson } from '../core/util.js';
|
|
15
|
+
/** Config keys that make git run a program, or load more config. */
|
|
16
|
+
const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool)\s*=/i;
|
|
17
|
+
const SECTION = /^\s*\[([^\]]*)\]\s*/;
|
|
18
|
+
const MARK = '# [agent-sandbox] disabled, added inside the sandbox: ';
|
|
19
|
+
/** Files in .git that make git read config/hooks from somewhere else. */
|
|
20
|
+
const REDIRECTS = ['commondir', 'config.worktree'];
|
|
21
|
+
/** Names you are likely to run in a project folder; with any of these extensions Windows would pick the local copy. */
|
|
22
|
+
const COMMANDS = new RegExp('^(' + [
|
|
23
|
+
// this tool's own commands (package.json "bin")
|
|
24
|
+
'sandbox', 'windows-development-sandbox', 'agent-sandbox', 'claude-sbx', 'claude-sandbox', 'codex-sbx', 'codex-sandbox', 'gemini-sbx', 'gemini-sandbox', 'claude-host', 'sbx-show', 'sbx-hide',
|
|
25
|
+
// agents
|
|
26
|
+
'claude', 'codex', 'gemini', 'opencode', 'qwen', 'crush', 'copilot', 'kilo', 'kilocode', 'amp', 'cline', 'cn', 'kimi', 'reasonix', 'codewhale', 'aider', 'cursor-agent', 'agent', 'goose',
|
|
27
|
+
// toolchains and everyday tools
|
|
28
|
+
'node', 'npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'corepack', 'volta', 'nvm', 'deno', 'bun', 'bunx', 'tsc', 'git', 'gh', 'code', 'python', 'python3', 'py', 'pip', 'pip3', 'uv', 'uvx', 'pyenv', 'poetry',
|
|
29
|
+
'cargo', 'rustc', 'rustup', 'dotnet', 'nuget', 'msbuild', 'go', 'java', 'javac', 'mvn', 'mvnw', 'gradle', 'gradlew', 'ruby', 'gem', 'bundle', 'rake', 'rails', 'php', 'composer',
|
|
30
|
+
'make', 'cmake', 'ninja', 'gcc', 'g\\+\\+', 'clang', 'psql', 'mysql', 'mariadb', 'mongosh', 'redis-cli', 'docker', 'kubectl', 'terraform', 'az', 'aws',
|
|
31
|
+
'powershell', 'pwsh', 'cmd', 'where', 'tasklist', 'tar', 'curl', 'ssh', 'scp',
|
|
32
|
+
].join('|') + ')\\.(exe|com|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|ps1)$', 'i');
|
|
33
|
+
const hash = (file) => (isLink(file) ? 'link' : crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'));
|
|
34
|
+
const plainGitDir = (workspace) => {
|
|
35
|
+
const dir = path.join(workspace, '.git');
|
|
36
|
+
return dirExists(dir) && !isLink(dir) ? dir : null;
|
|
37
|
+
};
|
|
38
|
+
const filesIn = (dir, accept) => dirExists(dir) && !isLink(dir)
|
|
39
|
+
? Object.fromEntries(fs.readdirSync(dir, { withFileTypes: true }).filter((e) => (e.isFile() || e.isSymbolicLink()) && accept(e.name)).map((e) => [e.name, hash(path.join(dir, e.name))]))
|
|
40
|
+
: {};
|
|
41
|
+
const configLines = (dir) => {
|
|
42
|
+
const file = path.join(dir, 'config');
|
|
43
|
+
return fileExists(file) && !isLink(file) ? fs.readFileSync(file, 'utf8').split(/\r?\n/) : [];
|
|
44
|
+
};
|
|
45
|
+
export const snapshotRepo = (workspace) => {
|
|
46
|
+
const dir = plainGitDir(workspace);
|
|
47
|
+
return {
|
|
48
|
+
gitDir: dir !== null,
|
|
49
|
+
config: dir ? configLines(dir) : [],
|
|
50
|
+
hooks: dir ? filesIn(path.join(dir, 'hooks'), (n) => !n.endsWith('.sample')) : {},
|
|
51
|
+
redirects: dir ? REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n))) : [],
|
|
52
|
+
commands: filesIn(workspace, (n) => COMMANDS.test(n)),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** Moves files into <dir>\agent-sandbox-quarantine\<time>\ and reports them. */
|
|
56
|
+
const quarantine = (from, names, into, kind) => {
|
|
57
|
+
if (names.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
const target = path.join(into, 'agent-sandbox-quarantine', String(Date.now()));
|
|
60
|
+
fs.mkdirSync(target, { recursive: true });
|
|
61
|
+
return names.map((name) => {
|
|
62
|
+
fs.renameSync(path.join(from, name), path.join(target, name));
|
|
63
|
+
return { kind, detail: name + ' (moved to ' + target + ')' };
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
/** Comments out newly added config lines that make git run programs. Existing lines are never touched. */
|
|
67
|
+
const disableConfig = (dir, before) => {
|
|
68
|
+
const lines = configLines(dir);
|
|
69
|
+
const known = _.countBy(before);
|
|
70
|
+
const seen = {};
|
|
71
|
+
const findings = [];
|
|
72
|
+
let inInclude = false;
|
|
73
|
+
const fixed = lines.map((line) => {
|
|
74
|
+
const header = SECTION.exec(line);
|
|
75
|
+
if (header)
|
|
76
|
+
inInclude = /^include(if\s+"[^"]*")?$/i.test(header[1].trim());
|
|
77
|
+
seen[line] = (seen[line] ?? 0) + 1;
|
|
78
|
+
if (line.startsWith(MARK) || seen[line] <= (known[line] ?? 0))
|
|
79
|
+
return line;
|
|
80
|
+
// git also accepts "[core] fsmonitor = x" on one line, so look past a leading section header.
|
|
81
|
+
const keyPart = line.replace(SECTION, '').trim();
|
|
82
|
+
if (!(DANGEROUS_KEY.test(keyPart) || (inInclude && keyPart.includes('='))))
|
|
83
|
+
return line;
|
|
84
|
+
findings.push({ kind: 'config', detail: line.trim() });
|
|
85
|
+
return MARK + line.trim();
|
|
86
|
+
});
|
|
87
|
+
if (findings.length)
|
|
88
|
+
fs.writeFileSync(path.join(dir, 'config'), fixed.join('\n'), 'utf8');
|
|
89
|
+
return findings;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Compares the workspace with its pre-launch snapshot and neutralises anything new that would execute on this PC.
|
|
93
|
+
* Returns what was found (and fixed). Never touches what was already there before the sandbox ran.
|
|
94
|
+
*/
|
|
95
|
+
export const guardRepo = (workspace, before) => {
|
|
96
|
+
const findings = [];
|
|
97
|
+
const commandsNow = filesIn(workspace, (n) => COMMANDS.test(n));
|
|
98
|
+
findings.push(...quarantine(workspace, Object.keys(commandsNow).filter((n) => before.commands[n] !== commandsNow[n]), workspace, 'command'));
|
|
99
|
+
const dir = plainGitDir(workspace);
|
|
100
|
+
if (!dir) {
|
|
101
|
+
const replaced = before.gitDir && fs.existsSync(path.join(workspace, '.git'));
|
|
102
|
+
if (replaced)
|
|
103
|
+
findings.push({ kind: 'git', detail: '.git was replaced by a link or a "gitdir:" file inside the sandbox. Do not run git in ' + workspace + ' until you have checked it.' });
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
const hooksDir = path.join(dir, 'hooks');
|
|
107
|
+
const hooksNow = filesIn(hooksDir, (n) => !n.endsWith('.sample'));
|
|
108
|
+
findings.push(...quarantine(hooksDir, Object.keys(hooksNow).filter((n) => before.hooks[n] !== hooksNow[n]), dir, 'hook'));
|
|
109
|
+
findings.push(...quarantine(dir, REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n)) && !before.redirects.includes(n)), dir, 'git'));
|
|
110
|
+
findings.push(...disableConfig(dir, before.config));
|
|
111
|
+
return findings;
|
|
112
|
+
};
|
|
113
|
+
// ---- snapshot storage (host-only, next to the sync state) ---------------------------------------------------------
|
|
114
|
+
const syncDir = (dataPath) => path.join(path.dirname(path.dirname(dataPath)), 'sync');
|
|
115
|
+
const snapshotFile = (dataPath) => path.join(syncDir(dataPath), path.basename(dataPath) + '-repo.json');
|
|
116
|
+
const findingsFile = (dataPath) => path.join(syncDir(dataPath), path.basename(dataPath) + '-repo-findings.json');
|
|
117
|
+
export const saveRepoSnapshot = (dataPath, workspace) => writeJson(snapshotFile(dataPath), { workspace, snapshot: snapshotRepo(workspace) });
|
|
118
|
+
/** Re-checks the workspace of the current/last launch; findings are kept in a host-only file for `status`. */
|
|
119
|
+
export const checkRepo = (dataPath) => {
|
|
120
|
+
const saved = tryReadJson(snapshotFile(dataPath));
|
|
121
|
+
if (!saved?.snapshot?.commands)
|
|
122
|
+
return [];
|
|
123
|
+
const findings = guardRepo(saved.workspace, saved.snapshot);
|
|
124
|
+
if (findings.length)
|
|
125
|
+
writeJson(findingsFile(dataPath), [...repoFindings(dataPath), ...findings.map((f) => ({ ...f, at: new Date().toISOString() }))]);
|
|
126
|
+
return findings;
|
|
127
|
+
};
|
|
128
|
+
export const repoFindings = (dataPath) => tryReadJson(findingsFile(dataPath)) ?? [];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Config } from '../core/types.js';
|
|
2
|
+
export type SecretsConfig = Config['secrets'];
|
|
3
|
+
export declare const isSecretName: (name: string, secrets: SecretsConfig) => boolean;
|
|
4
|
+
export declare const isSecretValue: (value: string) => boolean;
|
|
5
|
+
/** Whether a variable (name and value) may go into the sandbox. secrets.allow always wins. */
|
|
6
|
+
export declare const mayPass: (name: string, value: unknown, secrets: SecretsConfig) => boolean;
|
|
7
|
+
/** A copy of an env/headers map without the secrets; the names removed are added to `removed` (with `where`). */
|
|
8
|
+
export declare const stripVariables: (vars: unknown, secrets: SecretsConfig, where: string, removed: string[]) => unknown;
|
|
9
|
+
/** settings.json for the sandbox: secrets out of "env", credential helper commands removed. */
|
|
10
|
+
export declare const stripSettings: (settings: Record<string, unknown>, secrets: SecretsConfig, removed: string[]) => Record<string, unknown>;
|
|
11
|
+
/** Every MCP server in a .claude.json-shaped object (top level and per project). */
|
|
12
|
+
export declare const stripMcpServers: (state: Record<string, unknown>, secrets: SecretsConfig, removed: string[]) => Record<string, unknown>;
|
|
13
|
+
/** Secret-looking files in the workspace root and one folder down (skipping dependency folders). */
|
|
14
|
+
export declare const findSecretFiles: (workspace: string) => string[];
|