Skip to content

Security and Access

Archi edited this page Oct 20, 2026 · 2 revisions

Security and Access

Nautelle can start agents on machines that hold your source code, GitHub credentials and paid model subscriptions — so those machines are the asset it protects. This page explains who can do what, how accounts work, what the audit log guarantees, why a compromised server still cannot do whatever it wants on a runner, and exactly what data leaves your machines.

flowchart TB
  user["Person<br/>(cookie session, CSRF)"] -->|"role × project membership"| api["API"]
  api -->|"typed command allowlist<br/>(ADR-0010)"| runner["Runner"]
  runner -->|"local policy check<br/>(ADR-0047)"| exec["tmux · git · gh · claude"]
  api --> audit[("Audit log<br/>hash-chained, append-only")]
  subgraph machine["Your machine"]
    runner
    exec
    creds["Claude / Codex / gh credentials<br/>never leave the machine"]
  end
Loading

Threat model, in brief

  • A stolen user session or runner token turning into code execution on runners.
  • A malicious skill from a public registry.
  • A forged inbound webhook triggering runs.
  • A teammate spending the owner's subscription.

Accounts

Nautelle uses local accounts: email and password (argon2id), with a server-side cookie session and a CSRF token on every state-changing request.

  1. The first admin is created from the command line — the seed creates no users:

    bun run admin:create

    It prompts, or reads ADMIN_EMAIL and ADMIN_PASSWORD from the environment (set both, run, then remove them). In a container, see Self-Hosting.

  2. Registration is closed by default. An admin opens it under Admin › Users.

  3. A new account waits as pending until an admin approves it and assigns a role in one action — or rejects it.

Changing your password revokes your other sessions.

Failed passwords

Failed sign-ins slow the source down; they never lock the account. Failures are counted per (email, IP): after 5 in a row that source waits 1 second, doubling with each further failure. Anyone signing in from another address gets through at once, so a stranger cannot lock you out. When an account first crosses the threshold, every admin is notified, and refused sign-ins are written to the audit log.

Roles

Every request and every runner command is checked against the person's global role and their project membership.

Role Can
Admin everything: users and registration, runners, integrations, prices, budgets, the audit log, terminal attach, profile-wide skill installs, AI providers
Operator steer the projects they are a member of: run cards and skills, control the orchestrator, approve pull requests and action requests, create issues and schedules
Viewer read the projects they are a member of, including session content and action-request input

A project you are not a member of answers 404, as if it did not exist. Members are managed under Project › Settings › Members.

Audit log

Every privileged action — sign-ins, user and role changes, runner commands, approvals, merges, budget overrides, schedule and webhook changes, terminal attaches, AI provider key changes — is written to an append-only audit log with the state before and after.

  • Hash-chained. Each record stores sha256(previous hash ‖ record), so altering or removing a record breaks the chain.
  • Append-only in the database. A trigger rejects UPDATE, DELETE and TRUNCATE on the table.
  • Verified nightly (03:00 server time by default, AUDIT_VERIFY_CRON), and on demand.
  • Secrets are redacted before hashing: passwords, tokens, codes and secrets never enter a record.
  • Admin › Audit lists, filters and verifies records and exports them as CSV.

The runner trust model

The runner is the only party that can refuse what the server asks, and it does so in two layers.

  1. A typed command allowlist. The runner accepts only named commands with strict argument schemas — never a shell string. Paths are confined to registered project roots and their worktrees. Runners advertise which commands they support.
  2. A local security policy the server cannot change. It lives in the runner's own config, and only nautelle-runner policy on the machine writes it. It decides which directories may hold projects, the highest permission mode any launch may use (auto by default), and whether terminal typing, profile-wide skill installs and messages into running sessions are allowed (all off by default). A refusal names the rule and the exact command that lifts it. See Installing the Runner.

Other protections:

  • Runner tokens are 256-bit, issued once from a pairing code, stored hashed, revocable and never logged. Plain http:// is refused except for loopback, unless you explicitly pair with --insecure.
  • Local hook and MCP calls reach the daemon only through a 0600 unix socket and carry a per-session capability token.
  • Releases are signed with Ed25519. Homebrew and the npm shim verify the signature before installing or running the binary, and release secrets reach only protected tags.
  • Text the runner sends — pane frames, logs, reports, PR bodies, hook content — passes one redactor for tokens, keys and credentials. It is pattern-based and best-effort.

Important

The local policy bounds the blast radius; it is not a sandbox. Below the permission cap, an agent in auto still does what its prompt asks within Claude Code's own permission checks.

Remote approvals

With away mode on, Claude Code permission prompts become action requests in the web app and Telegram. Each one carries a redacted preview of the tool input (at most 1,000 characters, with a hash of the full input) so you know what you approve. Deciding needs the operator role; every decision is audited. An issue a session files for the fleet waits until a person approves queuing it.

What leaves your machines

Data Where it goes When
Session metadata (names, ids, timing, token counts) your Nautelle API always
Session content (prompts, replies, tool I/O) your Nautelle API, redacted on the runner only for projects where an admin turned it on
Agent credentials (Claude, Codex, gh) nowhere never
Stored text people save (titles, bodies, feedback) DeepL only when DEEPL_API_KEY is set; otherwise a small model on the runner normalizes it
Assistant conversations and the data its tools read the AI provider an admin configured (Anthropic, OpenAI, xAI, OpenRouter) only when the assistant is used
Telegram voice messages the configured speech-to-text service only when it is configured; audio is never stored

The control plane never reads a project's filesystem; the assistant's provider sees only what the API already stores or what you typed. Provider keys are sealed with AES-256-GCM under LLM_SECRETS_KEY; webhook secrets, the Telegram bot token and the GitHub App's secrets under APP_ENCRYPTION_KEY. Keep both keys out of database backups — either alone is useless.

CI pipelines

Pull-request pipelines are untrusted: anyone who can push a branch, a card worker included, can open one. Steps that run on a pull request get no Docker socket, no host volume, no secret and no privileged container. See Contributing.

See also

Decisions: ADR-0008 · ADR-0010 · ADR-0021 · ADR-0033 · ADR-0047 · ADR-0048 · ADR-0049 · ADR-0052 · ADR-0053 · ADR-0059 · Spec: #3, #8

Clone this wiki locally