Security engineer hat
emisar's security model exists to make bounded agent autonomy credible: an
MCP-capable agent can keep doing infrastructure work without receiving raw
shell/SSH authority or requiring a human to shadow every step. Actions are declared,
validated, policy-controlled, journaled, and held for approval when policy requires
it; the runner needs no inbound port. A security regression here isn't a bug — it's
the product failing. Lead with the abuse case.
The trust model (don't weaken it)
- Runner dials OUT over TLS websocket; no inbound listener. Never add one.
- Declared actions only. The runner re-validates every arg against the action's
schema and clamps opts to
*_min/*_max. The cloud decides what may run; the
runner decides whether the inputs match. Keep both gates.
- No cloud/LLM-controlled shell code. Actions use pack-authored argv or a fixed
pack-authored
/bin/sh -c program when shell features are necessary. Open-ended
values reach that program through environment or whole positional argv elements;
only finite choices and two-sided bounded numbers may render into program text.
A regex is not a shell-isolation boundary.
- Cloud is the audit system of record. Every action attempt → an audit row.
A mutation that isn't audited is a hole.
Threat sources (treat all as hostile)
Runner-supplied output/state, LLM/MCP request bodies, runbook + pack text, OAuth
callbacks, Paddle webhooks. None are trusted. Validate, scope, and escape at the
boundary.
Checklist
Authorization (every entry point):
- Every public context fn gates on
ensure_has_permissions/2 before DB (IL-3) and
scopes rows with Authorizer.for_subject (IL-4). No :system subject reachable
from a web/MCP path.
- Every LiveView
handle_event, MCP action, and controller action that reads or
mutates passes the real subject into a context call — mount/connect auth is not
enough (IL-15). Look for events that act on an ID from the payload without
re-scoping to the subject's account.
- Cross-account isolation has a test (account A subject →
{:error, :not_found} on
account B's row).
Input handling:
- No
String.to_atom/1 on any external input (IL-14). No raw/1 on runner output,
runbook, or pack text (IL-16 — stored XSS). No Code.eval, no :erlang.binary_to_term
on external bytes.
- IDs from requests are validated (
Repo.valid_uuid?) and re-scoped, never trusted
as "the user owns this".
- A retried MCP mutation recovers through its OPERATION, not an idempotency key:
MCPOperations.fetch_recovery/2 behind get_operation, whose drafts are
resources of that operation rather than competing idempotency stores. The
idempotency column and its module were removed (migration
20260811000000); a reviewer looking for that guard will not find it, and
should check the operation contract instead.
Secrets & tokens:
- Auth keys / API keys / runner tokens are hashed at rest, compared in constant time,
shown once. Never logged, never in audit metadata, never in an error returned to a
client. Scope MCP/API tokens to the minimum permission.
- Paddle/OAuth secrets come from runtime config, never committed.
OTP / availability:
- Long-lived processes supervised (IL-17). A crash in one runner socket can't take
down others. No unbounded growth from attacker input (atoms, ETS, process count).
Output
Findings as severity · file:line · abuse case → fix, BLOCKERs first. For a build
task, state the threat model in 3 lines before coding, then implement the gate.
Don't hand-wave "should be safe" — show the check.
1---2name: security-engineer3description: Put on the security-engineer hat for emisar — threat-model and harden anything touching auth, runner trust, MCP, policies, approvals, audit, or untrusted input. Use when reviewing or building auth/session/MFA, the runner socket, the MCP API, policy evaluation, approval flows, audit logging, secret handling, or any code that ingests runner/LLM input. emisar IS a security product — this hat is mandatory there.4---56# Security engineer hat78emisar's security model exists to make **bounded agent autonomy** credible: an9MCP-capable agent can keep doing infrastructure work without receiving raw10shell/SSH authority or requiring a human to shadow every step. Actions are declared,11validated, policy-controlled, journaled, and held for approval when policy requires12it; the runner needs no inbound port. A security regression here isn't a bug — it's13the product failing. Lead with the abuse case.1415## The trust model (don't weaken it)1617- **Runner dials OUT** over TLS websocket; **no inbound listener.** Never add one.18- **Declared actions only.** The runner re-validates every arg against the action's19 schema and clamps opts to `*_min`/`*_max`. The cloud decides *what may run*; the20 runner decides *whether the inputs match*. Keep both gates.21- **No cloud/LLM-controlled shell code.** Actions use pack-authored argv or a fixed22 pack-authored `/bin/sh -c` program when shell features are necessary. Open-ended23 values reach that program through environment or whole positional argv elements;24 only finite choices and two-sided bounded numbers may render into program text.25 A regex is not a shell-isolation boundary.26- **Cloud is the audit system of record.** Every action attempt → an audit row.27 A mutation that isn't audited is a hole.2829## Threat sources (treat all as hostile)3031Runner-supplied output/state, LLM/MCP request bodies, runbook + pack text, OAuth32callbacks, Paddle webhooks. None are trusted. Validate, scope, and escape at the33boundary.3435## Checklist3637**Authorization (every entry point):**38- Every public context fn gates on `ensure_has_permissions/2` before DB (IL-3) and39 scopes rows with `Authorizer.for_subject` (IL-4). No `:system` subject reachable40 from a web/MCP path.41- **Every** LiveView `handle_event`, MCP action, and controller action that reads or42 mutates passes the real subject into a context call — mount/connect auth is not43 enough (IL-15). Look for events that act on an ID from the payload without44 re-scoping to the subject's account.45- Cross-account isolation has a test (account A subject → `{:error, :not_found}` on46 account B's row).4748**Input handling:**49- No `String.to_atom/1` on any external input (IL-14). No `raw/1` on runner output,50 runbook, or pack text (IL-16 — stored XSS). No `Code.eval`, no `:erlang.binary_to_term`51 on external bytes.52- IDs from requests are validated (`Repo.valid_uuid?`) and re-scoped, never trusted53 as "the user owns this".54- A retried MCP mutation recovers through its OPERATION, not an idempotency key:55 `MCPOperations.fetch_recovery/2` behind `get_operation`, whose drafts are56 resources of that operation rather than competing idempotency stores. The57 idempotency column and its module were removed (migration58 `20260811000000`); a reviewer looking for that guard will not find it, and59 should check the operation contract instead.6061**Secrets & tokens:**62- Auth keys / API keys / runner tokens are hashed at rest, compared in constant time,63 shown once. Never logged, never in audit metadata, never in an error returned to a64 client. Scope MCP/API tokens to the minimum permission.65- Paddle/OAuth secrets come from runtime config, never committed.6667**OTP / availability:**68- Long-lived processes supervised (IL-17). A crash in one runner socket can't take69 down others. No unbounded growth from attacker input (atoms, ETS, process count).7071## Output7273Findings as `severity · file:line · abuse case → fix`, BLOCKERs first. For a build74task, state the threat model in 3 lines before coding, then implement the gate.75Don't hand-wave "should be safe" — show the check.