What Intercis enforces, and what it cannot see
Intercis is a proxy that sits outside your agent process. Your agent's traffic to Anthropic or OpenAI runs through it, policy runs on the tool calls the model asks for, and a denied call is taken out of the response before your agent runtime can execute it.
Four files on this page you can fetch, and one of them runs against another. Everything else is us naming the file or migration that implements a control, in a repository we do not publish.
Four files, with no email and no call
-
Read
/evidence/event_hash_trigger.sql, the database code that writes the hash on every audit row. -
Run
/evidence/verify_event_chain.py, the script that reads those hashes back. -
Feed it
/evidence/sample-chain.json, five rows with one verdict rewritten after the fact. The commands are under data handling. -
Point your tooling at
/.well-known/security.txt, the RFC 9116 contact record.
The rows a vendor questionnaire opens with
Twelve rows, answered here rather than in a form.
- Annual third-party audit
- No. None done, none scheduled.
- Third-party penetration test
- No. Nothing has been tested by an outside party.
- Managed-device programme
- No.
- Written disaster recovery plan
- No. Backups exist as a platform feature; a tested plan does not.
- Cyber insurance
- No. None is carried today; it is an open item on our corporate-readiness list.
- Status page
- No. Nothing measures the product's own uptime or latency, so there is no figure to put on one. A co-located latency benchmark is an open roadmap item; until it runs we would rather publish nothing than a number we have not measured.
- Log forwarding to your SIEM
- No. No Splunk or Datadog app, no syslog forwarder, no webhook out. A CSV export exists. SIEM integration is a planned Enterprise-tier roadmap item with no date.
- Single sign-on and multi-factor for your team
- No. Dashboard sign-in is email and password only. SSO and SAML are planned Enterprise-tier roadmap items with no date.
- Paperwork your counsel will ask for
- No. Nothing is in force, and there is no non-disclosure agreement. A pilot agreement, a data-processing addendum and a subprocessor list exist as drafts awaiting attorney review. A pilot runs without one.
- Deleting your data
- No self-serve control. Deleting the tenant deletes its rows: every event row hangs off it by a cascading foreign key. There is no per-row delete, no button, and no written process for asking.
- Subprocessor list
- Yes. The table below, read from our dependency files.
- Multi-factor on our own consoles
- Not claimed here. A console setting can be switched off in a browser tab and this page would not know.
Architecture and data flow
Your runtime, us, and the provider. The drawing shows what a deny does on the way back, and the two branches that leave the proxy.
A governed call, everywhere on this page, means a tool call the model asked for that crosses the Anthropic or OpenAI API wire and that your own runtime would execute.
The drawing in words. Your runtime sends its request to our proxy, which forwards it on your own provider key. The response comes back carrying the tool_use block; on a deny that block has been deleted before your runtime sees it. Two branches hang off the proxy: the classifier, on our key, and the audit row.
Relayed traffic goes upstream on your own provider key, taken off the inbound request, held in memory for that request and never stored. The classifier call is the exception and runs on our key. The audit row is written by the proxy with a service-role credential.
Code: apps/proxy/main.py, apps/proxy/openai_routes.py,
apps/proxy/db.py. Install shape: apps/dashboard/lib/onboarding-snippets.js.
-
LimitationBeing in-path is a configuration setting, not a wall
A fully compromised process can point its base URL back at the provider and go straight past us. Pair Intercis with an egress rule that blocks direct calls to
api.anthropic.comandapi.openai.comfrom the network your agents run on. With that rule we can claim tamper resistance. Without it we cannot. -
LimitationWe are a new trust boundary, and we are hosted only
Your prompts and tool inputs pass through our proxy in plaintext. The classifier call carries the tool name and the full tool input to Anthropic or OpenAI under an Intercis account, so that request sits under our agreement and not yours. There is no self-hosted build you can buy today. We have rehearsed a self-hosted deployment of the proxy end to end in our own lab, and what is undecided is the licensing, not the mechanism.
What we see and what we do not
The boundary is the wire. A tool call that crosses it is judged and logged; a tool the provider runs on its own servers is logged and never judged.
Judged and logged
- Anthropic
POST /v1/messages, including the streaming form. - OpenAI
POST /v1/responsesandPOST /v1/chat/completions. - Execution tools: bash, shell, computer use, code execution, PowerShell.
- File-writing tools: write, edit, multiedit, apply_patch, notebook edit.
- Any
mcp__tool your own runtime executes. - Read-only tools, by the deny list only (no classifier, by design).
Not judged by us
- Provider-hosted tools:
mcp_call,mcp_list_tools,mcp_approval_request. - Provider-hosted search and execution: web search, file search, code interpreter, image generation.
- Anything your agent does without calling Anthropic or OpenAI.
- Traffic on a provider or a route shape we have not built support for yet.
Code: apps/proxy/main.py (the gated tool families),
apps/proxy/openai_routes.py (the hosted item types),
apps/proxy/hosted_observe.py (the observe row).
Tests: apps/proxy/tests/test_classifier_gate_union.py,
apps/proxy/tests/test_stream_drain_safety.py.
-
LimitationHosted tool calls are observed, not governed, and we judge three routes
A tool the provider runs on its own servers has already run by the time the response reaches us: it writes one observe row and is never judged or blocked. If your agents rely on hosted MCP servers, that part of their behaviour is outside our control. The three routes above are the ones we judge, so any other endpoint, and any provider we have not built for, is ungoverned today.
Enforcement controls
Two layers decide a governed call: one deterministic, one a model. Only the first fails closed.
The action is deleted, not reported
A deny does not attach a warning to the response. The model's tool_use block
is removed and a plain assistant text block takes its place, naming the policy that
matched, with stop_reason set to end_turn. Your runtime never
receives a tool call to execute.
Code: apps/proxy/main.py.
Test: apps/proxy/tests/test_enforcement_deletion.py.
A deterministic rule layer
111 deny rules across 17 threat categories, counted in the repository on 12 September 2026: destructive shell and database statements, file wiping, credential and network exfiltration, supply chain, privilege escalation, container and cloud escape, persistence, encoded execution, Windows and PowerShell, vault access, lateral movement, log tampering, resource exhaustion and reconnaissance. That 17 counts the section headings in the file. The prefixes on the policy names give eighteen.
The rules are regular expressions, so the same call gets the same verdict every time. Sixteen MITRE ATT&CK and OWASP annotations sit in the file as comments over groups of rules; a rule itself is a pattern and a policy name with no identifier field, so there is no per-rule mapping and no machine-readable export of one.
Two calls are stopped by structure before any rule reads them. A tool input longer than 65,536 bytes is denied: the scan stops, the classifier is not consulted, and that deny is deliberately not routed through the exclusion check, so an operator cannot suppress it. A malformed tool name is gated the same way. We would rather stop a call we could not read than pass it.
Code: apps/proxy/deny_list.py.
Tests: apps/proxy/tests/test_deny_list_truncation.py pins the rule count,
apps/proxy/tests/test_deny_list_scan_cap.py pins the cap,
apps/proxy/tests/test_deny_list_windows.py covers the Windows category.
An intent classifier on a rule miss, which fails open
When no rule decides the call, a small model reads the call's own content and answers
with one word, safe or destructive. Anything else is treated as no answer. It runs on
claude-haiku-4-5-20251001 and gpt-5.4-mini, and only about
tools that can act. The credential is ours, never the tenant's.
If it cannot answer, the call is allowed, or stopped on fail closed. The rule layer
underneath still matches, and it is patterns: while a fail-open classifier is down, a
destructive command rewritten to slip past them has nothing left to stop it. Either way
the row carries classifier_status and a typed
classifier_reason, so "allowed because the control was down" stays
distinguishable from "allowed because it was judged safe", and the failure paths we have
found emit an audit_invariant signal with the tenant, route, upstream status
and provider. A rolling window of the last 200 consultations, at 20 samples or more, pages us
above a 5% unavailable share and again on recovery below 2.5%; an operator reads it on
the token-gated operator route, not the public health page. The alarm dates from the
31 August 2026 outage in the change log.
Code: apps/proxy/classifier.py, apps/proxy/classifier_result.py,
apps/proxy/audit_signal.py, apps/proxy/classifier_health.py.
Migrations: infra/supabase/migrations/042_events_classifier_status.sql and 043 to 045.
Test: apps/proxy/tests/test_classifier_unavailable_share.py.
The injection scanner never blocks
Thirteen fixed patterns in four families: instruction override, prompt extraction, role hijack and delimiter escape. A hit writes one row with the verdict observe, marks the request as injected, and keeps the first 200 characters of the matching message. Blocking on injection is not implemented today.
Code: apps/proxy/injection.py (the patterns),
apps/proxy/main.py (the snippet).
Exclusions cannot be permanent or blanket
An exclusion releases a rule that is wrong for your work. It must carry an end date later than the day it was created, so a hole cannot be left open by accident. Structural safety denies cannot be excluded at all, and the classifier and tool-scope rules refuse a policy-wide waiver: an exclusion there has to name a command fingerprint, a target substring or a path scope. The store fails closed: unreachable and uncached means the rules stand.
Code: apps/proxy/exclusions.py.
Migrations: infra/supabase/migrations/028_policy_exclusions.sql to 031.
Streaming is buffered before it is judged
The proxy collects the whole stream, reassembles the tool calls from their argument fragments, judges them, and only then replays it. Nothing reaches your agent until the upstream stream finishes, which is latency we add. A stream cut mid-argument is judged as the fragment it is, never as an empty call, and a structural collector failure is a refusal, never a silent allow.
Code: apps/proxy/stream.py, apps/proxy/openai_stream.py.
Test: apps/proxy/tests/test_stream_drain_safety.py.
Observe first, enforce when you decide
A tenant runs in observe or in enforce. In observe the same verdict is computed and the
same row is written, recorded as
observe instead of
deny, and nothing is blocked. A pilot starts
there. Three verdicts decide a call:
allow,
deny,
observe. The database constraint carries a
fourth, pending, left from an earlier write path and not written by the
proxy today. Observe mode is also where this product lost data once, dated in the
change log.
Code: apps/proxy/tenants.py, apps/dashboard/lib/policy-rollup.js.
Migrations: infra/supabase/migrations/001_tenants.sql,
infra/supabase/migrations/020_events_verdict_observe.sql.
What we can and cannot say about detection
The rule layer catches 40 of the 47 payloads in our own agentic-threat corpus, 85.1%, measured 12 September 2026 by running the benchmark. Five of the seven misses are one destructive command rewritten to slip past a rule. We wrote that corpus, so the number measures regression stability and not an attacker, and it holds no safe payloads, so it says nothing about how often we would stop work that was fine. Our false-positive rate on that traffic is 15.47%, 737 of 4,764 governed calls, reviewed by bucket on 13 September 2026.
Code: scripts/detection_bench.py.
Test: apps/proxy/tests/test_detection_bench.py.
Corpus: apps/proxy/tests/test_agentic_threats.py.
Data handling
One row per governed call, the tool input beside it, and the conversation in neither.
What a row holds, and what it does not
The time, the tenant, the agent, the tool name, a short excerpt of the target, the verdict, the policy that matched, whether the injection scanner fired, the wire route, a command fingerprint, cloud context, the Claude Code session and parent-agent ids, token usage and model where the provider reports them, the classifier status and reason, and the two hash columns. Beside the row, the structured tool input.
Not stored: the conversation, the system prompt, model temperature, agent memory state, and your provider key. The one exception is the injection scanner's 200-character snippet. There is no session replay feature.
Recognisable credential material is masked in both the tool input and the target excerpt, recursively through every string, with the key, header or URL structure left intact so an analyst still sees that a credential was passed and where. Masking runs after the enforcement decision and after the command fingerprint, so it cannot change a verdict. If masking throws, we persist the event unmasked and log the bug rather than drop the record.
Code: apps/proxy/db.py, apps/proxy/redact.py.
Migrations: infra/supabase/migrations/000_events_base.sql onward,
infra/supabase/migrations/032_events_claude_code_attribution.sql.
Test: apps/proxy/tests/test_redact.py.
Append-only by grant, and hash-chained per tenant
The events table carries only insert and select policies; update, delete and truncate
are revoked from client roles, so no user or compromised agent holds a grant to alter a
logged verdict. Our service-role credential bypasses row-level security by design.
A database trigger (migrations 052 and 055) refuses any UPDATE to a row
except the 90-day tool-input scrub; it does not cover DELETE or stop a
role able to drop, replace or switch it off.
Each row also carries a SHA-256 hash computed by a database trigger before the insert, over its own audit fields plus the previous row's hash, forming one chain per tenant. From 2026-09-20 it covers every column but the tool input; earlier rows are hashed over ten named fields and are never re-hashed. Same-tenant inserts are serialised so the chain cannot fork under load. That makes the log tamper-evident, not tamper-proof: somebody holding our service-role credential could rewrite a tail and recompute the hashes, and no anchor outside our database would catch it. The tool input sits outside the chain because the 90-day scrub would otherwise break every chain.
The chain catches an edit or deletion that left the later hashes alone. It cannot show a row exists for every governed call: a row that never reached the database leaves a chain that verifies, and the ways one fails to arrive are below. Completeness is a different property and we have no control for it.
The formula, as the trigger runs it and the verifier repeats it:
event_hash = sha256_hex(
enc(prev_event_hash)
+ enc('2') format marker
+ enc(id) + enc(tenant_id) + enc(agent_id)
+ enc(action) + enc(target) + enc(verdict) + enc(policy)
+ enc(injected) true is 't', false is 'f'
+ enc(source)
+ enc(created_at) UTC, YYYY-MM-DDTHH:MM:SS.ffffffZ
+ enc(cloud_provider) + enc(region) + enc(workload_type)
+ enc(cloud_account_id) + enc(cluster_name)
+ enc(command_fingerprint)
+ enc(cc_session_id) + enc(cc_agent_id) + enc(cc_parent_agent_id)
+ enc(classifier_status) + enc(classifier_reason)
+ enc(usage_input_tokens) + enc(usage_output_tokens) + enc(model)
)
enc(NULL) = "N"
enc(s) = length(s) + ":" + s length in characters
first row for a tenant: prev_event_hash = sha256_hex(tenant_id)
Each field is length-prefixed, so no edit can move a character across a field boundary and leave the hash unchanged.
Both sides are published.
/evidence/event_hash_trigger.sql
holds the functions and the trigger, copied out of migrations 040 and 054.
/evidence/verify_event_chain.py
reads it back: the file we run, 585 lines in the repository and 591 as
published, standard library only, no network calls.
Nothing in the product emits an export for it, so we published an input.
/evidence/sample-chain.json is five
rows for one made-up tenant, in the earlier format. Row three was a deny on
terraform destroy -auto-approve.
Its verdict now reads allow, the
policy name still beside it, and the stored hash is the one
that row had before the edit.
curl -O https://intercis.io/evidence/verify_event_chain.py
curl -O https://intercis.io/evidence/sample-chain.json
python3 verify_event_chain.py sample-chain.json
tenant 6f5c1a9e-3d24-4a7b-9f01-2c8e7b4d5a60: FIRST BREAK at event id 2d8f3e04-6b52-4c17-90a3-8e1d5b7c4f26 — event_hash mismatch under format v1: stored 6befbb993a0a61b2…, recomputed 712dc0ec4922331a… (row fields altered) (5 events checked; 2 v1, 0 v2 verified before the break)
Exit code 1, and four more lines, quoted whole on
the demo page. Put that one word back to deny, run it
again, and the same script prints
ok (5 events, chain verified from genesis; 5 v1, 0 v2) and exits 0.
Nothing in the run reaches our database. Getting the log out is
below.
Migrations: infra/supabase/migrations/007_rls_policies.sql,
infra/supabase/migrations/026_revoke_truncate_client_roles.sql,
infra/supabase/migrations/040_event_hash_chain.sql,
infra/supabase/migrations/054_events_chain_v2.sql.
Verifier: scripts/verify_event_chain.py.
Test: apps/proxy/tests/test_event_chain_verify.py.
Retention, deletion, and the one column that is emptied
No job deletes an event row. The audit row is kept for the life of the tenant account. The tool input is the exception: a scheduled database job runs nightly at 03:41 UTC and sets that one column to null on rows older than 90 days, up to 10,000 rows a night. Whether it has yet had a row old enough to scrub is a question about our own age and not our configuration.
Deletion is the account: every event row hangs off the tenant by a cascading foreign key, so removing the account removes its rows. There is no per-row delete and no self-serve button. The 90-day window is fixed for everyone, and tenant-configurable retention does not exist today.
Migrations: infra/supabase/migrations/035_events_raw_request_retention.sql,
infra/supabase/migrations/002_events_tenant_id.sql.
An audit write never blocks your agent
A failed write is logged and never propagated, because a database outage on our side must not break your pipeline. An allow or observe row the database rejects is dropped, not queued. A deny row goes to a 5 MB spool file on the proxy's own disk, oldest line evicted first, and drains back 100 rows at a time once writes succeed; one the database permanently rejects goes to a dead-letter file beside the spool.
The spool write can itself fail, and when it does that deny row is lost. Four named
signals cover the gaps, among them proxy_event_spooled and
proxy_event_dead_lettered. Blocking is enforced in line and never waits on
the audit write.
Code: apps/proxy/db.py, apps/proxy/config.py.
Test: apps/proxy/tests/test_event_spool.py.
Getting the log out
The dashboard queries by agent, verdict, cloud provider and time window, and exports the filtered view as CSV. One row, in the shape it is stored:
created_at 2026-09-12T14:22:07Z
agent_id build-agent-3
action bash
target terraform destroy -auto-approve
verdict deny
policy infra-terraform-destroy
classifier_status not_run
injected false
source anthropic-messages
event_hash 9f2c... sha256, by the formula printed above
prev_event_hash 4a71...
Those values are made up. No customer row appears on this page.
There is no SIEM integration: no Splunk or Datadog app, no syslog forwarder, no webhook out. The CSV is eight columns and carries neither hash column, and there is no self-serve export that does, so your own rows cannot be put through the verifier today.
Code: apps/dashboard/components/ExportButton.jsx,
apps/dashboard/app/api/events/route.js.
Tenant isolation
The database scopes everything a browser or a client key can reach. One credential, ours, sits outside that and is scoped in code instead.
Row-level security, on from the first migration
Row-level security is enabled on the events table from the baseline migration with no policies at all, which fails closed, and a tenant-scoped select policy is granted later. Counted across the fifty-one migrations on 13 September 2026, eighteen tables carry it: nine hold customer data and the other nine are our own operational tables. The public anonymous role can only insert into the signup table.
Two permissive read policies once defeated that scoping in production, one on the signup
table and one on the events table itself. Postgres evaluates permissive policies with OR,
so a policy reading using (true) beat the correct tenant-scoped policy
sitting beside it, and signup was open self-serve. Both are written up with dates and
mechanisms in the change log. Nothing in code stops the next one:
there is no test that fails when a permissive policy is added to a tenant-scoped table.
Code review is the only control on this, and code review is what found both of them.
Migrations: infra/supabase/migrations/007_rls_policies.sql,
infra/supabase/migrations/022_design_partners_read_leak.sql,
infra/supabase/migrations/025_events_read_leak.sql,
infra/supabase/migrations/027_revoke_unused_client_dml.sql.
The path that bypasses it, named
The proxy's own service-role credential bypasses row-level security by design, because it writes rows for every tenant. Scoping on that path is enforced in the proxy before the query: the tenant is resolved from the API key on every request. Privileged administrative mutations are audit-first, so the audit row is inserted before the mutation and a failed audit write aborts the mutation instead of letting it proceed unlogged.
In the dashboard, counted on 12 September 2026, 20 of the 21 API route files reference the tenant id, and the twenty-first resolves the caller's own tenant. Authentication runs ahead of the routes in the middleware, and cross-tenant isolation has its own end-to-end test.
Code: apps/proxy/tenants.py, apps/proxy/audit_guard.py,
apps/dashboard/middleware.js, apps/dashboard/app/api/.
Test: apps/dashboard/__tests__/e2e/cross-tenant-isolation.test.js.
Authentication and keys
One key authenticates a governed request, checked before any policy runs.
Proxy keys
A key is ik_live_ followed by 64 hex characters, 32 bytes from the operating
system's cryptographic random source. We store an HMAC-SHA256 of it under a server-side
secret, not the key and not a bare hash, and compare in constant time. You see the
plaintext once; logs and the dashboard show an eight-character prefix. Revocation takes
effect on the next request, because keys are deliberately not cached.
A key can be bound to one registered agent. Bound, possession of the key is the identity and a request naming a different agent is refused with a 403 and its own event row. Unbound, which is the older default, any holder of the key can claim any registered agent of the same tenant. There is no self-serve control for binding in the dashboard today.
Code: apps/proxy/tenants.py, apps/proxy/agents.py.
Migration: infra/supabase/migrations/048_tenant_api_keys_bound_agent.sql.
Test: apps/proxy/tests/test_agent_identity_binding.py.
What a failed request gets back
Every request on the governed routes must carry the x-intercis-key header,
validated before any policy runs. A missing or wrong key gets the same opaque 401 either
way. A valid key whose subscription is inactive gets a 402, a deliberate distinction, so
a paying customer sees a billing problem and not a credential one.
One optional endpoint is off by default and authenticated by a webhook signature instead.
It has a hole worth stating: before a signing secret is set it accepts unsigned calls so
the connection can be bootstrapped, returning no-secret-configured. Once a
secret exists, every unsigned or stale-timestamped call is refused. Nothing stops the
endpoint being switched on before the secret is set.
Code: apps/proxy/main.py, apps/proxy/inference_hooks.py,
apps/proxy/config.py.
Test: apps/proxy/tests/test_inference_hooks.py.
Rate limits and dashboard sign-in
300 requests a minute per tenant by default, an optional per-agent limit that inherits the tenant's when unset, and 10 failed authentication attempts a minute per calling address. The limiter is a sliding window held in memory on the proxy instance that serves the request. Each instance counts on its own, and there is no shared store behind it.
The dashboard uses cookie sessions through Supabase Auth, refreshed by the middleware on each request. Every API route that serves a user verifies the session and resolves the tenant, and Origin checks cover the mutating routes a browser can reach; the one exception to both is the Stripe webhook, authenticated by Stripe's own signature. Owner and member roles are enforced by the database policies, and the signup and password-reset forms ask for 12 characters in the browser. There is no single sign-on and no multi-factor today.
Code: apps/proxy/rate_limit.py, apps/proxy/client_ip.py,
apps/dashboard/lib/auth-middleware.js, apps/dashboard/lib/csrf.js.
The 300 is a column default in infra/supabase/migrations/001_tenants.sql.
Test: apps/proxy/tests/test_agent_rate_limit.py.
Infrastructure and subprocessors
Three vendors run the product. The other four each see one part of it, named beside them.
| Vendor | Runs | Sees |
|---|---|---|
| Supabase | Postgres, authentication, row-level security | Audit rows, tenants, agents, keys as HMAC values, dashboard accounts |
| Railway | The proxy, as a container image | All governed traffic in transit, in memory |
| Vercel | This site, the dashboard, their analytics | Dashboard sessions and requests to this site |
| Anthropic and OpenAI | The model your agent calls, and our classifier | Relayed traffic on your key. Classifier calls on our key, carrying the tool name and tool input |
| Sentry | Error and invariant signals from the proxy and the dashboard | Errors, and the audit-invariant tags named above |
| Stripe | Subscription billing | Billing identifiers and the subscription state |
| hCaptcha | A captcha widget on the dashboard signup form | The browser and network address of a person signing up, where the widget loads |
The captcha is in the table as a dependency, not as something that runs: it loads only where a site key is set, and the dashboard's content-security policy does not list its domain.
Read from: apps/proxy/requirements.txt, apps/dashboard/package.json,
apps/proxy/railway.json, apps/site/vercel.json,
apps/dashboard/vercel.json.
Platform properties, which are theirs and not ours
Encryption at rest, TLS termination and its minimum version, certificate issuance and container isolation are Supabase, Railway and Vercel defaults. We configure none of them and we do not audit them. No region is pinned in any configuration file in our repository.
Response headers we do set
| Header | Value | Where |
|---|---|---|
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Site, dashboard, proxy |
| X-Frame-Options | DENY | Site, dashboard, proxy |
| X-Content-Type-Options | nosniff | Site, dashboard, proxy |
| Referrer-Policy | strict-origin-when-cross-origin | Site, dashboard, proxy |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Site, dashboard, proxy |
| Cache-Control | no-store | Proxy |
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' and our Supabase project origin; img-src 'self' data:; frame-ancestors 'none'; | This site |
The proxy is a JSON API and sets no content-security policy. The dashboard's is broader than
this site's because it runs its own scripts: it allows
'unsafe-eval' as well as 'unsafe-inline'. We list the headers and
we do not grade them.
This page loads one script: Vercel Web Analytics at
/_vercel/insights/script.js, our own domain. It counts page views and sets no
cookie. An inline script keeps your theme choice in local storage and sends it
nowhere. Otherwise one stylesheet and our fonts. The policy permits any same-origin
script, so what is absent is our choice.
Config: apps/site/vercel.json, apps/dashboard/vercel.json,
apps/proxy/main.py, apps/proxy/railway.json.
How we secure Intercis
Everything above is a control on your traffic. This is what sits around the company that holds the keys.
Where the credentials live
The database service-role credential, the secret the key hashes are computed under and both classifier provider keys are environment variables on the platforms that run the code. None is in the repository. Commits are scanned before they land and the full history is scanned again in the build, which carries a positive control: it plants a well-formed key and fails if the scanner misses it. The local scan is skipped where the scanner is not installed, which is a real gap; the build does not skip.
Config: .githooks/pre-commit, .gitleaks.toml,
.github/workflows/deploy.yml.
Runbook: docs/runbook-security-2026-07-27.md.
One person, and what that costs
Intercis is one person. The Supabase, Railway, Vercel, Sentry, Stripe and provider consoles are that person's accounts. There is no shared administrator login and no second operator. Database backups are a Supabase platform feature; we have not run a restore drill and there is no restore runbook, which is why the questionnaire answers no on a disaster recovery plan. The deny spool is not backed up and is not meant to be: it drains into the database or it is lost.
Enforcement does not need a person. The proxy keeps running, rules keep matching, rows keep being written and the spool keeps draining. What stops is every change to them: a new rule, an exclusion, a key binding, an answer to an incident. If that person is unavailable for two weeks, enforcement stands still instead of failing open, and nobody else can move it. That is a single point of failure. A first engineering hire is on our roadmap, gated on revenue rather than on intent.
Certification status
Intercis is not SOC 2 certified. No audit is underway and no auditor is engaged. There is no ISO 27001 certificate, no penetration test report, and no export that produces evidence for either. A SOC 2 and ISO 27001 evidence-package export is a planned Enterprise-tier item on our roadmap with no date attached; what exists today is the audit trail above, which a reviewer can query and verify.
There is no audit budget and no compliance team behind this page, and we do not publish the deny-rule set.
Responsible disclosure
If you find a way past any control on this page, write to
security@intercis.io. The same address is in
/.well-known/security.txt, in the
RFC 9116 shape your tooling expects. A request or response shape is more useful than a
description.
A bypass of our own enforcement is the highest-severity class of defect we have and goes to the front of the queue. There is no bug bounty, no reward and no SLA on that address: one person reads it. Please do not test against another tenant's data.
Change log
Last reviewed: September 2026. We added a deny rule, so the rule set went from 110 to 111 and this page followed. The review also corrected the row-level-security table count from eighteen to seventeen, wrote up both production tenant-boundary bugs, published the four files at the top of the page, and cut every offer that had no process behind it.
-
31 August 2026: the classifier was down for four hours
The intent classifier was unavailable for nearly four hours and the rule layer alone decided 442 governed calls. The 442 counts audit rows carrying the classifier reason for that window, 25.6% of that day's governed traffic. That traffic was our own tenant, not a customer's. The deny rules still matched and blocked, so the floor held, but the second opinion was gone and we were slow to see it. We cannot recover the upstream status for those calls: it was in a log line on a Railway deployment since removed. Status and provider now ride on the row and the signal, and the rolling unavailable-share alarm on the operator route exists because of that day.
-
29 July 2026: any signed-in user could read every tenant's event rows
A permissive select policy reading
using (true)sat alongside the correct tenant-scoped policy on the events table, and Postgres evaluates permissive policies with OR, so the wrong one won. Signup was open self-serve, so anyone who registered an account could read agent ids, exact commands, targets and policy verdicts belonging to every tenant. For a governance product that is the customer's infrastructure activity. The client roles also held aTRUNCATEgrant, which row-level security does not filter, so the audit log was deletable by a stranger until this closed.Found by our own security-review step and confirmed by query against production. It was pre-existing. Migration 025 dropped the policy and revoked the grants. The review two days earlier had fixed the same bug on another table without noticing this copy.
-
27 July 2026: observe mode lost every row it ever wrote
Observe mode wrote a verdict the database would not take, and we lost every row of it. The constraint admitted
allow,denyandpending. The proxy wroteobserve. The audit writer swallows every exception by design, so that a database problem on our side cannot break a customer's pipeline. The three together meant every observe-mode event this product had ever produced in production was rejected by Postgres and dropped, and the counts at that moment were allow 202, deny 71, pending 1, observe 0. No backfill was possible. Observe is the mode a pilot starts in, so the hole sat where a new customer looks first.Migration 020 widened the constraint, and the spool work that followed in August split a transient failure from a permanent one. Still open: an allow or observe row the database rejects leaves nothing but a log line. It is the same shape of hole, narrower.
-
27 July 2026: the same policy sat on our signup table
The same permissive policy, byte for byte, sat on our design-partner signup table. Anyone who registered an account could read every signup: email, company, role and fleet size. Migration 022 dropped it. The review that wrote that migration recorded the fix and did not look for a second copy, which is how the events table above went another two days.