moflo 4.8.69 → 4.8.71
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/.claude/guidance/shipped/moflo-spell-sandboxing.md +65 -0
- package/package.json +2 -2
- package/src/modules/cli/dist/src/commands/doctor.js +47 -17
- package/src/modules/cli/dist/src/epic/spells/auto-merge.yaml +4 -0
- package/src/modules/cli/dist/src/epic/spells/single-branch.yaml +7 -0
- 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 +101 -2
- package/src/modules/spells/dist/core/sandbox-profile.js +5 -1
|
@@ -233,6 +233,71 @@ Semantics (from `resolveEffectiveSandbox()` in `src/modules/spells/src/core/plat
|
|
|
233
233
|
|
|
234
234
|
Existing projects that predate this block get it auto-appended on session start — never require `moflo init` to re-run after a version bump.
|
|
235
235
|
|
|
236
|
+
## Authoring Checklist — Always Double-Check Step Permissions
|
|
237
|
+
|
|
238
|
+
Before shipping any new or edited spell step, walk through every item. Silently-missing permissions don't fail with `CAPABILITY_DENIED` — they fail confusingly several steps later.
|
|
239
|
+
|
|
240
|
+
1. **What does the command actually do?** List every external effect — file reads/writes, git/gh calls, outbound HTTP, Claude subagents, credential use.
|
|
241
|
+
2. **Which capabilities map to those effects?** `fs:read`, `fs:write`, `shell`, `net`, `credentials`, `agent`, `browser`, `memory`.
|
|
242
|
+
3. **What is the minimum `permissionLevel`?**
|
|
243
|
+
- Pure analysis (read only) → `readonly`
|
|
244
|
+
- Edits project files but no shell/network → `standard`
|
|
245
|
+
- Runs shell commands, **or needs network inside a bwrap-sandboxed step** → `elevated`
|
|
246
|
+
- Spawns Claude subagents with unrestricted tools → `autonomous`
|
|
247
|
+
4. **Does this step need network?** If so, either give it `permissionLevel: elevated` or declare the `net` capability explicitly. See the Troubleshooting section — bwrap strips network from any bash step that has neither, and the failure surfaces as a DNS error, not a permission error.
|
|
248
|
+
5. **Does the command chain multiple statements (`;`, `&&`, `||`)?** Lead the command with `set -e`. A trailing tolerated-failure cleanup like `git stash pop ... || true` will otherwise return 0 for the whole step even when the real work (checkout, pull, push) failed.
|
|
249
|
+
6. **Does this step produce state that later steps rely on?** If this step silently no-ops, will the downstream symptom be understandable? If not, tighten the failure mode (`set -e`, explicit error, `failOnError: true`).
|
|
250
|
+
|
|
251
|
+
When reviewing a spell PR, scan every bash step for a missing `permissionLevel` and ask: *does this step touch the network, or does it depend on network state from earlier?* If yes, `elevated` (or an explicit `net` grant) is required.
|
|
252
|
+
|
|
253
|
+
## Troubleshooting
|
|
254
|
+
|
|
255
|
+
### Symptom: bash step fails with DNS / SSH resolution errors inside a spell
|
|
256
|
+
|
|
257
|
+
Typical error messages from inside a bash step:
|
|
258
|
+
|
|
259
|
+
- `ssh: Could not resolve hostname github.com: Temporary failure in name resolution`
|
|
260
|
+
- `fatal: Could not read from remote repository.`
|
|
261
|
+
- `curl: (6) Could not resolve host ...`
|
|
262
|
+
- `getaddrinfo ENOTFOUND ...`
|
|
263
|
+
- Any other DNS/connection failure, even though the **same command works in your normal shell**.
|
|
264
|
+
|
|
265
|
+
**Tell-tale clue:** the error mentions `Temporary failure in name resolution` (a glibc-specific wording). That means the step is running inside a Linux sandbox (`bwrap` on Linux / WSL), **not** your outer shell — Git Bash or PowerShell won't produce that exact message.
|
|
266
|
+
|
|
267
|
+
**Root cause:** `src/modules/spells/src/core/bwrap-sandbox.ts` isolates the network by default:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
if (!hasNet && !needsToolHomeAccess(options.permissionLevel)) {
|
|
271
|
+
args.push('--unshare-net'); // ← no network, no DNS
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A bash step gets network access only when **one** of these is true:
|
|
276
|
+
|
|
277
|
+
1. The step declares a `net` capability, **or**
|
|
278
|
+
2. The step's `permissionLevel` is `elevated` or `autonomous`.
|
|
279
|
+
|
|
280
|
+
If neither applies, bwrap runs the command in a namespace with `--unshare-net`, and DNS silently fails. There is no log line announcing the network was taken away — you just see the command's own DNS error.
|
|
281
|
+
|
|
282
|
+
**Fix:** for any bash step that does `git pull`/`git push`/`git fetch`, `gh` API calls, `curl`, `npm install`, or any other outbound network:
|
|
283
|
+
|
|
284
|
+
```yaml
|
|
285
|
+
- id: create-branch
|
|
286
|
+
type: bash
|
|
287
|
+
permissionLevel: elevated # ← grants network in bwrap
|
|
288
|
+
config:
|
|
289
|
+
command: "git pull origin main && ..."
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Or declare the `net` capability explicitly if the step doesn't need the full `elevated` profile (note: `bash-command.ts` must include `net` in its declared capabilities for the engine to accept the grant — otherwise you'll see `Capability violation: step type "bash" does not declare capability "net"`).
|
|
293
|
+
|
|
294
|
+
**Quick diagnosis checklist** when a spell's bash step can't reach the network:
|
|
295
|
+
|
|
296
|
+
1. Does the same command work in your outer shell? If yes, it's sandbox-related, not config.
|
|
297
|
+
2. Is the error wording glibc-style (`Temporary failure in name resolution`)? → bwrap is involved.
|
|
298
|
+
3. Open the spell YAML — does the failing step have `permissionLevel: elevated`? If not, add it and retry.
|
|
299
|
+
4. If you use `set -e` in a multi-command bash step, **do it**. Without it, a trailing `... || true` (common for stash-pop cleanups) will mask the real network failure and you'll see a confusing error several steps later (e.g. "pathspec did not match" when a branch that was never pulled/created is later checked out).
|
|
300
|
+
|
|
236
301
|
## See Also
|
|
237
302
|
|
|
238
303
|
- `.claude/guidance/shipped/moflo-spell-engine.md` — Spell engine usage and YAML format
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.71",
|
|
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.70",
|
|
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 {
|
|
@@ -58,6 +58,8 @@ steps:
|
|
|
58
58
|
# on dirty tree, missing auth, or unreachable remote.
|
|
59
59
|
- id: checkout-base
|
|
60
60
|
type: bash
|
|
61
|
+
# elevated — bwrap network access for git pull (see single-branch create-branch).
|
|
62
|
+
permissionLevel: elevated
|
|
61
63
|
preflight:
|
|
62
64
|
- name: "no unmerged files"
|
|
63
65
|
command: "git diff --name-only --diff-filter=U"
|
|
@@ -123,6 +125,8 @@ steps:
|
|
|
123
125
|
# 5: Pull merged changes
|
|
124
126
|
- id: pull-merged
|
|
125
127
|
type: bash
|
|
128
|
+
# elevated — bwrap network access for git pull (see checkout-base).
|
|
129
|
+
permissionLevel: elevated
|
|
126
130
|
config:
|
|
127
131
|
# set -e: fail fast if checkout/pull fails (see checkout-base).
|
|
128
132
|
command: "set -e; git stash --include-untracked -q 2>/dev/null || true; git checkout {args.base_branch}; git pull origin {args.base_branch}; git stash pop -q 2>/dev/null || true"
|
|
@@ -55,6 +55,11 @@ steps:
|
|
|
55
55
|
# Preflights run BEFORE any step — fail fast on dirty tree or auth issues
|
|
56
56
|
- id: create-branch
|
|
57
57
|
type: bash
|
|
58
|
+
# elevated — needed for bwrap to share the host network so `git pull`
|
|
59
|
+
# can reach github. Without it, bwrap adds --unshare-net and the pull
|
|
60
|
+
# fails with a DNS / SSH resolution error even though the outer shell
|
|
61
|
+
# has working network.
|
|
62
|
+
permissionLevel: elevated
|
|
58
63
|
preflight:
|
|
59
64
|
- name: "no unmerged files"
|
|
60
65
|
command: "git diff --name-only --diff-filter=U"
|
|
@@ -134,6 +139,8 @@ steps:
|
|
|
134
139
|
# Preflight: verify origin remote exists so push won't fail mid-way
|
|
135
140
|
- id: push-branch
|
|
136
141
|
type: bash
|
|
142
|
+
# elevated — bwrap network access for git push (see create-branch).
|
|
143
|
+
permissionLevel: elevated
|
|
137
144
|
preflight:
|
|
138
145
|
- name: "origin remote configured"
|
|
139
146
|
command: "git remote get-url origin"
|
|
@@ -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-slim';
|
|
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,25 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
|
|
|
167
174
|
displayStatus: `OS sandbox: disabled (denylist active)`,
|
|
168
175
|
};
|
|
169
176
|
}
|
|
170
|
-
//
|
|
177
|
+
// Windows: give beginner-friendly setup instructions when sandboxing is
|
|
178
|
+
// enabled but Docker isn't ready. Runs before the generic "not available"
|
|
179
|
+
// branch so Windows users see actionable guidance instead of a terse
|
|
180
|
+
// "not available (win32)" message.
|
|
181
|
+
if (capability.platform === 'win32') {
|
|
182
|
+
if (!capability.available) {
|
|
183
|
+
throw new Error(formatWindowsDockerNotReadyMessage());
|
|
184
|
+
}
|
|
185
|
+
if (!config.dockerImage) {
|
|
186
|
+
throw new Error(formatWindowsDockerImageMissingMessage());
|
|
187
|
+
}
|
|
188
|
+
if (!dockerImageExists(config.dockerImage)) {
|
|
189
|
+
throw new Error(formatWindowsDockerImageNotPulledMessage(config.dockerImage));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// tier: full — require OS sandbox on non-Windows platforms
|
|
171
193
|
if (config.tier === 'full' && !capability.available) {
|
|
172
194
|
throw new Error(`Sandbox tier "full" requires an OS sandbox but none was detected on ${capability.platform}. ` +
|
|
173
|
-
`Install bubblewrap (Linux)
|
|
195
|
+
`Install bubblewrap (Linux) or set sandbox.tier to "auto".`);
|
|
174
196
|
}
|
|
175
197
|
if (!capability.available) {
|
|
176
198
|
return {
|
|
@@ -187,6 +209,83 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
|
|
|
187
209
|
displayStatus: `OS sandbox: ${capability.tool} (${capability.platform})`,
|
|
188
210
|
};
|
|
189
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Check whether a Docker image is available locally (already pulled).
|
|
214
|
+
* Returns false on any error (daemon down, image missing, docker not in PATH).
|
|
215
|
+
*/
|
|
216
|
+
function dockerImageExists(image) {
|
|
217
|
+
try {
|
|
218
|
+
execSync(`docker image inspect ${shellQuote(image)}`, { stdio: 'ignore', timeout: 5000 });
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Minimal shell quoting for image names — keeps the execSync call safe. */
|
|
226
|
+
function shellQuote(value) {
|
|
227
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
228
|
+
}
|
|
229
|
+
// ── Beginner-friendly setup messages (Windows) ──────────────────────────
|
|
230
|
+
function formatWindowsDockerNotReadyMessage() {
|
|
231
|
+
return [
|
|
232
|
+
'Sandboxing is enabled, but Docker Desktop is not ready on this machine.',
|
|
233
|
+
'',
|
|
234
|
+
'Windows sandboxing runs your spell steps inside a Docker container so',
|
|
235
|
+
'they cannot touch the rest of your system. This is a one-time setup:',
|
|
236
|
+
'',
|
|
237
|
+
' 1. Install Docker Desktop (free):',
|
|
238
|
+
' https://www.docker.com/products/docker-desktop/',
|
|
239
|
+
'',
|
|
240
|
+
' 2. After installing, start Docker Desktop from the Start menu.',
|
|
241
|
+
' Wait for the whale icon in your system tray to stop animating —',
|
|
242
|
+
' that means Docker is ready.',
|
|
243
|
+
'',
|
|
244
|
+
' 3. Open PowerShell (or any terminal) and pull the recommended image:',
|
|
245
|
+
` docker pull ${RECOMMENDED_DOCKER_IMAGE}`,
|
|
246
|
+
'',
|
|
247
|
+
' 4. Add this to your moflo.yaml:',
|
|
248
|
+
' sandbox:',
|
|
249
|
+
' enabled: true',
|
|
250
|
+
` dockerImage: ${RECOMMENDED_DOCKER_IMAGE}`,
|
|
251
|
+
'',
|
|
252
|
+
'Not ready to set this up? You can turn sandboxing off instead by setting',
|
|
253
|
+
'`sandbox.enabled: false` in moflo.yaml.',
|
|
254
|
+
].join('\n');
|
|
255
|
+
}
|
|
256
|
+
function formatWindowsDockerImageMissingMessage() {
|
|
257
|
+
return [
|
|
258
|
+
'Sandboxing is enabled, but no Docker image is configured.',
|
|
259
|
+
'',
|
|
260
|
+
'Docker is ready on this machine — it just needs to know which image to',
|
|
261
|
+
'run your spell steps inside. This is a one-time setup:',
|
|
262
|
+
'',
|
|
263
|
+
' 1. Open PowerShell (or any terminal) and pull the recommended image:',
|
|
264
|
+
` docker pull ${RECOMMENDED_DOCKER_IMAGE}`,
|
|
265
|
+
'',
|
|
266
|
+
' 2. Add this to your moflo.yaml:',
|
|
267
|
+
' sandbox:',
|
|
268
|
+
' enabled: true',
|
|
269
|
+
` dockerImage: ${RECOMMENDED_DOCKER_IMAGE}`,
|
|
270
|
+
'',
|
|
271
|
+
`The recommended image (${RECOMMENDED_DOCKER_IMAGE}) includes node, npm,`,
|
|
272
|
+
'bash, git, and curl. Any image with bash will work.',
|
|
273
|
+
].join('\n');
|
|
274
|
+
}
|
|
275
|
+
function formatWindowsDockerImageNotPulledMessage(image) {
|
|
276
|
+
return [
|
|
277
|
+
`Sandboxing is enabled, but the Docker image "${image}" is not available`,
|
|
278
|
+
'on this machine yet.',
|
|
279
|
+
'',
|
|
280
|
+
'To fix this, open PowerShell (or any terminal) and run:',
|
|
281
|
+
` docker pull ${image}`,
|
|
282
|
+
'',
|
|
283
|
+
'This only needs to happen once — Docker caches the image afterwards.',
|
|
284
|
+
'',
|
|
285
|
+
'If Docker Desktop is not running, start it from the Start menu first',
|
|
286
|
+
'and wait for the whale icon in your system tray to stop animating.',
|
|
287
|
+
].join('\n');
|
|
288
|
+
}
|
|
190
289
|
/**
|
|
191
290
|
* Format a one-line log message for spell startup.
|
|
192
291
|
*/
|
|
@@ -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*)');
|