# Security model

## Who runs what

Upseam never runs your code or the model's answer. Your CI does.

The model's answer becomes a commit on a branch `upseam/*` in your
repository. That branch triggers your `push` and `pull_request` workflows,
and because it is a branch of the same repository, not a fork, those
workflows get your repository's secrets before anyone reviews the pull
request. So the model's answer is untrusted code with access to your CI
secrets, and the defenses below are built around that.

The untrusted input is the vendors' changelog and spec text, the text of your
internal contracts and your own code: all of them go into the model's brief.
An instruction planted in any of them could ask the model to add a network
call or read environment variables.

Defenses, strongest first:

1. **Deterministic patch gates** decide what may be pushed. See below.
2. **Vendor text is data.** Changelogs and specs are quoted in the brief as
   untrusted data, and the model is told not to follow them. This lowers the
   risk but does not remove it, which is why the gates exist.
3. **Only brief files, never `.github/workflows`, never the default branch.**
   The App has no Workflows permission and pushes only to `upseam/*`.
4. **`ask` pushes nothing before approval**, so your CI sees no patch until a
   person decides. See [Delivery modes](delivery-modes.md).
5. **Dependabot and Renovate bumps always go through `ask`.**
6. **Your side.** Keep secrets away from jobs that run on `upseam/*` before
   review, and protect the default branch.

## Patch gates

The gates are an allow-list, not a list of forbidden things. A patch that
fails any of them is not pushed, and the finding goes to **Needs you**.

- **Files and place.** Only files in the brief change. Each edit sits inside a
  statement that matched the change, within two lines of the match, and
  touches the changed symbol or its replacement. Each edit has a size budget
  of twice the matched place plus 40 characters. Whitespace-only edits and
  invisible or bidirectional characters are refused.
- **Parsing.** Every changed file must still parse: JavaScript and TypeScript
  with the TypeScript compiler, Python and Go with a lexer. Other file types
  and Go test files are refused.
- **Control flow.** In the whole file, the number of `return`, `throw` or
  `raise`, `if`, loops, `switch`, `try`, `break` and `continue`, and of
  comparison and logical operators, must not change. Conditions of `if`,
  loops and `? :` must stay exactly as they were. Words like auth,
  permission, role, token or session at the edit stay.
- **Same tokens, one rename.** At the edit, the sequence of names, keywords,
  strings, numbers and punctuation must stay the same, except that a matched
  symbol becomes its replacement or the new name the vendor's change gives. A
  replaced string or number may sit anywhere; a replaced name only after `.`
  or `?.`, as an object key, or as a Python keyword argument, and never as a
  global, a forbidden name or a name the file already declares. Boolean
  literals do not change, values of properties whose names look like URLs,
  hosts, keys, tokens, secrets, auth or certificates do not change, and a
  value may only be assigned to a property the matched place already
  assigned.
- **Simple syntax.** In JavaScript and TypeScript, changed lines may use only
  names, member access, literals, objects and arrays with spread, calls, `new`
  of a class the file already constructs, `await`, arithmetic, comparisons,
  `? :`, arrow functions, `const` and `let` declarations with destructuring,
  `return`, type annotations, `as`, `satisfies` and `!`. Computed indexes,
  template strings with substitutions, dynamic `import()` and similar
  constructs are refused. In Python, the only keywords a patch may add are
  `return`, `await`, `None`, `True` and `False`; dunder names, new f-strings,
  calling the result of an expression and computed indexes are refused. In
  all languages, non-ASCII names are refused. In Go, a patch may not change
  the package clause, imports, top-level declarations, compiler directives
  (`//go:`, `//line`, `#cgo`) or build constraints, may add no keyword or
  semicolon, and may not change code outside the edited lines.
- **Statement ends.** A line break, `;` or comment may not start or end a
  statement in a new place: each edit keeps where its statements end, and in
  Python at which indentation the next statement starts. A line break after
  `return` that would empty it is refused.
- **Forbidden names and addresses.** Names such as `process`, `fetch`, `eval`,
  `require`, `os`, `environ`, `subprocess`, `exec`, `open` and `__import__`,
  and any URL, host or IP address, are refused in added lines unless the same
  line or URL was there before.
- **Type changes only.** Conversion calls from a small list (for example
  `String`, `Number`, `JSON.parse` in JavaScript and TypeScript; `str`, `int`,
  `len` in Python) are allowed only for a vendor change of type "type
  change", and only inline where the value is used. Any other new call must be the SDK method the vendor's
  change names, on a receiver the removed lines already called. In Go, the
  SDK pointer helpers (`stripe.String`, `stripe.Int64`, …) count as
  conversions when the file imports the official SDK.
- **No new locals.** A patch may not declare any new local name in any
  language: no `const`, `let` or `var`, no Python assignment to a new name, no
  Go `:=`. Such a patch goes to a person.
- **New names and strings** come only from the file, from the symbols and
  replacements of the vendor's change.
- **Re-scan.** After the edit, Upseam searches the patched files again for the
  group's changes. If any matched place is left unchanged, the patch is
  refused.

**What remains.** A patch that passes the gates can still change how data
flows within two lines of the matched code, for example by renaming to a name
from the vendor's data. In `auto`, your CI runs it with your secrets before
review. That is why `ask` is the default. Legitimate edits the gates refuse,
such as editing a condition or a line with `process.env`, go to a person too.

## What Upseam stores

| Stored                                                     | Not stored                                                   |
| ---------------------------------------------------------- | ------------------------------------------------------------ |
| Installation, repositories, settings                        | Your code, code lines, diffs, pull request and issue bodies  |
| Surfaces: provider, SDK, versions, pins as file and line    | Repository archives: only in a temporary directory during a step |
| Matches: path, line, symbol                                 | The model's answer after the step                            |
| Change events from your internal contracts: changed operations, types and fields | |
| Proposals: branch, commit, pull request number, CI status   | GitHub installation tokens: in memory only, for up to an hour |
| Approvals: channel, decision, who decided                   | Your model key when you generate in your GitHub Actions      |
| Encrypted: Slack bot token, model key (when stored); its last four characters in plain text as a hint |                                                              |
| Product events, see below, for 13 months                    |                                                              |

Uninstalling the App deletes the installation's repositories, surfaces,
findings, settings and encrypted secrets. A removal marker with only the
installation id and the time of removal stays. Its product events are
deleted, Upseam asks PostHog to delete the pseudonymous person, and a marker
with the installation id is kept for up to 13 months so that late events are
not exported. Records of queued work and received webhook deliveries
are not deleted, and the Slack bot token is not revoked with Slack: see
[Uninstall](install.md#uninstall). Every row is scoped to its installation by
the database itself, with row-level security.

## Who else receives your data

- **GitHub**, where your code already lives.
- **Your Slack workspace**, if connected: file paths and line numbers, no code.
- **The model vendor you choose**: the change data and the files with matches,
  sent with your key.
- **PostHog**: the pseudonymous product events above, no code.
- **Nobody else gets your code.** Change events are labelled with Jev
  (TypeSafe) once, when Upseam ingests a vendor change; your code is never sent
  to TypeSafe.

## Product analytics

Upseam records product events such as: the App was installed, a vendor
change was ingested, a dashboard issue was created or updated, a model or
Slack was connected, and a pull request was opened, got a CI result, was
merged or closed. Events carry only ids, flags and fixed values such as the
mode, never code, repository names or user logins. They are kept for 13
months.

Upseam exports these events to PostHog. The installation id is replaced with
a keyed hash (HMAC), and PostHog's IP geolocation is switched off. How long
PostHog keeps them is set in PostHog, not by Upseam.

This landing page and this docs site separately send their own pseudonymous
events to the same PostHog project, in PostHog's cookieless mode: no
cookies, no local or session storage, and no visitor profile. They count
page views, Web Vitals, and clicks on a fixed set of elements such as
calls to action, code copy buttons and outbound links. PostHog adds its
standard properties, such as the page address and title; events never
carry code or anything you type. Nothing loads, and no request is
sent, if your browser sends Do Not Track or Global Privacy Control.
Cookieless site visitors cannot be linked to an installation. Every
event marks itself so PostHog skips IP geolocation for it, the same as
the product events above; PostHog still uses your IP address itself to
compute the daily cookieless identifier, which is how cookieless mode
works, but that address is not stored as an event property.

## Accounts and sessions

- The setup and settings pages use Sign in with GitHub. An installation is
  shown only to users GitHub lists for it, and every change is checked against
  a fresh answer from GitHub, with no cached permissions.
- The session lives in an encrypted, `HttpOnly`, `Secure` cookie for up to
  seven days. **Sign out** revokes the GitHub token.
- Every form is protected against cross-site requests, and the pages send a
  strict Content Security Policy.
