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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ unavailable is an error, never a quiet downgrade to weaker isolation.
| `podman` | 18 | Kernel, rootless | Free | Linux without Docker |
| `ssh` | 16 | Whatever the remote gives you | Free if you own the box | An Oracle Always Free instance, a Pi, a VPS |
| `fly` | 14 | microVM | Metered | Bursty parallel work |
| `local` | 10 | **Guardrails only** | Free | WSL2 gives real Linux; POSIX jails your own shell |
| `local` | 10 | **Guardrails only** | Free | WSL2 gives real Linux; POSIX runs your own shell with guardrails |

> **The `local` provider is not a sandbox.** It stops accidents, not adversaries.
> `husk doctor` reports `isolated: false` for it, and the first MCP tool result says so
Expand Down
13 changes: 7 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ Expect that to change at 1.0, when a released minor is worth holding open.
The full analysis is in [`docs/SECURITY-MODEL.md`](docs/SECURITY-MODEL.md). The short
version, because a security page that buries the caveat is not a security page:

**The `local` provider is not a sandbox.** It is a guarded working directory. It pins
the working directory, resolves every path through `realpath` and refuses escapes,
scrubs credential-shaped environment variables, caps output, kills the process tree on
timeout, and refuses a deny list of unrecoverable commands. That stops accidents. It
does not stop an adversary. `husk doctor` reports `isolationKind: guardrails` for it,
**The `local` provider is not a sandbox.** It is a guarded working directory. Its file
tools resolve every path through `realpath` and refuse escapes, shell commands start in
the workspace, credential-shaped environment variables are scrubbed, output is capped,
the process tree is killed on timeout, and a deny list of unrecoverable commands is
refused. That stops accidents. It does not stop an adversary: a shell command can still
read and write anything your user can, including outside the workspace. `husk doctor` reports `isolationKind: guardrails` for it,
and the CLI says so before you use it.

**A prompt-injected model is closer to an adversary than to an accident.** If an agent
Expand All @@ -52,7 +53,7 @@ than a plain "isolated", because the difference matters.

| control | what it does |
| --- | --- |
| Path jail | `/work` and `/tmp` only; `..` traversal and escaping symlinks both refused |
| Path jail (file tools) | `/work` and `/tmp` only; `..` traversal and escaping symlinks both refused |
| Env scrubbing | `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `AWS_*` and friends never reach a command |
| Command policy | a deny list of unrecoverable commands, anchored to command position |
| Network floor | loopback, link-local and RFC1918 refused **even in `network.mode: full`** |
Expand Down
5 changes: 3 additions & 2 deletions apps/docs/content/mcp/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ On the `local` provider:

```text
[husk] local computer cmp_8v9z on real Linux via wsl:Ubuntu. This is a guarded working
directory, NOT a sandbox: /work is jailed and destructive commands are refused, but it
shares the host kernel and network. Start Docker for real isolation.
directory, NOT a sandbox: file tools are confined to /work and destructive commands are
refused, but shell commands can still reach anything your user can on the host --
filesystem, kernel and network. Keep secrets and untrusted input out of it.
```

On anything else:
Expand Down
4 changes: 2 additions & 2 deletions docs/SECURITY-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ directory, your SSH keys, your Docker socket, or your other containers.

| control | what it stops |
| --- | --- |
| path jail | every filesystem call resolves through `realpath` and is rejected if the target leaves the workspace — including via a symlink created inside it |
| 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 |
| 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 |
| process-tree kill | a timeout kills the whole process group, not just the shell |
| mount namespace (WSL2) | `/work` is bind-mounted per exec inside `unshare -mr`, so two computers cannot see each other's files |
| mount namespace (WSL2) | `/work` is bind-mounted per exec inside `unshare -mr`, so the `/work` an agent sees is always this computer's workspace |

What it does **not** stop: the agent shares your kernel, your network, and your user
account. It can reach anything your user can reach that is not specifically blocked. The
Expand Down
25 changes: 25 additions & 0 deletions packages/agent/src/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,31 @@ describe('Agent: ceilings', () => {
expect(result.usage.costUsd).toBe(0);
});

it('refuses the first call when the prompt cannot fit the model context', async () => {
// A local server with a 512-token context (llama.cpp's long-time default)
// cannot hold Husk's own system prompt plus tool schemas (~2.5k). The run
// must say so, with the exact numbers, before a single model call.
const router = new FakeRouter([{ text: 'never sent' }], {
id: 'lmstudio/tiny',
provider: 'lmstudio',
name: 'tiny',
displayName: 'Tiny',
contextWindow: 512,
maxOutputTokens: 256,
supportsTools: true,
supportsVision: false,
supportsStreaming: true,
});
const agent = new Agent({ spec: specFor(), router, tools: [echo('echo')], logger: silent });
const result = await agent.run({ input: 'go' });

expect(result.stopReason).toBe('error');
expect(result.error?.code).toBe('E_CONTEXT_TOO_SMALL');
expect(result.error?.message).toMatch(/512-token context/);
expect(result.error?.message).toMatch(/needs about/);
expect(router.requests).toHaveLength(0);
});

it('stops on the token ceiling', async () => {
const turns = loopForever.map((t) => ({ ...t, usage: { inputTokens: 4000, outputTokens: 1000, costUsd: 0 } }));
const { agent } = agentWith(turns, [echo('echo')]);
Expand Down
33 changes: 31 additions & 2 deletions packages/agent/src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ class Run {
// Better to say so before the first token.
if (this.toolsByName.size > 0 && this.router.getModelInfo) {
const info = await this.router.getModelInfo(this.model).catch(() => undefined);
if (info?.contextWindow) this.contextWindowTokens = info.contextWindow;
if (info && info.supportsTools === false) {
const err = {
message: `${info.id} cannot call tools, and this husk declares ${this.toolsByName.size}`,
Expand Down Expand Up @@ -269,6 +270,9 @@ class Run {
this.queue.end();
}

/** The selected model's context window, when the router could say. */
private contextWindowTokens: number | undefined;

private result(stopReason: RunResult['stopReason'], error?: RunResult['error']): RunResult {
return {
runId: this.runId,
Expand All @@ -290,7 +294,30 @@ class Run {
await this.maybeTrim();
await this.resolvePricing();

const estimate = this.budget.estimate(this.estimatePromptTokens(), ASSUMED_OUTPUT_TOKENS);
const promptTokens = this.estimatePromptTokens();

// A request that cannot fit is rejected here, with the exact numbers,
// rather than by the server after a long local load -- or worse, silently
// truncated into nonsense. Local servers default to tiny contexts
// (llama.cpp long shipped n_ctx 512), and Husk's own system prompt plus
// the computer tool schemas is ~2.5k tokens on its own.
if (this.contextWindowTokens !== undefined) {
const needed = promptTokens + ASSUMED_OUTPUT_TOKENS;
if (needed > this.contextWindowTokens) {
const reason =
`this run needs about ${needed} tokens (~${promptTokens} for the prompt and tool schemas, ` +
`${ASSUMED_OUTPUT_TOKENS} reserved for the reply), over ${this.model}'s ` +
`${this.contextWindowTokens}-token context. Raise the model's context length ` +
`(LM Studio: Context Length; llama.cpp: --ctx-size) or shorten the prompt.`;
this.emit({ type: 'warning', message: `stopping: ${reason}` });
return {
stopReason: 'error',
error: { message: reason, code: 'E_CONTEXT_TOO_SMALL' },
};
}
}

const estimate = this.budget.estimate(promptTokens, ASSUMED_OUTPUT_TOKENS);
const decision = this.budget.check(estimate);
if (!decision.ok) {
this.emit({ type: 'warning', message: `stopping: ${decision.reason}` });
Expand Down Expand Up @@ -351,7 +378,9 @@ class Run {
this.pricingResolved = true;
if (!this.router.getModelInfo) return;
try {
this.budget.setPricing(pricingOf(await this.router.getModelInfo(this.model)));
const info = await this.router.getModelInfo(this.model);
if (info?.contextWindow) this.contextWindowTokens = info.contextWindow;
this.budget.setPricing(pricingOf(info));
} catch (err) {
this.log.debug(`could not price ${this.model}`, err);
}
Expand Down
60 changes: 60 additions & 0 deletions packages/browser/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,66 @@ async function codeOf(fn: () => Promise<unknown>): Promise<string> {
}
}

describe('a failed Chromium launch', () => {
it('is bounded, reports the browser log, and reaps the failed process', async () => {
const info = {
id: 'browser-failure',
name: 'browser-failure',
provider: 'docker',
state: 'running',
image: 'none',
workdir: '/work',
createdAt: '',
lastUsedAt: '',
spec: { network: { mode: 'full' } },
} as ComputerInfo;
const calls: Array<{ cmd: string; timeoutSec?: number }> = [];
const computer = {
id: info.id,
info,
async exec(input: { cmd: string; timeoutSec?: number }): Promise<ExecResult> {
calls.push(input);
if (input.cmd.includes('command -v chromium')) {
return { exitCode: 0, stdout: '/usr/bin/chromium\n', stderr: '', durationMs: 1, truncated: false, timedOut: false };
}
if (input.cmd.includes('--version')) {
return { exitCode: 0, stdout: 'Chromium 140.0\n', stderr: '', durationMs: 1, truncated: false, timedOut: false };
}
if (input.cmd.includes('python3 -c "import socket')) {
return { exitCode: 0, stdout: '43123\n', stderr: '', durationMs: 1, truncated: false, timedOut: false };
}
if (input.cmd.includes('for i in $(seq 1 80)')) {
return { exitCode: 1, stdout: '', stderr: '', durationMs: 40_000, truncated: false, timedOut: false };
}
if (input.cmd.includes('pkill -f')) {
return { exitCode: 0, stdout: '', stderr: '', durationMs: 1, truncated: false, timedOut: false };
}
throw new Error(`unexpected command: ${input.cmd}`);
},
async readTextFile(): Promise<string> {
return 'missing libnss3.so';
},
} as unknown as Computer;

let caught: unknown;
try {
await new BrowserSession(computer, { idleTimeoutMs: 0 }).activePage();
} catch (err) {
caught = err;
}

expect(isHuskError(caught) && caught.code).toBe('E_COMPUTER_FAILED');
expect((caught as Error).message).toMatch(/within 40 seconds/);
expect(JSON.stringify(caught)).toContain('missing libnss3.so');

const poll = calls.find((call) => call.cmd.includes('for i in $(seq 1 80)'));
expect(poll?.timeoutSec).toBe(90);
const cleanup = calls.find((call) => call.cmd.includes('pkill -f'));
expect(cleanup?.cmd).toContain('--remote-debugging-port=43123');
expect(cleanup?.timeoutSec).toBe(15);
});
});

describe('navigation is subject to the computer’s network policy', () => {
it('refuses a host outside an egress allow-list before opening a socket', async () => {
const c = fakeComputer('local', { mode: 'egress', allow: ['example.com'] });
Expand Down
14 changes: 12 additions & 2 deletions packages/browser/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,21 @@ export class BrowserSession {
`curl -sf --max-time 2 -o /dev/null http://127.0.0.1:${port}/json/version && exit 0; ` +
`sleep 0.5; done; exit 1`;

const ready = await this.computer.exec({ cmd, timeoutSec: 120 });
// The poll is 80 x (0.5s sleep + an instantly-refused connect), about 40s on
// a machine where Chromium simply never comes up. 90s covers a listener that
// accepts and wedges (each curl paying its full --max-time) with the exec
// kill as the hard backstop, so a failed launch is always bounded.
const ready = await this.computer.exec({ cmd, timeoutSec: 90 });

if (ready.exitCode !== 0) {
const log = await this.computer.readTextFile(LOG_PATH, 4000).catch(() => '');
throw new HuskError('E_COMPUTER_FAILED', 'Chromium started but never opened its debugging port', {
// A browser that never opened its port is still running, and close() only
// knows how to kill a port this method returned. Reap it here or every
// failed launch leaks one.
await this.computer
.exec({ cmd: `pkill -f -- "--remote-debugging-port=${port}" || true`, timeoutSec: 15 })
.catch(() => undefined);
throw new HuskError('E_COMPUTER_FAILED', 'Chromium started but never opened its debugging port within 40 seconds', {
hint: 'the log is in details -- a missing shared library or a stale singleton lock in the profile are the usual causes',
details: { port, log: log.slice(-2000) },
});
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/lib/discover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { discover } from './discover.js';

/**
* Discovery run from a repository root used to offer every Markdown doc as
* an importable transcript. Only chat-shaped Markdown belongs in the list;
* anything else is explicitly imported by path.
*/

let dir: string | undefined;

afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
dir = undefined;
});

async function cwd(): Promise<string> {
dir = await mkdtemp(join(tmpdir(), 'husk-discover-'));
return dir;
}

const CHAT = `# My conversation

## User

how do I reverse a list in python?

## Assistant

Use a slice: \`xs[::-1]\`.
`;

describe('discover', () => {
it('offers markdown that is actually a pasted chat', async () => {
const cwdPath = await cwd();
await writeFile(join(cwdPath, 'chat.md'), CHAT);
const found = await discover({ cwd: cwdPath, source: 'markdown' });
expect(found.map((c) => c.path)).toEqual([join(cwdPath, 'chat.md')]);
});

it('does not offer ordinary repository docs as transcripts', async () => {
const cwdPath = await cwd();
await writeFile(join(cwdPath, 'CHANGELOG.md'), '# Changelog\n\n## 1.0.0\n\n- added things\n');
await writeFile(join(cwdPath, 'README.md'), '# Project\n\nSome prose about the project.\n');
const found = await discover({ cwd: cwdPath, source: 'markdown' });
expect(found).toEqual([]);
});

it('still finds explicit transcript files next to the docs', async () => {
const cwdPath = await cwd();
await writeFile(join(cwdPath, 'README.md'), '# Project\n\nProse.\n');
await writeFile(join(cwdPath, 'session.md'), CHAT);
const found = await discover({ cwd: cwdPath, source: 'markdown' });
expect(found.map((c) => c.path)).toEqual([join(cwdPath, 'session.md')]);
});
});
32 changes: 29 additions & 3 deletions packages/cli/src/lib/discover.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readdir, stat } from 'node:fs/promises';
import { open, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { TranscriptSource } from '@husk-ai/core';
import { parseMarkdownChat } from '@husk-ai/sessions';

/**
* Find transcripts without being told where they are.
Expand All @@ -27,6 +28,28 @@ interface Root {
source: TranscriptSource;
match: RegExp;
depth: number;
/** Read a prefix and require it to look like a chat, not just carry the extension. */
sniff?: boolean;
}

/** Enough of a file to see whether a conversation starts -- a chat that does
* not begin in the first 32 KB is not what discovery is for. */
const SNIFF_BYTES = 32 * 1024;

async function sniffsAsChat(path: string): Promise<boolean> {
let fh;
try {
fh = await open(path, 'r');
const buf = Buffer.alloc(SNIFF_BYTES);
const { bytesRead } = await fh.read(buf, 0, SNIFF_BYTES, 0);
if (bytesRead === 0) return false;
// Two messages minimum: one role marker can be a coincidence in prose.
return (parseMarkdownChat(buf.subarray(0, bytesRead).toString('utf8'))?.messages.length ?? 0) >= 2;
} catch {
return false;
} finally {
await fh?.close();
}
}

function roots(cwd: string): Root[] {
Expand All @@ -39,8 +62,10 @@ function roots(cwd: string): Root[] {
{ dir: join(home, 'Downloads'), source: 'chatgpt', match: /^conversations.*\.json$/i, depth: 1 },
{ dir: join(home, 'Downloads'), source: 'universal', match: /chat.*\.json$/i, depth: 1 },
{ dir: join(home, '.cursor'), source: 'cursor', match: /\.(json|jsonl)$/i, depth: 2 },
// Anything the user is standing next to.
{ dir: cwd, source: 'markdown', match: /\.(md|markdown)$/i, depth: 1 },
// Anything the user is standing next to. Markdown is content-sniffed: a
// repo's CHANGELOG is not a pasted chat, and offering it as one made
// discovery useless exactly where people try it first.
{ dir: cwd, source: 'markdown', match: /\.(md|markdown)$/i, depth: 1, sniff: true },
{ dir: cwd, source: 'universal', match: /\.(jsonl)$/i, depth: 1 },
];
}
Expand Down Expand Up @@ -88,6 +113,7 @@ async function walk(dir: string, root: Root, depth: number, out: Map<string, Can
if (!st || st.size === 0) continue;
// A multi-hundred-megabyte JSON is not a chat someone meant to import.
if (st.size > 64 * 1024 * 1024) continue;
if (root.sniff && !(await sniffsAsChat(full))) continue;

out.set(full, {
path: full,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ describe.skipIf(!built)('exit codes', () => {
expect(r.status).toBe(1);
expect(r.stderr).toContain('no computer named "nope"');
expect(r.stderr).toContain('hint:');
});
}, 15_000);

it('exits 1 with a hint when asked to remove a computer that does not exist', () => {
const r = husk(['rm', 'anything']);
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ or, with no Docker running:

```
[husk] local computer cmp_8v9z on real Linux via wsl:Ubuntu. This is a guarded working
directory, NOT a sandbox: /work is jailed and destructive commands are refused, but it
shares the host kernel and network. Start Docker for real isolation.
directory, NOT a sandbox: file tools are confined to /work and destructive commands are
refused, but shell commands can still reach anything your user can on the host --
filesystem, kernel and network. Keep secrets and untrusted input out of it.
```

A model that believes it is sandboxed when it is not makes worse decisions than one that
Expand Down
Loading
Loading