Skip to content

Scope the path-jail claim to the provider that enforces it, and test the /work contract everywhere - #127

Merged
Hotragn merged 6 commits into
mainfrom
release/0.1.4
Sep 21, 2026
Merged

Hotragn merged 6 commits into
mainfrom
release/0.1.4

Conversation

@Hotragn

@Hotragn Hotragn commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Closes the gap to a publishable 0.1.4. Every HUSK finding from the QA roadmap was re-run rather than read, on a host with Docker, WSL2 and a real tool-calling model — an environment the first pass did not have.

This branch includes #126 (rebased onto main); merge that first or merge this and close it.

P0-1 — the isolation claim

Issue #120 is correct and reproduced. On docker:

readFile /etc/passwd               ALLOWED  "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:..."
listDir /                          ALLOWED  "20 entries"
writeFile /dev/shm/esc.txt         ALLOWED  "written"
writeFile /tmp/../etc/esc          REFUSED  E_FS_DENIED ... Read-only file system

The last line is the read-only rootfs, not a jail. The design is fine; the sentence was not.

SECURITY-MODEL.md had already been corrected by 0f664b6. The copies that still said it were README.md:196, the trust-boundary diagram in all four of its forms (source JSON, generated HTML, both exported PNGs), and the diagram's path jail node itself — which sits in the main flow every provider passes through. The PNGs were re-exported through the diagram's own exporter and re-fitted to the repo's 2492×1116 geometry.

The other six controls in that sentence were each probed on a live local and a live docker computer before being left in:

control local docker
deny list (rm -rf /, mkfs, fork bomb) refused refused
env scrub (canary + AWS_SECRET_ACCESS_KEY) both empty both empty
output caps (5 MB) truncated: true truncated: true
process-tree kill (2s timeout) exit 124 in 2.3s exit 124 in 2.0s
redact() sk-ant…[redacted] sk-ant…[redacted]
audited() one dispatch point same

Two further claims in SECURITY-MODEL.md were scoped: redact() matches AWS access key IDs and cannot match a secret access key (40 chars of base64, nothing to anchor on — every other advertised format was tested and does redact); and "never writes credentials to ~/.husk" is true of the environment but not of husk import, which caches your transcript verbatim. The local path-jail row now also records #113 — on a Windows host it refuses any symlink inside the workspace, because the file tools read through \wsl.localhost\ and cannot follow a Linux link.

HUSK-001..008 — re-run

Finding Result
001 /work mismatch PASS. New conformance suite, 45 assertions, local + docker + the CLI binary. Mutation-tested: break the mount and no-op rewriteGuestPaths and 7 go red.
002 jail language PASS for local, and a second live instance found and fixed (above).
003 context ceiling PASS. Real husk run on ollama/qwen2.5:1.5b: 2 steps, 45.1s, 982 tokens, correct tool call, grounded answer.
004 browser startup PASS, bounded. Real launch failed in 1.6s, no orphan matching --remote-debugging-port. Nit: the message says "40 seconds" where the ceiling is 90.
005 import discovery PASS. 18 candidates → 1; CHANGELOG.md gone. One residual (launch/twitter.md, whose **Tweet 1 (hook)** headers parse as roles).
006 secrets in slugs PASS. name: use-key-redacted-for-the-bot, no canary anywhere in the emitted spec.
007 distiller contradiction PASS.
008 Windows + Node 22 PASS, and it was never Node 22. The failing log is EBUSY … rmdir in smoke.test.ts — a Windows teardown race, same family as #80/#63. Green for 12 consecutive runs.

The conformance suite

packages/sdk/src/conformance.test.ts is the acceptance test the roadmap asked for and the repo did not have. Four writers × four readers over one computer, so a surface that keeps its own path space fails against the other three instead of passing its own round trip. Nothing mocked: real providers, a real Fastify socket, the real MCP dispatcher, the real CLI as a child process.

Also

  • browser_status promised a 111 MB download on providers that cannot perform it. It now names the read-only rootfs and the three ways out (The Chromium linkability check runs after the 111 MB download, and browser_status does not mention that container providers cannot run a browser #122).
  • The README quickstart did not work as written. up dev picks docker when a daemon runs, base is debian:bookworm-slim, and line 3 exited 127 with python3: not found. Pinned to --flavor python; prose now says what each provider prints. Re-run clean against a fresh npx cache.
  • CHANGELOG.md records the /work fix and everything else user-facing since 0.1.3.
  • server.json for the official MCP registry, validating against the 2025-12-11 schema, with 512×512 light/dark icons rasterised from brand/logo/mark.svg.
  • Test count measured, not adjusted: 1,730 across 99 files, and it no longer claims they all pass with no Docker — 22 are the container half of the matrix.

Onboarding, and testing the artifact

Added after review, because neither is visible from inside the repo.

husk onboard — the guided setup there wasn't one of. doctor is a diagnostic; it reports eleven unconfigured model providers and leaves you to choose. onboard walks the same ground and does the next thing each time: names the provider and how isolated it really is, creates a computer and destroys it in front of you, names the cheapest route to a model, prints the MCP line for your editor, then one command to run. Five steps, each skippable, safe to re-run, --json clean.

It never asks for an API key — husk reads credentials from the environment and does not store them, and there is a test asserting the prompt's absence, because that is a contract this release already had to scope twice.

The one-time orientation was wired to husk up alone, so anyone whose first command was husk mcp — a model about to be handed a shell — was told nothing about the boundary. run, serve and mcp orient too now, on stderr, verified not to disturb the JSON-RPC channel.

npm run install-smoke — everything else in the gate tests the workspace. npm test runs against source with symlinks; preflight packs --dry-run and runs the bins inside the monorepo, where hoisting satisfies imports the published package never declared. Both are green on a tree that cannot be installed. That is how 0.1.1 shipped a bin that exited 0 and did nothing, and how 0.1.3 shipped three pins a version behind.

It packs all eleven, installs into a scratch dir outside the checkout, and drives the installed copies — CLI lifecycle on docker, MCP handshake (21 tools), SDK import — plus all 28 cross-package pins read out of the tarballs.

That last check is static on purpose, and the first version of it was wrong. overrides pointing at the local tarballs is what stops npm satisfying a pin from the registry and testing code that isn't here — and it masks a stale pin completely. Injecting @husk-ai/sessions: 0.1.2 passed every check. Reading the pins from the artifact catches it, and it cannot be a resolution check pre-publish: the correct version isn't on the registry yet, so a transitive install would 404 on a correct pin and sail through a stale published one. --registry is the post-publish mode.

Gate

npm run build:packages && npm run typecheck && npm test — green, 1,739 passed / 19 platform-skipped across 100 files. npm run install-smoke — clean. npm run drift 51 ok. npm run links 45/45. node scripts/secret-scan.mjs clean.

Every command in README.md and launch/ executed as written against a clean npm_config_cache and a clean HUSK_HOME on published @husk-ai/cli@0.1.3.

Not in this diff

launch/ and Husk QA and refinement roadmap.md are gitignored on purpose (.gitignore:108-112). The 27 @husk/@husk-ai/ fixes and the roadmap re-run are on disk but deliberately not committed.

No version bump here — that is its own commit, and it waits for a go-ahead on publishing.

…hind

Two failures that both ended in an error message about something other than
the thing that went wrong.

The local provider test's afterEach removed HUSK_HOME with no retries and
lost a race with writes still landing under computers/, failing the macOS
job with `ENOTEMPTY: directory not empty, rmdir` -- after every assertion in
the file had already passed. #80 fixed this same family in the CLI smoke
test; this mirrors it, including the part that matters most: teardown can
warn, but it can no longer fail a run it has nothing to say about.

`husk exec dev -- 'uname -sr && python3 -V'` is the documented form and a
posix one. cmd.exe does not strip single quotes, so husk received `'uname`
as an executable name and the failure surfaced as a raw OCI error naming
neither husk nor the quote that caused it. An argument that opens a single
quote and never closes it is not something a real argv produces, so exec now
catches it before resolving a provider and prints the double-quoted form of
what the caller meant. A token quoted on both sides is left alone; that one
is ambiguous and could be deliberate.

The docs taught only the form that breaks there, so the Windows-facing
double-quoted form is now in both READMEs and in `husk help exec`.

The README's test count was already stale and this adds a file; it now
reports what a full local run measures.
…the /work contract everywhere

Issue #120 says the path jail does not hold on docker. It does not. `read_file
/etc/passwd` returns the container's copy, `list_dir /` returns twenty entries, and
`/dev/shm` takes a write; what stops the other writes is the read-only root
filesystem, not a jail. The design is right -- the container is the boundary, and a
jail inside a kernel boundary is largely redundant -- so the sentence was the thing
that was wrong.

The issue attributes it to SECURITY-MODEL.md, which 0f664b6 had already corrected.
The copies still saying it were the README, the trust-boundary diagram in all four
of its forms -- source JSON, generated HTML and both exported PNGs -- and the
diagram's own `path jail / realpath, rejects escapes` node, which sits in the main
flow where every provider passes through it. All of them now say `local file tools
only`.

The other six controls in that sentence were not taken on trust either. Each was
probed against a live local computer and a live docker computer: the deny list
refuses `rm -rf /`, `mkfs` and a fork bomb on both; the scrub empties a canary and
`AWS_SECRET_ACCESS_KEY` on both; 5 MB of output comes back `truncated` on both; a 2s
timeout kills a process tree in about 2s on both; `redact()` hides an `sk-ant` key on
both; `audited()` wraps one dispatch point. Those six stay.

Two smaller claims in SECURITY-MODEL.md were scoped rather than deleted. `redact()`
matches AWS access key IDs and cannot match a secret access key, which is forty
characters of base64 with nothing to anchor on. And "never writes credentials to
~/.husk" is true of the environment and false of `husk import`, which caches your
transcript verbatim -- everything derived from it is redacted first, but the cached
copy of your own file is not rewritten.

The local path-jail row described a realpath check that rejects links leaving the
workspace. On a Windows host it rejects any symlink inside the workspace, because
the file tools read through \wsl.localhost\ and cannot follow a Linux link (#113).
A relative link and a guest-absolute link to a file that never leaves /work both
fail; the same links work on docker. Said, rather than implied.

Beyond the claims:

- `packages/sdk/src/conformance.test.ts` is the acceptance test the QA roadmap asked
  for and the repository did not have. Four writers x four readers -- the runtime
  Computer, the MCP tools, the HTTP control plane and the shell -- over one computer,
  so a surface that keeps its own path space fails against the other three rather
  than passing its own round trip. Plus exec-by-/work-path through three surfaces,
  listing, the `/workshop` lookalike, the relative default workdir, and the CLI
  binary as a child process. It runs on local and, when a daemon answers, docker.
  Mutation-tested: force the WSL /work mount off and no-op `rewriteGuestPaths` and
  seven of them go red.
- `browser_status` promised a 111 MB Chromium download on providers that cannot
  perform it -- the container root filesystem is read-only and the base image has
  neither curl nor python3, so `browser_goto` fails before a byte moves. It now says
  so, and names the three ways out (#122).
- The README quickstart did not work as written. `up dev` picks docker when a daemon
  is running, `base` is debian:bookworm-slim, and the third line exited 127 with
  `python3: not found`. The prose only promised that output "with Docker stopped".
  Pinned to `--flavor python` and rewritten to say what each provider prints.
- CHANGELOG records the /work fix, which was the largest user-facing correction
  since 0.1.3 and was in no changelog at all.
- `server.json` declares `io.github.hotragn/husk` for the official MCP registry, with
  512x512 light and dark icons rasterised from `brand/logo/mark.svg`. It validates
  against the 2025-12-11 schema. Submission waits for 0.1.4 to be on npm, since the
  package version it pins has to resolve.
- The test-count line was measured rather than adjusted: 1,730 across 99 files, and
  it no longer claims they all pass with no Docker, because twenty-two of them are
  the container half of the conformance matrix and skip without a daemon.
@vercel

vercel Bot commented Sep 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
husk-dev Ready Ready Preview Sep 19, 2026 11:41am UTC
husk-dev-docs Ready Ready Preview Sep 19, 2026 11:41am UTC

@github-actions github-actions Bot added documentation Improvements or additions to documentation pkg: runtime Touches packages/runtime pkg: mcp Touches packages/mcp pkg: sdk Touches packages/sdk pkg: cli Touches packages/cli apps Touches apps/ (console, docs, web) build & ci Workflows, scripts, or root build config labels Sep 19, 2026
The conformance suite asserted the `/work` contract unconditionally and went red
on both Windows runners: no WSL there, so the local provider falls back to
cmd.exe, where there is no `/work`, no `sh` and no heredoc. Twelve failures, all
of them the shell-side pairs, and every one of them the documented behaviour
rather than a defect -- the fallback reports its own raw workspace as `workdir`
precisely so it is not claiming a path it cannot honour.

`local.test.ts` guards the same contract with `skipIf(platform === 'win32')`,
which would work and would also be wrong here: this repository is developed on a
Windows host *with* WSL2, where the contract does hold and the suite has to run.
Platform is a proxy for the question. The question is whether this computer got
`/work`, and the provider already answers it in `info.workdir`.

So the gate creates one throwaway local computer and reads that field. Creation
is lazy -- nothing runs inside until an exec -- so the probe costs a directory.
On a cmd.exe host the local matrix and the CLI case skip together and the docker
half still runs; simulated here, 22 passed and 23 skipped with no failures.
The failure said "never opened its debugging port within 40 seconds". Forty is
what 80 attempts x 0.5s comes to while every connect is refused instantly, which
is the common case and not the ceiling: a listener that accepts and then wedges
makes each curl pay its full --max-time, and the wait runs to the 90-second exec
kill that is the real backstop. Someone who waited ninety seconds and was told
forty has been given a number that does not match what they just watched.
The conformance suite gated its docker half on `docker version` exiting zero.
GitHub's windows-latest runner answers that yes -- and then cannot pull
debian:bookworm-slim, because the daemon it is running serves Windows
containers. The suite booted a provider that looked available and died on
`E_COMPUTER_FAILED could not obtain an image`.

Two questions were being asked as one: is a daemon up, and can it run the images
this suite needs. `{{.Server.Os}}` answers the second, and returning anything
other than `linux` now skips the docker half the same way an absent daemon does.
…talls them

Two gaps that only show up from outside the repository.

`husk doctor` answers "what can this machine do" and answers it well. It is not
an onboarding: it hands you eleven model providers that are not configured and
leaves you to pick. Between `npm i -g @husk-ai/cli` and a working first command
sat read doctor, choose a provider, go and find a key, export it, come back,
work out which of twenty commands to run. All of it documented, none of it in
front of you. A bare `husk` printed the twenty-command wall.

`husk onboard` walks the same ground and then does the next thing each time. It
names the provider and how isolated it actually is, in the words the security
docs use rather than softer ones -- this is the screen where someone forms that
belief. It creates a computer, runs a command in it and destroys it, so you have
seen the thing work before you configure anything. It names the cheapest route
to a model when there is none, free and local first. It prints the MCP line for
the editor you already use. Then one command to run next, chosen from what this
machine can actually do.

It never asks for an API key. Husk reads credentials from the environment and
does not store them, and an onboarding that offered to hold one would make that
sentence false on the very first run -- so it prints the export line and
re-probes. There is a test for the absence of that prompt, because it is a
contract and not a preference. With no terminal it creates nothing and prints
the path as text: a CLI that blocks for input inside a Dockerfile is a CLI
people stop installing.

The one-time orientation was wired to `husk up` alone, so anyone whose first
command was `husk mcp` -- a model about to be handed a shell -- was told nothing
about the boundary at all. `run`, `serve` and `mcp` orient too now, on stderr,
so the protocol channel stays clean.

The second gap is that every existing check tests the workspace. `npm test` runs
against source with symlinks between packages; `preflight` packs with
`--dry-run` and runs the bins from inside the monorepo, where hoisting satisfies
imports the published package never declared. Both are green on a tree that
cannot be installed, which is how 0.1.1 shipped a bin that exited 0 and did
nothing, and how 0.1.3 shipped three pins a version behind in the dependency
group nobody reads.

`npm run install-smoke` packs all eleven, installs them into a scratch directory
outside the checkout, and drives the installed copies: CLI lifecycle, MCP
handshake over stdio, SDK import. It also reads all twenty-eight cross-package
pins out of the tarballs rather than the checkout.

That last check is static, and the first version of it was not. Pointing
`overrides` at the local files is what stops npm quietly satisfying a pin from
the registry and testing code that is not in this checkout -- and it also masks
a stale pin completely. Injecting `@husk-ai/sessions: 0.1.2` into the server
manifest passed every check. Reading the pins out of the artifact catches it,
and it has to be static because before a publish the correct version is not on
the registry: an install that resolved transitively would 404 on a correct pin
and sail through a stale one that happens to be published. `--registry` is the
post-publish mode that resolves for real.
@Hotragn
Hotragn merged commit 4d9b0dc into main Sep 21, 2026
13 checks passed
@Hotragn
Hotragn deleted the release/0.1.4 branch September 21, 2026 14:39

This branch was successfully deployed

2 active deployments
Preview – husk-dev-docs 66564522 Deployed Sep 19, 2026 by vercel[bot]
Preview – husk-dev 66564522 Deployed Sep 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

apps Touches apps/ (console, docs, web) build & ci Workflows, scripts, or root build config documentation Improvements or additions to documentation pkg: browser Touches packages/browser pkg: cli Touches packages/cli pkg: mcp Touches packages/mcp pkg: runtime Touches packages/runtime pkg: sdk Touches packages/sdk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant