Security and trust posture
What Lorenz trusts, what it isolates, and what it leaves to you. This page is for operators and evaluators deciding whether Lorenz fits a given threat model. The honest summary: Lorenz is a high-trust harness. The workflow file is executable code, the agent runs with broad host access, and several layers assume the inputs reaching them are not adversarial. The SPEC requires every implementation to document this posture explicitly, and this page is that document.
The trust boundary
The orchestrator is a scheduler and tracker reader, not a policy sandbox. Three things execute with the same authority as the daemon process.
- Workflow hooks. The four lifecycle hooks (
after_create,before_run,after_run,before_remove) inWORKFLOW.mdrun as arbitrary shell throughbash -lc(a login shell) with the workspace as the working directory. The SPEC states it plainly: hooks are fully trusted configuration. Anyone who can editWORKFLOW.mdcan run code on the host. - The coding agent. The agent (Codex or Claude) runs commands and edits files inside the workspace. Lorenz auto-approves its permission requests by default (see Approval gating below).
- Out-of-tree worker drivers. When
worker.worker_pool.drivernames a module specifier instead of a built-in kind, Lorenz dynamic-imports that module into the daemon process at startup or reload. This is the same trust boundary as hooks: arbitrary code in the daemon, not a plugin sandbox.
Treat WORKFLOW.md and any out-of-tree driver as you would treat the daemon's own source. Review them. Restrict who can push to them. Do not run a workflow you would not run by hand.
Out-of-tree driver loading
The worker pool can load a driver from outside the repo by module specifier (worker.worker_pool.driver: ./my-driver.ts#myExport, an npm package name, a @scope/name, a /absolute path, or a file: URL). The loader (apps/cli/src/workerDriverLoader.ts) constrains this in ways worth knowing.
- Loads only at startup and reload, never on the acquire path. A module is imported when the daemon boots and again only if a reload changes the specifier string. It is never imported while leasing a worker. Module code is pinned for the daemon lifetime: changing the code requires a restart, only changing the config to a new specifier hot-loads.
- An exact built-in kind always wins. The loader checks the registry for the literal kind first, so a published npm package named
dockercannot shadow the built-indockerdriver. - The load is audited. A successful load emits
worker_pool_driver_loaded; the pinned module emitsworker_pool_driver_module_pinned. Cache-busting query strings are rejected withworker_pool_driver_invalid_specifier, and an SDK-version mismatch throwsworker_pool_driver_sdk_mismatch(the SDK is pinned at major version1).
The driver is registered under the exact configured specifier string, not the module's self-declared kind. See Out-of-tree extensions for the full loading contract.
Secret handling
Secrets are resolved at config-parse time, not baked into WORKFLOW.md. The resolution order in resolveConfiguredSecret (packages/config/src/parse.ts) is fixed:
- If a value is exactly
$VAR(the whole string matches^\$[A-Za-z_][A-Za-z0-9_]*$), it is replaced byenv[VAR], or the empty string if unset. Substring interpolation is not done:$HOME/xpasses through unexpanded. - If that yields nothing, the provider's env fallback applies (for example
LINEAR_API_KEYfor Linear,JIRA_API_KEYfor Jira). - Any remaining
op://value is read through the 1Password CLI: anop --versionprobe, thenop read <ref>. A bareop://fallback resolves even with no inline value set.
The intent is that secrets live in the environment or in 1Password, and WORKFLOW.md holds only the reference. Keep literal API keys out of the committed file. The 1Password path shells out to op on PATH; its absence throws a specific message, and a failed op read throws Failed to resolve 1Password reference: <ref>. Tool-pack option values (tools.<pack>.*) and tracker credentials go through the same resolver, so string options can also carry $VAR / op://.
See Secret resolution for the decision tree and examples.
Workspace containment
Each issue gets a directory under workspace.root. The workspace layer keeps every path inside that root and rejects escapes.
validateWorkspaceCwd/ensureInsideRootrealpath the root and the target and reject a blank or newline-containing path (invalid_workspace_cwd), the root itself used as a cwd (refusing to use workspace root as cwd), a symlink that escapes the canonical root (unsafe symlink in workspace path), and a path resolving outside the root (workspace outside root).- Containment is re-checked immediately before each local hook launch, so a cwd swapped to an out-of-root symlink between validation and execution is caught.
safeIdentifiersanitizes the tracker identifier into the directory name by replacing every character outside[A-Za-z0-9_.-]with_. Two identifiers differing only in stripped characters collide on the same directory; identifiers are not otherwise disambiguated.- Skill overlay sources must be real directories with no symlink anywhere in their subtree (
rejectSourceTreeSymlinks), or the sync throwsworkspace_skill_source_symlink. A skill that is a file throwsworkspace_skill_source_unsupported. Overlaid skills land in.lorenz/skills/with a.gitignoreof*so they are never committed.
Hook subprocesses are spawned detached in their own process group. On timeout (hooks.timeout_ms, default 60000) Lorenz sends SIGTERM to the whole group, then SIGKILL after a 5000ms grace, so a hook that backgrounds children cannot leak them. Hook output is truncated to 4096 characters in logs.
SSH worker access
When work runs on a remote worker host, hooks and the agent execute over SSH (packages/ssh/src/index.ts). SSH inherits the host's own trust: Lorenz runs cd <workspace> && <command> on the worker under whatever credentials the SSH config provides.
- The
LORENZ_SSH_CONFIGenvironment variable is passed tosshas-F <path>, letting you pin host keys, identities, jump hosts, andProxyCommandper deployment without touching the workflow. - Remote commands run in a detached process group; on timeout (
worker.ssh_timeout_ms, default60000) the entire group isSIGTERMed thenSIGKILLed after5000ms. - Remote workspace root resolution expands
~/$HOMEagainst the worker's$HOMEover SSH, not the local one, so a remote root cannot be aliased to a local path by accident.
Two unrelated config surfaces share the "static SSH" name: the legacy worker.ssh_hosts list (pre-existing destinations the runtime shards across, no provisioning) and the static-ssh worker-pool driver. They are mutually exclusive in config. See Static SSH workers.
MCP endpoint authentication
Agents reach tracker tools over an HTTP MCP endpoint at POST /mcp. Every request carries a random base64url token in an Authorization: Bearer <token> header; a request without a valid token gets 401 {error:{code:'unauthorized'}}. There are two token kinds, both held in a process-local map (in-memory only, never persisted or shared across processes):
- Settings-scoped bearer (Token A). Valid only for the scope
mcp:<sha256(identity)>, derived from the server host, port, full tracker config, and canonicalized tool options. Any settings change rotates the scope and invalidates tokens minted for the old one. This is the token the single-tenant ACP/local endpoint and the observability server accept. - Per-run scoped claim (Token B). An opaque token whose bytes carry no scope: it resolves server-side to a daemon-minted claim that pins the run (
runKey, worker host, generation) and a coarse lifetime cap. The claim-enforcing MCP server re-checks the claim on every request - expiry, then the per-operation tool allowlist, then a liveness-and-generation re-check that returns false once the run has settled, its host was recycled, or a higher-generation re-acquire superseded it. Any miss denies; it never falls back to Token A. TherunKeyis resolved from the token, never from a self-reported header.
Per-run isolation comes from the Token B claim, not from a distinct network path. Co-resident runs packed onto one worker host (worker.worker_pool.slots_per_machine > 1) share one SSH reverse tunnel (ssh -R, one per host, refcounted by @lorenz/worker-host-pool); they are kept apart by their distinct claims rather than by separate ports. Remote-worker workflows can set server.mcp_port to give these claims a dedicated Lorenz-owned listener while the dashboard keeps server.port; the dedicated listener always binds to loopback. Local agents always retain the existing shared dashboard/MCP port. If server.mcp_port is unset and the dashboard already owns server.port, a remote per-run endpoint refuses to attach to that Token-A-only mount. The token never traverses an open network port.
The shared dashboard/MCP endpoint binds to server.host (default 127.0.0.1); a dedicated server.mcp_port endpoint always binds to loopback. A failed tool call is returned as data (isError: true inside an HTTP 200 JSON-RPC result), not as a transport error, so a misbehaving tool cannot crash the seam. See the HTTP API reference and tracker tools.
The Codex sandbox default
The vendored codex-acp bridge runs Codex in its Agent mode by default (DEFAULT_AGENT_MODE). That mode is workspace-write: the agent may read and edit files in the workspace and run commands, with networkAccess: false and approvals set to on-request. Because Lorenz auto-approves those requests (next section), the practical effect is workspace-scoped writes with no network, no approval prompts.
A workflow can widen this to agent-full-access (danger-full-access), which lets Codex edit files outside the workspace and run commands with network access. That is a deliberate, documented step up in blast radius. The default is the narrower mode; do not move to full access without a reason.
Claude sessions are configured through a provider_config overlay (settings.json shape) rather than a sandbox mode. The default Claude record sets provider_config.permissions.defaultMode = 'dontAsk'; the vendored claude-agent-acp bridge additionally disallows the AskUserQuestion tool for every Claude session, independent of the record. See Codex and Claude for the per-agent details.
Slack bot_user_id gating
The Slack tracker requires tracker.bot_user_id (or the SLACK_BOT_USER_ID environment variable). Without it, the Slack tool ops throw slack tools are unavailable: tracker.bot_user_id (or SLACK_BOT_USER_ID) is not configured.
This is a safety gate, not a formality. The mention matcher (isBotMention) only treats a message as work when it mentions that specific bot user id. If bot_user_id is unset, the matcher falls back to matching any <@...> mention, so any human-to-human mention in a watched channel could spawn an agent run. Setting bot_user_id scopes dispatch to messages that explicitly address the bot.
Approval gating: what Lorenz does not do
The SPEC lists "mandating strong sandbox controls beyond what the coding agent and host OS provide" and "mandating a single default approval, sandbox, or operator-confirmation posture" as explicit non-goals. Lorenz follows that. Concretely, there is no built-in approval gate beyond the agent's own sandbox mode and the host OS.
- Permission requests are auto-approved. The ACP executor selects the first option whose kind starts with
allowand emitsapproval_auto_approved. It does not pause for an operator. If no allow option exists, it emitsapproval_requiredand returns a cancelled outcome. - No human-in-the-loop confirmation step. Lorenz does not prompt before a hook runs, before a command executes, or before a ticket write. Ticket writes happen through agent tooling, governed by the workflow prompt, not a Lorenz policy.
- No per-issue eligibility filter beyond dispatch routing. Lorenz dispatches on tracker state and labels. It does not vet whether an issue's content is trusted before the agent reads it.
The SPEC frames harness hardening as part of the core safety model, not an afterthought, and points to deployment-specific controls Lorenz does not ship: OS/container/VM isolation, network restrictions, separate credentials, and tracker-source filtering. Those are yours to add.
Hardening checklist
For an operator standing up a deployment, in rough priority order:
- Lock down
WORKFLOW.md. Treat it as production code. Require review on changes; restrict who can push to the branch the daemon reads. - Vet out-of-tree drivers. Review any module behind
worker.worker_pool.driver. Pin it. Auditworker_pool_driver_loadedevents. - Keep secrets out of the file. Use
$VARenv references orop://1Password references. Never commit literal keys. - Run on dedicated, low-privilege hosts. Hooks and agents run with the daemon's authority. Give that process the minimum filesystem, credential, and network access the workflow needs.
- Keep the Codex default sandbox. Stay on
workspace-writeunless a workflow genuinely needsagent-full-access. Reserve network access and out-of-workspace writes for cases that require them. - Add external isolation. Containerize or VM-isolate the worker. Restrict outbound network. Use separate credentials per deployment.
- Set
bot_user_idfor Slack. Always configure it so only mentions of the bot trigger runs. - Bind the MCP endpoint narrowly. Keep
server.hoston127.0.0.1unless a remote tunnel requires otherwise; rely on the per-run SSH reverse tunnel for worker access rather than exposing the port. - Pin SSH host keys. Point
LORENZ_SSH_CONFIGat a hardened config with known hosts and explicit identities. - Filter dispatch. Use dispatch routing (
tracker.dispatch.only_routes, label prefixes) so out-of-scope or untrusted issues do not automatically reach an agent. - Run
lorenz doctor. Validate the workflow and prerequisites before going live.
See also
- Workspaces - containment rules, hook execution, and the skill overlay in full
- Workflows - the
WORKFLOW.mdcontract that hooks and config live in - Secret resolution -
$VAR/op:/// env-fallback order - Out-of-tree extensions - the dynamic-import loading contract
- SPEC - the trust-and-safety requirements this page documents