Skip to content

Security: vimoxshah/clockwork

Security

docs/security.md

Clockwork — Security Model

Honest layering. We describe what each boundary does and does not do. Anything marketed beyond this document would be a trust violation.

The three layers (and what each one actually is)

1. OS sandbox = THE containment boundary

Every run executes inside a per-run macOS Seatbelt profile (packages/runner/src/sandbox.ts, versioned in source). Every engine spawn — Claude, Codex, OpenCode, Hermes, and the BYOK agent's shell — is wrapped by applySandbox(); runner-child builds the spec. A source-reading test (packages/runner/test/runner-env-wiring.test.ts, "sandbox wiring") fails the build if any runner stops calling it.

Correction (2026-09-05, ADR-034). Before this date the profile and its escape tests existed but no production run was inside one — the runner was constructed without a spec. If you installed an earlier build, your runs were contained by the run-env allowlist and worktree isolation only. This document now describes what ships.

Enforced:

  • Writes are default-denied. A run may write only to:
    • its own worktree or scratch directory,
    • /dev/null (git requires it),
    • Clockwork-managed tool caches under ~/.clockwork/cache/ (npm, pnpm, yarn, pip, XDG, cargo, go, gem/bundler, uv, poetry, gradle, composer, nuget) — redirected there by env so package managers work without opening $HOME,
    • the Claude CLI's per-run work dir /tmp/claude-<uid>/<cwd-slug> and its cwd-tracking file /tmp/claude-<hex>-cwd (exact-name regex; without these the CLI's Bash tool cannot start a shell),
    • scoped engine-state subpaths: ~/.claude/{projects,statsig,shell-snapshots,logs}, and for the engine actually running: ~/.codex, ~/.opencode + opencode's share/config/cache dirs, or ~/.hermes (+ hermes's $HOME/.hermes-tmp.<pid> staging file by exact-name regex). A run cannot modify global Claude config that future runs would load, cannot write to your home directory, other repos, or system locations.
  • Escape hatch is loud, never silent. CW_SANDBOX=off disables the wrap for a run; it is logged in the run's live log, written to the safety journal as sandbox_disabled, and stamped on the report as sandboxed: false.
  • Credential paths are explicitly unreadable: ~/.ssh, ~/.aws, ~/.gnupg, ~/.config/gcloud, browser profiles, cookies, shell histories. Symlinks are resolved at profile-generation time; a context root pointing into a credential path is refused outright (S-86).
  • Process supervision: every run gets its own process group; termination is identity-verified (pid + pgid) group kills, never bare-pid kills.

Verified exceptions (documented, not hidden):

  • Keychain file reads remain possible. Claude Code sources its OAuth via the login keychain even headless; denying keychain files breaks authentication entirely. Other applications' keychain items remain protected by their own per-item ACLs, which fail closed for headless processes. Net exposure equals what an interactive claude session already has on the same machine.
  • File reads are same-user-broad. On macOS 26, read-restricted Seatbelt profiles make dyld abort before main() for every toolchain binary (verified; crash reports in ADR-023). Reads therefore match an interactive agent session. This is why worktree isolation is called "accident isolation" and NOT a security claim.

The full escape matrix lives in spikes/reports/T008-sandbox.md: SSH keys, AWS creds, GnuPG, shell history, out-of-scope writes, symlink traversal, and writes into read-only context roots are all OS-denied; git, node, and the claude engine itself function inside the profile.

2. Worktrees = accident isolation (NOT a security claim)

Repo runs execute in ~/.clockwork/worktrees/<task>/<run-id> on branch clockwork/<task>/<run-id>. Your working tree is untouched by construction. This bounds mistakes, not adversaries.

3. Deny-list + permission modes = policy UX

A global-only deny-list blocks force-pushes to protected branches, package publishing, and credential-path access attempts, and journals every hit (FR-27). On the Claude engine its floor entries (force-push to a protected branch, package publishing) are enforced for every Bash call through the PreToolUse hook described below; non-floor entries still only apply to calls the CLI chooses to prompt for. Other engines have no per-command hook and rely on the sandbox. It shapes normal behavior; it does NOT stop a determined adversarial prompt from routing around patterns. The sandbox above is what bounds damage.

bypassPermissions is not offered in H1. Permission modes are plan (dry-run) and acceptEdits.

Prompt injection: documented, not solved

Attached files and URLs are untrusted input. Our stance (ADR-010): bound the damage (sandbox + budgets + branch-isolation), surface intent (policy events in reports), never claim immunity. plan mode exists exactly for untrusted-source jobs. Imported templates/profiles arrive disabled with a security preview.

Repo-shipped job offers (F5)

A .clockwork/jobs.json/.yaml file inside a target repository is untrusted input — it arrived with someone else's commit, not with your keyboard. The declared job type (RepoJobSpec) has no permissionMode, engine, byokId, or budget field at all; even a hostile file that includes one is ignored — the parser never reads those keys. An imported job always gets the same conservative defaults /templates/import already uses (acceptEdits, $2 / 50 turns / 3600s, queued schedule) and is created disabled, with a security preview computed at discovery time. See docs/agent-workforce.md (F5) and ADR-040.

Proof-of-work export (F12)

GET /workforce/runs/:runId/proof-of-work produces a file meant to leave your machine and be published somewhere you don't control, so it gets its own, stricter rules on top of the report masking above: every interpolated string is escaped and passed through the same maskSecrets helper as the rest of the product (no flag disables it), the file has no external <script>/<link>/ remote <img> reference of any kind, and the most sensitive artifact — the transcript — is excluded unless you explicitly ask for it per export. Every export is written to the audit log.

Secrets & credentials

  • Clockwork stores no Anthropic credentials — runs ride your existing Claude Code login (pointer only).
  • Delivery credentials live outside the daemon DB (keychain at packaging; env/ file bridge for CLI installs) and are never visible to runner processes — runners get sanitized envs and stdio IPC only.
  • BYOK provider keys travel to the runner over the daemon⇄child stdin channel, never the environment. A process's exec-time environment stays readable through sysctl (KERN_PROCARGS2) by any same-user process, sandboxed or not, so an env var that is "read then deleted" is not a boundary (found in review 2026-09-06; the transport is the fix, the profile cannot be). One qualifier: a BYOK config that reads its key from an environment variable (auth: 'env') has the key in the daemon's own exec-time environment by construction, and that stays readable through the same sysctl path. Keychain-backed configs are fully closed; prefer them.
  • The local API is loopback-only with a bearer token stored 0600.

Engine capability honesty

The Claude engine (headless claude -p) asks Clockwork before each gated tool call through --permission-prompt-tool, served by a loopback HTTP MCP server in the runner (verified on CLI 2.1.261, ADR-034 — this replaces ADR-020's "no hook as of 2.1.238"). A request holds until a human answers or the run's wall-clock budget ends, then fail-safe denies; both outcomes are recorded. The tool is not visible to the model, so the agent cannot approve itself.

Codex, OpenCode and Hermes expose no permission hook; for them the sandbox and budgets are the containment, and the UI hides approval affordances. There is no "SDK engine". Two engine specifics (ADR-035): Codex runs with its own workspace-write sandbox off while Clockwork's is on — macOS refuses to apply a second Seatbelt inside a (deny default) profile (sandbox_apply: Operation not permitted, every allow bisected), so exactly one layer applies and it is ours; only CW_SANDBOX=off falls back to codex's. Codex's inner sandbox also blocked shell-command network; under Clockwork's profile network is allowed, as for every engine. Hermes 0.21.0's oneshot path ignores --in, so HermesRunner sets TERMINAL_CWD to the worktree; before this its file writes landed in $HOME.

Closed 2026-09-06 — the policy floor now sees every Bash call (ADR-035). Probed 2026-09-05 through the production runner on CLI 2.1.261 under acceptEdits: npm view left-pad version was sent to the prompt tool, but git push --force origin main executed with no prompt at all — the CLI's own acceptEdits behaviour, not a settings leak. The deny-list would have refused it and was never consulted.

The fix: every run injects a Claude Code PreToolUse hook (matcher Bash) through --settings. The hook (packages/runner/src/floor-hook.ts, generated per run next to the MCP config, no imports beyond node:http) posts the command to the bridge's /floor route, where the supervisor runs evaluateCommand; a floor hit exits 2 and the CLI refuses the call with the reason. It is fail-closed: bridge unreachable, malformed input, timeout — every error path also exits 2, so a CLI format change breaks runs loudly instead of silently un-protecting them. Hooks fire in every permission mode, so coverage no longer depends on what the CLI chooses to prompt for. The payload also pins disableAllHooks: false: the CLI honours that switch from a repo's own .claude/settings.json, and without the pin one committed key turned the floor off while the report still said sandboxed: true (probed 2026-09-06; CLI-flag settings outrank project settings, so the pin wins). Cost per Bash call: about 60 ms (Node start plus one loopback round trip, median of ten).

Verified 2026-09-06 through the production runner-child: npm view … still prompted (bridge intact), git push --force origin main came back as "force-push to protected branch 'main' is blocked by global deny-list" and never ran; recorded as a policy_deny event and a deny_list_hit journal entry.

Still open, surfaced not hidden: the run inherits HOME, so permissions.allow rules in ~/.claude/settings.json and the developer's own SessionStart/SessionEnd hooks apply to unattended runs. The floor hook holds regardless (deny beats allow in the CLI's hook precedence); pinning --setting-sources is a product decision left open. The hook matches the Bash tool only: an MCP server the repo declares in .mcp.json (kept on purpose — --strict-mcp-config would drop it) that runs shell on the agent's behalf is not matched, so the floor does not see those calls; the sandbox still bounds them. The permission bridge listens on loopback with no request authentication, and that is not a gap we plan to close with a bearer token. Any secret the CLI must present — in permissions.json, the hook file, --settings argv, or the environment — is readable by the agent running inside that same CLI. A token cannot separate the CLI from its own agent; it would just be another readable file. Instead the bridge bounds the noise: it binds to loopback only, stops reading a request past 4 MiB, holds at most 16 prompts per run and denies the rest. Every decision still routes back to the real caller by request id, so a forged prompt can never approve a real call — a sandboxed agent that reads the port from its own argv can still post fake approval prompts into the human's inbox, and that remains possible noise, not an escalation.

Remote approvals (Telegram)

The daemon can resolve an approval from Telegram's inline keyboard as well as from the Inbox (ADR-036). This is outbound-only: the daemon long-polls api.telegram.org's getUpdates; it never opens a port or accepts an inbound connection, so the "never exposed to your network" claim above still holds — the daemon still binds 127.0.0.1 only. Trust that a button press is legitimate rests on two facts, not a secret the daemon has to mint: the chat the press came from must match the task's configured delivery.telegram.chatId, and — because anyone present in a group or supergroup could tap someone else's button — a group chat additionally requires the pressing user's id to be on that task's telegram.allowedUserIds; no list configured for a group means no button in that group ever does anything. A rejected press changes nothing and answers "Not allowed". The decision itself is resolved through the exact same code path as the Inbox's respond button (RunManager.respondToApproval), so the CAS, the forward into a still-live run, and the audit trail cannot differ between the two. One consequence worth naming plainly: anyone who holds the bot token can act as the bot, including pressing Approve or Deny — the token is a credential, and losing it means losing this channel's integrity, the same as losing any other API key.

Reporting a security issue

See CONTRIBUTING.md for responsible disclosure. Confirmed containment escapes are stop-ship events with public post-mortems.

There aren't any published security advisories