Architecture of the extension broker, capability model, sandbox, consent, and token management.
The extension system is implemented in the mew-ext-broker crate, with
transport support from mew-hooks-runtime. This document covers the
internal architecture for contributors extending or modifying the
extension system.
Crate structure
Section titled “Crate structure”mew-ext-broker├── broker.rs — ExtensionBroker: Dispatcher impl, spawns extensions├── capabilities.rs — Capability enum, CapabilitySet, CapabilityDelta├── capability_descriptions.rs — Consent prompt builders├── consent.rs — ConsentState, ConsentDecision, ConsentResolver├── discovery.rs — discover_extensions, DiscoveredExtension├── manifest.rs — ExtensionManifest, parse_manifest, validate_manifest├── sandbox.rs — Seatbelt profile generation, SandboxConfig├── tokens.rs — Token minting, validation, rotation (keyring + file)├── audit_log.rs — Gate audit logging├── event_queue.rs — Bounded event queue scaffolding (not yet wired)└── principal.rs — Principal, PrincipalKindManifest format
Section titled “Manifest format”mew-ext.toml at the package root:
[extension]name = "my-ext"version = "0.1.0"description = "An example extension"
[extension.entry]run = ["node", "dist/index.js"] # optional; declarative-only if absent
[extension.capabilities.hooks]observe = truegate = ["bash"] # gate specific toolsgate_mutate = false # gate with input mutationmutate_headers = false # rewrite provider request headers
[extension.sandbox]net = false # deny network by defaultfs_read = ["/etc/hosts"] # additional read pathsfs_write = ["/tmp/output"] # additional write paths
[provides] # optional, relative to package rootskills = "skills/"personas = "personas/"subagents = "agents/"Validation
Section titled “Validation”validate_manifest in manifest.rs checks:
- Non-empty name and version
- Name is a single path component (no
/,\, or..— prevents path traversal during install) - No sensitive paths in
sandbox.fs_read/fs_write(denies~/.ssh,~/.aws,~/.gnupg, credentials files)
Capability model
Section titled “Capability model”Capabilities are defined in capabilities.rs. The Capability enum has
~17 variants with risk tiers:
| Tier | Capabilities | Consent behavior |
|---|---|---|
| Always granted | Storage, ConfigRead |
No prompt |
| Low | Ui, Register, HooksObserve, Events{session,meta} |
Batch prompt |
| Medium | SessionsRead, SessionsManage, SessionsPrompt, HooksMutate, Events{session,full}, Events{global,meta} |
Batch prompt |
| High | HooksGate, HooksMutateHeaders, HooksMutateShellEnv, HooksMutateChatParams, PermissionsResolve, Events{global,full} |
Individual prompt |
| Highest | HooksGateMutate |
Individual prompt |
CapabilitySet
Section titled “CapabilitySet”A HashSet<Capability> wrapper with:
satisfies(&Capability) -> bool— checks with hierarchy (e.g.,HooksGateMutatesatisfiesHooksGateandHooksMutate;Events{global,full}satisfies all event scopes)difference(&CapabilitySet) -> CapabilityDelta— for consent delta detection on manifest upgradesintersect(&CapabilitySet) -> CapabilitySet— for clamping persisted consent to manifest’s requested setlegacy_full()— all capabilities (for bare plugins)observe_only()— storage + config read + hooks observe (for restricted/declined)
Consent flow
Section titled “Consent flow”The consent resolver (build_consent_resolver in mew/src/setup/agent.rs)
runs per extension at broker spawn time:
- Check persisted consent state (
consent.json) - If no prior consent: prompt for all requested capabilities (two-phase: batch non-sensitive, then individual sensitive)
- If prior consent exists and manifest unchanged: clamp persisted caps
to manifest’s requested set via
intersect - If manifest grew (new capabilities): prompt only for the delta
Consent state persists capability ID strings (not the enum) to
~/.local/share/mew/extensions/consent.json. The LEGACY_FULL_SENTINEL
string marks bare-plugin full-access consent.
ConsentDecision → CapabilitySet mapping
Section titled “ConsentDecision → CapabilitySet mapping”Approved → legacy_full() (bare plugins)Restricted → observe_only() (declined)ApprovedWithCaps → the granted set (manifest extensions)The to_caps(fallback) method maps the decision, using fallback only
for the Approved variant. For manifest extensions, observe_only() is
passed as the fallback (fail-closed if somehow Approved reaches the
manifest path).
Sandbox
Section titled “Sandbox”Profile generation
Section titled “Profile generation”build_sandbox_profile in sandbox.rs generates a Seatbelt
S-expression profile. The profile is default-deny with allow rules for:
- Package dir (read/write):
(allow file-read* file-write* (subpath (param "PACKAGE_DIR"))) - Storage dir (read/write):
(allow file-read* file-write* (subpath (param "STORAGE_DIR"))) - Widened paths from manifest:
(allow file-read* (literal "/path")) - Network: denied unless
sandbox.net = true - Process exec/fork: allowed (extension needs to run)
- Pipe I/O:
(allow file-read-data file-write-data)— allows read/write on already-open FDs (stdin/stdout pipes) without allowing open() on arbitrary paths /devaccess: for/dev/nulletc.- System services:
sysctl-read,mach-lookup,process-info-pidinfo,signal (target self)
Security note: The profile must NOT contain a blanket
(allow file-read* file-write*) without a path filter — that would
negate the sandbox entirely. The file-read-data/file-write-data rule
is safe because it covers read()/write() syscalls on open FDs, not
open() on new paths.
Profile injection prevention
Section titled “Profile injection prevention”escape_path escapes backslash, double-quote, newline, carriage return,
and tab in path literals to prevent Seatbelt profile injection via
crafted manifest paths.
ARG_MAX guard
Section titled “ARG_MAX guard”The profile text is passed as a CLI argument to sandbox-exec -p. If
the profile exceeds 100KB (many widened paths), a warning is logged. The
extension may fail to spawn with E2BIG.
SpawnSpec
Section titled “SpawnSpec”SpawnSpec::Command in mew-hooks-runtime/src/transport.rs carries an
optional sandbox: Option<(String, Vec<(String, String)>)> — a tuple of
(profile_text, params) kept as plain types to avoid a cross-crate
dependency. to_command() wraps the extension command with
sandbox-exec -p <profile> -D KEY=VALUE ....
Bare plugins use SpawnSpec::Path — no sandbox.
Token management
Section titled “Token management”tokens.rs implements attach tokens for socket-transport extensions:
- Minting:
ulid::Ulid::new()generates 26-character ULIDs (128 bits of entropy) - Storage: keyring first (
mew-ext-tokensservice), file fallback at~/.config/mew/extensions/tokens/<name>.token(permissions 0600, parent dir 0700) - Marker files: when a token is stored in the keyring, an empty
marker file is written so
list_tokened_extensionscan discover it for rotation - Validation:
constant_time_eqprevents timing side-channels - Rotation:
rotate_all_tokenscollects per-extension results instead of aborting on first failure (partial success is reported)
Not yet wired:
validate_tokenis exported but has no callers. The daemon socket-attach path that would consume it requires the daemon to own a broker (currently constructed per-session). This is deferred to a separate plan.
Discovery
Section titled “Discovery”discover_extensions(cwd) in discovery.rs scans:
- Project-local:
<cwd>/.mew/extensions/<name>/ - Global:
~/.config/mew/extensions/<name>/
discover_extensions_from_dirs(project_dir, global_dir) is the
testable variant that takes explicit paths. The production function
delegates to it using directories::UserDirs (not ProjectDirs —
important: ProjectDirs resolves to a different path on macOS).
Dedup precedence: project beats global on name collision.
DiscoveredExtension carries the parsed manifest, root path, scope,
and [provides] paths. provides_skills(), provides_personas(),
provides_subagents() extract individual path lists that feed into the
existing loaders (mew-skills, mew-personas, mew-subagents).
Broker spawn flow
Section titled “Broker spawn flow”ExtensionBroker::from_dirs_filtered_with_config in broker.rs:
-
Bare plugins (legacy path): spawn via
SpawnSpec::Path, no sandbox. Consent resolver called withNonemanifest.Approved→legacy_full(). -
Manifest extensions: skip if disabled or declarative-only (no
entry.run). Build sandbox profile ifsandbox_available(). Spawn viaSpawnSpec::Commandwith sandbox. Consent resolver called withSome(&manifest).ApprovedWithCaps→ granted set. No resolver →ApprovedWithCaps(requested_capabilities())(fail-open for testing).
Both paths produce (Arc<PluginSlot>, Principal) pairs sorted
alphabetically by slot name for deterministic hook ordering.
State integration
Section titled “State integration”mew-config::State has a revoked_extensions: Vec<String> field
(serde default, skipped when empty). mew ext revoke adds to this list;
mew ext rotate-all clears it. The daemon will check this list on every
socket attach when the attach path ships.
Platform support
Section titled “Platform support”| Platform | Sandbox | Status |
|---|---|---|
| macOS | Seatbelt via sandbox-exec |
Shipped |
| Linux | Landlock + seccomp | Deferred |
| Windows | None | Not planned |
On unsupported platforms, sandbox_available() returns false, the
broker logs a warning, and the extension runs unsandboxed. mew ext doctor shows [unsandboxed (platform)].