> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nika.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Security model

> What protects a Nika workflow: SSRF defense on by default, a shell blocklist, taint-tracked trust levels, typed security errors, and a hardened Rust supply chain.

export const STATUS = {
  head: "95962d5cd",
  branch: "main",
  version: "0.91.0",
  cratesWorkspace: 39,
  cratesAdmitted: 39,
  cratesTarget: "42",
  wipCrates: [],
  libTests: 2989,
  clippyWarnings: 0,
  adrs: 62,
  adrsAccepted: 42,
  adrsProposed: 18,
  providers: 32,
  capabilityRules: 49,
  hygieneVectors: 38,
  hygieneGreen: 28,
  hygieneYellow: 3,
  hygieneRed: 0,
  lastUpdated: "2026-06-25"
};

Workflows run attacker-influenced inputs by nature: they fetch URLs,
shell out, and feed model output into tools. Nika's position is that the
**mechanism must be safe before policy makes it configurable**: the
dangerous primitives ship hardened by default, and a violation is a
typed `NIKA-SEC-*` error your workflow can read, not a stack trace.

## The threat model in one table

| You write            | The attacker controls                       | The defense                                   |
| -------------------- | ------------------------------------------- | --------------------------------------------- |
| `invoke: nika:fetch` | the URL (or a page that redirects)          | 3-layer SSRF defense, on by default           |
| `exec:`              | argument values flowing from upstream tasks | shell blocklist + data-channel pattern        |
| `infer:` → tool use  | the fetched content the model reads         | trust levels + spotlight + injection scanning |
| `secrets.*` bindings | log access                                  | masked at the binding layer, never logged     |

On top of the hardened mechanisms, a `permits:` block makes the file
itself declare its blast radius — hosts, paths, programs, tools, all
default-deny once declared. A task reaching beyond the boundary is
caught by `nika check`, statically, with the exact fix:

<video autoPlay muted loop playsInline poster="/images/posters/permits-audit.png" style={{ borderRadius: "0.75rem" }}>
  <source src="https://mintcdn.com/supernovae-acdf3706/fG5hUlcbwPVmVQP4/videos/permits-audit.webm?fit=max&auto=format&n=fG5hUlcbwPVmVQP4&q=85&s=16891e754493a52347ea7705a1e2e4bf" type="video/webm" data-path="videos/permits-audit.webm" />

  <source src="https://mintcdn.com/supernovae-acdf3706/fG5hUlcbwPVmVQP4/videos/permits-audit.mp4?fit=max&auto=format&n=fG5hUlcbwPVmVQP4&q=85&s=6509d9bbfa18d0a8cb555d5b94c83160" type="video/mp4" data-path="videos/permits-audit.mp4" />
</video>

## SSRF defense (on by default)

`nika:fetch` refuses to be bounced into private space. Three layers,
verbatim from the engine (`nika-http`):

<Steps>
  <Step title="Static checks (pure)">
    Scheme allow-list (`http`/`https` only), blocked hostnames
    (`localhost`, cloud metadata), literal-IP range checks: loopback,
    RFC 1918, link-local/metadata (`169.254.169.254`), CGN, IPv6 local
    ranges, v4-mapped v6.
  </Step>

  <Step title="DNS resolution check">
    Non-literal hosts are resolved and **every address** is
    range-checked: this kills decimal-IP tricks (`http://2130706433/`)
    and public names that resolve to private addresses.
  </Step>

  <Step title="Per-hop redirect re-check">
    Client-level redirects are disabled; the engine follows redirects
    itself and re-runs layers 1+2 on **every hop**. A public host
    cannot 302 the client into your VPC.
  </Step>
</Steps>

Response sizes are capped (64 MiB default) and self-signed TLS is
rejected by default. Private-network access is opt-in configuration,
never the default.

## exec: the data-channel rule

`exec` in shell mode runs every command against a blocklist before
spawn. But the structural defense is in how you write workflows:
**tainted data goes through data channels, never code channels**.

```yaml theme={"system"}
# ❌ tainted output interpolated into a command string
- id: bad
  exec:
    command: "echo ${{ tasks.fetch_comment.output }}"

# ✅ tainted output passed as data
- id: good
  exec:
    command: tee
    args: ["/tmp/comment.txt"]
    stdin: "${{ tasks.fetch_comment.output }}"
```

Passing tainted data to a privileged sink without going through a data
channel raises a typed taint violation: taint is a property of the
**data**, not of who runs the workflow.

## Trust levels + prompt injection

Content entering a workflow carries a trust level. Untrusted content
(fetched pages, user comments) is tracked as it flows into prompts and
tools:

| Defense layer                                              | Error surface                        |
| ---------------------------------------------------------- | ------------------------------------ |
| dangerous tool invoked on untrusted data → blocked         | `NIKA-SEC` family (`security_error`) |
| strict-mode trust-level violation                          | `NIKA-SEC` family                    |
| exfiltration canary token found in output                  | `NIKA-SEC` family                    |
| prompt-injection scanner (+ ML detection)                  | `NIKA-SEC` family                    |
| untrusted data reached a prompt without spotlight wrapping | `NIKA-SEC` family                    |
| workflow recursion depth / self-launch blocked             | `NIKA-SEC-003` (registered)          |

The three codes the spec REGISTERS today: `NIKA-SEC-001` (exec blocklist
hit), `NIKA-SEC-002` (agent tool outside the whitelist), `NIKA-SEC-003`
(run-recursion bound). The runtime trust layers above are engine-side
(the Shield): they emit within the `NIKA-SEC` namespace as they land.

Every failure is a [typed error](/reference/error-codes) with a stable
code and a `transient` flag. Your `on_error:` can act on it.

## Secrets

`${{ secrets.* }}` is a first-class namespace (vault/env/file-backed).
Secret values are **masked in logs and traces** at the binding layer:
they never appear in the event stream, and they are not readable back
from a task's recorded output.

## Supply-chain hardening (the engine itself)

* `unsafe_code = "forbid"` workspace-wide · zero `.unwrap()` in `src/` (CI-enforced)
* `cargo deny` on every PR · `cargo audit` on every dependency change
* Sealed kernel traits · external code cannot impersonate engine I/O
* {STATUS.libTests} lib tests · {STATUS.clippyWarnings} clippy warnings · every crate passes 12 admission gates
* AGPL-3.0-or-later · the source travels with the binary, always auditable

## Reporting a vulnerability

**[security@supernovae.studio](mailto:security@supernovae.studio)** (please not via public issues).
Acknowledgement ≤72h, triage ≤7 days, disclosure ≤90 days. Full policy,
disclosure process and the out-of-scope list:
[SECURITY.md](https://github.com/supernovae-st/nika/blob/main/SECURITY.md).

## See also

<CardGroup cols={2}>
  <Card title="Error codes" icon="triangle-exclamation" href="/reference/error-codes">
    The `NIKA-SEC` namespace + every typed failure.
  </Card>

  <Card title="Bindings" icon="link" href="/concepts/bindings">
    Taint, scopes, and the `secrets.*` namespace.
  </Card>

  <Card title="Builtins · fetch" icon="globe" href="/reference/builtins">
    The `nika:fetch` contract (engines MUST ship SSRF defense).
  </Card>

  <Card title="SECURITY.md" icon="shield" href="https://github.com/supernovae-st/nika/blob/main/SECURITY.md">
    Reporting, disclosure timeline, hall of fame.
  </Card>
</CardGroup>
