← Về thư mục
📄 / / profiles / r-and-d / raw_evidence / OpenAI4S / docs / security.md

Security

⚠️ Read this before exposing the daemon beyond localhost.

Python/R scientific workers now have an OS-sandbox adapter at their spawn boundary. On macOS it uses Seatbelt (sandbox-exec); on Linux it uses bubblewrap. The default OPENAI4S_KERNEL_SANDBOX=auto performs a real startup self-test, enforces the boundary when available, and otherwise continues with a high-visibility degraded status. Use enforce to fail closed before a worker starts, or off for an explicit trusted-host opt-out. Unsupported/degraded is not equivalent to sandboxed; keep the default loopback bind even when the self-test passes. Team-mode local Cells are stricter: their session read policy is an authorization boundary, so auto may not degrade, off is refused, and a missing backend or incomplete/failed self-test prevents the Cell from starting.

The sandbox makes the host filesystem read-only to a worker except for the session workspace and its private temporary directory. Raw worker network is blocked unless the trusted host-global OPENAI4S_KERNEL_ALLOW_RAW_NETWORK=1 escape hatch is set. Host-side Web/MCP services remain available through audited Host RPC. This boundary covers Python/R kernels and their subprocesses; the separate local compute/jobs surface remains a privileged local operation and must not be treated as an untrusted multi-tenant sandbox.

For a team-mode local Cell (including a first-action exec_background worker), the verified policy additionally hides the entire OpenAI4S data directory, other members' users/<name> areas in writable OPENAI4S_DATA_ROOTS, and the canonical system temporary directory where stale sibling kernel directories may remain. It re-exposes only the current workspace and private kernel temp as writable, plus exact read-only roots needed by this session: its runtime and authorized Skill sidecars, its owner's personal data area, and its opaque checksum-verified Artifact input cache. A bounded no-follow inventory refuses a pre-existing workspace hardlink whose other name is outside the workspace. Data roots that overlap the system temporary directory are rejected in team mode instead of silently hiding their shared/read-only content or reopening another user's namespace.

This is a boundary around OpenAI4S-managed tenant data, not general hostile same-UID containment. The kernel retains read access to ordinary host paths outside those managed roots (for example unrelated files in the daemon account's home), and an arbitrary non-OpenAI4S process running as the daemon's Unix UID remains inside the operator trust boundary. Use separate OS accounts, containers/VMs, or equivalent resource-plane isolation when users must be mutually hostile at the host-filesystem level.

The targeted credential-file denies cover the configured OPENAI4S_DATA_DIR and the well-known default ~/.openai4s instance (resolved and de-duplicated). Building the deny set from a single data directory left the default instance's access-token, worker-bootstrap secret, shares/ and openai4s.db readable to an enforced cell whenever OPENAI4S_DATA_DIR was redirected — a CLI run with a custom data dir, a benchmark, the test suite, or a second daemon. Both instances' credential files are denied regardless of which one the running daemon uses; non-default secondary instances (a third data dir with no relation to either) remain outside this set and rely on the same-UID trust boundary above.

macOS — reading the daemon's exec-time environment. On macOS a process's argument and environment block is reachable through sysctl(CTL_KERN, KERN_PROCARGS2, <pid>), and for a daemon whose LLM key is configured by environment variable / .env that block holds the key in cleartext. Linux exposes the same block through procfs, and what refuses the read there is a different mechanism, described after the macOS discussion below. The Seatbelt profile denies the process-info introspection class (for other processes) and the kern.proc sysctls. Between processes in different sessions the kernel gates the KERN_PROCARGS2 read behind both authorization paths, and passing either allows it, so both denies are required and neither alone suffices.

That gate applies only when the reader and its target are in different sessions; a reader in its target's own session is let through with both denies in place (measured on macOS 26.6). The daemon is not reliably detached: start.sh runs openai4s serve in the foreground, and only serve --detached calls setsid. The guarantee comes from the reader's side instead: PipeTransport starts every kernel worker with start_new_session=True, and the BYOC provider helper is spawned the same way, so neither a cell nor a provider shim is ever in the daemon's session, however the daemon was launched. That flag therefore carries security weight on macOS. test_an_enforced_kernel_cannot_read_its_daemons_environ drives the real kernel spawn path and fails if the flag is dropped.

Verified end to end against a live daemon under enforce: with the denies removed a cell recovers the daemon's real API key via KERN_PROCARGS2 (fingerprint match); with them in place the cell's sysctl returns EPERM and the key is not recovered, while a normal analysis turn and the science stack (numpy/pandas, matplotlib, scikit-learn n_jobs=2, multiprocessing, subprocess, an R cell, urllib HTTPS) keep working.

What the denies cost: an enforced cell can no longer query other processes' details. psutil.process_iter() and psutil.Process().children() raise PermissionError (measured on macOS 26.6 with psutil installed). loky's kill_process_tree calls Process().children() when psutil is present, yet executor.shutdown(kill_workers=True) still completed in the same measurement, and joblib Parallel is unaffected. The denies cannot be narrowed without reopening the read, because KERN_PROCARGS2 is named under kern.proc. A process's reads of its own details are unaffected.

What the denies do not cover is a process in the cell's own session, for example a same-UID sibling the cell started. That read stays open, which is consistent with the same-UID trust boundary above. As defence in depth, macOS deployments running untrusted cells can also keep the key in the keychain SecretBroker (the auto default), where it is never placed in the daemon environment at all.

Linux — reading another process's environment. Linux exposes the same block as /proc/<pid>/environ, and again as /proc/<pid>/task/<tid>/environ. The single-user bubblewrap policy keeps the host PID namespace, so that an interrupt can be delivered to the worker bubblewrap starts, and that leaves the daemon's procfs entries visible to a cell. What refuses the read on a default install is bubblewrap's user namespace, not a mount. For a daemon running as an ordinary user with a bubblewrap that is not installed setuid, bubblewrap always puts the cell in a new user namespace. Every environ open goes through the kernel's ptrace read check (cap_ptrace_access_check), which allows it only to a caller in the target's own user namespace holding at least the target's capabilities, or to one with CAP_SYS_PTRACE in the target's namespace. A cell in bubblewrap's child namespace is neither, so the read fails for every process outside the sandbox and by every path: the daemon's environ and task/<tid>/environ, MCP stdio connector children (a connector's declared env, credentials included, is their exec-time environment), and a uv run parent. A daemon running as root fails the same check another way, because bubblewrap drops the cell's capabilities. The policy's --ro-bind /dev/null /proc/<daemon>/environ mask is a single-file backstop behind the check; it covers neither task/<tid>/environ nor any other process. Setuid bubblewrap (older EL7-style installs) runs the cell without a user namespace, as the daemon's UID in the daemon's namespace, and there those other paths stay readable: use an unprivileged bubblewrap, or keep keys out of the daemon's environment through the SecretBroker. Team read isolation and WSL policies add --unshare-pid, so their /proc shows only the sandbox's own processes. This account of the Linux boundary follows the kernel's check and has not been measured: the Linux tests in tests/test_sandbox_credential_denies.py inspect bubblewrap's argv, and harness.smoke.linux_sandbox does not read another process's environment from inside the sandbox.

openai4s.security adds independent policy layers:

layer env (default) what it does
OS kernel sandbox OPENAI4S_KERNEL_SANDBOX (auto) Seatbelt/bubblewrap detection + write/network self-test; enforce fails closed, auto reports degradation
Child environment allowlist always on rebuilds the Python/R environment from explicit runtime names; daemon LLM/API/cloud/OAuth secrets and loader-injection variables are not inherited
Pre-exec classifier OPENAI4S_SAFETY (heuristic) screens every agent-authored Python/R cell (heuristic / llm / off); an opted-in user's REPL Cell skips this classifier but still enters the worker sandbox and audit path
dlopen audit hook OPENAI4S_SAFETY_AUDIT_HOOK (on) sys.addaudithook refuses ctypes.dlopen of a .so from an agent-writable path
Biosecurity screener OPENAI4S_BIOSECURITY (on) trajectory screener (ALLOW / ESCALATE / BLOCK) on biosecurity-relevant content
Injection detector OPENAI4S_INJECTION_SCAN (on) annotates tool-returned content (web / PDF / MCP) so the model treats it as data, not instructions
Egress allowlist OPENAI4S_EGRESS (off) application policy for web_fetch / web_search and authorized host.bash; the OS sandbox is the separate raw-network boundary
Fake-IP DNS bridge OPENAI4S_ALLOW_FAKE_IP_DNS (off) accepts RFC 2544 198.18.0.0/15 proxy answers only for a hostname in the built-in/user-approved egress catalog; IP literals and every other private/metadata range remain blocked
Remote-compute confinement OPENAI4S_COMPUTE_CONFINEMENT (auto) the provider helper runs inside a real OS boundary — Seatbelt on macOS, bubblewrap on Linux — that puts the user's home out of reach — a tmpfs over it on Linux; on macOS a denial of file-read-data and file-read-xattr, since an xattr on macOS routinely holds the file's own bytes and getxattr was serving what open refused — confines writes to the job's stage directory, and (macOS) denies the keychain services, because the credential is read by securityd and no file rule covers that. available() proves it by establishing a boundary and probing it, not by which; the helper re-checks from inside before reading a credential and exits 71 without acting if it does not hold. The network is deliberately not isolated (network_isolated: false) — calling a provider's REST API is the helper's whole job, so outbound egress is a separate capability and is not enabled. enforce refuses byoc:* ops only where no boundary can be established: no bwrap/sandbox-exec on PATH, a host that fails the self-test (e.g. unprivileged user namespaces disabled), or a platform with no backend — and it refuses on every op, not just submit. auto degrades visibly in those same cases; off skips the wrapping entirely (see docs/compute.md)
Secret store OPENAI4S_SECRET_STORE (auto) credentials behind an opaque reference in the system keychain (after a real round-trip self-test) or the process environment; auto fails closed when neither is available. Plaintext is reachable only by asking for it by name, and no obfuscated-file fallback exists
Data-dir permissions always on the data dir is 0700 and the database (plus any -wal/-shm) is 0600; POSIX only — Windows needs an ACL, and the posture reports supported: false there rather than claiming a boundary
Browser response headers always on the app shell permits only external same-origin scripts; the exact vendored Ketcher editor document additionally needs unsafe-eval for its upstream runtime. App-origin Artifact bytes remain script-free. Executable HTML previews require a scoped grant on the other loopback origin, with a response-level sandbox and closed fetch directives. nosniff, framing policy, and Referrer-Policy apply at the response boundary; see the preview limitations below

web_fetch rejects loopback and private-network targets by default to reduce SSRF risk. OPENAI4S_ALLOW_PRIVATE_FETCH=1 is an explicit trusted-local override (useful for testing a service on 127.0.0.1); it does not weaken the kernel OS sandbox or authorize arbitrary worker networking.

The Windows launcher separately detects Clash-style Fake-IP DNS inside WSL and sets the narrower OPENAI4S_ALLOW_FAKE_IP_DNS=1 process flag. This is not a private-network override: only a hostname already present in the egress catalog (or explicitly granted by the user) may use a synthetic 198.18.0.0/15 answer. A literal address, an unlisted hostname, a credential-endpoint child hostname, loopback, link-local, metadata, and every other private range still fail closed.

Additional enforcement: an opencode-style permission broker gates risk-bearing tools, a secret-file guard blocks .env / *.key / id_rsa from file tools. Within the explicitly trusted workspace root, the guard also blocks every relative path that descends through .ssh / .aws / .gnupg / .docker / .kube / .azure / .config/gcloud / .config/gh, whatever the file is called. The root itself is the trust boundary: deliberately choosing a workspace already inside one of those directories does not reapply its parent segments and make the whole workspace unusable. The guard checks the path obtained by resolving the caller's string, so an already-present symlink alias cannot hide a secret path beneath that root. On POSIX, actual file operations then pin the workspace and each parent with descriptor-relative (dir_fd, openat-style) traversal, use O_NOFOLLOW for each acquired entry, and validate and consume reads through the same file descriptor; writes and downloads publish through that pinned parent. Regular files opened for consumption with multiple hard links are refused rather than guessed safe. Native Windows lacks these required capabilities, so the affected operations fail closed instead of falling back to a pathname os.replace or check-then-open sequence. This closes concurrent userspace namespace-substitution windows; it is not a claim to withstand a compromised host kernel or arbitrary changes to kernel filesystem semantics. The alias check completes a bounded, no-follow inventory of the workspace before using a candidate; an unreadable, timed-out, or entry-truncated inventory is refused rather than treated as proof that no credential alias exists. When Stage 7 auto_review is enabled, the wider credential name tier is evaluated before even a permissive default file rule. A match is promoted to an audited ask: an attached channel shows it to a human, while a headless run is refused by deterministic credential policy before Guardian can authorize it. Recursive content search follows the same split because its eventual file set is discovered only after approval. File-tool paths are workspace-confined. host.bash binds its canonical working directory to the workspace or an explicitly trusted extra root, but it does not parse every command argument as a path jail: outside reads can remain possible, and outside writes are not an OS guarantee when the sandbox is off or degraded. Approval requests are durable SQLite records. They survive broker/daemon recreation and are resolvable by ID; the absence of a browser subscriber never silently allows a request. Outside an explicitly enabled Stage 7 auto_review, headless execution defaults to deny unless the operator explicitly sets OPENAI4S_UNATTENDED_APPROVAL=allow.

A durable card is not a replay token. While the daemon is still running, a decision wakes the exact blocked call. After a daemon restart that thread is gone: approving the surviving card records that the old operation did not execute, appends an argument-free permission_resolution marker to the Action Ledger, and returns requires_continue=true. The browser then requires an explicit Continue and replan action. Conversation/project/global choices persist the selected standing rule. A once choice instead creates one exact root_frame_id + tool + permission-target grant, expires after 15 minutes, and is consumed atomically only when a fresh matching action reaches an ask decision. Stored/redacted approval payloads are never executed as arguments.

Executable Artifact previews use a scoped alternate origin

The default Preact Workbench creates an HTML iframe with the empty sandbox attribute and, on a direct HTTP loopback deployment, asks for an authenticated POST /api/v1/artifacts/{id}/sandbox-grant before navigating it anywhere; a granted URL is remembered per Artifact version until shortly before it expires, so reopening the same report does not mint again. The alternate origin is the other loopback hostname (127.0.0.1 ↔ localhost) at the daemon's port; no second listener is started. On any other origin, and whenever the grant is refused, the frame loads the inert app-origin preview (/preview/<artifact-id> or /preview/<version-id> for an exact version) instead. There is no client-side origin override: the client derives the one alternate origin from its own location and accepts a grant only for exactly that origin.

A signed grant binds a nonempty Artifact root frame, the selected Artifact version, an expiry, the minting app origin, and the exact alternate origin where it may be spent. Its default lifetime is one hour. The URL is /sandbox/<grant>/preview/<artifact-id-or-filename>: a relative image or script URL retains the grant in its path. Grant routes accept only reads of Artifact bytes in that frame; they expose no API or app shell and neither require nor set a session cookie. Missing, forged, expired, cross-frame, and wrong-Host grants return 404. In particular, changing the preview URL's hostname back to the app hostname cannot return executable Artifact bytes. The only permitted framing ancestor is the origin that minted the grant. A grant request that presents the daemon's own session cookie (os_token, or the team login cookie) is also refused with 404: the legitimate spend is the cross-site subframe load, on which a SameSite=Strict cookie is never sent, so a cookie can only mean a grant URL opened top-level on a loopback name that also holds a session -- the one browsing context in which the executable document could load that name's authenticated API as a same-site subresource (an <img> of another frame's file, readable through a canvas) and then navigate itself away with the bytes. Cookies of other local applications on the same hostname are not the daemon's and do not count. The cookie is read by scanning the raw header, because a cookie parser that abandons a malformed pair would let the previewed document hide the credential behind one it sets itself. This closes the reachable path -- a grant URL opened from the address bar or a bookmark, where the cookie is sent -- rather than every path: a cross-site top-level navigation withholds a SameSite=Strict cookie from the navigation itself while the loaded document's own same-origin subresources still carry it. Nothing in the Workbench links a grant URL that way, and the response grants no popup or top-navigation sandbox permission, so the document cannot arrange it; the residual below still applies. The main document remains pinned to the selected captured version. Sibling resources resolve by ID or an unambiguous filename within that frame and use their current captured versions; this is not a frozen multi-file bundle. The inert /preview/ route answers a report's relative reference by the same frame-scoped rule when the request's same-origin Referer names the report, so a figure does not render in one preview mode and 404 in the other. All served bytes must pass snapshot checksum verification, and a missing or damaged snapshot does not fall back to a mutable workspace file.

The granted response and upgraded iframe use sandbox="allow-scripts allow-same-origin". This lets a report execute its inline JavaScript and load granted sibling images, styles, and scripts while remaining cross-origin with the Workbench. The response CSP blocks fetch, XHR, WebSocket, form submission, external script/image/style resources, plugins, and base-URL changes; it grants no popup or top-navigation sandbox permission. This is narrower than a general web runtime: reports that fetch JSON or third-party libraries need to package their data and dependencies locally. Ordinary /preview/ and Artifact-byte URLs on the app origin retain script-src 'none' and the response sandbox, including direct navigation. Ketcher is authenticated first-party UI and stays on the app origin, with same-origin framing and API access. Its pinned upstream runtime requires JavaScript string evaluation. Only the exact /static/vendor/ketcher/index.html document receives a script-src policy with 'unsafe-eval'; it still loads scripts only from the same origin and does not permit inline script. The /ketcher wrapper, main app shell, and all Artifact response policies keep their existing evaluation limits. This exception trusts the vendored editor's runtime and is not granted to model-authored HTML.

This is not a browser egress or per-document isolation guarantee. Artifact JavaScript can read its grant from location.pathname, and CSP fetch/form directives do not prevent an iframe from navigating itself to an external URL carrying that grant or report data. Referrer-Policy: no-referrer on grant responses prevents automatic referrer disclosure, not explicit disclosure by the report. A leaked grant remains a bearer read capability until expiry for anyone able to reach the daemon at the bound Host; it is not revoked by a browser logout. Grants share the alternate browser origin, and a browser that previously opened the app on that hostname may already have origin storage or cookies there. Frame scoping is HTTP authorization, not a separate origin for each report. Host binding closes the navigation back into the current Workbench's origin; it does not remove these residual risks. Wildcard binds, remote origins, and unverified reverse-proxy topologies therefore receive no executable preview grant. Keep the daemon loopback-bound and treat grant URLs as sensitive, temporary links.

The Notebook REPL is off by default

The web UI's right-hand Notebook is a read-only execution trace by default. The developer REPL is disabled and only appears when OPENAI4S_NOTEBOOK_REPL=1. With it off, the mutating kernel/* routes (execute, env, restart, stop, start, interrupt) return 403; kernel/install remains available because it backs Customize → Compute. When enabled, the input is multiline, selects Python/R, and appends a new immutable Cell through the same FIFO execution coordinator as Agent work. Interrupts must carry the exact execution_id, owner.kind, and owner.id; broad session-level SIGINT is rejected.

Provider-native JSON control tools — deterministic list/read/glob/grep/web/env/ edit/write and orchestration capabilities — route through the same policy envelope as host.* Cell calls. Their public schema, approval metadata, and real behavior live together in named Tool subclasses. The legacy fenced tool-block syntax is compatibility-only and is not the advertised action surface.

There is no registered shell tool. host.bash asks the Host to authorize the exact command hash, canonical cwd, active worker generation, challenge, and short expiry; detected domains are checked during authorization. The session frame ID is retained for audit, not as an additional consume-time token binding. The worker validates and consumes that random token once before it starts subprocess; the Host never executes the shell. Static command/egress checks remain defense in depth, and the redacted result plus a bounded workspace diff enter the audit/step records. A missing, expired, reused, wrong-generation, or mismatched token fails closed.

User-authored Skills are likewise separated from bundled trust. Host/Web writes are confined to personal or project Skill roots, reject symlink/path escapes, and cannot shadow a bundled directory. User-space frontmatter cannot promote a document to the trusted openai4s origin; the normal Host authoring workflow uses an explicit publish transition from draft to personal. A model's Host edit asks by default because both SKILL.md and kernel.py become executable inputs to later turns. In team mode the Host lifecycle is admin-only even for a project member; deliberate human project mutations remain on the authenticated HTTP project boundary.

Secret reads and secret logs

The agent can introspect its own SQLite store through the read-only host.query, so secret-bearing and internal-control tables are denylisted and never reach it:

Credential values passed to host.credentials.set(name, value) are held only in an in-memory vault (never persisted). To keep that true end to end, the RPC audit log redacts them: credentials_get / credentials_list are not logged at all, and credentials_set is logged for audit with its args redacted — the plaintext value never enters host_call_log. The replay tape recorder likewise skips credentials_set, so an exported notebook cannot carry a plaintext credential.

Correlation IDs and structured logs

Every HTTP request carries an id (observability.py). A client-supplied X-Request-Id is honoured — bounded to 64 chars and stripped to [A-Za-z0-9-_], so it cannot forge a log line or inject a header — otherwise one is generated. It is echoed back in X-Request-Id and held in a ContextVar so anything reached from the request, including a thread it spawns, can stamp the same id without threading a parameter through every call.

Structured logs are off unless OPENAI4S_STRUCTURED_LOGS=1: turning them on by default would change what every existing deployment writes to disk. When on, each event is one JSON object per line on stderr.

Redaction is by value shape, not field name. A denylist of key names is not evidence that a log has no secrets in it — a credential stored under an unremarkable key is precisely the one such a rule misses. So any long, opaque, mixed-class string is replaced by <redacted:<fingerprint>> wherever it occurs, including nested, alongside the obvious key-name matches. The fingerprint is stable and non-reversible, so two lines about the same secret remain correlatable without either revealing it. Paths, URLs, and short identifiers are deliberately preserved — redaction that eats the useful fields makes the log worthless, and a worthless log stops being read.

Prompts and research data are never logged by this path. There is no log_prompt helper, and the request log records the path only, never the query string. The model's messages and the kernel's data are the likeliest carriers of a user's unpublished work, so the default is that they have no route out through here at all.

Retention is bounded by construction. diagnostics.rotate_log rolls a log at 8 MiB and keeps 3 generations, deleting the oldest — a size rather than a duration, because a daemon can be quiet for a week or chatty for an hour and bytes are what actually run out. Unbounded logs are not a neutral default; they are a slow disk-full that arrives at the least convenient moment.

openai4s diagnostics writes a redacted bundle for a bug report: postures and versions, plus log tails. The database is never included — it holds research work and, until every credential is brokered, secrets — and the manifest names what was left out, so nobody is tempted into a second, manual, unredacted collection. Log lines pass through observability.redact_text, which scans word by word: redact asks whether a whole value is a credential, which is right for a field and wrong for a log line where a token sits mid-sentence. An earlier version of the bundle passed the structured lines and leaked the plain one.

The log tail it collects is logs/app.out — the file every packaged launcher redirects the daemon's stdout and stderr into, and therefore where structured events land, since they are written to stderr. An earlier version globbed *.log*, which matches no file the product writes, so a bundle from a real install carried postures and versions and no logs at all, with a manifest that listed what it did include and so read as complete.

A credential inside a URL needs its own pass. redact_text scans word by word and a URL has no spaces, so the whole thing arrives as one word — and _looks_opaque deliberately answers "not a credential" for anything starting http://, because fingerprinting every URL would gut the log. The secret is inside, in a query value or a path segment, so URL-shaped words go through observability.redact_url, which keeps the parameter name as provenance and fingerprints the value. The daemon's own startup banner is exactly this shape — listening at http://127.0.0.1:8760/?token=…, printed to stdout, which the launchers redirect into app.out, which the bundle collects.

What leaves in the bundle is decided deny-by-default, and that is a different layer from the redaction above. redact/redact_text/ redact_identities/redact_url make the local operator log safer to read, and the log keeps its richness on disk. The archive is narrower, because it is the thing standing between a user's disk and a public issue tracker:

record_diagnostic is the source, and it no longer renders the exception. There is no redacted rendering of str(exc) on the record, because a rendering is the one operation an unknown exception influences and it can be arbitrary, enormous, or itself raise. The record carries the surface, the exception's class category — the nearest ancestor in a set of exception types this repository names, so type("PRIVATE_COHORT_ALPHA_SEVEN", (RuntimeError,), {}) reports RuntimeError and the caller's own string never appears — and an error_class fingerprint derived from the type, so two occurrences of the same failure remain recognisably the same failure and a support ticket quoting a request_id still leads somewhere. The same rule applies to the agent's observation when an environment switch fails, and to the two posture probes in security_posture, which report an error_type rather than an exception message.

An earlier version of this section said a shell command quoted inside a failure was "deliberately not removed" because the bundle is operator-facing. That was wrong on its own evidence: the same change made app.out the file the bundle collects, and once an artifact leaves the machine "operator-facing" is not a property it still has.

Credentials at rest

Model and search credentials are held by a SecretBroker (security/secret_broker.py): the row stores an opaque reference and the value lives in the system keychain. New references use secret://v2/<store-namespace>/<scope>/<name>; the namespace is a domain-separated digest of the canonical database path, not the path or any credential. Thus two data directories cannot overwrite the same physical Keychain or Secret Service slot, and copying a database does not copy authority to its credentials. The reference is not derived from the value, so it is safe to log and safe to sit in a row. Covered today: llm_api_key, tavily_api_key, the shared agent_plan_key used by DataPro and Doubao Search, the per-profile api_key of every saved model profile, and every connector env value.

Legacy v1 system-keychain references contain no Store ownership evidence. They are never read, claimed, or deleted automatically; the UI reports the credential absent and the user saves it again into the v2 Store namespace. This deliberately leaves the ambiguous legacy slot untouched because another data directory may still use it. DB-local plaintext v1 slots remain readable, as do explicitly process-global environment variables.

Connector env brokers every value, not only the credential-shaped ones. Choosing by variable name would mean a regex over names — the same name-based heuristic the confined compute runtime's README warns about, where "a secret stored under an unrecognized name is not removed". A connector's env is small, the UI only ever shows the names, and a benign MODE=prod in the keychain costs nothing next to one missed TOKEN_FOR_X.

A reference is a truthy string that is not a key, which sets one trap worth knowing about: if profile["api_key"]: reports a revoked credential as present, and handing that field to a provider fails auth in a way that looks like a bad key. Every read goes through resolve_profile_key / Store.get_secret_setting, which resolve the value and report absence honestly. Deleting a profile deletes its credential, so a removed endpoint does not leave its key in the keychain with nothing left that refers to it.

mode (OPENAI4S_SECRET_STORE) behaviour
auto (default) System keychain (verified by a real round-trip self-test), else environment injection. If neither is available, fail closed — refuse to handle credentials at all.
keychain Keychain only. Fail closed.
env Environment injection only. Fail closed.
plaintext Store in the database in the clear. Never implicit; asked for by name.

auto fails closed rather than degrading. It used to fall through to plaintext with a warning, which inverted the risk: the deployment least able to protect a secret — a Linux server, with neither a keychain nor a session bus — was exactly the one that silently got none, while a laptop that needed it least got the keychain. A warning printed at boot is not a control; it scrolls away and the credential stays in the clear.

Servers supply credentials through the environment. The preferred variable is OPENAI4S_SECRET_V2_<STORE_NAMESPACE>_<SCOPE>_<NAME>. Existing OPENAI4S_SECRET_<SCOPE>_<NAME> variables (for example, OPENAI4S_SECRET_LLM_LLM_API_KEY) remain an explicit process-global fallback, so an upgrade does not silently change an operator's deployment contract. Set either from systemd's EnvironmentFile, a Kubernetes Secret, or whatever the config management already owns; set OPENAI4S_SECRET_ENV=1 to opt in before any are configured. Nothing is written to disk — stronger than the keychain case, not a fallback from it. It is read-only on purpose: if the environment owns the secret, the app must not overwrite it behind the operator's back, so a write attempt fails with the exact preferred variable name to set.

An injected credential resolves with no settings row at all, which is the only state a fresh server can be in: nothing can put the reference row there, because put refuses by design and migration has no plaintext to move. A resolver that stopped at an empty row made the variable dead on any data directory that had never had a key saved through a writable backend — and nothing raised, so the symptom was only that the UI reported the model as unconfigured. resolve_setting therefore asks a read-only backend for <scope>/<key> when the row is absent, the scope coming from the same SETTINGS_SECRETS table migration uses. Read-only specifically: behind a writable backend an empty row is the app's own answer, and since clearing a key swallows a failed delete, going to the backend anyway would let a revoked credential come back to life.

The reference it builds carries this Store's namespace, i.e. the same v2 reference put would have written. That is not a detail: a v1 reference reaches only the plain OPENAI4S_SECRET_<SCOPE>_<NAME>, while the refusal an operator sees when the UI declines to save a key names the namespaced OPENAI4S_SECRET_V2_<NS>_<SCOPE>_<NAME>. Built the v1 way, following that instruction exactly still resolved to nothing — the same dead end one spelling over. The v2 path tries the namespaced variable and falls back to the plain one, so both work; the plain form is the portable one, since a namespace is derived from the data directory's real path and a Secret written against one data directory would not resolve after the volume moved.

The corollary is that clearing an injected key from the UI does not unset it — the environment owns that value, and the settings route reports the has_api_key it re-reads afterwards rather than claiming the clear took.

Backends are driven through the system CLIs, because the core is stdlib-only and cannot depend on keyring: security on macOS, secret-tool (Secret Service) on Linux desktops. The value is fed on stdin, never argv — security's own help says "Use of the -p or -w options is insecure", and a value on the command line is readable by any local ps for the life of the call. Presence of the CLI is not treated as availability of a keychain: a locked keychain or a missing session bus fails only at first use, so the broker proves a round-trip before trusting a backend with a real secret.

There is deliberately no obfuscated-file backend. Base64, XOR, or a hand-rolled cipher over a key stored beside the ciphertext is not a boundary; it is a plaintext store described in words that suggest otherwise.

Existing plaintext keys migrate on daemon start, ordered write → verify by reading back → replace the row with a reference. Every prefix of that is safe to be interrupted at: crash after the write and the plaintext is still authoritative and the next start retries. The verify step reads the value back and compares it, because a write that did not raise is not evidence the value is retrievable — and a reference that resolves to nothing is worse than the plaintext it replaced. A key that cannot be migrated stays plaintext and keeps working, reported on stderr.

Still outstanding, and stated plainly rather than left implied:

What is enforced throughout is that credentials do not leave over the API: connector and model-profile responses are allowlist projections (env_keys / has_api_key, never the values), covered by canary regressions in tests/test_secret_canary.py that assert on the secret's bytes rather than on field names.

BYOC provider import-time secret scrubbing

The remote-compute worker (openai4s_compute_provider) loads an untrusted-ish provider shim (skills/remote-compute-<id>/provider.py) by file path. To keep a provider's top-level module code from reading credential-shaped or known-prefix environment variables, scrubbing is two-staged. This is a name-based heuristic — a secret stored in a variable whose name matches neither rule below is not scrubbed:

Because stage 1 cannot know the provider's declared prefixes before importing it, the baseline is what enforces the name-based rule at provider import time; the provider-specific prefixes are folded in at stage 2, before the credential is read. Non-secret operational vars the worker needs (e.g. OPENAI4S_HOST_NETNS_INO for the confinement probe, HTTP_PROXY/HTTPS_PROXY) do not match either rule and survive. This is enforced by synthetic-secret import-time and prologue tests in tests/test_compute_nvidia.py.

Remote access

The daemon binds 127.0.0.1 by default. Reach the UI over an SSH tunnel — never expose 0.0.0.0 on an untrusted network:

ssh -L 8760:127.0.0.1:8760 user@your-host

One documented exception: when a WSL2 user has explicitly set localhostForwarding=false in .wslconfig, the Windows launcher binds the daemon to the WSL NAT (eth0) address instead, because loopback is then unreachable from the Windows browser. That address is routable only from the Windows host across Microsoft's virtual switch — not from the LAN — and the token gate below still applies to it. Details: Windows / WSL2 guide.

The server requires an access token by default, on loopback too. It is minted once under the data dir (access-token, mode 0600), survives restarts, and is printed at startup as a URL you open once to set the cookie. Scripts send it as Authorization: Bearer <token> or X-OpenAI4S-Token.

The ?token= form in that startup URL works for one thing only: opening the app at /. Every other path refuses it — including /preview/<id>, which answers with artifact bytes and used to be bootstrappable because the rule was written as "not /api/v1/* and not /static/*" rather than as an allowlist. A mutation carrying ?token= is refused outright, cookie or no cookie: a URL with a credential in it is a credential you can paste into chat, and one that still works is one nobody notices they leaked.

There is no way to turn the gate off. OPENAI4S_REQUIRE_TOKEN=0 did that on loopback for exactly one minor release (decision D1, v0.2.x) and is ignored from 0.3.0. The reason is what the daemon exposes: kernel/execute, compute/jobs and host.bash all execute code, and "local" includes every other process on the machine. The Host and Origin guards stop a malicious web page; they do nothing about a local process.

Web sharing

Web sharing (off by default; see webshare.md) never changes the daemon's bind — it dials out over WSS to a relay you run. The public surface is a materialized read-only snapshot, not a proxy to the gateway. Invariants:

The tunnel is not a way to remotely access the daemon: the read-only snapshot surface has no route overlap with the gateway.