Every security control in RepoOps, and the code behind it
A full tour of the security architecture across the local app and the hosted dashboard. Redaction, write-trust, signed packs, the enforcement gate, the kill-switch, the credential broker, tenant isolation, and the limits we will not paper over. Every claim names the module that implements it.
The security page states the headline claims and links each one to a module. This post is the long version: what runs where, why it is built that way, what is on by default, what an operator has to arm, and the places where the honest answer is "we do not defend against that."
The shape is easy to state. The local app holds the raw material, so that is where the strongest controls live. The hosted dashboard holds redacted metadata from many machines, so that is where tenancy and identity carry the weight. Both halves fail closed on the paths that matter, and both refuse to fabricate a value they do not have.
Part one: the local app
The desktop app and the aggregator run on your machine. They read your repos, your Claude Code session records, your MCP inventory, and your agent telemetry. None of that leaves the machine unless you bind the install to a hosted team, and even then only after redaction.
Redaction is a chokepoint, not a filter you can skip
lib/redact.mjs is the one secret-and-PII pattern table in the product. Capture runs in one of two modes, and the default is the safe one. In metadata mode the prompt and assistant text are dropped entirely and only counts, timings, tools, tokens, cost, model, and session id survive. In content mode the text is kept but every payload goes through redactText first.
The write path enforces this at a single point. lib/events/writer.mjs appendEvent calls redactEventPayload(payload, classes) and records a fingerprint of what it removed, so there is no second writer that could forget. The class list comes from the per-tenant policy in lib/events/redaction-policy.mjs, and on any policy error the resolver degrades to the strict profile. Degrading to an empty class list would pass raw text through, which is the exact failure the fail-closed rule exists to prevent.
There is a read-side twin. lib/redact-edge.mjs scrubs the context RepoOps assembles for a coding agent before the agent reads it, so a live key sitting in a repo file never reaches the model prompt. Same engine, same policy resolution, same strict-profile degrade.
Every module that needs to recognize a secret reuses that table. The NHI inventory does. The write-time secret gate does. The canary generator shapes its honeytoken to match it. Nothing in the tree keeps a second, drifting list.
Catching a secret the moment an agent writes it
lib/secret-gate.mjs checks content an agent is about to write into a file, before it reaches git. On a match it records a blocked event and returns the redacted view. The recorded event never carries the raw secret: only the match types and counts, a canonical fingerprint of what was removed, and a preview already run through redactText. Events live in a bounded ring of 200 in the local SQLite store, so the log cannot grow without limit.
lib/security-guardrails.mjs covers the footguns that predate agents and got worse with them. Six checks run over a tracked repo's working tree: a .env-family file tracked by git, a key or private key in a tracked file, the same in an untracked-but-not-ignored file, a server bound to 0.0.0.0, wildcard CORS, and a .env present but missing from .gitignore. The detection half is pure regex over already-read content, so it is fast to test, and a non-git path returns an error result rather than throwing.
Agent identity: what the agent can see, and what it should hold instead
A coding agent runs with your long-lived API keys in its environment. Those keys are non-human identities, and they are the most valuable thing in the blast radius of a prompt injection.
lib/nhi/inventory.mjs is the read side. Given the env and config surfaces a tracked agent can see, it lists one identity row per discovered secret plus an over-exposure report: a twenty-minute run holding a token that does not expire for a year. The invariant is that no raw secret is stored, emitted, logged, or transmitted. A row carries a sha256 prefix as a non-reversible fingerprint, the pattern type, and metadata. Scopes and TTL are derived only where the token describes itself, from a JWT exp or a scope claim. Otherwise they are null, an honest unknown rather than a made-up number, and over-exposure is flagged only when the TTL is actually known.
lib/broker/credential-broker.mjs is the write side. Rather than the agent holding the real key, a local broker holds it and mints a short-lived, task-scoped token from it. Three invariants are proven by its test suite. The raw key never leaves the module: it is read from the injected key source, used to derive a token, and dropped, never returned and never stored, and a minted token records only a fingerprint of itself. No configured key means no token, an explicit error rather than a raw-key fallback. Every mint and revoke is attested with the install's ledger signer and verified by recomputing the digest from the authenticated fields, never by trusting a stored one, so a tampered exchange record fails verification.
The broker is not an HSM and the module header says so. A process running as you that can read the broker's own store can still reach the key material. What the broker buys is real but bounded: the raw key is out of the agent's environment and context window, and a token an injection exfiltrates is scoped to one task, expires, and can be revoked. A raw key is none of those things.
The enforcement gate
lib/enforcement-gate.mjs is the decision point for a single pending tool call. decide({ pendingCall, bundle }) returns allow, block, or hold. It is pure: no I/O, no network, no model call. It runs the coarse agent-policy rules plus the per-call risky-command and secret-exfiltration-pairing checks, then reads the policy bundle's per-detector mode to decide whether a firing detector blocks, warns, or stays quiet.
Placement was the design decision that mattered. The gate rides the tool's own synchronous hook boundary (scripts/cc-pretooluse-enforce.mjs on Claude Code PreToolUse), never the capture path, because a capture path that fails open cannot enforce anything. A missing or broken bundle yields allow. That is deliberate: a policy fault degrades to no enforcement, never to a blocked call, because a security control that bricks your editor gets uninstalled.
lib/mcp-enforce.mjs is the sibling seam for MCP tool calls, running the same gate over the same bundle. Both ship dormant. The hook is not registered in settings, and the MCP seam only consults the gate when REPOOPS_MCP_ENFORCE=1, so until an operator arms it the call path does one cheap env check and dispatches unchanged.
hold is the approval-gated verdict, raised by the lethal-trifecta gate and the consensus always-gate list. It is currently inert by construction: with no approver wired, a hold degrades to a flag with a reason rather than hard-blocking every action. We would rather ship a documented gap than a gate that silently stops working.
lib/policy-bundle.mjs is the versioned object all of this reads. It carries an org baseline plus team and repo overrides (repo wins over team wins over org), a per-detector mode, allowlists, a semver, a diff, and a rollback. Six detectors are actually fired by the single-call gate. Two more are known ids a bundle may name, and the bundle carries their mode without pretending to enforce them.
lib/enforcement-ramp.mjs decides when a detector is allowed to start blocking. A detector climbs advisory to warn to enforcing one rung at a time, and only when its measured false-positive rate is at or under the bound (5% by default) over a minimum sample count (20 by default). A noisy detector stays where it is. Nobody gets to promote a rule on a hunch.
When something goes wrong: forensics, then containment
lib/run-forensics.mjs reconstructs one run from the session record already on disk: an ordered timeline, a blast-radius rollup, and a step-through replay. No new capture, no model call, no network. The core functions are pure over an in-memory record, and the reader's loaders are injectable.
It is honest about its own resolution. Tool calls are stored aggregated by name with per-run totals, because the source has no per-call timestamp, so the timeline anchors those steps at the run start rather than inventing times for them. Security markers do carry real per-event timestamps, because the detectors recorded them.
lib/run-kill-switch.mjs is the containment half, and it is human-gated. A kill is three legs, run in order and reported one by one: revoke the run's broker token, signal the agent process, block the run's branch so a git hook refuses further commits. The run is marked contained only when all three legs complete. If the broker is not armed, that leg reports "broker not enabled, token not revoked" and the other two still run. A partial kill is reported as a partial kill. The blocked-branch list is durable in the local store, so it survives a daemon restart.
The sandbox and its egress allowlist
Brain-authored verbs run under Deno. lib/sandbox/capabilities.mjs maps logical capability names onto --allow-* flags, a capability with no mapping is dropped, and a request for --allow-all is rejected outright, since that vector is the whole reason the sandbox exists. Each entry in the table is the smallest scope that lets the named capability work.
lib/egress-policy.mjs sits over that runtime with a per-repo allowlist of pinned hosts. A pinned host becomes exactly one net:<host> capability and nothing else leaves. A policy that cannot be loaded is treated as the empty allowlist, which means every host the run reached is unlisted and the alert fires. There is no path that falls through to allow-all egress on a load error, and a test proves a throwing loader yields the restrictive verdict.
MCP is a supply chain, so we treat it like one
lib/mcp-security/allowlist.mjs lets you pin the MCP servers and tool sets you approve. The pin is the triple of source tool, server name, and config hash. The hash is part of the identity on purpose: a server whose definition drifts no longer matches its pin and gets flagged again, which is the rug-pull defense. Decisions append to a ledger, pins are idempotent, and blocking is opt-in on top of flagging.
lib/mcp-security/canary.mjs mints a honeytoken shaped like a real AWS access key id but with the literal string CANARY in the account segment, so it authenticates to nothing. The point of a canary is that it should never move. If it shows up in outbound content, that is proof of exfiltration rather than a heuristic, and the existing exfiltration-shape detector already flags the read-then-upload pair.
lib/mcp-security/redteam-runner.mjs points the tool-poisoning attack library at your own configuration and returns a fails-closed or fails-open verdict per attack, plus which of your real servers each technique would flag. It runs the shipped detector, not a re-implementation of it, and it never executes a server.
Memory poisoning, and the packs that carry it
The moment a brain is writable from outside, it is a target. Marketplace packs are ed25519 signed (lib/federation/signing.mjs), and lib/federation/pack-installer.mjs verifies the signature against the receiving brain's trust list. An untrusted signer or a tampered bundle is rejected with nothing written and no skip path. Even a verified pack lands as one inert proposed note through lib/marketplace/install-held.mjs: invisible, running nothing, until you approve it. Reject deletes it and leaves no trace.
A standing red-team suite (lib/security/redteam/redteam.test.mjs) runs in the required CI checks job and attacks these defenses on every change. Four adversarial fixtures, shaped the way a real campaign would shape them: a pack from an untrusted signer, a synced notes file carrying live secrets and an injection, an install proposal whose fields carry a prompt injection and a comment-closer, and a trusted signer's pack with mutated bytes. Each asserts the defense fails closed. A regression that opens a gate fails the build before it can merge.
The page also publishes what the suite does not test, because coverage without a stated boundary is a marketing claim. It does not cover the hosted TypeScript write-trust guard (that has its own suite), network-level attacks, the cryptographic soundness of ed25519 itself, or whether a human at the approval gate decides correctly. The suite proves the gate exists and fails closed. It cannot prove the person is paying attention.
Part two: the hosted dashboard
The hosted side at repoops.ai holds redacted metadata from many machines and many teams. Its job is to keep those tenants apart, keep credentials unrecoverable where they can be, and keep the ones that must be recoverable encrypted with a key the database provider does not hold.
Encryption at rest, above the provider's layer
website/lib/secret-box.ts encrypts secret fields with AES-256-GCM before the value reaches a column. The envelope is versioned and self-describing (enc.v1.<iv>.<ciphertext>.<authTag>), with a 12-byte random IV per encryption and the 16-byte GCM auth tag, so a flipped byte fails decryption rather than returning garbage.
The OAuth client secret is the case that forces this design: the authorization code flow replays it to the identity provider, so a one-way hash is not an option. Rather than trust provider-managed at-rest encryption alone, the value is encrypted with an app-held key and decrypted only in the process that needs it. The key is required in production, and encrypt throws when it is unset, so a misconfigured deploy fails closed instead of silently persisting plaintext.
Sessions, tokens, and revoking them
The session cookie is a stateless HMAC. That is fast and cheap, and it had a real gap: a stolen cookie stayed valid for its full seven days and could not be invalidated short of rotating the signing secret, which logs everyone out. website/lib/session-revocation.ts closes it with one integer version per actor. Minting stamps the current version into the payload, verification re-reads it and rejects anything lower. Bumping an actor's version revokes every outstanding cookie for that actor at once, and the next sign-in works normally. The only state is one row per actor who has ever been revoked.
The rollout was built to be dark-safe. Every read of the version degrades to zero on any error, which makes the check a no-op identical to the previous behavior and never locks a valid session out. Cookies minted before the change carry no version and keep verifying until natural expiry, so nobody was force-logged-out on deploy.
CLI tokens (website/lib/cli-token.ts) use the same HMAC format rather than pulling in a JWT library, since the same function mints and verifies them. The CLI does not verify signatures offline: the secret stays server-side, and a forged local token only fools the forger's own gate.
The external personal read token (website/lib/personal-read-token.ts) is the tightest one. It is stored only as a SHA-256 hash, so the raw value is returned once and never persisted. Its scope is an allowlist that fails narrow: a row is admitted only if it passes both the kinds allowlist and the path-prefix allowlist. Both dimensions are optional, but the underlying query is always additionally bounded to the owner and to active rows, so no scope shape exists that reads another user or a proposed row. It cannot escalate to a write, because no write path imports the module and the resolver returns read context only.
Write-trust on the personal brain
website/lib/personal-write-guard.ts is the hosted twin of the local write-trust rule. A write the owner makes from their own authenticated session or CLI token, not flagged external, lands active. A write declaring an external source, a web capture filed overnight, an agent ingest from outside the owner, lands proposed and stays invisible to every reader until the owner confirms it. Nothing can assert its own trust tier.
It reuses rather than reinvents: the same server-side redactor the team ingest runs, the same path-traversal-safe normalizer, and a sha256 content address for provenance. It does not re-root a Merkle tree per personal write, which would be the speculative version of this.
Tenancy
Cross-tenant reads are not a 403. They are a zero-row response. website/lib/scoped-query.ts wraps every cloud-side read against the events table and delegates the policy decision to the same scope contract that gates local brain requests. A cross-scope read silently returns nothing, so the boundary gives a hostile caller no error oracle to probe. Routes can inspect the reason for their own audit trail, but they never hand it back to an untrusted client.
Underneath, tenancy is a required argument rather than a convention. The audit events repository takes accountId as a required first parameter, resolved from the authenticated principal, and the request body or query string never supplies it. The redaction policy resolver is bounded to a single team id, and an empty team id short-circuits to defaults rather than running an unbounded read. There are dedicated integration suites for tenant isolation, org-scope isolation, and rollup-scope isolation.
website/lib/tenant-assertion.ts closes a confused-deputy hole. The hosted MCP holds a shared billing secret, and that secret alone once let its holder name any tenant. Now a request must carry an ed25519 signature over the specific account and user id that only the private-key-holding MCP could produce. The website verifies with the public key only, so leaking the website environment can never mint a read. No algorithm field is read from the token: it always verifies ed25519 against an ed25519 key, so there is no "alg":"none" or HMAC downgrade surface, and the subject match is constant-time. The rollout is staged. With no public key set the gate reports fallback mode and keeps the previous shape validation; setting the key switches enforcement on.
Roles, directories, and provisioning
The role ladder is read-only, member, admin, owner. website/lib/idp-role-mapping.ts adds a second source of truth: an enterprise identity provider's directory groups. The resolver takes the higher of the directory role and the membership role, so a group can raise a member's role but never lower it. It is pure, and it deliberately never writes membership. Applying a directory role is the security-sensitive write, and that decision stays with the operator.
SCIM (website/lib/scim/) is dormant by construction. Every route calls the gate first, and the gate denies cleanly on three grounds: no SAML or SCIM config returns 404, an absent or wrong bearer returns 401 after a constant-time compare, and a team below the entitlement tier returns 403. With no token configured, nothing reaches the membership model. Provisioning maps onto the existing users and memberships tables rather than building a second membership system, so deprovisioning removes the membership and access ends immediately. Every mutating operation writes a redacted audit row, and those rows surface in the manager-only SSO audit export.
Headers, rate limits, and previews
website/lib/security-headers.mjs applies HSTS with a two-year max-age, frame-ancestors 'none' against clickjacking, object-src 'none', and base-uri 'self'. Permissions-Policy denies camera and geolocation and allows the microphone for same-origin only, because the voice capture feature needs it.
The CSP is deliberately a starter, and there is a scar behind that word. On 2026-07-02 a default-src 'self' directive took the whole marketing site down. In CSP, omitting script-src and style-src does not leave them unrestricted: they fall back to default-src, which blocked every inline style attribute and every Next.js bootstrap script. Curl-based checks never see it, because only browsers enforce CSP. The three directives kept have no such fallback blast radius, and the module comment records why.
Ingest is rate limited by a per-account token bucket, 60 pushes a minute with a 1.5x burst, backed by a globally consistent daily quota. The limiter is in-process per serverless instance, which the module says plainly: an attacker can fan out across instances, and the daily counter is the real backstop for cost containment. Backup re-ingest is exempt, because a tenant cap must never throttle a backup.
One more small thing. A middleware adds X-Robots-Tag: noindex to every response on a non-production deploy. Vercel already does this for default preview hostnames, but not for a preview aliased to a real custom domain, which is the case that matters.
What we do not claim
Prompt injection is not solved at the model layer, and nothing here claims to solve it. These controls bound blast radius. A well-shaped injection can still get an agent to do something you did not ask for, and the honest goal is that the damage is small, visible, and reversible rather than silent.
The control layer ships off by default. Inline enforcement, the kill-switch, and the secret broker are all dormant until an operator arms them, because a tool that blocks a call behind your back is a tool people rip out. That means a fresh install is watching and proving, not enforcing, and the security page says so.
RepoOps is not SOC 2 certified. Type II for the hosted tier is in progress, we are scoping the observation window with an independent auditor, and we will publish a date when the auditor agrees to one rather than before. What ships today is evidence, not a certificate: lib/compliance/framework-map.mjs maps our signals to SOC 2, ISO 42001, NIST AI RMF, and EU AI Act controls, and lib/compliance/evidence-pack.mjs exports an audit-ready pack per repo backed by the attestation trail. That is evidence for your audit.
The last one is structural. Every claim above resolves to a module path you can open, and the red-team suite runs in CI on every change. That is the standard we hold ourselves to, and it is the standard we would like you to hold us to. If you find a gap, [security@repoops.ai](mailto:security@repoops.ai) and the disclosure policy are how to tell us.