Repository navigation
crewhaus v0.7.0
Tools a harness can run without spending a token. A CrewHaus harness could
call 22 builtin tools; it can now call 549. They read chains and ledgers, drive
git and package managers, gate a page before it publishes, operate a fleet, and
answer the hundreds of small questions a run used to spend a model call on —
almost none of them makes one, and a spec turns them on a capability group at a
time rather than a name at a time. Two of the fixes below are worth reading even if
you upgrade nothing: an SSRF classifier that compared address text, found by
auditing all ten copies of it, and a daemon status page that had been binding
the wildcard rather than loopback.
Added
-
Fifty-seven new
@crewhaus/tool-*packages (#453, #454, #458, #459,
#462, #463, #465, #466, #467, #469, #470, #471, #472). Each is an ordinary
builtin — it takes a typed input, does the work in-process, and returns a
result — so the work it does costs no tokens and cannot drift between runs.
Two of the 549 are the exception and say so in their own descriptions:
ImageGeneratecalls a remote image model andWebSearchcalls a paid
search API. Every other one is local work. Broadly:- Pure computation, no I/O at all. Text, structured data (JSON, YAML,
TOML, CSV, XML), encoding and hashing, dates and schedules, maths and
exact money, schema validation, control flow, and tabular intake:
tool-text,tool-data,tool-encode,tool-datetime,tool-math,
tool-schema,tool-flow,tool-table. - The working tree and the toolchain. Files and archives, processes,
git, GitHub and GitLab, SQLite, documents, HTML, packaging and lockfiles,
code intelligence, and the gates you run before a change lands —
tool-fsx,tool-proc,tool-git,tool-codehost,tool-sql,
tool-docs,tool-html,tool-pkg,tool-code,tool-verify,
tool-changeset,tool-buildperf,tool-supplychain,tool-registry,
tool-containers,tool-distribution. - What a harness remembers and what it reports. Durable state, secrets
handling, messaging, observability and cost:tool-state,tool-secure,
tool-secrets,tool-notify,tool-obs,tool-media. - Money and chains, reading only. ABI encoding and EIP-712 digests,
live EVM reads, token and DeFi queries, a hash-chained local ledger,
e-invoices, payment files and counterparty checks:tool-onchain,
tool-chainread,tool-chaincall,tool-token,tool-defi,
tool-ledger,tool-einvoice,tool-kyc,tool-money,
tool-objectstore. - The operator's own machine. System and network facts, scheduled jobs,
filesystem watches, the system package manager and the desktop:
tool-host,tool-hostfs,tool-cron,tool-pkgmgr,tool-desktop. - CrewHaus operating itself. Specs, evals, datasets, approvals, harness
lifecycle, fleets, deployments, routing and discovery:tool-specops,
tool-evalops,tool-dataset,tool-approvals,tool-lifecycle,
tool-fleet,tool-deploy,tool-routing,tool-discovery,
tool-crewhaus.
What these tools deliberately will not do matters as much as what they
do, because each of these was a capability that would have been easy to
add and wrong to ship:- None of these tools signs or sends a transaction. They read. No
schema among them takes a private key, and each of the five chain
packages refuseseth_sendTransactionandeth_sendRawTransactionat
the RPC seam by assertion rather than by omission. (The separate
@crewhaus/tool-evm-tx, which does sign, is wired per shape and is not
one of the 549.) - Nothing moves money.
PaymentFileBuildwrites a payment file and
InvoiceRenderrenders an invoice; transmitting either is a human's job
through their own bank. No schema takes a banking credential. - Nothing acquires privilege.
PackageInstallnever runssudo,
doas,runasorpkexec, and no schema has a password field. A
manager that needs root refuses and hands back the exact command an
operator would run; Homebrew, which does not, proceeds. SecretLookupdoes not return the secret. A tool result reaches a
model's context, then a transcript, a trace and a log, so it reports
presence, backend, length and a truncated fingerprint. There is no
reveal option.- Nothing a caller supplies becomes program text.
osascript -etakes
source code and a Windows toast body is XML, sotool-desktoppasses
every caller value as an argument the script reads by index, never
interpolated into a statement. DeployPromotewas not built. Itsprotected_envsguard cannot be
enforced by a tool, which never sees how the permission engine resolved
the target environment; with a standing allow the guard would be
decorative. The reasoning is kept in a docblock rather than lost.
- Pure computation, no I/O at all. Text, structured data (JSON, YAML,
-
tools:gains a grammar for turning on a capability group (#453).tools: - all-code # every tool in the code roll-up - -bash # ...except this one - webFetch # plus one tool by nameOrder does not matter: every include is unioned, then every exclude is
subtracted, so an exclusion always wins and a reader never has to simulate
the list top to bottom. A category is either a leaf, owning tool keys and
mapping 1:1 to the package that implements them, or a roll-up owning other
categories and expanding transitively — one mechanism for bothall-fsand
all-code. Expansion happens once, at lowering, so the IR only ever carries
concrete names and no target emitter changed. A spec that uses no category
syntax is returned by reference, so every bundle written before this existed
still compiles byte-identically.Two mistakes fail the compile with the offending path rather than passing
quietly: an unknown category, and an exclusion that removes nothing — a
-gitPushmatching no included tool is almost always a typo, and ignoring
it would leave the author believing a tool is gated when it is not.Because a category system is only as good as being able to see inside it:
crewhaus tools categories,tools show <tool>,tools search <query>and
tools list --category. The registry is the new@crewhaus/tool-categories,
which is data only and imports no tool package — the compiler imports it and
codegen has to stay offline. -
crewhaus hangar --lanprints a QR code you can scan (#455). It binds
this machine's LAN address instead of loopback and renders the console's
URL, token and all, as a QR code in the terminal. Scan it and the fleet
view, the run feed and the Advisor open on your phone — which is the point,
because they are exactly what you want on a second screen while a terminal
is busy, and nobody types a 64-character token into a phone twice.The encoder is a new dependency-free package,
@crewhaus/qr-code. A
package rather than an install becauseapps/clicarries exactly one
non-workspace dependency by policy, and becausebun build --compile
embeds only statically-referenced imports — a QR library that loads a data
file at runtime would passbun testand brick the released binary. This
one has no data files at all.Two things a terminal renderer meets that a PNG renderer never does. A
terminal cell is twice as tall as it is wide, so half-block characters
carry two module rows per text row and the modules come out square. And a
terminal has no fixed background, so every line carries an explicit
black-on-white colour pair; without it the same characters read as a QR
code on a dark theme and as its photographic negative on a light one. With
colour disabled the mapping inverts instead, because the only remaining way
to make light modules light is to draw them and let the terminal's dark
background be the ink.Picking the address is its own problem:
os.networkInterfaces()offers no
ranking, and on a laptop most of what it offers is useless to a phone — VPN
tunnels, Apple's peer-to-peer radios, container bridges, self-assigned
169.254 addresses. The ranking prefers RFC 1918 on a physical-looking
interface, and ties break on the interface name so the answer is stable
across boots. -
crewhaus services setup— the external services a harness needs, in one
command. A spec declares that it wants a Slack channel, a public URL and
a hosted wiki; everything on the other side of those declarations has been a
click-path through three consoles, repeated per harness. The new
@crewhaus/service-setuppackage creates them: a Cloudflare named tunnel
with a public hostname and its DNS record, a Thredz wiki space, and a Slack
app built from a manifest, applied, and installed. Credentials land in the
harness's own.env; the wiki slug lands in the spec'sthredz.space,
which the compiler bakes in as a literal and so cannot live in.env.Everything is derived from the spec — including the credential variable
NAMES, which come from its own$VARrefs, becausechannel-adapter-slack
never reads a credential from the environment and the names are therefore
the author's choice. No naming convention is assumed.This does not reverse
channel provision's deliberate emit-and-instruct
decision for Slack, it sits above it. That command is part of the runtime's
credential tier, which carries only what the daemon uses and cannot call the
manifest API.services setupis an operator command holding short-lived
provisioning credentials — a Slack app-configuration token, a Cloudflare API
token, a Thredz key — that are read once, used, and dropped. What lands in
the harness is still only what the daemon needs.Three details are load-bearing. Setup stands in on the events port to
answer Slack'surl_verificationchallenge, because the daemon refuses to
boot without the signing secret that only the app-creation response carries
— and skips standing in when something is already listening. The Cloudflare
ingress write is read-merge-write with the catch-all pinned last, since
PUT …/configurationsreplaces the whole configuration and a malformed list
is accepted withsuccess: truewhile the connector silently keeps serving
the old one. And the Slack credentials are persisted before the install
step runs, because the signing secret is returned exactly once and no API
reads it back.The Slack manifest now also sets
settings.interactivity, closing a gap
that left approval cards rendering while their buttons did nothing.Two more properties fall out of the same read-merge-write discipline. The
ingress round trip carries every field Cloudflare stored that this package
does not model —originRequestabove all, which is where Cloudflare Access
lives — because not modelling a field is not a licence to delete it on a
full-replace PUT. And routes are keyed on hostname AND path, since
cloudflared matches both: a{host, path}rule beside a bare{host}one
is a normal two-route config, not a stale duplicate.Setup never installs or restarts the cloudflared connector; it prints the
command, and re-offers it on any run where no connector is attached rather
than only on the run that created the tunnel.--dry-runrenders the full
plan without contacting anything.
Fixed
-
One private-address classifier, parsed rather than pattern-matched, in
all ten copies (#460). Ten packages guard an outbound request against a
private destination, each carried its own copy, and the copies had drifted.
An audit of all ten — a runnable proof required per claim, then an
independent pass whose job was to refute each one — found six confirmed
exploitable.A guard that classifies IPv6 by comparing address text never sees what
a URL parser produces:new URL("http://[::ffff:169.254.169.254]/")has
hostname[::ffff:a9fe:a9fe], so a check written for the dotted spelling
waves it through. And64:ff9b::a9fe:a9feis 169.254.169.254 on any
network running DNS64/NAT64 — which on a cloud host is the metadata service
that hands out credentials. Parsing numerically is necessary but not
sufficient: one copy already parsed and was still exploitable, because it
knew64:ff9b::/96and not the64:ff9b:1::/48variant.The worst instance was proven end to end.
computer-use-driver's SSRF
proxy read itsCONNECTtarget from a regex onreq.url, so that host
never passed throughnew URL()and uncompressed spellings survived
intact:CONNECT [0:0:0:0:0:0:0:1]:<port>opened a tunnel to a local
service, and the module's own docstring claimed it "cannot be leveraged by
other local processes to reach internal services". It could.The classifier is now one block, byte-identical in all ten, parsing IPv4
(decimal, hex, octal, packed and short forms) and IPv6 numerically and
extracting the embedded IPv4 from every family a URL parser or a DNS64
answer can produce. The guard that keeps them identical executes the
classifier against a table of spellings rather than comparing source text —
the first version compared text, which is the original vulnerability
reproduced in the thing meant to catch it. -
A liveness probe that did not answer is no longer read as a dead
process (#461).ProcessOps.isAlivereturned a boolean, so it had no way
to say "the platform would not tell me". On Windows the probe is
powershell.exeunder aspawnSynctimeout, and on timeout the runner
returns undefined, whichisAliveturned intofalse. Three callers then
acted on a slow machine as though a daemon had died: adoption reported
pid-deadwithverified: true, the run-file lock treated it as
permission to break a live start lock, and the supervisor calledonExit
on an adopted run that had not exited.isAlivenow returns true, false or undefined, and every caller branches
on=== false. POSIX never returns undefined and the contract says so:
kill(pid, 0)is a syscall that decides. The probe budget was also one
constant for both platforms — 5,000ms, a POSIX assumption applied to
PowerShell, which routinely takes one to three seconds and much longer on a
loaded host; Windows now gets 20,000ms, named separately so the reason
survives. Visibly,daemon statusprints "could not determine whether it
is running" instead of "not running", anddaemon logs --followstays
attached on an unanswered probe rather than truncating a live daemon's
output. -
A harness with a
gateway:block published its status page to the whole
network (#455).Bun.servewith nohostnamebinds the wildcard, and the
channel daemon's control-UI listener omitted it — so the harness name,
shape, boot time, wired channel list and turn and heartbeat counts were
served unauthenticated to anything that could reach the port, along with the
HTML dashboard. No secrets and no write surface, so this is reconnaissance
rather than compromise, but it was on by default and nothing announced it.What made it invisible is that
server.hostnamethen reports
"localhost"— a getter, not the bound interface.lsofshowed
TCP *:<port>and a fetch to the machine's LAN address answered 200. The
emitted boot banner saidhttp://localhost:<port>for the same reason and
was equally wrong. Everything else about this port already said local: the
gateway:block is strict with no bind field, so an operator could not
have opted in or out; no image publishes it; and both in-repo readers dial
127.0.0.1. The listener now matches what four other subsystems already
assert, and the banner is true. -
A containerized
manageddaemon answered nothing from outside its own
network namespace (#455).gateway.listen(PORT)tookgateway-server's
127.0.0.1default — the right default for a library and the wrong bind
for this shape, which has exactly one listener and rides/healthzon the
same socket. So every probe and every request from outside the namespace
was refused: Docker, the Helm chart's liveness and readiness probes, and
all four PaaS adapters, each of which mapsmanagedtoweb. The bind is
nowHOST, which pairs with the barePORTthis emit already reads and
was already allow-listed beside it in the supervisor's non-secret
environment keys. -
The Hangar's flywheel view matched two workflow names that nothing ever
writes (#475). The endpoint filtered.github/workflowsthrough a
hand-written pattern,/crewhaus-(flywheel|eval-gate|sentinel)/, and
crewhaus-eval-gateandcrewhaus-sentinelare systemd unit labels rather
than filenames — plausible-looking siblings of the real ones, which is why
the pattern could sit there looking correct. The eval gate
(crewhaus-eval.yml), the sentinel (sentinel-drift.yml), the model-plan
and the dream workflows were all invisible, so a harness scaffolded with
init --sentinelreported no workflows and was told it had no flywheel
scaffolding. The filenames now have one owner that both the scaffolders and
the endpoint read, and matching is on the exact basename. -
One unreadable line in an eval run index no longer hides every run in the
file (#474).readRunIndexalready skipped a line it could not parse —
the index is an append-only log and one torn write must not hide the rest —
butJSON.parsedoes not throw onnull,42or[], so a line holding
any of those was passed through as a run. The first.runIdread off it
threw and took the whole read down. Field-level validation is deliberately
still absent: an object-shaped row with a nonsensepassRatecomes back, so
the reports that name such rows can keep naming them. -
A
.envvalue containing a"or a\no longer changes shape on a
write/read round trip.encodeEnvValueescapes both characters whenever
it has to quote a value, but nothing reversed that:upsertEnvVar(path, "K", 'a"b')wroteK="a\"b"and read backa\"b. The asymmetry sat in two
places at once — the console'sreadEnvFileFactsandparseEnvText, which
builds the environment a spawned daemon actually receives — so a value could
in principle have survived the presence check and still reached the child
altered.No credential in use was affected: provider tokens are all
[A-Za-z0-9_.-], which never reaches the quoting path. The fix is
therefore latent correctness, and the reason it needed doing is that the
failure would first appear as an auth error nobody traces back to an env
file.@crewhaus/harness-supervisornow owns the oneunquoteEnvValue, and
@crewhaus/hangar-serverre-exports itsparseEnvTextinstead of keeping a
verbatim copy — the duplication is what let the same bug live in two places.
Round-trip tests cover a space,#,",\,=,$, and the empty
string, through both readers. -
crewhaus channel provisionsets the Slack manifest's interactivity
request URL. The emitted manifest configured only Event Subscriptions, so
an app created from it rendered approval cards whose Approve/Deny buttons
reached nothing — the gateway routes clicks on/<adapter>/actions, a
separate webhook from/<adapter>/events.
Changed
-
Five modules moved out of
apps/cliinto packages of their own (#468).
No behaviour changed and no tool shipped in that change — it exists because
thirteen deterministic tools could not be written while the logic they need
lived in an app, and nopackages/tool-*may depend on one. The new
packages are@crewhaus/harness-advice,@crewhaus/dataset-ops,
@crewhaus/eval-ops,@crewhaus/spec-changelogand
@crewhaus/harness-lifecycle; the CLI now imports what it used to own. If
you imported any of those modules fromapps/clidirectly, they have
moved. -
The audit envelope derives its wrapping key once per DEK version instead
of once per record (#456).encryptPayloadminted a fresh scrypt salt for
every record, so the derivation could never be cached: ~52ms and ~64MB per
record, about 19 records a second.A per-record salt buys nothing here. A salt's job is to make precomputation
against this KEK worthless, and one random salt does that completely —
an attacker testing a passphrase guess picks any single record and computes
one scrypt, whether the corpus holds one salt or a million. Salt diversity
pays when you are stretching different secrets; here there is one KEK and,
per tenant, one DEK. Spent on a higher cost parameter instead, the same CPU
budget actually multiplies an attacker's work.The wrap cache is keyed on the KEK reference, the DEK version and the DEK
bytes — a store shared with another process can hand back different bytes
under an unchanged version, and pairing a stale wrap with fresh bytes would
emit a record that never decrypts. The wrap's ciphertext is reused rather
than re-encrypted under a stable key, which keeps exactly one key and IV
pair in play and so spends no GCM nonce budget. Records written with the
old per-record salt still decrypt, and there is a test that says so.
What's Changed
- feat(services): provision a harness's Slack app, tunnel and wiki space by @crewhaus in #452
- feat(tools): 411 deterministic tools and the all- selector grammar by @crewhaus in #453
- feat(tools): onchain, html, verify and table — 438 deterministic tools by @crewhaus in #454
- feat(tools): change sets, build performance, registries, supply chain and containers — 453 tools by @crewhaus in #458
- feat(tools): read an EVM chain and value what you find — 471 tools by @crewhaus in #459
- fix(security): one private-address classifier, parsed not matched, in all ten copies by @crewhaus in #460
- fix(supervisor): a liveness probe that did not answer is not a dead process by @crewhaus in #461
- feat(tools): a ledger, the fixed-format financial files, counterparty checks — 482 tools by @crewhaus in #462
- test(single-binary-cli): pin the release manifests byte-for-byte, before anything refactors them by @crewhaus in #464
- feat(tools): the operator's machine, read-only and offline half — 493 tools by @crewhaus in #463
- feat(tools): render and verify release manifests — 495 tools by @crewhaus in #465
- feat(tools): system package management without privilege escalation — 497 tools by @crewhaus in #466
- feat(tools): the operator's desktop, where escaping is the boundary — 505 tools by @crewhaus in #467
- refactor(cli): extract the fourteen modules thirteen tools are blocked behind by @crewhaus in #468
- feat(tools): the CrewHaus-native operations PR-zero unblocked — 525 tools by @crewhaus in #469
- feat(tools): fleet, deploy, routing and discovery — 539 tools, §4.23 complete by @crewhaus in #470
- feat(tools): the last nine buildable catalog entries — 548 tools by @crewhaus in #471
- feat(tool-verify): SeoLint — the last buildable catalog entry, 549 tools by @crewhaus in #472
- perf(spec-patch): parse each optimizable-paths fixture once, not once per entry by @crewhaus in #473
- fix(eval-report): a line that parses but is not a run took the whole index down by @crewhaus in #474
- perf(audit-encryption): derive the KEK wrapping key once per DEK version, not per record by @crewhaus in #456
- feat(hangar): scan a QR code to open the console on your phone by @crewhaus in #455
- fix(hangar): the flywheel endpoint matched two names nothing ever writes by @crewhaus in #475
- test(tool-hostfs): pin the two-event recovery on facts, and make twelve files greppable again by @crewhaus in #476
- chore(release): crewhaus 0.7.0 by @crewhaus in #477
Full Changelog: v0.6.0...v0.7.0