Skip to content

Latest commit

 

History

History
764 lines (698 loc) · 48.5 KB

File metadata and controls

764 lines (698 loc) · 48.5 KB

Security model

Open Session runs agents against untrusted input: customer tickets, channel events, webhook payloads, and the open internet. The stance throughout is enforce at the tool/env/credential layer, never just in prompts — a prompt rule is guidance, a stripped tool or scoped token is a guarantee. This document is the full reference behind the invariant summary in AGENTS.md.

Automation least-privilege

Automation runs (especially event-triggered ones like support-ticket triage) process untrusted text — ticket content is data the agent reads, never configuration for the run.

  • Runner local tools receive an explicit, non-inherited environment. The base includes PATH/HOME/LANG, scoped session scratch variables when available, and Git identity. Eligible runs may additionally receive projected GitHub authority, short-lived AWS credentials through a fixed credentials-file pointer, or human-enabled Claude/Codex pool credentials. OPENSESSION_MODEL is not added to Pi's local-tool environment. MCP subprocesses use safe SDK defaults plus their configured headers/env or OAuth projection. Neither path inherits the server's full environment or ~/.opensession.env.
  • The host's instance-role AWS session is never sent to a Runner. A Runner run host receives credentials only for the IAM role an administrator configured on that Runner, assumed server-side and fetched by the host with its per-launch dial-back token (src/server/routes/run-host-aws.ts). The run's aws grant is decided by the server at launch and checked again there, so an automation descendant cannot ask its way into the role. The interactive-only Runner MCP also grants the configured role to bounded commands, after checking Runner command permissions. It sends only the assumed role's AWS environment over the authenticated channel, never in the command text or audit log. Clients explicitly advertise support, and failed credential issuance prevents dispatch. Internal workspace probes do not opt into this grant; the MCP is not mounted for unattended runs. (runners.md)
  • Each automation has an optional mcpServers allowlist (per-automation field, settable via the API); runs only see those servers. Example: a support-triage automation might name only its support-inbox, identity, analytics, issue-tracker, error-tracker, and billing servers so it can look up the customer, related issues, and errors while investigating.
  • Scheduled automation inputs are a separate read path from the primary run's MCP allowlist. Built-in input providers fetch a bounded time window, pass it through a tool-less one-shot reducer as explicitly untrusted data, and persist only a cursor after the primary run succeeds. Raw Slack text and reductions are not stored in the checkpoint. A Slack input never grants the primary model Slack tools; optional Slack output is likewise server-side, disabled independently, and derived from the final structured report.
  • Automation runs hard-deny customer-facing, identity-mutating, and incident.io mutation tools (enforced for direct runs and interactive resumes of automation sessions): Plain thread writes (reply_to_thread, mark_thread_done/todo, snooze_thread) and the WorkOS write/destructive subset (create/delete/update user+org, revoke, invitations, password/verification emails, impersonation URLs). incident.io is declare-and-read only: incident_create may create a triage incident, while incident updates, follow-up writes, escalation responses, alert attachment changes, investigation controls, extension writes, and skill-feedback writes are stripped. Reads stay allowed; suggested customer replies go in an internal Plain note. Linear (including issue creation) and Sentry are internal, so their writes are allowed. That is the "spin off work" affordance.
  • Automations run on Pi in detached run hosts. runAutomation maps every native or legacy Pi model id onto Pi at dispatch (automationModel; unset uses DEFAULT_PI_AUTOMATION_MODEL in automations.ts). Deny-sets are enforced before Pi registers MCP tools, and its guarded local tools keep filesystem and environment access contained. opensession-admin, the unrestricted opensession-sessions, and per-user (allowedUsers) servers stay out of automation runs. The scoped automation-safe set is documented below. Pi's codemode tool adds no authority: its scripts run in a QuickJS sandbox with no file system, network, or timers, and can call only the run's active tools, through the same guarded definitions, deny-sets, and audit as direct calls. Pi's other built-in extensions (its own MCP client, tool search, and model catalog access from scripts) stay off. Both engine run gates are deny-by-default on journal kind: interactive kinds (prompt/goal/create/linear/slack), unattended kinds (automation/plain/action/security-scan/github-*), everything else refused.
  • A Plain Ask Sidekick discussion session (plainDiscussionId, docs/setup/plain.md) is prompted by a teammate but reads the same untrusted ticket text, so every one of its turns carries the automation deny-set plus the Plain customer-facing writes and the Stripe money movers, passes no user, gets no AWS credentials (unless the instance opts untrusted runs in with integrations.aws.untrustedRuns), and mounts only opensession-plain-discussion (the Approve/Deny-gated customer reply and Stripe execution) in place of the interactive set. The run-rpc fallback builder serves that same set, so a hosted or sandboxed turn cannot ask for more.
  • mode is per automation. Ask has guarded read/find/grep/ls/bash tools but no Write/Edit. For an unsandboxed ordinary repository it uses a stable, shared detached worktree pinned to origin/<defaultBranch>; only a repository configured as a shared self-development checkout uses its live checkout. Sandboxed ask runs use the sandbox workspace. Code gets an isolated writable workspace/worktree and can edit and commit. Every automation run holds a repository-scoped App installation token and never a person's (docs/setup/github.md, "Who holds which credential"): code runs mint the code permission set (push the branch, reply, inspect checks and Actions logs), while ask runs — the review workflows, which process untrusted PR content — mint the read-only set and ignore any launcher-supplied token, so nothing they can be injected into holds write capability. Only a code turn a connected person started holds that person's token instead. What that token may do to the default branch is a ruleset decision on GitHub. Interactive code runs follow the repository's publication workflow; automation descendants retain their server-enforced publication policy. Every other scope still applies: MCP allowlist, denied writes, IMDS blocking, and the explicit environment.
  • A sandboxed automation's workspace is a fresh disposable Daytona Executor. Its agent loop, model credentials and MCP connections stay on this server; only its file and shell tools run in the Executor, over the run's own run-rpc token (docs/self-hosting-sandboxes.md, "Where the agent runs"). Open Session admits it only after Daytona has passed qualification, including a live domain-allowlist check. The provider applies that allowlist before the base runtime, repository setup hooks, or private workspace seeds enter the guest. Each run requires one hard-pinned Anthropic or OpenAI subscription account, no fallback model, no nested CLI credentials, and an explicit MCP allowlist. The allowlist adds only the callback, clone, base runtime, and operator-approved domains: model APIs and MCP servers are reached from this server, never from the Executor. It probes one allowed and one blocked destination before continuing, and strictly deletes the Executor after the run. There is no host fallback: a tool call that cannot reach the Executor fails.
  • When adding an automation, scope it: pick ask mode unless it must write, and name only the MCP servers it uses.
  • Interactive sessions may publish an existing workspace file through opensession-assets write_asset.sourcePath. The server resolves the path inside that session's host, runner, or Sandbox workspace, rejects absolute paths, traversal, control characters, and symlinks escaping the workspace, checks the 4 MiB limit before reading, then writes through the configured asset store. Agents do not need direct access to the asset storage directory, and successful publication sends the normal assets_changed event.
  • A code automation's prReviewer is preserved and added to its instructions, but it grants no GitHub authority. It matters only when the run already has an authorized publication path. See Automation PR credentials and review requests.
  • An automation's readRepos (sibling owner/repo names under its own owner) gives its runs a second, read-only installation token, GH_READ_TOKEN, covering its repo plus those. The primary GH_TOKEN is never widened, the mint fails closed when the App is not installed on one of the repositories, and only the names are journaled. See Who holds which credential.

Stripe: a third enforcement tier

Stripe is money-moving, so it gets a tier beyond allow/deny: the tools in STRIPE_CONFIRM_TOOLS (runner-shared.ts: create_refund, cancel/update_subscription, and the raw-API mutators stripe_api_execute + stripe_api_write since they can hit any permitted endpoint — keep this list in sync with mcp.stripe.com's live catalog). Run the MCP on a restricted key (write on Refunds + Subscriptions + Invoices only — invoice voiding included; read on core billing resources, nothing else — Stripe enforces this ceiling server-side). On Pi there is no per-call approval card, so ordinary interactive and unattended runs strip the confirm tools from the model's tool list. The server stays mounted and Stripe reads keep working. Guidance differs by run type: unattended runs put the proposal in their internal note; interactive runs ask the human in the session. The explicit exception is Plain's refund/cancellation approval path: after a teammate's go-ahead is classified against an existing proposal, a dedicated execution run omits both the deny and confirm sets and therefore exposes the Stripe mutators. Its execution prompt directs the model to perform the approved action. (Dropping the whole server from interactive runs was tried and reverted: it blanked Stripe reads for no security gain. The money movers were unreachable either way, and Stripe enforces the restricted key's write ceiling.)

Keychain credentials

A teammate registers a credential (host, header, optional method and path ceiling) over the signed-in HTTP route; it is stored 0600 on the server and no API or tool returns it. Any interactive session may ask to borrow any credential for a stated purpose. Only the owner answers: in their Slack DM, where a button counts only when the clicking Slack user is the one the ask was sent to, or on the web, which requires the owner's verified GitHub sign-in (a claimed name or an automation token is refused). On the web the ask shows in Settings and as a card in the asking session. That card is not a session question card, which anyone watching the session or another agent through session control could answer. The socket frame only says the session's asks changed; the card's content comes from /api/keychain/asks, which returns an ask only to the credential's verified owner, and its buttons post to the same owner-checked answer route as Settings.

Approval mints a grant for the asking session: once (one call, one hour) or standing (seven days), revocable by the owner or requester. The agent uses it only through the interactive-only call_credential tool. The tool runs in the server and takes its session from the run-rpc token that routed the call, never from the agent's arguments, so a grant works only in the session it was issued to; a grant id copied from a transcript authorizes nothing elsewhere. There is no HTTP broker route. The call goes only to the credential's host over HTTPS; the method and the parsed, normalized path are checked against the credential's ceiling; redirects are not followed; agent-supplied headers are limited to a short allowlist. The secret is scrubbed from the returned headers and body verbatim and in common encodings (URL, JSON, base64), which is best effort. A credential registered as status-only returns only the HTTP status, for APIs that might echo the secret in a form scrubbing cannot catch.

A loopback URL that any local process could reuse is exactly what the broker route was, so bulk work by a script has its own path instead. The agent asks for a scripted run (request_credential with run): the owner's message says it is a scripted run and shows the exact command and the call cap, and offers only Approve run or Decline. An ordinary once or standing grant cannot start a run, and a run grant cannot be used through call_credential. run_with_credential starts that command, character for character, as one script run (see Script runs below) on the server, in the session's workspace, with a minimal environment and KEYCHAIN_PROXY_URL. That URL points at a loopback port the run's script host opens for this run only, with a 32-byte random secret in its path. The host relays each request over the run's 0600 unix socket to the server, which compares the secret's SHA-256 with the one it stored, in constant time, and injects the credential; the host never holds a credential, and the secret is never written to disk. Every proxied call is checked against the grant and the credential's method and path ceiling, counted against the cap, and audited (keychain_run_call, keychain_run_denied, keychain_run_ended). The injected header, cookies and routing headers cannot be set by the script, redirects are not followed, and the secret is scrubbed from response headers and text bodies. The proxy closes when the process exits, times out (12 hours at most), is stopped, or its grant is revoked; a request after that, or with another run's secret, is refused. A grant starts one run. Sessions in a Sandbox or on a Runner cannot start one, because the proxy listens on the server. While a run is live, another process of the same Unix user could read its environment; the exposure is limited to that run's lifetime and cap.

One run may use several credentials (request_credential with credentials and run), for a script that needs more than one API in the same process. Each credential's owner gets one message listing every credential in the run, its owner and its cap, and the command; an owner of several approves them together. The asks and grants share a group id, and run_with_credential starts the run only when a live grant from that one request exists for every credential: a missing approval, or approvals from two different requests, start nothing. A decline withdraws the other owners' asks. The script gets one URL per credential, KEYCHAIN_PROXY_URL_<SLUG>, each on its own port with its own secret and forwarding only to its own credential's host, so one credential's URL can never reach another's host. Method and path limits, caps and audit entries are per credential. All of a run's URLs close together, and revoking any of its grants ends the whole run.

A server restart does not end a run: its script host keeps the command running, holds the script's requests until the server is back (a request cut off mid-flight is retried only for GET and HEAD), and the run's grants stay claimed until it ends, then settle with its call counts. A run whose host is gone without recording an end, or a claimed grant whose run is not live at boot, is marked interrupted. Such grants cannot be reused; asking again for the same command tells each owner that the earlier run was cut off and roughly how many calls it had made, and approving starts the command again from its beginning.

The old broker URL (/api/keychain/broker/...) answers every caller with 410 and names these two paths, rather than a sign-in error.

Logins

A login is the one keychain secret an agent is given. It holds a sign-in page (https:// only), a username and a password, for a test account a session signs in with in its own browser. A password has to be typed into a page the agent drives, and the agent can read back any field it typed into, so no relay could keep it hidden. The design makes that disclosure explicit and bounded instead.

The owner registers it in Settings → Account (Add login) or through register_credential with kind: "login", where they paste the password into the session's card as with any secret. The username and sign-in page are metadata and are shown to teammates and agents. An ask for a login says that the agent will see the password, names the account and page, and offers only Release password or Decline. Once, standing and run approvals do not apply to it, and a release approval does not apply to an API credential.

Approval mints a single-use release grant for the asking session, valid for one hour. use_login spends it and writes the password to a fresh 0700 directory as a 0600 file in the session's scratch dir, inside the Sandbox for a Sandbox session, where the command receives it through its environment rather than its arguments. The tool result carries the sign-in page, the username and the file path, never the password, so it stays out of the transcript. The file is removed after 30 minutes; a server restart before then leaves it until the scratch dir is swept. If the write fails, the grant is put back. A Runner session cannot receive a login. A login is never usable through call_credential or a scripted run. Each release is audited (keychain_login_released).

Nothing stops the agent from reading the file, copying the password, or putting it in a transcript, commit or log despite its instructions. Treat every release as disclosing the password to that session, and register only dedicated test accounts as logins, never a person's own sign-in.

Limit: the keychain does not protect secrets from a local agent that goes looking for them. The store is a 0600 file owned by the service user, and agent shells run as that same Unix user. On a host where that user also has root (passwordless sudo, or membership in the docker group), anything a separate keychain user or process held would be readable too, including the server's memory. So treat everyone who can run an agent on the host as able to read every stored secret. What the keychain does guarantee is narrower: no tool, API, transcript, or prompt ever hands an agent a secret (except a login's password, released to a file when its owner approves), every use needs the owner's approval for one session, and every call is audited. It prevents accidental exposure and makes use visible; it is not a barrier against a deliberately hostile agent. Closing that gap needs agent runs under a separate unprivileged user that can reach neither the store nor the process that injects the secret.

Mac Keychain requests

The interactive-only opensession-keychain server can request one macOS Keychain generic-password item for one exact HTTPS API call. Requests carry only the service/account identifiers, purpose and HTTP intent. A ten-minute, memory-only queue binds each to the prompting teammate's roster-resolved GitHub login and session. Verified human web identity is required; name-picker fallback and machine authentication are rejected. Restart revokes pending requests. Atomic claim precedes execution, so two Macs cannot execute the same request and failures do not retry it.

Execution starts only through the native app menu. The full intent appears in a native menu; selecting Use once dispatches an exact service/account lookup to the packaged, signed OS Keychain helper. No remote-page IPC can read credentials or trigger execution. The Mac revalidates and freezes the intent and checks the session/organization again after selection and claim. The helper directly calls Apple's SecKeychainFindGenericPassword, never /usr/bin/security, a shell, or 1Password. macOS supplies its own item-access prompt and enforces the item's ACL. Allow grants that access once; Always Allow changes subsequent behavior. Existing trusted-application permissions may suppress prompts. The app neither changes ACLs nor forces prompts by disturbing the user's Keychain settings. Every request still requires an explicit native menu action, including when Keychain already trusts the helper.

The helper receives only service/account identifiers, a minimal environment with no inherited dynamic-library injection variables or credentials, and a two-minute deadline. It returns the value through a private pipe to Electron's main process and exits. The value never goes to a renderer, server, agent-readable file, environment, or model context. The main process injects it into one approved HTTPS request with a thirty-second deadline. No browser cookies, redirects, private/loopback/tailnet DNS targets, custom ports, arbitrary headers, helper error text, or response content are forwarded. The model receives only a fixed outcome and numeric HTTP status. This deliberately sacrifices response data: substring redaction cannot prove an API won't echo or encode the credential. The human must trust the approved API destination with the secret; Open Session cannot control that service after receipt.

This replaces the earlier CLI-based 1Password path. It does not offer 1Password vault access, item enumeration, raw export, Apple Passwords/iCloud access, or Keychain mutation. Native tests use a new disposable keychain with interaction disabled, never the default search list or real user credentials.

Script runs

The interactive-only opensession-scripts server starts a long shell command as a supervised script run (src/server/script-runs.ts). Like a Portal, a run outlives the call that started it, so automation runs, workflow scripts, and Sandbox or Runner sessions cannot start one. The command gets a minimal environment (PATH, HOME, LANG, TMPDIR and the session scratch path), never the server's own environment, and runs as the service user in the session's workspace, with the same reach as the agent's shell.

Each run is owned by a script host process in its own transient user scope in the low-priority workload slice. The host enforces the run's deadline itself, so it cannot outlive it while the server is down. The registry and per-run files are written 0600 in a 0700 directory under the state dir. Anyone who can view the session can read the run's output tail and stop it; automation identities cannot stop runs over HTTP.

Local file requests

The interactive-only opensession-local-files server lets an agent ask the person watching a session for files from their own computer. The agent supplies only a purpose and an optional hint. It never names, lists, or reads a path on that computer: the person chooses files in their own file picker (the native panel in the Mac app), and the browser uploads them. There is no pairing, no background service, and no connection from the server to the person's machine, which is what separates this from a Runner.

Uploads use the chunked upload routes (/api/uploads), which write to a sparse file on disk, never buffer a whole file, check each chunk's size and optional SHA-256, refuse an upload that would leave less than 5 GB free, and prune unfinished uploads after a day. Answering a request (/api/local-files) moves only files from the staged uploads directory into the session's own uploads folder; any other path is rejected. Automation identities and cross-origin requests cannot answer. File contents are untrusted input to the agent, like any attachment.

Per-user MCP servers (allowedUsers)

An MCP server in mcp-config.json can carry an optional allowedUsers: string[]. When non-empty, the gate checks both the current prompter and, for an ordinary session, its creator. A match by either identity exposes the server in that session, so a teammate steering a session created by an allowed person can use it. Omitted/empty means available to everyone, the default. Entries are matched by userMatchesAny (packages/core/opensession-server/src/server/shared/user-mappings.ts) through the same identity table as commit attribution, so names, nicknames, email addresses, GitHub logins, and Slack ids resolve to the configured person.

  • Enforcement is at the runner layer, not the prompt: filterMcpServers(scope, user, grantUsers) (runner-shared.ts, consumed by pi-mcp-bridge.ts) drops a restricted server unless one of the gate identities is cleared, then strips allowedUsers before the config reaches the engine. Both the automation allowlist and this gate apply.
  • Ordinary session runs supply the current prompter and, where applicable, the session creator as a grant identity. Automation dispatch and automation-owned resumes explicitly drop both identities, so an allowedUsers-restricted server remains invisible even if the automation's mcpServers allowlist names it.
  • Provider account selection is the one place a person's identity survives an automation-owned session: accountUser (session-run-inputs.ts) names the human who sent the prompt, so their personal Claude subscription is tried before the shared pool when they take over an automation's session. A machine sender (the automation's own tick, a review handoff, auto-continue) resolves to no account user and stays pool-only. accountUser never feeds the MCP gate, GitHub credentials, or the trust profile. It is journaled with the run so restart recovery keeps the same routing. On every launch path (host, detached pi host, Runner, sandbox) a person's takeover turn carries no automation pin (runAccountSpec, remoteRunAccountPolicy), because account selection tries a pin before personal accounts; the automation's own turns keep their pin.
  • Manage it from the Connections UI (the Add-MCP form has an "Allowed users" field; each server card has a Restrict/Edit-access button → PUT /api/connections/mcp/:name with {allowedUsers}), or via opensession-admin (add_mcp_server's allowedUsers, and set_mcp_allowed_users). Backing helpers: addMcpServer / setMcpAllowedUsers in packages/core/opensession-server/src/server/connections.ts.
  • Deploying runner-layer filtering code requires a real systemctl restart. Adding, removing, or re-scoping a server in mcp-config.json is picked up on the next run/message and does not require a restart.

GitHub webhook actor trust (public repositories)

A valid webhook signature proves that GitHub sent an event. It does not prove that the GitHub user who caused it is trusted. This distinction is critical for public repositories, where anyone can open or update a PR, write a PR comment, or submit a review.

The GitHub agent therefore treats identity.team[].github as its human trust roster and policy.githubBotLogins as its machine trust roster. Actor-driven entry points fail closed before starting an agent run or steering a session:

  • PR and inline-review @mention commands require the comment author's exact GitHub login on the team roster. author_association is never accepted as a substitute.
  • label-triggered review, auto-fix, simplify, and adversarial runs require the label actor on the roster.
  • automatic review on open/reopen/push/ready events requires a rostered sender or configured bot for same-repository PRs. An external fork is the only exception: it may start the isolated public-review path described below.
  • merge automations and deploy-workflow session notifications require a rostered actor or configured bot.
  • startup recovery revalidates persisted requesters, so a previously accepted public event cannot bypass the boundary after a restart.

GitHub webhook trust follows the identity roster. With GitHub web sign-in active, successful sign-ins automatically join that roster as ordinary members. Control access to the instance accordingly. An empty roster disables human GitHub commands rather than making the webhook integration public.

Isolated public PR reviews

External fork PRs can receive an automatic semantic review without running GitHub Actions or placing contributor code in a host worktree:

  1. GitHub REST supplies the PR identity and a size-bounded patch, pinned by base repository, PR number, base SHA, head repository and head SHA.
  2. A fresh disposable Daytona Executor anonymously fetches refs/pull/<number>/head plus the immutable base SHA, verifies both commits and checks out the head with Git hooks disabled. The source-verification profile refuses prewarmed and project-template resources and skips runtime bootstrap, dial-back, private workspace seed files, and repository setup/resume hooks. Any mismatch fails closed. Provider deletion must be confirmed before model inference.
  3. A tool-less one-shot model receives only the bounded immutable patch. Model and GitHub credentials never enter the guest, and untrusted repository text never reaches a host-side shell, filesystem tool or MCP server.
  4. The control plane rechecks the live head SHA before posting bounded summary and inline comments through the GitHub App. Public comments contain no private session link and advertise no write-capable actions.

Public reviews never run repository setup from the fork, tests, autofix, simplify, adversarial review, conversational commands, pushes or handoffs. Review policy comes from the registered base checkout. The path is limited by file count, changed lines, patch bytes, attempts per SHA, reviews per author and a repository-wide daily budget. The OPENSESSION_PUBLIC_REVIEW_* environment settings can lower or raise those bounded defaults. If Executor provisioning, immutable ref verification, strict disposal, or the model call fails, there is no host-agent fallback.

Public intake is an operator-controlled launch, not an App permission. Before a human repository administrator changes GitHub's PR creation policy to allow all users, operators must qualify the Daytona provider, enable the review automation, and keep fork-origin GitHub Actions disabled or approval-gated. The shipped PR workflows additionally skip every job whose head repository differs from the base repository. Ordinary App installation tokens exclude repository Administration. The App grant includes it for the private repository creation route, which mints a separate uncached token. Connected-user tokens inherit the widened grant intersected with the person's permissions, including in interactive code runs; they cannot be narrowed by the installation-token mint sets. Changing and auditing public-intake settings remains a one-time human-admin task. See GitHub authority for the connected-user and host-private-key implications.

Private information and public repositories

A run's inputs are the organization's private record: session context, memory, Slack and Linear threads, local instructions, other checkouts, and the instance config. A public repository's PRs, commits, branch names, comments, and files are readable by anyone. Nothing may cross from the first set to the second.

The boundary has three layers:

  • Visibility is resolved per repo and fails closed. treatRepoAsPublic (repo-visibility.ts) honors an explicit repos.<id>.public in config.json, otherwise asks GitHub for the repository's visibility with the instance credential. code.storage repos are private to the instance. No token, an API failure, or no GitHub remote all count as public.
  • Every code run in a public repo gets the ## Public repository rule in its shared run instructions (run-instructions.ts): describe the change in terms of the repository alone; the attribution footer and Co-authored-by trailer are the only session facts allowed through. The rule is per repo, so it does not fragment the prompt cache across a repo's sessions.
  • policy.privateTerms is a server-side tripwire. List the hostnames, customer names, private repository names, and other terms that must never appear in public. In a public repo, a bash command that publishes text (git commit, tag, push, branch, checkout -b, gh pr create, edit, comment, review, gh issue, gh api, and so on) is refused before it runs when it contains one of the terms, heredoc and quoted bodies included. Read-only commands stay usable. The list is instance-local configuration: do not commit it to a public repository.

The tripwire is not the boundary. A term list only catches the terms it knows, and text that reaches a file before being committed is not scanned. The prompt rule and reviewers remain responsible for everything else.

GitHub credential scoping (out-of-org writes fail server-side)

The "repositories outside your org require confirmation" rule in AGENTS.md is enforced with credential scope, not just prompts. One GitHub App may have installations on several accounts. Server reads and writes resolve the installation from the repository owner and use a short-lived token for that installation. Trusted repository code runs receive a token narrowed further to the owner-verified owner/repo. integrations.github.installationOwner is only the default for calls that do not name a repository. Teammate device-flow tokens are limited by both the App's installations and the person's own GitHub access. Out-of-installation writes therefore fail at GitHub's side.

The App is a fail-closed boundary: token-mint failure never consults ambient gh hosts.yml accounts, SSH credentials, or a connected human. Process-local Git config rewrites GitHub SSH remotes to HTTPS so the projected App or user credential is the only authority a run can use.

Per-user GitHub auth + web sign-in (opt-in, config integrations.github)

Off by default. Opting in uses the same App identity as bot traffic: integrations.github carries userPrAuth, oauthClientId, oauthClientSecret, appSlug, and installationOwner; the private key lives at ~/.opensession/github-app.pem (or the path in OPENSESSION_GITHUB_APP_KEY). Environment App identity values win over config. Enabling userPrAuth activates both halves below:

  • PRs as the prompting person (packages/core/opensession-server/src/server/github-auth.ts): teammates connect their GitHub account via the OAuth device flow (Connections UI card, or implicitly by signing in). Tokens live per-login in ~/.opensession/github-auth.json (0600, never returned by any API). A code turn a connected person started holds their token in its shell (pi-runner runGithubEnv, also for a Sandbox workspace, where each shell command receives it in its environment), so its pushes and any PR it opens are theirs; the gateway uses the same token for the UI's PR routes (merge, close, review, comment). Agents use gh directly rather than dedicated PR MCP tools. Ask runs, unattended runs, and machine senders hold an App token and never a person's (docs/setup/github.md). The run user resolves to a login through the same identity table as commit attribution, so the mapping is config (identity.team[].github), not code. Person-authored PRs need no bot-attribution assignee. Repository instructions, GitHub permissions, and rulesets govern interactive publication; automation and ask-mode restrictions remain enforced. A person-started code turn in the registered repository's main checkout may follow its direct-push workflow even without a connected personal token: the base-branch command guard is omitted there, but PR merge and approval guards remain. This exception follows the actual working directory, not the repo's default checkout mode, so isolated worktrees on the same project keep their base-branch guard. Ask, unattended, and machine-authored turns retain the full guard in either checkout mode. Credential selection is unchanged: this grants no token or ambient-login fallback, and GitHub still enforces permissions and rulesets.
  • GitHub web sign-in (packages/core/opensession-server/src/server/web-auth.ts + routes/auth.ts): when active, the UI's name picker is replaced by a real sign-in (UserGate → device flow → HttpOnly opensession_auth cookie; sessions in ~/.opensession/web-sessions.json, sliding 90d). Ordinary /api/* requests and the UI /ws require that web session. Exceptions are /api/auth/*; health/readiness endpoints; client update feeds and artifacts; runner registration/heartbeat; the separately bearer-gated keypad route; workload-identity discovery, JWKS, and lease-gated token endpoints; and machine WebSocket transports authenticated by their own transport credentials. Page and static-asset loads remain open so sign-in can render, while published /d applications are authenticated. Portal ports are forward-authenticated through /api/portal-auth/<port>; a browser navigation without a session is redirected to the app's own origin with a same-host return URL and sent back after sign-in (portal-sign-in.ts), never to another host. Any GitHub account that can reach this instance and complete its device flow may sign in. Missing accounts are automatically added to identity.team without an organization, invite, or roster admission gate. Every signed-in member can manage the workspace and receives roster-based GitHub webhook trust. Network access must be controlled separately. Existing identity mappings are preserved; enrollment does not assign roles. Enrollment matches only the verified GitHub login, never a profile name or email. New display names use the login with a collision-safe suffix/prefix; no claimed profile fields are copied into identity mappings. Roster persistence must succeed before a session is issued. A membership incarnation prevents old sessions and sockets from becoming valid again after automatic rejoining. Removing a member revokes their existing sessions but is not a permanent sign-in ban: another verified sign-in can create a new membership. The verified identity OVERRIDES client-claimed user on every WS message and stamps createdByLogin on new sessions; a one-time boot migration backfills createdByLogin onto existing sessions from createdBy (marker: ~/.opensession/sessions/.github-user-migration.json). Non-browser callers (curl/CDP recipes) authenticate with Authorization: Bearer <token> using a token from the web-sessions file.

One sign-in flow, for every client: the device flow (POST /api/auth/device → the person enters the code on github.com → /api/auth/device/poll, which the server also polls to completion itself so a suspended phone doesn't lose the outcome). There is deliberately no authorization-code redirect. A redirect has to return to the exact origin it left, and on the iOS PWA it comes back in Safari rather than the installed app; native apps can't take one at all. GitHub side: one org-owned GitHub App with "Enable Device Flow" checked, installed only on the repositories Open Session should reach. GitHub App user tokens are what scopes teammates' tokens to your org (see the previous section): they can't reach public/third-party repos, they expire ~8h, and github-auth.ts refreshes them via a rotating refresh token (20-min ticker parked on globalThis + refresh-on-boot; getters never hand out an expired token: the owner-identity tools are not mounted and web mutations return 403 to "connect your account"). A refresh rotates the token string, which changes the shared-server config hash → drain-respawn at next run start, by design. oauthClientSecret is what that refresh grant needs. Signing in never uses it, so an instance without one signs people in and then drops them at the first expiry.

"Enable Device Flow" is not optional on the GitHub side. It is the only sign-in there is, so an app without it refuses every attempt with device_flow_disabled, and nobody can get in. startGithubDeviceFlow maps that one code to a sentence naming the switch, since it is the failure that locks out a whole instance at once.

Self-management tools (Slack + interactive Open Session sessions)

Slack is an ingress to regular native Open Session sessions, not a separate agent runner. After the existing DM/channel admission checks, processMessage uses SessionControl.createSession or deliverToSession. Questions also get a session-owned code workspace (their prompt forbids unsolicited edits), so runtime verification can use the same Sandboxes and Portals as a web-created session. Repository and personal Sandbox defaults apply normally. Legacy threads carry forward a transcript handoff; an existing owned worktree stays on its host so uncommitted work is not lost. Slack records and old transcripts remain intact.

The shared interactiveMcpServers(user, sessionId) builder supplies the entire interactive tool set, including opensession-admin, opensession-sessions, Portals, Repositories, Assets and Workflows. Admitted interactive teammates have the same tool authority as web users (isAdmin: true); there is no second Slack MCP allowlist or per-run override. Verified Slack-to-GitHub identity is stamped at creation, not accepted from model input. Per-user connector grants still apply to the normal session runner. Channel-scoped memory is included in the opening context; subsequent turns use the regular session's memory machinery.

Replies in automation-owned or Plain discussion threads continue through their existing session and its restricted run-input policy. They never create a new unrestricted session. Automation descendants retain their inherited restrictions. Do not mount the interactive builder on automation paths: untrusted ticket text must never receive session-control or configuration tools. The in-process MCPs reach detached/remote runners through the same run-RPC proxies as web sessions. Slack-specific code handles admission, attachments, thread links and reply presentation only. Native session admission, cancellation, recovery and tool selection remain the authority after a message has been accepted.

The opensession-sessions in-process MCP (packages/core/opensession-server/src/agents/slack/sessions-tools.ts) is a sibling, wired in its unrestricted shape only to interactive runs. The scoped automationSelf shape is described below. It lets the agent see and steer every other Open Session session: read tools list_sessions (with a waiting state filter and an exact createdBy identity filter) and get_session (explicit creator/creation timestamp, state, pending question, and transcript tail) are open to any whitelisted user; the control tools — answer_session_question, send_to_session, cancel_session, create_session — are gated to the trusted user via isAdmin. The tools don't touch in-process state directly; they go through the SessionControl registry (packages/core/opensession-server/src/server/session-control.ts) that packages/core/opensession-server/src/server/session-control-wiring.ts populates at boot with the same helpers the WebSocket handlers use — so steering from here behaves exactly like a human in the web UI, and an autonomous monitor can call the same registry directly without the MCP. Sessions whose runs aren't owned by this process (CLI/tmux) are surfaced as observe-only and can't be steered/cancelled. Do not wire the unrestricted opensession-sessions server into automation paths. Cross-session control from untrusted ticket text is a privilege-escalation path.

Automation-safe in-process servers

Automations never receive opensession-admin or the unrestricted interactive opensession-sessions server. automationRunInProcessMcp mounts this explicit set:

  • Every automation receives opensession-report, opensession-turn, opensession-databases, opensession-health, and opensession-audit. The latter two expose aggregate host metrics and a bounded daily audit digest, not arbitrary filesystem or command access.
  • opensession-databases is scoped the way opensession-report is: a run only sees databases tagged with its own automation id, and the ones it creates carry that tag. Every statement is screened before it reaches SQLite (database-sql-guard.ts refuses ATTACH, DETACH, VACUUM INTO, load_extension and every non-schema PRAGMA), reads run on a read-only connection, and all SQLite work runs on the databases worker, so untrusted ticket text can fill or drop the automation's own tables and nothing else.
  • opensession-papercuts is mounted when the repository toggle is enabled (default on; Settings → Papercuts). It can append a papercut and list at most 50 recent entries, using 14 days by default and at most 120 days. Reads include stored text, the by label, repository, and timestamp. The store is ~/.opensession/papercuts, and entries also mirror to the audit log. It has no session-control or configuration-mutation tools.
  • opensession-workflows is mounted only when a human enables the automation's workflows flag. Workflow MCP calls retain that automation's MCP allowlist and AUTOMATION_DENIED_TOOLS policy.
  • The scoped opensession-sessions/opensession-self pair is mounted only when a human enables automation.selfImprove.
  • opensession-incident is mounted only when a human enables automation.readIncidentDeclarer, and only when the run's triggering event names an incident.io incident (incident_id or reference). Its one tool reads the Open Session session that declared THAT incident: title, link, server-computed evidence and its latest assistant messages. It refuses any other incident. The incident-to-session link is the server's own record of which session's incident_create succeeded (incident-declarations.ts, scanned from the run's tool stream), never the incident summary, so editing the summary cannot point a responder at another session. Separately, every run's incident_create gets the declaring session's link appended to its summary before it is sent.
  • opensession-sessions in its humanResume shape is mounted on a turn a person sends to an automation-owned session (a thread reply or a message in the web UI; resolveSessionRunInputs reports it as the automation+human-spawn branch). It carries the session list/get reads and spawn_task, task_status, and cancel_task only, never answer_session_question, send_to_session, cancel_session, or create_session. Without isAdmin, cancel_task cancels only children this session started with spawn_task (persisted parentSessionId), so it is not cancel_session under another name. Children are created for the person who prompted, so they are ordinary interactive sessions in that person's workspaces, depth-guarded like every spawned child. The automation's own ticks never carry it, and neither does a scheduled /loop tick sent in a person's name ("Kent (loop)", loopActor): the prompter is interactivePrompter(user) (RunInputs.humanPrompter), undefined for every machine actor and every scheduled actor, while accountUser keeps the name for billing. automationSessionMcp applies the same classifier at the mount, so a reattachment (local host, Runner, sandbox) that registers the persisted account user on the run token's humanPrompter still fails closed. Runner and sandbox turns proxy the same automation-bar set over run-rpc that the in-process and hosted paths mount; run-session computes it once before choosing a backend. Sandboxed descendants (sessions with an automationDescendantPolicy) are excluded. The turn keeps the automation's MCP allowlist, denials, and dropped user.

A self-improving automation's runs and thread-reply resumes receive session list/get reads plus spawn_task, task_status, and cancel_task; the direct answer_session_question, send_to_session, cancel_session, and create_session controls are omitted. This is not child-only containment: task_status accepts any session id, and cancel_task currently passes any process-owned session id to SessionControl.cancelSession without proving the target is a child. Treat selfImprove as carrying cross-session read and cancellation capability until those tools are narrowed.

opensession-self (packages/core/opensession-server/src/agents/slack/self-improve-tools.ts) can read the automation's own record and run update_own_prompt for that automation only, with a timestamped backup, automation_self_update audit event, and a length floor against degenerate rewrites. Schedule, model, mode, and repository remain human-only. Spawned children use the normal session-creation path, are PR-gated, and have spawn depth limited to 2. Never enable selfImprove for an automation triggered by untrusted event or ticket text; it is intended for introspective scheduled runs over trusted telemetry.