diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0153263b7..c813da3d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 | @@ -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.) diff --git a/BACKGROUND_JOBS.md b/BACKGROUND_JOBS.md index 6c022c7d8..88ef45088 100644 --- a/BACKGROUND_JOBS.md +++ b/BACKGROUND_JOBS.md @@ -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: @@ -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; @@ -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 @@ -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 @@ -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 @@ -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. | @@ -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 @@ -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. diff --git a/Dockerfile b/Dockerfile index d4abda7ab..9f047164f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/SECURITY.md b/SECURITY.md index a43246e50..4ce5255cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,11 +33,14 @@ 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). @@ -45,6 +48,10 @@ being external attackers reaching the public HTTPS endpoint. 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 diff --git a/backend/app/app_capabilities.py b/backend/app/app_capabilities.py index 5b44f9d44..358613b63 100644 --- a/backend/app/app_capabilities.py +++ b/backend/app/app_capabilities.py @@ -13,7 +13,7 @@ from typing import Any -CONTRACT_SCHEMA = 2 +CONTRACT_SCHEMA = 3 # Host-mediated browser capabilities. These are deliberately separate from @@ -148,6 +148,7 @@ def contract_from_manifest(manifest: dict[str, Any]) -> dict[str, Any]: """Return the normalized capability contract for a validated manifest.""" perms = manifest.get("permissions") or {} schedule = manifest.get("schedule") or {} + job_authority = perms.get("job_authority", "platform") requested_logs = perms.get("chat_log_access", "none") # The only chat-log route is structurally redacted. A historical ``full`` # declaration therefore has summary effectiveness, never silent full access. @@ -192,10 +193,9 @@ def contract_from_manifest(manifest: dict[str, Any]) -> dict[str, Any]: "cron": cron, "user_configurable": bool(schedule.get("user_configurable", False)), "initialize_on_install": bool(schedule.get("initialize_on_install", False)), - "agent": bool(perms.get("background_agent", False)), - # A job is outside the iframe. This label is intentionally explicit so - # ``filesystem_api: false`` is never misread as constraining the job. - "authority": "scoped_system_job" if perms.get("background_agent") else "app_job_process", + # A job is outside the iframe. This authority is intentionally explicit + # so ``filesystem_api: false`` is never misread as constraining it. + "authority": job_authority, } if job else None ), diff --git a/backend/app/manifest_contract.py b/backend/app/manifest_contract.py index 1ef7a6256..75e7f9607 100644 --- a/backend/app/manifest_contract.py +++ b/backend/app/manifest_contract.py @@ -201,13 +201,24 @@ def validate_manifest_contract(manifest) -> None: "Manifest `permissions.chat_log_access` must be one of " "none/summary/full." ) + if "background_agent" in permissions: + _fail( + "Manifest `permissions.background_agent` has been removed; use " + "`permissions.job_authority: scoped`." + ) + if ( + "job_authority" in permissions + and permissions["job_authority"] not in ("platform", "scoped") + ): + _fail( + "Manifest `permissions.job_authority` must be one of platform/scoped." + ) for field in ( "manage_apps", "manage_skills", "github_access", "github_connect", "filesystem_access", - "background_agent", ): if field in permissions and not isinstance(permissions[field], bool): _fail(f"Manifest `permissions.{field}` must be a boolean.") @@ -344,9 +355,9 @@ def validate_manifest_contract(manifest) -> None: "Manifest `schedule.initialize_on_install` requires `schedule.job`." ) - if permissions.get("background_agent") is True and not ( + if "job_authority" in permissions and not ( isinstance(schedule, Mapping) and schedule.get("job") ): _fail( - "Manifest `permissions.background_agent: true` requires `schedule.job`." + "Manifest `permissions.job_authority` requires `schedule.job`." ) diff --git a/backend/scripts/app-job-runner.py b/backend/scripts/app-job-runner.py index 191c0ca27..ca6a53259 100755 --- a/backend/scripts/app-job-runner.py +++ b/backend/scripts/app-job-runner.py @@ -24,6 +24,12 @@ DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000") TOKEN_FILE = DATA_DIR / "service-token.txt" +CURRENT_CAPABILITY_CONTRACT_SCHEMA = 3 +SUPPORTED_CAPABILITY_CONTRACT_SCHEMAS = frozenset({1, 2, 3}) +PLATFORM_JOB_AUTHORITY = "platform" +SCOPED_JOB_AUTHORITY = "scoped" +LEGACY_PLATFORM_JOB_AUTHORITY = "app_job_process" +LEGACY_SCOPED_JOB_AUTHORITY = "scoped_system_job" # Cron discards this supervisor's stdout, so every FAILURE must leave # a durable line — a silent early exit (bad path, dead token, missing @@ -198,10 +204,10 @@ def _job_access( shared.mkdir(parents=True, exist_ok=True) if shared.is_dir() and not shared.is_symlink(): (read_write if shared_level == "write" else read_only).append(shared) - # The owner-reviewed background-agent capability grants access to connected - # provider credentials, while the app's own settings may select a provider - # at runtime (Memory is one such app). job-context deliberately excludes app - # storage settings, so restricting mounts to the system primary/fallback + # Scoped authority grants access to connected provider credentials. The + # app's own settings may select a provider at runtime (Memory is one such + # app). The job context deliberately excludes app storage settings, so + # restricting mounts to the system primary/fallback # silently breaks a valid app-level override. Mount every supported provider # directory that actually exists; the masked /data tree still exposes no # other owner/platform state and ordinary app jobs never take this path. @@ -218,6 +224,51 @@ def _job_access( ) +def _job_authority(context: dict) -> str | None: + """Resolve trusted job authority without weakening modern receipts. + + A null contract is a legitimate pre-contract platform-authority state. + Schemas 1 and 2 retain the old boolean/authority pair; schema 3 carries the + manifest's explicit authority directly. Reject contradictory, incomplete, + or unknown receipts instead of silently granting platform process authority. + """ + if "capability_contract" not in context: + return None + contract = context["capability_contract"] + if contract is None: + return PLATFORM_JOB_AUTHORITY + if not isinstance(contract, dict): + return None + + schema = contract.get("schema") + if ( + type(schema) is not int + or schema not in SUPPORTED_CAPABILITY_CONTRACT_SCHEMAS + or "background" not in contract + ): + return None + background = contract["background"] + if background is None: + return PLATFORM_JOB_AUTHORITY + if not isinstance(background, dict): + return None + + authority = background.get("authority") + if schema == CURRENT_CAPABILITY_CONTRACT_SCHEMA: + if "agent" in background: + return None + if authority in (PLATFORM_JOB_AUTHORITY, SCOPED_JOB_AUTHORITY): + return authority + return None + + agent = background.get("agent") + if agent is True and authority == LEGACY_SCOPED_JOB_AUTHORITY: + return SCOPED_JOB_AUTHORITY + if agent is False and authority == LEGACY_PLATFORM_JOB_AUTHORITY: + return PLATFORM_JOB_AUTHORITY + return None + + def run() -> int: argv = sys.argv[1:] wait_for_ready = argv[:1] == ["--wait-for-ready"] @@ -293,39 +344,41 @@ def run() -> int: child_env["APP_JOB_STATE_DIR"] = str(job_state) command = ["bash", str(resolved), str(app_id)] executor = "process" - if isinstance(context.get("capability_contract"), dict): - background = context["capability_contract"].get("background") - if isinstance(background, dict) and background.get("agent") is True: - sandbox_home = Path(tempfile.mkdtemp(prefix=f"mobius-job-{app_id}-")) - if os.geteuid() == 0: - os.chown(sandbox_home, 1000, 1000) - launch, probes = select_executor( - _job_access(app_id, resolved, context), - command, - child_env, - sandbox_home, + authority = _job_authority(context) + if authority is None: + _log(app_id, "failed: invalid capability contract for app job") + return 4 + if authority == SCOPED_JOB_AUTHORITY: + sandbox_home = Path(tempfile.mkdtemp(prefix=f"mobius-job-{app_id}-")) + if os.geteuid() == 0: + os.chown(sandbox_home, 1000, 1000) + launch, probes = select_executor( + _job_access(app_id, resolved, context), + command, + child_env, + sandbox_home, + ) + if launch is None: + reasons = "; ".join( + f"{probe.executor}: {probe.detail}" for probe in probes + ) + _log( + app_id, + f"failed: no supported secure background-job executor ({reasons})", + ) + return 5 + command = launch.command + child_env = launch.env + executor = launch.executor + if executor == "landlock": + rejected = next( + probe.detail for probe in probes + if probe.executor == "bubblewrap" + ) + _log( + app_id, + f"sandbox: selected landlock; bubblewrap unavailable ({rejected})", ) - if launch is None: - reasons = "; ".join( - f"{probe.executor}: {probe.detail}" for probe in probes - ) - _log( - app_id, - f"failed: no supported secure background-job executor ({reasons})", - ) - return 5 - command = launch.command - child_env = launch.env - executor = launch.executor - if executor == "landlock": - rejected = next( - probe.detail for probe in probes - if probe.executor == "bubblewrap" - ) - _log( - app_id, - f"sandbox: selected landlock; bubblewrap unavailable ({rejected})", - ) lease_value["executor"] = executor _atomic_json(lease, lease_value) child = subprocess.Popen( diff --git a/backend/scripts/app_job_sandbox.py b/backend/scripts/app_job_sandbox.py index e7902a99c..180da81ea 100644 --- a/backend/scripts/app_job_sandbox.py +++ b/backend/scripts/app_job_sandbox.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Secure executors for reviewed ``background_agent`` app jobs. +"""Secure executors for reviewed scoped-authority app jobs. -The app manifest is normalized into one ``JobAccess`` contract. Bubblewrap -and Landlock are replaceable enforcement mechanisms for that contract; neither -is allowed to reinterpret app permissions. Selection uses a real probe and -fails closed when this host cannot enforce the contract. +The app manifest is normalized into one filesystem ``JobAccess`` contract. +Bubblewrap and Landlock are replaceable enforcement mechanisms for that +contract; neither is allowed to reinterpret app permissions. Selection uses +real primitive probes and fails closed when this host cannot provide a secure +executor. Landlock filesystem rules are delegated to util-linux ``setpriv``. The small helper in this file supplies only the two protections setpriv does not: @@ -240,7 +241,7 @@ def _helper_command(command: list[str]) -> list[str]: def probe_landlock() -> ExecutorProbe: - """Probe the complete fallback: scopes, seccomp and filesystem rules.""" + """Probe the fallback primitives: scopes, seccomp and a filesystem denial.""" abi = landlock_abi() if abi < LANDLOCK_MIN_ABI: diff --git a/backend/scripts/seed-skills/cron.md b/backend/scripts/seed-skills/cron.md index 75d31b822..921ba528b 100644 --- a/backend/scripts/seed-skills/cron.md +++ b/backend/scripts/seed-skills/cron.md @@ -13,13 +13,13 @@ The container has `cron` installed. Cron tasks run as `mobius` and get the app i `/var/spool/cron/crontabs/` lives in the image layer, not on `/data`, so a rebuild starts with an empty crontab. Installed apps should declare `schedule.default` and `schedule.job` in `mobius.json`. The installer persists an `init-cron.sh` declaration, but boot never executes app-owned shell from that file. FastAPI lifespan parses the effective cadence and job, validates the live app/source tree, and rewrites the entry through `app-job-runner.py` before cron starts. -Managed jobs receive a short-lived app-scoped `APP_TOKEN`, not the owner service token. A manifest with `permissions.background_agent: true` also runs inside the reviewed filesystem sandbox. Its job can see its read-only source, numeric app storage, declared Memory mount, and configured provider credentials; it cannot see arbitrary owner/platform state. +Managed jobs receive a short-lived app-scoped `APP_TOKEN`, not the owner service token. A manifest with `permissions.job_authority: scoped` also runs inside the reviewed filesystem sandbox. Its job can see its read-only source, numeric app storage, declared Memory mount, and configured provider credentials; it cannot see arbitrary owner/platform state. For an installable app, use the manifest contract: ```json { - "permissions": { "background_agent": true }, + "permissions": { "job_authority": "scoped" }, "schedule": { "default": "30 5 * * *", "user_configurable": true, diff --git a/backend/tests/test_app_capabilities.py b/backend/tests/test_app_capabilities.py index e292e8a2a..384a8d041 100644 --- a/backend/tests/test_app_capabilities.py +++ b/backend/tests/test_app_capabilities.py @@ -32,7 +32,7 @@ def _manifest(**over): "system_prompt": "memory-core.md", "permissions": { "chat_log_access": "summary", - "background_agent": True, + "job_authority": "scoped", "shared_memory": "write", }, "schedule": { @@ -73,9 +73,10 @@ def test_preview_returns_server_derived_contract_and_digest( "chat_start" ) assert body["capability_contract"]["background"]["authority"] == ( - "scoped_system_job" + "scoped" ) - assert body["capability_contract"]["schema"] == 2 + assert "agent" not in body["capability_contract"]["background"] + assert body["capability_contract"]["schema"] == 3 assert body["capability_contract"]["runtime"] == {} diff --git a/backend/tests/test_app_jobs.py b/backend/tests/test_app_jobs.py index 707ed745f..f77741112 100644 --- a/backend/tests/test_app_jobs.py +++ b/backend/tests/test_app_jobs.py @@ -16,6 +16,7 @@ from jose import jwt from app import app_jobs, models +from app.app_capabilities import CONTRACT_SCHEMA, contract_from_manifest from app.config import get_settings from app.install import _crontab_command_path @@ -114,6 +115,100 @@ def _load_runner(): return module +def test_runner_job_authority_matches_the_reviewed_contract(): + runner = _load_runner() + assert runner.CURRENT_CAPABILITY_CONTRACT_SCHEMA == CONTRACT_SCHEMA + assert runner.SUPPORTED_CAPABILITY_CONTRACT_SCHEMAS == { + 1, 2, CONTRACT_SCHEMA, + } + + assert runner._job_authority({}) is None + legacy = {"capability_contract": None} + no_job = {"capability_contract": contract_from_manifest({})} + omitted = { + "capability_contract": contract_from_manifest({ + "schedule": {"job": "job.sh"}, + }), + } + assert runner._job_authority(legacy) == runner.PLATFORM_JOB_AUTHORITY + assert runner._job_authority(no_job) == runner.PLATFORM_JOB_AUTHORITY + assert runner._job_authority(omitted) == runner.PLATFORM_JOB_AUTHORITY + + for declared, expected, legacy_agent, legacy_authority in ( + ( + "platform", + runner.PLATFORM_JOB_AUTHORITY, + False, + runner.LEGACY_PLATFORM_JOB_AUTHORITY, + ), + ( + "scoped", + runner.SCOPED_JOB_AUTHORITY, + True, + runner.LEGACY_SCOPED_JOB_AUTHORITY, + ), + ): + contract = contract_from_manifest({ + "schedule": {"job": "job.sh"}, + "permissions": {"job_authority": declared}, + }) + assert contract["background"]["authority"] == expected + assert "agent" not in contract["background"] + assert runner._job_authority( + {"capability_contract": contract}, + ) == expected + for schema in (1, 2): + legacy_contract = dict( + contract, + schema=schema, + background={ + **contract["background"], + "agent": legacy_agent, + "authority": legacy_authority, + }, + ) + assert runner._job_authority( + {"capability_contract": legacy_contract}, + ) == expected + + +@pytest.mark.parametrize("contract", [ + {"schema": CONTRACT_SCHEMA}, + { + "schema": CONTRACT_SCHEMA, + "background": { + "agent": True, + "authority": "scoped", + }, + }, + { + "schema": CONTRACT_SCHEMA, + "background": { + "authority": "scoped_system_job", + }, + }, + { + "schema": 2, + "background": { + "agent": False, + "authority": "scoped_system_job", + }, + }, + { + "schema": 2, + "background": { + "agent": 1, + "authority": "scoped_system_job", + }, + }, + {"schema": True, "background": {"authority": "scoped"}}, + {"schema": CONTRACT_SCHEMA + 1, "background": None}, +]) +def test_runner_rejects_inconsistent_or_unknown_modern_job_authority(contract): + runner = _load_runner() + assert runner._job_authority({"capability_contract": contract}) is None + + def test_live_check_calls_real_app_endpoint(monkeypatch): runner = _load_runner() seen = {} @@ -163,7 +258,10 @@ def test_bootstrap_waits_for_ready_before_minting_a_job_token( ) monkeypatch.setattr(runner, "_app_is_live", lambda *_args: True) monkeypatch.setattr( - runner, "_job_context", lambda *_args: {"source_dir": str(source)}, + runner, "_job_context", lambda *_args: { + "source_dir": str(source), + "capability_contract": None, + }, ) monkeypatch.setattr( runner.subprocess, "Popen", lambda *_args, **_kwargs: types.SimpleNamespace(wait=lambda: 0), @@ -247,7 +345,10 @@ def test_wrapper_runs_job_only_after_live_check(tmp_path, monkeypatch): monkeypatch.setattr( runner, "_job_context", - lambda app_id, token: {"source_dir": str(source)}, + lambda app_id, token: { + "source_dir": str(source), + "capability_contract": None, + }, ) popen = types.SimpleNamespace(wait=lambda: 0) calls = [] @@ -269,6 +370,46 @@ def test_wrapper_runs_job_only_after_live_check(tmp_path, monkeypatch): assert "AGENT_TOKEN" not in child_env +def test_wrapper_does_not_downgrade_invalid_current_job_authority( + tmp_path, monkeypatch, +): + runner = _load_runner() + data_dir = tmp_path / "data" + source = data_dir / "apps" / "memory" + source.mkdir(parents=True) + job = source / "fetch.sh" + job.write_text("#!/bin/sh\nexit 0\n") + monkeypatch.setattr(runner, "DATA_DIR", data_dir) + monkeypatch.setattr(runner, "_mint_app_token", lambda _app_id: "app-token") + monkeypatch.setattr(runner, "_app_is_live", lambda *_args: True) + monkeypatch.setattr(runner.os, "getsid", lambda _pid: os.getpid()) + monkeypatch.setattr( + runner, + "_job_context", + lambda *_args: { + "source_dir": str(source), + "capability_contract": { + "schema": CONTRACT_SCHEMA, + "background": { + "authority": "scoped_system_job", + }, + }, + }, + ) + calls = [] + monkeypatch.setattr( + runner.subprocess, + "Popen", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + monkeypatch.setattr( + runner.sys, "argv", ["app-job-runner.py", "57", str(job)], + ) + + assert runner.run() == 4 + assert calls == [] + + def test_wrapper_rejects_a_job_from_another_live_app(tmp_path, monkeypatch): runner = _load_runner() data_dir = tmp_path / "data" @@ -332,7 +473,7 @@ def test_wrapper_rejects_job_context_without_exact_app_identity( assert calls == [] -def test_background_agent_policy_contains_only_declared_data_scope( +def test_scoped_authority_policy_contains_only_declared_data_scope( tmp_path, monkeypatch, ): runner = _load_runner() @@ -349,7 +490,10 @@ def test_background_agent_policy_contains_only_declared_data_scope( "primary": {"provider": "claude"}, "fallback": None, "capability_contract": { - "background": {"agent": True}, + "schema": CONTRACT_SCHEMA, + "background": { + "authority": "scoped", + }, "data": {"shared_memory": "write"}, }, } @@ -382,7 +526,10 @@ def test_runner_records_executor_and_cleans_job_home(tmp_path, monkeypatch): runner, "_job_context", lambda *_args: { "source_dir": str(source), "capability_contract": { - "background": {"agent": True}, + "schema": CONTRACT_SCHEMA, + "background": { + "authority": "scoped", + }, "data": {"shared_memory": "none"}, }, }, @@ -468,7 +615,10 @@ def test_secure_executors_enforce_the_same_data_contract(executor, monkeypatch): "primary": None, "fallback": None, "capability_contract": { - "background": {"agent": True}, + "schema": CONTRACT_SCHEMA, + "background": { + "authority": "scoped", + }, "data": {"shared_memory": "write"}, }, } @@ -562,7 +712,10 @@ def test_landlock_fallback_scopes_processes_and_unix_sockets(monkeypatch): monkeypatch.setattr(runner, "DATA_DIR", data_dir) policy = runner._job_access(57, probe.resolve(), { "capability_contract": { - "background": {"agent": True}, + "schema": CONTRACT_SCHEMA, + "background": { + "authority": "scoped", + }, "data": {"shared_memory": "none"}, }, }) diff --git a/backend/tests/test_validate_app_cli.py b/backend/tests/test_validate_app_cli.py index 6e95f0cad..0bad78a35 100644 --- a/backend/tests/test_validate_app_cli.py +++ b/backend/tests/test_validate_app_cli.py @@ -82,7 +82,7 @@ def test_validator_rejects_manifest_type_holes_and_missing_package_files(tmp_pat {"id": "Bad/Slug"}, {"permissions": {"cross_app_access": "admin"}}, {"permissions": {"shared_memory": "all"}}, - {"permissions": {"background_agent": "yes"}}, + {"permissions": {"job_authority": "root"}}, {"offline": {"writes": "eventually"}}, {"schedule": {"default": "@daily"}}, {"schedule": {"default": "0 0 * * * *"}}, @@ -121,10 +121,10 @@ def test_system_prompt_requires_explicit_system_app_identity(tmp_path): _validate_manifest(manifest) -def test_background_agent_requires_declared_job(tmp_path): +def test_job_authority_requires_declared_job(tmp_path): _write_app(tmp_path, "export default function App(){ return
}") manifest = json.loads((tmp_path / "mobius.json").read_text()) - manifest["permissions"] = {"background_agent": True} + manifest["permissions"] = {"job_authority": "scoped"} (tmp_path / "mobius.json").write_text(json.dumps(manifest)) result = _run(tmp_path) @@ -134,6 +134,20 @@ def test_background_agent_requires_declared_job(tmp_path): _validate_manifest(manifest) +def test_removed_background_agent_permission_fails_clearly(tmp_path): + _write_app(tmp_path, "export default function App(){ return
}") + manifest = json.loads((tmp_path / "mobius.json").read_text()) + manifest["permissions"] = {"background_agent": True} + manifest["schedule"] = {"job": "job.sh"} + (tmp_path / "mobius.json").write_text(json.dumps(manifest)) + + result = _run(tmp_path) + assert result.returncode == 1 + assert "has been removed" in result.stderr + with pytest.raises(HTTPException, match="has been removed"): + _validate_manifest(manifest) + + def test_validator_materializes_declared_static_asset_destinations(tmp_path): _write_app( tmp_path, diff --git a/docker-compose.yml b/docker-compose.yml index 8fba878a2..66008fb46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: container_name: mobius init: true restart: unless-stopped - # Bubblewrap is the preferred boundary for reviewed `background_agent` app + # Bubblewrap is the preferred boundary for reviewed scoped-authority app # jobs. Nested Docker blocks its namespace setup by default. The image's # setuid bwrap keeps only these setup capabilities and drops them before the # unprivileged job starts; recoveryd deliberately receives none of them.