v0.6.0
Breaking
- Native subprocess capsules refuse to launch when the OS-level sandbox is unavailable (security, fixes #655). Previously, when
bwrapfailed its user-namespace probe — most commonly on Ubuntu 24.04+ wherekernel.apparmor_restrict_unprivileged_userns=1ships enabled — Astrid silently fell through to an unsandboxed launch with a singletracing::warn!line as the only signal. This contradicted the README's "subprocess capsules are sandboxed" promise: a Node.js MCP server (or OpenClaw Tier 2 plugin) could read~/.ssh/id_rsa, write to~/.bashrc, or punch through the~/.astridtmpfs overlay without any capability check firing. The new default policy isRequired:ProcessSandboxConfig::sandbox_prefix()returns an actionableErrinstead ofOk(None)when the sandbox can't be applied, and the MCP server-startup path propagates the error so the daemon refuses to launch the subprocess. The only escape hatch isASTRID_SANDBOX_POLICY=off, which silently launches without a sandbox — for trusted dev environments and CI runners where the kernel can't be configured. There is no "warn and fall through" middle state: a soft fallback hides the security gap and that is the bug. The error message names the sysctl remediation (sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0) and the env-var escape hatch directly. Breaking for any Ubuntu 24.04+ install that was tacitly relying on the silent fallback — those deployments now need to either flip the sysctl (recommended) or setASTRID_SANDBOX_POLICY=off(only if the sandbox bypass is intentional). caps grant <agent> "*"now requires--unsafe-adminacknowledgement. Mirrors the long-standing rail ongroup create --caps "*". Without it, the group-level safety check was trivially bypassable: instead of creating a custom admin-equivalent group (kernel rejects withoutunsafe_admin = true), an operator couldcaps grant bob "*"and silently promotebobto universal admin via direct grant. Layer 6'smutate_capsnow refuses anyGrantthat includes the literal bare*capability unlessunsafe_adminis true on the request; the CLI surfaces this ascaps grant bob "*" --unsafe-adminand rejects the call client-side before any IPC round-trip. Multi-segment wildcards (network:egress:*,self:capsule:*) are unaffected — they're inherently scoped, the gate only triggers on the universal pattern. Breaking for automation: the newAdminRequestKind::CapsGrant.unsafe_admin: boolfield defaults tofalsevia#[serde(default)]so wire-format clients that omit it land on the safe side, but any tooling that previously assumed a bare*grant would succeed must now passunsafe_admin = true(or set--unsafe-adminon the CLI).PrincipalProfilefiles moved out of the principal home directory. Per-principalprofile.tomlnow lives at~/.astrid/etc/profiles/{principal}.tomlinstead of~/.astrid/home/{principal}/.config/profile.toml. Profile contents are 100% system policy (enabled, groups, grants, revokes, quotas, auth public keys, egress, process allowlist) — keeping them inside the principal's home directory let any capsule withfs_read = ["home://"]read its own policy file (andfs_writewould have let it self-elevate). The new location sits outside thehome://VFS scheme entirely.PrincipalProfile::path_for(&PrincipalHome)is nowPrincipalProfile::path_for(&AstridHome, &PrincipalId); same forload/save. A one-shot migration inseed_default_principal_admin_profilemoves any legacyhome/{principal}/.config/profile.tomlto the new location on next boot. (#672)AdminKernelRequestandAdminKernelResponseare now wrapper structs with the typed body on akind/bodyfield, plus an optionalrequest_idfor client-side correlation. Pre-existing test fixtures usingAdminKernelRequest::AgentCreate { ... }should construct the variant onAdminRequestKindand convert (AdminRequestKind::AgentCreate { ... }.into()orAdminKernelRequest::new(...)). The wire format is forward-compatible:request_idis omitted whenNone. (#672)AuditAction::AdminRequestgained aparams: Option<serde_json::Value>field. Forward-compatible (#[serde(default)]+skip_serializing_if), but external consumers parsing audit entries with strict schemas may need to add the field. Capture is for forensic replay (issue #672). (#672)PermissionErrorgained aPrincipalDisabledvariant thrown by the Layer 5 enforcement preamble when the caller's profile hasenabled = false. Existingmatchblocks against the enum need a new arm. (#672)Kernel.groupsfield type changed fromArc<GroupConfig>toArc<ArcSwap<GroupConfig>>(issue #672 — Layer 6). The boot-loaded group config is now hot-reloadable through the admin IPC topics (astrid.v1.admin.group.*); every authorization check clones the currentArcviaload_full()on each request. Enforcement preambles hold their ownArcsnapshot per check, so in-flight checks observe a consistent config even during a swap. Any direct consumer matching onArc<GroupConfig>must migrate tokernel.groups.load_full().- Built-in
agentgroup gainsself:quota:getandself:agent:listcapabilities (issue #672).self:*already subsumed both, but operators inspecting or matching on the exact capability vector see a minor widening. This makes agent self-service visibility into their own row and quotas an explicit contract rather than an incidental consequence ofself:*. - Default principal's profile now carries
groups = ["admin"]after boot (issue #670).bootstrap_cli_root_userwritesgroups = ["admin"]to~/.astrid/home/default/.config/profile.tomlon any boot where the profile is absent or hasgroups=[] && grants=[] && revokes=[]. Operators who previously ran with an explicitly emptygroupslist — and no grants/revokes — will see the default principal gain full management-API capabilities on the next boot. Either edit the profile to add a non-admin group (e.g.restricted), or add an explicitgrants/revokesentry, to block the auto-seed. Profiles that already name any group, grant, or revoke are left untouched. (#670) CapabilityTokensigning format bumped v1 → v2 withprincipalsigned into the payload (Layer 4 multi-tenancy, issue #668). Existing v1 persistent tokens on disk failverify_signature()after upgrade and get rejected withInvalidSignatureplus atracing::error!pointing operators at re-mint. There is no silent migration path — changing the signing payload is a cryptographic break, not a data migration.CapabilityToken::createandcreate_with_optionsnow take a requiredprincipal: PrincipalIdargument. (#668)Allowance.principal: PrincipalIdis now required at construction.AllowanceStorekeys on(principal, id);find_matching,find_matching_and_consume,consume_use,export_session_allowances,export_workspace_allowances, andclear_session_allowancesnow take&PrincipalId. A newclear_all_session_allowancesretains the global sweep for kernel-initiated shutdown. (#668)CapabilityStore::has_capability/find_capabilitynow take&PrincipalId. Tokens whoseprincipaldoes not match the caller are rejected up front, even if the resource pattern matches — fail-closed cross-principal check. Revocation and single-use consumption stay global (they are about the token's identity, not the caller). Persistent KV keys changed fromcaps:tokens/{token_id}tocaps:tokens/{principal}/{token_id}.CapabilityValidator::checkandvalidate_by_idalso thread&PrincipalId. (#668)Kernel.active_connectionsis now per-principal (DashMap<PrincipalId, AtomicUsize>).connection_opened(&PrincipalId)/connection_closed(&PrincipalId)take the connecting principal; only that principal's session allowances are cleared on last-disconnect. Newtotal_connection_count()sums across principals for ephemeral-shutdown. (#668)Kernel.overlay_vfsreplaced byKernel.overlay_registry: Arc<OverlayVfsRegistry>. Each invoking principal resolves their ownOverlayVfson first use; Agent A's workspace writes never reach Agent B's view of the same tree. The registry is bounded (default 1024 principals, tunable viaASTRID_OVERLAY_REGISTRY_MAX_PRINCIPALS) with idle-eviction.Kernel.vfsnow points at a plain workspaceHostVfs— kernel-internal paths that do not know a principal keep using that field. (#668)SecurityInterceptor::interceptandApprovalManager::check_approvaltake&PrincipalId. Single-tenant callers passPrincipalId::default(). (#668)ApprovalDecision::ApproveWithAllowancenow boxesAllowance(Box<Allowance>) — the addedprincipalfield pushed the enum past clippy'slarge_enum_variantthreshold. (#668)- WASM engine migrated from Extism to wasmtime Component Model. The kernel now loads Component Model binaries via
Component::from_binary, not Extism modules. Existing capsules compiled withextism-pdkwill not load — they must be rebuilt with the migrated SDK targetingwasm32-wasip2. This is a coordinated multi-repo migration (SDK + 16 capsule repos). (#632) - WIT host function signatures retyped. All 49 functions now use proper typed params/returns (
result<T, string>, WIT records,u64handles) instead ofstring-based JSON blobs. TheHostResult0x00/0x01 prefix encoding is removed — errors are returned via WITresulttypes. (#632) - Guest export
astrid-hook-triggersignature changed. Wasfunc(input: list<u8>) -> list<u8>. Nowfunc(action: string, payload: list<u8>) -> capsule-result. The action name and payload are separate typed parameters; the return is the typedcapsule-resultrecord. (#632) capsule_abimodule removed fromastrid-core. Types (CapsuleAbiContext,CapsuleAbiResult,LogLevel, etc.) are replaced bywasmtime::component::bindgen!generated types. (#632)- Approval API simplified.
risk-levelremoved fromapproval-requestWIT record.decisionremoved fromapproval-response. Capsules declare action + resource, get back approved/denied. Risk classification was speculative complexity — the kernel manages allowance-based approval without risk levels. (#641)
Added
-
astrid -p ... --session <ID_OR_NAME>accepts either a UUID or a name. A string that parses as a UUID is used as-is for resume; anything else is treated as a stable session name and hashed via UUID v5 (NAMESPACE_URL). Operators can copy the UUID printed by--print-sessionstraight into--session <that UUID>for the next turn and round-trip works, matching thecargo/gh/claudeconvention on the same flag. Help text uses value nameID_OR_NAME. -
agent createinheritsdefault's per-principal state out-of-the-box. After the home tree provisions, the kernel handler copies env JSON (~/.astrid/home/default/.config/env/<capsule>.env.json→home/<new>/.config/env/...), per-capsule KV namespaces (default:capsule:<id>:*→<new>:capsule:<id>:*), and per-capsule secret files (secrets/default/<capsule>/<key>→secrets/<new>/<capsule>/<key>) for everyenv_type = "secret"field every installed capsule declares. The new agent reaches the chat REPL with the same model selection, base URLs, and credentials the operator already configured for default — no freshastrid secret setfor every key on every new agent. Best-effort: a copy failure logswarnand leaves the agent in a "needs manual setup" state, but doesn't roll back the profile or home tree (those already succeeded; the confidentiality boundary is intact regardless). The registry read lock is held only while extracting capsule IDs + per-capsule secret-key lists — the async KV copy and blocking file-system copy run after the lock is dropped so concurrent install/update/remove isn't blocked for the duration of the inheritance. -
agent createrich provisioning flags now chain end-to-end (no new IPC required).--group,--memory,--timeout,--storage,--processes,--egress,--process-allowused to all bail with "needs kernel-side IPC that has not shipped" and fall back to a copy-pasteable three-line script. The flags now compose using the existing admin handlers:--grouppasses through toAgentCreate.groups; the quota flags fetch the new agent's defaultQuotasand write them back via a singleQuotaSet;--egress/--process-allowtranslate tonetwork:egress:<label>andprocess:spawn:<cmd>capability patterns. All inputs (quota specs, capability labels) are validated client-side before any IPC and the capability grants ride along onAgentCreate.grantsso a malformed pattern can't leave a half-provisioned agent on disk.--bare,--distro(non-default), and--linkstill bail with a clear "needs distro / admin.agent.link IPC" message — those genuinely need new admin topics. Tested live:agent create alice --group admin --memory 32M --timeout 60s --storage 512M --processes 4 --egress openai,internal --process-allow git,npmlands all four quota fields, four capability grants, and the admin group membership atomically. -
agent modify --add-group / --remove-groupnow actually works. Previously the CLI parsed the flags but emitted "needsadmin.agent.modifyIPC that has not shipped" and bailed at exit 2; operators worked around it by hand-editingetc/profiles/{p}.tomland rebooting the daemon. The newAdminRequestKind::AgentModify { principal, add_groups, remove_groups }variant ships the partial-update path end-to-end: per-group idempotent (set-based comparison so a no-op modify reportschanged = falseregardless of Vec ordering), profile-validate before save, cache-invalidate after, audit-logged asadmin.agent.modify. Empty (no flags) is rejected with a clear error so a script that forgot to populate either list can't silently no-op. -
Cargo-like manifest schema (forward-compatible parser side).
Capsule.tomlnow accepts the new[publish]/[subscribe]tables, the flat[exports]/[imports]form with quoted"namespace:interface"keys, and[[tool]]blocks with the operator-reviewabledescription_for_llmfield — all per the cargo-like-manifest RFC. Each[publish]/[subscribe]entry carries a requiredwit = "@scope/repo/iface/record"(or bare-name self-reference) plus optionalversion/tag/rev/branch/pathsource pinning andhandler = "..."to bind to a#[astrid::interceptor("...")]export. Keys in those tables also serve as the IPC publish/subscribe ACL — when present, they supersede[capabilities].ipc_publish/ipc_subscribe. The legacy nested[exports.<ns>] foo = "1.0"form, the[[topic]]blocks, the[[interceptor]]blocks, and the[capabilities].ipc_publish/ipc_subscribearrays continue to parse and behave exactly as before — both forms coexist during the migration window. Resolver, registry index, and lockfile (BLAKE3 verification) plumbing land in a follow-up; today the parser storeswitrefs as strings without resolving them. The parser rejects entries that set more than one ofversion/tag/rev/branch/pathat deserialize time so ambiguous manifests fail fast at install instead of producing inconsistent resolver behaviour. -
EnvScope { Agent, Shared }enum onEnvDef. Operator-only sharing model forenv_type = "secret"env vars: per-agent (default, fail-closed) or host-wide (shared, kernel falls through on per-agent miss). The capsule manifest does NOT declare scope — capsules come from external sources and cannot be trusted to mark their own credentials as host-shared (a malicious capsule could otherwise mark its bot token "shared" and inherit every agent's invocation budget). The operator decides scope atastrid secret set --scopetime. Additive: the consuming routing change ships in a separate PR. -
astrid setupsubcommand + bundledAppArmorprofile (follow-up to #655). Shipsdist/apparmor/astrid— a narrowflags=(unconfined)profile whose only addition is theuserns,rule — and anastrid setupsubcommand that diagnoses the Linux sandbox prerequisites and prints the exact commands (or runs them viasudowith--apply) to install the profile at/etc/apparmor.d/astrid. With the profile loaded,bwrapregains the ability to callunshare(CLONE_NEWUSER)on Ubuntu 23.10+ / 24.04 hosts that keepkernel.apparmor_restrict_unprivileged_userns=1enabled, so native subprocess capsules (MCP servers, OpenClaw Tier 2 plugins) launch under the defaultSandboxPolicy::Requiredwithout the operator having to flip a kernel-wide sysctl (which would weaken every other unprivileged process on the host, not just Astrid).astrid setup --print-apparmoremits the profile alone — distro packagers can pipe it straight into a.deb/.rpmbuild without invoking the diagnostic path. The subcommand is a no-op on non-Linux hosts and on Linux hosts where the sandbox probe already passes. -
ipc-publish-ashost function + uplink principal propagation. New WIT entry (astrid:host/ipc.ipc-publish-as) and SDK wrappers (publish_as/publish_json_as) stamp the outgoing IPC envelope with a caller-supplied principal instead of the capsule's own. Used by uplinks (CLI proxy, future Telegram/Discord bridges) to relay external client traffic while preserving the operator's claimed identity through to the kernel's caller resolution and Layer 5/6 capability enforcement. Gated host-side onuplink = trueinCapsule.toml [capabilities](the gate usesmanifest.capabilities.uplink— the matchinghas_uplink_capabilityfield inHostStatewas previously bound to!manifest.uplinks.is_empty(), which is the unrelated[[uplink]]declaration list). Before this, every uplink-forwarded message landed at the kernel asprincipal = <uplink's principal>andastrid agent switchwas purely cosmetic — admin commands asaliceexecuted asdefaultand bypassed RBAC. The IPC quota bucket is also keyed by the relayed principal so two principals routed through the same uplink cannot starve each other's per-tenant budget. Trust-the-uplink model: the kernel does not verify the uplink authenticated the claimed principal — same trust level as today's "anyone with the daemon socket token can act as default-admin", just extended to the per-principal axis. Cryptographic per-connection auth lives in #658. -
CLI redesign — modern noun-verb structure with multi-tenancy admin surface (issue #657).
- The CLI is now modelled after
ghandfly:astrid agent <verb>,astrid capsule <verb>,astrid quota <verb>, etc. - System-level operations (
status,start,stop,restart,ps,top,who,logs) stay as bare verbs for speed. astridwith no subcommand still drops the operator into an interactive agent session — the unchanged self-hosting path.- Phase 1 covers the admin-surface bulk unblocked by Layer 6 (#672); sub-agent delegation, vouchers, audit, trust, budget, and remote A2A surfaces are registered in the clap tree but stubbed with tracking-issue references (
#656,#658,#675,#653) and exit code 2 so CI scripts can pattern-match. - New verbs implemented end-to-end against the Layer 6 admin IPC:
agent create / list / show / delete / enable / disable / switch / current,group create / list / show / delete / modify,caps show / grant / revoke / check,quota show / set,secret set / list / delete,capsule install / update / list / remove / tree / build / config / show,distro apply,gc,restart,logs,doctor,version,completions,update,ps,top,who. - Every list/show command supports
--format pretty|json|yaml|toml. - Per-agent commands accept
--agent <name>/-ato override the active context; without the flag they fall back to~/.astrid/run/cli-context.tomlwritten byastrid agent switch. - Migration aliases retained:
session info→session show,self-update→update,wit gc→gc, top-levelbuild→capsule build. - New
admin_clientmodule wrapsSocketClientwithrequest_idcorrelation (UUID v4 per call, echoed onastrid.v1.admin.response.<topic>) so multiple in-flight admin commands can disambiguate responses, and tolerates broadcast frames (astrid.v1.capsules_loaded) whose payloads do not round-trip throughIpcPayload::RawJson's tag. - New
read_until_topichelper onSocketClientpropagates the same skip-and-retry pattern to the bare verbs. (#657)
- The CLI is now modelled after
-
PrincipalProfile.enabledis now enforced by the Layer 5 management-API preamble. Pre-Layer-6 the flag was set on disk byagent.disablebut never consulted byauthorize_request— operators who disabled an agent saw the flag persist while the agent kept passing authz checks. The preamble now resolves the caller's profile, and ifenabled = falsereturns the newPermissionError::PrincipalDisabledvariant before the capability check. (#672) -
PrincipalProfile.enabledis also now enforced at Layer 3 (WasmEngine::invoke_interceptor). Pre-fix, only the management API honored the flag; capsule invocations bypassed it entirely. The Layer 3 gate runs right after profile cache resolution and returnsCapsuleError::WasmError("principal '{p}' is disabled")with asecurity_event = truelog. In-flight invocations finish under the old value (we only check at entry); new invocations afteragent.disableare refused. Together with the Layer 5 gate,agent.disablenow denies every surface a principal can drive. (#672) -
Phantom-principal pre-condition on every mutating admin handler.
caps.grant,caps.revoke,quota.set,agent.enable, andagent.disablenow require the target'sprofile.tomlto already exist on disk. Without this gate, a typo'd principal name (alicvsalice) silently materialized a phantom principal —PrincipalProfile::load_from_pathreturnsDefaultonNotFound, the handler then saved the mutated default to disk, and any future traffic claiming that principal inherited the phantom permissions.quota.getgot the same gate so a typo doesn't return Default-shaped quotas without revealing the mistake. (#672) -
agent.deleteremovesprofile.toml. Pre-fix the handler unlinked the identity and invalidated the cache but left the policy file on disk; subsequent traffic claiming that principal would re-load the old policy. Home-directory data (capsule KV, audit chain) is still left intact — that's an ops-managed concern. (#672) -
agent.disableandcaps.revokeagainst thedefaultprincipal are rejected. Thedefaultprincipal is the bootstrap admin anchor; either operation could lock the operator out of the management API entirely (the newenabledgate denies every request from a disabled principal, andcaps.revoke self:*would clear the operator's grants).caps.grantandquota.setondefaultremain allowed — they only add/adjust, never remove. (#672) -
AdminKernelRequestnow carries an optionalrequest_idechoed back onAdminKernelResponse. Lets clients with multiple in-flight requests on the sharedastrid.v1.admin.response.<topic>channel disambiguate responses. The wire shape is{ "request_id": "...", "method": "...", "params": {...} }—request_idisskip_serializing_if = Option::is_none, so single-client deployments emit no extra field. The typed body lives onAdminKernelRequest.kind: AdminRequestKind(the previous enum, renamed). (#672) -
AuditAction::AdminRequest.params: Option<serde_json::Value>captures the request payload (capabilities granted, quotas set, group definition, etc.) so forensic replay doesn't require diffingprofile.toml/groups.tomlsnapshots.Nonefor legacyKernelRequestentries with no params struct. Forward-compatible add (#[serde(default)]+skip_serializing_if). (#672) -
Layer 6 management IPC for agent lifecycle, quotas, groups, and capability grants (issue #672). New
astrid.v1.admin.*IPC surface with 13 topics covering agent (create,delete,enable,disable,list), quotas (set,get), groups (create,delete,modify,list), and per-principal capabilities (grant,revoke). Every topic runs through the existing Layer 5CapabilityCheck::requirepreamble — there is no new authz mechanism. Capability mappings:admin.agent.create|delete|enable|disable→agent:create|delete|enable|disableadmin.agent.list→self:agent:list(self) /agent:list(cross-tenant)admin.quota.set|get→self:quota:set|get/quota:set|getadmin.group.create|delete|modify|list→group:create|delete|modify|listadmin.caps.grant|revoke→caps:grant|caps:revoke
Mutating topics acquire a new
tokio::sync::Mutex<()>(Kernel.admin_write_lock) so concurrent writers never interleave onprofile.toml/groups.toml. Every profile-mutating handler (quota.set,caps.grant,caps.revoke,agent.enable,agent.disable) callsprofile_cache.invalidate(&target)after the atomic write so subsequent authz checks reflect the new state without waiting on kernel restart. Group admin topics rewritegroups.tomlatomically (tempfile + rename +0o600on Unix) and thenkernel.groups.store(Arc::new(new_config))theArcSwap— in-flight checks holding the oldArcfinish under the old config, the nextload_fullsees the new one. Built-in groups (admin,agent,restricted) cannot be deleted or modified;defaultprincipal cannot be deleted.caps.grantnever clears a matching revoke — Layer 5 precedence (revoke > grant > group) is preserved. Every allow and deny writesAuditAction::AdminRequestwithmethodset to the topic wire name (admin.agent.create, etc.) andtarget_principalset when operating on another principal.agent.deleteremoves theAstridUserIdand CLI identity link but does not scrub the home directory — reclamation is an ops concern. (#672) -
AdminKernelRequest/AdminKernelResponsewire types inastrid-types::kernelwith tagged serde (#[serde(tag = "method", content = "params")]), mirroring the shape ofKernelRequest/KernelResponse. Response variants includeSuccess(Value),AgentList(Vec<AgentSummary>),GroupList(Vec<GroupSummary>),Quotas, andError. (#672) -
Atomic
GroupConfig::save_to_path+saveinastrid-core::groups::io_impl— tempfile + rename +0o600on Unix, mirroringPrincipalProfile::save_to_path. On rename failure the tempfile is removed so no secret-adjacent stale state is left on disk. (#672) -
GroupConfig::insert_custom_group,modify_custom_group,remove_group,is_builtin_name— immutable-value mutators that return a newGroupConfigwith the requested change applied. Built-in names are rejected; modify/remove of unknown custom groups returns the newGroupConfigError::UnknownGroupvariant (vsDuplicateNamefor insert collisions). (#672) -
IdentityStore::delete_userandlist_userson the trait andKvIdentityStore— required byadmin.agent.delete(delete user record + all links pointing at it, idempotent for unknown UUIDs) andadmin.agent.list(enumerate user records). The name-index entry is cleared only when it still points at the deleted UUID (last-writer-wins survives). (#672) -
arc-swapworkspace dependency for lock-free hot-reloadableGroupConfig. (#672) -
Capability/group enforcement on the management API (issue #670). Every arm of
kernel_router::handle_requestnow runs through aCapabilityCheckenforcement preamble before reaching the handler body. EachKernelRequestvariant maps to a required capability (Shutdown → system:shutdown,ReloadCapsules → self:capsule:reload,InstallCapsule → self:capsule:install,ListCapsules/GetCommands/GetCapsuleMetadata → self:capsule:list,GetStatus → system:status,ApproveCapability → self:approval:respond); no default-allow branch. The caller's principal is resolved fromIpcMessage.principal(falling back toPrincipalId::default()for pre-#658 single-token socket traffic), theirPrincipalProfilecomes from the profile cache, and the newGroupConfigis boot-loaded from$ASTRID_HOME/etc/groups.toml(missing file → built-ins only). Precedence follows revoke > grant > group-inherited, so operators can revoke specific capabilities from admins without dropping the admin membership. Built-in groups (admin,agent,restricted) cannot be redefined at load time; custom groups must opt-in viaunsafe_admin = trueto grant the universal*. Every allow and deny outcome writes a newAuditAction::AdminRequest { method, required_capability, target_principal }audit entry (chain-linked, signed). Thedefaultprincipal is seeded withgroups = ["admin"]on first boot so single-tenant deployments keep full access with no config.CapabilityCheckis a pure function — no I/O, no locking — and is a distinct namespace from the runtimeCapabilityTokensystem (which continues to gate capsule-level sensitive actions). (#670) -
GroupConfig+Groupinastrid-core::groupswith a TOML loader at$ASTRID_HOME/etc/groups.toml, baked-inadmin/agent/restrictedbuilt-ins, fail-closed handling for missing/malformed files, rejection of built-in redefinition, rejection of capability strings containing**or shell metacharacters, and aGroup::unsafe_adminopt-in for custom groups that need the universal*capability. (#670) -
PrincipalProfile.grantsandPrincipalProfile.revokes(per-principal capability overrides) validated at load/save time via the newastrid_core::capability_grammar::validate_capabilityhelper. Revokes have strict precedence over grants and group-inherited capabilities. (#670) -
CapabilityCheck+PermissionErrorinastrid-capabilities— the policy-evaluation primitive. Borrowed evaluator over(&PrincipalProfile, &GroupConfig)withhas(&str) -> boolandrequire(&str) -> Result<(), PermissionError>. Pure, thread-safe, zero-alloc on the hot path. (#670) -
AuditAction::AdminRequest { method, required_capability, target_principal }— new audit action for management-API requests, written withAuthorizationProof::Systemon allow andAuthorizationProof::Deniedon deny. (#670) -
Principal-scoped
AllowanceStore,CapabilityStore,OverlayVfs, and connection counter (Layer 4).Allowance.principalandCapabilityToken.principalare now required construction-time fields. All store lookups are principal-filtered up front; Agent A's approvals and capabilities never match Agent B. A newastrid_vfs::OverlayVfsRegistrygives each invoking principal a freshOverlayVfson first use, with a bounded (default 1024) LRU-evicting cache keyed byPrincipalId.Kernel.active_connectionsbecame a per-principalDashMap; only the disconnecting principal's session allowances are cleared, while ephemeral shutdown still waits on the globaltotal_connection_count().WasmEngine::invoke_interceptorresolves the invoking principal's overlay on every call and installs it onHostState.invocation_overlay_vfs. Single-tenant deployments passPrincipalId::default()and see no behavior change. Extends invariants #6 and #7 from issue #653 (Agent A cannot use Agent B's approvals or capabilities). (#668) -
Per-invocation quota enforcement from
PrincipalProfile(Layer 3).WasmEngine::invoke_interceptornow resolves the invoking principal'sPrincipalProfilethrough a new kernel-scopedPrincipalProfileCache(astrid_capsule::profile_cache) and applies per-invocation:max_memory_bytesvia a rebuiltStoreLimits,max_timeout_secsvia the epoch deadline (non-daemon capsules only), andmax_ipc_throughput_bytesvia the rate limiter.IpcRateLimiteris rekeyed fromUuidto(Uuid, PrincipalId)so two principals sharing a single capsule instance never starve each other's throughput.ManagedProcessand active HTTP streams are tagged with the creator principal and counted per-principal against the existing per-capsule hard ceilings (MAX_BACKGROUND_PROCESSES = 8,MAX_ACTIVE_HTTP_STREAMS = 4) — the effective cap is alwaysmin(profile, hard_cap). Profile load failures (malformed TOML, unknown fields, invalid values, futureprofile_version) fail the invocation closed; there is no fallback to the capsule owner's limits.max_storage_bytesis read but not enforced (no storage accountant yet; deferred). Single-tenant deployments without aprofile.tomlget Layer 2'sDefaultprofile and see no behavior change. (#666) -
Per-principal
PrincipalProfile+profile.toml. Newastrid_core::profilemodule with the per-principal policy struct (enablement, groups, auth methods, network egress, process spawn, resource quotas) plus loader and atomic writer at~/.astrid/home/{principal}/.config/profile.toml. Missing file falls back toDefault; malformed TOML, unknown fields, failed validation, or a futureprofile_versionare hard errors. Save is atomic on Unix (temp write at0o600, thenrename). Validation fires on both load and save. Pure data plumbing — Layer 3 enforcement ininvoke_interceptor, hot-reload, management IPC, and CLI are separate follow-ups. (#663) -
Content-addressed WIT store at
~/.astrid/wit/{blake3}.wit. Install-time WIT files (includingdeps/) are recursively hashed, deduped, and stored with atomic writes. Per-capsulewit/is removed after addressing;meta.json.wit_filesis the authoritative manifest. Append-only by design for replay preservation. (#649) -
astrid wit gc— admin-only mark-sweep GC for the WIT content store. Dry-run by default,--forceto delete. Scans all principal homes + workspace. (#649) -
Per-invocation
home://and/tmp/VFS scoping. A shared capsule serving multiple agents now resolveshome://and/tmp/against the invoking agent's home directory (~/.astrid/home/{principal}/) instead of the capsule owner's. The security gate accepts aprincipal_homeparameter andWasmEngine::invoke_interceptorbuilds a per-principal VFS bundle when the invocation principal differs from the capsule's. Unregistered principals (no home directory on disk) receive a clean denial — the kernel does not auto-create principal homes. Single-tenant installs (all traffic underdefault) see no behavior change. Precursor to multi-tenancy (#653). (#549) -
Per-invocation
SecretStoreand capsule log re-scoping.has_secretnow reads secrets from the invoking agent's KV namespace (and OS keychain scope), andastrid_logwrites to the invoking agent's~/.astrid/home/{principal}/.local/log/{capsule}/{date}.log.HostStategainsinvocation_secret_store/invocation_capsule_logfields pluseffective_secret_store()/effective_capsule_log()accessors;WasmEngine::invoke_interceptorinstalls per-principal resources alongside the existing invocation VFS bundle and clears them on exit. Unregistered principals receiveNone— no attacker home is auto-created. Finishes #653 Layer 1's side-channel isolation started in #659. (#661) -
WIT-driven IPC topic schemas. Capsules declare
wit_type = "record-name"on[[topic]]entries inCapsule.toml. At install time,wit-parserreads the record from the capsule'swit/directory, extracts field names, types, and///doc comments into JSON Schema, and bakes it intometa.json. At runtime,WasmEngine::load()populates theSchemaCatalogfrom baked schemas. The LLM sees typed field descriptions without capsule authors writing JSON Schema by hand. (#643) -
astrid-build::wit_schemamodule — converts WIT records to JSON Schema. Handles primitives,option<T>,list<T>, tuple, enum, flags, variant, result, nested records, and type aliases. (#643) -
wit_type: Option<String>field onTopicDefinCapsule.toml— references a WIT record by kebab-case name. (#643) -
Schema catalog (
SchemaCatalog) for A2UI Track 2 — maps IPC topics to schema definitions. Populated at capsule load time from bakedmeta.jsonschemas. (#632, #643) -
Epoch-based WASM timeout with
EpochTickerGuardRAII type — replaces Extism wall-clock timeout. 5-minute deadline for interceptors, u64::MAX for daemons/run-loops, 10-minute safety net for lifecycle hooks. (#632) -
64MB per-capsule WASM memory limit via
StoreLimitsBuilder(matches old Extism setting). Global budget for multi-tenant hosting is a follow-up (#639). (#632) -
New WIT record types:
spawn-request,interceptor-handle,net-read-status(variant),capability-check-request/response,identity-*-request,elicit-request. (#632)
Changed
- Persistent daemon no longer auto-shuts down after 5 minutes idle. The non-ephemeral mode (
astrid start) used to defaultASTRID_IDLE_TIMEOUT_SECSto 300s and kill operator daemons mid-session. Idle shutdown is now opt-in for persistent mode —ASTRID_IDLE_TIMEOUT_SECS=<secs>re-enables it for housekeeping flows that genuinely want auto-shutdown.--ephemeralmode is unchanged (30s after last disconnect, env-var override still honoured). astrid capsule install/update/list/removeno longer accept--agent/-aor--group/-g. Capsules are deployed once and shared across every principal — per-invocation isolation is provided by the kernel's caller-context scoping (KV namespace, home, secrets, log, quotas, audit), not by duplicating the WASM. The--agentflag previously parsed and was discarded (let _ = agent;indispatch.rs); the silent no-op misled operators into thinking they were installing per-tenant. Removed in line with the NEAR-style "one contract, many callers" model. Capsule config and secrets remain per-agent:astrid capsule config -a <agent>andastrid secret set ... -a <agent>still scope to a principal because they hold per-tenant data, not capsule code.
Removed
-
Raw
.wasmrelease asset install paths. Capsule distribution is now.capsulearchive or clone+build. Raw WASM assets can't carry WIT dependencies. (#649) -
install_standard_wit()from init. Fetched stale per-interface WIT from upstream repo; shared contracts are now bundled byastrid-buildinto each capsule archive. (#649) -
extismdependency — replaced by directwasmtime43 +wasmtime-wasi43. (#632) -
capsule_abi.rs(252 lines) — hand-written WIT type mirrors. (#632) -
host/shim.rs(430 lines) — Extism dispatch shim,WasmHostFunctionenum,register_host_functions(), manual memory helpers. (#632) -
RiskLevelenum and all references — removed from WIT, IPC payloads, approval engine, audit entries, CLI renderers, policy engine, and test fixtures. Approval prompts now render with a single style. The allowance store handles "don't ask again" patterns without risk classification. (#641)
Fixed
-
astrid whonow attributes connections to the actual agent holding the socket. The kernel's per-principalactive_connectionscounter was previously incremented byclient.v1.connectedevents fired fromnet_acceptwith no principal stamp — at accept time the host has no idea which principal will eventually claim the socket, so the unstamped publish landed underdefault. The uplink (cli capsule) now publishes the lifecycle event with the claimed principal once the first principal-stamped ingress message arrives.DaemonStatusgainsconnections_by_principal: Vec<PrincipalConnectionCount>(optional / skip-if-empty) so older daemons stay wire-compatible. The CLI uses the sharedSocketClient::extract_kernel_responseto decode the response (so any future envelope change is picked up uniformly acrossps/daemon/who) and falls back to the bare count + default attribution when the new field is absent. -
astrid caps check <self>works for non-admin queriers. Previously failed because the CLI callsadmin.group.listto resolve group-inherited caps andGroupListrequired the admin-tiergroup:list. Now self-scoped:(GroupList, AuthorityScope::Self_)requiresself:group:list, which theagentbuiltin satisfies viaself:*. The mutating group operations (group create / delete / modify) keep their dedicated caps (group:create,group:delete,group:modify) and remainAuthorityScope::Global— read-only widening only. -
ipc::recvandipc::pollinstall per-message invocation context and clear on empty. Run-loop capsules that consume IPC viasubscribe+recv(prompt-builder, registry, context-engine) used to silently fall back to the capsule owner's principal (default), so non-default agent chat hung when the chain bounced through a run+recv capsule. The host now mirrors the dispatcher's per-interceptor invocation-context setup onto the recv path:caller_context,invocation_kv, andinvocation_capsule_logare set from the first message of a recv'd batch, so subsequent publishes / KV reads stamp the publisher's principal correctly. Mixed-principal batches are truncated to the first publisher's contiguous prefix with asecurity_event = truewarn, so trailing messages from a different principal can't be silently mis-stamped under the first's context. Empty drains (timeout, cancellation, idle poll) explicitly clear the recv context so a previous publisher's principal doesn't leak into the next guest host call. Same-principal fast path: when the new message's principal matches the currently installed one, the KV namespace and capsule log are reused instead of re-opened, dropping per-tick I/O for the steady-state chat run loop. -
astrid secret setroutes secret-typed values through the file-per-secret store (security). Previously everysecret setinvocation wrote plaintext JSON to<principal_home>/.config/env/<capsule>.env.jsonregardless of whether the manifest declared the key asenv_type = "secret".OPENAI_API_KEYand friends sat in cleartext on disk even though the manifest correctly marked them secret. The CLI now reads the installedCapsule.toml, looks up theEnvDeffor the key, and writes secret-typed values through a newastrid_storage::FileSecretStorerooted at~/.astrid/secrets/<scope>/<capsule>/<key>with 0600 perms (atomic via tempfile+rename, 64 KiB per-file read cap, null-byte and path-separator rejection in keys).--scope agent|sharedcontrols per-agent vs host-wide; the manifest does NOT declare scope — capsules can't elevate their own credentials to host-shared. Kernel-sideget_configconsults the same store at invocation time, per-agent first with host-wide fall-through. Plaintext secret-typed env JSON entries are stripped at capsule load with asecurity_event = truewarning so legacy on-disk state heals on next boot.secret listcross-references the manifest's[env]declarations to surface each row with a STORAGE column —file(green) for the new path,env-json(dim) for non-secret config,env-json (LEGACY!)(red) for secret-typed keys still on plaintext, awaiting the load-time heal.secret deletechecks the file store first when the manifest declares the key secret. Pivoted away from an earlier OS-keychain implementation because headless containers and CI environments often lack a DBus secret-service / keyring; the file path works uniformly across Linux, macOS, Windows without backend gymnastics. -
caps checknow resolves group-inherited capabilities. Previously the CLI answeredindeterminate: 'bob' belongs to groups agent — group capabilities not enumerated by Layer 6whenever the requested capability could only be satisfied through group membership. The new implementation runs the same resolution order the kernel's Layer 5 enforcement preamble uses — explicit revokes → direct grants → group-inherited patterns — callingastrid_core::capability_grammar::capability_matches(the kernel-side matcher) on the data returned byadmin.agent.list+admin.group.list. Reports the matching pattern in the output so operators can trace why a capability resolved the way it did. Verified live:caps check bob self:capsule:install→allowed: 'bob' inherits from group 'agent' (pattern: self:*). -
caps grant/caps revoke/caps checkhelp text updated to a syntactically valid example. The previous example wasnetwork:egress:api.openai.com, but the capability grammar disallows dots in segments (segments are[a-zA-Z0-9_-]+or bare*, no shell metacharacters so strings round-trip through TOML and the audit log without escaping). Operators following the example gotcapability segment "api.openai.com" contains invalid character '.'. Help text now showsnetwork:egress:openaiand similar dot-free examples, and explicitly names the grammar. -
caps showno longer mislabels revoked grants as active (precedence display). Layer 5 evaluates revoke > grant; a grant shadowed by any revoke pattern is dead at check time even though both rows persist on disk. The pretty renderer now uses the kernel's owncapability_matches(revoke, cap)to detect shadowing (so pattern revokes likesys:*correctly marksys:statusgrants asshadowed by revoke), instead of exact string equality which missed pattern-based suppression. Fixes column alignment in the table at the same time — ANSI escape codes inside{:<N}format specs were counted toward width but didn't render visually, shifting every subsequent column. -
agent createprovisions the new principal's home directory tree, fail-closed. The kernel handler now callsprincipal_home(&p).ensure()after writingetc/profiles/{p}.toml, so~/.astrid/home/{p}/.local/{kv,log,audit,tmp,tokens,capsules}and~/.astrid/home/{p}/.config/envexist before the first interceptor scoped to the new principal fires. Without this,caller_context.principalresolved to a directory that didn't exist and per-invocation KV/secret/log/tmp/audit overrides had nowhere to land — silent fallback to default's namespace would defeat multi-tenancy at the data plane. If provisioning fails, the handler rolls back the identity link and profile file and returns an error rather than leaving a half-provisioned agent whose invocations would leak into another principal's namespace. -
CLI
read_messageskips unparseable broadcast frames instead of dying on the first one. The kernel publishesastrid.v1.capsules_loadedwith anIpcPayload::RawJsonwhose inner JSON is emitted viato_guest_bytes— thetypediscriminator is stripped before the proxy forwards it to the socket. Strictserde_json::from_slice::<IpcMessage>propagated the resultingmissing field typeerror up through the TUI run loop on the very first tick, broke out, restored the terminal, and dropped the operator back to a shell prompt with no visible cause.read_messagenow logs unparseable frames atdebugand reads the next valid frame instead. The crash was masked while the wasip2 stub-run misclassification was suppressing every interceptor — once that fix landed, the chat-stack capsules actually started publishing the broadcast and the silent TUI exit became reachable on every boot. -
IpcMessagenow tolerates wire frames that omittimestampandsignature. Both fields are serde-defaulted (timestampfalls back toUtc::now()on deserialize,signaturetoNone). The CLI proxy capsule (astrid-capsule-cli) forwards bus messages to socket clients using only the fields exposed by the SDK'sipc::Message:{topic, payload, source_id}. The SDK does not surface the original timestamp or signature, so every CLI proxy frame previously omitted them and the headless client's strictfrom_slice::<IpcMessage>silently failed on every frame —astrid -pand the chat REPL never receivedagent.v1.responseand either timed out at 120s or hung indefinitely. Defaulting at the deserialization boundary preserves semantics for in-process publishers (which always set the fields viaIpcMessage::new) while letting forwarding capsules pass partial frames through. Round-trip behaviour for already-complete frames is unchanged. -
Interceptor dispatch restored for capsules without a
#[astrid::run]loop. After the wasmtime Component Model migration, the kernel's run-loop pre-scan misclassified every Component Model capsule as a live run-loop daemon, zeroed out the store/instance, and returnedNotSupported("plugin handles interceptors internally via IPC auto-subscribe")for every direct interceptor invocation. End-to-end chat REPL was non-functional. Thewasm32-wasip2toolchain auto-synthesizes a single shared nop function for every mandatory WITfunc()export the source crate doesn't implement (run,astrid-install,astrid-upgrade) and aliases all unimplemented exports to that one function. The pre-scan now treats arunexport aliased toastrid-installorastrid-upgradeas a stub and routes the capsule through the direct-invoke path. The same fix applies to lifecycle pre-scans, so capsules without real#[astrid::install]/#[astrid::upgrade]no longer compile a transient component just to run a nop. Real#[astrid::run]daemons (prompt-builder,context-engine,cli) keep using the auto-subscribe path introduced in #343. -
[[topic]]declarations now accept trailing-suffix wildcards (e.g.llm.v1.request.generate.*). The previous validator rejected every wildcard in topic names, which broke fan-out topic families where the trailing segment names a provider, source, or recipient that can't be enumerated at manifest-author time (multiple LLM providers, multiple session callbacks, hook fan-out targets). Every member of the family shares the same envelope, so a pattern is the genuine schema declaration. Mid-segment (a.*.b) and leading (*.b) wildcards are still rejected — the bus matcher only supports trailing-suffix wildcards, so those would silently never fire. Bare*is rejected as too broad. Mirrorsipc_subscribe's host-side check. -
bwrap mount ordering hides capsule directory on Linux. Hidden
--tmpfsoverlays (e.g.~/.astrid) were applied before writable--bindmounts, erasing capsule directories inside the hidden path. Reordered so bind-mounts come after tmpfs and punch through. Mirrors the ancestor check already present in the macOS Seatbelt path (PR #534). (#648) -
bwrap silently fails on Ubuntu 24.04+ (AppArmor).
kernel.apparmor_restrict_unprivileged_userns=1blocks user namespaces required by bwrap. Added a cached probe at startup that detects this and falls back to unsandboxed execution with a clear warning and distro-specific install instructions. (#648) -
capsule removeno longer deletes env config by default. User configuration (API keys, secrets) inenv.jsonis preserved across uninstall/reinstall cycles. Use--purgeto explicitly delete saved configuration. (#647) -
astrid-buildtargetswasm32-wasip2for Component Model capsules. Was still targetingwasm32-wasip1, producing plain WASM modules. (#649) -
astrid-buildbundles SDK shared WIT (astrid-contracts.wit) into capsule archives as a WIT dep, sowit_typereferences inCapsule.tomlresolve at install time without manual WIT duplication. (#649) -
JSON Schema field names converted to snake_case in
wit_schemato matchserde(rename_all = "snake_case")wire convention. (#649) -
reqwest::blockinginside#[tokio::main]panics on first run. All HTTP call sites inself_update.rs,init.rs, andcapsule/install.rsusedreqwest::blocking::Client, which creates an internal tokio runtime that panics on drop inside the outer async context. Converted to asyncreqwest. Only manifested on fresh installs (no cache/lock files to short-circuit). (#645) -
INTERNAL_SUBSCRIBER_COUNTdebug_assert race.EventDispatchersubscribed to the event bus insidetokio::spawn(dispatcher.run()), so the assert could fire before the spawned task started. Moved subscription intoEventDispatcher::new(). (#645)
Install
From source (requires Rust 1.94+):
cargo install astrid
Pre-built binaries:
Download the archive for your platform, extract, and add to PATH:
tar xzf astrid-*-$(uname -m)-*.tar.gz
sudo mv astrid-*/astrid astrid-*/astrid-daemon astrid-*/astrid-build /usr/local/bin/
Then run astrid init to set up capsules.
With many thanks from the following Astrinauts 🚀
- Joshua J. Bouw
- Pavel Grigorenko