Security posture

A summary for a security reviewer. Every claim below names the file that makes it true — read the file if you need more than the summary.

Abhed is pre-release (0.1.x), built by a one-person company. No claim here should be read as a certification; see "What is not in place" at the end.

Trust boundaries

Sandbox tiers. Abhed executes agent-issued commands inside a sandbox whose strength is explicit and self-reporting (internal/sandbox/sandbox.go):

TierMechanismNotes
noneRuns directly on the hostSuitable only for a trusted single-user local run
processProcess-level confinement (macOS sandbox-exec, Linux namespaces/seccomp)The deployed default — see deploy/config.json's sandbox.min_tier: "process"
containerOCI container: namespace isolation, shared kernel
vmmicroVM or gVisor userspace kernelStrongest tier implemented

Select (internal/sandbox/sandbox.go) picks the strongest backend available that meets the configured MinTier, and refuses to start if none qualifies, naming what it tried and how to fix it — it never silently downgrades (see the comment above Select and README.md's "The sandbox never silently downgrades"). This is enforced for the hosted console by internal/deploycheck/config_test.go, which fails the build if deploy/config.json ships sandbox.min_tier: "none" or empty.

Note: docs/architecture/03-security.md describes a more elaborate tier design (gVisor by default, a microVM per session) as the target architecture. That document is explicit that this is engineering judgment, not verified practice, and the tiers actually implemented in internal/sandbox/sandbox.go today are none / process / container / vm, verified by the escape tests in that package. Treat the design doc as direction, and this document and the code as what runs today.

Policy engine order. Every tool call is evaluated in a fixed order, documented at the top of internal/policy/policy.go:

Hooks → Deny rules → Ask rules → Permission mode → Allow rules → Callback

Deny rules are absolute. A matching deny rule blocks the call even in bypass mode — the most permissive mode Abhed has (internal/policy/policy.go, ModeBypass comment: "dangerous; refusable by org policy"). Rules are scoped per-command, not per-tool: allowing bash(npm test) never allows bash(rm -rf /). internal/deploycheck/config_test.go asserts the deployed deny list actually blocks SSH keys, cloud credentials, .env files, and the Abhed config itself, and that no allow rule pre-approves an interpreter or file-reading command that could be used to exfiltrate one of those files under the cover of an approved rule.

Extensions may only veto, never permit. internal/extension/extension.go states the rule directly: "An extension may VETO, never PERMIT." Hooks run first in the policy order specifically so they can veto before anything else executes (internal/extension/host.go), and an extension's refusal is enforced the same way a deny rule is — it can turn an allow into a deny, never the reverse. internal/extension/extension_test.go asserts this directly (an extension can force Decision: Deny).

Tool output is tagged untrusted at ingest. internal/store/schema.sql's events table carries a trust column (CHECK (trust IN ('trusted', 'untrusted'))) on every event, so provenance travels with the data rather than being inferred later. File contents, tool output, and MCP responses are tagged at the point they enter the system (README.md: "Everything untrusted is tagged at ingest"). Policy decisions are made on the action requested, never on the untrusted text that motivated it (docs/architecture/03-security.md §2, "L2").

Data flow — what leaves the deployment

Nothing, by default. The container Abhed runs in has no route to the host filesystem and, per deploy/run.sh's sandbox flags, the shell tool itself gets no network access (sandbox.allow_network: false in deploy/config.json; verified by TestProcessSandboxBlocksNetworkByDefault per docs/architecture/03-security.md §7).

web_search, when enabled, is the one narrow, structured exception: a Go tool in the server process makes the request, not the sandboxed shell, so a compromised session cannot turn it into an arbitrary outbound connection (internal/deploycheck/websearch_test.go's comment: "Web search reaches the internet through a Go tool in the server process instead... with no way to turn it into an arbitrary request"). It is enabled on the deployed hosted console (deploy/config.json: "web_search": {"enabled": true, "provider": "duckduckgo"}) and is a separate capability from shell networking — enabling one does not enable the other, and TestWebSearchSurvivesSandboxHardening (internal/deploycheck/websearch_test.go) fails the build if that separation breaks.

The model endpoint the operator configured. Prompts and context go to whatever model endpoint is set in model.providers — for the hosted console today that is a model running on the same machine (docs/access-policy.md: "On this deployment that is a model running on the same machine, so prompts do not leave it. That is a property of this deployment, not a promise about every deployment."). Abhed is model-agnostic by construction (docs/vision.md); a self-hosted operator who points it at a third-party API is sending data to that API, and that is their configuration choice, not Abhed's.

Storage

Postgres, with a schema in internal/store/schema.sql. Two properties the schema comment states directly: events are append-only (no UPDATE, no DELETE), and tenant isolation is enforced by row-level security, not only by query construction.

Append-only by trigger, not convention. abhed_events_immutable() and access_events_immutable() are triggers that raise an exception on any UPDATE or DELETE against events and access_events — the database itself refuses the operation, regardless of what the application code does or how it is compromised.

FORCE ROW LEVEL SECURITY, not just ENABLE. The schema comment explains why this specific word matters: a table's owner bypasses ordinary RLS, and the application role almost always owns the tables it created, so ENABLE alone leaves the isolation policy silently inert. This was found by TestRowLevelSecurityIsolatesTenants, which could read another tenant's rows until FORCE was added (README.md's evidence-discipline section tells the same story). sessions, events, checkpoints, access_grants, and access_events all carry ENABLE and FORCE together, with a current_setting('app.tenant_id', true) policy on each.

Two database roles, and the app refuses to run as the wrong one. deploy/run.sh provisions abhed_admin (cluster superuser, used only by the script to provision) and abhed_app (NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB, owns the application's tables). The reason is in the script's comment: "Postgres does not apply row-level security to a superuser, so a Abhed connected as one has every isolation policy in the schema and none of the isolation." After provisioning, run.sh checks rolsuper OR rolbypassrls on abhed_app and exits with an error rather than starting Abhed if that role is privileged.

Authentication

Four modes (docs/ops/enabling-auth.md): none (local dev only), local (username/password Abhed holds), proxy (identity from a trusted reverse proxy's headers), and oidc (verified token or browser sign-in against an IdP). local and oidc can run side by side.

Container hardening (deploy/run.sh)

Every flag is commented in the script with the specific threat it closes. Summary:

ControlFlagWhy
No host filesystem accessno bind mount; workspace is a named volume"no host path is mounted, so there is no host path to escape to"
Unprivileged user--user 10001:10001
No Linux capabilities--cap-drop ALL"An agent's shell has no business with CAP_NET_ADMIN or CAP_SYS_ADMIN"
No privilege escalation--security-opt no-new-privilegesBlocks setuid-binary escalation
Immutable image--read-onlyA compromise can't install a backdoor into the binary or persist outside the two writable volumes
Ephemeral scratch space--tmpfs /tmp:rw,noexec,nosuid,size=512mGone on restart, noexec so a dropped payload can't run
Resource caps--memory 2g --memory-swap 2g --pids-limit 512 --cpus 2"A runaway or hostile agent should exhaust its own limits, not the host's"
Loopback-only bind--publish 127.0.0.1:8080:8080The reverse proxy is the sole route in
Config mounted read-only at the managed path--volume $CONFIG:/etc/abhed/config.json:roLoading config at the managed path sets Managed, making bypass mode refusable and policy non-escalatable from inside the container
Skills mounted read-only--volume $SKILLS:/workspace/.abhed/skills:roSkills are instructions; the agent must not be able to rewrite its own operating rules

deploy/GO-LIVE.md's "What is protecting you" table restates this same set for the live abhed.zybuu.com deployment, plus the transport and browser layers (TLS/HSTS, CSP, frame-ancestors 'none') and auth/authorization (bypass refused even when signed in, rate limits on sign-in).

Telemetry

Off by default. internal/config/config.go's TelemetryConfig.Enabled is a bare bool with no default set, so it is false unless explicitly turned on — and deploy/config.json, the config actually deployed to abhed.zybuu.com, does not set a telemetry block at all, so the hosted console runs with it off.

When enabled, it goes to the operator's own collector, not to Zybuu. internal/telemetry/otlp.go's package comment: it "ships them over OTLP/HTTP to whatever collector the operator runs," posting to <Endpoint>/v1/traces where Endpoint is a value the operator sets — there is no built-in Zybuu endpoint. The event log stays the source of truth regardless; telemetry is described as "a tap on it," so a collector being down cannot slow or fail a session.

What is NOT in place

Stated plainly, per docs/access-policy.md's own convention ("What we owe you... stated plainly rather than buried"):