Skip to content

feat: 0.5.0 - Fix governance server and major architecture decisions - #48

Merged
evansibok merged 33 commits into
mainfrom
ev/0.5.0
Aug 8, 2026
Merged

feat: 0.5.0 - Fix governance server and major architecture decisions#48
evansibok merged 33 commits into
mainfrom
ev/0.5.0

Conversation

@evansibok

@evansibok evansibok commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Per-app identity. nanny init writes a permanent .nanny/app.json (app_id + name), once, ever, per app. Meant to be committed. Never regenerated.
  • nanny run --join=<appId> replaces blind auto-detection for joining a governance server. --app=<id> extends nanny status/nanny stop to target one app explicitly.
  • Per-app governor state (~/.nanny/servers/<appId>/...) replaces the old global, unkeyed ~/.nanny/server.* files, fixing a real collision between unrelated apps' --serve instances on one machine.
  • [runtime]/mode removed from nanny.toml entirely. Sync now turns on purely from local credential presence (nanny auth login + a self-minted, app-scoped key). No config field, no way for it to disagree with reality.
  • Per-app Cloud credentials, gitignored, self-minted by nanny run on any logged-in machine, independent of the app's one-time nanny init.
  • CONNECT proxy auth rework: a separate proxy_token for the CONNECT tunnel (never the session token), a real axum/hyper upgrade-handling bug fix, a single dispatch checkpoint (GovernorService) so CONNECT can't bypass a router-only check, constant-time token comparison.
  • Two real proxy gaps fixed: HTTPS_PROXY/HTTP_PROXY are now actually auto-injected when the joined server has [proxy] configured (previously silent no-op unless set by hand), and a denied proxy request now actually stops the run, matching every other denial path.
  • fresh_run() in both SDKs: start a new governed run mid-process with its own independent counter. Replaces the previously-undocumented pattern of setting NANNY_RUN_ID directly.
  • instrument() now covers OpenAI's Responses API, not just Chat Completions, so reasoning-model calls that require Responses (tool calls + real reasoning_effort) are measured instead of silently unmeasured.
  • cache_read/cache_write on LlmUsageRecorded, normalized across OpenAI, Anthropic, DeepSeek, and Gemini's incompatible cache-accounting shapes.
  • Every Nanny-owned data file is now JSON except nanny.toml itself, the only file meant to be hand-edited and commented.
  • StepCompleted actually fires now (was silently dead since POST /step, the only caller, had none), and proxied CONNECT calls now count as steps too, closing the last of three ToolAllowed sites that didn't.
  • Full nanny/ doc sweep for all of the above.

Full detail in CHANGELOG.md.

Test plan

  • cargo build --workspace
  • cargo test --workspace
  • cargo clippy --workspace --all-targets
  • Live manual test: governor + --join, real DeepSeek + Tavily calls through the CONNECT proxy (GoTM deployment)
  • Live manual test: --join survives a NannyStop without killing the governor, governor keeps serving after

… real hard stop

Two gaps found while auditing [proxy] allowed_hosts for the OpenClaw demo:

- G8: nothing injected HTTPS_PROXY/HTTP_PROXY into the governed child, so the
  allowlist silently did nothing unless a human remembered to set it by hand —
  a fail-open gap the manifesto forbids. cmd_run_via_network_server now
  injects these (plus lowercase and NO_PROXY) whenever the SERVER (not the
  joining client's own nanny.toml, which may live elsewhere) has [proxy]
  configured, via a new ~/.nanny/server.proxy discovery file.

- G9: a proxy denial only failed that one CONNECT; the run itself was never
  marked stopped, contradicting docs and every other denial path. route_proxy
  now calls mark_stopped, and also gates on an already-stopped run the same
  way route_tool_call/route_rule_evaluate already do, so a denied run can't
  keep tunneling to an allowed host afterward.

Python SDK needed no code change for G9 — existing 410 handling already maps
an unknown/non-limit reason (including "ToolDenied") to ExecutionStopped; a
test was added to prove it, not new logic.
Comments and a docstring referenced internal plan-tracker shorthand
(tracker IDs used to sequence work on a planning doc). Those don't belong in
source — they rot as the tracker moves on and mean nothing to anyone reading
the code without that context. Reworded to explain the reasoning directly.
… Telegram channel setup

Covers the install method chosen (npm over the curl|bash shell installer),
the LaunchAgent runtime shape and config file location, the channel pivot
from mail to Telegram (mail is a skill wrapping himalaya/gog with no simple
sandbox override; WhatsApp links a real account via QR, Telegram uses an
isolated bot identity), and the security audit findings from a fresh
install, including the tool-hardening attempt that broke basic message
sending and was reverted.
Opt-in, dollars-denominated, mirrors [limits]'s existing named-subtable
pattern ([budget.<name>] alongside [limits.<name>]). Purely inert here —
parsed and validated, never resolved. Resolution into the real tokens = N
value happens once in the CLI, before the engine ever sees the config;
nanny-core and nanny-bridge never see [budget] at all. Absent by default,
so every existing nanny.toml keeps working unchanged.
Local runtime side of the app-identity/governor-isolation redesign
(cloud/IMPLEMENTATION_PLAN.md, "App identity, governor isolation &
Cloud-scoped auth"):

- `nanny init` now writes a permanent `.nanny/app.toml` identity
  (appId + a human-facing name) alongside `nanny.toml` — one-time,
  local, never regenerated.
- `--serve` state (`server.addr`/`.token`/`.pid`/`.proxy`) is keyed by
  app id under `~/.nanny/servers/<app_id>/`, replacing the old global,
  unkeyed files that let two unrelated apps' governors collide.
- `nanny run --join=<appId>` replaces blind auto-detection of "whatever
  governor is running" with an explicit, id-only join; fails loudly if
  the target isn't reachable instead of silently falling back to a
  local bridge.
- `nanny status --app=<id>` / `nanny stop --app=<id>` extend the former
  zero-argument commands to target one app's governor.
- `mode` is removed from `nanny.toml` entirely. Sync is decided purely
  by whether an app-scoped Cloud credential exists locally; no
  credential, no sync, no config knob. `--no-sync` still overrides.
- Per-app, gitignored credential storage
  (`.nanny/credentials.local.toml`), self-minted on `nanny run` per
  machine (not at `nanny init`, which is one-time and can't reach every
  machine an app is later deployed to).

CONNECT proxy: real bugs found and fixed, not just the isolation work
above:

- Routing a CONNECT request through axum's `Router::call()` silently
  broke hyper's server-side upgrade handoff (confirmed with a minimal
  reproduction outside this codebase). `GovernorService` now
  intercepts CONNECT before the router ever sees it; regular routes
  are unaffected.
- CONNECT now authenticates via `Proxy-Authorization: Basic
  <b64(proxy_token:)>`, a new, separate credential from
  `session_token` — the only credential mechanism a generic
  proxy-aware HTTP client can actually deliver on a CONNECT handshake
  with zero app-side code changes. If it ever leaks (e.g. via a
  developer's own verbose HTTP client logging, which prints proxy
  URLs), the blast radius is "open a tunnel to an already-allowlisted
  host," not full run control.
- Rate-limit and auth checks moved out of axum `.layer()`s (which
  structurally can't see CONNECT) into `GovernorService::call`, the
  one dispatch point every request — CONNECT or not — passes through.
  There is no longer a second place a future universal check could be
  added and silently not cover CONNECT.
- Token comparisons (`session_token`, `proxy_token`) now use a
  constant-time compare instead of `==`, closing a timing
  side-channel.
- The injected `HTTPS_PROXY` URL always includes an explicit empty
  password (`http://token:@host`) — some HTTP clients (Python's
  `requests`/urllib3) silently drop the username too when the password
  is merely absent rather than empty.

Full workspace test suite green throughout; new tests cover --join,
--serve state keying, CONNECT auth (both credentials), and the
proxy-URL env var injection.
… global path

nanny_server_is_running() only ever checked the old global
~/.nanny/server.addr, so `certs import`/`rotate` could never detect a
running governor under the per-app ~/.nanny/servers/<app_id>/ layout
unless NANNY_BRIDGE_ADDR happened to already be set. Now scans all
per-app server dirs for a reachable server.addr.

Also drops a stale "there is no --force" mention in identity.rs's doc
comments — --force was never implemented or shipped.
Covers the breaking local-runtime redesign already on this branch:
per-app identity, keyed governor state, explicit --join, per-app
Cloud credentials, and the CONNECT-proxy auth rework.
Rewrites managed-mode.mdx, governance-server.mdx, http-proxy-mode.mdx,
and init.mdx around the new model: sync decided by login presence (no
mode/[runtime] field), explicit --join=<appId> (no auto-detect), and
per-app state under ~/.nanny/servers/<appId>/. Single-block fixes to
README.md, sdks/python/README.md, quickstart.mdx, nanny-toml.mdx, and
cli-auth.mdx for the same reasons.

Also cleans up stray em-dashes in commit-adjacent comments in
certs.rs and identity.rs.
run.mdx: add --join to the options table, reword --no-sync off
"managed mode", update status/stop examples and paths to the per-app
--app form.

auth.rs: fix a stale doc comment describing sync as gated on
mode = "managed".

examples/: strip the dead [runtime]/mode block from the 4 example
nanny.toml files, and the two stale `nanny server start` lines from
metrics_crew's proxy comment. No other changes to the example apps.
Follows this project's own established convention (0.3->0.4 got the
same folder rename in b7cdd0d, documented in docs/AGENTS.md's own
versioning policy): a minor version with a meaningfully different
mental model gets a fresh docs folder, not an in-place patch update.
Adds a /v0.4/:slug* -> /v0.5/:slug* redirect alongside the existing
v0.1-v0.3 ones, and updates docs.json's version switcher and all
internal cross-links accordingly.

Also removes stray em-dashes from prose introduced earlier in this
branch's doc sweep.
…ying

Adds regression coverage for gaps found while auditing this branch's
test suite:
- secure_compare: equal, unequal, different-length, empty inputs
- a session_token must not authenticate CONNECT, and a proxy_token
  must not authenticate an ordinary request (the whole point of
  splitting them into two credentials)
- nanny_server_state_dir: different app ids never collide, the same
  id is always stable, and --app wins outright over cwd resolution
- AppCredentials: save/load round trip, 0600 file permissions, the
  gitignore entry is added exactly once, and self-minting short-
  circuits to the existing credential without a network call

Also removes stray em-dashes from comments introduced earlier in this
branch (some of the earlier cleanup had merged unrelated parentheticals
across line breaks; fixed those to read correctly instead of just
removing the character).
Five tests covering the exact features this branch adds (--join,
loopback --serve without certs, and proxy env-var injection) were
Windows-excluded because they sandboxed state into a temp dir by
overriding HOME, and dirs::home_dir() ignores HOME on Windows.

Adds NANNY_HOME, a real override checked before falling back to the
OS home directory, since a plain env var read behaves identically on
every platform where an OS profile-directory lookup does not. Used it
in nanny_server_state_dir (the one function both --serve and --join
resolve state through), then switched the five tests to it and
dropped their #[cfg(not(windows))] gates.

T10 and T11 also assumed a POSIX `sh` on PATH for their [start].cmd,
which this project's own existing Windows tests already avoid
elsewhere in this file. Gave both a cmd.exe equivalent, and accounted
for the one real behavioral difference this surfaces: an unset
variable prints as empty in sh but as the literal %VAR% text in
cmd.exe, so T11's assertion branches on platform for that reason only.
Added while un-skipping the Windows --join/--serve tests; it's a real,
always-available override (not test-only), so it belongs in docs, not
just a code comment.
…on an unreachable bridge

These four raised a raw httpx.ConnectError (full traceback and all)
straight through @agent/@tool when the governor wasn't reachable,
instead of the typed BridgeUnavailable every other bridge failure mode
already gets. @rule's own GET /status check already had this exact
guard; this makes every bridge call consistent with it via one shared
_bridge_call() context manager instead of relying on each call site to
remember.

Also bumps sdks/python/pyproject.toml (and its lockfile) from 0.4.2 to
0.5.0 — it was still unbumped on this branch, and release.yml's
publish-pypi job hard-fails if the SDK version doesn't exactly match
the release tag.
GoTM consumes nanny-sdk via a local path dependency, not a version
pin, so this number has no effect on local testing either way — it
only matters to release.yml's publish-pypi version-match check, which
only runs on an actual tag push. Bumping it now just locks in "0.5.0
is final" before that's decided. Bump this as the last step before
actually tagging and publishing, not before.
Cargo.toml and CHANGELOG.md already call this release 0.5.0; leaving
the SDK at 0.4.2 just left the branch internally inconsistent for no
benefit, since the version string has no effect on anything until an
actual tag push (GoTM consumes it via a local path dependency, not a
version pin). Trivially revertable later if the release scope changes.
nanny.toml's [observability] table has always promised "here's where
your event log goes." Local `nanny run` kept that promise via
EventWriter; `nanny run --serve` silently didn't, it never read
config.observability at all, so log = "file" quietly did nothing the
moment an app moved from local runs to a long-lived governor. Any app
relying on that file (piping it to Datadog, tailing it for a live
token count, whatever) would work locally and silently stop working
under --serve, with no error.

NetworkServer::start_blocking_synced gains an optional
local_log_path: Option<PathBuf>. The existing drain thread (previously
spawned only when a cloud event_sink was attached) now also spawns
when a local log path is given, and both destinations get their own
copy of each drained batch: draining is destructive, so this is the
one place that reads a run's events, cloud sync and the local file
never steal from each other. start_blocking's own signature is
unchanged (passes None through), so none of its existing test call
sites needed updating.

commands/server.rs resolves local_log_path from the server's own
nanny.toml, mirroring EventWriter::from_config's exact decision logic
(same missing-log_file error) so both paths fail the same way.
log = "stdout" stays a no-op for --serve on purpose: a long-lived
server continuously mixing NDJSON into its own stdout status output
would be noisy and wrong, unlike a short-lived local run.

Verified against a real joined client (gotm-nanny's --join flow):
gotm.ndjson, empty at server start, filled with real ToolAllowed/
AgentScopeEntered/LlmUsageRecorded events the moment a real governed
execute cycle ran against it, flushed per event, same guarantee local
mode already had.
…equired

log_file (a developer-specified path) is replaced by file (an optional
bare name, no extension, no path separators). Nanny always owns the
directory now: .nanny/logs/, auto-created, auto-gitignored. Unset, the
filename defaults to log.ndjson; Nanny always appends .ndjson itself.

Both nanny run and nanny run --serve resolve through the same
ObservabilityConfig::resolve_log_path, so the two paths can no longer
drift the way --serve's own [observability] gap just did (e968fee).
…/step endpoint

handle_tool_call already incremented step_count on every allowed call
(both the registry-tool and user-defined @tool paths), but never
emitted the matching StepCompleted event, only a separate /step
endpoint did, and nothing in any SDK, Python or Rust, ever called it.
Confirmed via a repo-wide grep and a test that had already noticed the
symptom in passing ("tool call also ticks").

handle_tool_call now emits StepCompleted alongside ToolAllowed, the
pairing the event-log docs already claimed. /step, handle_step, and
route_step are removed entirely: keeping an unused endpoint that
writes to the same counter as the real path was a live double-count
risk, not just dead code.

Rewrote the 8 tests that exercised /step to exercise real tool calls
instead. Full workspace suite passes (117 bridge tests + rest).
…un mid-process

Both SDKs let you set NANNY_RUN_ID directly, an internal detail the
client happens to read fresh on every call, never documented as
something to rely on. A real integration need (a process running
several independent phases back to back, each wanting its own clean
token/step budget instead of inheriting whatever an earlier phase
already spent) had no discoverable way to express it.

nanny::fresh_run() (Rust) / nanny_sdk.fresh_run() (Python) mint a new
run id and set it, mirroring each other exactly. Only meaningful under
--serve/--join (the server keys independent state per run id); a safe
no-op under local nanny run, where one process is already one run.

docs/v0.5/concepts/limits.mdx also gets a direct explanation of why
this exists: named limit sets share one cumulative counter for the
whole run, they are not independent per-scope budgets, a real
misconception the guide's own wording previously encouraged ("hitting
the analysis budget doesn't kill the reporter" was flatly wrong under
the real, shared-counter behavior). Both SDK guides get a matching
fresh_run reference section.
…leanup

handle_connect emitted ToolAllowed on an allowed proxy tunnel but never
incremented step_count or emitted StepCompleted, the only one of the
three real ToolAllowed call sites in the codebase that didn't. Invisible
unless an agent's only governed actions are proxied LLM calls with no
@tool calls, exactly the case that showed a real run doing real,
budget-consuming work with its step count stuck at zero.

Also reworks "bridge" language across docs/CLI health output toward
"enforcement", per an audience audit distinguishing internal
implementation terms from what users should see, and documents the
shared-counter (not per-scope-budget) mechanics of named limit scopes
more precisely in ARCHITECTURE.md and the Python SDK README.
…le except nanny.toml

LlmUsageRecorded gains optional cache_read/cache_write fields, a generic
finer split of input tokens for providers that report prompt-caching usage
(OpenAI, Anthropic, DeepSeek, Gemini). Reporting only: enforcement still
debits input + output exactly as before. nanny_sdk.instrument() normalizes
each provider's own incompatible field shape into these two fields, with a
real fix along the way: Anthropic's own input_tokens is exclusive of cache
usage (unlike DeepSeek/OpenAI), so the SDK now adds cache_read/cache_write
into input for Anthropic specifically, keeping Nanny's own input field one
universal, provider-independent total regardless of source.

Also converts every Nanny-owned data file to JSON except nanny.toml itself,
the only file meant to be hand-edited and commented: .nanny/app.toml,
~/.nanny/credentials.toml, and .nanny/credentials.local.toml are now .json.
credentials.toml was a real, already-published 0.4.2 format; the other two
were new and unpublished in this branch. No automatic migration.
Chat Completions rejects tool calls combined with real reasoning_effort
for every current OpenAI reasoning model, so GoTM's move to the Responses
API for real reasoning+tools left every OpenAI call unmeasured: instrument()
only ever patched client.chat.completions.create. Adds a second patch for
client.responses.create, plus usage extraction for its distinct
ResponseUsage shape (input_tokens/output_tokens, input_tokens_details for
cache), disambiguated from Anthropic's coincidentally same-named fields so
a Responses-API call can never fall into Anthropic's additive cache-total
formula and silently over-debit the budget.
The Python SDK guide claimed stop reasons propagate up and terminate the
process via nanny run in all cases. That's only true under plain nanny run
(no --serve): --join has no watcher on the child process at all, so an
uncaught NannyStop in a long-lived server's request handler is left to the
host framework's own crash behavior, not something Nanny handles for you.
Adds a matching section to the governance-server guide explaining why, with
a runnable fresh_run()/try-except pattern for containing a stop per request.
@evansibok evansibok changed the title 0.5.0: per-app identity, --join, and CONNECT proxy auth rework v0.5.0 Aug 8, 2026
Three real changes from this branch weren't yet reflected: the CONNECT
proxy auto-injection fix (HTTPS_PROXY was silently a no-op unless set by
hand), the proxy-denial-now-a-real-stop fix, and OpenAI Responses API
instrumentation. All three landed earlier on this branch but predated or
were missed by the original changelog entry. Also bumps the 0.5.0 date to
today, the actual publish date.
…lippy failure

Internal runtime::report_usage took 8 positional args, one over clippy's
too_many_arguments threshold, which CI runs with -D warnings (a hard error,
not the warning the same lint prints locally without that flag). The two
split harness fields were reconstructed from a single Option<Harness> at
the public boundary just to split it again three lines later. Passing the
struct straight through removes both the split and the arg, no behavior
change. Verified with cargo clippy --workspace --all-targets -- -D warnings
(CI's exact invocation) and cargo test --workspace, both clean.
@evansibok evansibok changed the title v0.5.0 feat: 0.5.0 - Fix governance server and major architecture decisions Aug 8, 2026
…auto-merge

Verified before writing this: of the 80 currently-open Dependabot alerts,
only 1 is in sdks/python/uv.lock (the actual shipped SDK); the 3 criticals
and most of the 36 highs are in examples/python/metrics_crew/uv.lock, a
demo app nobody installs. 4 highs are real, in the root Cargo.lock
(quinn-proto, openssl) — unrelated to Python entirely.

- dependency-review.yml: blocks a PR from introducing a new dependency
  with a known critical/high vulnerability (diff-only, PR-triggered).
- security-audit.yml: scheduled (daily) full-state check via the
  Dependabot Alerts API, since a diff check alone can never catch a new
  CVE published against an already-merged, untouched dependency. Hard
  fails on shipped code (root Cargo.lock, sdks/python/uv.lock); reports
  examples/** as non-blocking, since nothing there ships to a user.
- dependabot.yml: weekly version-update PRs, shipped code and examples
  configured and labeled separately.
- dependabot-auto-merge.yml: auto-merges only patch-level, security-driven
  Dependabot PRs, and only after explicitly waiting on and checking every
  CI run itself (gh pr checks --watch), not by relying on GitHub's native
  auto-merge timing. That's deliberate: main currently has no branch
  protection, so auto-merge's default wait-for-required-checks behavior
  would not actually wait for anything. Recommend enabling branch
  protection on main (require ci-rust, ci-python, dependency-review,
  security-audit) regardless — this workflow's own wait covers Dependabot
  PRs specifically, not every other PR.
…ty alerts

quinn-proto (memory exhaustion, RUSTSEC) is confirmed unreachable from any
workspace member on any target (cargo tree -i --target all: nothing to
print) — a stale, unused lockfile entry with zero effect on the compiled
binary either way. Updated anyway to clear the alert; the cascade this
pulled (rand 0.9->0.10, dropping zerocopy/ppv-lite86) is confined to that
same dead subgraph.

openssl (UB in X509Ref::ocsp_responders, real and reachable via
reqwest -> native-tls -> hyper-tls, used by nannyd directly) bumped
0.10.78 -> 0.10.81, a same-minor patch release, no API change.

Both scoped with cargo update -p, not a full workspace update — a first
attempt at a full `cargo update` touched over a dozen unrelated packages
(rand, wasm-bindgen, webpki-roots, zerocopy) and was reverted before
committing anything, in favor of these two narrow, verified fixes.

Verified: cargo build --workspace, cargo test --workspace (118+22+38+5+61+
9+13 passed, 0 failed), cargo clippy --workspace --all-targets -- -D
warnings (CI's exact invocation), all clean.
Emulates a pattern from a sibling project's npm-audit gate, adapted for
this repo's ecosystems since the underlying tool (audit-ci) is npm-only:
kept the Dependabot Alerts API as the data source (consistent, GitHub-
computed severity across ecosystems — raw RUSTSEC advisories often carry
no CVSS score at all, so cargo-audit/pip-audit output alone can't reliably
answer "is this critical/high") and added the missing piece, a reviewed
allowlist with mandatory expiry, keyed by the alert's own stable number
rather than package name so a distinct new advisory against an
already-listed package is never silently covered by an old entry.

- .security-allowlist.jsonc: starts empty — both real shipped-code findings
  (quinn-proto, openssl, previous commit) are fixed outright, not
  allowlisted.
- scripts/check_security_allowlist.py: rejects any entry missing a real
  expiry, with a placeholder/too-short "notes", or expired-but-still-active.
  Tested directly against both a clean and a deliberately-bad allowlist.
- scripts/security_audit_gate.py: filters open critical/high alerts by
  scope (shipped vs examples/), cross-references shipped-scope findings
  against the allowlist, exits 1 on anything uncovered. Verified against
  the real, current alert data for this repo.
- security-audit.yml: wires both scripts in; behavior (shipped hard-fails,
  examples/ reports only) is unchanged, the allowlist is additive.
…-fix lag

security-audit.yml already re-runs on every push to main, not just the
daily cron, so a normal fix was never really waiting a full day. What
remained was GitHub's own alert-index re-scan lag after a merge, which can
briefly leave a just-fixed alert reporting as still open. No API exists to
force that re-scan faster, so this retries the gate itself (4 attempts, 15s
apart) before actually failing — shrinks the visible window from
"potentially until the next scheduled run" to "under a minute," without
masking a real, persistent finding (still fails loudly after all retries
are exhausted).
@evansibok
evansibok merged commit 4c74261 into main Aug 8, 2026
10 checks passed
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