Skip to content

docs/specs: design agent hardening - #61

Draft
sorah wants to merge 42 commits into
mainfrom
hardening/spec
Draft

sorah wants to merge 42 commits into
mainfrom
hardening/spec

Conversation

@sorah

@sorah sorah commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Today any process running as the user can connect to the agent socket and obtain credentials for any logged-in role, and a squatter on the socket can receive OAuth codes from clients. This records the design for closing both as 19 numbered specs under docs/specs/, one per layer of this stack, so implementers can read and execute them in order:

  • 001: threat model, terminology, the trust chain and the roadmap
  • 002: build layout: LTO release builds, a default cli feature so the platform helper binary stays small, CI size reports, and per-architecture macOS releases instead of the universal binary
  • 003-005: mutual client/agent verification anchored in a setgid marker group, with mairu harden and re-exec for non-packaged installs
  • 006: a mairu.platform protobuf contract, served by a mutually verified helper process that runs every platform service
  • 007-008: platform authorization (polkit, with pkttyagent for headless use) with per-session grants and per-exec leases, decided by Cedar policy where a human check is a context condition like MFA in IAM
  • 009: a second gate before vending, with the real caller identity from sts:GetCallerIdentity
  • 010: tool labels such as ai-agent, as a guardrail
  • 011-012: user policy files in $HOME that apply only their forbid policies until approved, and approval anchored in integrity records, with per-server digests
  • 013-016: presence through the SSH agent, notifications and opening URLs, WSL2 profiles, and Windows Hello
  • 017-019: macOS, for a separate implementer: code signatures over an XPC LaunchAgent, harden and the release pipeline, and a helper bundle with LocalAuthentication and keychain integrity stores

Every guarantee is bounded by the path to root, and policy approval is anchored in the protected record so a captured password cannot approve key changes. Open macOS and WSL2 questions are marked Q-M and Q-W.

This is the bottom of a stack; the implementation layers follow after these specs are reviewed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM

sorah and others added 4 commits October 23, 2026 03:51
Today any process running as the user can connect to the agent socket
and obtain credentials for any logged-in role, and a squatter on the
socket can receive OAuth codes from clients. This records the design
for closing both, so the Linux implementation and a separate macOS
implementation can follow one document:

- mutual client/agent verification anchored in a setgid marker group
  (Linux/BSD) or code signatures (macOS), with `mairu harden` and
  re-exec for non-packaged installs
- platform authorization (polkit, LocalAuthentication, or FIDO touch
  through the forwarded ssh-agent) with per-session grants and
  per-exec leases
- integrity-protected authorization policy whose values stay in
  $HOME, recorded in a group-only directory and Secret Service
- a mairu.platform protobuf contract, served in-process or by a
  mutually verified helper, plus notifications and opening URLs
- headless operation, with tmux over SSH as the reference workflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Notifications and URL opening carry no security weight, so they don't
need to be the agent's decision. Clients now emit every event and pick
how to deliver it: their own backend, the agent (and through it the
platform helper), a user-supplied helper command, or nothing. The
choice lives in unprotected config.json, environment and flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pipe-pane -O and capture-pane only see pane output, and password
prompts disable echo, so they never see the password. The real paths
are input-side: reading the user-owned pty, tmux key bindings set
through the server socket, fake prompts, and ptrace when Yama allows
it. The same applies to sudo in the same pane, which uses the same
login password, so sudo escalation is now stated as out of scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A homegrown rule format would be one more thing to learn. Cedar keeps
IAM's evaluation model (default deny, forbid overrides permit), comes
with schema validation and tooling, and is a Rust crate.

A human check becomes context.presence, much like MFA conditions in
IAM: the agent asks only when some fresh presence would make the
policy permit the request, and remembered grants and leases are just
other candidate presences. Unapproved files contribute only their
forbid policies, so they can only tighten. Settings that aren't
policies move to authorization.json. The schema and every policy in
this spec were validated with cedar-policy 4.14.0 in strict mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sorah and others added 25 commits October 23, 2026 04:20
Every anchor on a hardened host is root-owned, so the design is only
as strong as the user's path to root: sudo and polkit passwords,
NOPASSWD, root-equivalent groups, and on WSL2 `wsl.exe -u root` via
interop. Spelling that out exposed a gap: approval fell back to
whatever methods were in effect, so a captured password could approve
a policy registering someone else's key. Approval now uses only the
anchor stored with the last approved integrity record.

WSL2 gets integrated (interop on, Windows Hello via a KeyCredential
helper, a guardrail only) and isolated (interop off, ssh-agent over
`ssh -A`, native-Linux strength) profiles, with open questions that
need a WSL2 host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The combined spec mixed three concerns with different reviewers and
lifecycles:

- hardening.md: threat model, trust chain, marker group, `mairu
  harden`, client/agent mutual verification, WSL2 profiles, macOS
  signing, and the stack order for all layers
- policy.md: when the agent asks, grants and leases, presence
  methods, the Cedar policy, integrity states and the approval anchor
- platform-helper.md: the Platform trait, mairu.platform protocol,
  helper mutual verification, polkit, integrity stores,
  notifications/URLs, headless and macOS backends

Content moved as is, with section references rewritten to point to
the right file. This also repairs the digest definition, which the
previous commit had mangled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each numbered section of the three specs now opens with a short
show-me style visual: pseudocode, call trees, file trees, Mermaid
sequence, flow and state diagrams, or a diff. They summarize the
section for a first read; the existing text is untouched and stays
normative. Every Mermaid block parses with mermaid 11.4.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeping platform-service code out of the process that holds session
tokens shrinks what a bug in D-Bus, Secret Service, portal or
LocalAuthentication handling can reach, and having one IPC path on
every OS removes in-process/IPC branching.

- the Platform trait splits into ProcessPlatform (peer credentials,
  process facts; in-process everywhere) and PlatformServices (the
  PlatformHelper contract; only ever HelperClient in the agent)
- the helper shares the agent's lifetime over an inherited socketpair:
  no filesystem rendezvous, parent checked by the helper, child
  trusted by construction, EOF ends it
- clients make one connection and reach services only through agent
  RPCs; convenience modes are auto|agent|command|none, with a
  SessionHint so the helper can reach the client's desktop session
- macOS uses the same binary as its helper and plans no app bundle
- the helper layer moves right after install in the stack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Official builds will carry an Apple Developer ID, so macOS can rely on
a team-based identity that is stable across versions instead of
pinning cdhashes and root-owned copies. Builds without one still need
to work, for packagers and for contributors outside the developer
program.

- hardening §9: official, third-party and ad-hoc tiers, decided at
  run time from the binary's own signature; a security floor in a
  signed embedded Info.plist against downgrades to old releases; the
  release pipeline; harden for ad-hoc builds; Homebrew left open
  pending research
- hardening §15: dev profile isolating contributor builds from an
  installed mairu, Linux and macOS contributor workflows without the
  developer program, CI jobs, and maintainer checks before release
- platform-helper §11: embedded Developer ID notifier for official
  builds, locally assembled ad-hoc notifier otherwise; keychain store
  per tier

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Homebrew only re-signs Mach-O files it rewrites, casks are never
re-signed, and homebrew/cask now disables casks failing Gatekeeper,
which a notarized build passes. A cask in the tap is therefore the
proposed shape for official builds, with a from-source formula under
another name; the choice stays open until the tap changes.

The research also showed ways the notifier could break: Gatekeeper
rejects modified notarized bundles, App Management can block another
team from touching one, a quarantined parent can quarantine what it
writes, and `open -a` resolves by name. Extraction now uses a fresh
directory per version and flavour, strips quarantine, checks the
designated requirement, launches by full path, and uses a bundle id
per flavour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Homebrew gets a cask for the signed release binary, with the
from-source formula renamed to mairu-src. aqua, mise and ubi keep
installing the release tarball unchanged: the binary carries the
notifier and extracts it itself, so installers need no bundle
awareness.

Those installers run versions side by side, so removing notifiers of
other versions could break a version still in use; cleanup now
removes notifiers that went unused for 30 days instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Official-only properties are checked by a maintainer as a releng
checklist step before publishing, and pull requests touching the
signing pipeline get a manually triggered dry run on their branch,
since forks never receive the release secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Floor" did not say what is being bounded. The value is the oldest
mairu a peer will talk to, so name it minimum-client-version
(Cargo.toml metadata) and MairuMinimumClientVersion (signed
Info.plist). It is raised by releng only in releases containing a
security fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A flaw confined to the agent's own handling is fixed by upgrading the
running agent and needs no lockout of older clients; only client-side
and protocol-level flaws justify refusing older peers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The built-in policy capped every presence at 12 h, so a long-running
`mairu exec` would hit a prompt on its next credential refresh and
fail when unattended. Leases now last the process lifetime under
policy control, but lapse after the process goes 1 h without
credentials, counted from the expiry of the last ones it obtained on
demand.

The sidecar's auto-refresh pre-fetches regardless of use, so it marks
those requests; they never move the idle clock and never raise a
prompt, returning PRESENCE_REQUIRED instead. IDE callers keep the
5-minute idle by default, with a documented policy to relax it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Splitting the helper by session-bus exposure was considered and
declined in favour of the simpler process model. The residual risk
(session-bus input sharing a process with authorization answers) is
recorded in the security table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
What an SSH agent requires before signing (a touch, a confirmation,
Touch ID, or nothing) is invisible to mairu, so insisting on FIDO keys
judged something mairu cannot know. Any registered key type is now
accepted: registering a key states that its use needs the user's
approval, and choosing such a key is the user's responsibility.
sk- keys still have their user-present flag verified, since that is
the one lock visible in a signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Measured on a Windows 11 25H2 host:

- `wsl.exe -u root` gives root without a prompt from inside a distro
  with interop on, and from any Windows process or interop-enabled
  distro regardless of the target's setting, so the isolated profile
  only protects against code inside that distro
- a Hyper-V socket channel needs an admin-registered service GUID and
  carries no peer credentials, so it is not the default
- WSLg has no Linux browser, xdg-open or notification daemon, so
  `open_url` uses rundll32 url.dll,FileProtocolHandler (explorer.exe
  fallback, whose exit status lies) and `auto` honors $BROWSER

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Listing other distributions needs interop, which an isolated
distribution lacks, so doctor states the limit instead of checking
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Measured on macOS 27.2:

- LOCAL_PEERTOKEN and LOCAL_PEERPID follow the last process to do
  I/O, unlike Linux SO_PEERCRED; lending a connected fd to a genuine
  signed mairu makes its token stand in for the attacker, so one check
  per connection is unsound. Q-M10 opens the transport question:
  re-check per read, or XPC with per-message audit tokens.
- The legacy keychain gives no integrity: same-user code deleted an
  item and planted a forged one trusting only mairu, read without a
  prompt. The keychain store is now weak, and strong integrity needs
  the protected directory through harden; the data-protection
  keychain needs an entitlement an unbundled binary lacks.
- Socketpairs report the creator until the child does I/O (Q-M1),
  and notifier results so far are interim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
LOCAL_PEERTOKEN reports whichever process last did I/O on the socket,
so a genuine signed mairu lent an attacker's connected fd passes the
check. Re-checking after every read only narrows that to a race.

XPC stamps every message with its sender's audit token, so macOS
clients talk to the agent through a per-user LaunchAgent Mach service
instead, verified on every message. Running the agent in gui/<uid>
also puts LocalAuthentication prompts in the GUI session (Q-M2). How
SSH-only logins reach the service is left open as Q-M11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
The legacy keychain gives no integrity: same-user code can delete the
record and plant a forged one that mairu accepts. Counting it as
approval would let that code relax a policy which signed macOS builds
otherwise enforce.

Every tier can now get the protected directory through `sudo mairu
harden`, as on Linux. Developer ID builds additionally run the
platform helper from a signed bundle carrying keychain-access-groups,
so the record lives in the data-protection keychain, where only the
team's entitled code can touch it, without root.

With two strong stores, records gain a generation so a store that
missed an approval is brought up to date rather than read as tampered.
Weak stores approve only on unhardened agents. Recovery on macOS goes
through an administrator authorization, since root cannot reach the
data-protection keychain item.

Also record the last WSL2 findings: Windows Hello signatures are RSA
PKCS #1 v1.5 with SHA-256 (with a test vector), and Windows OpenSSH's
FIDO support does not offer Windows Hello as an authenticator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Windows Hello is offered to ssh-keygen once the setting to save
passkeys to this device is on, and the platform credential is created.
OpenSSH_for_Windows 9.5p2 then rejects its self-attestation, the same
failure as a third-party passkey provider, so the isolated WSL2
profile still has no Windows Hello.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
From the unlocked re-run on macOS 27.2:

- A Background job in user/<uid> reaches a gui/<uid> Mach service
  while a console session exists, so clients bootstrap the agent into
  gui/<uid> and fall back to user/<uid> only without a GUI login.
- LocalAuthentication shows its sheet on the console from either
  domain, but the administrator dialog needs gui/<uid>.
- Ad-hoc notifiers start out denied with no prompt and must be turned
  on in System Settings; the permission is keyed by bundle id, so it
  survives rebuilds. Name the flavours apart and ship an icon.
- Secure Enclave key blobs can be used by any binary, so they would
  prove only that someone was at the Mac; they are not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Same-user code can start any genuine binary, including an old official
agent with an agent-side flaw, and current clients would accept it as
long as it met the minimum client version. Raising that value for
agent-side flaws would also lock out older clients that were never at
fault, so clients get their own minimum for the agent instead.

Also document SSH agents that ask for Windows Hello before signing as
an option for isolated WSL2, and have the Homebrew cask refresh an
existing hardened copy on upgrade, without failing the upgrade when
sudo does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
With root and a real SSH session on macOS 27.2:

- A setgid exec by a user outside the group makes a restricted
  process (P_SUGID, DYLD_* stripped, task_for_pid refused), so the
  marker group carries over (Q-M9). Ad-hoc re-signing is
  deterministic, so the harden copy adds no identity of its own.
- From SSH, clients reach and bootstrap the gui/<uid> LaunchAgent
  while a console login exists (Q-M11); the administrator dialog
  fails with -60007, and the notifier shows on the console.
- LocalAuthentication shows its sheet on the console for requests
  from SSH as well.

Such requests keep the platform sheet by default, with the origin in
the reason text. The agent derives it from the peer's audit session
and exposes it as context.console_session, so a policy can forbid it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
No unmanaged Mac and no Developer ID are available yet. Q-M11 without
a console login, Q-M3's MDM question, and the official-build checks
(Q-M12, the Developer ID notifier, harden on a notarized quarantined
binary) are deferred. The design does not hinge on them: the client
falls back to user/<uid> whenever gui/<uid> cannot be bootstrapped,
and a helper bundle that does not work falls back to harden alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
The notifier was kept apart from the helper bundle on the assumption
that notifications need a LaunchServices launch, which cannot pass
the helper's socketpair. That was never tested: what is confirmed is
only that the poster must be a signed bundle's main executable.

If a directly exec'd bundle can post notifications, the notifier is
merged into the helper bundle, and ad-hoc builds assemble an
unentitled helper bundle so the helper is always a bundle on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
A directly exec'd bundle executable can use UNUserNotificationCenter
once the bundle is registered with `lsregister -f`; the separate
notifier existed only because the earlier tests launched it through
`open`, which registers as a side effect. The helper bundle now posts
notifications itself, so macOS has one bundle per flavour.

Builds without an embedded bundle assemble an unentitled one from
their own binary, recording the agent's cdhash in the signed
Info.plist so the helper can verify its parent, and the agent checks
the running child against the cdhash it expects. A notification click
starts the bundle without --fd; that instance exits at once.

Also pin down the extraction rules: per-version directories (plus the
agent's cdhash for assembled bundles), a separate base directory for
the dev profile, a tar archive embedded in the signed binary, the
agent writing the bundle at startup and `harden` writing its own
root-owned copy, and how concurrent writers settle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
sorah and others added 13 commits October 24, 2026 17:03
The marker was looked up by name and compared with the effective gid.
Taking it from what this exec's setgid bit actually granted, confirmed
by a reverse lookup, keeps another NSS source's `mairu` group from
shadowing it, and AT_SECURE keeps an inherited effective gid from
counting. Gids 0 and 65534 are never a marker.

A password on the group would let anyone who knows it `newgrp mairu`
and hold the marker as real and effective gid, which both passes the
peer check and opens the protected directory. `harden` removes such a
password, and the agent, clients and platform helper reject peers
whose real gid is the marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
A one-time suggestion is easy to miss and never comes back. Show it
again at most once per terminal session, every 7 days by default
(`harden_suggestion.interval_days`, 0 for every session), unless
turned off through config.json or MAIRU_NO_HARDEN_SUGGESTION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
A week between reminders is long enough to forget the suggestion
entirely. Default to one hour (`harden_suggestion.interval_secs`),
counted across all terminals; 0 keeps the once-per-terminal-session
mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
The detector already matched process ancestry and environment names;
only its output was hard-wired to "AI agent". Requests now carry
labels in context.tools, configured per label in tool_detection, with
`ai-agent` as the built-in one that the built-in policies use.

Labels are a guardrail: they can be evaded and forged, and policies
may use them to restrict or to grant knowing that. Humans can also
mark a terminal session with `mairu lock --tool <label>`; the mark
follows the ancestry into tools' tty-less sessions, and removing it
early always asks the human, since the marked tool could otherwise do
so itself.

The hardening/ai-agents layer becomes hardening/tool-detection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Before fetching, the agent knows only the server and the role name,
and a custom server's role names need not say which account they
open. Authorization now runs twice: gate 1 (AssumeRole) as before,
so a refused request fetches nothing, and gate 2 (UseCredentials)
once the credentials are in hand, with sts:GetCallerIdentity cached
alongside them and the role's Account parent set to the real account.

Gate 2 runs the full decision procedure, so a forbid that a fresh
check would satisfy makes the agent ask then, showing the real
identity. Always-on built-ins keep gate 2 open by default and refuse
credentials whose account differs from the one the role name
declares. The STS endpoint comes only from approved settings, never
from the environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Policies name credential servers by id, but servers.d was not
protected: same-user code could point an approved `dev` at the
production credential server and wait for the next routine login,
after which dev's relaxed rules would apply to production.

Each server's identity fields are now digested into the approved
record. A request uses the stricter of the policy's state and its
server's: a server not yet approved gets only built-ins and forbids,
and an approved server whose config changed is locked down, without
affecting the others. The config checked is the one captured in the
session at login, since that is what fetches credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Implementers read and execute the specs in order, one per gh-stack
layer, so the three topic specs become 19 numbered ones following the
spec template. 001 is the shared threat model and roadmap, 002 to 016
are the Linux layers, and 017 to 019 are for the macOS implementer.

- 002-build-layout is new: LTO and stripped release builds, a default
  `cli` feature gating client and agent code so the platform helper
  binary stays small, CI size reports, and per-architecture macOS
  releases instead of the universal binary
- gate 2, policy approval and Windows Hello become their own layers,
  and notify moves before wsl2
- diagrams are Mermaid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Diagrams translated literally from the ASCII summaries mostly listed
items instead of showing a flow. Each one is now redrawn to fit
GitHub's page width, replaced with a table where it was a mapping, or
dropped where the text right after it already says the same.

- the hardened copy lives under a configurable harden home (`--home`,
  `MAIRU_HARDEN_HOME`, `harden_home`): `/usr/local` on Linux, and
  `/opt/mairu` on macOS, where Homebrew makes `/usr/local` writable
  by the user on Intel Macs
- Linux release binaries embed the Windows Hello helper for the
  matching architecture and write it out on first use

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
mise, aqua, ubi with --extract-all, Homebrew casks and distribution
packages all keep files next to the binary, so release archives carry
the macOS helper bundle and the Windows Hello helper beside mairu
instead of embedding them. Builds from source ship none and keep the
fallbacks they needed anyway: an assembled bundle on macOS, a
configured path on WSL2. This drops the helper from every binary and
the build-order dependency in the release workflow.

- 002 defines the lookup: next to the canonical executable, then
  ../lib/mairu/ for packages; harden copies the helpers it finds
- the Windows Hello helper is pinned by SHA-256 (recorded by release
  builds, or approved in authorization.json), since a replaced helper
  could have the human's gesture sign another statement
- Q-M14 and Q-W6 open: registering bundles from installers'
  directories, and running the Windows helper from the Linux
  filesystem
- 018: standalone ubi overwrites in place rather than keeping a
  directory per version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
A security audit of the numbered specs found ways for same-uid code to
defeat the design that the specs did not cover:

- the agent built AWS clients from ~/.aws/config and AWS_* variables,
  honoured HTTPS_PROXY, and rustls-native-certs reads SSL_CERT_FILE
  even under AT_SECURE: a hardened agent now clears its environment to
  an allowlist, builds AWS clients explicitly, uses no proxy and the
  system roots; approved settings may add a proxy and roots (004, 009,
  011, 017)
- one approval obtained under false pretences could install an
  attacker's approval key: approval needs a terminal and a typed
  confirmation code, and an anchor change needs a second check and
  waits 24 hours, cancellable (012, 013, 019)
- upgrades told users to run user-writable binaries with sudo: the
  installed hardened copy now verifies a sigstore attestation of the
  new binary and copies it without running it (002, 005, 018)
- fs.suid_dumpable=1 left hardened processes traceable (003)
- one platform prompt was shared by different requests with the same
  grant key; coalescing is now per peer and request (007)
- older hardened copies counted as genuine on Linux: minimum client
  and agent versions, and an inventory of hardened copies (004, 005)
- a repointed server was locked down rather than refused (012)

Also recorded: helper-held keys do not close the integrated WSL2 gap,
since root reached through interop is the same Windows user (015, 001).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
- 017 still claimed old hardened copies are unreachable on Linux,
  which the audit disproved; it now points at the Linux minimum
  versions of 004
- the rule that agent connections are O_CLOEXEC and closed before the
  executor runs the user's command was only a threat-table row; it is
  now part of the executor's rules in 003 and the 004 checklist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
A code typed back from the terminal binds nothing that same-user code
cannot reproduce in its own pseudo-terminal. Q-A1 records the
alternatives under exploration, number matching through the platform
prompt among them, until a design is settled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
Approval now works like `mairu exec --confirm-trust`: the client shows
both files, the summary and the approval digest, and asks before any
presence prompt appears (or takes --confirm <digest> for scripts). A
reflexive touch has nothing to approve, and with one approval at a
time, approval and credential checks are never pending together.

The typed confirmation code, the second check and the 24-hour delay
on approval-key changes are dropped: the code was reproducible by
same-user code, and the delay made every key change wait a day with
little left to protect against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dg17LE9aWqsb1VBUCVcKLM
@sorah
sorah added this pull request to stack #63 October 30, 2026 21:06

This branch has not been deployed

No deployments
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