From 23b6a55ffc18871178546429b084cf90647ac4fc Mon Sep 17 00:00:00 2001 From: rgjordana <309798046+rgjordana@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:26 +0000 Subject: [PATCH 1/3] Document explicit app-job authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define platform and scoped authority as the two current execution boundaries, name the manifest choice directly, and record executor limits and receipt compatibility. Co-authored-by: Möbius Agent --- ARCHITECTURE.md | 7 +- BACKGROUND_JOBS.md | 147 +++++++++++++++++++++------- SECURITY.md | 15 ++- backend/scripts/seed-skills/cron.md | 4 +- docker-compose.yml | 2 +- 5 files changed, 128 insertions(+), 47 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0153263b7..91d545431 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 | diff --git a/BACKGROUND_JOBS.md b/BACKGROUND_JOBS.md index 6c022c7d8..9492c6007 100644 --- a/BACKGROUND_JOBS.md +++ b/BACKGROUND_JOBS.md @@ -1,18 +1,24 @@ -# 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. + +## The stable scoped design The stable part of the system is a small semantic contract: @@ -24,7 +30,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 +39,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 +87,58 @@ 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. It had one official consumer, Memory, which migrates to the +explicit scoped declaration. Rejecting the old spelling is important: silently +ignoring it would grant that job platform authority. + +Capability receipt schema 3 records `authority: scoped|platform` directly and +does not carry the old redundant agent boolean. The launcher continues to read +coherent schema 1 and schema 2 receipts already stored on persistent volumes, +mapping their old boolean/authority pair to the current values. A null receipt +remains the documented pre-contract platform-authority state. A known receipt +with `background: null` is also complete: owner-authored or legacy apps can +have a job discovered from their source tree without a stored manifest job +declaration. + +A missing receipt field, unknown future schema, malformed current authority, +or contradictory legacy pair fails closed. A future schema must therefore make +its launcher migration deliberate rather than silently changing 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 +152,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 @@ -134,8 +206,8 @@ 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. +the gap visible because it was the first Store app to combine scoped job +authority 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 @@ -191,20 +263,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,7 +286,8 @@ 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 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/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/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. From 5c84572b0d4b261ff04ac0d54e35c5d1117f28ce Mon Sep 17 00:00:00 2001 From: rgjordana <309798046+rgjordana@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:26 +0000 Subject: [PATCH 2/3] Validate explicit app-job authority receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit schema-3 receipts with direct scoped or platform authority, reject the removed manifest boolean, preserve coherent stored schema-1/2 receipts, and fail closed on malformed context. Co-authored-by: Möbius Agent --- backend/app/app_capabilities.py | 10 +- backend/app/manifest_contract.py | 17 ++- backend/scripts/app-job-runner.py | 125 ++++++++++++------ backend/scripts/app_job_sandbox.py | 13 +- backend/tests/test_app_capabilities.py | 7 +- backend/tests/test_app_jobs.py | 167 +++++++++++++++++++++++-- backend/tests/test_validate_app_cli.py | 20 ++- 7 files changed, 296 insertions(+), 63 deletions(-) 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/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, From a3034216dd44e58748c6e240842bde7fefb91783 Mon Sep 17 00:00:00 2001 From: rgjordana <309798046+rgjordana@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:12:39 +0000 Subject: [PATCH 3/3] Tighten app-job authority documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Möbius Agent --- ARCHITECTURE.md | 4 +- BACKGROUND_JOBS.md | 104 +++++++++++++++++---------------------------- Dockerfile | 2 +- 3 files changed, 42 insertions(+), 68 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 91d545431..c813da3d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1106,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 9492c6007..88ef45088 100644 --- a/BACKGROUND_JOBS.md +++ b/BACKGROUND_JOBS.md @@ -18,6 +18,20 @@ 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: @@ -107,24 +121,12 @@ 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. It had one official consumer, Memory, which migrates to the -explicit scoped declaration. Rejecting the old spelling is important: silently -ignoring it would grant that job platform authority. - -Capability receipt schema 3 records `authority: scoped|platform` directly and -does not carry the old redundant agent boolean. The launcher continues to read -coherent schema 1 and schema 2 receipts already stored on persistent volumes, -mapping their old boolean/authority pair to the current values. A null receipt -remains the documented pre-contract platform-authority state. A known receipt -with `background: null` is also complete: owner-authored or legacy apps can -have a job discovered from their source tree without a stored manifest job -declaration. - -A missing receipt field, unknown future schema, malformed current authority, -or contradictory legacy pair fails closed. A future schema must therefore make -its launcher migration deliberate rather than silently changing an installed -job's authority. +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 @@ -193,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 scoped job -authority 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. | @@ -290,5 +264,5 @@ When this boundary changes: 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