Skip to content

Security boundaries

Mjolnir gives coding agents the ability to execute commands. Its security model is therefore a set of explicit boundaries, not a promise that agent-generated commands are harmless.

Bare runtimes preserve approvals where the harness supports them; isolated runtimes run unrestricted. A disposable boundary limits damage to the runtime, but it does not prevent the agent or its model provider from reading data and credentials intentionally placed inside that boundary.

Your agent’s own traffic goes to its model provider under your account, as it would without Mjolnir. Apart from that, a default installation sends the following.

FeatureWhat is sentWhere it goesHow to turn it off
Jev turn classifierAfter a minute of silence in a turn, and after a reply ends: up to 1 KiB of your latest prompt, up to 2 KiB of the latest assistant text, a size-limited transcript summary, tool titles (up to 128 bytes each), the harness name, and counts of background and queued commands.The public Cloudflare proxy mj-jev-proxy.eng-admin-a63.workers.dev, which forwards it to TypeSafe (api.typesafe.ai). With TYPESAFE_API_KEY set, or a key in ~/.secrets/typesafe_api_key, it goes straight to api.typesafe.ai with your key.[jev] enabled = false, or Settings → Privacy → Jev (hosted service). A blank key does not disable it; it falls back to the proxy.
Automatic continuationWhen a reply ends and the agent may have stopped early: your messages since the last context reset and recent assistant replies. Tool history is not sent.The same proxy (/v2/continuation-verdict), or api.typesafe.ai with your key.[continuation] enabled = false or [jev] enabled = false in config.toml, or clear Enabled under Settings → Continuation.
Semantic help searchWhile you type in the help filter, 200 ms after the last key: the filter text and the text of every help row.The same proxy (/v1/help-search), or api.typesafe.ai with your key.[jev] enabled = false. It runs only while the help filter has text.
Update checkThe installed version’s channel is asked for the latest release. No session content is sent.GitHub Releases, the npm registry, or the Homebrew tap on GitHub.Set MJOLNIR_NO_UPDATE_CHECK.

One switch stops every Jev request: set enabled = false under [jev] in config.toml, or clear Settings → Privacy → Jev (hosted service). Then nothing in the first three rows leaves the machine from the dashboard or from sessions started afterwards. Workers read the switch when they start, so a session that was already running keeps sending turn text until you resume or restart it. What you give up: a turn ends only when the harness ends it, so a turn that goes quiet while the agent waits for you stays Working until the harness reports the end of the turn; automatic continuation does not run; and help search matches text only. See Configuration.

The proxy source is in services/jev-proxy/. It does not write request bodies to logs, and it limits requests per client IP address. Mjolnir keeps a local copy of each Jev request in jev-decisions/ under its data directory; see Session lifecycle.

Execution policy is selected by the runtime

Section titled “Execution policy is selected by the runtime”
RuntimePolicy
bare on the local machinePreserve the selected profile and harness’s configured approvals
bare on an ssh machine with permissions = "guardian"Preserve configured approvals
bare on an ssh machine with permissions = "yolo"Force unconstrained execution
podman, docker, apple-container, and bare on an aws-ec2 machineForce unconstrained execution

Mjolnir translates the unconstrained policy into the selected harness’s own controls:

HarnessUnconstrained enforcement
Codexagent-full-access
Claude CodebypassPermissions with its sandbox disabled
Kimi Codeauto
Grok Buildalways approve with its sandbox disabled
Muse Codethe :ask-me permission profile in its staged settings, allowAll approvals with auto-review off, and --disable-sandbox (no permission checks; set muse_auto_review = true on the profile to restore the reviewer, which can still deny)
OpenCode"permission": "allow" in the staged opencode.json
DSHdsh-acp’s permission_mode allow, selected on every session open

Kimi’s mode is named auto, but in this context it approves every call. It is not a low-risk guardian policy.

Codex, Claude Code, Grok Build, and Muse Code can preserve guardian-style approvals on a bare runtime. Muse’s guardian is muse-acp’s auto-review: Mjolnir stages the :ask-me permission profile, selects promptUnmatched approvals, and turns auto-review on, so a read-only Muse reviewer answers each approval and a failed review denies the action. In containers Muse instead runs without permission checks (allowAll approvals with auto-review off) and disables only the sandbox there; a profile with muse_auto_review = true keeps the review in containers too. With guardian approvals the sandbox stays on, even when a profile’s MUSE_SERVE_ARGS passes --disable-sandbox. Kimi Code cannot preserve approvals. OpenCode asks before tools according to its permission rules, but runs every command directly on the host: the project states that its permission system is not a sandbox. DSH under configured approvals selects dsh-acp’s permission_mode ask, so every write, command, and MCP tool call asks first, but it has no sandbox either: commands run directly on the host, and only its file tools are confined to the workspace roots. The dashboard’s new-session and resume wizards warn when a harness without guardian support is paired with a raw target; mj new, the API, and the web viewer do not. A warning is not a sandbox either way—choose a container or instance instead.

Mjolnir does not expose arbitrary extra container-runtime arguments. Container names, ownership labels, capabilities, and generated mount modes come from Mjolnir and the selected runtime rather than from config.toml. A chosen image and runtime must therefore already provide any capability their workload or mount contract requires. See Container targets and the Configuration reference.

Decide what belongs inside the trust boundary

Section titled “Decide what belongs inside the trust boundary”

The controller host is trusted with the canonical configuration, session database, recovery archives, profile credentials, and cookie-signing key. A live target is trusted with:

  • the repositories and uncommitted work placed in its workspace;
  • the allowlisted profile files needed to run the selected harness;
  • live harness and, on non-local sessions, GitHub credentials;
  • any extra directories or images attached to the session; and
  • prompt text, tool output, and conversation state delivered to the harness.

Anyone who controls the target at a sufficiently privileged level can inspect those live copies. Isolation protects the rest of the controller host; it does not make copied secrets unreadable inside the target. Likewise, provider-side handling of prompts and repository content is governed by the selected harness and account, not by Mjolnir.

Mjolnir stores what agents print as they printed it. Transcripts, checkpoints, and worker diagnostics keep any secret that appears in agent output, for example after an agent runs env or reads a token file, and the dashboard and web viewer show it. The evidence sent to the Jev classifier, and its local copy in jev-decisions/, first has every unbroken run of 24 or more letters, digits, -, _, ., or + replaced. That catches many API keys and tokens but not every credential: an AWS secret key containing /, or a short access key ID, passes through. A secret an agent prints can therefore still reach the hosted classifier; turn Jev off, as described above, to stop sending turn text to it.

Sessions are not isolated from each other’s agents

Section titled “Sessions are not isolated from each other’s agents”

Mjolnir does not keep one session’s agent away from another session’s data. Through Mjolnir’s own tools, an agent in any session, including one in a container or on a remote target, can copy files from other sessions’ workspaces (the mj-agents fetch tool, in sessions with delegation tools enabled) and search and read the controller’s indexed conversations, including other projects’ (the mj-memory history tools). Treat every agent you run as trusted with all of your sessions’ workspaces and conversations.

Agents on the controller host can do more. The daemon’s API under /api/v1 accepts one bearer token, kept in the owner-only api-token file in Mjolnir’s data directory; mj api and mj github-token read it from there. Any process that runs as your user on the controller host can read that file, and that includes agents in bare local sessions. With the token, a process can also write any session’s workspace files, read any transcript, change a session’s settings such as its model, destroy sessions, and mint GitHub App installation tokens for any repository the App can reach.

Mjolnir does not scope this token to one session. An agent in a bare local session already runs with your account’s authority: it can read your files, your credentials, and Mjolnir’s data directory directly, so a narrower API token would not contain it. Each worker’s control socket follows the same rule: any process running as the same user on the worker’s machine can connect to it and use every method it serves. No /api/v1 route edits Mjolnir’s configuration: profiles, targets, and the other settings in config.toml change only through the dashboard, the cookie-authenticated web viewer settings, or the file itself.

Sessions in containers and on remote targets are not given the api-token file, but do not rely on that as a boundary. The fetch tool can copy any file inside another local session’s workspace, sub-agent reports, or temporary directory, and Mjolnir’s data directory can lie inside one of them: for example when a bare local session works in your home directory, or when MJ_DATA_DIR points under /tmp. An agent that copies the token and can reach the daemon’s API gains everything listed above. Treat every agent you run, in any runtime, as trusted with your sessions and with what the token allows.

Mjolnir never copies a harness home wholesale. It builds a per-session profile home from a harness-specific allowlist:

HarnessCategories staged into a session
CodexAuthentication, config, instructions, rules, and skills
Claude CodeAuthentication and account config, settings, CLAUDE.md, skills, and plugins
Kimi CodeAuthentication, config, device ID, instructions, MCP config, skills, agents, and plugins
Grok BuildAuthentication, config, agent ID, instructions, skills, and plugins
Muse CodeAuthentication, settings, trust configuration, instructions, skills, and rules
OpenCodeAuthentication, configuration, instructions, and skills
DSHInstructions and skills (which DSH does not read); its API key reaches the session through the environment, not a file

Symbolic links inside an allowlisted profile entry are followed: the session receives the contents of the file or directory a link points to, even when the target is outside the harness home. Check where your profile’s links point before you use it with a target. A link whose target is missing is skipped. Files outside the allowlist—such as general shell state, unrelated cloud credentials, and arbitrary caches—do not enter the session merely because they live beneath your home directory.

The staged skills tree also carries the Mjolnir-authored mj skill. It does not carry the skills a harness writes for itself: the skills Claude Code syncs from your claude.ai account (skills/synced/), Claude Code’s skills/.trash/, and Codex’s built-in skills (skills/.system/). The harness manages those itself. The mj-memory MCP history tools can read the controller’s indexed session corpus, including conversations from other projects. Historical conversations are reference data, not instructions for the current session.

The staged profile is still active configuration. Instructions, plugins, skills, and MCP settings can execute code or direct an agent to external services. Audit them as part of the selected profile’s trust boundary. See Profiles and harnesses for profile setup.

The profile on the controller is the canonical copy. While sessions are live, Mjolnir reconciles rotating harness credentials between that profile and its session copies about once a minute. Skills are synchronized from the canonical profile into live sessions on the same cadence.

Credential bytes travel only in private controller-to-worker request and response frames. They are structurally excluded from the durable event journal and recovery archive. Non-secret fingerprints and freshness timestamps may appear in diagnostics.

If the controller’s gh CLI is authenticated, Mjolnir also pushes its active GitHub token into every live non-local session, including raw SSH targets. The token is not included in recovery archives. Its effective authority is still the authority granted to that GitHub account, so a remote target receiving it belongs inside the token’s trust boundary. Raw local sessions do not receive this synchronized GitHub token.

AWS credentials for an Amazon Bedrock profile (one that authenticates with the AWS credential chain) are delivered to container and SSH sessions the same way. What a session receives depends on the profile:

  • With no [profiles.<id>.aws] table, a session receives a copy of the controller host’s own AWS credentials. Anything running in the session can use everything those credentials allow, for as long as they last. mj doctor warns about this.
  • With [profiles.<id>.aws] role_arn = ..., the controller uses the host’s AWS login to assume that IAM role for each session (AWS AssumeRole), and the session receives only the resulting temporary credentials. They are limited to the role intersected with a session policy (by default, Bedrock invocation only, or the policy you set), they last an hour unless you set duration_seconds, and the controller renews them after half of that time. The session id is the role session name, so AWS CloudTrail attributes every call to its session; source_identity = true also records it as the immutable SourceIdentity. The host’s own credentials never leave the controller. If AWS refuses the role, a launch fails with AWS’s error and the fix. A running session keeps the scoped credentials Mjolnir issued for it until they expire while the sync reports the failure, but a session that still holds the host’s own credentials (installed before the role was configured) has them withdrawn, because static host keys never expire. The worker records beside the secret which role, session, policy and SourceIdentity Mjolnir issued credentials for, authenticated with a key derived from the controller’s private signing key that a session cannot compute, so a record a session wrote itself counts for nothing; so after a daemon restart Mjolnir still keeps what it issued and withdraws everything else. A session on a worker too old to withdraw is moved to a current worker automatically when it is idle, and withdrawn then. Mjolnir never falls back to the host’s credentials, and the session’s other synchronization (GitHub token, skills, logins) continues. See Limiting the AWS credentials sessions receive.

This applies only to sessions that receive synced credentials. A raw local session reads the host’s AWS files directly, an EC2 session uses its instance role (Mjolnir requires IMDSv2 on every instance it launches; see AWS EC2 machines), explicit AWS keys in a profile or target environment stay as configured, and a Bedrock API key (AWS_BEARER_TOKEN_BEDROCK) is not covered. Scoped credentials bound what stolen credentials can do and for how long; they do not stop code in the session from using them while they are valid.

The slopcop login travels the same way. When the machine running Mjolnir is signed in to slopcop (SLOPCOP_TOKEN, or the login slopcop login stored), Mjolnir keeps a copy of that token, and of a non-default service URL, in every live non-local session. The session’s slopcop command exports it on each run. Checks run from a session bill the workspace that login belongs to. Raw local sessions read the host’s own login instead.

Rotating OAuth grants can race when several live copies refresh at once. Mjolnir refreshes Codex credentials ahead of expiry and distributes the newer copy. For Claude Code, use a long-lived setup token when running concurrent or unattended sessions:

Terminal window
mj login --profile <claude-profile-id> --setup-token

That token is stored in Mjolnir’s private controller data, outside the Claude profile home, and is passed to new and resumed Claude sessions as CLAUDE_CODE_OAUTH_TOKEN. It authorizes model requests; it does not enable Claude Remote Control or claude.ai connectors.

By default, Mjolnir’s own SSH connections accept a host key they have never seen, record it in known_hosts, and refuse a key that later changes (StrictHostKeyChecking=accept-new). That covers its connections to SSH machines and EC2 instances, whose key is always new at first contact, and the Git fetches it runs for its clone cache and checkpoint checks, whether those run on the controller or on an SSH machine that hosts a target’s containers. Someone who can intercept a first connection can present their own key, and it is trusted from then on. Other Git commands that run on a target follow that target’s own SSH settings. A StrictHostKeyChecking option in a machine’s extra_args (or an EC2 machine’s ssh_args) changes the checking for Mjolnir’s connections to that machine. Setting GIT_SSH_COMMAND does not reliably change it for Mjolnir’s Git fetches: some of them set their own command.

A key that is already in known_hosts is checked, not trusted anew, unless an SSH option you set turns checking off. To avoid first-use trust:

  • Before the first connection, add the verified key of each Git host and each machine to the known_hosts file of the account Mjolnir uses, on every machine involved: the controller and each SSH machine you use as a target. Take the expected fingerprint from a source you trust, such as the SSH key fingerprints your Git host publishes, and compare it with the key you add. ssh-keyscan only reports what the network presents, so it is not a check by itself.
  • To refuse unknown keys outright on an SSH machine, add "-o", "StrictHostKeyChecking=yes" to its extra_args; Mjolnir puts extra_args first, so it wins. EC2 machines take the same option in ssh_args. This covers Mjolnir’s connection to the machine, not Git.

Isolated sessions use independent network clones

Section titled “Isolated sessions use independent network clones”

A bundle repository declared with local = "/absolute/path" supplies its configured default network fetch and push destinations. Mjolnir clones the fetch remote’s default branch into the target and starts on that branch. The target has no Git connection back to the controller checkout.

Local unpublished commits and staged, unstaged, or untracked files are not copied. Normal Git pushes go to the configured network push destination(s). Suspending saves a checkpoint without publishing a branch into the host repository. Only raw local sessions support repositories without network remotes. See Workspaces and bundles.

An additional directory selected in the new-session or resume flow makes that directory’s contents readable by the agent. Do not attach a parent directory when the agent only needs one child, and never use a read-only choice as a confidentiality control—it prevents writes, not reads.

Podman, Docker, and Podman-over-SSH normally present writable attachments through a copy-on-write overlay. Agent writes go to session-owned storage and do not modify the source directory. A source on a known-incompatible filesystem such as NFS, SMB, FUSE, FAT, or another overlay is downgraded to read-only and the launch reports it. Apple containers use read-only attachments. EC2 receives a copied resource directory rather than a host mount. Bare local and bare SSH targets do not accept additional directories.

Attachment storage is not the recovery boundary. Put durable results under the session’s project workspace or push them to a repository before stopping. See Container targets for runtime-specific behavior.

The web viewer can also attach images to a prompt when the harness advertises image support. Unsent images remain only in that browser’s memory; stored drafts retain text, not images. Once sent, the image is delivered to the controller and the selected harness as prompt content. Treat it exactly like any other data disclosed to the agent and model provider.

The personal web viewer is enabled by default. mj daemon status prints its URL and a six-digit login code. The code is exchanged for a signed session cookie; protected snapshot, transcript, draft, and action APIs all require a valid cookie. Cookies are HTTP-only and same-site, are marked secure under HTTPS, and expire after 30 days without authenticated requests by default. Authenticated requests renew new phone-login cookies while preserving the viewer identity. Desktop bootstrap cookies and cookies issued by older builds retain their original expiry; sign in again to opt an older phone cookie into renewal.

The six-digit code is intentionally convenient rather than high entropy. Five wrong codes lock the login endpoint. Repeated lockouts back off from 30 seconds to a maximum of one hour, and a correct code clears the failure history. Do not publish the code or an authenticated browser session.

The QR login URL contains a strong secret derived from the cookie-signing key in Mjolnir’s private data directory. It stays valid across daemon restarts. Save the original QR login URL as a private bookmark if you want to sign in again after the browser clears its cookies; the redirected / URL does not contain that credential. The six-digit code still changes at restart.

Anyone who captures the QR or its login URL can sign in until the key is rotated. Signing out revokes that viewer identity and clears its cookie, but does not revoke the QR URL or sign other viewers out. Revocations persist in phone-cookie-revocations.json beside the signing key, preventing delayed responses from restoring access even after a daemon restart. If saving logout fails, the server reports an error; retry logout once storage is writable. Keep this file with the signing key; an unreadable or corrupt file prevents viewer startup rather than accepting revoked cookies.

To revoke all cookies and saved login URLs, stop the daemon, remove phone-cookie-key from its instance data directory, then start it again. Keep the same durable data directory across restarts to preserve access.

Cookie rejection diagnostics at debug level distinguish an absent, malformed, expired, revoked, or incorrectly signed cookie without recording credentials. An absent cookie may mean the browser evicted it; a bad signature may indicate key rotation. Mjolnir has one personal viewer trust domain; it does not provide per-user roles or session-level authorization.

Without trusted TLS, the server remains on loopback. With automatic Tailscale detection, Mjolnir exposes the viewer on the tailnet only after it obtains a trusted ts.net certificate. Explicit non-loopback service requires both a TLS certificate and key. Mjolnir refuses a non-loopback plaintext listener.

See Web viewer and desktop app for setup and Troubleshooting for connection and login failures.