yolocage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yolocage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # yolocage
2
+
3
+ Sandboxed claude-code / codex in a container, with a built-in egress credential scrubber as the safety net. Run your AI coding agent in `--dangerously-skip-permissions` mode without worrying about accidentally leaking the `.env` file you just `cat`'d into context.
4
+
5
+ ```bash
6
+ cd ~/dev/myproject
7
+ npx yolocage claude # or: yc claude
8
+ ```
9
+
10
+ That's it. Claude starts in your project directory, using your existing claude.ai login, with HTTP egress to `api.anthropic.com` routed through an in-cage scrubber that catches credential-shaped strings before they reach the LLM.
11
+
12
+ ## What yolocage actually does
13
+
14
+ When you accidentally `Read .env` or `git config --list` into an agent's context, that secret enters the conversation and gets re-sent to the LLM on every subsequent turn. Once it's in context, hooks can't reach it. Yolocage runs a mitmproxy-based scrubber inside the container that intercepts HTTPS to the LLM provider and same-length-redacts credential-shaped strings out of the request body before they leave the cage.
15
+
16
+ Yolocage is the opinionated container + CLI wrapper. The scrubber library is published separately as [ssproxy](https://github.com/jlamendo/ssproxy).
17
+
18
+ ## Requirements
19
+
20
+ - docker (CLI access; yolocage will sudo automatically if you're not in the docker group)
21
+ - node 16+ (for npm/npx)
22
+
23
+ That's it. Mac (Intel + Apple Silicon) and Debian/Ubuntu Linux supported.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ # One-shot: no install, just run
29
+ npx yolocage claude
30
+
31
+ # Or install globally
32
+ npm i -g yolocage
33
+ yc claude
34
+ ```
35
+
36
+ > The global install registers both `yolocage` and `yc` as commands. Yandex Cloud's CLI also uses `yc` — if you have both, PATH order decides which wins. Invoke `yolocage` to disambiguate, or alias one of them. The conflict is rare enough that the short alias is worth shipping; document over remove.
37
+
38
+ ## Usage
39
+
40
+ ### Shortcut form (per-directory, persistent)
41
+
42
+ ```bash
43
+ yc # defaults to claude in $(pwd)
44
+ yc claude # explicit
45
+ yc codex # codex instead of claude
46
+ yc claude -- --resume # pass-through args after --
47
+ ```
48
+
49
+ Each project directory gets its own cage. The cage name is derived deterministically from the directory path (`yc-<type>-<basename>-<8hex>`), so re-running `yc claude` in the same dir either **resumes** the stopped cage or **attaches** to the running one — never spawns a duplicate. Claude is run with `--continue` by default so the prior in-cwd conversation is restored on re-attach.
50
+
51
+ Three outcomes from a single `yc claude` invocation, picked automatically:
52
+
53
+ - **No cage for this dir yet** → create, start, attach. Cage persists between sessions.
54
+ - **Cage exists but is stopped** → `docker start -ai` (restart and reattach). Claude `--continue` resumes the prior session.
55
+ - **Cage is already running** → `docker attach` to the live session. Press `Ctrl-P Ctrl-Q` to detach without stopping the cage; `Ctrl-C` exits claude (and stops the cage).
56
+
57
+ Cages accumulate as you move between projects. Use `yc list` to see them and `yc rm <name>` to clean up. Volumes for the per-cage mitmproxy CA and any state live alongside the container — `yc rm` removes both.
58
+
59
+ ### Named cages (persistent)
60
+
61
+ ```bash
62
+ yc create projectbox --type=claude --bind-workspace=./src
63
+ yc run projectbox # attach
64
+ yc list # show all cages
65
+ yc rm projectbox # destroy
66
+ yc logs projectbox # tail
67
+ yc pull # refresh yolocage images
68
+ yc update # update yolocage CLI + images in one shot
69
+ ```
70
+
71
+ ### Updating yolocage
72
+
73
+ ```bash
74
+ yc update # binary + images
75
+ yc update --check # just report the version delta
76
+ yc update --no-pull # skip the docker pull
77
+ yc update --force # reinstall current version
78
+ ```
79
+
80
+ `yc update` runs `npm install -g yolocage@latest` (with sudo if the global prefix needs root) and then re-pulls the cage images. If you installed via `npx` instead of a global install, `yc update` will tell you so and only refresh the images — the next `npx yolocage` invocation fetches the latest binary on its own.
81
+
82
+ Named cages persist between runs. Use them when you want to maintain in-container state across sessions (with `--tmux`) or want explicit naming for the cage holding your active claude session.
83
+
84
+ ### Configuration
85
+
86
+ Precedence (lowest to highest):
87
+ 1. Type defaults (workspace = cwd, config dir = `~/.claude` for claude, `~/.codex` for codex)
88
+ 2. `~/.ycrc`
89
+ 3. `./.ycrc` (project-local)
90
+ 4. CLI flags
91
+
92
+ #### `.ycrc` syntax
93
+
94
+ Simple key=value, comments via `#`. Path values expand leading `~` and literal `$HOME` (no other shell expansion):
95
+
96
+ ```
97
+ # ~/.ycrc — defaults applied to every cage
98
+ type = claude
99
+ memory = 4g
100
+ tmux = true
101
+ ssproxy_extensions = ~/.yolocage/scrubbers.json
102
+
103
+ # Extra binds appended across layers
104
+ extra_bind_dirs = ~/.aws:/home/agent/.aws:ro
105
+ ```
106
+
107
+ ```
108
+ # ./.ycrc — project-local overrides
109
+ image = yolocage/claude:1.4.2
110
+ extra_bind_dirs = ./secrets:/etc/secrets:ro
111
+ ```
112
+
113
+ #### Flags
114
+
115
+ | flag | meaning |
116
+ |---|---|
117
+ | `--type=claude\|codex` | which agent (shortcut form embeds this in the verb) |
118
+ | `--bind-workspace=PATH` | host path mounted at `/workspace` (default: cwd) |
119
+ | `--config-dir=PATH` | host path for the type config dir |
120
+ | `--bind-dirs=H:C[:M]` | replaces type defaults (repeatable) |
121
+ | `--extra-bind-dirs=H:C[:M]` | appends extra binds (repeatable) |
122
+ | `--ssproxy-extensions=PATH` | custom scrub pattern file |
123
+ | `--tmux` / `--no-tmux` | run agent inside tmux (default: false) |
124
+ | `--memory=2g`, `--cpus=2` | docker resource limits |
125
+ | `--image=REPO:TAG` | override default image |
126
+
127
+ ### Cascade semantics
128
+
129
+ | key | append or replace across layers |
130
+ |---|---|
131
+ | `type`, `image`, `workspace`, `config_dir`, `memory`, `cpus`, `tmux` | replace (later wins entirely) |
132
+ | `bind_dirs` | replace (later layer wins entirely, including its absence) |
133
+ | `extra_bind_dirs` | append (union of all layers, dedupe by `host:container`) |
134
+ | `ssproxy_extensions` | replace |
135
+
136
+ ## Custom scrub patterns
137
+
138
+ Companies can extend the scrubber with their own credential patterns:
139
+
140
+ ```bash
141
+ yc claude --ssproxy-extensions=./.yolocage/scrubbers.json
142
+ ```
143
+
144
+ ```json
145
+ [
146
+ { "id": "acme-internal-token", "regex": "\\bACME_[A-Z0-9]{32}\\b" },
147
+ { "id": "acme-deploy-key", "regex": "\\b(acme_deploy_[a-f0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)" }
148
+ ]
149
+ ```
150
+
151
+ Patterns are byte-stream regex over the request body. **Use the boundary-anchor convention** shown in the second example — the scrubber does same-length replacement, and a regex without a trailing anchor can overrun the secret boundary into a JSON-structural byte and break the upstream LLM's request parsing. See `docs/writing-scrub-extensions.md`.
152
+
153
+ ## Security model
154
+
155
+ Yolocage is a **safety net for accidental leakage**, not a defense against a hostile agent.
156
+
157
+ - The scrubber catches credential-shaped strings on egress to known LLM API hosts. It does not prevent an agent that's already been prompt-injected from exfiltrating data through other channels.
158
+ - The cage runs `--dangerously-skip-permissions`. The agent CAN modify any file in your mounted workspace. Don't mount paths you don't want the agent to touch.
159
+ - The mitmproxy CA cert lives in a per-cage docker volume; it signs intercepts only of LLM API hosts originating from inside that cage. Blast radius of a leaked CA is "someone with the same yolocage image already on your machine," which means they have bigger problems.
160
+
161
+ ## Open source
162
+
163
+ - yolocage (this repo): MIT, the opinionated container + CLI
164
+ - [ssproxy](https://github.com/jlamendo/ssproxy): MIT, the scrubber library
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,104 @@
1
+ // Bind-spec parser.
2
+ //
3
+ // Accepted shapes:
4
+ // host:container
5
+ // host:container:ro
6
+ // host:container:rw
7
+ //
8
+ // Trickiness: Mac paths can contain colons in volume names (rare) and
9
+ // Windows-style C:\… isn't supported, but the parser must not choke on
10
+ // "host paths that have colons" if the third-from-end token isn't a
11
+ // recognized mode. The algorithm:
12
+ //
13
+ // - Split on `:`.
14
+ // - If 2 tokens: host=tokens[0], container=tokens[1], mode=default.
15
+ // - If 3+ tokens: check if the LAST token is `ro`/`rw`. If yes:
16
+ // mode = last; container = second-to-last; host = everything else
17
+ // joined by `:`.
18
+ // Otherwise:
19
+ // container = last; host = everything else joined by `:`;
20
+ // mode = default.
21
+ //
22
+ // `~`-expansion happens on the host side (same rules as .ycrc).
23
+
24
+ 'use strict';
25
+
26
+ const os = require('os');
27
+
28
+ const VALID_MODES = new Set(['ro', 'rw']);
29
+ const DEFAULT_MODE = 'rw';
30
+
31
+ function expandHomeOnHost(p) {
32
+ if (typeof p !== 'string') return p;
33
+ if (p.startsWith('~/')) return os.homedir() + p.slice(1);
34
+ if (p === '~') return os.homedir();
35
+ return p.replace(/\$HOME(?![A-Za-z0-9_])/g, os.homedir());
36
+ }
37
+
38
+ function parseBindSpec(spec) {
39
+ if (typeof spec !== 'string' || spec.length === 0) {
40
+ throw new Error(`bind-spec: empty or non-string spec`);
41
+ }
42
+ const tokens = spec.split(':');
43
+ if (tokens.length < 2) {
44
+ throw new Error(`bind-spec: invalid spec ${JSON.stringify(spec)} (need at least host:container)`);
45
+ }
46
+
47
+ let host, container, mode;
48
+ if (tokens.length === 2) {
49
+ [host, container] = tokens;
50
+ mode = DEFAULT_MODE;
51
+ } else {
52
+ const last = tokens[tokens.length - 1];
53
+ if (VALID_MODES.has(last)) {
54
+ mode = last;
55
+ container = tokens[tokens.length - 2];
56
+ host = tokens.slice(0, tokens.length - 2).join(':');
57
+ } else {
58
+ mode = DEFAULT_MODE;
59
+ container = last;
60
+ host = tokens.slice(0, tokens.length - 1).join(':');
61
+ }
62
+ }
63
+
64
+ if (!host) throw new Error(`bind-spec: empty host path in ${JSON.stringify(spec)}`);
65
+ if (!container) throw new Error(`bind-spec: empty container path in ${JSON.stringify(spec)}`);
66
+ if (!container.startsWith('/')) {
67
+ throw new Error(`bind-spec: container path must be absolute, got ${JSON.stringify(container)}`);
68
+ }
69
+ host = expandHomeOnHost(host);
70
+
71
+ return { host, container, mode };
72
+ }
73
+
74
+ function formatBindSpec(b) {
75
+ return `${b.host}:${b.container}:${b.mode}`;
76
+ }
77
+
78
+ // Dedupe a list of bind specs by their host:container pair (mode is
79
+ // not part of the dedupe key — later mode wins). Preserves input
80
+ // order, dropping subsequent duplicates.
81
+ function dedupeBindList(list) {
82
+ const seen = new Map(); // "host:container" -> index in out
83
+ const out = [];
84
+ for (const b of list) {
85
+ const key = `${b.host}:${b.container}`;
86
+ if (seen.has(key)) {
87
+ // Later layer's mode wins.
88
+ out[seen.get(key)] = b;
89
+ } else {
90
+ seen.set(key, out.length);
91
+ out.push(b);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ module.exports = {
98
+ parseBindSpec,
99
+ formatBindSpec,
100
+ dedupeBindList,
101
+ expandHomeOnHost,
102
+ VALID_MODES,
103
+ DEFAULT_MODE,
104
+ };
package/lib/config.js ADDED
@@ -0,0 +1,158 @@
1
+ // Configuration cascade.
2
+ //
3
+ // Precedence (lowest → highest):
4
+ // 1. type defaults (claude / codex)
5
+ // 2. ~/.ycrc
6
+ // 3. ./.ycrc
7
+ // 4. CLI flags
8
+ //
9
+ // Per-key cascade rule (matches the table in README.md §"Cascade semantics"):
10
+ //
11
+ // type, image, workspace, config_dir, memory, cpus, tmux,
12
+ // ssproxy_extensions → replace (later wins entirely)
13
+ // bind_dirs → replace (later layer wins entirely)
14
+ // extra_bind_dirs → append + dedupe by host:container
15
+ //
16
+ // The output is a fully-resolved plain-object cage spec ready for
17
+ // lib/docker.js to translate into a `docker run` argv.
18
+
19
+ 'use strict';
20
+
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ const { getType } = require('./types');
25
+ const { readYcrc } = require('./ycrc');
26
+ const { parseBindSpec, dedupeBindList } = require('./bind-spec');
27
+
28
+ const REPLACE_KEYS = new Set([
29
+ 'type',
30
+ 'image',
31
+ 'workspace',
32
+ 'config_dir',
33
+ 'memory',
34
+ 'cpus',
35
+ 'tmux',
36
+ 'ssproxy_extensions',
37
+ 'bind_dirs', // replace, not append
38
+ ]);
39
+
40
+ const APPEND_KEYS = new Set(['extra_bind_dirs']);
41
+
42
+ // Resolve a raw cascade-merged spec into a normalized cage spec. The
43
+ // raw spec carries .ycrc-shaped strings; the normalized spec carries
44
+ // the values we'll hand to docker.
45
+ function normalize(raw) {
46
+ const type = raw.type || 'claude';
47
+ const td = getType(type);
48
+
49
+ const workspace = raw.workspace
50
+ ? path.resolve(raw.workspace)
51
+ : path.resolve(process.cwd());
52
+
53
+ const configDirHost = raw.config_dir
54
+ ? path.resolve(raw.config_dir)
55
+ : td.configDirHost;
56
+
57
+ // bind_dirs (replace): if the user set it, use ONLY those + the
58
+ // workspace + the config_dir. If unset, generate the type defaults.
59
+ let bindDirs;
60
+ if (Array.isArray(raw.bind_dirs) && raw.bind_dirs.length > 0) {
61
+ bindDirs = raw.bind_dirs.map(parseBindSpec);
62
+ } else {
63
+ bindDirs = [
64
+ { host: workspace, container: '/workspace', mode: 'rw' },
65
+ { host: configDirHost, container: td.configDirContainer, mode: 'rw' },
66
+ ];
67
+ }
68
+
69
+ const extraBindDirs = (raw.extra_bind_dirs || []).map(parseBindSpec);
70
+ const allBinds = dedupeBindList([...bindDirs, ...extraBindDirs]);
71
+
72
+ const tmux = parseBool(raw.tmux);
73
+
74
+ return {
75
+ type,
76
+ image: raw.image || td.image,
77
+ workspace,
78
+ configDirHost,
79
+ configDirContainer: td.configDirContainer,
80
+ bindDirs: allBinds,
81
+ memory: raw.memory || null,
82
+ cpus: raw.cpus || null,
83
+ tmux,
84
+ ssproxyExtensions: raw.ssproxy_extensions || null,
85
+ cmd: td.cmd.slice(),
86
+ passthrough: Array.isArray(raw.passthrough) ? raw.passthrough.slice() : [],
87
+ };
88
+ }
89
+
90
+ function parseBool(v) {
91
+ if (typeof v === 'boolean') return v;
92
+ if (v === undefined || v === null || v === '') return false;
93
+ const s = String(v).toLowerCase();
94
+ return s === 'true' || s === '1' || s === 'yes' || s === 'on';
95
+ }
96
+
97
+ // Merge `incoming` into `acc`. Replace keys clobber; append keys union.
98
+ function mergeLayer(acc, incoming) {
99
+ if (!incoming) return acc;
100
+ for (const k of Object.keys(incoming)) {
101
+ if (APPEND_KEYS.has(k)) {
102
+ const prev = Array.isArray(acc[k]) ? acc[k] : [];
103
+ const next = Array.isArray(incoming[k]) ? incoming[k] : [incoming[k]];
104
+ acc[k] = [...prev, ...next];
105
+ } else if (REPLACE_KEYS.has(k)) {
106
+ if (incoming[k] !== undefined) acc[k] = incoming[k];
107
+ } else {
108
+ // Unknown key — preserve last-wins like REPLACE for forward compat.
109
+ if (incoming[k] !== undefined) acc[k] = incoming[k];
110
+ }
111
+ }
112
+ return acc;
113
+ }
114
+
115
+ // Build the cascade.
116
+ // opts.type — explicit type from argv ('claude'|'codex'|...)
117
+ // opts.homeYcrcPath — typically ~/.ycrc
118
+ // opts.projectYcrcPath — typically ./.ycrc (cwd)
119
+ // opts.cliLayer — already-parsed object from commander
120
+ function resolveCascade(opts) {
121
+ const acc = {};
122
+
123
+ // Layer 1: type baseline — sets `type` only. Defaults for workspace
124
+ // / config_dir flow through normalize() based on cwd + $HOME.
125
+ if (opts.type) mergeLayer(acc, { type: opts.type });
126
+
127
+ // Layer 2: ~/.ycrc
128
+ let home = null;
129
+ try {
130
+ home = readYcrc(opts.homeYcrcPath);
131
+ } catch (e) {
132
+ throw new Error(`error reading ${opts.homeYcrcPath}: ${e.message}`);
133
+ }
134
+ if (home) mergeLayer(acc, home);
135
+
136
+ // Layer 3: ./.ycrc
137
+ let project = null;
138
+ try {
139
+ project = readYcrc(opts.projectYcrcPath);
140
+ } catch (e) {
141
+ throw new Error(`error reading ${opts.projectYcrcPath}: ${e.message}`);
142
+ }
143
+ if (project) mergeLayer(acc, project);
144
+
145
+ // Layer 4: CLI flags
146
+ if (opts.cliLayer) mergeLayer(acc, opts.cliLayer);
147
+
148
+ return normalize(acc);
149
+ }
150
+
151
+ module.exports = {
152
+ resolveCascade,
153
+ normalize,
154
+ mergeLayer,
155
+ parseBool,
156
+ REPLACE_KEYS,
157
+ APPEND_KEYS,
158
+ };
package/lib/docker.js ADDED
@@ -0,0 +1,170 @@
1
+ // Docker shell-out helpers.
2
+ //
3
+ // All docker invocations route through here. The module probes once on
4
+ // first use for whether the current user can talk to the docker daemon
5
+ // without sudo; if not, every command is prefixed with `sudo`. The
6
+ // fix-it message ("add yourself to the docker group with …") is printed
7
+ // at most once per process.
8
+ //
9
+ // Tests mock `child_process` to drive the probe branches.
10
+
11
+ 'use strict';
12
+
13
+ const { spawnSync, execFileSync } = require('child_process');
14
+
15
+ let _sudoChecked = false;
16
+ let _useSudo = false;
17
+ let _noticePrinted = false;
18
+
19
+ function _probe(env) {
20
+ if (_sudoChecked) return _useSudo;
21
+ _sudoChecked = true;
22
+
23
+ // DOCKER_HOST suggests the user is talking to a non-local daemon
24
+ // (rootless, ssh:// tunnel, etc.) — don't try sudo in that case;
25
+ // the env tells us they've already wired it up themselves.
26
+ if (env.DOCKER_HOST) {
27
+ _useSudo = false;
28
+ return _useSudo;
29
+ }
30
+
31
+ // First try docker info as the calling user.
32
+ const direct = spawnSync('docker', ['info'], { stdio: 'ignore', env });
33
+ if (direct.status === 0) {
34
+ _useSudo = false;
35
+ return _useSudo;
36
+ }
37
+
38
+ // Try sudo. If sudo isn't installed or password-prompts, this fails
39
+ // and we fall through to "use direct + let the error tell the user".
40
+ const sudo = spawnSync('sudo', ['-n', 'docker', 'info'], { stdio: 'ignore', env });
41
+ if (sudo.status === 0) {
42
+ _useSudo = true;
43
+ return _useSudo;
44
+ }
45
+
46
+ // Couldn't reach docker either way. Don't trip sudo — let the next
47
+ // docker call surface the real error to the user.
48
+ _useSudo = false;
49
+ return _useSudo;
50
+ }
51
+
52
+ function maybeSudo(env) {
53
+ return _probe(env || process.env);
54
+ }
55
+
56
+ // Reset the module-level cache. For tests and re-init flows; not part
57
+ // of the public CLI surface.
58
+ function _resetProbeCache() {
59
+ _sudoChecked = false;
60
+ _useSudo = false;
61
+ _noticePrinted = false;
62
+ }
63
+
64
+ function _printNoticeOnce(stderr) {
65
+ if (_noticePrinted) return;
66
+ _noticePrinted = true;
67
+ stderr.write(
68
+ 'yolocage: docker requires sudo on this system. To skip this, add ' +
69
+ 'yourself to the docker group:\n' +
70
+ ' sudo usermod -aG docker $USER && newgrp docker\n'
71
+ );
72
+ }
73
+
74
+ // Build the docker argv for a given cage spec. Returns:
75
+ // { argv: [...], cmd: [...] } for piping into spawnSync(argv[0], argv.slice(1).concat(cmd))
76
+ function buildRunArgs(spec, opts) {
77
+ opts = opts || {};
78
+ const args = ['run'];
79
+ if (opts.rm !== false) args.push('--rm');
80
+ if (opts.interactive !== false) args.push('-it');
81
+ if (opts.detach) args.push('-d');
82
+ if (opts.name) args.push('--name', opts.name);
83
+
84
+ // Per-cage docker volume for mitmproxy CA + state. Shared across
85
+ // create/run for named cages so the CA persists.
86
+ const volName = opts.volName || (opts.name ? `${opts.name}-mitmproxy` : 'yolocage-mitmproxy-ephemeral');
87
+ args.push('-v', `${volName}:/home/agent/.mitmproxy`);
88
+
89
+ for (const b of spec.bindDirs) {
90
+ args.push('-v', `${b.host}:${b.container}:${b.mode}`);
91
+ }
92
+
93
+ if (spec.memory) args.push('--memory', spec.memory);
94
+ if (spec.cpus) args.push('--cpus', String(spec.cpus));
95
+
96
+ // Workdir defaults to /workspace.
97
+ args.push('-w', '/workspace');
98
+
99
+ // Env: tmux toggle + extensions path.
100
+ args.push('-e', `YC_TMUX=${spec.tmux ? '1' : '0'}`);
101
+ if (spec.ssproxyExtensions) {
102
+ // The host file is mounted RO into a stable container location.
103
+ args.push('-v', `${spec.ssproxyExtensions}:/etc/yolocage/scrub-extensions.json:ro`);
104
+ args.push('-e', 'SSPROXY_EXTENSIONS=/etc/yolocage/scrub-extensions.json');
105
+ }
106
+
107
+ args.push(spec.image);
108
+ const cmd = [...spec.cmd, ...(spec.passthrough || [])];
109
+ return { argv: args, cmd };
110
+ }
111
+
112
+ // Compose the final argv with sudo prefix as needed.
113
+ function dockerArgv(args, env) {
114
+ env = env || process.env;
115
+ if (maybeSudo(env)) return ['sudo', '-n', 'docker', ...args];
116
+ return ['docker', ...args];
117
+ }
118
+
119
+ // Does a container with this name exist (running OR stopped)?
120
+ // Uses `docker container inspect` which exits 0 for any state.
121
+ function cageExists(name, opts) {
122
+ opts = opts || {};
123
+ const env = opts.env || process.env;
124
+ const argv = dockerArgv(['container', 'inspect', name], env);
125
+ const res = spawnSync(argv[0], argv.slice(1), { stdio: 'ignore', env });
126
+ return res.status === 0;
127
+ }
128
+
129
+ // Is the container with this name in the Running state? Returns false
130
+ // for non-existent OR stopped containers.
131
+ function cageRunning(name, opts) {
132
+ opts = opts || {};
133
+ const env = opts.env || process.env;
134
+ const argv = dockerArgv(
135
+ ['container', 'inspect', '-f', '{{.State.Running}}', name],
136
+ env
137
+ );
138
+ const res = spawnSync(argv[0], argv.slice(1), {
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ env,
141
+ encoding: 'utf8',
142
+ });
143
+ if (res.status !== 0) return false;
144
+ return String(res.stdout || '').trim() === 'true';
145
+ }
146
+
147
+ // Run a docker command synchronously, inheriting stdio. Returns the
148
+ // exit status; callers may pass {stdio: 'pipe'} for captured output.
149
+ function runDocker(args, opts) {
150
+ opts = opts || {};
151
+ const env = opts.env || process.env;
152
+ const stderr = opts.stderr || process.stderr;
153
+ if (maybeSudo(env)) _printNoticeOnce(stderr);
154
+ const argv = dockerArgv(args, env);
155
+ const res = spawnSync(argv[0], argv.slice(1), {
156
+ stdio: opts.stdio || 'inherit',
157
+ env,
158
+ });
159
+ return res;
160
+ }
161
+
162
+ module.exports = {
163
+ maybeSudo,
164
+ buildRunArgs,
165
+ dockerArgv,
166
+ runDocker,
167
+ cageExists,
168
+ cageRunning,
169
+ _resetProbeCache,
170
+ };
package/lib/types.js ADDED
@@ -0,0 +1,64 @@
1
+ // Type defaults table. The shortcut form (`yc claude`) and named-cage
2
+ // `--type=…` flag pick from these. v0 ships claude + codex enabled; opencode
3
+ // is sketched in but `v0:false` so the CLI errors cleanly if the operator
4
+ // asks for it.
5
+ //
6
+ // Each entry's `bindDirs` describes the default mounts for a fresh cage:
7
+ // workspace: a host path mounted at /workspace (default = cwd)
8
+ // configDir: a host path for the agent's per-user config (claude.ai login
9
+ // cache + codex auth). Replaceable via --config-dir.
10
+ //
11
+ // `cmd` is the in-container command line the entrypoint exec()s by default.
12
+ // CLI pass-through args (`yc claude -- --resume`) are appended after.
13
+
14
+ 'use strict';
15
+
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ const HOME = os.homedir();
20
+
21
+ const TYPE_DEFAULTS = {
22
+ claude: {
23
+ v0: true,
24
+ image: 'yolocage/claude:dev',
25
+ configDirHost: path.join(HOME, '.claude'),
26
+ configDirContainer: '/home/agent/.claude',
27
+ // --continue auto-resumes the most recent in-cwd conversation; harmless on
28
+ // first run (claude falls back to a fresh session). With per-cwd persistent
29
+ // cages, this is what the operator wants by default — `yc claude` always
30
+ // either resumes or starts fresh, never re-introduces itself mid-project.
31
+ cmd: ['claude', '--continue', '--dangerously-skip-permissions'],
32
+ },
33
+ codex: {
34
+ v0: true,
35
+ image: 'yolocage/codex:dev',
36
+ configDirHost: path.join(HOME, '.codex'),
37
+ configDirContainer: '/home/agent/.codex',
38
+ cmd: ['codex', '--full-auto'],
39
+ },
40
+ opencode: {
41
+ v0: false,
42
+ image: 'yolocage/opencode:dev',
43
+ configDirHost: path.join(HOME, '.config', 'opencode'),
44
+ configDirContainer: '/home/agent/.config/opencode',
45
+ cmd: ['opencode'],
46
+ },
47
+ };
48
+
49
+ function isKnownType(type) {
50
+ return Object.prototype.hasOwnProperty.call(TYPE_DEFAULTS, type);
51
+ }
52
+
53
+ function getType(type) {
54
+ if (!isKnownType(type)) {
55
+ throw new Error(`unknown --type=${type} (known: ${Object.keys(TYPE_DEFAULTS).join(', ')})`);
56
+ }
57
+ const t = TYPE_DEFAULTS[type];
58
+ if (!t.v0) {
59
+ throw new Error(`--type=${type} support coming in v2 (v0 ships claude + codex only)`);
60
+ }
61
+ return t;
62
+ }
63
+
64
+ module.exports = { TYPE_DEFAULTS, getType, isKnownType };