# SECURITY

## 0.8.2 — cross-platform setup and native confinement

`juju setup` is a **local-owner installation action**, not an MCP permission grant. It can install or register local dependencies, but it does not grant OAuth scopes/workspace access, enable trusted/bypass, approve schedules, enable outbound web by default, or authorize Desktop apps. Web remains an explicit `--enable-web` owner opt-in and Desktop uses separate app consent.

Direct setup downloads are fixed-version and integrity-pinned. Redirects are restricted to reviewed HTTPS publisher hosts. Managed paths remain below private JUJU state and are activated in the JUJU process rather than appended to the machine PATH. Existing owner configuration is replaced atomically only after a concurrent-change check.

Windows command sandboxing uses pinned `@anthropic-ai/sandbox-runtime@0.0.75`, its dedicated `srt-sandbox` account and SID-scoped Windows Filtering Platform fence. JUJU parses and cross-checks the exact runtime status identity before accepting provisioning, and a validation receipt is tied to the runtime + JUJU worker fingerprint. `sandbox:true`/required policy fails closed unless real confinement checks have passed.

The dedicated sandbox group receives **read/execute only** on the exact architecture-specific `srt-win` native helper subtree. JUJU verifies required RX and rejects write, append, delete, DACL-change or ownership rights. It does not make the whole JUJU state, model cache, JavaScript runtime or `System32` writable/readable just to launch a command. Random Windows sandbox launch scratch is placed under a common traversable `ProgramData` parent while each leaf is private and receives only temporary session grants. Owner-state reads remain explicitly denied inside the sandbox.

Playwright Chromium on Windows keeps `chromiumSandbox:true`. Where elevated Administrator inheritance prevents AppContainer startup, setup adds RX only for the standard AppContainer package SIDs on the exact Playwright `chromium-<revision>` cache subtree; no write/modify permission is granted and custom browser locations are refused by the automatic ACL routine.

Windows desktop uses an unlocked interactive session, window/process identity, app allowlists, freshness/bounds checks and UI Automation/SendInput without UAC/UIPI bypass. Linux desktop uses same-user X11 process identity plus XTEST/AT-SPI and is claimed only for X11/XWayland-style sessions with those facilities. Pure Wayland portal-only sessions are outside the 0.8.2 claim.

Native acceptance evidence is in [TEST_REPORT](TEST_REPORT.md). Manual cases not actually exercised remain unclaimed.

## 0.8.0 candidate — Windows boundaries and authenticated owner IPC

The Windows implementation is **experimental pending native CI and manual
acceptance**. This section supersedes historical descriptions of an
unauthenticated, POSIX-permissions-only IPC protocol below.

Owner IPC now uses a private per-listener credential and fresh challenge/HMAC
proofs for both peers and each request/response. The token is not sent on the
wire. The client verifies the server before sending an owner command; stale,
replayed or invalid proofs are refused. Windows uses generation-specific
named pipes; POSIX retains Unix sockets. There is no unauthenticated fallback
for old servers, including `status` and `stop`.

Windows state defaults to LOCALAPPDATA/juju. The private directory code
establishes and checks DACLs for the current SID, SYSTEM and Administrators;
failed ACL establishment/verification is an error. POSIX mode bits are not
presented as a Windows security boundary. Same-OS-user processes and
administrators remain trusted; this is not protection from the machine owner.
The two-user Windows acceptance gate is still outstanding.

Implicit helper executables resolve to absolute trusted paths outside the
workspace, including canonical-path checks; Windows PATHEXT is allowlisted.
Batch arguments are deliberately restricted rather than escaped heuristically.
Native argv is bounded in UTF-16 units. NTFS path checks reject ADS, reserved
devices, trailing dots/spaces and short-name spellings and recheck canonical
components against containment and secret/protected policy. These checks do
not eliminate the documented same-user filesystem check/use races. Actual
8.3-volume, junction, DACL and Defender behavior still requires native evidence.

Windows has **no JUJU command OS sandbox**. Explicit sandbox requirements fail
closed, including schedules; no automatic fallback changes the owner's saved
policy. Jobs run with OS-user privileges and cancellation uses a hard kill of
the live owned process tree, not old database PIDs. Escaped descendants and
Job Object confinement are not guaranteed. Chromium's own sandbox is distinct
from the JUJU command sandbox and is not automatically disabled.

See [ADR-030](adr/030-windows-native-candidate.md),
[Windows implementation/evidence status](WINDOWS_0.8.0.md) and
[manual acceptance](MANUAL_ACCEPTANCE.md). Tests use isolated synthetic state;
they do not modify the connected owner's real security configuration.


> **0.2.0 update (supersedes older workspace-only grant wording below):** New
> OAuth grants authenticate the JUJU installation; every request still checks
> a separate current-workspace/client scope ACL. Legacy tokens remain bound to
> their original root until a fresh login. Bare `juju` now starts HTTP and a
> private token-protected config listener on 127.0.0.1:21731; subprocess clients
> must explicitly use `juju stdio`. `--allow --all` and `--bypass` are run-only
> execution overrides, never authentication or workspace-ACL bypasses.
> See [ADR-017](adr/017-installation-identity-local-config.md) and the
> [0.2 setup guide](../README.md#quickstart) for the current contract.
>
> **0.3.0:** the owner can switch the ACTIVE workspace at runtime from the
> Local Config page ([ADR-019](adr/019-runtime-workspace-switch.md)). The
> switch is owner-only (loopback config plane + private capability), never an
> MCP tool, always goes through the strict root policy, refuses while jobs run
> or requests are in flight, refuses a root another live JUJU process serves,
> mints a new workspaceId/epoch, and applies the NEW root's own trust and
> client ACL rows — nothing is copied. Old-workspace context is refused
> (`WORKSPACE_MISMATCH`/`STALE_WORKSPACE`), pending approvals of the old
> workspace are denied, and a failed switch leaves the old workspace serving.
> The Local Config UI ships as static assets with a `'self'`-only CSP (no
> inline code, no external assets); everything from the API is rendered with
> DOM text APIs, never HTML strings.

This document states JUJU's security model **and its limits** plainly. Several
of the most important statements here are about what JUJU does **not** protect
against. Read them before enabling `trusted` mode or exposing a tunnel.

> **0.7.1:** the compact HTTP surface (ADR-029) adds gateway tools
> (`juju_write` and friends) that DISPATCH to the existing per-operation
> contract; they grant nothing. Every gateway call re-runs the target
> operation's own OAuth scope, workspace ACL, workspaceId/epoch, strict input
> schema, trust policy, per-action local approvals (bound to the target
> operation), path/secret guards, hash conflicts, idempotency and audit
> through the same `invokeToolDefinition` pipeline as a direct call. A
> read-only token cannot write/execute through any gateway; `juju_discover`
> serves only static tool metadata (schemas + hashes), never tokens, grants
> or owner state. Owner controls remain outside every MCP surface.

## Threat model

**In scope (what v1 defends against):**

- A remote, unauthenticated caller reaching the public endpoint. Every tool
  requires a valid OAuth access token bound to this workspace; there is no
  local-IP bypass, because tunnel traffic also arrives on loopback
  ([src/server/appServer.ts](../src/server/appServer.ts), locked-mode and
  bearer-auth gating).
- A remote caller with a token for a *different* workspace trying to read this
  one (workspace binding in the token, rechecked every call —
  [src/auth/verifier.ts](../src/auth/verifier.ts)).
- Tool bugs and confused-deputy mistakes that would read outside the workspace,
  follow a symlink out, or overwrite unrelated files (shared path policy —
  [src/workspace/fs.ts](../src/workspace/fs.ts); preview/hash/journal —
  [src/services/changes/applier.ts](../src/services/changes/applier.ts)).
- Untrusted repository content (README, AGENTS.md, `.juju.json`, comments, test
  output) attempting to change policy, widen scope, or exfiltrate secrets. Such
  content is treated as data, never instructions
  ([src/config/projectConfig.ts](../src/config/projectConfig.ts) rejects
  authority keys; overview labels docs untrusted).

**Explicitly OUT of scope (NOT defended against in v1):**

- **A hostile process running as the same OS user.** Once you approve arbitrary
  execution, or if malware already runs under your account, it can read JUJU's
  state, its IPC socket, and your files directly. JUJU is not an OS sandbox.
- **Filesystem TOCTOU races** against a concurrent local actor. The path checks
  are check-then-use in pure Node; a hostile local process racing between the
  check and the operation is not formally prevented.
- **Grandchild processes that detach** from a job's process group (daemons).
  `job_cancel` signals the owned group; escapees may survive (reported, not
  hidden).

## The central limitation: `cwd` is not a sandbox

The workspace directory guard restricts JUJU's **own** file tools. It does
**not** confine programs JUJU runs for you. When you grant execution
(`trusted` mode, or a per-action approval), the program runs with your real OS
user's privileges and can read and write anywhere you can — inside or outside
the workspace, network included.

- `shell: false` and argv arrays (used everywhere JUJU spawns —
  [src/services/jobs/jobManager.ts](../src/services/jobs/jobManager.ts),
  [src/services/git/gitService.ts](../src/services/git/gitService.ts)) remove
  shell parsing/injection **in JUJU's wrapper**. They do **not** confine the
  program: `npm test`, `node`, `python` and friends execute arbitrary code
  through package scripts, dependency hooks, and interpreters.
- A command named `test` or `lint` is still arbitrary code.

If you need a real boundary, run JUJU (and the code it executes) inside an OS
sandbox, VM, or container. That is out of scope for v1 and is **not** claimed.

## Trust modes

| Mode | read/search | file changes (via plans) | run code |
|---|---|---|---|
| `inspect` (default) | allowed | **per-action** local approval | **per-action** local approval |
| `edit` | allowed | standing local consent within policy | **per-action** local approval |
| `trusted` | allowed | allowed | allowed — **your OS privileges; no sandbox** |

`trusted` mode requires an explicit `juju trust --mode trusted --yes` with a
printed warning. See [src/security/policy.ts](../src/security/policy.ts).

## Authorization model (defense in depth)

Real authority is the **intersection** of three independent checks:

1. **OAuth scope** on the token: `juju:read` / `juju:write` / `juju:exec`
   ([src/tools/context.ts](../src/tools/context.ts) refuses a call whose token
   lacks the tool's required scope, before the handler runs).
2. **Workspace binding**: the token names a workspace and grant; the verifier
   rejects a token for another workspace or a revoked grant on every call.
3. **Local trust mode + per-action approvals**: mutations and execution are
   gated by the trust matrix; where approval is required, it is bound to the
   principal, workspace, epoch, and an exact action digest with an expiry
   ([src/security/approvals.ts](../src/security/approvals.ts)). Changing the
   arguments changes the digest and requires a fresh approval.

Approvals are granted **only** over the private local IPC socket
(`juju approve ID`). No MCP tool and no public HTTP route can approve anything;
`approval_status` is read-only. The ChatGPT confirmation dialog is an
additional layer, not a replacement for server-side authorization.

## Filesystem policy

Every filesystem touch — reads, listing, search candidates, Git output scoping,
the TypeScript language service, diagnostics, change plans, backups — goes
through one `WorkspaceFS` ([src/workspace/fs.ts](../src/workspace/fs.ts)):

- workspace-relative POSIX paths only; absolute, `~`, drive-letter, UNC, NUL and
  `..` traversal are rejected lexically;
- **segment-aware containment** via `path.relative` (never a bare
  `startsWith`, so `/repo2` does not pass as inside `/repo`);
- **no symlink traversal**: every path component from the root down is `lstat`ed
  and a symlink anywhere on the path is refused;
- **hardlinked regular files** (`nlink > 1`) are refused for direct file tools;
- **secret deny** (`.env*`, keys, `.npmrc`, `.ssh`, cloud creds, `*.pem`,
  `*.tfstate`, owner-configured globs) is a hard deny that `includeIgnored`
  can **never** bypass, and applies to search, Git output and semantic tools
  too ([src/workspace/ignores.ts](../src/workspace/ignores.ts)).
- `.env.example` is denied by default (secure default per the spec's conflict
  rule; ADR-011). Allow it only via explicit local policy.

`.git/` internals and JUJU state are protected: no raw `.git` file tool, and
tracked secrets are filtered out of Git output even though they are committed.

**Data egress:** when the AI reads a file, that content is sent to your MCP
client/provider. The secret denylist is best-effort; ordinary source files can
still contain secrets. You are responsible for what you expose.

## Change safety & recovery

- **Preview → approve → apply → rollback.** Reads carry raw-byte SHA-256;
  applies re-verify every expected hash and refuse `FILE_CHANGED` on drift.
- **Durable journal + per-file backups**; per-file atomic temp-write + `fsync` +
  rename. Multi-file changes are **not** one atomic filesystem transaction; a
  mid-apply failure that cannot be cleanly reverted becomes
  `PARTIAL_RECOVERY_REQUIRED` and blocks further mutations until
  `juju recover`. JUJU never runs `git reset --hard`, `git clean`, or `stash`.
- **Rollback refuses** if a human edited a changed file after apply — human
  bytes are never overwritten.
- **Idempotency** keys are durable (≥24h): same key + same payload replays the
  same receipt; same key + different payload is `IDEMPOTENCY_CONFLICT`. After a
  crash in the gap between a side effect and its receipt, state is marked
  uncertain and never auto-replayed.

## Secrets, keys, and state at rest

- Global config dir is `0700`, files `0600`; the SQLite DB and key files are
  `0600` ([src/config/paths.ts](../src/config/paths.ts),
  [src/auth/keys.ts](../src/auth/keys.ts)).
- Signing (ES256 JWKS) and cookie keys are persistent and generated once.
  **Encryption at rest is not provided and would not protect against a process
  running as the same OS user** — that is the documented limit.
- Tokens are **never** logged and **never** passed to child processes; the
  child environment is a whitelist that excludes `JUJU_CONFIG_DIR`,
  `NODE_OPTIONS`, `LD_PRELOAD`/`DYLD_*`, and OAuth material
  ([src/security/env.ts](../src/security/env.ts)). Error/log text is redacted
  best-effort ([src/security/redact.ts](../src/security/redact.ts)).
- The npm tarball contains no state DB, keys, tokens, or `.env`; there are no
  install lifecycle hooks (verified by `tests/packaging/pack.test.ts`).

## Network boundary

- Binds `127.0.0.1` only. `EADDRINUSE` exits non-zero; JUJU never kills the
  conflicting process or silently switches ports.
- Host header allowlist (configured hostname + loopback) and Origin validation;
  `Origin: null` is denied. `trust proxy` is limited to loopback; the issuer is
  always the explicit configured `publicUrl`, never derived from
  `Host`/`X-Forwarded-*`.
- `/healthz` returns minimal liveness only — no paths, user name, or secrets.
  No debug/Inspector/metrics endpoint is exposed on the public listener.

## Reporting

This is a reference implementation, not a penetration-tested product. The
security design (IPC consent, immutable plans, revocation, budgets) is
implemented and covered by adversarial tests (`tests/security/*`), but same-user
arbitrary execution and filesystem races are **not** solved by a path
allowlist. Treat `trusted` mode as "I trust this code with my account".

## v1.1 direct tools and `run_command`

The direct tools (`write_file`, `edit_file`, `delete_path`, `move_path`,
`make_directory`) are one-call fronts over the same planner and journaled
applier as `preview_changes → apply_changes`: every direct write is
hash-verified, backed up, recorded in `change_history` and reversible. They
add ergonomics, not authority — path policy and secret denies apply unchanged,
and in `inspect` mode they still require a local approval.

`run_command` executes a caller-supplied command **string** via a fixed system
shell binary (`/bin/bash -c`, falling back to `/bin/sh`). Security-wise this is
identical to what a trusted caller could already do with
`exec_command(program:'sh', args:['-c', …])`: it is gated by the exec policy
(trusted mode, or a per-action `juju approve`), runs with your OS privileges,
and is **not** sandboxed. JUJU itself still spawns with `shell:false` — the
only shell parsing is the one the caller explicitly requested.
`git_commit` runs repository hooks and is gated as an exec-class action for the
same reason; `all: true` stages only paths from the scoped, secret-filtered
status, so a stray `.env` is never committed.

## v1.2 additions

**stdio transport (`juju stdio`).** No OAuth: the client is a process the same
OS user started in the same session, so it *is* the owner's principal
(`local-stdio`, all scopes). This mode never listens on a socket other than the
private IPC one, and the HTTP server never sets a local principal — an HTTP
request without a verified bearer token can never inherit it. Trust modes,
approvals, path policy and the journal are identical in both transports.

**OS sandbox for commands** (`run_command{sandbox}`, `commandSandbox`). macOS
`sandbox-exec` (seatbelt) / Linux `bwrap` wrap the command's process tree so it
can **write** only inside the workspace, temp dirs and declared cache paths, and
optionally cannot open network connections. It does not restrict *reads*, does
not protect against a hostile same-user process, and `sandbox-exec` is
deprecated by Apple (still functional). `require` fails closed when no sandbox
facility exists; `prefer` reports `sandboxed:null` and runs unsandboxed.

**Parallel commands** (`run_commands`) share the concurrent-job limit and need
one approval per batch outside `trusted`.

**`fetch_url`** is off unless the owner sets `allowWebFetch`. It is https-only,
resolves the host and refuses loopback/private/link-local/CGNAT addresses
(re-checked on every redirect hop, max 5), sends no cookies or auth, caps the
body, and is gated like a command because it is outbound network access from
your machine. A DNS-rebinding window between the check and the connection
remains (documented limitation).

**LSP adapter.** Language servers are owner-registered (`juju lsp add`), resolved
from the trusted PATH or an absolute path outside the workspace, run as your
user, and only ever see files read through `WorkspaceFS`. Out-of-workspace
locations they report are counted, never disclosed, and block partial renames.

**`apply_patch` / `replace_in_files` / `edit_file{dryRun}`** create the same
immutable plans as `preview_changes` and commit through the same journal —
no new write path was added.

**Secret exceptions.** `secretAllow` (global config only) can re-allow a specific
denied path such as `.env.example`; repository config cannot.


## 0.3.1: reviewed workspace binding and handler lifetime

Every local config POST requires X-Juju-Workspace and X-Juju-Epoch from the
owner's last reviewed state in addition to the private bearer token. Missing,
stale or switching contexts return 409 before mutation, including A→B→A.
No automatic retry on a new workspace. The UI captures context before consent
dialogs and discards unsaved edits when the workspace/epoch changes.

MCP transport activity and tool handler lifetime are tracked separately. A
socket close releases transport accounting only. The tool lease lasts through
handler completion, audit and response construction (finally releases it on
failure). Switching stays gated through old-service teardown. Shutdown refuses
new work, closes HTTP connections, and waits for tool leases before closing
workspace/installation stores. A stuck handler may delay graceful shutdown;
we do not close its database from underneath it after a fixed timeout.

## 0.4: desktop is a separate owner-controlled capability

See [DESKTOP.md](DESKTOP.md) and [ADR-020](adr/020-desktop-access.md). The owner
explicitly requested this extension beyond v1's workspace-only tools. Default
is OFF even under trusted, --allow --all or --bypass. Temporary owner grants name
exact application bundle IDs, current workspace/epoch and a 1–480 minute TTL.
Since 0.4.1, explicit local `--persist --yes` instead saves consent for this
workspace path until revoked, even while stopped. It resumes on restart/return
to that path; other roots get no permission. Temporary grants retain their
original expiry/boot behavior. CLI grants live in private SQLite, never in
repository config. Each replacement has a new revision; snapshots/approvals
still bind epoch, principal and policy revision. Disable (CLI or private UI)
forgets saved consent permanently, including while the server is stopped.
Native setup/OS permission requests are
local CLI operations, never remote tool side effects. The config stop endpoint
is on 21731 only, behind its token and stale-context checks.

Desktop content/input requires juju:exec + current path/client access. Control
also requires the usual exec trust/approval. Captures and AX may disclose data
outside the workspace in permitted apps; they are not subject to filesystem
secret globs. AX skips secure field values/children, but screenshots/OCR and
ordinary labels can expose private information. No blanket redaction guarantee.

Short-lived, principal-bound snapshots and native window/frontmost/geometry/
occlusion checks reduce accidental input to another window. UI or focus can
still change between checks and dispatch. App allowlists are not an OS sandbox;
apps can act outside the workspace, trusted shell retains OS-user rights, and
same-UID interference is outside the boundary. Changes caused by desktop input
are not journaled file plans and cannot be rolled back by rollback_changes.

Each attempted action writes a digest-only durable receipt before native input.
Uncertain actions are not retried automatically. Stop invalidates snapshots and
blocks future calls but cannot retract posted OS events. Desktop operations
serialize within the service; this is not a machine-wide lock against other
processes. Native child environment excludes inherited credentials and the
helper accepts typed JSON only. It never enables OS permissions remotely.

## 0.4.2 explicit local kill command

`juju kill` is owner CLI only. It stops verified JUJU control endpoints for the
selected installation; it does not expose a remote kill tool or HTTP route.
It checks workspace identity and socket ownership/modes and never signals PIDs
from a state record or kills an unrelated service just because it occupies
21730. Same-UID interference remains outside the trust boundary. Runtime
per-instance records are removed on orderly close; stale sockets/records do
not authorize blind process termination.

Only JUJU-owned jobs receive cancellation signals through their live handles.
Active file operations are drained before their stores close. Unresponsive
servers/unfinished drains are reported with nonzero exit, without an unsafe
force-kill fallback. Keys, tokens, grants, ACLs, saved settings and user files
are not erased. Existing token expiry/revocation and workspace authorization
still apply after restart; this command is not a reconnect guarantee by a
third-party client.

## Large inputs (0.4.3)

Input profiles are local-owner configuration, never an MCP action or repo setting.
They do not relax OAuth, scopes, workspace ACL, trust, sandbox or path guards.
There is no unlimited mode: HTTP/STDIO JSON buffers, UTF-8 shell/file content,
plan totals and native argv stay bounded. HTTP authenticates before buffering
large JSON. Batch command sizes/NUL are checked before any launch. Literal
replace expansion is rejected before constructing excessive output strings.
Private long-command scripts are 0600 in 0700 job directories, written only after
authorization and state registration, removed on normal close/cancel/spawn failure.
A crash can leave source in private state; it is never rerun on restart. Same-UID
interference remains outside the security boundary. Bigger inputs can consume
more memory/disk; output limits remain separate and unchanged. See LARGE_INPUTS.

## 0.6.0 direct access and scheduled approval

Per-conversation confirmation and chat allowlists have been removed at the
owner's request. Tools use OAuth/local identity, workspace ACL, required scope,
workspace ID/epoch and their existing trust/action/Desktop checks. No chat ID
is trusted, required or inferred. Conversations sharing an authorized connection
share that connection's access; JUJU does not claim per-chat isolation.

Legacy usage/chat database rows are retained as inert history. They no longer
allow, deny, expire or revoke runtime access. To revoke access, remove the
client's workspace scopes or revoke the OAuth grant. Reload cached client tool
schemas after upgrade; see [CONFIRMATION](CONFIRMATION.md).

Schedules require their own immutable local approval. See [SCHEDULES](SCHEDULES.md).
Default scheduled execution requires an OS sandbox with network off; explicit
owner --no-sandbox is possible. Commands use current project scripts/dependencies,
not an approved code revision. Remote proposals cannot approve themselves.

Local approval relies on the existing OS-user/private-IPC boundary. An AI with
unsandboxed trusted exec or sufficient Desktop rights can act as that OS user;
this system cannot protect the owner control plane from hostile same-UID code.
Server instructions prohibit using exec/desktop to self-approve, but those
instructions do not replace OS isolation. Directory guards are not a sandbox.

## 0.6.1 owner client picker

Filtering Client access changes presentation, not OAuth authority. Registrations
and grants remain installation-wide; ACLs are still per workspace. No automatic
scope grant, global client deletion or inferred active connection is introduced.
The new available-client GET requires the same private token/origin/proxy checks
plus workspace/epoch headers; candidates contain no secret or other-root data.
Stale add submissions cannot overwrite an ACL another owner tab has just added.

## 0.6.2 explicit owner client deletion

No remote MCP deletion tool is added. Private manage/delete routes require the
existing local capability, origin/Host/proxy checks and current workspace/epoch.
Deletion additionally requires the literal confirmation marker and per-client
review hash covering registration, ACLs and grant state. Duplicate/empty/oversized
selections and stale snapshots fail. Every selected client is validated before
any mutation in one SQLite IMMEDIATE transaction; audit failures roll back too.

Only selected client identities are deleted; no wildcard reset, credential log,
key regeneration or file operation occurs. Provider models and pending approvals
for them are invalidated, while other clients' sessions/grants remain. The token
verifier checks registration existence as defense in depth. Interaction completion
rechecks after asynchronous grant persistence to avoid restoring a deleted ACL.

This blocks new authenticated calls; it does not retract already-authorized file
operations, stop live jobs, roll back edits or securely wipe SQLite free pages.
Grant-backed schedules fail their next permission check; owner-created schedules
and history remain. Same-UID processes are outside the isolation boundary.
