Conversation
…#30) * refactor: replace legacy core with rehauled CLI Deletes the legacy CommonJS devcontainer.json generator and moves the oclif + ink rehaul into packages/core. Drops the standalone-workspace leftovers (nested lockfile, nested pnpm-workspace.yaml, self-referential link: dependency) and folds allowBuilds into the root workspace file. * feat: publish rehauled CLI as @theredguild/devcontainer-wizard@2.0.0 Claims the established npm identity instead of debuting under the unpublished @theredguild/dcw name. Installs both a dcw and a devcontainer-wizard binary; dcw stays the advertised command. Adds a ./bin/run.js export subpath so the unscoped wrapper package can resolve the CLI entry, plus an oclif manifest prepack step. * fix(wrapper): resolve the CLI entry subpath, bump to 2.0.0 The scoped package's '.' export now points at the library index, so require.resolve of the bare name spawned a module that does nothing. Resolve @theredguild/devcontainer-wizard/bin/run.js instead. Also adds a dcw bin, drops the unused @oclif/core dependency, and replaces the stale README copied from the legacy core package. * docs: rewrite for v2 and repoint the skill at the scoped name Root and core READMEs now document the oclif/ink CLI rather than the v1 devcontainer.json wizard, and state that both dcw and devcontainer-wizard binaries are installed. SKILL.md references @theredguild/devcontainer-wizard. Drops the stale REPORT.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017LP6DbMB3irfkRdh31tV8e * fix: apply code review fix wave for dcw v2 republish Addresses the 8 findings from the final whole-branch review: - README.md / packages/core/README.md: warn about the npm EEXIST global install collision between the two published bin names, document `dcw attach` (v1 -> v2 editor workflow) in Quick start and Upgrading from v1, and note that Apple Containers does not enforce network isolation (airgapped profiles stay networked without --strict). - packages/core/skill/SKILL.md: add `dcw attach` to the command table and a full usage subsection, and add the missing --[no-]ssh and --force create flags, so the "full command surface, flag vocabulary" claim in the frontmatter holds. - packages/wrapper/README.md: add a breaking-change notice for the v1 audience on the unscoped npm name, with the correct v1 pin (devcontainer-wizard@1). - Add MIT LICENSE at the repo root and copy it into packages/core and packages/wrapper (workspace subpackages don't inherit the root file), wired into both packages' `files` array. - pnpm-workspace.yaml: replace the inert `allowBuilds` key with the real `ignoredBuiltDependencies` key so pnpm actually skips esbuild's/@pnpm's build scripts instead of warning about them. - Remove the stale, unshipped packages/wrapper/pnpm-lock.yaml (v1-era, references packages no longer used). - packages/wrapper/bin.js: forward SIGINT/SIGTERM/SIGHUP to the spawned child so killing the wrapper directly no longer orphans the CLI process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017LP6DbMB3irfkRdh31tV8e * docs(skill): correct three inaccurate claims in the shipped agent skill SKILL.md ships inside the npm tarball and is read by AI agents to drive the CLI, so a wrong claim there misleads automation rather than a human who can check --help. - The "every command" qualifier on global flags was untrue: dcw skill takes none of them, and exec/shell/logs/agent set enableJsonFlag = false, so --json yields no payload there and their stdout must not be parsed as JSON. - attach --workspace was undocumented; it sets the host directory mounted at /workspace when attach has to start a stopped container. - rm --purge silently requires --yes, throwing E_CONFIRM with exit 2. Verified against src/base-command.ts, src/commands/{skill,exec,shell,logs, agent,attach,rm}.ts and src/errors.ts. * fix(engine): report Apple Containers and Docker capabilities from reality Two capability maps described engines that do not exist, in opposite directions, and both defeated `--strict`. Apple Containers declared `capDrop` unsupported ("no Linux capability management; isolation is provided by the per-container VM"), so the translator dropped it. Verified against `container` CLI 1.0.0, it is exposed and genuinely enforced: `--cap-drop ALL` takes CapEff from 00000000a80425fb to 0000000000000000. Every profile on that engine ran with the full default capability set, NET_RAW included. `--read-only` is enforced there too, but stays unsupported on purpose: `readonly-os` always pairs it with ten tmpfs mounts, five carrying uid=1000,gid=1000, and Apple's `--tmpfs` takes the whole argument as the mount path, so those options cannot be expressed. Emitting bare paths would mount root-owned empty filesystems over /home/vscode/.local and .ssh, hiding baked tools and breaking `dcw attach`. Dropping the pair loudly beats applying half of it silently. Docker declared AppArmor supported on every host. On macOS it runs a Linux VM with no AppArmor LSM: the flag is accepted, `docker inspect` returns an empty AppArmorProfile, and the container has no /proc/self/attr/current. Availability now comes from the daemon via `docker info` SecurityOptions rather than the client's platform, so a macOS client driving a remote Linux daemon over DOCKER_HOST is judged correctly in both directions, and an unprobeable daemon fails closed. This makes `--strict` fail on macOS for all four built-in profiles, which is the honest result. Podman and Lima already modelled this. Also fixes `container list --format json` parsing: the schema nests everything under `configuration`/`status`, so reading Docker's flat keys produced name:"" and status:"[object Object]", and the label filter was accepted but ignored (the CLI has no --filter). `dcw ls` called running containers absent, the presence guard in stop/rm matched the unrelated buildkit container, and environments became unremovable. A malformed row is now skipped rather than throwing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(hardening): make --strict fail closed and show applied hardening in --json dcw could request a control, watch the engine quietly not apply it, and report success. Three separate paths let that through. `--json` omitted it. `dcw up --json` reported dropped controls, but `create --up --json` and `attach --json` did not: warnings went to this.warn(), which JSON consumers never see. An agent running `create --profile airgapped --up --json` against an engine that cannot air-gap got `started: true` and no field anywhere in the envelope indicating the container was networked. All three now return the same HardeningReport: appliedFlags, warnings, dropped, unenforced. `--strict` was skipped by every command that enters a running container. It promises to fail if requested hardening cannot be honored; up/create enforced that when starting one, but exec, shell and agent ignored the flag entirely. A shared assertStrictContainer() preflight now runs before entering. It blocks on unenforced controls as well as dropped ones -- AppArmor on a daemon with no LSM is not "dropped" (the flag is on the command line, it just does nothing), so checking droppedHardening alone walked straight past it. The manifest records unenforcedHardening to make that visible. `dcw agent` inferred the air-gap from silence. It read spec.hardening -- what was requested -- so on an engine that cannot honor --network=none it told the user the agent had no network, then forwarded ANTHROPIC_API_KEY into a container that was online, alongside the untrusted code under audit. It now requires positive evidence: --network=none must actually appear in the flags the container was launched with. Missing evidence returns 'unknown' and is treated as unsafe, because absence of evidence is not evidence of enforcement. Two ordering fixes so refusals cost nothing: strict is evaluated before the image build (the verdict depends only on plan and capabilities, both known upfront, so a doomed run no longer builds for minutes first), and `agent --strict --install` decides before npm install mutates the container. Finally, oclif parse errors under --json bypassed the envelope contract: `dcw ls --json --nope` serialized the whole CLIError -- ~120 kB of resolved config, home directory, shell and plugin list -- to stdout with no code, no message, and exit 1 instead of the documented 2. Now a 113-byte E_USAGE envelope with the parse error's own exit code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(attach): bind published SSH to loopback and refuse before mutating `dcw attach --port` emitted a bare `-p 2222:2222`, which Docker publishes on 0.0.0.0 -- an SSH server on a container full of untrusted contract code, reachable from the LAN. Confirmed against a live container: HostIp "" and a listener on *:39222 over both v4 and v6. findFreePort() already probes 127.0.0.1, so loopback was plainly the intent; it is now explicit. Three ordering bugs let attach do irreversible work before reaching a refusal it could have made up front: - `--port` on an air-gapped environment is correctly rejected, but the guard lived in upEnvironment, downstream of a force-remove. Running it killed the live container -- losing an ephemeral tmpfs /workspace -- and only then reported the request was impossible. - `--strict` was checked after provisioning SSH keys, writing ~/.ssh/config and potentially launching an editor. - restarting for `--port` force-removed the running container before the fresh-translation strict check could object. All three now decide before touching anything. A container being reused is judged by what was recorded when it actually started, not by re-running translate() against today's capability map. A container started before the capability corrections in 0369d20 -- with drop-cap genuinely dropped and no --cap-drop on its command line -- would otherwise have passed `attach --strict` under rules it was never launched under, and reported nothing dropped. `--folder` must now be absolute: Zed's remote target is a URL, so `--folder work` silently produced ssh://dcw-demowork. Validated before any environment lookup, since it needs none. Also extracts isOnPath(). Both editor detection and ProxyCommand resolution spawned `command -v`, but `command` is a shell builtin -- macOS ships /usr/bin/command, most Linux distros do not -- so on Linux both silently took their fallback path. resolveDcwInvocation() had no `which` fallback at all, so it always wrote an absolute node+entry ProxyCommand there instead of the stable `dcw ssh-proxy`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(state): keep dcw state private, and stop guessing about containers Manifests inherited a 022 umask -- 0644 files, 0755 directories. They record appliedFlags, which embed the absolute workspace path and the repo URL, so any other local account could read where you work and on what. Now 0600/0700, with an explicit chmod before the rename so rewriting an already-loose manifest tightens it, and a chmod on the directory too: mkdir's mode applies only to directories it creates, so one left 0755 by an earlier version would have stayed readable forever. loadManifest() now refuses a manifest whose inner name does not match its filename. Commands resolve an environment by filename but then act on the inner name -- image tag, container name, state dir -- so `dcw build outer` would build, tag and persist state for `inner`, silently clobbering another environment's namespace. listManifests() already skipped these; loadManifest() handed them back. `rm --purge` no longer needs a working engine. Purging is mostly local state, but engine resolution ran first and unconditionally, so with Docker uninstalled or its daemon down the command failed with E_NO_ENGINE and the environment could never be deleted. Resolution is best-effort under --purge; non-purge still fails as before. `dcw ls` reconciled every environment against a single auto-detected engine, so one created on another engine read as absent while running. Engine failures were also swallowed into a definite 'absent' -- its own doc comment listed an 'unknown' status the code never produced. It now reconciles each environment against its own engine and says 'unknown' when the engine cannot answer, rather than asserting the container is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(spec): close git URL credential and option-injection gaps The scheme-URL rule forbade `@` outright to block https://user:token@host. That also blocked ssh://git@github.com/o/r.git -- the exact form the validation error recommends for private repos -- while the identical scp-style git@github.com:o/r.git was accepted, on the stated grounds that `user@` is a login and not a secret. The two forms are the same remote written two ways, so they are now treated alike: a bare `user@` is allowed on ssh:// and git://, while user:password@ and all http(s) userinfo stay rejected. Percent-encoding walked straight through that check. `ssh://user%3Apass%40host/repo` contains no literal `:` or `@` in its userinfo, so it satisfied the regex; git then percent-decodes it and connects as user:pass@host. The credentials the rule exists to block were persisted to the manifest and the generated Containerfile anyway. `%` is now excluded from every URL branch -- no remote dcw supports needs it. assertCloneSafe() rejected shell metacharacters but not a leading dash, so `--upload-pack=<cmd>` -- a build-time RCE primitive -- passed the check that exists precisely to catch it. Not reachable through the CLI, since zod rejects it at the boundary first, but this function is the defence-in-depth layer before the value is spliced into an unquoted `RUN git clone` line, and it now covers the case independently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * feat(hardening)!: default to the development profile, not to nothing With no --profile, the hardening set was empty: no capability drops, no no-new-privileges, no secure tmpfs. `--strict` passed vacuously, because nothing had been requested that could fail. The wizard reinforced it -- "None (no extra hardening)" was listed first, and the selector highlights the first choice -- so accepting every default in a tool whose purpose is isolating untrusted contract code produced an environment with no isolation at all. Absence of a choice now means the default posture rather than no hardening: `development` (secure tmpfs, no-new-privs, apparmor, secure DNS). Naming --harden keys is itself a deliberate choice, so the default applies only when neither --profile nor --harden was given -- it is never merged on top of explicit keys. `--profile none` is the explicit opt-out, and unknown-profile errors now point at it. In the wizard, profiles lead and "None" moves to the bottom, so opting out is a deliberate act rather than the path of least resistance. BREAKING CHANGE: `dcw create` with neither --profile nor --harden now produces a `development`-hardened environment instead of an unhardened one. Scripts that relied on the empty set must pass --profile none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * chore: make `pnpm test` work from the repo root The root manifest had no scripts block, so `pnpm test` exited 1 with no output at all -- the failure mode where nothing appears to be wrong. Adds build/test/test:watch/test:e2e/clean, each delegating to the core package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * docs: document hardened-by-default, --profile none, and macOS AppArmor Catches the docs up to two behavior changes they did not describe. dcw create with neither --profile nor --harden now applies the development profile rather than creating an unhardened environment; --profile none is the explicit opt-out. Both READMEs and SKILL.md previously implied the opposite, and SKILL.md hedged the development profile as "default-ish" while listing no none entry at all. For a security tool, docs that understate the default are the wrong direction to be wrong in. AppArmor is not enforced on macOS: Docker Desktop and OrbStack run containers in a Linux VM whose daemon reports no AppArmor support, now probed via docker info SecurityOptions instead of assumed. The engine table's Docker row claimed "Full Linux MAC" unconditionally on all platforms, which was wrong on macOS. SKILL.md now also states that --strict fails closed there, so agents do not report AppArmor protection that is not present. Verified against src/domain/profiles.ts (DEFAULT_PROFILE, NO_PROFILE), src/commands/create.ts, src/engine/drivers/docker.ts and src/wizard/App.tsx. Suite green at 247 passed / 1 skipped. * docs: correct the hardening-degradation contract and document the JSON surface c5f47d0 said AppArmor is "dropped with a warning" on macOS. It is not dropped: dockerCaps() marks it caveated with enforced:false, and translate() routes that into `unenforced` -- only `unsupported` reaches `dropped`. An agent following that sentence would check `dropped`, find [], and report the environment as fine. Verified live: a hardened env on this host returns dropped:[] and unenforced:["apparmor"]. The same defect ran wider than that one sentence. Both READMEs and SKILL.md described degradation as a single bucket -- "if the engine can't honor an option it is dropped with a warning" -- which is incomplete for every caveated control, not just AppArmor. All three now name both buckets: dropped (never applied) and unenforced (applied, but the engine may not enforce it), either of which fails --strict. Documents the JSON surface agents actually script against, none of which was written down: - exit-code table (0-9) with the machine `code` for each, replacing the bare claim that "exit codes are deterministic" - the --json error envelope, now uniform for usage errors too, and the rule that warnings go to stderr so stdout stays parseable - `unenforced` alongside appliedFlags/warnings/dropped on `up --json`, and the `hardening` object on `create --up --json` and `attach --json` - `dcw ls` status vocabulary, including the new `unknown` (engine unreachable) which must not be read as `absent`, and the fact that `ls` stays non-fatal where other commands exit 5 - --strict now also refusing to enter an already-running container that was started with dropped or unenforced hardening - git URL rules: bare ssh://user@host is fine, embedded credentials and percent-encoding are rejected, and why (the URL is persisted) - `dcw agent` distinguishing an enforced air-gap from a dropped or unknown one before forwarding provider credentials - published SSH ports binding 127.0.0.1 only Corrects the Apple Containers engine row, which still claimed it "drops Linux cap ... hardening". It applies --cap-drop; what it drops is read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp. Also corrects the wrapper README, the npm page the unscoped v1 install base lands on: installing both packages globally does not leave them "competing for the same symlinks", npm aborts with EEXIST and installs nothing. Adds the uninstall-first path. Every documented exit code was re-verified against the built CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * docs: drop the packages/desktop workspace entry README listed `packages/desktop` as a workspace member, but it was never tracked -- 0 files in git, no commit on this branch touched it -- so a fresh clone never had it. The "How to contribute" section described a directory contributors could not see. It carried no package.json either, so pnpm never treated it as a workspace project; `pnpm install --frozen-lockfile` still reports the same three projects and the lockfile is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…nstall The image pins ENV HOME=/home/vscode for every stage, so the `USER root` node step ran with the same HOME as the unprivileged user. Upstream's devcontainers node install.sh runs its npm-version block as root but wraps the yarn/pnpm steps in `su vscode`, so root seeded root-owned entries in the shared /home/vscode/.npm/_cacache and the later su-vscode npm call died with EACCES. The script runs under `set -e`, so it aborted there and the chown meant to repair the cache never executed — the whole build failed at the node step for every environment that pulls Node. Give root its own HOME instead. The script is fetched to a file first because an env prefix on `curl ... | bash` binds to curl, never to the interpreter on the far side of the pipe. Also list $PNPM_HOME/bin on PATH alongside $PNPM_HOME. pnpm links global bins into $PNPM_HOME itself on v10 and into $PNPM_HOME/bin on v11, but its preflight check refuses `pnpm install -g` unless $PNPM_HOME/bin is present, which broke the hardhat install once the node step started succeeding. Verified end to end on Apple Containers: build exits 0, failedTools is empty, and node/pnpm/hardhat/claude all run inside the container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2
…ing apt noise When nothing serves DNS on the vmnet gateway, every `RUN apt-get update` inside an Apple Containers build ends in `Temporary failure resolving 'deb.debian.org'`. That reads like a broken Containerfile and is actually a host port-53 conflict: Apple's gateway resolver does a wildcard IPv4 bind, which macOS refuses with EADDRINUSE once anything holds a specific 127.0.0.0/8 address on :53 — a local DNS-over-HTTPS proxy, for instance. The IPv6 wildcard bind still succeeds, so `*:53` looks healthy in netstat while IPv4 has no listener at all. Upstream: apple/container#402. Probe for exactly that shape, but only after `container build` has already failed, so healthy builds pay nothing and no preflight can fail closed on a working host. The diagnosis needs both halves — a builder with no resolvers of its own AND no IPv4 listener for the gateway — and fails open, leaving the original build error intact whenever the probe output is unparseable. Raise it as E_ENGINE_DNS (exit 10), separate from E_ENGINE_UNAVAILABLE: the engine is running fine, and the fix is a host network change, not starting a daemon. The message names the processes holding :53 and the `container builder start --dns <ip>` workaround. Also correct the driver's `dns` capability note, which claimed support was "limited" without saying how. `--dns` is genuinely honored by `container run`, so `secure-dns` stays a real control and the capability stays `caveated`; it is the build path that silently ignores the flag, because build steps run inside the shared `buildkit` container and inherit its resolvers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2
…hell rc Upstream's devcontainers node install.sh puts node under /usr/local/share/nvm and exports it only by appending an `nvm.sh` snippet to ~/.zshrc, so node, npm, npx, pnpm and any npm -g bin (claude included) were reachable from `dcw shell` but invisible to `dcw exec` — which runs the command directly, not through an interactive shell. `dcw exec env -- node -v` failed with "failed to find target executable node", and every Node-based tool with a shebang wrapper died with "exec: node: not found", including hardhat, the framework the environment exists to run. The script sets NVM_SYMLINK_CURRENT and keeps a $NVM_DIR/current symlink precisely so a Dockerfile can put it on PATH; do that. Pinning the symlink rather than a versioned directory keeps `nvm alias default` working inside the container. NVM_DIR is declared in the node snippet rather than the base layer so node-less images don't export a dangling path. While here, stop UV_INSTALL re-appending USR_LOCAL_BIN, LOCAL_BIN and PNPM_HOME to a PATH that already ended in all three, which was tripling those entries in every image. Verified from a non-interactive exec on the rebuilt image: node v24.19.0, npm 11.17.0, pnpm 10.18.0, hardhat 3.13.0, claude 2.1.237. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2
A pnpm invocation from the repo root drops a 19M content-addressable store at .pnpm-store/, which showed up as untracked noise in every status check and was one stray `git add .` away from being committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2
Takes the suite from 278 to 733 tests and from 70% to 100% coverage of `src/` on all four metrics, with the bars pinned at 100 in vitest.config so a newly uncovered path fails the run. Seven commands had no in-process coverage at all (build, up, ls, create, engines, schema, ssh-proxy) and several more sat well under half; the engine drivers, the SSH/attach plumbing and the whole ink wizard were largely untested. Subprocess CLI tests exercise real behaviour but are invisible to the coverage instrument, so commands are now driven in-process through a small oclif harness (test/helpers/command.ts) that stubs only parse/log/warn/error/exit/jsonEnabled. Fixes a real bug found while covering the wizard: all six multi-select steps rendered the same component type at the same position with no key, so React reused one instance and read its `initial`-seeded state exactly once. `dcw create --lang solidity --framework foundry` reached the wizard and came back empty — only the first category survived — and the cursor row leaked between steps. Two regression tests lock the fix in. Reaching 100% branches meant deleting arms no input could take rather than hiding them behind ignore pragmas: `split(re)[0] ?? ''` fallbacks that only existed for noUncheckedIndexedAccess, `m.spec.engine ?? null` where zod already defaults it, a `default:` on an index-bounded switch, and two dead arms in attach (the "could not launch" line is unreachable because the explicit-editor path throws first). All behaviour-identical. `tmpfsSizeBytes` is now exported and tested directly, and Banner owns the "no title yet" case so App needs no unreachable fallback. Also adds `pnpm test:coverage`, and `pnpm typecheck` with a new tsconfig.test.json — the tests were never type-checked before, which had let two type errors sit in existing test files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LFtDpUHewj56KruJAWbFz
…visory `pnpm audit --prod` reported 12 advisories (10 high, 2 moderate), all transitive through @oclif/core 4.5.2: minimatch, brace-expansion and picomatch (via tinyglobby). 4.14.0 depends on minimatch ^10.2.5 and newer tinyglobby, so the whole set clears — production audit is now clean. The bump stayed inside the declared ^4 range; pnpm raised the floor to ^4.14.0 to match. Everything still passes on the new major-minor: 733 tests at 100% coverage, typecheck, build, the e2e lifecycle, `oclif manifest`, and a by-hand check that the --json error envelope contract is intact (E_USAGE/exit 2 for an unknown flag and a bad --engine value, E_NOT_FOUND/exit 8 for a missing environment). One test needed adjusting, in the test rather than the source. oclif's own fallback handler changed how it serializes an ExitError under --json: 4.5 logged the raw error object, 4.14 logs a structured form via toErrorJson. The assertion had pinned that shape, which is oclif's implementation detail — what dcw actually promises is that it does not claim an ExitError as its own (no E_* envelope, no second exit), so the test now asserts that instead. Remaining advisories are dev-only (the `oclif` CLI's AWS SDK deps, and vitest/vite/esbuild). None are reachable at runtime and none ship: the published tarball is bin/, dist/, skill/, the manifest and LICENSE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LFtDpUHewj56KruJAWbFz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
… (#30)
Deletes the legacy CommonJS devcontainer.json generator and moves the oclif + ink rehaul into packages/core. Drops the standalone-workspace leftovers (nested lockfile, nested pnpm-workspace.yaml, self-referential link: dependency) and folds allowBuilds into the root workspace file.
Claims the established npm identity instead of debuting under the unpublished @theredguild/dcw name. Installs both a dcw and a devcontainer-wizard binary; dcw stays the advertised command.
Adds a ./bin/run.js export subpath so the unscoped wrapper package can resolve the CLI entry, plus an oclif manifest prepack step.
The scoped package's '.' export now points at the library index, so require.resolve of the bare name spawned a module that does nothing. Resolve @theredguild/devcontainer-wizard/bin/run.js instead.
Also adds a dcw bin, drops the unused @oclif/core dependency, and replaces the stale README copied from the legacy core package.
Root and core READMEs now document the oclif/ink CLI rather than the v1 devcontainer.json wizard, and state that both dcw and devcontainer-wizard binaries are installed. SKILL.md references @theredguild/devcontainer-wizard. Drops the stale REPORT.md.
Claude-Session: https://claude.ai/code/session_017LP6DbMB3irfkRdh31tV8e
Addresses the 8 findings from the final whole-branch review:
dcw attach(v1 -> v2 editor workflow) in Quick start and Upgrading from v1, and note that Apple Containers does not enforce network isolation (airgapped profiles stay networked without --strict).dcw attachto the command table and a full usage subsection, and add the missing --[no-]ssh and --force create flags, so the "full command surface, flag vocabulary" claim in the frontmatter holds.filesarray.allowBuildskey with the realignoredBuiltDependencieskey so pnpm actually skips esbuild's/@pnpm's build scripts instead of warning about them.Claude-Session: https://claude.ai/code/session_017LP6DbMB3irfkRdh31tV8e
SKILL.md ships inside the npm tarball and is read by AI agents to drive the CLI, so a wrong claim there misleads automation rather than a human who can check --help.
Verified against src/base-command.ts, src/commands/{skill,exec,shell,logs, agent,attach,rm}.ts and src/errors.ts.
Two capability maps described engines that do not exist, in opposite directions, and both defeated
--strict.Apple Containers declared
capDropunsupported ("no Linux capability management; isolation is provided by the per-container VM"), so the translator dropped it. Verified againstcontainerCLI 1.0.0, it is exposed and genuinely enforced:--cap-drop ALLtakes CapEff from 00000000a80425fb to 0000000000000000. Every profile on that engine ran with the full default capability set, NET_RAW included.--read-onlyis enforced there too, but stays unsupported on purpose:readonly-osalways pairs it with ten tmpfs mounts, five carrying uid=1000,gid=1000, and Apple's--tmpfstakes the whole argument as the mount path, so those options cannot be expressed. Emitting bare paths would mount root-owned empty filesystems over /home/vscode/.local and .ssh, hiding baked tools and breakingdcw attach. Dropping the pair loudly beats applying half of it silently.Docker declared AppArmor supported on every host. On macOS it runs a Linux VM with no AppArmor LSM: the flag is accepted,
docker inspectreturns an empty AppArmorProfile, and the container has no /proc/self/attr/current. Availability now comes from the daemon viadocker infoSecurityOptions rather than the client's platform, so a macOS client driving a remote Linux daemon over DOCKER_HOST is judged correctly in both directions, and an unprobeable daemon fails closed. This makes--strictfail on macOS for all four built-in profiles, which is the honest result. Podman and Lima already modelled this.Also fixes
container list --format jsonparsing: the schema nests everything underconfiguration/status, so reading Docker's flat keys produced name:"" and status:"[object Object]", and the label filter was accepted but ignored (the CLI has no --filter).dcw lscalled running containers absent, the presence guard in stop/rm matched the unrelated buildkit container, and environments became unremovable. A malformed row is now skipped rather than throwing.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
dcw could request a control, watch the engine quietly not apply it, and report success. Three separate paths let that through.
--jsonomitted it.dcw up --jsonreported dropped controls, butcreate --up --jsonandattach --jsondid not: warnings went to this.warn(), which JSON consumers never see. An agent runningcreate --profile airgapped --up --jsonagainst an engine that cannot air-gap gotstarted: trueand no field anywhere in the envelope indicating the container was networked. All three now return the same HardeningReport: appliedFlags, warnings, dropped, unenforced.--strictwas skipped by every command that enters a running container. It promises to fail if requested hardening cannot be honored; up/create enforced that when starting one, but exec, shell and agent ignored the flag entirely. A shared assertStrictContainer() preflight now runs before entering. It blocks on unenforced controls as well as dropped ones -- AppArmor on a daemon with no LSM is not "dropped" (the flag is on the command line, it just does nothing), so checking droppedHardening alone walked straight past it. The manifest records unenforcedHardening to make that visible.dcw agentinferred the air-gap from silence. It read spec.hardening -- what was requested -- so on an engine that cannot honor --network=none it told the user the agent had no network, then forwarded ANTHROPIC_API_KEY into a container that was online, alongside the untrusted code under audit. It now requires positive evidence: --network=none must actually appear in the flags the container was launched with. Missing evidence returns 'unknown' and is treated as unsafe, because absence of evidence is not evidence of enforcement.Two ordering fixes so refusals cost nothing: strict is evaluated before the image build (the verdict depends only on plan and capabilities, both known upfront, so a doomed run no longer builds for minutes first), and
agent --strict --installdecides before npm install mutates the container.Finally, oclif parse errors under --json bypassed the envelope contract:
dcw ls --json --nopeserialized the whole CLIError -- ~120 kB of resolved config, home directory, shell and plugin list -- to stdout with no code, no message, and exit 1 instead of the documented 2. Now a 113-byte E_USAGE envelope with the parse error's own exit code.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
dcw attach --portemitted a bare-p 2222:2222, which Docker publishes on 0.0.0.0 -- an SSH server on a container full of untrusted contract code, reachable from the LAN. Confirmed against a live container: HostIp "" and a listener on *:39222 over both v4 and v6. findFreePort() already probes 127.0.0.1, so loopback was plainly the intent; it is now explicit.Three ordering bugs let attach do irreversible work before reaching a refusal it could have made up front:
--porton an air-gapped environment is correctly rejected, but the guard lived in upEnvironment, downstream of a force-remove. Running it killed the live container -- losing an ephemeral tmpfs /workspace -- and only then reported the request was impossible.--strictwas checked after provisioning SSH keys, writing ~/.ssh/config and potentially launching an editor.--portforce-removed the running container before the fresh-translation strict check could object.All three now decide before touching anything.
A container being reused is judged by what was recorded when it actually started, not by re-running translate() against today's capability map. A container started before the capability corrections in 0369d20 -- with drop-cap genuinely dropped and no --cap-drop on its command line -- would otherwise have passed
attach --strictunder rules it was never launched under, and reported nothing dropped.--foldermust now be absolute: Zed's remote target is a URL, so--folder worksilently produced ssh://dcw-demowork. Validated before any environment lookup, since it needs none.Also extracts isOnPath(). Both editor detection and ProxyCommand resolution spawned
command -v, butcommandis a shell builtin -- macOS ships /usr/bin/command, most Linux distros do not -- so on Linux both silently took their fallback path. resolveDcwInvocation() had nowhichfallback at all, so it always wrote an absolute node+entry ProxyCommand there instead of the stabledcw ssh-proxy.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
Manifests inherited a 022 umask -- 0644 files, 0755 directories. They record appliedFlags, which embed the absolute workspace path and the repo URL, so any other local account could read where you work and on what. Now 0600/0700, with an explicit chmod before the rename so rewriting an already-loose manifest tightens it, and a chmod on the directory too: mkdir's mode applies only to directories it creates, so one left 0755 by an earlier version would have stayed readable forever.
loadManifest() now refuses a manifest whose inner name does not match its filename. Commands resolve an environment by filename but then act on the inner name -- image tag, container name, state dir -- so
dcw build outerwould build, tag and persist state forinner, silently clobbering another environment's namespace. listManifests() already skipped these; loadManifest() handed them back.rm --purgeno longer needs a working engine. Purging is mostly local state, but engine resolution ran first and unconditionally, so with Docker uninstalled or its daemon down the command failed with E_NO_ENGINE and the environment could never be deleted. Resolution is best-effort under --purge; non-purge still fails as before.dcw lsreconciled every environment against a single auto-detected engine, so one created on another engine read as absent while running. Engine failures were also swallowed into a definite 'absent' -- its own doc comment listed an 'unknown' status the code never produced. It now reconciles each environment against its own engine and says 'unknown' when the engine cannot answer, rather than asserting the container is gone.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
The scheme-URL rule forbade
@outright to blockhttps://user:token@host. That also blocked
ssh://git@github.com/o/r.git -- the exact form the validation error recommends for private repos -- while the identical scp-style git@github.com:o/r.git was accepted, on the stated grounds that
user@is a login and not a secret. The two forms are the same remote written two ways, so they are now treated alike: a bareuser@is allowed on ssh:// and git://, while user:password@ and all http(s) userinfo stay rejected.Percent-encoding walked straight through that check.
ssh://user%3Apass%40host/repocontains no literal:or@in its userinfo, so it satisfied the regex; git then percent-decodes it and connects as user:pass@host. The credentials the rule exists to block were persisted to the manifest and the generated Containerfile anyway.%is now excluded from every URL branch -- no remote dcw supports needs it.assertCloneSafe() rejected shell metacharacters but not a leading dash, so
--upload-pack=<cmd>-- a build-time RCE primitive -- passed the check that exists precisely to catch it. Not reachable through the CLI, since zod rejects it at the boundary first, but this function is the defence-in-depth layer before the value is spliced into an unquotedRUN git cloneline, and it now covers the case independently.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
With no --profile, the hardening set was empty: no capability drops, no no-new-privileges, no secure tmpfs.
--strictpassed vacuously, because nothing had been requested that could fail. The wizard reinforced it -- "None (no extra hardening)" was listed first, and the selector highlights the first choice -- so accepting every default in a tool whose purpose is isolating untrusted contract code produced an environment with no isolation at all.Absence of a choice now means the default posture rather than no hardening:
development(secure tmpfs, no-new-privs, apparmor, secure DNS). Naming --harden keys is itself a deliberate choice, so the default applies only when neither --profile nor --harden was given -- it is never merged on top of explicit keys.--profile noneis the explicit opt-out, and unknown-profile errors now point at it.In the wizard, profiles lead and "None" moves to the bottom, so opting out is a deliberate act rather than the path of least resistance.
BREAKING CHANGE:
dcw createwith neither --profile nor --harden now produces adevelopment-hardened environment instead of an unhardened one. Scripts that relied on the empty set must pass --profile none.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
pnpm testwork from the repo rootThe root manifest had no scripts block, so
pnpm testexited 1 with no output at all -- the failure mode where nothing appears to be wrong. Adds build/test/test:watch/test:e2e/clean, each delegating to the core package.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
Catches the docs up to two behavior changes they did not describe.
dcw create with neither --profile nor --harden now applies the development profile rather than creating an unhardened environment; --profile none is the explicit opt-out. Both READMEs and SKILL.md previously implied the opposite, and SKILL.md hedged the development profile as "default-ish" while listing no none entry at all. For a security tool, docs that understate the default are the wrong direction to be wrong in.
AppArmor is not enforced on macOS: Docker Desktop and OrbStack run containers in a Linux VM whose daemon reports no AppArmor support, now probed via docker info SecurityOptions instead of assumed. The engine table's Docker row claimed "Full Linux MAC" unconditionally on all platforms, which was wrong on macOS. SKILL.md now also states that --strict fails closed there, so agents do not report AppArmor protection that is not present.
Verified against src/domain/profiles.ts (DEFAULT_PROFILE, NO_PROFILE), src/commands/create.ts, src/engine/drivers/docker.ts and src/wizard/App.tsx. Suite green at 247 passed / 1 skipped.
c5f47d0 said AppArmor is "dropped with a warning" on macOS. It is not dropped: dockerCaps() marks it caveated with enforced:false, and translate() routes that into
unenforced-- onlyunsupportedreachesdropped. An agent following that sentence would checkdropped, find [], and report the environment as fine. Verified live: a hardened env on this host returns dropped:[] and unenforced:["apparmor"].The same defect ran wider than that one sentence. Both READMEs and SKILL.md described degradation as a single bucket -- "if the engine can't honor an option it is dropped with a warning" -- which is incomplete for every caveated control, not just AppArmor. All three now name both buckets: dropped (never applied) and unenforced (applied, but the engine may not enforce it), either of which fails --strict.
Documents the JSON surface agents actually script against, none of which was written down:
codefor each, replacing the bare claim that "exit codes are deterministic"unenforcedalongside appliedFlags/warnings/dropped onup --json, and thehardeningobject oncreate --up --jsonandattach --jsondcw lsstatus vocabulary, including the newunknown(engine unreachable) which must not be read asabsent, and the fact thatlsstays non-fatal where other commands exit 5dcw agentdistinguishing an enforced air-gap from a dropped or unknown one before forwarding provider credentialsCorrects the Apple Containers engine row, which still claimed it "drops Linux cap ... hardening". It applies --cap-drop; what it drops is read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp.
Also corrects the wrapper README, the npm page the unscoped v1 install base lands on: installing both packages globally does not leave them "competing for the same symlinks", npm aborts with EEXIST and installs nothing. Adds the uninstall-first path.
Every documented exit code was re-verified against the built CLI.
Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy
README listed
packages/desktopas a workspace member, but it was never tracked -- 0 files in git, no commit on this branch touched it -- so a fresh clone never had it. The "How to contribute" section described a directory contributors could not see.It carried no package.json either, so pnpm never treated it as a workspace project;
pnpm install --frozen-lockfilestill reports the same three projects and the lockfile is unchanged.Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy