Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,83 @@ not here.

## [Unreleased]

### Added

- **`husk onboard` — the guided setup that was missing.** Between `npm i -g @husk-ai/cli`
and a working first command sat: read `doctor`, pick one of eleven model providers, go
and find a key, export it, come back, work out which of twenty commands to run. Every
step of that was documented and none of it was in front of you. `onboard` walks the
same ground and then does the next thing each time — it names your provider and how
isolated it actually is, creates a computer and destroys it in front of you so you have
seen it work before configuring anything, names the cheapest route to a model if you
have none, and prints the MCP line for your editor. Five steps, each skippable, safe to
re-run.

It never asks for an API key. Husk reads credentials from the environment and does not
store them; an onboarding that offered to hold one would make that false on the very
first run, so it prints the `export` line and re-probes. With no terminal it prints the
whole path as text and creates nothing, because a CLI that blocks for input inside a
Dockerfile is a CLI people stop installing.
- **The one-time orientation now reaches every entry point.** It was wired to `husk up`
alone, so anyone whose first command was `husk mcp` — a model about to be handed a
shell — was never told what the isolation boundary was. `run`, `serve` and `mcp` say it
too now, on stderr, so the MCP protocol channel stays clean.

### Fixed

- **`/work` did not name the same file for the shell as it did for the file tools.**
Husk tells an agent that `/work` is its workspace. On the `local` provider the file
tools honoured that and the posix shell did not, so `write_file('/work/scrape.py')`
followed by `python3 /work/scrape.py` returned file-not-found — "write a script, then
run it", on the provider you get by default when there is no Docker. The shell now
maps guest-absolute paths onto the workspace it is already running in: quote-aware,
lexical, trailing slashes preserved so `/work/$f` still concatenates, and lookalikes
like `/workshop` left alone rather than helpfully repointed at a host file. Heredoc
bodies are copied through untouched, because `cat > /work/notes.md <<EOF` is how an
agent writes a file and the thing it writes about is usually the workspace. On WSL2
the shell gets a real `/work` instead, bind-mounted per exec inside `unshare -mr`.
A conformance suite now writes a file through the runtime, MCP, HTTP and shell
surfaces and reads it back through every other one, against both `local` and
`docker`, so the next surface to invent its own path space fails in CI.
- **The safety language called `/work` jailed when only half of it was.** That was true
of the file tools and false of the shell, in four documents and in the MCP note a
model reads before its first tool call — and a model that believes its shell is
contained takes risks it otherwise would not. The docs now say which half is
confined. The same correction reaches the trust-boundary diagram and the README for
0.1.4: the path jail is a `local` control, not something every provider enforces. On
`docker`, `podman` and `fly` the container is the boundary, so `read_file
/etc/passwd` returns the container's own copy.
- **A run that could not fit the model's context was truncated into nonsense instead of
refused.** Local servers still default to tiny contexts — llama.cpp long shipped
`n_ctx 512` — and Husk's own system prompt plus the computer tool schemas is about
2.5k tokens before the conversation starts. The run is now rejected before the first
call, naming the tokens needed, the tokens available and the setting to raise.
- **A Chromium launch that never opened its debugging port leaked a browser per
attempt.** The wait is bounded and the process is reaped, so a machine that cannot
start a browser says so instead of accumulating one. The error quotes the real
ceiling too: it said "within 40 seconds", which is the poll's arithmetic while every
connect is refused instantly, not the 90 seconds a wedged listener can actually
take.
- **`husk import` offered ordinary repository Markdown as chat transcripts.** Run from a
project root it listed eighteen candidates, `CHANGELOG.md` among them, which made
discovery useless in the first place most people try it. Markdown is content-sniffed
now and has to look like a conversation.
- **A secret in a transcript title survived into the name derived from it.** Distilling
a chat that mentioned an API key produced the slug `use-key-sk-antredacted` — the
body was redacted and the prefix was not, which is a smaller leak rather than no
leak. Redaction runs before anything becomes a name, a slug or a filename.
- **The distiller reported "configured without tools" while the spec it had just
written listed them.** Prose and structured output are checked against each other.
- **An MCP computer that had been stopped could look connected while every command
failed.** The next call resumes it where it can and reports the failure clearly where
it cannot, and a failed readiness probe no longer returns a healthy-looking header.
- **`husk exec` turned a cmd.exe quoting mistake into somebody else's error message.**
The documented `husk exec dev -- 'uname -sr && python3 -V'` is a posix-shell form;
`cmd.exe` does not strip single quotes, so Husk received `'uname` and the container
runtime failed with `exec: "'uname": executable file not found in $PATH`. Husk now
recognises the stray quote before it resolves a provider and prints the double-quoted
form of what you meant.

- **On Windows without a working WSL, `husk doctor` said nothing useful and sometimes
contradicted itself.** The warning written for that case was gated on a test that no
reachable state could pass, so it had never printed. The `local` provider reported
Expand Down
57 changes: 44 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,33 @@ sandboxed when it is not makes worse decisions than one that knows.
## The CLI

```bash
npx @husk-ai/cli doctor # what this machine can offer
npx @husk-ai/cli up dev # bring up a Linux computer
npx @husk-ai/cli onboard # guided setup, five steps, start here
npx @husk-ai/cli doctor # or go straight to what this machine can offer
npx @husk-ai/cli up dev --flavor python # bring up a Linux computer
npx @husk-ai/cli exec dev -- 'uname -sr && python3 -V'
npx @husk-ai/cli rm dev # tear it down
npx @husk-ai/cli rm dev # tear it down
```

On a laptop with Docker stopped and WSL2 installed, `exec` prints
`Linux 6.18.33.2-microsoft-standard-WSL2` and `Python 3.14.4`. `doctor` reports every
provider it probed, which one it would pick, and why the others were skipped.
`onboard` names your provider and how isolated it really is, creates a computer and
destroys it in front of you, points at the shortest route to a model if you have none,
and prints the MCP line for your editor. It never asks for an API key — husk reads
credentials from the environment and does not store them, so it prints the `export` line
and re-checks.

On Windows `cmd.exe`, use double quotes -- it does not strip single ones, so the
quotes would reach the container as part of the command:

```bat
npx @husk-ai/cli exec dev -- "uname -sr && python3 -V"
```

`--flavor python` is there because the default `base` flavour is
`debian:bookworm-slim`, which has no `python3` -- and a quickstart whose third line
prints `python3: not found` is not a quickstart. On a laptop with Docker running, that
sequence prints `Linux 6.18.33.2-microsoft-standard-WSL2` and `Python 3.12.14`; with
Docker stopped and WSL2 installed it picks `local` and prints the same kernel with the
distro's own `Python 3.14.4`. `doctor` reports every provider it probed, which one it
would pick, and why the others were skipped.

## Turn a chat into a bot

Expand Down Expand Up @@ -116,8 +134,9 @@ checks. Five worked examples live in [`examples/`](examples/).
reporter, no version ping.
- **No native modules.** `npm install` finishes on Windows with no C++ toolchain. Node
20.10 or newer.
- **1,570 tests across 81 files**, all passing with no Docker, no API key and no
network.
- **1,739 tests across 100 files**, passing with no API key and no network. Twenty-two of
them are the container half of the workspace-conformance matrix and skip when no Docker
daemon answers; the rest do not need one.

## How it works

Expand Down Expand Up @@ -183,13 +202,25 @@ unavailable is an error, never a quiet downgrade to weaker isolation.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/diagrams/png/05-trust-boundaries.dark.png">
<img alt="A tool call passes the path jail, command policy and env scrub into the computer; results pass redact() and the audit log" src="docs/diagrams/png/05-trust-boundaries.light.png">
<img alt="A tool call passes the local-only path jail, the command policy and the env scrub into the computer; results pass redact() and the audit log" src="docs/diagrams/png/05-trust-boundaries.light.png">
</picture>

These hold in every mode, `local` included: the path jail, the command deny list, the
environment scrub, output caps, process-tree kill, `redact()` on every result and
`audited()` on every MCP call. What `local` does not give you is a kernel boundary. It
shares your kernel, your network and your user account.
These hold in every mode, `local` included, and were re-checked against a live `local`
and a live `docker` computer for 0.1.4: the command deny list, the environment scrub,
output caps, process-tree kill, `redact()` on every result and `audited()` on every MCP
call.

The path jail is not one of them, and this paragraph used to say it was
([#120](https://github.com/Hotragn/husk/issues/120)). It is a `local` control, and there
it confines the file tools rather than the shell. On `docker`, `podman` and `fly` the
container *is* the boundary, so the file tools address the container's own filesystem:
`read_file /etc/passwd` returns the container's copy, and `/dev/shm` takes a write.
Neither reaches your machine, which is the point of running those providers — a jail
inside a kernel boundary is largely redundant. Claiming one anyway is exactly what this
section exists not to do.

What `local` does not give you is a kernel boundary. It shares your kernel, your network
and your user account.

`169.254.169.254` stays blocked even under `network.mode: full`, because `full` means
the internet and not the cloud metadata service that hands IAM credentials to whatever
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/guides/discord-bot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ branches for `http`, `webhook` and `cron` only, so a husk declaring

The Discord *adapter* is complete and tested — a raw gateway client with resume,
heartbeat tracking, rate limiting and idempotency. It is simply not wired into
`@husk-ai/server`. Step 5 is the glue that connects them, and it is about 40 lines.
`@husk-ai/server`. Step 6 is the glue that connects them, and it is about 40 lines.
</Warning>

---
Expand Down
Binary file added brand/logo/mark-512-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/logo/mark-512-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 16 additions & 3 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,24 @@ because that step had been sitting on `main`, unused, since `0.1.2`.
2. **`npm run preflight`.** Read-only: manifests, publish order, and what the registry
already has.

3. **Tag at an explicit SHA**, never at `HEAD`. `HEAD` moves; a release is a point.
3. **`npm run install-smoke`.** Packs all eleven, installs them into a scratch directory
outside the checkout, and drives the installed copies: the CLI lifecycle, an MCP
handshake over stdio, an SDK import. Everything else in the gate tests the *workspace*,
where a symlink satisfies an import the published package never declared — which is how
0.1.1 shipped a bin that did nothing and 0.1.3 shipped three pins a version behind.

4. **`release.yml` publishes in dependency order**, then creates the GitHub Release.
It also reads every cross-package pin out of the **tarball** rather than the checkout.
That is a static check on purpose: before a publish the correct version is not on the
registry, so an install that resolved transitively would 404 on a correct pin and sail
through a stale one that happens to be published. After the publish,
`npm run install-smoke -- --registry` drops the local overrides and resolves the graph
for real.

5. **Verify from outside the repo**, against the registry rather than the workflow:
4. **Tag at an explicit SHA**, never at `HEAD`. `HEAD` moves; a release is a point.

5. **`release.yml` publishes in dependency order**, then creates the GitHub Release.

6. **Verify from outside the repo**, against the registry rather than the workflow:

```bash
npx -y @husk-ai/cli@X.Y.Z doctor
Expand Down
16 changes: 12 additions & 4 deletions docs/SECURITY-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ directory, your SSH keys, your Docker socket, or your other containers.

| control | what it stops |
| --- | --- |
| path jail (file tools) | every file-tool call resolves through `realpath` and is rejected if the target leaves the workspace — including via a symlink created inside it. Shell commands are **not** path-confined: they start in the workspace, but absolute host paths and `..` reach whatever your user reaches |
| path jail (file tools) | every file-tool call resolves through `realpath` and is rejected if the target leaves the workspace — including via a symlink created inside it. Shell commands are **not** path-confined: they start in the workspace, but absolute host paths and `..` reach whatever your user reaches. On a Windows host the jail currently errs the other way and refuses *any* symlink inside the workspace, because the host reads those files through `\\wsl.localhost\` and cannot follow a Linux link ([#113](https://github.com/Hotragn/husk/issues/113)) |
| environment scrub | `ANTHROPIC_API_KEY`, `AWS_*`, `*_TOKEN`, `*_SECRET` and everything else not on a small allow-list never reach the process |
| command policy | a short list of unrecoverable commands (`rm -rf /`, `mkfs`, `dd of=/dev/sda`, `curl … \| sh`, `sudo`, fork bombs) is refused |
| output caps | a runaway process cannot exhaust memory through captured output |
Expand Down Expand Up @@ -114,8 +114,13 @@ Two independent controls:
rather than forwarded.
2. **Nothing credential-shaped leaves a tool.** Every tool result passes through
`redact()` before it re-enters the conversation, matching Anthropic, OpenAI, Google,
GitHub, Slack and AWS key formats plus PEM private keys. An agent that `cat`s a `.env`
sees the file; the model sees `sk-ant-…[redacted]`.
GitHub, Slack and AWS access key IDs plus PEM private keys. An agent that `cat`s a
`.env` sees the file; the model sees `sk-ant-…[redacted]`.

An AWS *secret* access key is the documented exception: it is forty characters of
base64 with no prefix and nothing to match on, so no pattern finds it without also
redacting ordinary hashes. Pair the scrub with `none` egress when that is the
credential you are worried about.

To pass a credential deliberately, name it:

Expand Down Expand Up @@ -144,7 +149,10 @@ In `readonly`, dangerous tools are never offered.
phone-home. The only calls Husk makes are to the model provider you configured and to a
registry when pulling an image.
- **No credential storage.** Husk reads keys from the environment and never writes them to
`~/.husk`.
`~/.husk`. One thing does land there verbatim: `husk import` caches the transcript you
gave it, so a chat log containing a key keeps that key in `~/.husk/transcripts`. What
Husk *derives* from it is redacted first — the distilled `husk.yaml`, its name, its
slug and its filename — but the cached copy of your own file is not rewritten.
- **No remote execution by default.** `husk serve` binds loopback, and it refuses to start
on a non-loopback address without `HUSK_TOKEN` set.

Expand Down
6 changes: 4 additions & 2 deletions docs/diagrams/05-trust-boundaries.architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"id": "pathjail",
"type": "security",
"label": "path jail",
"sublabel": "realpath, rejects escapes",
"sublabel": "local file tools only",
"row": 0,
"col": 2,
"size": [
Expand Down Expand Up @@ -286,7 +286,7 @@
"dot": "emerald",
"title": "Holds in every mode, including local",
"items": [
"Path jail, command deny list, env scrub, output caps, process-tree kill",
"Command deny list, env scrub, output caps, process-tree kill",
"redact() on every tool result; audited() on every MCP call"
]
},
Expand All @@ -303,6 +303,8 @@
"dot": "amber",
"title": "The honest limits",
"items": [
"The path jail is a local-only control, and confines the file tools, not the shell",
"On docker/podman/fly the container is the boundary: read_file /etc/passwd returns the container's copy",
"mode: egress is enforced at the tool layer, not by a firewall — a raw socket is not stopped",
"An empty allow-list permits nothing; approval with no approver wired denies, not allows"
]
Expand Down
8 changes: 5 additions & 3 deletions docs/diagrams/05-trust-boundaries.html
Original file line number Diff line number Diff line change
Expand Up @@ -5193,16 +5193,16 @@ <h1>Trust boundaries — what crosses which line</h1>
<text data-detail="context" x="378" y="118" class="t-muted" font-size="9" text-anchor="middle">shell · write_file · browse</text>
</g>

<g id="node-pathjail" data-node-id="pathjail" data-node-label="path jail" tabindex="0" role="button" aria-label="Focus path jail, realpath, rejects escapes, Architecture component" aria-pressed="false" data-node-kind="security" data-node-sublabel="realpath, rejects escapes" data-node-context="Architecture component">
<title>path jail · realpath, rejects escapes · Architecture component</title>
<g id="node-pathjail" data-node-id="pathjail" data-node-label="path jail" tabindex="0" role="button" aria-label="Focus path jail, local file tools only, Architecture component" aria-pressed="false" data-node-kind="security" data-node-sublabel="local file tools only" data-node-context="Architecture component">
<title>path jail · local file tools only · Architecture component</title>
<rect x="516" y="70" width="180" height="68" rx="6" class="c-mask"/>
<rect x="516" y="70" width="180" height="68" rx="6" class="c-security" stroke-width="1.5"/>
<g aria-hidden="true" data-semantic-sigil="security" class="semantic-sigil s-security" transform="translate(522 76) scale(0.6875)">
<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
<path d="m5.8 8 1.5 1.5 3-3"/>
</g>
<text data-node-label="" data-detail-anchor="" x="606" y="102" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">path jail</text>
<text data-detail="context" x="606" y="118" class="t-muted" font-size="9" text-anchor="middle">realpath, rejects escapes</text>
<text data-detail="context" x="606" y="118" class="t-muted" font-size="9" text-anchor="middle">local file tools only</text>
</g>

<g id="node-denylist" data-node-id="denylist" data-node-label="command policy" tabindex="0" role="button" aria-label="Focus command policy, anchored deny list, Architecture component" aria-pressed="false" data-node-kind="security" data-node-sublabel="anchored deny list" data-node-context="Architecture component">
Expand Down Expand Up @@ -5571,6 +5571,8 @@ <h3>What local does NOT give you</h3>
<h3>The honest limits</h3>
</div>
<ul>
<li>&bull; The path jail is a local-only control, and confines the file tools, not the shell</li>
<li>&bull; On docker/podman/fly the container is the boundary: read_file /etc/passwd returns the container's copy</li>
<li>&bull; mode: egress is enforced at the tool layer, not by a firewall — a raw socket is not stopped</li>
<li>&bull; An empty allow-list permits nothing; approval with no approver wired denies, not allows</li>
</ul>
Expand Down
Binary file modified docs/diagrams/png/05-trust-boundaries.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/diagrams/png/05-trust-boundaries.light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading