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
| Property | Enforcement |
|---|---|
| Tenant isolation in the 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 conflicts surface, never silently overwrite | Version-vector comparison gates the merge |
| Replay 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
| 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
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:
- 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. - Build-time check. Tools like
esbuild-plugin-resolve-extensionsornext-config-with-validationcan fail builds that bundle server-only imports into client chunks. - The query subpath is server-only.
@pleach/core/querybypasses 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:
- Schema-validate every input.
defineToolenforces this via Zod; the validation runs beforeexecutefires. - Honor
ToolContext.signal. Tools that ignore it keep running after the user aborts — a DoS vector if the tool spawns expensive work. - Use the per-tool interrupt pattern for destructive
actions. Configure
InterruptConfig.perToolApprovalto require explicit approval for tools that write or delete. - 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/gatewayreads 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.Xonce 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
-
userIdandorganizationIdalways passed; never defaulted - Schema bundle applied; RLS extended for membership if needed
-
PIIRedactorwired (via@pleach/compliance) for regulated deployments -
TamperEvidencewired for evidence-required deployments - Plugin set audited; versions pinned
- Tool inputs Zod-validated (automatic; verify no
as anyescapes) -
InterruptConfig.perToolApprovalconfigured 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 behindNODE_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
Scrubberinstances before persistence.@pleach/complianceships four (SSN-US,Luhn,US-DL,KeyedRegex); hosts add more viacontributeScrubbers. The CI gateaudit:c8-event-type-allowlist-coverageenforces coverage. See Scrubbers, Compliance. -
Tamper-evident hash chain —
harness_event_logcarriesprev_hashandrow_hashcolumns 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 construction —
runtime.tenantstampstenant_idon every event log row, every audit ledger row, every OTel span, and (viawithTenantHeader) 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 log —
AUDIT_RECORD_VERSION_HISTORYexposes 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_idattribute — set automatically on every emitted span whenruntime.tenantis 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
Multi-tenant
Tenant-isolation patterns and the production checklist.
Schema
The RLS template and how to extend it.
Audit ledger
The compliance plug-points (`TamperEvidence`, `PIIRedactor`, `GDPRSoftDelete`).
Env vars
What the runtime reads from the environment.
Scrubbers
Write-time PII gate with four shipped detectors and host extension.
Hash chain
Tamper-evident chaining over `harness_event_log` rows.