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 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_MODELis 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'sawsgrant 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
mcpServersallowlist (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
inputsare 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_createmay 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.
runAutomationmaps every native or legacy Pi model id onto Pi at dispatch (automationModel; unset usesDEFAULT_PI_AUTOMATION_MODELin 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 unrestrictedopensession-sessions, and per-user (allowedUsers) servers stay out of automation runs. The scoped automation-safe set is documented below. Pi'scodemodetool 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 withintegrations.aws.untrustedRuns), and mounts onlyopensession-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. modeis 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 toorigin/<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-assetswrite_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 normalassets_changedevent. - A code automation's
prRevieweris 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(siblingowner/reponames under its own owner) gives its runs a second, read-only installation token,GH_READ_TOKEN, covering its repo plus those. The primaryGH_TOKENis 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 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.)
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.
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.
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.
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.
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.
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 stripsallowedUsersbefore 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'smcpServersallowlist 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.accountUsernever 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/:namewith{allowedUsers}), or via opensession-admin (add_mcp_server'sallowedUsers, andset_mcp_allowed_users). Backing helpers:addMcpServer/setMcpAllowedUsersin 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 inmcp-config.jsonis picked up on the next run/message and does not require a restart.
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
@mentioncommands require the comment author's exact GitHub login on the team roster.author_associationis 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.
External fork PRs can receive an automatic semantic review without running GitHub Actions or placing contributor code in a host worktree:
- GitHub REST supplies the PR identity and a size-bounded patch, pinned by base repository, PR number, base SHA, head repository and head SHA.
- A fresh disposable Daytona Executor anonymously fetches
refs/pull/<number>/headplus 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. - 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.
- 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.
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 explicitrepos.<id>.publicinconfig.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 repositoryrule in its shared run instructions (run-instructions.ts): describe the change in terms of the repository alone; the attribution footer andCo-authored-bytrailer 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.privateTermsis 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.
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.
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-runnerrunGithubEnv, 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 useghdirectly 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_authcookie; sessions in~/.opensession/web-sessions.json, sliding 90d). Ordinary/api/*requests and the UI/wsrequire 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/dapplications 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-hostreturnURL 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 toidentity.teamwithout 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-claimeduseron every WS message and stampscreatedByLoginon new sessions; a one-time boot migration backfillscreatedByLoginonto existing sessions fromcreatedBy(marker:~/.opensession/sessions/.github-user-migration.json). Non-browser callers (curl/CDP recipes) authenticate withAuthorization: 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.
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.
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, andopensession-audit. The latter two expose aggregate host metrics and a bounded daily audit digest, not arbitrary filesystem or command access. opensession-databasesis scoped the wayopensession-reportis: 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.tsrefusesATTACH,DETACH,VACUUM INTO,load_extensionand every non-schemaPRAGMA), 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-papercutsis 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, thebylabel, 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-workflowsis mounted only when a human enables the automation'sworkflowsflag. Workflow MCP calls retain that automation's MCP allowlist andAUTOMATION_DENIED_TOOLSpolicy.- The scoped
opensession-sessions/opensession-selfpair is mounted only when a human enablesautomation.selfImprove. opensession-incidentis mounted only when a human enablesautomation.readIncidentDeclarer, and only when the run's triggering event names an incident.io incident (incident_idorreference). 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'sincident_createsucceeded (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'sincident_creategets the declaring session's link appended to its summary before it is sent.opensession-sessionsin itshumanResumeshape is mounted on a turn a person sends to an automation-owned session (a thread reply or a message in the web UI;resolveSessionRunInputsreports it as theautomation+human-spawnbranch). It carries the session list/get reads andspawn_task,task_status, andcancel_taskonly, neveranswer_session_question,send_to_session,cancel_session, orcreate_session. WithoutisAdmin,cancel_taskcancels only children this session started withspawn_task(persistedparentSessionId), so it is notcancel_sessionunder 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/looptick sent in a person's name ("Kent (loop)",loopActor): the prompter isinteractivePrompter(user)(RunInputs.humanPrompter), undefined for every machine actor and every scheduled actor, whileaccountUserkeeps the name for billing.automationSessionMcpapplies the same classifier at the mount, so a reattachment (local host, Runner, sandbox) that registers the persisted account user on the run token'shumanPrompterstill 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 anautomationDescendantPolicy) are excluded. The turn keeps the automation's MCP allowlist, denials, and droppeduser.
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.