moflo 4.8.70 → 4.8.72
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/package.json +2 -2
- package/src/modules/cli/dist/src/commands/doctor.js +47 -17
- package/src/modules/cli/dist/src/version.js +1 -1
- package/src/modules/cli/package.json +1 -1
- package/src/modules/spells/dist/commands/bash-command.js +14 -0
- package/src/modules/spells/dist/core/docker-sandbox.js +195 -0
- package/src/modules/spells/dist/core/platform-sandbox.js +80 -2
- package/src/modules/spells/dist/core/sandbox-profile.js +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.72",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. Forked from ruflo/claude-flow with patches applied to source, plus feature-level orchestration.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"@types/js-yaml": "^4.0.9",
|
|
113
113
|
"@types/node": "^20.19.37",
|
|
114
114
|
"eslint": "^8.0.0",
|
|
115
|
-
"moflo": "^4.8.
|
|
115
|
+
"moflo": "^4.8.71",
|
|
116
116
|
"tsx": "^4.21.0",
|
|
117
117
|
"typescript": "^5.9.3",
|
|
118
118
|
"vitest": "^4.0.0"
|
|
@@ -1332,37 +1332,67 @@ export const doctorCommand = {
|
|
|
1332
1332
|
return { name: 'Spell Engine', status: 'warn', message: 'Unable to check spell engine' };
|
|
1333
1333
|
}
|
|
1334
1334
|
}
|
|
1335
|
-
// Check sandbox tier — reports
|
|
1335
|
+
// Check sandbox tier — reports OS sandbox capability AND, if the project
|
|
1336
|
+
// has `sandbox.enabled: true`, whether the effective sandbox would
|
|
1337
|
+
// actually start (e.g. Windows Docker image pulled and configured).
|
|
1336
1338
|
async function checkSandboxTier() {
|
|
1337
1339
|
try {
|
|
1338
|
-
// Walk up to CLI package root, then resolve sibling spells package
|
|
1339
|
-
// (works from both src/ and dist/src/ locations)
|
|
1340
1340
|
const __doctorDir = dirname(fileURLToPath(import.meta.url));
|
|
1341
1341
|
let cliPkgRoot = __doctorDir;
|
|
1342
1342
|
while (cliPkgRoot !== dirname(cliPkgRoot) && !existsSync(join(cliPkgRoot, 'package.json'))) {
|
|
1343
1343
|
cliPkgRoot = dirname(cliPkgRoot);
|
|
1344
1344
|
}
|
|
1345
1345
|
const sandboxPath = resolve(cliPkgRoot, '..', 'spells', 'dist', 'core', 'platform-sandbox.js');
|
|
1346
|
-
const
|
|
1346
|
+
const sandboxModule = await import(pathToFileURL(sandboxPath).href);
|
|
1347
|
+
const { detectSandboxCapability, loadSandboxConfigFromProject, resolveEffectiveSandbox, } = sandboxModule;
|
|
1347
1348
|
const cap = detectSandboxCapability();
|
|
1348
|
-
|
|
1349
|
+
const config = await loadSandboxConfigFromProject(process.cwd());
|
|
1350
|
+
// If sandboxing isn't enabled in moflo.yaml, just report capability.
|
|
1351
|
+
if (!config.enabled) {
|
|
1352
|
+
if (cap.available) {
|
|
1353
|
+
return {
|
|
1354
|
+
name: 'Sandbox Tier',
|
|
1355
|
+
status: 'pass',
|
|
1356
|
+
message: `${cap.tool} available (${cap.platform}) — sandboxing off in moflo.yaml`,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
const offHint = {
|
|
1360
|
+
win32: 'Install Docker Desktop and set sandbox.dockerImage in moflo.yaml to enable sandboxing',
|
|
1361
|
+
linux: 'Install bubblewrap: sudo apt install bubblewrap',
|
|
1362
|
+
darwin: 'sandbox-exec should be available on macOS — check /usr/bin/sandbox-exec',
|
|
1363
|
+
};
|
|
1349
1364
|
return {
|
|
1350
1365
|
name: 'Sandbox Tier',
|
|
1351
1366
|
status: 'pass',
|
|
1352
|
-
message:
|
|
1367
|
+
message: `sandboxing off (${cap.platform}, denylist active)`,
|
|
1368
|
+
fix: offHint[cap.platform],
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
// Sandboxing is enabled — run the real resolver and surface any error.
|
|
1372
|
+
try {
|
|
1373
|
+
const effective = resolveEffectiveSandbox(config);
|
|
1374
|
+
if (effective.useOsSandbox) {
|
|
1375
|
+
const imageHint = effective.config.dockerImage ? `, ${effective.config.dockerImage}` : '';
|
|
1376
|
+
return {
|
|
1377
|
+
name: 'Sandbox Tier',
|
|
1378
|
+
status: 'pass',
|
|
1379
|
+
message: `${cap.tool} ready (${cap.platform}${imageHint})`,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
name: 'Sandbox Tier',
|
|
1384
|
+
status: 'warn',
|
|
1385
|
+
message: `denylist only (${cap.platform})`,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
return {
|
|
1390
|
+
name: 'Sandbox Tier',
|
|
1391
|
+
status: 'warn',
|
|
1392
|
+
message: `sandboxing enabled but not ready (${cap.platform})`,
|
|
1393
|
+
fix: err instanceof Error ? err.message : String(err),
|
|
1353
1394
|
};
|
|
1354
1395
|
}
|
|
1355
|
-
const platformHint = {
|
|
1356
|
-
win32: 'Windows has no OS-level sandbox — denylist and capability gateway still active',
|
|
1357
|
-
linux: 'Install bubblewrap: sudo apt install bubblewrap',
|
|
1358
|
-
darwin: 'sandbox-exec should be available on macOS — check /usr/bin/sandbox-exec',
|
|
1359
|
-
};
|
|
1360
|
-
return {
|
|
1361
|
-
name: 'Sandbox Tier',
|
|
1362
|
-
status: 'warn',
|
|
1363
|
-
message: `denylist only (${cap.platform})`,
|
|
1364
|
-
fix: platformHint[cap.platform] ?? 'No OS sandbox available for this platform',
|
|
1365
|
-
};
|
|
1366
1396
|
}
|
|
1367
1397
|
catch (err) {
|
|
1368
1398
|
return {
|
|
@@ -10,6 +10,7 @@ import { resolvePermissions } from '../core/permission-resolver.js';
|
|
|
10
10
|
import { checkDestructivePatterns, checkDestructivePatternsScoped, formatDestructiveError, validateDestructiveScope, formatScopeViolation, } from './destructive-pattern-checker.js';
|
|
11
11
|
import { wrapWithSandboxExec } from '../core/sandbox-profile.js';
|
|
12
12
|
import { wrapWithBwrap } from '../core/bwrap-sandbox.js';
|
|
13
|
+
import { wrapWithDocker } from '../core/docker-sandbox.js';
|
|
13
14
|
export const bashCommand = {
|
|
14
15
|
type: 'bash',
|
|
15
16
|
description: 'Run a shell command and capture output',
|
|
@@ -155,6 +156,19 @@ export const bashCommand = {
|
|
|
155
156
|
permissionLevel: context.permissionLevel,
|
|
156
157
|
});
|
|
157
158
|
}
|
|
159
|
+
else if (tool === 'docker') {
|
|
160
|
+
const image = context.sandbox.config.dockerImage;
|
|
161
|
+
if (!image) {
|
|
162
|
+
// Defence-in-depth — resolveEffectiveSandbox should have thrown
|
|
163
|
+
// already when the image is missing. If we somehow get here,
|
|
164
|
+
// fall through to unsandboxed rather than crashing the step.
|
|
165
|
+
throw new Error('Docker sandboxing enabled but no dockerImage configured');
|
|
166
|
+
}
|
|
167
|
+
sandboxWrap = wrapWithDocker(command, caps, projectRoot, {
|
|
168
|
+
image,
|
|
169
|
+
permissionLevel: context.permissionLevel,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
158
172
|
}
|
|
159
173
|
catch (err) {
|
|
160
174
|
console.log(`[bash] ${tool} wrapping failed, running unsandboxed: ${err.message}`);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Docker Sandbox Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Wraps a bash command with `docker run` for execution inside a Linux
|
|
5
|
+
* container. Mirrors the bwrap/sandbox-exec interface so `bash-command.ts`
|
|
6
|
+
* can dispatch to any platform's sandbox identically.
|
|
7
|
+
*
|
|
8
|
+
* Design notes vs bwrap:
|
|
9
|
+
* - Paths must be translated: Windows `C:\path` won't exist in the
|
|
10
|
+
* container. projectRoot mounts at `/workspace` (the container's CWD);
|
|
11
|
+
* scopes inside projectRoot translate to `/workspace/<rel>`; absolute
|
|
12
|
+
* scopes outside the project are skipped with a log line.
|
|
13
|
+
* - Tool home paths mount under `/root/<rel>` so that claude/gh/git inside
|
|
14
|
+
* the container read and write the same config files the user edits on
|
|
15
|
+
* Windows.
|
|
16
|
+
* - `--network none` is the default; `net` cap or elevated/autonomous omit
|
|
17
|
+
* it (default bridge) — mirrors the bwrap policy.
|
|
18
|
+
* - `--rm` handles cleanup automatically.
|
|
19
|
+
*
|
|
20
|
+
* @see https://github.com/eric-cielo/moflo/issues/412
|
|
21
|
+
*/
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { posix, isAbsolute, relative } from 'node:path';
|
|
24
|
+
/** Container path where projectRoot is mounted. */
|
|
25
|
+
const CONTAINER_WORKSPACE = '/workspace';
|
|
26
|
+
/** Container path where the user's home is projected (for tool home mounts). */
|
|
27
|
+
const CONTAINER_HOME = '/root';
|
|
28
|
+
/**
|
|
29
|
+
* Tool home paths mounted rw for elevated/autonomous steps so claude, gh,
|
|
30
|
+
* git, npm can persist their config/credentials/cache. Mirrors the bwrap
|
|
31
|
+
* allowlist; Windows-specific Application Support paths aren't included
|
|
32
|
+
* because the Linux tools inside the container look at POSIX paths.
|
|
33
|
+
*/
|
|
34
|
+
const TOOL_HOME_PATHS = [
|
|
35
|
+
// Claude Code
|
|
36
|
+
'.claude',
|
|
37
|
+
'.claude.json',
|
|
38
|
+
// GitHub CLI
|
|
39
|
+
'.config/gh',
|
|
40
|
+
// git
|
|
41
|
+
'.gitconfig',
|
|
42
|
+
'.git-credentials',
|
|
43
|
+
// npm
|
|
44
|
+
'.npmrc',
|
|
45
|
+
'.npm',
|
|
46
|
+
// Shared XDG locations
|
|
47
|
+
'.config',
|
|
48
|
+
'.cache',
|
|
49
|
+
'.local/share',
|
|
50
|
+
'.local/state',
|
|
51
|
+
];
|
|
52
|
+
function needsToolHomeAccess(level) {
|
|
53
|
+
return level === 'elevated' || level === 'autonomous';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalise a host path into the form Docker Desktop accepts on `-v` mounts.
|
|
57
|
+
* Docker for Windows accepts either native Windows paths or the `/c/...`
|
|
58
|
+
* form — we pass the native path through untouched since `execFile`-style
|
|
59
|
+
* spawning avoids shell-quoting issues.
|
|
60
|
+
*/
|
|
61
|
+
function normaliseHostPath(p) {
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Translate a host scope path into a container path.
|
|
66
|
+
*
|
|
67
|
+
* - If inside `projectRoot` → mount at `/workspace/<relative>` (no extra bind)
|
|
68
|
+
* - If outside → return null (caller will add a dedicated bind at the same
|
|
69
|
+
* POSIX-form path and log that the path is being projected)
|
|
70
|
+
*/
|
|
71
|
+
function translateScopePath(scopePath, projectRoot) {
|
|
72
|
+
if (!isAbsolute(scopePath)) {
|
|
73
|
+
const cleaned = scopePath.replace(/^\.\/+/, '');
|
|
74
|
+
return {
|
|
75
|
+
containerPath: posix.join(CONTAINER_WORKSPACE, cleaned),
|
|
76
|
+
needsBind: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const rel = relative(projectRoot, scopePath);
|
|
80
|
+
const isInside = !rel.startsWith('..') && !isAbsolute(rel);
|
|
81
|
+
if (isInside) {
|
|
82
|
+
return {
|
|
83
|
+
containerPath: posix.join(CONTAINER_WORKSPACE, rel.replace(/\\/g, '/')),
|
|
84
|
+
needsBind: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Outside project — give it a dedicated mount at a synthetic container path.
|
|
88
|
+
// We use a hash-free, deterministic name based on the leaf so repeated
|
|
89
|
+
// scopes produce stable paths.
|
|
90
|
+
return {
|
|
91
|
+
containerPath: `/mnt/sandbox/${toSafeSegment(scopePath)}`,
|
|
92
|
+
needsBind: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function toSafeSegment(hostPath) {
|
|
96
|
+
return hostPath
|
|
97
|
+
.replace(/[\\:]+/g, '_')
|
|
98
|
+
.replace(/\//g, '_')
|
|
99
|
+
.replace(/^_+|_+$/g, '');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build `docker run` arguments from step capabilities.
|
|
103
|
+
*
|
|
104
|
+
* Default posture: project mounted read-only at `/workspace`, no network,
|
|
105
|
+
* no tool home access. Capabilities grant additional permissions:
|
|
106
|
+
* - fs:read scoped → `-v <host>:<container>:ro` for each outside-project
|
|
107
|
+
* path; inside-project paths are already covered by
|
|
108
|
+
* the workspace bind
|
|
109
|
+
* - fs:read unscoped → no-op (workspace is already read-only)
|
|
110
|
+
* - fs:write scoped → promotes scoped paths to rw
|
|
111
|
+
* - fs:write unscoped → promotes `/workspace` to rw
|
|
112
|
+
* - net → omit `--network none`
|
|
113
|
+
*
|
|
114
|
+
* When `permissionLevel` is `elevated` or `autonomous`:
|
|
115
|
+
* - Mount tool home paths rw at `/root/<rel>`
|
|
116
|
+
* - Share the host network (omit `--network none`)
|
|
117
|
+
*/
|
|
118
|
+
export function buildDockerArgs(command, capabilities, projectRoot, options) {
|
|
119
|
+
const args = ['run', '--rm', '-i'];
|
|
120
|
+
// ── Workspace mount (read-only by default) ──────────────────────────
|
|
121
|
+
const fsWrite = capabilities.find(c => c.type === 'fs:write');
|
|
122
|
+
const projectRw = Boolean(fsWrite && (!fsWrite.scope || fsWrite.scope.length === 0));
|
|
123
|
+
args.push('-v', `${normaliseHostPath(projectRoot)}:${CONTAINER_WORKSPACE}${projectRw ? '' : ':ro'}`);
|
|
124
|
+
args.push('-w', CONTAINER_WORKSPACE);
|
|
125
|
+
// Track container paths we've already bound so we don't double-mount.
|
|
126
|
+
const mountedContainerPaths = new Set([CONTAINER_WORKSPACE]);
|
|
127
|
+
// ── fs:write scoped — rw binds for each path ────────────────────────
|
|
128
|
+
// Inside-project scopes use overlay binds: Docker Desktop honours a second
|
|
129
|
+
// `-v` whose container target is a subpath of the first, and the second
|
|
130
|
+
// mount's mode wins for its subtree. That gives us per-scope rw without
|
|
131
|
+
// opening the whole workspace — the same posture bwrap provides via
|
|
132
|
+
// `--ro-bind /` + `--bind <scope>`.
|
|
133
|
+
if (fsWrite && fsWrite.scope && fsWrite.scope.length > 0) {
|
|
134
|
+
for (const scopePath of fsWrite.scope) {
|
|
135
|
+
const resolved = isAbsolute(scopePath) ? scopePath : posix.join(projectRoot, scopePath.replace(/^\.\/+/, ''));
|
|
136
|
+
const { containerPath } = translateScopePath(resolved, projectRoot);
|
|
137
|
+
if (mountedContainerPaths.has(containerPath))
|
|
138
|
+
continue;
|
|
139
|
+
args.push('-v', `${normaliseHostPath(resolved)}:${containerPath}`);
|
|
140
|
+
mountedContainerPaths.add(containerPath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── fs:read scoped — ro binds for outside-project paths ─────────────
|
|
144
|
+
const fsRead = capabilities.find(c => c.type === 'fs:read');
|
|
145
|
+
if (fsRead && fsRead.scope && fsRead.scope.length > 0) {
|
|
146
|
+
for (const scopePath of fsRead.scope) {
|
|
147
|
+
const resolved = isAbsolute(scopePath) ? scopePath : posix.join(projectRoot, scopePath.replace(/^\.\/+/, ''));
|
|
148
|
+
const { containerPath, needsBind } = translateScopePath(resolved, projectRoot);
|
|
149
|
+
if (!needsBind)
|
|
150
|
+
continue;
|
|
151
|
+
if (mountedContainerPaths.has(containerPath))
|
|
152
|
+
continue;
|
|
153
|
+
args.push('-v', `${normaliseHostPath(resolved)}:${containerPath}:ro`);
|
|
154
|
+
mountedContainerPaths.add(containerPath);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ── Tool home paths (elevated/autonomous only) ──────────────────────
|
|
158
|
+
if (needsToolHomeAccess(options.permissionLevel)) {
|
|
159
|
+
const home = options.homeDir ?? homedir();
|
|
160
|
+
if (home) {
|
|
161
|
+
for (const rel of TOOL_HOME_PATHS) {
|
|
162
|
+
const hostPath = posix.join(home.replace(/\\/g, '/'), rel);
|
|
163
|
+
const containerPath = posix.join(CONTAINER_HOME, rel);
|
|
164
|
+
if (mountedContainerPaths.has(containerPath))
|
|
165
|
+
continue;
|
|
166
|
+
args.push('-v', `${normaliseHostPath(hostPath)}:${containerPath}`);
|
|
167
|
+
mountedContainerPaths.add(containerPath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ── Network isolation ───────────────────────────────────────────────
|
|
172
|
+
const hasNet = capabilities.some(c => c.type === 'net');
|
|
173
|
+
if (!hasNet && !needsToolHomeAccess(options.permissionLevel)) {
|
|
174
|
+
args.push('--network', 'none');
|
|
175
|
+
}
|
|
176
|
+
// ── Image + command ─────────────────────────────────────────────────
|
|
177
|
+
args.push(options.image);
|
|
178
|
+
args.push('bash', '-c', command);
|
|
179
|
+
return args;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Wrap a bash command for execution inside a Docker container.
|
|
183
|
+
*
|
|
184
|
+
* Returns a `SandboxWrapResult` identical in shape to the bwrap/sandbox-exec
|
|
185
|
+
* wrappers. `--rm` handles container cleanup, so `cleanup()` is a no-op.
|
|
186
|
+
*/
|
|
187
|
+
export function wrapWithDocker(command, capabilities, projectRoot, options) {
|
|
188
|
+
const args = buildDockerArgs(command, capabilities, projectRoot, options);
|
|
189
|
+
return {
|
|
190
|
+
bin: options.dockerBin ?? 'docker',
|
|
191
|
+
args,
|
|
192
|
+
cleanup: () => { },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=docker-sandbox.js.map
|
|
@@ -19,6 +19,8 @@ export const DEFAULT_SANDBOX_CONFIG = {
|
|
|
19
19
|
enabled: false,
|
|
20
20
|
tier: 'auto',
|
|
21
21
|
};
|
|
22
|
+
/** Recommended image for first-time Windows Docker sandbox setup. */
|
|
23
|
+
export const RECOMMENDED_DOCKER_IMAGE = 'node:20-bookworm';
|
|
22
24
|
// ============================================================================
|
|
23
25
|
// Detection (cached)
|
|
24
26
|
// ============================================================================
|
|
@@ -122,9 +124,14 @@ export function resolveSandboxConfig(raw) {
|
|
|
122
124
|
return DEFAULT_SANDBOX_CONFIG;
|
|
123
125
|
const enabled = raw.enabled;
|
|
124
126
|
const tier = raw.tier;
|
|
127
|
+
const dockerImageRaw = raw.dockerImage ?? raw.docker_image;
|
|
128
|
+
const dockerImage = typeof dockerImageRaw === 'string' && dockerImageRaw.trim().length > 0
|
|
129
|
+
? dockerImageRaw.trim()
|
|
130
|
+
: undefined;
|
|
125
131
|
return {
|
|
126
132
|
enabled: typeof enabled === 'boolean' ? enabled : DEFAULT_SANDBOX_CONFIG.enabled,
|
|
127
133
|
tier: isValidTier(tier) ? tier : DEFAULT_SANDBOX_CONFIG.tier,
|
|
134
|
+
...(dockerImage ? { dockerImage } : {}),
|
|
128
135
|
};
|
|
129
136
|
}
|
|
130
137
|
function isValidTier(value) {
|
|
@@ -167,10 +174,26 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
|
|
|
167
174
|
displayStatus: `OS sandbox: disabled (denylist active)`,
|
|
168
175
|
};
|
|
169
176
|
}
|
|
170
|
-
//
|
|
177
|
+
// Windows: Docker is required for OS sandboxing. If Docker is available,
|
|
178
|
+
// auto-default the image and auto-pull it on first use so the user doesn't
|
|
179
|
+
// have to do manual setup. Only throw if Docker itself isn't installed/running.
|
|
180
|
+
if (capability.platform === 'win32') {
|
|
181
|
+
if (!capability.available) {
|
|
182
|
+
throw new Error(formatWindowsDockerNotReadyMessage());
|
|
183
|
+
}
|
|
184
|
+
const image = config.dockerImage || RECOMMENDED_DOCKER_IMAGE;
|
|
185
|
+
if (!config.dockerImage) {
|
|
186
|
+
// Mutate config to carry the defaulted image through the rest of the pipeline
|
|
187
|
+
config = { ...config, dockerImage: image };
|
|
188
|
+
}
|
|
189
|
+
if (!dockerImageExists(image)) {
|
|
190
|
+
dockerPullImage(image);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// tier: full — require OS sandbox on non-Windows platforms
|
|
171
194
|
if (config.tier === 'full' && !capability.available) {
|
|
172
195
|
throw new Error(`Sandbox tier "full" requires an OS sandbox but none was detected on ${capability.platform}. ` +
|
|
173
|
-
`Install bubblewrap (Linux)
|
|
196
|
+
`Install bubblewrap (Linux) or set sandbox.tier to "auto".`);
|
|
174
197
|
}
|
|
175
198
|
if (!capability.available) {
|
|
176
199
|
return {
|
|
@@ -187,6 +210,61 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
|
|
|
187
210
|
displayStatus: `OS sandbox: ${capability.tool} (${capability.platform})`,
|
|
188
211
|
};
|
|
189
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Check whether a Docker image is available locally (already pulled).
|
|
215
|
+
* Returns false on any error (daemon down, image missing, docker not in PATH).
|
|
216
|
+
*/
|
|
217
|
+
function dockerImageExists(image) {
|
|
218
|
+
try {
|
|
219
|
+
execSync(`docker image inspect ${shellQuote(image)}`, { stdio: 'ignore', timeout: 5000 });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Pull a Docker image, printing a one-time setup banner so the user knows
|
|
228
|
+
* what's happening and why. Throws if the pull fails.
|
|
229
|
+
*/
|
|
230
|
+
function dockerPullImage(image) {
|
|
231
|
+
console.log(`[spell] One-time setup: pulling Docker image ${image} for sandboxing...\n` +
|
|
232
|
+
` This only happens once — Docker caches the image afterwards.`);
|
|
233
|
+
try {
|
|
234
|
+
execSync(`docker pull ${shellQuote(image)}`, { stdio: 'inherit', timeout: 300_000 });
|
|
235
|
+
console.log(`[spell] Docker image ${image} is ready.`);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
throw new Error(`Failed to pull Docker image "${image}".\n\n` +
|
|
239
|
+
'Make sure Docker Desktop is running and you have internet access, then try again.');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Minimal shell quoting for image names — keeps the execSync call safe. */
|
|
243
|
+
function shellQuote(value) {
|
|
244
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
245
|
+
}
|
|
246
|
+
// ── Beginner-friendly setup messages (Windows) ──────────────────────────
|
|
247
|
+
function formatWindowsDockerNotReadyMessage() {
|
|
248
|
+
return [
|
|
249
|
+
'Sandboxing is enabled, but Docker Desktop is not ready on this machine.',
|
|
250
|
+
'',
|
|
251
|
+
'Windows sandboxing runs your spell steps inside a Docker container so',
|
|
252
|
+
'they cannot touch the rest of your system. This is a one-time setup:',
|
|
253
|
+
'',
|
|
254
|
+
' 1. Install Docker Desktop (free):',
|
|
255
|
+
' https://www.docker.com/products/docker-desktop/',
|
|
256
|
+
'',
|
|
257
|
+
' 2. After installing, start Docker Desktop from the Start menu.',
|
|
258
|
+
' Wait for the whale icon in your system tray to stop animating —',
|
|
259
|
+
' that means Docker is ready.',
|
|
260
|
+
'',
|
|
261
|
+
`MoFlo will auto-pull the default image (${RECOMMENDED_DOCKER_IMAGE}) on`,
|
|
262
|
+
'the first spell run.',
|
|
263
|
+
'',
|
|
264
|
+
'Not ready to set this up? Turn sandboxing off by setting',
|
|
265
|
+
'`sandbox.enabled: false` in moflo.yaml.',
|
|
266
|
+
].join('\n');
|
|
267
|
+
}
|
|
190
268
|
/**
|
|
191
269
|
* Format a one-line log message for spell startup.
|
|
192
270
|
*/
|
|
@@ -135,8 +135,12 @@ export function generateSandboxProfile(capabilities, projectRoot, options = {})
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
// ── net ──────────────────────────────────────────────────────────────
|
|
138
|
+
// Elevated/autonomous steps spawn CLI tools (claude, gh, git, npm) that
|
|
139
|
+
// need network to reach their APIs. Mirror the tool-home-paths policy
|
|
140
|
+
// and bwrap's host-network share so those tools can reach
|
|
141
|
+
// api.anthropic.com, api.github.com, etc. without DNS/TLS failures.
|
|
138
142
|
const hasNet = capabilities.some(c => c.type === 'net');
|
|
139
|
-
if (hasNet) {
|
|
143
|
+
if (hasNet || needsToolHomeAccess(options.permissionLevel)) {
|
|
140
144
|
lines.push('');
|
|
141
145
|
lines.push('; Network access');
|
|
142
146
|
lines.push('(allow network*)');
|