Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,10 @@ Host-mediated device/browser access uses the versioned capability broker; see
[`CAPABILITIES.md`](CAPABILITIES.md) for the manifest, app API, wire protocol,
provider contract, lifecycle rules, and trust-tier escape hatches.
Server-side app jobs have a separate two-tier model: ordinary reviewed scripts
retain the Möbius process authority, while `background_agent` jobs run through
one reviewed data contract and the strongest secure executor available on the
host. See [`BACKGROUND_JOBS.md`](BACKGROUND_JOBS.md) for the contract,
retain the Möbius process authority, while jobs declaring
`job_authority: scoped` run through one reviewed data contract and the
strongest secure executor available on the host. See
[`BACKGROUND_JOBS.md`](BACKGROUND_JOBS.md) for the contract,
Bubblewrap/Landlock selection, history, and verification strategy.

| Tier | Boundary and capability | UX / standalone consequence |
Expand Down Expand Up @@ -1105,6 +1106,6 @@ cover it deterministically.

- **Build / test / run commands and the dev loop:** `CONTRIBUTING.md`. (The #1 deploy gotcha — a stale `/data/platform/frontend/dist` masking a fresh image — is covered under *Frontend serving priority* above.)
- **Secure server-side app jobs:** `BACKGROUND_JOBS.md` defines the
background-agent data contract, portable executor design, historical
rationale, and topology-level verification.
scoped-authority data contract, private executor adapters, rationale, and
topology-level verification.
- **Subsystem deep-dives are inlined above** as their own sections: *Stop-chat contract*, *AskUserQuestion interception*, *Chat persistence — single-writer actor*, *Navigation back-stack + drawer model*, *Service worker + offline*, and *Mini-app manifest (mobius.json)*. (The chat-persistence v2 design + staged-rollout notes remain internal/gitignored — the as-built contract is the section above.)
211 changes: 129 additions & 82 deletions BACKGROUND_JOBS.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
# Secure background jobs
# Server-side app-job authority

Möbius has two server-side app-job tiers:
Möbius has two server-side app-job authority profiles:

- **Ordinary jobs** run reviewed, owner-installed app scripts with the
historical authority of the Möbius process.
- **Background-agent jobs** declare `permissions.background_agent: true`.
They can run an AI agent without an owner watching, so they receive a
narrower, owner-reviewed data contract enforced by a process sandbox.
- **Platform-authority jobs** run reviewed, owner-installed app scripts with
the historical authority of the Möbius process.
- **Scoped jobs** receive a narrower, owner-reviewed data contract enforced by
a process sandbox. These jobs declare
`permissions.job_authority: scoped`.

This document defines that contract, why its implementation is portable, and
the verification required to change it. Browser iframe isolation is a separate
boundary; see [`CAPABILITIES.md`](CAPABILITIES.md) for browser-side apps.
These names describe operating-system authority, not whether a script happens
to use AI. Platform-authority jobs may run agents, while a scoped job may run
ordinary deterministic code. Scheduled versus on-demand execution and
`embeds_agent` are separate choices.

## The stable design
This document defines the scoped contract, why its implementation is portable,
and the verification required to change it. Browser iframe isolation is a
separate boundary; see [`CAPABILITIES.md`](CAPABILITIES.md) for browser-side
apps.

## Decision: one contract, two private executors

The reviewed access contract is portable; Linux enforcement mechanisms are
not. Some supported hosts deny the namespace and mount operations Bubblewrap
requires but provide Landlock ABI 6+. Others allow Bubblewrap but lack a usable
Landlock implementation. Möbius therefore keeps one filesystem policy with two
private launch adapters, selects by probing required behavior, and fails closed
if neither works.

This is deliberately not an executor framework. Apps cannot select a mechanism,
and executor details do not enter manifests or capability policy. Remove
Bubblewrap when every supported deployment passes the Landlock probe; remove
Landlock when every supported deployment permits Bubblewrap.

## The stable scoped design

The stable part of the system is a small semantic contract:

Expand All @@ -24,7 +44,7 @@ JobAccess
extra_write
```

For the current manifest vocabulary this means:
For scoped authority this means:

- app source is readable and not writable;
- the app's numeric storage is readable and writable;
Expand All @@ -33,25 +53,28 @@ For the current manifest vocabulary this means:
may refresh credentials;
- the app gets a minimal environment, a short-lived app token, and a unique
writable home/temp directory;
- the editable platform checkout, database, service token, other app data,
undeclared shared data, sibling-process control, and host UNIX sockets are
denied (read-only image runtime under `/app` remains visible);
- the editable platform checkout, database, service token, other app data, and
undeclared shared data are denied (read-only image runtime under `/app`
remains visible);
- sibling signalling and direct memory/file-descriptor inspection are denied;
- outbound IP networking remains available.

The runner derives this contract once. Executors consume it; they do not
interpret manifests or contain Memory-specific rules.
`JobAccess` is deliberately a filesystem contract. The runner derives it once;
executors consume it without interpreting manifests or adding Memory-specific
rules. Process and socket isolation are executor properties described below,
not fields that this four-path value pretends to make identical.

Two executors implement the contract:

1. **Bubblewrap** is preferred when a real namespace probe succeeds. It
supplies private mount, PID, IPC, and UTS namespaces and hides masked paths.
2. **Landlock** is the fallback when the kernel exposes ABI 6 or newer and a
complete enforcement probe succeeds. `setpriv` installs filesystem rules;
a small helper adds Landlock process/abstract-socket scopes and seccomp
denials for pathname UNIX sockets and direct sibling-process inspection.
2. **Landlock** is the fallback when the kernel exposes ABI 6 or newer and its
required-primitives probe succeeds. `setpriv` installs filesystem rules; a
small helper adds Landlock signal/abstract-socket scopes and seccomp denials
for pathname UNIX sockets and direct sibling-process inspection.

If neither probe succeeds, the job does not run. There is no unsandboxed
fallback for a reviewed background agent.
If neither probe succeeds, the job does not run. There is no platform-authority
fallback for a reviewed scoped job.

### Startup and scheduled execution

Expand All @@ -78,17 +101,46 @@ An app asks for access, not for Bubblewrap or Landlock. Deployment mechanics
must not leak into the manifest. This keeps app review stable when kernels,
container runtimes, and hosting platforms change.

### Keep authority small; express nuance as access

Authority is intentionally a small choice: either a job is confined to its
reviewed resources or it is trusted with platform process authority. The
scoped resource contract carries the useful nuance—source, storage, declared
shared data, provider credentials, and future specific capabilities.

Do not add an authority profile merely to express a new resource permission.
Add a narrow field to the scoped contract instead. A genuinely new profile is
justified only when a demonstrated requirement needs a materially different
enforcement boundary, such as hostile-tenant or resource-quota isolation.

### Name authority directly

An app with a server-side job may declare
`permissions.job_authority: scoped|platform`. Omitting the field preserves the
historical platform authority for existing ordinary jobs. The public
declaration therefore describes the operating-system boundary directly rather
than implying that sandboxing depends on whether a script uses AI.

The earlier `background_agent` boolean has been removed rather than retained as
an alias. Silently ignoring that spelling would grant its sole official
consumer platform authority. Current receipts record the declared authority
directly; coherent older receipts remain readable for existing volumes.
Missing, malformed, contradictory, or unknown receipt data fails closed so a
future schema cannot silently change an installed job's authority.

### Probe behavior, not host names

Möbius does not branch on Railway, Docker, Kubernetes, architecture, or an
environment variable claiming a feature exists. Bubblewrap is selected only
after the namespace operation needed by a real job succeeds. Landlock is
selected only after its ABI, filesystem restriction, process scoping, and
socket filter all work together.
selected only after its ABI, a real filesystem denial, signal scope, and socket
filter all work together.

The probes run at job launch. They are cheap compared with an agent job and
avoid a capability cache that can become stale after a container or host
change.
change. They answer “can this host provide the required primitives?”, not “has
every adversarial behavior test just been rerun?” The latter belongs in the
test suite and deployment smoke checks.

### Prefer the strongest working executor; fail closed

Expand All @@ -102,6 +154,28 @@ not as an identical filesystem view. A future job that genuinely requires a
private PID or mount namespace must become a new explicit requirement; it must
not silently receive the Landlock executor.

### Deliberate limits

Scoped app jobs are reviewed internal code in a single-owner system. This
boundary reduces the data exposed to a job; it is not a hostile-tenant
container or a CPU, memory, and process-count quota.

Landlock does not create a private PID namespace. Same-owner scheduling and
resource-limit controls may therefore remain possible even though sibling
signals, process memory, file descriptors, and protected `/proc` contents are
denied. Its parent-death signal covers the directly launched process, not an
arbitrary descendant that deliberately creates an independent lifetime. A job
that creates a separate session owns that session's cleanup, as it owns its
other application-level resources.

Socket behavior also differs. Landlock blocks `socket(AF_UNIX, ...)`, so a job
cannot open pathname or abstract UNIX endpoints; private
`socketpair(AF_UNIX, ...)` IPC remains available. Bubblewrap masks the pathname
socket locations used by the host, but keeps the network namespace so jobs
retain outbound IP networking; it does not promise a separate abstract UNIX
namespace. Apps must not depend on addressable private UNIX sockets unless that
becomes an explicit reviewed requirement with shared tests.

### One policy, small adapters

There is one path policy and two launch builders. There is deliberately no
Expand All @@ -121,56 +195,28 @@ executor qualifies, the durable app-job log records both probe diagnostics.
This reuses the lease and failure log rather than adding a database or health
service.

## Why the system reached this point

Background-agent isolation was introduced when Memory moved from
platform-owned code into a modular system app. Bubblewrap was a sound initial
executor: it could make the container filesystem read-only, mask owner data,
mount only reviewed paths, and isolate processes with familiar namespace
semantics.

Nested Bubblewrap is not only an image property, though. The outer container
runtime must permit namespace and mount setup. The bundled Docker Compose
deployment was later given the required capabilities and security profile, so
that deployment worked. Managed runtimes that do not expose equivalent outer
container controls can reject Bubblewrap before app code starts. Memory made
the gap visible because it was the first Store app to combine
`background_agent` with install-time initialization.

That initialization exposed three independent integration assumptions in
sequence: the backend was not ready to mint a scoped token, scheduled jobs
assumed the old local port and baked runner, and the managed host rejected
Bubblewrap's namespace setup. Each correction belongs to its owning layer:
readiness in bootstrap launch, address/runner selection in the shared job
handoff, and host portability in secure executor selection. None belongs in
Memory itself.

The resulting lesson is narrower than “build a sandbox framework”:
Bubblewrap was coupled to one deployment topology, while the reviewed access
contract was not. Landlock provides a second enforcement path on modern
restricted hosts without requiring namespace creation. Keeping both small
preserves stronger isolation where available and portability where it is not.

Related architecture already documented elsewhere:

- [`ARCHITECTURE.md`](ARCHITECTURE.md) defines “solve at the core,” “design for
the next change,” and “keep the shared foundation lean.”
- [`CAPABILITIES.md`](CAPABILITIES.md) establishes the broader pattern that
declarations are owner-readable contracts and mechanisms are narrow
providers.
- [`SECURITY.md`](SECURITY.md) distinguishes hardened technical boundaries
from accepted trade-offs.
- The original runner comments explain the narrower background-agent data
contract and why jobs write as the `mobius` data owner.
- The Compose security settings explain why nested Bubblewrap needs explicit
outer-runtime support.
## Origin

Scoped authority was introduced when Memory moved from platform-owned code into
a modular app. Bubblewrap was a sound first executor, but its namespace setup
depends on privileges granted by the outer container runtime; installing the
binary inside an image cannot recover privileges the host withholds. Landlock
made the same reviewed data contract enforceable on such hosts without
namespace creation.

Memory's install-time initialization also exposed readiness, callback-address,
and stale-runner assumptions. Those corrections live in the shared launch path,
not in Memory or the sandbox adapters. The durable lesson is to keep app policy
independent of both application identity and deployment mechanism.

## Alternatives considered

| Alternative | Why it is not the current design |
|---|---|
| Bubblewrap only | Excludes demonstrated managed hosts that deny nested namespaces. |
| Landlock only | Discards stronger private namespaces and excludes older kernels where Bubblewrap already works. |
| Bubblewrap only | Excludes supported hosts whose outer runtime denies nested namespaces. |
| Landlock only | Discards stronger private namespaces and excludes supported hosts where Landlock is disabled or too old. |
| A different namespace launcher | Cannot recover namespace or mount privileges withheld by the outer runtime. |
| A container or VM per job | Moves isolation to a host orchestrator and adds deployment-specific images, mounts, credentials, and lifecycle. |
| Run unsandboxed if probing fails | Silently violates the permission the owner reviewed. |
| Branch on deployment name | Brittle: the relevant property is kernel/runtime behavior, not branding. |
| Add retries or a durable job queue | Does not fix an executor that can never start; solves a different problem. |
Expand All @@ -191,20 +237,20 @@ Each executor also verifies its mechanism-specific boundary:

- Bubblewrap: real namespace creation, masked owner data, and process-group
revocation.
- Landlock: ABI 6+, filesystem enforcement, denied sibling signals and process
inspection, denied `AF_UNIX` sockets, parent-death termination, and temp
cleanup.
- Landlock: ABI 6+, filesystem enforcement, denied sibling signals and direct
process inspection, denied addressable `AF_UNIX` endpoints, direct-launcher
parent-death behavior, and temp cleanup.

Selection tests cover Bubblewrap preference, Landlock fallback, and the
fail-closed case with both diagnostics. CI may skip a real executor only when
the host cannot provide it; each supported deployment topology must therefore
run one end-to-end secure-job smoke test rather than treating a skip as proof.

A release-level startup smoke should install a trivial `background_agent` app
or Memory on a fresh volume, wait for its ready marker, and fail with the
executor diagnostics if initialization cannot start. This catches image,
kernel, outer-runtime, callback-address, and startup-order integration failures
that unit tests cannot.
A release-level startup smoke should install a trivial app declaring
`job_authority: scoped`, or Memory, on a fresh volume; wait for its ready
marker; and fail with the executor diagnostics if initialization cannot start.
This catches image, kernel, outer-runtime, callback-address, and startup-order
integration failures that unit tests cannot.

## Change checklist

Expand All @@ -214,8 +260,9 @@ When this boundary changes:
2. State any executor asymmetry explicitly; do not weaken the common contract.
3. Run the shared adversarial suite against every available executor.
4. Run one real secure job in each supported deployment topology.
5. Verify job-group termination, parent-death behavior, and temp cleanup.
5. Verify job-group revocation, direct-launcher parent-death behavior, and temp
cleanup; separately test any sessions an app intentionally creates.
6. Check both AMD64 and ARM64 images because syscall numbers, system packages,
and seccomp resolution are architecture-sensitive.
7. Keep failures actionable and never silently run a background agent as an
ordinary process.
7. Keep failures actionable and never silently run a scoped job with platform
authority.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
# Chromium copy via the symlinks below (~/.agent-browser is where
# agent-browser looks by default).
#
# Background-agent jobs prefer Bubblewrap: its audited setuid mode retains only
# Scoped-authority jobs prefer Bubblewrap: its audited setuid mode retains only
# the setup capabilities needed for mount/PID namespaces and drops them before
# execing the unprivileged job. docker-compose.yml supplies the outer-container
# grants absent from Docker's defaults. util-linux + libseccomp provide the
Expand Down
15 changes: 11 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,25 @@ being external attackers reaching the public HTTPS endpoint.
the app's exact installed permissions. Opacity protects ambient **owner**
authority — it is not a promise that ordinary app code never sees its own
scoped credential.
- **Background-agent jobs:** apps declaring `permissions.background_agent`
run under the reviewed data contract in
- **Scoped app jobs:** apps declaring `permissions.job_authority: scoped` run
under the reviewed data contract in
[`BACKGROUND_JOBS.md`](BACKGROUND_JOBS.md). Möbius prefers Bubblewrap after a
real namespace probe, otherwise uses a fully probed Landlock ABI 6+ boundary,
and fails closed if neither executor can enforce the contract.
real namespace probe, otherwise uses Landlock ABI 6+ after probing its
required filesystem, signal, and socket primitives, and fails closed if
neither executor can enforce the reviewed filesystem boundary. This reduces
data exposure for reviewed internal jobs; it is not hostile-tenant or
resource-quota isolation.
- **Rate limiting:** 120 req/min global, 3-5/min on auth endpoints.
Uses TCP peer address (not X-Forwarded-For).

## Accepted trade-offs

These are intentional design decisions appropriate for a single-owner app:

- **Platform-authority app jobs:** reviewed jobs that do not request the scoped
boundary retain the historical authority of the Möbius process. Their
capability receipt records `platform`; the launcher must not infer
authority from whether the script appears to use an agent.
- **Owner JWT in shell localStorage:** opaque mini-app frames cannot read it,
but script execution in the shell document itself remains equivalent to the
owner. Moving the shell session to an HttpOnly cookie would further reduce
Expand Down
Loading