Skip to content
NVIDIAPublic

About

Stops AI agents from taking unwanted or overeager actions. Nomos reviews each proposed action against the agent's own context (its permissions, task, justification, blast radius and signed receipts of what it has observed) using OPA policy and an LLM council. Approved actions get a signed, single-use Writ; the executor runs exactly that.

Resources

Contributing

Security policy

Stars

14 stars

Watchers

1 watching

Forks

Latest commit

 

History

311 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nomos

The point. Autonomy is what makes agents useful. It is also what lets them delete the wrong files, crash a server, leak credentials, or push a destructive change to production. Nomos is the oversight layer that catches those actions before they execute — so agents stay autonomous, and the destructive ones never run.

Defense in depth. Every privileged action passes through multiple independent layers before it executes. Each layer catches a different class of problem:

  • OPA policy filter — deterministic hard rules that don't need LLM judgment: production justification requirements, blocked paths, packaged-file integrity. Optional and customer-supplied, so deployments encode their own non-negotiables.
  • LLM-backed council — a multi-member panel that votes on whether the action matches its stated intent, scope, and blast radius. Catches semantic mismatches and adversarial framing that fixed rules can't anticipate.
  • Writ of Execution — a cryptographically signed, time-bounded, single-use token that pins the exact command, working directory, and packaged files into bytes the executor can verify before running.
  • Sandboxed executor — runs the command inside an isolated container with capabilities dropped, enforcing its own tool whitelist, path restrictions, and blocked patterns.

An action only runs when every layer clears it. That coordination — not any single check — is what keeps the bad ones from running.


Overview

Security Council + Writ of Execution + Sandboxed Execution. Nomos is an authorization kernel for privileged operations with cryptographic accountability. Agents propose intent; an LLM-backed council evaluates it; approved actions are sealed in a signed Writ of Execution and run inside an isolated container sandbox.

Nomos solves a specific problem: how do you let AI agents take real actions (run commands, modify infrastructure, access data) while maintaining enterprise-grade control?

Without a governing layer, multi-agent systems trend toward opaque decisions, overbroad privileges, and irreproducible incidents. Nomos enforces a strict authorization pipeline where no command executes without cryptographic proof of approval.

What It Does

Agent: "I need to run: /usr/local/bin/python3 -c 'from pathlib import Path; Path(\"/workspace/test.txt\").write_text(\"hello\\n\")'"
       ↓
OPA: deterministic front-door policy checks the normalized intent
       ↓
Code Review (optional): sidecar inspects packaged files before the voters
       ↓
Council: 3 LLM reviewers evaluate the command independently
         - Command Integrity Reviewer: checks for injection, obfuscation
         - Environment Isolation Reviewer: validates scope and targeting
         - Blast Containment Reviewer: assesses worst-case impact
       ↓
Writ Factory: mints a signed, time-bound, single-use authorization token
       ↓
Executor: verifies signature, claims nonce, runs command in sandbox
       ↓
Result: { exit_code: 0, stdout: "...", duration: 0.02s }

Key Properties

  • Cryptographic accountability — Every approved action is sealed in an Ed25519-signed Writ of Execution. The signature proves council approval; the nonce prevents replay; the expiry limits the window of use.
  • Multi-party consensus — Council members are LLM agents routed through NVIDIA API Catalog NIM inference endpoints by default, with direct Anthropic/OpenAI alternatives. They vote independently. Optional debate rounds let members reconsider after seeing each other's reasoning.
  • Defense in depth — OPA can reject intents before LLM review; even after council approval, the executor enforces its own policy: tool whitelists, denied paths, blocked command patterns, and working directory restrictions.
  • Full audit trail — Every step (submission, evaluation, voting, debate, minting, execution, verification) is recorded in a hash-chained, signed ledger.
  • Pluggable transports and policy — Use MCP over Streamable HTTP, the gRPC proxy, custom OPA policies at deployment time, custom executor profiles, or in-process council tests.

Use Cases

  • IDE agent governance — Route Windsurf/Cursor/Copilot shell commands through the council before execution
  • CI/CD guardrails — Gate production deployments, database migrations, and infrastructure changes behind council approval
  • Multi-agent orchestration — Let autonomous agents request privileged operations with cryptographic proof of authorization
  • Security research — Test how LLM reviewers handle jailbreak attempts, social engineering, and obfuscated commands

Start Here

Operating system requirement: Nomos deployment is supported and tested on Linux. Native macOS deployment is not currently supported. See Deployment Prerequisites for the complete requirements.

1. Choose a Local Target

The fastest path is scripts/deploy_stack.sh. It creates deploy/.env, generates local secrets, deploys the stack, creates or verifies the first agent account, and prints the URLs you should use.

Which path am I on? This quick start uses scripts/deploy_stack.sh. Its Kind path deploys service mode: deploy/kubernetes/kind-values.yaml sets storage.mode: service, so PostgreSQL, dev Vault transit signing and an nginx /mcp gateway come up with the stack. Its Compose path (DEPLOY_TARGET=compose) is the one that uses a redis durable store. deploy/ documents a second pair of bootstrappers — deploy/scripts/compose-up.sh and deploy/kubernetes/scripts/kind-up.sh — which deploy service mode on both targets. They are not interchangeable: the URLs, the ports and the credential layout differ. Start here unless you specifically need service mode on Compose; see deploy/README.md for that path.

Target Use When Key Prerequisites
Kind, default You want the closest local Kubernetes path. Docker, Kind, kubectl, Helm, NVIDIA API key
Docker Compose You want a simpler Docker-only startup or a remote VM install. Docker Compose v2, NVIDIA API key

The default council uses the same model for all roles through NVIDIA API Catalog. Retries and debate rounds can add requests, so size provider quota for the enabled roles and expected workload. Commented mixed-model and direct-vendor examples are in configs/council.yaml; see One model by default, several if you want them. Open the Build/API page for any configured model, choose Generate API Key, and store the generated value as NVIDIA_API_KEY.

The hosted NVIDIA API Catalog endpoints are intended for evaluation and trial use. Their response time includes shared endpoint queueing and model serving, so it can vary with endpoint load and rate limits and does not measure Nomos processing overhead. A slow response, timeout, or HTTP 429 can prevent a council member from completing its review, in which case Nomos fails closed.

If a trial endpoint is temporarily overloaded or returns HTTP 429, wait before retrying. When evaluating a model whose normal response time exceeds the current limit, update request_timeout_seconds on that llm_backends entry in the active council config and restart the council. For predictable production latency, use a self-hosted NVIDIA NIM or another production inference backend, such as the documented direct-provider alternatives, sized for your workload. Review the NVIDIA API Trial Terms before relying on the hosted API Catalog endpoints. Anthropic/OpenAI keys are only needed after activating the matching commented direct-vendor backends.

2. Start the Council

Run one of these from the repository root:

# Default local Kind bootstrap
NVIDIA_API_KEY=nvapi-... ./scripts/deploy_stack.sh

# Kind deployment from a pre-created nomos/nomos-secrets Secret.
# This does not create or update deploy/.env.
KIND_USE_EXISTING_SECRET_ONLY=1 ./scripts/deploy_stack.sh

# Docker Compose bootstrap instead of Kind
NVIDIA_API_KEY=nvapi-... DEPLOY_TARGET=compose ./scripts/deploy_stack.sh

# Compose plus the browser demo
NVIDIA_API_KEY=nvapi-... DEPLOY_TARGET=compose WITH_DEMO=1 ./scripts/deploy_stack.sh

The script keeps deploy/.env at mode 600. If the file does not exist, it creates it and generates Redis, dashboard, signup, account-integrity, telemetry, and council-management secrets. If the agent account is new, it stores NOMOS_AGENT_ID and NOMOS_AUTH_TOKEN in deploy/.env.

Use KIND_USE_EXISTING_SECRET_ONLY=1 only when nomos/nomos-secrets already exists. That path validates required Secret keys, deploys without creating deploy/.env, and patches any newly minted agent token back into the Kubernetes Secret. If the Secret is Helm-owned, the script reuses the live Secret data through a temporary 0600 Helm values file that is deleted after the upgrade. If the Secret is pre-created outside Helm, it deploys with secrets.create=false.

Agent bootstrap distinguishes four states and reports only credential presence, never the credential value:

  • An absent account is created and its one-time raw token is written to the protected store.
  • An existing account is reused without mutation only after the protected raw token is SHA-256 hashed and compared in constant time with the account hash; the token index must also resolve to that account.
  • An existing account with a missing or unverifiable protected token stops the deployment before authenticated scenarios and prints safe recovery choices.
  • AGENT_FORCE_ROTATE=1 explicitly rotates the selected account and replaces its stored token.

Redis stores only a SHA-256 hash, so a lost raw token cannot be recovered from the account record. Restore the protected file or Secret from your secret manager when its credential is still valid. Verification is read-only and prints neither the raw candidate nor the complete stored hash. Provisioning creates another identity and leaves existing credentials valid; rotation replaces a selected identity's token and immediately invalidates its old credential.

For repeatable validation of a shared or long-lived deployment, provision a dedicated identity with membership only in the configured validation council (the default is council-prod). It is created with the developer role, because the documented authenticated end-to-end checks write under /workspace and so need file:write; operator and admin infrastructure checks remain separately opt-in. Because the identity is dedicated and separate from the deployment account, it can be rotated without disturbing NOMOS_AUTH_TOKEN — though rotating it still invalidates any client already holding the old NOMOS_VALIDATION_AUTH_TOKEN.

Run a normal deployment first. VALIDATION_IDENTITY=1 provisions the validation account in addition to the deployment account — it is not a way to bootstrap a fresh deployment on its own, and using it before the standard account exists leaves the deployment without its NOMOS_AGENT_ID credential:

./scripts/deploy_stack.sh                      # standard deployment account
VALIDATION_IDENTITY=1 ./scripts/deploy_stack.sh  # then the validation identity

This stores separate NOMOS_VALIDATION_AGENT_ID and NOMOS_VALIDATION_AUTH_TOKEN values, preserving the deployment account's NOMOS_AGENT_ID and NOMOS_AUTH_TOKEN. Local values remain in the 0600 deploy/.env; Kind also persists them in nomos/nomos-secrets. A clean checkout can reuse the Kubernetes values with KIND_USE_EXISTING_SECRET_ONLY=1 VALIDATION_IDENTITY=1. If the validation account exists but those values are absent, restore them, choose a different dedicated AGENT_ID, or deliberately rotate only the validation identity:

VALIDATION_IDENTITY=1 AGENT_FORCE_ROTATE=1 ./scripts/deploy_stack.sh

3. Find the Running Services

The deploy script prints the active URLs at the end.

Service Kind default with port-forwards Compose default
Monitor UI http://127.0.0.1:9901/ http://$NOMOS_PUBLIC_HOST:9901/
Council MCP http://127.0.0.1:9901/mcp http://$NOMOS_PUBLIC_HOST:9901/mcp
Council gRPC 127.0.0.1:9904 $NOMOS_PUBLIC_HOST:9904
Agent signup https://127.0.0.1:9905/ https://$NOMOS_PUBLIC_HOST:9905/
Demo UI https://127.0.0.1:8000/ https://$NOMOS_PUBLIC_HOST:8000/ when WITH_DEMO=1
Executor http://127.0.0.1:9907 http://$NOMOS_PUBLIC_HOST:9907

The Demo UI and Demo API require HTTP Basic auth. deploy_stack.sh generates DEMO_AUTH_PASSWORD for normal bootstrap runs and stores it in deploy/.env. With KIND_USE_EXISTING_SECRET_ONLY=1, the password lives in the nomos/nomos-secrets Secret under DEMO_AUTH_PASSWORD; the username defaults to demo through DEMO_AUTH_USER.

Compose publishes host ports by default. On a remote machine, set NOMOS_PUBLIC_HOST=<reachable-ip-or-dns> before deploy and put firewall or reverse-proxy policy around exposed ports.

4. Verify the Stack

Install local client/test dependencies once per checkout:

uv sync --extra dev
# or: python3 -m pip install -e ".[dev]"
# Monitor and upstream council are healthy
curl -s http://127.0.0.1:9901/healthz | python3 -m json.tool

# Executor is healthy and has the council's signing key
curl -s http://127.0.0.1:9907/v1/health | python3 -m json.tool

# gRPC proxy is healthy
NOMOS_GRPC_SMOKE_ADDR=127.0.0.1:9904 uv run pytest tests/test_grpc_live_smoke.py -q

For Kind deployments that use only nomos-secrets, load live-test credentials from the Kubernetes Secret instead of sourcing deploy/.env:

export NOMOS_MCP_LIVE_URL=http://127.0.0.1:9901/mcp
export NOMOS_GRPC_SMOKE_ADDR=127.0.0.1:9904
export NOMOS_AGENT_ID="$(kubectl --context kind-nomos -n nomos get secret nomos-secrets -o jsonpath='{.data.NOMOS_AGENT_ID}' | base64 -d)"
export NOMOS_AUTH_TOKEN="$(kubectl --context kind-nomos -n nomos get secret nomos-secrets -o jsonpath='{.data.NOMOS_AUTH_TOKEN}' | base64 -d)"

When using the dedicated validation identity, map its protected values into the standard client variables without printing the token:

set -a
. deploy/.env
set +a
export NOMOS_AGENT_ID="$NOMOS_VALIDATION_AGENT_ID"
export NOMOS_AUTH_TOKEN="$NOMOS_VALIDATION_AUTH_TOKEN"
test -n "$NOMOS_AGENT_ID" && test -n "$NOMOS_AUTH_TOKEN"

For the full post-deploy validation ladder, use Council MCP Validation. It separates the portable Kind/Compose MCP checks from Compose-only smoke scripts.

5. Manage Agent Accounts

deploy_stack.sh creates agent-developer by default unless SKIP_ACCOUNT=1 is set. You can create, rotate, and disable accounts later from inside the council container:

./scripts/agent_accounts.sh create alice-agent --role developer
./scripts/agent_accounts.sh rotate-token alice-agent
./scripts/agent_accounts.sh disable alice-agent
./scripts/agent_accounts.sh list

Role presets:

Role Permissions
readonly shell:read, file:read
developer shell:read, shell:write, file:read, file:write
operator developer permissions plus infra:deploy
admin all permission scopes

6. Make an End-to-End Request

This path asks the council to authorize a command, receives a signed writ, and submits that writ to the executor.

import asyncio, json, urllib.request
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession

AUTH_TOKEN  = "<NOMOS_AUTH_TOKEN from deploy/.env>"
PROXY_TOKEN = "<MONITOR_MCP_PROXY_TOKEN from deploy/.env>"
AGENT_ID    = "agent-developer"

async def authorize_and_execute():
    # The monitor fronts the council with a Bearer-authenticated /mcp proxy.
    # Both tokens are written to deploy/.env by the deploy script.
    async with streamablehttp_client(
        "http://127.0.0.1:9901/mcp",
        headers={"Authorization": f"Bearer {PROXY_TOKEN}"},
        # Above the monitor proxy deadline (180s), which is above the council's
        # retry budget (150s). The client default is 30s, so a healthy council
        # response -- measured up to 47s -- can arrive after this call has
        # already hung up.
        timeout=240,
    ) as (r, w, _):
        async with ClientSession(r, w) as session:
            await session.initialize()

            result = await session.call_tool("council_authorize", arguments={
                "auth_token": AUTH_TOKEN,
                "agent_id": AGENT_ID,
                # Exclusive creation: `write_text` would overwrite an existing
                # file and would follow a symlink, either of which makes the
                # blast_radius below untrue. Opening with "x" fails instead.
                "command": "/usr/local/bin/python3 -c 'open(\"/workspace/test.txt\", \"x\").write(\"hello\\\\n\")'",
                "working_directory": "/workspace",
                "description": "Create a test file in the workspace",
                "target_resource": "/workspace",
                "justification": "Testing the end-to-end authorization flow",
                # The council denies a state-changing command that does not state
                # its blast radius. Say what changes and how to undo it.
                "blast_radius": "Creates exactly one new file, /workspace/test.txt, "
                                "and fails without modifying anything if that path "
                                "already exists or is a symlink. Nothing else is read "
                                "or modified. Rollback: delete the created file.",
            })
            auth = json.loads(result.content[0].text)
            print(f"Council decision: {auth['status']}")

            if auth["status"] != "approved":
                print(f"Denied: {auth.get('guidance', auth.get('error'))}")
                return

            writ_blob = auth["writ"]  # base64-encoded signed writ

    req = urllib.request.Request(
        "http://127.0.0.1:9907/v1/writs/execute",
        data=json.dumps({"writ": writ_blob}).encode(),
        headers={
            "Content-Type": "application/json",
            "X-Agent-ID": AGENT_ID,  # must match writ's target_agent_id
        },
    )
    with urllib.request.urlopen(req) as resp:
        exec_result = json.loads(resp.read())

    print(f"Exit code: {exec_result['exit_code']}")
    print(f"Stdout: {exec_result.get('stdout', '')}")

asyncio.run(authorize_and_execute())

7. Run Tests

# Core package tests
uv run pytest tests/ -v

# Portable deployed MCP and gRPC checks for Kind or Compose
uv run ./scripts/run_test_suite.py \
  --suite grpc-live \
  --suite mcp-live \
  --require-deployed

# Compose-only smoke test (requires host Redis access and deploy/.env from this stack)
uv run --env-file deploy/.env python scripts/test_smoke.py

# Compose-only live E2E demo (requires the default executor container name)
uv run --env-file deploy/.env python scripts/test_live_e2e.py

# Standard local regression wrapper with an HTML report
uv run ./scripts/run_test_suite.py --profile integration

Further Reading

Full README

Table of Contents


The Problem

As "build an agent" becomes cheap and near-instant, the differentiator stops being task competence and becomes enterprise-grade control: who is allowed to act, under what constraints, with what proof.

Without a governing layer, multi-agent systems trend toward:

  • Opaque decisions — No audit trail of what happened or why
  • Overbroad privileges — Agents with god-mode access
  • Irreproducible incidents — "It just broke" with no way to replay
  • "Trust me bro" postmortems — No cryptographic proof of actions

How It Works

Nomos enforces a strict authorization pipeline. No command executes without cryptographic proof of council approval.

Step What Happens
1. Propose Agent submits a structured Intent (what, why, target, rollback plan)
2. Authenticate Agent's token is verified against Redis-stored account (SHA-256, constant-time)
3. Categorize Intent is classified into permission scopes (SHELL_READ, FILE_DELETE, INFRA_DEPLOY, ...)
4. Chairman Pre-Auth Chairman agent checks agent permissions and assesses risk level (low/medium/high/critical)
5. Council Vote LLM-backed council members evaluate the intent and vote (approve/deny with reasoning)
6. Mint Writ If approved, a WritOfExecution is created — signed with the council's Ed25519 key, nonce-protected, time-bounded
7. Execute The writ is submitted to the Writ Executor (separate container), which verifies the signature, checks expiration, claims the nonce, and runs the command in a sandbox
8. Audit Every step is logged to a hash-chained, signed ledger

Architecture

For the current end-to-end workflow, including monitor ingress, OPA, multi-council routing, Code Review, council voting, writ minting, executor verification, and ledger browsing, see the browser-based Workflow Walkthrough. The diagram below is a compact README summary.

                         ┌──────────────────────────────────────────────────┐
                         │              AGENT (any LLM agent)               │
                         │                                                  │
                         │  "I need to run: mkdir /app/workspace/data"      │
                         └────────────────────────┬─────────────────────────┘
                                                  │
                                         MCP tool call
                                      (council_authorize)
                                                  │
                    ┌──────────────────────────────────────────────────────────────┐
                    │                                                              │
                    │        MONITOR :9901 → COUNCIL SERVICE  (internal :9901)     │
                    │                                                              │
                    │  ┌──────────────┐  ┌───────────────┐  ┌──────────────┐       │
                    │  │ Authenticator│  │  Permission   │  │  Chairman    │       │
                    │  │  (Redis +    │─>│  Categorizer  │─>│  Pre-Auth    │       │
                    │  │   SHA-256)   │  │  (7 scopes)   │  │ (risk+gate)  │       │
                    │  └──────────────┘  └───────────────┘  └──────┬───────┘       │
                    │                                              │               │
                    │                                              ▼               │
                    │                ┌──────────────────────────────────┐          │
                    │                │     LLM Council Members          │          │
                    │                │  ┌──────────┐ ┌──────────┐       │          │
                    │                │  │ Command  │ │Environment│ ...  │          │
                    │                │  │Integrity │ │ Isolation │      │          │
                    │                │  │ Reviewer │ │ Reviewer  │      │          │
                    │                │  └────┬─────┘ └─────┬─────┘      │          │
                    │                │       │approve      │approve     │          │
                    │                └───────┴─────────────┴────────────┘          │
                    │                               │                              │
                    │                       threshold met                          │
                    │                               │                              │
                    │                               ▼                              │
                    │                ┌──────────────────────────┐                  │
                    │                │      WritFactory         │                  │
                    │                │  ┌────────────────────┐  │                  │
                    │                │  │ WritOfExecution    │  │                  │
                    │                │  │  - command         │  │                  │
                    │                │  │  - nonce           │  │                  │
                    │                │  │  - expires_at      │  │                  │
                    │                │  │  - Ed25519 sig     │  │                  │
                    │                │  └────────────────────┘  │                  │
                    │                └───────────┬──────────────┘                  │
                    │                            │                                 │
                    └────────────────────────────┼─────────────────────────────────┘
                                                 │
                                       signed writ blob
                                         (base64 JSON)
                                                 │
                    ┌──────────────────────────────────────────────────────────────┐
                    │                                                              │
                    │       WRIT EXECUTOR  (container, profile-selectable)       │
                    │                                                              │
                    │  Verification Pipeline (12 steps):                           │
                    │                                                              │
                    │  ┌───────────┐  ┌───────────┐  ┌───────────┐                 │
                    │  │ 1. Deser- │─>│2. Agent   │─>│3. Signa-  │                 │
                    │  │    ialize │  │   binding │  │   ture    │                 │
                    │  └───────────┘  └───────────┘  └───────────┘                 │
                    │  ┌───────────┐  ┌───────────┐  ┌───────────┐                 │
                    │  │ 4. Tempo- │─>│5. Nonce   │─>│6. Direc-  │                 │
                    │  │    ral    │  │   claim   │  │   tory    │                 │
                    │  └───────────┘  └───────────┘  └───────────┘                 │
                    │  ┌───────────┐  ┌───────────┐  ┌───────────┐                 │
                    │  │ 7. Blocked│─>│8. Tool    │─>│9. Denied  │                 │
                    │  │  patterns │  │ whitelist │  │  tool args│                 │
                    │  └───────────┘  └───────────┘  └───────────┘                 │
                    │  ┌───────────┐  ┌───────────┐  ┌───────────┐                 │
                    │  │10. Denied │─>│11. Empty  │─>│12. Packa- │                 │
                    │  │    paths  │  │   command │  │  ged files│                 │
                    │  └───────────┘  └───────────┘  └─────┬─────┘                 │
                    │                                      │                       │
                    │                                 subprocess                   │
                    │                             (isolated env, cwd,              │
                    │                              timeout enforced)               │
                    │                                      │                       │
                    └──────────────────────────────────────┼───────────────────────┘
                                                            │
                                                            ▼
                                              { exit_code, stdout, stderr, duration }
                    ┌──────────────────────────────────────────────────────────────┐
                    │                                                              │
                    │        REDIS  (container, port 6379)                         │
                    │                                                              │
                    │  nomos:accounts:{id}      — agent credentials + perms           │
                    │  nomos:nonces:{hex}       — atomic nonce claims (SET NX)        │
                    │  nomos:writs:{id}         — writ metadata + outcomes            │
                    │  nomos:intents:{id}       — intent history                      │
                    │  nomos:ratelimit:{cat}:{id} — rate limiting counters            │
                    │                                                              │
                    └──────────────────────────────────────────────────────────────┘

Kernel Levelization

The kernel itself follows Lakos-style levelization — each level depends only on levels below it:

Level 4  SecurityCouncil          — orchestrates the full workflow
Level 3  SandboxExecutor          — runs actions with token enforcement
Level 2  CapabilityService        — mints/validates scoped tokens
         QuorumManager            — multi-party weighted voting
Level 1  LedgerChain              — hash-chained append-only audit log
         PolicyEngine             — explicit policy rules
Level 0  Clock, Crypto, Types     — pure utilities, no external deps

Writ of Execution

The Writ of Execution is the core authorization primitive. It is a cryptographically signed, nonce-protected, time-bounded token that authorizes exactly one command execution.

Anatomy

{
  "writ_id":              "writ-9ff898532e9d4be3",
  "intent_hash":          "sha256 of the original intent",
  "command":              "/usr/local/bin/python3 -c 'from pathlib import Path; Path(\"/app/workspace/test.txt\").write_text(\"hello\\\\n\")'",
  "working_directory":    "/app/workspace",
  "allowed_env":          {"PATH": "/usr/local/bin:/usr/bin:/bin"},
  "target_agent_id":      "agent-developer",
  "issued_at":            1739226000.0,
  "expires_at":           1739226300.0,
  "max_executions":       1,
  "nonce":                "cryptographically random 32-byte hex",
  "council_id":           "council-prod",
  "threshold_used":       "unanimous",
  "chairman_risk_level":  "low",
  "member_votes":         [{"member_id": "m1", "decision": "approve"}, ...],
  "signature":            "Ed25519 signature over canonical JSON"
}

Security Properties

Property Mechanism
Authenticity Ed25519 signature — only the council's private key can produce a valid writ
Integrity Any modification to any field invalidates the signature
Non-replayability Nonce is atomically claimed in Redis (SET NX); second use is rejected
Time-bounding expires_at enforced by executor; TTL scales with risk (low=300s, critical=30s)
Scope-limiting working_directory and allowed_env restrict what the command can access

Verification Pipeline

The trusted executor contract performs agent binding before handing the writ to the example enforcer. Together, the adapter and enforcer run these checks before executing the command. Failure at any step aborts immediately:

  1. Deserialize — decode base64, parse JSON into WritOfExecution
  2. Agent binding — verify X-Agent-ID matches the writ's target_agent_id
  3. Signature — verify Ed25519 signature against council's public key
  4. Temporal — check issued_at <= now <= expires_at
  5. Nonce — atomically claim nonce in Redis (prevents replay)
  6. Directory — verify working_directory is in the allowed list
  7. Blocked patterns — reject catastrophic commands (fork bombs, sudo, pipe-to-shell, etc.)
  8. Tool whitelist — every command segment must use an allowed tool
  9. Denied tool args — per-tool argument restrictions (e.g., git push --force)
  10. Denied paths — command must not reference forbidden paths (e.g., /etc/shadow)
  11. Command — reject empty commands
  12. Packaged files — materialize signed attachments only if hashes and paths validate

Steps 1–5 are contract/cryptographic/nonce checks. Steps 6–12 are local policy and materialization checks (executor-level). A command can be council-approved yet still blocked by local policy — defense in depth.


Deployment

Prerequisites

Nomos deployment is supported and tested on Linux. Other host environments, including native macOS, are not currently part of the supported or tested platform matrix. They may work when the required dependencies are installed, but platform-specific setup or troubleshooting may be necessary. For the most consistent experience, use a Linux host or Linux virtual machine.

  • Kind and Docker Compose deployments, as well as account-management operations, require Bash 4.3 or newer.
  • Default Kind path: Docker, Kind, kubectl, Helm, GNU timeout when KIND_PORT_FORWARD=1, and an NVIDIA API key.
  • Docker Compose path: Docker Compose v2 and an NVIDIA API key.
  • Anthropic/OpenAI keys are only needed if you switch members to direct-vendor backends.

Start the Stack

# Default Kind deployment with local port-forwards
NVIDIA_API_KEY=nvapi-... ./scripts/deploy_stack.sh

# Kind deployment from an existing Kubernetes Secret, with no deploy/.env writes
KIND_USE_EXISTING_SECRET_ONLY=1 ./scripts/deploy_stack.sh

# Docker Compose deployment with the default OpenShell executor
NVIDIA_API_KEY=nvapi-... DEPLOY_TARGET=compose ./scripts/deploy_stack.sh

# Compose with a project-specific dev executor profile
NVIDIA_API_KEY=nvapi-... DEPLOY_TARGET=compose EXECUTOR_PROFILE=executor-dev \
    EXECUTOR_UID=$(id -u) EXECUTOR_GID=$(id -g) ./scripts/deploy_stack.sh

# Verify
curl -s http://127.0.0.1:9901/healthz | python3 -m json.tool

The deploy script creates deploy/.env, generates missing local secrets, seeds deploy/dynamic-config/, creates or verifies the first agent account, and stores NOMOS_AGENT_ID/NOMOS_AUTH_TOKEN when it mints a new token. It also generates DEMO_AUTH_PASSWORD so the Demo UI is never exposed without Basic auth on scripted bootstrap paths. Set KIND_USE_EXISTING_SECRET_ONLY=1 for pre-seeded Kind clusters where runtime secrets must stay in Kubernetes and no host-local env file should be written.

Direct Compose remains useful for debugging. Include the Sentinel override if you want to match the scripted Compose default:

docker compose -f deploy/docker-compose.yml \
    -f deploy/docker-compose.redis-sentinel.yml \
    --profile executor-openshell up -d --build

This starts three Redis containers and three Sentinel containers, and wires council, agent-signup, and the standard executor profiles to discover the current Redis master through Sentinel. Set COMPOSE_REDIS_TOPOLOGY=standalone only for local single-Redis runs that explicitly do not need failover.

Services

Service Default exposure Purpose
redis Compose publishes 6379; Kind keeps it in-cluster Nonce store, accounts, rate limiting
opa Compose publishes 8181; Kind keeps it in-cluster Default front-door OPA policy filter
monitor 9901 External MCP reverse proxy, dashboard, and metrics
council Internal 9901 only MCP server with LLM council members
council-grpc 9904 gRPC proxy that forwards to MCP through the monitor
agent-signup 9905 Dev-only self-service account console
demo 8000 Council Chamber demo UI
executor-openshell 9907 Default trusted executor; isolated OpenShell-backed sandbox
executor-default 9902 Legacy restrictive Docker executor
executor-dev 9903 Dev executor with extended tools and host bind mounts

Redis, OPA, council, monitor, the gRPC proxy, agent-signup, and the demo are no-profile Compose services — a bare docker compose up starts all of them. Executors are profile-gated; choose the executor profile you want to run. scripts/deploy_stack.sh starts the no-profile services selectively, so agent-signup comes up only when WITH_AGENT_SIGNUP is set (default 1) and the demo only when WITH_DEMO is set (default 0). Set the *_BIND_ADDR and *_PORT variables in deploy/.env or the shell when you need tighter exposure than the Compose defaults.

Key Management

The council generates an Ed25519 key pair at first startup (/app/keys/council.key + council.pub). The executor reads the public key from the same volume to verify writ signatures.

# View the council's public key fingerprint
curl -s http://127.0.0.1:9907/v1/health | python3 -m json.tool

Key Rotation

The executor supports multiple public keys for zero-downtime rotation:

  1. Generate new key pair
  2. Add old public key to executor's additional_keys
  3. Restart executor
  4. Switch council to sign with new key
  5. After max_writ_ttl elapses, remove old key

Quick Start

Install the local client/test dependencies before running the Python examples or helper scripts:

uv sync --extra dev
# or: python3 -m pip install -e ".[dev]"

Live E2E: Council → Writ → Executor

import asyncio, json, urllib.request
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession

# The monitor fronts the council with a Bearer-authenticated /mcp proxy; the
# deploy script writes this token to deploy/.env.
PROXY_TOKEN = "<MONITOR_MCP_PROXY_TOKEN from deploy/.env>"

async def authorize_and_execute():
    agent_id = "agent-developer"

    # 1. Connect through the monitor's Bearer-authenticated /mcp proxy.
    #    Without this header the monitor returns 401 before the council is
    #    reached, so the example fails with no council decision at all.
    async with streamablehttp_client(
        "http://localhost:9901/mcp",
        headers={"Authorization": f"Bearer {PROXY_TOKEN}"},
        timeout=240,  # see the note above: the 30s default cuts off a healthy council
    ) as (r, w, _):
        async with ClientSession(r, w) as session:
            await session.initialize()

            # 2. Request authorization for a command
            result = await session.call_tool("council_authorize", arguments={
                # Both tokens come from deploy/.env: the agent token authenticates
                # to the council, PROXY_TOKEN (above) to the monitor in front of it.
                "auth_token": "your-agent-token",  # from deploy/.env or agent_accounts.py output
                "agent_id": agent_id,
                # Exclusive creation: `write_text` would overwrite an existing
                # file and would follow a symlink, either of which makes the
                # blast_radius below untrue. Opening with "x" fails instead.
                "command": "/usr/local/bin/python3 -c 'open(\"/workspace/test.txt\", \"x\").write(\"hello\\\\n\")'",
                "working_directory": "/workspace",
                "description": "Create a test file",
                "target_resource": "/workspace",
                "justification": "Testing the E2E flow",
            })
            auth = json.loads(result.content[0].text)
            print(f"Council decision: {auth['status']}")

            if auth["status"] != "approved":
                print(f"Denied: {auth.get('guidance', auth.get('error'))}")
                return

            # 3. Submit the signed writ to the executor
            writ_blob = auth["writ"]

    req = urllib.request.Request(
        "http://localhost:9907/v1/writs/execute",
        data=json.dumps({"writ": writ_blob}).encode(),
        headers={
            "Content-Type": "application/json",
            "X-Agent-ID": agent_id,
        },
    )
    with urllib.request.urlopen(req) as resp:
        exec_result = json.loads(resp.read())

    print(f"Executed: success={exec_result['success']}, exit_code={exec_result['exit_code']}")

asyncio.run(authorize_and_execute())

The executor rejects execution calls without an X-Agent-ID header matching the writ's target_agent_id.

Programmatic (Same Process)

from nomos.level_0.types import Intent, CouncilMember, AgentRole
from nomos.level_4.council import SecurityCouncil, CouncilConfig
from nomos.level_3.executor import NoOpHandler

config = CouncilConfig(
    council_id="my-council",
    require_justification=True,
    default_token_ttl_seconds=300,
)
council = SecurityCouncil(config=config)
council.register_agent("my-agent", AgentRole.APPLICATION)
council.register_council_member(
    CouncilMember(member_id="alice", public_key_hex="abc", display_name="Alice", weight=1)
)
council.register_action_handler(NoOpHandler())

intent = Intent.create(
    agent_id="my-agent",
    action_type="system.noop",
    description="Test action",
    target_resource="/test",
    justification="Testing the system",
)

result = council.submit_intent(intent)
if result.requires_approval:
    council.approve_intent(intent.intent_id, "alice")
exec_result = council.execute_intent(intent.intent_id)
print(f"Success: {exec_result.success}")

MCP Tools Reference

The council exposes eighteen MCP tools via NVIDIA NeMo Agent Toolkit: authorization/status operations, explicit v1/v2 aliases for authorize and propose, signed-ledger reads, and first-class dynamic council management tools. Connect with any MCP client:

from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession

# PROXY_TOKEN is MONITOR_MCP_PROXY_TOKEN from deploy/.env. Port 9901 is the
# monitor's /mcp proxy, which returns 401 without the Bearer header.
async with streamablehttp_client(
    "http://localhost:9901/mcp",
    headers={"Authorization": f"Bearer {PROXY_TOKEN}"},
    timeout=240,  # see the note above: the 30s default cuts off a healthy council
) as (r, w, _):
    async with ClientSession(r, w) as session:
        await session.initialize()
        result = await session.call_tool("council_authorize", arguments={...})
Tool Purpose Key Parameters
council_authorize Full authorization flow — authenticate, evaluate, mint writ auth_token, agent_id, command, working_directory, description, justification
council_propose_intent Submit intent for evaluation (without minting writ) auth_token, agent_id, command, description, target_resource, justification
council_v1_authorize / council_v1_propose_intent Explicit v1 aliases Same as council_authorize / council_propose_intent
council_v2_authorize / council_v2_propose_intent Explicit multi-council tools Same as above, with required council_id
council_check_status Check intent/workflow status intent_id
council_get_public_key Get council's Ed25519 public key —
council_check_writ_status Check writ lifecycle (valid/expired/used) writ_id
council_pending_approvals List intents awaiting approval —
council_ledger_page Page through signed ledger entries, newest first council_id, cursor_sequence, page_size
council_ledger_get Get one signed ledger entry by sequence council_id, sequence
council_list_councils List configured dynamic council profiles optional management_token
council_show_council Describe one council profile council_id, optional management_token
council_create_council Create a generated profile from a server-side template management_token, council_id, template, enabled
council_put_council_profile Create or replace a full structured profile management_token, council_id, profile, optional enabled
council_set_council_enabled Enable or disable a dynamic council management_token, council_id, enabled
council_validate_catalog Validate the catalog and referenced profiles optional management_token

Mutating dynamic-council tools are fail-closed unless the service environment sets COUNCIL_MANAGEMENT_TOKEN or NOMOS_COUNCIL_MANAGEMENT_TOKEN. To require that token for council_list_councils, council_show_council, and council_validate_catalog, set allow_unauthenticated_read: false on each corresponding tool configuration.

Trusted Executor API

Executors conform to the trusted executor contract. The bundled executors/docker/ service is a local subprocess example, and executors/openshell/ is an OpenShell backend adapter. Production deployments can replace either with any service that implements the same endpoints.

Endpoint Method Purpose
/v1/writs/execute POST Submit a signed writ for execution. Body: {"writ": "<base64 blob>"} plus X-Agent-ID: <agent id>
/v1/health GET Health check with contract version, capabilities, uptime, and key fingerprint
/.well-known/nomos/trusted-executor.json GET Machine-readable contract descriptor
/execute, /health POST/GET Legacy aliases kept for existing clients

Nomos executors generate an executor-owned Ed25519 key and attach signed execution evidence to responses. The evidence binds the writ id, exact approved command, attachment hashes, stdout/stderr hashes, exit code, and verification stage so clients can detect tampering after the fact.


Kernel Internals

Permission Scopes

The categorize_intent function classifies commands into 7 permission scopes:

Scope Example Commands
SHELL_READ ls, cat, ps, grep, date, echo
SHELL_WRITE mkdir, cp, mv, chmod, chown
FILE_WRITE >, >>, touch, tee
FILE_DELETE rm, rmdir
INFRA_DEPLOY kubectl apply, terraform apply, docker run, helm install
INFRA_DESTROY kubectl delete, terraform destroy
FILE_READ cat, head, tail, grep, find

Council Members

Council members are LLM agents with configurable evaluation priorities. The shipped configuration uses Kimi K3 (moonshotai/kimi-k3) on NVIDIA API Catalog for three voting members with different security responsibilities:

# configs/council.yaml
members:
  - id: command-integrity
    display_name: "Command Integrity Reviewer"
    backend: nim-kimi
    priorities:
      - "Verify command matches the stated intent"
      - "Check for injection or hidden side effects"
      - "Validate parameters are reasonable"

  - id: environment-isolation
    display_name: "Environment Isolation Reviewer"
    backend: nim-kimi
    priorities:
      - "Ensure command stays within scoped resources"
      - "Check for environment variable leaks"

  - id: blast-containment
    display_name: "Blast Containment Reviewer"
    backend: nim-kimi
    priorities:
      - "Assess worst-case impact"
      - "Verify rollback is possible"

You can add or remove members to cover your workload’s risks, giving each reviewer a distinct responsibility such as sensitive-data access or production impact. Use ordered priorities and named evaluation_criteria to define those risks. With the shipped chairman.allow_quorum: false, approval is unanimous, so each member you add is another required approval.

One model by default, several if you want them

Each role selects its model independently. members[].backend, chairman.backend, and code_review.backend name an entry in llm_backends, so any role can use a different model or a different provider. Editing a shared entry changes every role that references it.

The shipped default uses the same model hosted on NVIDIA API Catalog for all council members, which keeps deployment to one credential and one endpoint. Distinct models give the council different review perspectives: each reviewer evaluates with a different set of strengths.

The commented mixed-model section in the council configuration assigns a different open-weight API Catalog model to each voting member:

Role Backend Model
Command Integrity nim-kimi moonshotai/kimi-k3
Environment Isolation nim-deepseek deepseek-ai/deepseek-v4-flash-0731
Blast Containment nim-nemotron nvidia/nemotron-3-ultra-550b-a55b
Chairman and code review nim-kimi moonshotai/kimi-k3

One caveat worth stating plainly, because it cuts the other way. The shipped chairman.allow_quorum: false makes approval unanimous, and a member that cannot be reached votes deny. Every provider you add is therefore another provider whose outage denies every request. Model diversity buys independent judgement at the cost of a larger surface that must all be up at once. If you add providers for robustness, size request_timeout_seconds and llm_retry_budget_seconds for the slowest of them, and consider whether unanimity is still the threshold you want.

The same file has a commented Alternative direct-vendor backends block. The documented example assigns Command Integrity to Claude Opus, Environment Isolation to Claude Sonnet, Blast Containment to GPT, and both chairman and code review to Claude Opus.

To activate either example, uncomment the matching llm_backends entries and set each role backend. Commented definitions do not run at default startup. See the deployment setup guide for the provider credentials each example needs, and changing the council model configuration to apply the change to a running council.

Other models to consider

These models are not in the shipped default or the commented examples. They are additional options for review quality and throughput:

Model Notes
DeepSeek V4 Pro Strong security-focused model biasing towards more conservative review decisions
GLM 5.3 (z-ai/glm-5.3) General-purpose option between Kimi’s usability and DeepSeek’s conservatism
Nemotron 3 Super (nvidia/nemotron-3-super-120b-a12b) Higher throughput than Nemotron 3 Ultra

Whichever models you choose, remove active backends that no role references so their keys are not required at startup. Size request_timeout_seconds and llm_retry_budget_seconds for the slowest backend you enable. Editing configs/council.yaml does not change a council that is already running: apply the change as described in changing the council model configuration, then validate with the endpoint compatibility checks.

Authentication

Agent accounts are stored in Redis with SHA-256 hashed tokens:

# Create named accounts. Run from the repository root; the script needs the
# project environment, so prefix with `uv run` if you are not already inside it.
./scripts/agent_accounts.sh create alice-agent --role developer
./scripts/agent_accounts.sh rotate-token alice-agent
./scripts/agent_accounts.sh disable alice-agent

# Optional local demo fixtures, for development machines with Python installed
python3 scripts/seed_accounts.py

Redis Key Schema

All keys are prefixed with nomos: to avoid collisions:

Key Pattern Type Purpose
nomos:accounts:{agent_id} Hash Token hash, display name, enabled flag
nomos:accounts:{agent_id}:perms Set Permission scope strings
nomos:nonces:{hex} String Writ ID (SET NX + EX for atomic claim + TTL)
nomos:writs:{writ_id} Hash Full writ metadata
nomos:writs:{writ_id}:outcome Hash Execution result (exit code, stdout hash, etc.)
nomos:intents:{intent_id} Hash Intent request + decision + timestamps
nomos:ratelimit:{category}:{identifier} String Rate limiting counters (INCR + EXPIRE over the window)

Testing

Local development checks

Install the development dependencies and commit hooks once per checkout:

uv run --extra dev pre-commit install

Run the normal hooks manually when needed:

uv run --extra dev pre-commit run --all-files

The slower coverage gate runs in CI. Run it locally when needed with:

uv run --extra dev pre-commit run unit-tests-coverage --hook-stage manual

Test Suite Overview

The core suite currently collects 1,400+ tests:

# Lightweight development run; optional NAT MCP tests skip
uv sync --extra dev
uv run pytest tests/ -v

# Full optional-backend run, matching CI dependencies
uv sync --extra all
uv run pytest tests/ -v

# Standard local regression wrapper and report
uv run ./scripts/run_test_suite.py --profile integration

# Deployed MCP/gRPC checks after Kind or Compose is running
uv run ./scripts/run_test_suite.py \
  --suite grpc-live \
  --suite mcp-live \
  --require-deployed

# By layer
uv run pytest tests/level_0/   # Clock, Crypto, Types
uv run pytest tests/level_1/   # Ledger, Policy
uv run pytest tests/level_2/   # Capability, Quorum
uv run pytest tests/level_3/   # Executor
uv run pytest tests/level_4/   # Council integration

# Writ system
uv run pytest tests/test_writ_factory.py
uv run pytest tests/test_enforcer.py
uv run pytest tests/test_e2e_writ_lifecycle.py
uv run pytest tests/test_key_rotation.py

# Auth system
uv run pytest tests/test_authenticator.py
uv run pytest tests/test_chairman_preauth.py
uv run pytest tests/test_permissions.py

# Infrastructure
uv run pytest tests/test_redis_store.py

Live E2E Tests (Requires Running Stack)

# Compose-local E2E against the default OpenShell executor (port 9907)
uv run python scripts/test_live_e2e.py

# Compose-local E2E against a specific executor profile
uv run python scripts/test_live_e2e.py 9903 deploy-executor-dev-1

The current script tests 3 live scenarios against Compose containers:

  • Authorized execution — council approves, Writ is minted, executor creates a file, and the file is verified.
  • Full-path compliance — bare echo is denied while /bin/echo is approved for the same low-risk intent.
  • Destructive command review — council evaluates rm -rf and prints the decision and reasoning.

For the full post-deploy MCP validation path, including Kind-safe checks, see Council MCP Validation.


Configuration Reference

Council Config (configs/council.yaml)

council_id: "council-prod"
council_key_path: "/app/keys"

redis:
  host: redis
  port: 6379
  db: 0
  key_prefix: "nomos:"

llm_backends:
  nim-kimi:
    provider: nim
    model: moonshotai/kimi-k3
    api_key: "${NVIDIA_API_KEY}"
    base_url: "https://integrate.api.nvidia.com/v1"
    max_tokens: 8192

members:
  - id: command-integrity
    display_name: "Command Integrity Reviewer"
    backend: nim-kimi
    priorities: [...]

Executor Config

The executor loads configuration from a YAML file (preferred) or environment variables (fallback). YAML config is validated at startup via Pydantic models — bad regex, invalid ports, or missing fields produce clear error messages.

Loading order: EXECUTOR_CONFIG env var → YAML file → env var overrides for select fields.

Variable Default Purpose
EXECUTOR_CONFIG — Path to executor.yaml (enables YAML mode)
EXECUTOR_PUBLIC_KEY_PATH — Path to council's council.pub (32 bytes, raw Ed25519)
EXECUTOR_COUNCIL_URL — Alternative: fetch key from council MCP (fallback)
EXECUTOR_REDIS_URL redis://localhost:6379/0 Redis for nonce claims
EXECUTOR_REDIS_PREFIX nomos: Redis key namespace (must match council's redis.key_prefix)
EXECUTOR_ALLOWED_DIRS /workspace Colon-separated allowed working directories
EXECUTOR_HOST 127.0.0.1 Listen address (0.0.0.0 in container mode)
EXECUTOR_PORT 9906 in the default OpenShell container; 9902 for legacy Docker Internal listen port
EXECUTOR_OPENSHELL_PORT 9907 Host port published by the default OpenShell Compose profile
EXECUTOR_MAX_TIMEOUT 120 in the default OpenShell profile; 60 for legacy Docker Max command execution time (seconds)
EXECUTOR_LOG_LEVEL INFO Log verbosity
EXECUTOR_PRIVATE_KEY_PATH — Writable executor-owned Ed25519 key for result evidence
EXECUTOR_PUBLIC_KEY_EXPORT_PATH — Optional raw public-key export path for evidence verification
OPENSHELL_ENDPOINT — OpenShell backend URL for executor-openshell
OPENSHELL_REQUEST_TIMEOUT 30 OpenShell backend request timeout
EXECUTOR_UID / EXECUTOR_GID 10001 Container process UID/GID (set via compose user:)

Environment variables override the corresponding YAML values for: EXECUTOR_PUBLIC_KEY_PATH, EXECUTOR_REDIS_URL, EXECUTOR_HOST, EXECUTOR_PORT, EXECUTOR_LOG_LEVEL, executor identity paths, and OpenShell backend settings.

Risk-Based TTLs

Risk Level Writ TTL Set By
low 300 seconds Chairman pre-auth
medium 120 seconds Chairman pre-auth
high 60 seconds Chairman pre-auth
critical 30 seconds Chairman pre-auth

Project Structure

Nomos/
├── nomos/
│   ├── level_0/              # Clock, Crypto, Types (WritOfExecution, Intent)
│   ├── level_1/              # LedgerChain, PolicyEngine
│   ├── level_2/              # CapabilityService, QuorumManager
│   ├── level_3/              # SandboxExecutor, Verifier
│   ├── level_4/              # SecurityCouncil orchestrator
│   ├── auth/                 # Authenticator, Permissions, WritFactory
│   ├── crypto/               # CouncilKeyManager
│   ├── config/               # CouncilSettings, RedisConfig
│   ├── llm/                  # LLM backends (Anthropic, OpenAI, NIM)
│   ├── nat/                  # NAT/MCP integration (functions, plugin)
│   ├── grpc/                 # gRPC proxy server + MCP client
│   ├── opa/                  # OPA policy-filter client
│   └── store/                # Redis + Postgres stores, migrations
├── trusted_executor_contract/ # Shared executor HTTP contract models
│   └── models.py             # Writ, request/response, health, descriptor, protocol
├── executors/
│   ├── common/               # Executor identity + signed evidence helpers
│   ├── docker/               # Local subprocess trusted executor
│   └── openshell/            # OpenShell trusted executor adapter
├── deploy/
│   ├── docker-compose.yml    # Redis + OPA + monitor + Council + executors
│   ├── Dockerfile            # Council container
│   ├── Dockerfile.executor   # Executor sandbox container (kubectl included)
│   ├── Dockerfile.openshell  # Local OpenShell-compatible runtime container
│   ├── dynamic-config/       # Runtime-mounted council catalog/profile copies
│   ├── executor.yaml         # Legacy Docker executor config (restrictive)
│   ├── executor-dev.yaml  # dev executor config
│   └── executor-openshell.yaml  # OpenShell executor config
├── configs/
│   ├── council.yaml          # Council configuration
│   └── council_nat_workflow.yaml  # NAT MCP workflow
├── scripts/
│   ├── agent_accounts.py     # Manage agent accounts in Redis
│   ├── seed_accounts.py      # Seed local demo accounts
│   └── test_live_e2e.py      # Live E2E test script
└── tests/                    # Core package tests

Executor Profiles

The executor uses Docker Compose profiles to support multiple configurations from a single docker-compose.yml. Each profile has its own YAML config, host port, workspace volume, and optionally bind-mounted host directories.

How Profiles Work

docker-compose.yml
├── redis              (always starts, no profile)
├── opa                (always starts, no profile)
├── council            (always starts, no profile)
├── monitor            (always starts, no profile)
├── council-grpc       (always starts, no profile)
├── agent-signup       (always starts, no profile)
├── demo               (always starts, no profile)
├── executor-openshell (profile: executor-openshell, default)
├── openshell          (profile: executor-openshell, isolated runtime)
├── executor-default   (profile: executor-default)
└── executor-dev
                       (profile: executor-dev)

A shared YAML anchor (x-executor-base) defines the security baseline inherited by all executors:

Setting Value
cap_drop ALL
security_opt no-new-privileges:true
read_only true
tmpfs /tmp (noexec, nosuid, 256MB)
user ${EXECUTOR_UID:-10001}:${EXECUTOR_GID:-10001}

Each profile then overrides only what it needs: port mapping, config file mount, volumes, and user identity.

Built-in Profiles

executor-default — Legacy Restrictive Baseline

docker compose -f deploy/docker-compose.yml --profile executor-default up -d
Property Value
Host port 9902
Config deploy/executor.yaml
Timeout 60 seconds
Allowed dirs /workspace
User UID 10001 (executor)
Workspace Docker volume (executor-default-workspace)
Tools Safe read/write/dev set (echo, cat, ls, git, python3, curl, etc.)

executor-dev — Development Executor

DEV_REPO_PATH=/path/to/my-project \
EXECUTOR_UID=$(id -u) EXECUTOR_GID=$(id -g) \
    docker compose -f deploy/docker-compose.yml \
    --profile executor-dev up -d
Property Value
Host port 9903 (direct — network_mode: host)
Config deploy/executor-dev.yaml
Network Host network (required for kind/k8s clusters on 127.0.0.1)
Timeout 120 seconds (longer than the 60s default, for dev builds/tests)
Allowed dirs /workspace, /workspace/projects, /tmp, /workspace/projects/my-project
User Host user's UID/GID (for correct file ownership on bind mounts)
Workspace Docker volume + bind-mounted host repo
Extra tools make, kubectl (on top of the standard executor's whitelist)
Extra mounts DEV_REPO_PATH at /workspace/projects/my-project, ${HOME}/.kube (read-only)
Denied args Same as the standard executor: rm, git, curl, chmod restrictions. kubectl has no per-tool argument restrictions — scope it with the kubeconfig you mount.

Note: network_mode: host means this profile shares the host's network stack. It can reach kind clusters, Redis, and the council on localhost directly. This trades some network isolation for k8s accessibility — appropriate for dev, not production.

executor-openshell — OpenShell Adapter

docker compose -f deploy/docker-compose.yml --profile executor-openshell up -d
Property Value
Host port 9907
Config deploy/executor-openshell.yaml
Backend Buildable openshell runtime service, internal POST /v1/execute
Evidence Executor-owned Ed25519 key in executor-openshell-keys
Runtime network openshell-net, internal-only and shared only with the trusted adapter
Tools Bash, Python 3, and basic local file utilities

Set OPENSHELL_ENDPOINT only when routing the trusted adapter to an external OpenShell deployment instead of the bundled local runtime.

UID/GID Passthrough

When bind-mounting host directories, the executor process must run as your host UID so that files it creates are owned by you — not by some container-internal user. The EXECUTOR_UID and EXECUTOR_GID variables control this:

# Default OpenShell profile: adapter runs as UID 10001 and runtime as UID 10002
docker compose -f deploy/docker-compose.yml --profile executor-openshell up -d

# Dev profile: runs as YOUR user so bind-mounted files are owned correctly
EXECUTOR_UID=$(id -u) EXECUTOR_GID=$(id -g) \
    docker compose -f deploy/docker-compose.yml --profile executor-dev up -d

The default profile defaults to UID 10001 (no host mounts needed). Dev profiles default to UID 1000 but should always be started with explicit EXECUTOR_UID/EXECUTOR_GID.


Executor YAML Configuration

Each executor profile is driven by a YAML file mounted into the container at /app/config/executor.yaml. The file is validated at startup by Pydantic models (executors/docker/config_schema.py) — invalid regex, bad ports, relative paths, or extra keys all produce clear error messages.

Default OpenShell Profile Shape

# ── Executor server ────────────────────────────────────
executor:
  listen_host: "0.0.0.0"           # bind address
  listen_port: 9906                 # internal OpenShell adapter port; host defaults to 9907
  log_level: "INFO"                 # DEBUG, INFO, WARNING, ERROR
  max_command_timeout: 120          # hard limit per command (seconds)
  identity_private_key_path: "/app/executor-keys/executor.key"
  identity_public_key_path: "/app/executor-keys/executor.pub"

# ── Council connection ─────────────────────────────────
council:
  public_key_path: "/app/public-keys/council.pub"  # Ed25519 public key
  url: "http://council:9901"                # fallback: fetch key via HTTP
  key_fetch_retries: 10
  key_fetch_delay: 3                        # seconds between retries

# ── Redis ──────────────────────────────────────────────
redis:
  url: "redis://redis:6379/0"
  key_prefix: "nomos:"               # must match council's prefix

# ── Filesystem access ──────────────────────────────────
filesystem:
  allowed_working_dirs:            # ONLY these dirs can be working_directory
    - /workspace
  denied_paths:                    # substring match — always rejected
    - /etc/shadow
    - .ssh
    - .aws

# ── Tool whitelist ─────────────────────────────────────
tools:
  allowed:                         # empty list = allow all (not recommended)
    - echo
    - cat
    - git
    - python3
  denied_args:                     # per-tool regex restrictions
    rm:
      - '-rf\s+/\s*$'
    git:
      - 'push\s+--force'

# ── Security ───────────────────────────────────────────
security:
  blocked_patterns:                # extra regex patterns to block
    - 'curl\s+.*--upload-file'
    - '/dev/tcp/'

Security Layers (Defense in Depth)

The executor enforces four independent policy layers, each configurable per-profile:

Layer Config Key How It Works Recommended defaults (shipped in executor.yaml / executor-dev.yaml)
Blocked patterns security.blocked_patterns Regex match against full command string Fork bombs, sudo, su, pipe-to-shell, raw device access, mkfs, netcat, http.server, rm --no-preserve-root, chmod 777
Tool whitelist tools.allowed Only listed binaries can execute (basename match) Safe read/write/dev set
Denied tool args tools.denied_args Per-tool regex against full command string rm -rf /, git push --force, git reset --hard, curl PUT/DELETE, chmod 777
Denied paths filesystem.denied_paths Substring match against full command string /etc/shadow, /etc/passwd, /root, /proc, /sys, .ssh, .gnupg, .aws (plus .kube/config in the production profile)

These four policy layers carry no built-in rules — they are entirely YAML-driven, and each collection defaults to empty (executors/docker/config.py; general executor settings such as ports and timeouts still have code-level defaults, but the security policy does not). The protections above are the recommended set shipped in the deploy/executor.yaml and executor-dev.yaml profiles (the OpenShell profile ships a lighter set); and because nothing is merged in, editing a profile YAML can weaken or remove them — for example, an empty tools.allowed disables the whitelist and allows every tool. Keep the shipped defaults unless you have a specific reason to change them, and review policy edits carefully.

Blocked Patterns (Recommended Set)

Both built-in profiles ship these patterns in their YAML:

security:
  blocked_patterns:
    # credential / secret exfiltration
    - 'env\s.*(?:SECRET|TOKEN|KEY|PASS)'
    - 'printenv\s.*(?:SECRET|TOKEN|KEY|PASS)'
    - 'base64.*(?:\.pem|\.key|\.crt)'
    # network exfiltration
    - 'curl\s+.*-d\s+@'
    - 'curl\s+.*--upload-file'
    - 'wget\s+--post-file'
    # reverse shells
    - '/dev/tcp/'
    - 'bash\s+-i\s+>'
    - 'exec\s+\d+<>/dev/tcp'
    # history / credential file reads
    - 'cat.*\.bash_history'
    - 'cat.*\.env'
    # package supply-chain
    - 'pip\s+install\s+--index-url'
    - 'npm\s+config\s+set\s+registry'

Creating a Custom Writ Executor

To create an executor profile for your own project, you need three things:

  1. A YAML config file — defines the security policy
  2. A service entry in docker-compose.yml — wires up ports, volumes, and identity
  3. Optionally: Dockerfile changes — if you need additional system packages

Step 1: Create Your Config YAML

Copy the default config and customize it:

cp deploy/executor.yaml deploy/executor-my-project.yaml

Edit the key sections:

executor:
  max_command_timeout: 180          # adjust for your workload

filesystem:
  allowed_working_dirs:
    - /workspace
    - /home/youruser/Development/my-project   # bind-mounted host repo
  denied_paths:
    - /etc/shadow
    - /etc/passwd
    - .ssh
    - .gnupg
    - .aws

tools:
  allowed:
    # start with the minimum set you need
    - echo
    - cat
    - ls
    - grep
    - find
    - mkdir
    - cp
    - rm
    - touch
    - python3
    - pip
    - git
    - sh
    # add project-specific tools:
    - npm
    - node
    - kubectl
  denied_args:
    rm:
      - '-rf\s+/\s*$'
      - '--no-preserve-root'
    git:
      - 'push\s+--force'
      - 'reset\s+--hard'

security:
  blocked_patterns:
    - 'env\s.*(?:SECRET|TOKEN|KEY|PASS)'
    - '/dev/tcp/'
    - 'bash\s+-i\s+>'
    # add project-specific patterns as needed

Step 2: Add a Compose Service

Add a new service to deploy/docker-compose.yml that inherits from the shared base:

  # ── Executor: My Project (dev) ────────────────────────────
  executor-my-project:
    <<: *executor-base
    profiles: ["executor-my-project"]
    user: "${EXECUTOR_UID:-1000}:${EXECUTOR_GID:-1000}"   # host user for bind mounts
    ports:
      - "9909:9902"                                        # unique host port; 9905 is agent-signup
    environment:
      - EXECUTOR_PUBLIC_KEY_PATH=/app/public-keys/council.pub
      - EXECUTOR_REDIS_URL=redis://redis:6379/0
      - EXECUTOR_HOST=0.0.0.0
      - EXECUTOR_PORT=9902
      - EXECUTOR_CONTAINER_MODE=1
      - EXECUTOR_CONFIG=/app/config/executor.yaml
    volumes:
      - council-public-keys:/app/public-keys:ro
      - ./executor-my-project.yaml:/app/config/executor.yaml:ro
      - executor-my-project-workspace:/workspace
      # Bind-mount your host repo (same path inside and outside)
      - /home/youruser/Development/my-project:/home/youruser/Development/my-project

Add the workspace volume:

volumes:
  # ... existing volumes ...
  executor-my-project-workspace:
    driver: local

Step 3: Mount Credentials (Optional)

If your project needs kubectl, cloud CLI, or other credential-based tools:

    environment:
      # ... existing env vars ...
      - KUBECONFIG=/home/executor/.kube/config
      - AWS_CONFIG_FILE=/home/executor/.aws/config
    volumes:
      # ... existing volumes ...
      - ${HOME}/.kube:/home/executor/.kube:ro          # always read-only!
      - ${HOME}/.aws:/home/executor/.aws:ro

Important: Always mount credentials as :ro (read-only). Remove the corresponding entry from denied_paths in your YAML config if you intentionally mount it (e.g., remove .kube/config if you mount ~/.kube).

Step 4: Add System Packages (Optional)

If your project needs packages not in the base image, you have two options:

Option A: Modify Dockerfile.executor (shared by all profiles)

# Add to the apt-get install block:
RUN apt-get update && apt-get install -y --no-install-recommends \
    your-package \
    && rm -rf /var/lib/apt/lists/*

Option B: Use a separate Dockerfile (profile-specific)

  executor-my-project:
    build:
      context: ..
      dockerfile: deploy/Dockerfile.executor.my-project   # custom Dockerfile
    # ... rest of config ...

Step 5: Start and Test

# Start your profile
EXECUTOR_UID=$(id -u) EXECUTOR_GID=$(id -g) \
    docker compose -f deploy/docker-compose.yml \
    --profile executor-my-project up -d --build

# Verify health
curl -s http://127.0.0.1:9909/v1/health | python3 -m json.tool

# Run E2E tests against your profile
uv run python scripts/test_live_e2e.py 9909 deploy-executor-my-project-1

Path Matching Strategy

When agents formulate commands, the working_directory in the writ must match an entry in allowed_working_dirs. For bind-mounted host directories, use the same path inside and outside the container so agents don't need to translate paths:

# In your executor YAML:
filesystem:
  allowed_working_dirs:
    - /home/youruser/Development/my-project

# In docker-compose.yml volumes:
volumes:
  - /home/youruser/Development/my-project:/home/youruser/Development/my-project

This way, when the agent says working_directory: /home/youruser/Development/my-project, the executor sees the same path and the files are the same.

Checklist for New Profiles

  • YAML config file created in deploy/
  • allowed_working_dirs includes all directories the agent needs
  • tools.allowed is minimal — only what the project requires
  • denied_paths excludes sensitive files (credentials, system config)
  • security.blocked_patterns is populated (not empty)
  • Compose service added with unique host port
  • user: set to ${EXECUTOR_UID}:${EXECUTOR_GID} if using bind mounts
  • Credential mounts are :ro
  • Workspace volume declared in volumes: section
  • Health check verified: curl http://localhost:<port>/v1/health

Client Integration

Nomos is designed to be consumed by any LLM-based IDE agent (Cascade/Windsurf, Cursor, Copilot, etc.) or autonomous agent that can make HTTP/MCP calls. The reference integration is with Windsurf Cascade.

Integration Pattern

The recommended integration uses a thin CLI wrapper script that the IDE agent calls via its run_command facility:

IDE Agent  →  run_command("scripts/nomos_execute.py --command '...' ...")
                    │
                    ├─ 1. Self-check: reads /proc/{ppid}/cmdline, rejects shell injection
                    ├─ 2. Calls council_authorize via MCP → gets signed writ
                    └─ 3. POSTs writ to executor /v1/writs/execute → returns {stdout, stderr, exit_code}

The CLI wrapper (nomos_execute.py) lives in the client project, not in Nomos. It reads connection details from ~/.config/nomos/<agent>.json:

{
  "auth_token": "<agent token from agent_accounts.py>",
  "agent_id": "cascade",
  "council_url": "http://localhost:9901/mcp",
  "executor_url": "http://localhost:9903"
}

IDE Safety Layers

When integrated with an IDE agent, the full protection stack is:

Layer Where What It Does
IDE rule Agent config Instructs the agent to route all commands through the CLI wrapper
Allowlist/Denylist IDE settings Auto-runs wrapper calls; denies shell operators (&&, |, ;)
Script self-check CLI wrapper Reads parent process cmdline, rejects shell injection (exit 99)
Council Nomos LLM-backed council evaluates and votes on the command
Writ executor Nomos Cryptographic verification + sandboxed execution
Executor profile Nomos Tool whitelist, denied args, denied paths, blocked patterns

Shell Injection Prevention

The CLI wrapper supports two command input modes to prevent shell metacharacter injection through the IDE's allowlist:

  • --command "kubectl get pods" — direct text, safe for commands without shell operators
  • --command-b64 "a3ViZWN0bC..." — base64-encoded, required when the command contains >, |, &&, ;
  • --encode "/bin/echo hello > /tmp/test" — utility mode that prints the base64 encoding and exits

The wrapper also performs a runtime self-check by reading /proc/{ppid}/cmdline to detect if it was invoked with chained shell operators (e.g., scripts/nomos_execute.py ... && rm -rf /). If detected, it exits with code 99 before doing anything.


Contributing

This project is currently not accepting contributions. See MAINTAINERS.md for the project's maintenance and release process.


License

Apache License 2.0. See LICENSE.

This project will download and install additional third-party open source software projects. Review the license terms of these open source projects before use. See LICENSE-THIRD-PARTY for details.


Acknowledgments

  • Architecture inspired by John Lakos's work on large-scale C++ software design
  • Built for integration with NVIDIA NeMo Agent Toolkit
  • MCP protocol support for universal agent compatibility

About

Stops AI agents from taking unwanted or overeager actions. Nomos reviews each proposed action against the agent's own context (its permissions, task, justification, blast radius and signed receipts of what it has observed) using OPA policy and an LLM council. Approved actions get a signed, single-use Writ; the executor runs exactly that.

Resources

Contributing

Security policy

Stars

14 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages