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:
- Deterministic patch gates decide what may be pushed. See below.
- 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.
- Only brief files, never
.github/workflows, never the default branch. The App has no Workflows permission and pushes only toupseam/*. askpushes nothing before approval, so your CI sees no patch until a person decides. See Delivery modes.- Dependabot and Renovate bumps always go through
ask. - 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,throworraise,if, loops,switch,try,breakandcontinue, and of comparison and logical operators, must not change. Conditions ofif, 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,
newof a class the file already constructs,await, arithmetic, comparisons,? :, arrow functions,constandletdeclarations with destructuring,return, type annotations,as,satisfiesand!. Computed indexes, template strings with substitutions, dynamicimport()and similar constructs are refused. In Python, the only keywords a patch may add arereturn,await,None,TrueandFalse; 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 afterreturnthat would empty it is refused. - Forbidden names and addresses. Names such as
process,fetch,eval,require,os,environ,subprocess,exec,openand__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.parsein JavaScript and TypeScript;str,int,lenin 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,letorvar, 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) | |
| 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. 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.
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,Securecookie 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.