# Security (/docs/security)



The runtime takes a strong default-deny posture: [storage](/docs/storage) adapters
are RLS-aware, the [audit ledger](/docs/audit-ledger) is append-only, the [fingerprint](/docs/fingerprint)
isolates tenants, and the plugin contract structurally prevents a
plugin from rewriting the lattice or bypassing the synthesize
seam. This page documents what the substrate enforces and what
you're still on the hook for.

## What the substrate enforces [#what-the-substrate-enforces]

| Property                                                       | Enforcement                                                                                |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Tenant isolation in the [cache](/docs/cache)                   | `tenantId` is in the fingerprint key — no shared cache buckets across tenants              |
| Append-only audit                                              | `ProviderDecisionLedger` interface has no update / delete — adapters cannot offer mutation |
| RLS-bound storage (anon clients)                               | Schema bundle ships owner-scoped policies on every `harness_*` table                       |
| Plugin can't rewrite the lattice                               | `audit:graph-stages` CI gate — out-of-lattice edges fail build                             |
| Plugin can't bypass the synthesize seam                        | `SynthesizeSeamHolder` + `TurnSynthesizeCounter` runtime invariants                        |
| Plugin can't reach the modelfamily matrix                      | `lint:harness-boundary` CI gate                                                            |
| [Sync](/docs/sync) conflicts surface, never silently overwrite | Version-vector comparison gates the merge                                                  |
| Replay [determinism](/docs/determinism)                        | Sync-only observers, deterministic reducers, fingerprint quantization                      |

These are structural — getting them wrong fails CI or throws at
runtime. They're not posture; they're contracts.

## What you're on the hook for [#what-youre-on-the-hook-for]

| Concern                                    | Where it lives                                                            |
| ------------------------------------------ | ------------------------------------------------------------------------- |
| Auth (who the request is)                  | Your handler — `authToken` and `userId` come from there                   |
| Service-role key handling                  | Server-only code paths; never reach the browser bundle                    |
| RLS policy correctness for shared sessions | Extend the bundle's owner-scoped policies for membership                  |
| Tenant-scoped runtime construction         | `organizationId` passed on every `new SessionRuntime`                     |
| Tool execution sandboxing                  | Tools that touch the filesystem / shell / network are your responsibility |
| Plugin trust model                         | Plugins run in-process; vet what you install                              |
| Provider key handling                      | BYOK / gateway pattern keeps tenant credentials scoped                    |

## Auth: `authToken` and `userId` [#auth-authtoken-and-userid]

Two related but distinct fields on `SessionRuntimeConfig`:

* `userId` — the row owner. Drives RLS in storage and tenant
  attribution in the audit ledger.
* `authToken` — bearer token forwarded to outbound API calls
  (provider, gateway, sync endpoints). Substrate-internal — not
  exposed to plugin code.

A typical authed request resolves both before runtime
construction:

```typescript
const session = await auth.verifyRequest(req);
if (!session) return new Response("Unauthorized", { status: 401 });

const runtime = new SessionRuntime({
  storage,
  userId:         session.userId,
  organizationId: session.orgId,
  authToken:      session.bearerToken,
});
```

Never default `userId` to `"anonymous"` in production. The
runtime accepts it for tutorials and mock mode; it's a
tenant-attribution leak in any real deployment.

## Service-role keys [#service-role-keys]

The Supabase service-role key bypasses RLS. The substrate uses
it for server-side adapters; the rule is: &#x2A;*service-role clients
never appear in browser bundles.**

Three patterns to enforce the rule:

1. **Server-only subpaths.** Import service-role clients only in
   `app/api/*` (Next.js), `server/*` (Remix), or equivalent.
   Browser entry points reach the runtime through HTTP, not by
   importing it directly.
2. **Build-time check.** Tools like `esbuild-plugin-resolve-extensions`
   or `next-config-with-validation` can fail builds that bundle
   server-only imports into client chunks.
3. **The query subpath is server-only.** `@pleach/core/query`
   bypasses RLS by design. Importing it from a client component
   is a documented anti-pattern; CI lint should flag it.

## RLS extensions for shared sessions [#rls-extensions-for-shared-sessions]

The default policies are owner-scoped — `user_id = auth.uid()`.
For multi-user sessions within a tenant, extend with membership:

```sql
DROP POLICY harness_sessions_owner_select ON harness_sessions;
CREATE POLICY harness_sessions_tenant_select ON harness_sessions
  FOR SELECT
  USING (
    user_id = auth.uid()::text
    OR id IN (
      SELECT session_id FROM harness_session_members
      WHERE user_id = auth.uid()::text
    )
  );
```

Apply analogous policies on `harness_checkpoints` and
`harness_auditable_calls`. The audit table additionally needs a
tenant filter — leaking audit rows across tenants is a real
security event regardless of session membership.

<Callout type="warn">
  Per-**user** isolation on `harness_event_log` is **chat-ownership**,
  not per-event `actor_id`. A single chat's event log carries mixed
  `actor_id` (the user for message/tool rows, `system` / `NULL` for
  session/cost/error/interrupt rows, `subagent:{id}` for delegated
  work), so a `WHERE actor_id = <caller>` read filter would hide the
  non-user rows from the owner and break reconstruction, export, and
  cost projections. Scope reads by **who owns the chat**
  (`ai_chats.created_by = <caller>`), checked in the API route
  *before* the event-log query — never by `actor_id`. The user gets
  their chat's complete trail iff they own the chat.
</Callout>

## Tool sandboxing [#tool-sandboxing]

The substrate doesn't sandbox tool execution. A tool that reads
the filesystem, spawns a process, or makes outbound HTTP
requests runs with the same privileges as the host process.

Mitigations, in order of strength:

1. **Schema-validate every input.** `defineTool` enforces this
   via Zod; the validation runs before `execute` fires.
2. **Honor `ToolContext.signal`.** Tools that ignore it keep
   running after the user aborts — a DoS vector if the tool
   spawns expensive work.
3. **Use the per-tool interrupt pattern for destructive
   actions.** Configure `InterruptConfig.perToolApproval` to
   require explicit approval for tools that write or delete.
4. **Move untrusted code execution into [`@pleach/coding-agent`](/docs/coding-agent).**
   The sandboxed code-execution surface runs guest-supplied code
   in a Firecracker microVM (Vercel Sandbox or equivalent), not
   in the host process.

## Plugin trust model [#plugin-trust-model]

`HarnessPlugin` is an in-process extension contract. Plugins run
with the runtime's privileges; a malicious plugin can read every
prompt, intercept every stream chunk, and emit fabricated
events.

This is the same trust model as any in-process library.
Practical guidance:

* Only install plugins you'd be willing to install as ordinary
  npm dependencies.
* Pin versions in `package.json`; review changelogs on upgrade.
* For untrusted plugins, the substrate has no isolation
  primitive — those are out of scope. If your threat model
  requires it, run different agent instances in different
  processes / containers and don't share plugin sets.

## Provider key handling (BYOK) [#provider-key-handling-byok]

Two patterns documented in [Multi-tenant](/docs/multi-tenant):

* **BYOK via runtime construction** — tenant key threaded into
  the provider at runtime build. Simplest; works when one
  tenant = one provider.
* **Gateway plugin** — `@pleach/gateway` reads tenant context
  off the runtime and routes per its own credentials store. Use
  when tenants share a provider but have different rate limits
  or fallback chains.

Either way: provider keys never reach the browser bundle, never
appear in audit-ledger payloads (the ledger records `family`,
`modelId`, `transport` — never the credential), and never appear
in logs.

## Audit ledger as evidence [#audit-ledger-as-evidence]

The append-only contract means: a regulator can verify that a
ledger row hasn't been tampered with by inspecting the schema
and the RLS policies. The `@pleach/compliance` SKU layers a
[hash chain](/docs/hash-chain) on top — each row's hash chains to the previous,
so any single-row mutation breaks the chain and the verify
pass reports the broken index.

For SOC 2 / HIPAA / 21 CFR Part 11 deployments, this is the
foundation. Compliance isn't shipped automatically — you have to
enable the hash-chain plug-point — but the substrate doesn't
get in the way.

## Sync conflicts and confused-deputy [#sync-conflicts-and-confused-deputy]

The version-vector sync detects conflicts at write time. The
default merger picks `merged` when fields don't overlap and
`local` when they do. If your application has fields where
`local` is the wrong default (e.g. a server-side compliance
flag the client shouldn't be able to override), pass a custom
merger in `SyncCoordinatorConfig.merger` that explicitly picks
`remote` for those fields.

A misconfigured default merger is the closest the substrate
comes to a confused-deputy issue. The merger is a single
function; the audit value is high.

## Secrets in environment variables [#secrets-in-environment-variables]

The runtime reads env vars at startup; the [Env vars](/docs/env-vars)
page enumerates them. Two patterns to keep secrets out of
process memory longer than necessary:

* Read `process.env.X` once at runtime construction; pass the
  resolved value as config. Never keep the env var live for
  later reads.
* For per-request secrets (a BYOK key resolved from a session),
  build the runtime per-request as documented in
  [Multi-tenant](/docs/multi-tenant). The runtime is GC'd after
  the request; the key goes with it.

## Logging and PII [#logging-and-pii]

The substrate's default loggers write event types and ids, not
payloads. Plugins can log whatever they choose; vet them.

For PII redaction inside the audit ledger payload itself, install
`@pleach/compliance` and wire its `PIIRedactor` plug-point — see [scrubbers](/docs/scrubbers). The
no-op default ships pass-through; production deployments running
without the redaction wired are recording raw user input into the
ledger.

## Production checklist [#production-checklist]

* [ ] Service-role keys server-only; CI enforces no import in client bundles
* [ ] `userId` and `organizationId` always passed; never defaulted
* [ ] Schema bundle applied; RLS extended for membership if needed
* [ ] `PIIRedactor` wired (via `@pleach/compliance`) for regulated deployments
* [ ] `TamperEvidence` wired for evidence-required deployments
* [ ] Plugin set audited; versions pinned
* [ ] Tool inputs Zod-validated (automatic; verify no `as any` escapes)
* [ ] `InterruptConfig.perToolApproval` configured for destructive tools
* [ ] Sync merger reviewed for confused-deputy-safe defaults
* [ ] Mock mode flag (`HARNESS_MOCK_MODE`) cannot be set in production env
* [ ] DevTools (`useHarnessDevTools`) gated behind `NODE_ENV !== "production"`

## Security-shaped substrate features [#security-shaped-substrate-features]

The security posture isn't only what's documented in the auth /
RLS / secret-handling sections above. Several substrate features
ship security-relevant behavior by default, and a reader on this
page shouldn't have to discover them through cross-reference.

* **Scrubber gate at write time** — every event log row is gated
  through registered `Scrubber` instances before persistence.
  `@pleach/compliance` ships four (`SSN-US`, `Luhn`, `US-DL`,
  `KeyedRegex`); hosts add more via `contributeScrubbers`. The CI
  gate `audit:c8-event-type-allowlist-coverage` enforces coverage.
  See [Scrubbers](/docs/scrubbers), [Compliance](/docs/compliance).

* **Tamper-evident hash chain** — `harness_event_log` carries
  `prev_hash` and `row_hash` columns that catch silent backfills,
  row reorders, and row removals after the fact. Verification walks
  the chain from a known root. The schema ships today; writer-side
  stamping is in soak. See [Hash chain](/docs/hash-chain).

* **Multi-tenant scoping by construction** — `runtime.tenant`
  stamps `tenant_id` on every event log row, every audit ledger
  row, every OTel span, and (via `withTenantHeader`) every
  outbound HTTP request. RLS at the database layer enforces; the
  facet provides the value. Two CI gates
  (`audit:tenant-scoping`, `audit:harness-event-log-tenant-id-required`)
  flag write sites that bypass the facet. See
  [Tenant facet](/docs/tenant-facet).

* **Audit-record version log** — `AUDIT_RECORD_VERSION_HISTORY`
  exposes the additive-promotion contract over the five typed
  records. A consumer reading the version log knows which fields
  changed in which substrate release and can pin its alert
  thresholds accordingly. See [Typed records](/docs/typed-records).

* **OTel `pleach.tenant_id` attribute** — set automatically on
  every emitted span when `runtime.tenant` is configured. The
  load-bearing field for per-tenant trace queries and incident
  scoping. See [OTel observability](/docs/otel-observability).

Each of these features layers on top of the auth / RLS /
secret-handling story documented above — substrate-level features
don't replace consumer-side discipline. They reduce the surface
where that discipline can silently slip.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Multi-tenant" href="/docs/multi-tenant" description="Tenant-isolation patterns and the production checklist." />

  <Card title="Schema" href="/docs/schema" description="The RLS template and how to extend it." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The compliance plug-points (`TamperEvidence`, `PIIRedactor`, `GDPRSoftDelete`)." />

  <Card title="Env vars" href="/docs/env-vars" description="What the runtime reads from the environment." />

  <Card title="Scrubbers" href="/docs/scrubbers" description="Write-time PII gate with four shipped detectors and host extension." />

  <Card title="Hash chain" href="/docs/hash-chain" description="Tamper-evident chaining over `harness_event_log` rows." />
</Cards>
