Dated 2026-10-26. What Saram holds of yours, where each thing lives, who or what can read it, how
it reaches a run, how it is kept out of the evidence a run publishes, how you take it back, and how
long it lasts, down to what you choose to publish. Then what Saram never holds and how to report a vulnerability.
Written for a customer and for anyone reviewing Saram on a customer’s behalf. Every claim is true
by a file, a commit or a recorded decision; the copy Saram’s operators keep names it beside the
claim, and the public copy of this page leaves those pointers out. No secret value appears here and
none ever will.
Saram is a behavioral QA harness: synthetic personas drive a real browser through your product and
the run produces an evidence-backed artifact. To do that it needs a door into your
product and a way to pay for model calls, which is the whole of why it holds anything at all.
One deployment exists, the dev stack on AWS (https://api.saram.sh, which was
https://api.saram.online before 2026-09-24), and
the current milestone is a private pilot on it. There is no production deployment yet,
so everything below describes dev. Dev is not a place for customer data.
Ten classes. Each table answers the same seven questions, in the same order. Class 7 splits the
last one into what is decided and what is deployed, because on dev today they differ. Class 9 is
what you choose to make public, so where the others say how a thing reaches a run, it says how it
leaves Saram. Class 10 is what a visitor with no account hands Saram on the homepage, and what
Saram hands back.
|
|
| What |
One machine credential for your tenant, sk_saram_<env>_ plus 40 base62 characters. The prefix is what lets GitHub’s secret scanning recognise a leaked one. |
| Where it lives |
An api_keys row in Saram’s Postgres, as sha256 of the whole plaintext beside the first eight characters of the random part. The plaintext exists once: in the reply to POST /api/v1/api-keys, or on the last line of saram-tenants key mint. It is stored nowhere. |
| Who can read it |
Nobody, including Saram. The gate looks the row up by those eight characters and compares digests with hmac.compare_digest. GET /api/v1/api-keys lists metadata only, no secret and no hash. The key is never printed, never put on a span, never recorded. |
| How it reaches a run |
As Authorization: Bearer on your request. The gate turns it into a Principal (tenant, subject, caller, scopes) and the key itself goes no further. |
| In the evidence |
Not in it. runs.created_by holds the key’s id (key_...) and runs.caller its label; the artifact carries neither. |
| How you revoke it |
DELETE /api/v1/api-keys/{key_id}, or saram-tenants key revoke. The row stays with revoked_at set so the runs it started keep pointing at something. --expires-in-days N refuses it at the gate after N days. Rotation is mint, move the caller, revoke. Suspending the tenant stops every key at once without touching one. |
| Retention |
The row is kept. The secret was never stored, so losing it means minting another. |
|
|
| What |
Who a person is and which organisation they act for. Saram stores users.idp_subject, users.email (nullable) and a memberships row carrying their role. |
| Where it lives |
The credential itself is WorkOS AuthKit’s: sign-up, sign-in, verification, password reset, organisations and invitations are theirs. Saram never sees a password. The dashboard holds a sealed wos-session cookie, server-side sealing only. The API holds no session at all. |
| Who can read it |
The API verifies the access token per request against a cached key set, with alg pinned to RS256, iss choosing the key set, exp and iat checked. No vendor SDK and no call to WorkOS on the request path. The token is never logged. |
| How it reaches a run |
The dashboard’s proxy forwards the person’s own token; the dashboard holds no key of its own and the pod is deployed without one. At /mcp a coding agent signs in through AuthKit and gets a token bound to that resource. |
| In the evidence |
runs.caller records the email, or the provider subject until an address arrives. trigger.actor is filled from the caller when a run starts over MCP. The artifact carries neither. |
| How you revoke it |
Remove the seat at WorkOS. The signed webhook mirrors it and the memberships row is what decides the role, never the token’s role claim, so a demotion takes effect on the person’s next request rather than when their session expires. Deleting the organisation suspends the tenant, never deletes it. |
| Retention |
Removing a seat at WorkOS deletes that organisation’s memberships row; the users row stays. Deleting the user at WorkOS deletes their users row and their memberships row in every organisation. The runs they started are kept. runs.created_by holds the person’s user id as text, not as a foreign key, so it outlives the deleted row and then names no user. runs.caller keeps what it was written with, the email or the provider subject. |
|
|
| What |
The provider or router key that pays for your run’s model calls. Bring-your-own keys are the product. |
| Where it lives |
AWS Secrets Manager, at saram/<env>/tenants/<tenant_id>/providers/<name>. The provider_credentials row in Postgres records the name, the kind and the awssm:// ref, never the value. |
| Who can read it |
The three model-calling workers (actor, observer, reproduction), at the start of the activity that makes the call. The API holds CreateSecret, PutSecretValue, DescribeSecret and DeleteSecret on that prefix and no GetSecretValue, so “the API cannot read a customer’s provider key” is a property of the deployment and not a promise about its code. |
| How it reaches a run |
As a reference and never as a value: providers.entries[].credential_ref in your run spec. Resolved in the worker that makes the call, with no cache, so a rotation is live on the next session. The resolved value enters no Temporal payload, span, event, checkpoint or artifact. POST /runs refuses any awssm:// ref outside your tenant’s prefix with a 422 naming the entry. |
| In the evidence |
The ref is in the spec, which the artifact carries, because a spec is evidence of what the run was asked to do. The value is in the Model object and nowhere else. Spans carry saram.provider.name, .kind and .model, never the credential and never base_url. |
| How you revoke it |
DELETE /api/v1/credentials/{name}. It force-deletes the secret with no recovery window, because a credential you withdrew has to stop being usable now. The row stays with deleted_at set as the only remaining record of what you had, and the name is free again. There is no replace: rotation is DELETE then POST. |
| Retention |
As long as you keep it. Nothing expires it. |
|
|
| What |
The credential that gets a browser past your staging host’s own gate, rather than past your product’s sign-in: HTTP basic auth (entry.basic), or a bypass header such as x-vercel-protection-bypass or a Cloudflare Access service token (entry.headers). |
| Where it lives |
Today: one deployment-wide JSON document in Secrets Manager, mounted read-only on the browser pool and the actor worker at a path the deployment configures. There is no customer channel for it, and secret:// refs are not tenant-checked. |
| Who can read it |
The pool, which applies basic as Playwright httpCredentials and headers as extraHTTPHeaders on the browser context. Never rendered into a persona prompt: a persona cannot type into a realm. |
| How it reaches a run |
target.auth.secret_ref in the spec, carried to the body as session_create.target_auth_secret_ref by the actor and by every replay attempt. |
| In the evidence |
The contract requires the pool to keep it out of every log line, span attribute and surface event, as it keeps request headers out. The pool half is not written yet, so nothing is applied and nothing is recorded: a run against a gated target ends its sessions errored at setup, saying so. |
| How you revoke it |
Rotate it at your own host, and ask the operators to remove the entry. You cannot do it yourself today. |
| Retention |
Until the entry is removed by hand. |
|
|
| What |
The account a persona signs in with: an email, a password, whether that address has a readable inbox, free-text notes, or pre-authenticated cookies instead of a login. |
| Where it lives |
The same deployment-wide secret store as class 4, with the same limits. |
| Who can read it |
Two readers with different appetites. The persona prompt renders email, password, inbox and notes and never sees a cookie; the pool applies cookies to the browser context and never reads the login. Each ignores the other’s fields, and the entry is closed (x-closed), so a field the contract does not list is refused rather than dropped in silence. |
| How it reaches a run |
target.seed.accounts in the spec, as a secret:// ref. |
| In the evidence |
Three sinks are closed and one is open. The actor’s checkpoint replaces the password with [redacted credential]; pydantic-ai’s content export is off, so no span carries it; the agent step-event writes a marker, {"text": "[secret:secret://...]", "secret": true}, in place of the typed value, and the artifact’s repro_steps carry the same marker, which reproduction resolves back at replay through its own store. The open sink is the pool’s surface trace: the published pool does not advertise the type.secret capability, so the actor sends the characters unflagged and surface.ndjson still records what was typed. |
| How you revoke it |
Change the password on your own product, and ask the operators to remove the entry. |
| Retention |
The entry until it is removed; the trace that records the session for your plan’s evidence retention (class 7). |
|
|
| What |
A file a persona hands to your product: a logo for an onboarding upload, a purchase order at checkout. |
| Where it lives |
Two objects in the evidence store, files/<caller>/<file_id>/blob and .../meta.json. You name it only as saram-file://<caller>/<file_id>. |
| Who can read it |
The pool, which fetches exactly the URIs the control plane gives it and never lists and never browses. No route serves an uploaded file’s bytes back at all, yours included; GET /api/v1/blobs refuses any key whose first segment is not a run, which is what makes it safe to keep files in the evidence bucket. Another caller’s file id is a 404, not a 403. |
| How it reaches a run |
The spec’s files[] block, by ref. POST /runs refuses a ref that names another caller, one whose metadata is not in the store, one past its expires_at, or more files or bytes than the deployment allows. |
| In the evidence |
The surface event carries the name, the size and the SHA-256, never the bytes. The persona never sees a path: the pool’s per-session temp directory is the only place one exists and it dies with the session. Bytes appear in no log line, span, checkpoint or event. |
| How you revoke it |
POST /api/v1/files/{file_id}/delete, at any time. The run’s evidence never copied the bytes, so that deletes the only copy. |
| Retention |
30 days, stamped at upload as expires_at from the deployment’s file lifetime, and read back from what was promised at upload rather than from the deployment’s current setting. Four things may delete a file: you, create_run refusing a run that names an expired one, a bucket lifecycle rule on the files/ prefix, and the saram-retention sweep. All four are on dev: the files/ rule at 30 days and the daily sweep since the deploy of 2026-10-21 (class 7, “Retention, as deployed”). |
|
|
| What |
The evidence a run is worth reading for: the agent and surface step-event streams, the actor’s checkpoints, observation.json and gate.json, and on the recordings side a screenshot per step, a video per session and the pool’s browser checkpoints, which are Playwright storageState and therefore whatever cookies and local storage your product set for that persona’s session. |
| Where it lives |
The evidence bucket under <run_id>/... and the recordings bucket under <run_id>/<session_id>/.... The layout is by run id; the browser pool goes on not knowing that tenants exist. |
| Who can read it |
GET /api/v1/blobs, gated with everything else and scoped to your tenant. A run outside it is a 404 byte-for-byte identical to the 404 a run id nobody ever used gets, because a 403 would confirm that a guessed id is real. That check is the whole of the S3 isolation. Presigned S3 URLs are refused on purpose: a presigned URL is bearer authority on the object and leaves both the gate and the ownership check behind. The dashboard proxies media same-origin and adds the header server-side. One exception is built: a share link (class 9) lets anyone holding it read one finished session’s video and an allowlisted view of its steps, through two anonymous routes that proxy the video and never presign it. It opens that one session and nothing else: no screenshot, checkpoint or trace, and no other session or run. A customer tenant’s session can be shared only if it was recorded under the pool’s mask and an operator has turned the tenant’s switch on (class 9). |
| How it reaches a run |
It is produced by one. |
| Redaction |
Typed credentials, as in class 5. Narration and target descriptions also lose a one-time code ([redacted code]) and a key-shaped string ([redacted key]) by pattern, in the actor before the line is stored, so the dashboard, MCP and a share page read one text; a session recorded before that reached dev keeps its narration as it was. The account email is kept on purpose. The pool masks the stored screenshots and video at record time, as solid black boxes: the value of every field the persona typed into, every autocomplete="one-time-code" field and every element carrying data-saram-mask, with what is inside it, on dev since 2026-10-23. Nothing else on screen is changed: text your product rendered, the account email and another user’s data are in the recording as the browser painted them, and you take one of those out by marking it data-saram-mask. What the persona is shown is not masked, because it has to read the page it tests; only what is stored is. A session recorded before the masking pool reached dev is unmasked, and the mask has limits. Treat a run’s recordings as being as sensitive as the account it ran on. |
| How you revoke it |
POST /api/v1/runs/{run_id}/delete, on a run of your own tenant with a key carrying runs:write. It deletes the run’s traces, checkpoints, screenshots, video and own documents in both buckets, then the run and every row below it; asked again it answers the same, and another tenant’s run is the same 404 an unknown one gets. A run still running, or with a retest still running, is a 409: cancel it first. What the run was billed stays in your usage. Dev serves it from the first deploy of a control plane carrying @saram/platform-api 0.8.0. A tenant is still retired by suspending it, revoking its keys and deleting its secrets. |
| Retention, as decided |
Session evidence for your plan’s number of days from runs.created_at, 90 on Pro and 14 on Free, swept by saram-retention; recordings the same number of days; the run’s record (its rows) and its own documents (the spec, the artifact, repro.json) are kept, because the record is the product and the trace underneath it is what goes stale. Every run row carries evidence_expires_at and evidence_expired, so a 404 on a trace is never ambiguous between “expired” and “never existed”. A run whose video S3 has already removed is marked expired then, and the rest of its session evidence goes on the same pass, because a half-dead trace is worse than none. |
| Retention, as deployed |
The per-plan number is built: a run created on Free keeps its evidence 14 days, on Pro 90, on a step plan the deployment’s 90, and a run keeps the longer of its plan’s number at creation and its tenant’s plan’s number now. On dev the recordings bucket’s one rule expires an object at 90 days, the backstop at the longest plan number, and in the evidence bucket nothing expires but files/, at 30. A probe reads both sets of rules off S3 on every deploy and fails the deploy on any other; it has read them from the first deploy after 2026-09-27, on 2026-10-21, to the deploy of 2026-10-24, which read 90 d and files/ = 30 d. The sweep is the retention CronJob, a real pass daily at 15:43 UTC. The deploy of 2026-10-24 listed its Jobs of 2026-10-22 and 2026-10-23, both completed. A Free run’s 14 days and every run’s session evidence in the evidence bucket have no rule behind them: they go when the sweep runs. Marking a run expired as soon as its video is gone (“Retention, as decided”) is on dev since 2026-10-23. Its first pass stamped evidence_gone_at on the nineteen runs from before 2026-09-20 whose media the bucket’s earlier thirty-day rule had taken, and took their remaining 526 keys. Those runs now read evidence_expired: true. Since a change of the same day, a missing video on a run younger than thirty days is reported as videos_missing_unexplained and neither stamped nor taken, so such a run reads live over a 404. The dashboard does not yet show “evidence expired”. |
|
|
| What |
A Stripe customer id on tenants.billing_customer_ref, a subscription id, and a ledger of what your runs metered. |
| Where it lives |
Saram’s Postgres (usage_events, append-only, unique per run, session, meter and role) and Stripe. |
| Who can read it |
You, through GET /api/v1/usage and GET /api/v1/usage.csv; an operator, through saram-tenants usage, which produces byte-for-byte the same file, because the operator sends the invoice and you check it. |
| What crosses to Stripe |
One meter, saram_steps: a quantity, a timestamp, your customer id, and the ledger row’s own id as the idempotency key. No run content, no spec and no evidence. |
| Redaction |
Not applicable. Nothing about a run’s content is in the billing path. |
| How you revoke it |
Your card and your invoices are Stripe’s hosted portal, reached from the usage screen behind keys:admin. Saram holds no payment instrument, so there is nothing here to revoke at Saram. |
| Retention |
The ledger is kept: run_id is deliberately not a foreign key, so the billing record survives retention and suspension. |
|
|
| What |
An unlisted link to one finished session’s replay, which anyone holding it may open with no sign-in. The link is the share host, https://share.saram.sh on dev, followed by a token: shs_ plus 32 base62 characters, about 190 bits. Not sk_, because it is meant to be posted and secret scanning should not page anyone over it. One link names one run and one session and opens nothing else. |
| Where it lives |
A share_links row in Saram’s Postgres, fenced by row-level security like every table a tenant owns: the run and session, who minted it, expires_at, revoked_at and revoked_by, a view count and the last view’s time, and of the token only its sha256 and its last four characters, so two links can be told apart. The token exists once, inside url in the create answer, and is stored nowhere, so a database read yields no working link; a lost link is replaced, never recovered. Nothing about a viewer is kept. The video it plays stays where class 7 put it. |
| Who can read it |
Anyone holding the link: that is what publishing is. They get an allowlist, built field by field: the video; the persona’s label and pack, the mission’s goal, the target’s origin, the result, the duration and the link’s expiry; and per step the action type, the target description, the narration, the page as origin plus path, and its window in the video. Never action_args, inputs, field, a URL’s query, fragment or ; parameters, dialogs, downloads, files, console or network text, the reasoning summary, the perceived state, tokens, model, latency, refs, ids, screenshots, verdicts or opportunities. In your tenant, runs:write mints and revokes and runs:read lists, over REST, the MCP tools or the dashboard (a Share button on a finished session, the public links on the run page); a link belongs to the tenant, as a run does, so any writer may revoke any of its links, and revoking the key that minted one leaves the link alone. |
| How it leaves |
Two anonymous routes, GET /api/v1/shared/session and GET/HEAD /api/v1/shared/session/video, live on dev. The token is in the link’s path, which the share host’s server reads; it reaches the API in X-Saram-Share and never in a path or a query, so no API access log has it, and nothing logs it. A malformed token is refused before a query; a well-shaped one is found by its sha256 alone, through a reveal that shows the application role that one row, and every later read acts for the link’s tenant, with no id read from the request. The video is streamed by proxy from the private bucket, never by a presigned URL. Every answer, refusals included, carries Cache-Control: no-store, an X-Robots-Tag starting noindex, Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff. A missing, malformed, unknown, revoked or expired token, the run’s evidence expired, the run or the session gone, the tenant suspended or its switch off, a customer’s session recorded without the mask, and a session with no stored video all get one 404, byte-identical in body and headers. Limits, per API pod: 60 requests at once and one a second after, per client address, then a 429; at most 24 open video streams per link. The page a reader opens is the dashboard image’s own on the share host, outside its sign-in gate and under a root layout with no session in it, on dev since 2026-10-23: the persona, the goal, the origin and the result, the video, and the steps, each seeking the video. The video is served from the share host’s own path, so the API’s address and the bucket never reach the reader’s browser, and the page carries a per-request content security policy with media-src 'self'. The sign-in cookie is host-only, so an owner who opens a link sends no session to it. An unknown link, and every other path on the share host, is one neutral 404 page. A post’s unfurl carries the persona’s name, the mission’s goal, the origin, the result and the step count, over one static card and never a frame of the session. |
| Redaction |
Narration and target descriptions are redacted for one-time codes and key-shaped strings at the source (class 7) and once more on the way out, so a session recorded before the actor did it is published no less redacted. The account email is shown. The view never carries what the persona typed as text, so the pool’s open typed-password sink does not reach it. The video is masked as class 7 says: typed values, one-time-code fields and data-saram-mask elements are black boxes, and everything else the page showed is in it. A customer tenant’s session is shared only if it was recorded under the mask: the pool stamps every session it opens, the session row keeps the stamp (sessions.recorded_under_mask, on dev since 2026-10-24), and create refuses a session without it with a 409 whatever the tenant’s switch says, as the public read does; a session recorded before that stays unshareable. The fleet’s tenant is exempt, its runs being against public products with Saram’s own throwaway accounts, so a fleet session recorded before the mask reached dev shows typed values as typed. The create answer, over REST and MCP, carries masked, whether the pool recorded the session under the mask, and warning: anyone holding the link can watch; what is masked, by name, or that nothing is; what is not, by name (the test account’s email, rendered text, another user’s data); and how to revoke. The dashboard’s share dialog shows the warning with the link. The create_share_link MCP tool’s description opens by saying the link is public, the recording unredacted, and how to revoke it. |
| How you revoke it |
POST /api/v1/shares/{share_id}/revoke, the revoke_share_link MCP tool, or Revoke on the run page’s public links in the dashboard, with runs:write. The next request is refused and there is no cache to purge; a viewer who buffered part of the video keeps only that. Asking again answers the same row. GET /api/v1/runs/{run_id}/shares, or list_share_links, lists a run’s links, revoked and expired ones included, and says whether your tenant’s sharing is on. Deleting the run deletes its links. The switch, tenants.public_shares, is off for every tenant until an operator turns it on with saram-tenants set <tenant> --public-shares on --by <who>, and back off the same way; no route sets it. Off, minting is a 403 and every link of the tenant is the 404 at once, rows kept; back on, they live again until their own expiry. A customer tenant’s switch is turned on only for a tenant that asked, and only once the mask and the narration set are deployed; on dev both are, since 2026-10-24. Before you post a link, open it signed out and watch the whole replay. |
| Retention |
30 days unless the create asks for 1 to 90 (expires_in_days), and never past the run’s evidence_expires_at; capped_by_evidence in the answer says when that won. No link that does not expire and no extending one: a link that should live longer is a new link. A revoked or expired row is kept for your list and goes only with its run (POST /api/v1/runs/{run_id}/delete cascades). |
The demo opens on no environment before the browser pool’s egress wall
is deployed there. Until then this class describes routes that are built
and closed: no demo tenant is configured on dev, so both routes
answer 404.
|
|
| What |
A stranger with no account on saram.sh pastes a site’s address, and Saram runs one capped session of one persona against it and hands back a 7-day link to the replay. Saram holds: the address as pasted, cut to origin plus path; the visitor’s network address, as an HMAC; a demo token, dmo_ plus 32 base62 characters (about 190 bits), the visitor’s only credential for that demo’s status, for 24 hours; and the run, its recording and its share link, as for any run (classes 7 and 9). The visitor holds no key, and no key is minted for the demo tenant. |
| Where it lives |
A demo_requests row in Saram’s Postgres, fenced by row-level security like every table a tenant owns: the address as HMAC-SHA256(pepper, its IPv4 /32 or IPv6 /64), so the table holds no raw address; the target’s registrable domain, not the URL; the demo token’s sha256 alone; the run’s id once it starts; and once the replay exists, the share link’s id and its token sealed under the demo token (AES-256-GCM under a key HKDF-SHA256 derives from the plaintext demo token, bound to the row’s id). A database read therefore yields neither token and no working link. The pepper is 32 characters or more, without which the api refuses to start once the demo tenant is named; no deployed environment holds one yet. A refusal by the bot check or a limit is a row too, with its reason and no token; a refused URL writes nothing. The URL itself lives where any run’s does: runs.spec and runs.target, query and fragment dropped before anything is stored. The run and its recording are the demo tenant’s, Saram’s own, on the hosted plan. |
| Who can read it |
The visitor, with the demo token: pending, running with a step count, done with the share link, failed or refused, and nothing of the run itself (no run id, verdict, issue or trace). Anyone the visitor gives the share link to sees class 9’s allowlist. Saram’s operators see the rows and the run as they see any tenant’s. Nobody else: the demo tenant has no standing key, no member and no sign-in. |
| How it leaves |
Two anonymous routes, POST /api/v1/demo/runs and GET /api/v1/demo/runs/status, called cross-origin by the landing’s page from https://saram.sh, which is the only origin they answer and is answered by no other route. No principal: the routes act as the demo tenant, built from the deployment’s demo tenant setting by one function and from nothing a request carries. The demo token reaches the API in X-Saram-Demo and never in a path or a query, so no access log has it. The pasted URL decides where the browser goes and nothing else: the server composes the rest of the run (one persona, one mission that forbids creating an account, paying or sending the visitor’s details, 20 steps, 240 seconds), refuses Saram’s own zones, an operator’s deny list, a non-default port and any private, cluster or local address (private_target), and counts per UTC day 3 demos per address, 10 per registrable domain, a model-spend ceiling and 2 in flight; a demo left waiting for a seat past a configured wait is cancelled. A Cloudflare Turnstile check gates every start where its secret is set (only compose and a local stack run without it), and fails closed. None of this is the wall: a name public at the check can be private at the browser, and a redirect is not re-checked, which is why the pool’s egress network policy is the precondition. Every answer carries class 9’s headers (no-store, noindex, no-referrer, nosniff); a missing, malformed, unknown or expired demo token gets one byte-identical 404; a per-address bucket (60 at once, one a second, per API pod) answers a 429 past it. |
| Redaction |
The query and the fragment of the pasted URL are dropped before it is stored or opened, so a reset link or a token in a query never reaches the browser or the run record; a capability in the path cannot be told apart from an ordinary path, and is the visitor’s own to give. The recording shows whatever the target rendered, as class 9 says; the demo tenant is exempt from the mask stamp, because its spec hands the persona no account and its mission forbids typing the visitor’s details, so there is no typed secret for the mask to hide. The visitor’s network address is never stored, only its keyed hash. |
| How you revoke it |
A visitor has nothing to revoke: the demo token dies at 24 hours, and the link at 7 days. An operator closes the demo at once with no deploy (saram-tenants suspend demo, or saram-tenants amend demo --public-shares off), which also stops every demo link playing; refuses a site for every later demo by adding it to the operators’ deny list; and withdraws one link with POST /api/v1/shares/{share_id}/revoke as the demo tenant, which takes a key minted on it for the purpose (saram-tenants key mint demo --name revoke-share --scopes runs:write --expires-in-days 1) and revoked after, since a break-glass key is the fleet’s and sees no other tenant’s links. |
| Retention |
The demo token: 24 hours. The share link: 7 days, never past the run’s evidence. The run and its recording: the hosted plan’s evidence term, as for any run (class 7); whether a stranger-chosen third party’s recording should live shorter is open. The demo_requests rows: kept, to be pruned at 30 days, not built yet. |
- Your card, or any payment instrument. Payment details are entered in Stripe’s hosted portal
and never touch Saram.
- Your people’s passwords. WorkOS AuthKit owns sign-in, reset and verification; Saram stores a
subject, an address and a role.
- A Claude subscription token. No Saram component collects, stores, refreshes or forwards one,
because Anthropic’s terms forbid it and making it work would mean impersonating Claude
Code.
- A credential inline in a run spec. A credential arrives only as a reference, and the entry
object is closed, so a field the contract does not list is treated as an inline secret and every
reader rejects it.
- A credential in a Temporal payload or in workflow history. Resolution happens inside the
activity that makes the call. Payloads stay references.
- A credential in a log line, a span, an event or a checkpoint. The gate never prints a key or
a token;
POST /api/v1/credentials takes the value as a write-only field that pydantic masks in
every repr and every dump; the ChatGPT connect path’s tests grep every response body, log line
and span for the token set.
- Your tenant id in the artifact. The artifact is what your coding agent reads and is meant to
be portable, so it carries no
tenant_id, no caller and no created_by.
- Another tenant’s anything, from your credential, or yours from theirs. Every read of a run,
its sessions, steps, artifact, issues, verdicts, opportunities and evidence takes the principal’s
tenant as a required argument, so a route that forgot it does not type-check.
/mcp is scoped
identically. Behind that filter, row-level security in Postgres refuses another tenant’s rows on
every table that holds them, the tenant’s own record and everything under a run included, and a
person’s own record to anyone but that person.
- A house-key fallback. A model credential that will not resolve fails the activity with the
reason; the worker’s own key is never reached for, because billing the house key for your run
invisibly is the thing the
providers block exists to prevent.
Email noxalgia@gmail.com with a subject beginning saram security. Include what you did, what
you saw, the URL or the run id, and the time. A proof of concept is welcome; a request that damages
or exfiltrates somebody else’s data is not, and stopping at the first evidence that a boundary is
crossable is enough.
- Do not open a GitHub issue. Issues are the visibility layer between the two
fleets and a report should not travel that way before it is
fixed.
- There is no rota and no bug bounty. Saram is two owners; expect a human answer rather than an
automated acknowledgement, and no fixed response time.
- If the finding is in a dependency or a vendor (WorkOS, Stripe, AWS, Playwright), say so and
report it there as well.