pleach
Operate

Security

Auth, RLS, secret handling, tenant isolation, and the surfaces that need attention before going to production.

The runtime takes a strong default-deny posture: storage adapters are RLS-aware, the audit ledger is append-only, the 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

PropertyEnforcement
Tenant isolation in the cachetenantId is in the fingerprint key — no shared cache buckets across tenants
Append-only auditProviderDecisionLedger 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 latticeaudit:graph-stages CI gate — out-of-lattice edges fail build
Plugin can't bypass the synthesize seamSynthesizeSeamHolder + TurnSynthesizeCounter runtime invariants
Plugin can't reach the modelfamily matrixlint:harness-boundary CI gate
Sync conflicts surface, never silently overwriteVersion-vector comparison gates the merge
Replay determinismSync-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

ConcernWhere it lives
Auth (who the request is)Your handler — authToken and userId come from there
Service-role key handlingServer-only code paths; never reach the browser bundle
RLS policy correctness for shared sessionsExtend the bundle's owner-scoped policies for membership
Tenant-scoped runtime constructionorganizationId passed on every new SessionRuntime
Tool execution sandboxingTools that touch the filesystem / shell / network are your responsibility
Plugin trust modelPlugins run in-process; vet what you install
Provider key handlingBYOK / gateway pattern keeps tenant credentials scoped

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:

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

The Supabase service-role key bypasses RLS. The substrate uses it for server-side adapters; the rule is: 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

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

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.

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.

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. 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

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)

Two patterns documented in 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

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 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

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

The runtime reads env vars at startup; the 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. The runtime is GC'd after the request; the key goes with it.

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. The no-op default ships pass-through; production deployments running without the redaction wired are recording raw user input into the ledger.

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

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, Compliance.

  • Tamper-evident hash chainharness_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.

  • Multi-tenant scoping by constructionruntime.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.

  • Audit-record version logAUDIT_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.

  • 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.

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

On this page