Skip to content

feat: dcw v2 — rehauled CLI, hardened by default, plus security audit… - #31

Open
d4rm5 wants to merge 7 commits into
mainfrom
develop
Open

feat: dcw v2 — rehauled CLI, hardened by default, plus security audit…#31
d4rm5 wants to merge 7 commits into
mainfrom
develop

Conversation

@d4rm5

@d4rm5 d4rm5 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

… (#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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy


d4rm5 and others added 7 commits August 20, 2026 14:50
…#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant