oh-my-graph is a personal, local tool that re-uses your own logged-in
claude session. This document states, honestly, what that means and where the
line is.
- A subprocess scheduler that runs each DAG node as
claude -p ...on the machine you run it from, under the account you are already logged into. - The same standing as running
claude -pyourself, or as tools like claude-squad: it drives a CLI you have already authenticated.
- Not a hosted or redistributed product that authenticates other people via subscription OAuth. Doing that would violate Anthropic's Terms of Service.
- Not a shared service. It never runs as a daemon serving other users.
- It never ships credentials, never proxies auth, and never stores or transmits your tokens.
- API-key scrub. Every child process oh-my-graph spawns — a node
subprocess, a
success_check.verifycommand, the git commands behind a node'sworktree:(a repo's own hooks may invoke claude), and theopen/xdg-openlaunch of theserveURL (the URL handler it dispatches to is arbitrary user-configured code) — starts from your environment withANTHROPIC_API_KEYandANTHROPIC_AUTH_TOKENdeleted. Those variables silently switchclaudefrom your subscription (OAuth) to metered API billing. Deletion matches the whole variable name without regard to case, on every platform, so it holds where environment lookups are case-insensitive (Windows) as well as where they are not — that rule is unconditional rather than platform-tagged so Linux CI executes the guarantee it states. Keys that merely begin with one of the names (ANTHROPIC_API_KEY_BACKUP) are left alone. The scrub is asserted by a unit test at every call site (internal/runner/claude_test.go,internal/verify/shell_test.go,internal/worktree/git_test.go,internal/browser/exec_test.go) that sets both variables in the parent process and proves neither survives into the built child command. - Never
--bare. That flag disables OAuth; oh-my-graph never passes it. - Never the Agent SDK / API. The node runtime is exclusively the
claudeCLI subprocess.
- Each node declares its own
allowed_toolsandpermission_mode. Grant only what a node needs. allowed_toolsis a declaration, not a sandbox. It is passed to the CLI as--allowedTools, which is unioned with the permissions your own~/.claude/settings.jsonalready grants — it can never shrink them. If your settings carry a standing grant likeBash(*)orWrite(*), a node has it regardless of what the graph declares. For hand-written graphs this is by design: the graph is your own reviewed artifact and your settings are the intended policy. Auto-planned graphs are the exception — see below, where--setting-sources ""turns the same declaration into a real limit.permission_mode: bypassPermissionsis opt-in per node and prints a loud warning at load time. It is never a graph default. Parallel nodes that share a working directory should stay read-only (permission_mode: plan) to avoid racing edits.
A planned graph is untrusted LLM output executed unattended, so it gets bounds a
hand-written graph does not. Beyond the plan-time rejections (no
bypassPermissions, no cwd, no planner-authored success_check.verify, no
agent, no tool outside a fixed allowlist, and a capped retry.max and
feedback.max), auto mode runs each planned node under a layered execution
ceiling.
A planner-authored success_check.verify is refused outright rather than
constrained: it is a shell command the engine runs, not a tool call, so no
permission mode, tool allowlist, deny list or cwd restriction applies to it.
It is available to hand-written graphs, which are your own reviewed artifact,
and to a command you supplied at invocation (--verify-cmd) — never to one
a plan authored.
That flag is ADR 0016: after a plan has been validated, trusted Go code attaches your command to the graph's sink nodes, so the engine runs the build itself and judges its exit code.
oh-my-graph auto "fix the failing spec" --verify-cmd './gradlew build'The command is checked for runnability before the planner call when it is a
plain program invocation, so a typo costs nothing — one carrying shell syntax is
left for sh -c to resolve rather than parsed here; it is printed with the plan (--plan-only shows it, per sink
node, with its timeout); it is bounded by --verify-timeout, which defaults to
10 minutes and may not exceed it; and it is snapshotted into the run's saved
graph.json. Every cycle of a --max-cycles goal loop plans a new graph and
every one of them gets it.
resume never takes a verification from a run directory on an auto graph: it
refuses the resume and names the node. A success_check.verify is
engine-run shell outside every ceiling layer, so a snapshot that carries one —
whether from your own --verify-cmd run or from an edit to graph.json — is
not something a resumed leg replays on trust, and a resume cannot tell the two
apart. The practical consequence: an auto run started with --verify-cmd
cannot be resumed. resume registers no --verify-cmd of its own to
re-supply the command with, and continuing such a run with the check silently
dropped is precisely the failure this mechanism exists to prevent — so it stops
instead. Hand-written graphs are unaffected: their verify: is your own
reviewed artifact and round-trips unchanged.
A planned node is granted nothing by --verify-cmd — no ceiling layer changes, and
the allowlist deliberately does not grow an entry per ecosystem, because
that would put this repository's toolchain inside every user's ceiling.
--verify-cmd is unbounded user shell with exactly the standing a hand-written
graph's verify: has had since ADR 0002, running on the same seam and
executing repo-authored code (gradlew, Makefile, npm) the way your own
terminal does. Stated, not closed: the difference from a repo-file-derived
grant is that you chose it.
Relatedly, oh-my-graph may detect build markers (gradlew, package.json,
Cargo.toml, …) in the invocation directory. Detection only ever prints a
suggested command. It never derives a grant, because a write-capable planned
node can create those files itself — a plan bootstrapping its own capability
with no attacker anywhere.
The layers:
| layer | mechanism | closes |
|---|---|---|
| 0 declaration | coordinator.plannedToolAllowlist |
what a plan may name at all — plan time, before any node runs |
| 1 isolation | --setting-sources "" |
your standing grants; settings hooks |
| 2 grant | --allowedTools under dontAsk default-deny |
scoped Bash |
| 3 narrowing | --tools "<names declared>" |
tools the model can attempt at all |
| 4 MCP | --strict-mcp-config, no --mcp-config |
mcp__<server>__<tool> |
| 5 residual | --disallowedTools |
anything the layers above got wrong |
Layer 0 is the only plan-time layer: it is the fixed allowlist above, enforced
by validatePlannedNodeTools before anything runs, so a plan naming Bash,
Bash(*) or an unrestricted WebFetch never becomes a graph. Layers 1–5 then
bound what the surviving declaration is worth at run time.
Layer 1 is what makes the rest bind. Permission rules are matched from every
loaded source, so a standing Bash(*) in your own ~/.claude/settings.json was
previously matching before a planned node's narrower Bash(git *) ever
mattered. Loading none of your user/project/local settings leaves oh-my-graph's
own argv as the only allow-rule source.
Measured on claude 2.1.220 (2026-07-29), not inferred from --help: with a
settings.json granting Bash(*) and a node declaring Bash(git *), an
out-of-scope shell command ran without Layer 1 and was denied with it,
while in-scope git kept working. So the previously-disclosed gap — "a node
declaring any scoped Bash(...) pattern keeps the whole Bash tool" — is
closed for auto-planned nodes. It remains accurate for hand-written graphs,
which run without layer 1's isolation: their declared allowed_tools is still
rendered as --allowedTools (layer 2 applies to every graph), but layers 1 and
3–5 are auto mode's alone by design.
Still a reduction, not a sandbox. What is not covered:
- MCP closure is unverified.
--strict-mcp-configis passed because oh-my-graph never passes--mcp-config, so the flag costs nothing — but this was not measured against a real MCP server (DESIGN.md, E5). Do not read Layer 4 as an observed guarantee. - Slash-command surface is still not enumerable by any of these mechanisms,
and neither is skill surface by these flags — but since v0.5.2 it is
bounded by a different one: an activation-eligible node reaches only the
corpus
autostages for it, printed with each skill's size and SHA-256 before the run, and an agent-mapped node holds noSkilltool at all (ADR 0017). - Enterprise policy settings are never dropped by
--setting-sources ""— which is deliberate: this cannot be used to step around a corporate policy. Conversely, on a machine withallowManagedPermissionRulesOnly,--allowedToolsrules are ignored entirely and the ceiling is the managed policy, not ours. - The ceiling rests on behaviour of a specific CLI version. A future claude release could change it; Layer 5 is retained precisely so a wrong assumption in Layers 1–4 degrades to the older, weaker ceiling rather than to nothing.
Planned nodes are more isolated and less capable than they used to be.
Dropping your settings also drops your CLAUDE.md, your hooks and your configured
MCP servers for those nodes. That is the intended direction, but it is a real
behaviour change: if your auto runs depended on an MCP server, they will stop.
Re-running a saved graph.json through oh-my-graph run drops the ceiling
entirely — that path assumes you reviewed the file. Treat auto as you would
any unattended agent: run it in a directory you are willing to have modified.
Everything above bounds what a planned node may call. It says nothing about where the node works, and the answer there is narrower than people assume:
- Every planned node runs in the directory you invoked
oh-my-graphfrom, in the checkout you had open.cwd:andworktree:are both rejected at plan time (validatePlannedNodeCwd,validatePlannedNodeWorktree), soautoprovisions no managed worktree of its own — a planned node edits and commits in your working tree unless it arranges otherwise itself. - Managed worktrees (
worktree:, ADR 0005) are a hand-written-graph feature, and they only ever branch from the invocation repository.worktree.GitManagerholds a single repo directory (the process's own), and every checkout it creates lives under$OMG_HOME/runs/<run-id>/worktrees.
So any other local repository gets no isolation from oh-my-graph at all —
including one your goal names by absolute path. If a node cds there, switches
that checkout's HEAD, or creates a worktree of its own, that is the node's
improvisation, not a guarantee the engine offers or can undo. A shared checkout
is the concrete hazard: a node changing HEAD in a repository some other process
is working in will collide with it, and the run's feed records the node's
result, not the git commands it chose to run.
auto now says this at plan time, for the repositories it can see. When
the goal or a planned prompt names an absolute path that resolves into a git
checkout outside the invocation repository, the plan printout — the one
--plan-only also renders — names that checkout, says where the plan named it,
and states that nothing there is isolated, before any node spends. It is a
warning and never a refusal: a multi-repository goal is legitimate, the engine
just cannot isolate it. Treat it as a floor, not a guarantee. It is a heuristic
read of the plan's text and it cannot see a path a node builds at run time, one
arriving through an --input or a parent's artifact, a repository reached by a
relative path, or what a node actually does once it is there — so a plan that
warns about nothing is not a plan that touches nothing outside this repository.
Two consequences worth planning around: keep a goal that spans repositories to
one that expects each node to isolate itself in the repositories it does not
own, and never verify such work by asserting a local HEAD — an assertion like
git -C <other-repo> rev-parse --abbrev-ref HEAD encodes an assumption about
where the work happened and fails on work that succeeded. The planner is told
to assert remote state (gh pr list --head <branch>) for exactly this reason.
Managed multi-repository worktrees are not implemented, and
ADR 0018
records the decision not to build them — the surface they need is the one
validatePlannedNodeCwd closed deliberately, the cost that decides it is
cleanup debris left in a repository you did not open, and the shape any future
proposal has to start from (a user-supplied --repo, never a planner-named or
detector-derived path) is written down there. That record also names the
measurement that would reverse it, so the warning above is the protection for
as long as this section says it is.
A hand-written node may run as one of your own Claude Code subagents
(agent: code-reviewer → claude -p --agent code-reviewer). oh-my-graph does
not parse the subagent's definition and makes no claim about how that
subagent's own tools: combines with the node's allowed_tools — that is the
CLI's precedence, and for this path it is unmeasured. (DESIGN.md's E6 probed
a subagent against --tools, but --tools is emitted only by auto mode, which
forbids agent: — so that result does not cover the hand-written case.) If a
subagent grants tools the node did not, assume it gets them.
Auto-planned nodes may not set agent: at all. Letting an unreviewed plan
pick which of your subagents runs a node would hand it that subagent's system
prompt, tool grant and model, routing around Layers 0–3 in one word. It is
rejected at plan time, and a reflection-driven test over graph.Node fails the
build if any future schema field is added without an explicit decision like this
one.
serve <run-id> renders one run; serve with no id renders a dashboard over
every run directory under $OMG_HOME/runs, with each run's own view mounted
at /run/<id>/ — one process, one port, all your runs. A fresh run/auto/
resume whose stdout is a terminal embeds the same server for that leg's
duration (--no-web opts out; a non-terminal stdout gets no server at all).
Run directories hold node prompts, artifacts and session ids, so:
- Loopback only. The listener binds
127.0.0.1(default port 8642), and a test asserts the bound listener address, not just the config. Additionally every request'sHostheader must name127.0.0.1orlocalhostor it is 403 — otherwise a hostile page could DNS-rebind a domain it controls onto 127.0.0.1 and read/api/*through your own browser. - Paths come from listings, not from URLs. A
/run/<id>/id is matched against the runs root's directory listing before any path is built from it, and a?node=<id>against the snapshot's own node set; a typo and a traversal probe are the same 404. The one read outside the run directory — the live transcript tail into your own~/.claude/projects— is named by the feed-published, shape-checked session UUID, never by URL input. - Nothing served is sniffable or framable. Every response of both
front-ends carries
X-Content-Type-Options: nosniffand a Content-Security Policy — which matters most on/api/result, where a node's raw reply is served astext/plainand must never be re-interpreted as HTML. The same policy'sframe-ancestors 'none'is the gate guard described below. servespawns nothing. The package imports noos/exec; both processes its features imply belong to the exec seams above.
It is not read-only. Since the gate routes landed (ADR 0014), two POSTs —
/api/gate/approve and /api/gate/reject — decide the gate a run is paused at,
which continues the run: rewriting state.json, appending to events.jsonl,
and running the nodes the gate was blocking. Four guards weigh the request —
the loopback bind, the Host check, an Origin check and a per-process token —
and one guard weighs the page, because all four of the others ask where a
request came from, and a clickjacked click answers every one of them honestly:
- Token (CSRF). 32 bytes of
crypto/rand, minted per serving process, rendered into the served page and demanded back inX-OMG-Token— missing is 400, mismatched is 403, compared in constant time. No shape of a gate POST reaches the resumer without it. It is a CSRF guard, not a login. - Origin. A POST whose
Originnames anything but this server's own origin is 403, so a decision from a page this process did not serve is refused on its provenance before its token is weighed. An absentOriginis allowed through — curl and the CLI's own tests send none, and the token remains the whole guard there. This narrows what a browser can do; it is hardening layered in front of the token, not the closing of a hole. - Frame refusal (clickjacking). Every response of both front-ends carries
frame-ancestors 'none'andX-Frame-Options: DENY, so no other page may embed this one. This is the one guard above that refuses the framing rather than the request, and it closes a hole the other four leave open by construction: a hostile page you are already visiting frameshttp://127.0.0.1:8642/run/<id>/— the port is the documented default, and the dashboard's/needs no run id at all — overlays it, and baits a click onto the approve button. The click lands inside the framed page, so that page reads its own token and the browser stamps this server's ownOrigin. Host loopback, Origin matching, token valid, constant-time compare passed: a gate nobody read is approved, and the run spends money.'self'andSAMEORIGINwould both still permit this, so the values are pinned by test. - A decision is valid only while the viewed run is genuinely paused at the named
gate; a held
resume.lock, a missing snapshot, a non-pending gate id, or a view with no resumer injected are each 409. Only the standaloneserveprocess injects a resumer.
What this does not give you: there is no authentication. The loopback bind is the read access control, so any process or user on the same machine can read every run — prompts, artifacts, session ids and transcript tails — and, holding a token from a served page, decide a paused gate. This is a single-user local tool and is scoped accordingly; widening the bind address would need a real auth story first.
The frame refusal and the CSP are browser-side guards only, and it is worth being exact about what that leaves:
- They stop a page from embedding this one and stealing a click. They do not stop that page from navigating your top-level window (or a popup) to the view: the gate would then be on screen, on this origin, with you looking at it. That is a nuisance, not a silent approval.
- They stop nothing that is not a browser. Any local process running as you can read a served page, take its token, and POST a gate decision — no header a server sends constrains a program that ignores headers. The loopback bind and the machine's own user boundary are still the whole story there.
- The CSP is written against what the shipped
ui/assets actually do, and one directive is loose by necessity:style-srccarries'unsafe-inline'because vendored cytoscape injects a<style>element at renderer init.script-srcstays'self'with no'unsafe-eval'— nothing shipped calls eval, and the one Function-constructor call in the vendored libraries (lodash'sFunction("return this")()root fallback, in cytoscape and dagre alike) is short-circuited before it in a browser, so it never runs. A cytoscape bump must re-verify the policy by hand (internal/serve/ui/vendor/README.mdsays so), because no Go test can execute vendored JavaScript.
A run directory under $OMG_HOME/runs/<run-id> is the run's whole memory: every
node's prompt and the values interpolated into it (state.json), every node's
full reply (<node-id>.out), loop feedback payloads, the event stream
(events.jsonl), and — for auto — the planner's saved spec (graph.json),
which inlines the body of any mapped local SKILL.md.
Those files are written owner-only: 0700 directories, 0600 files. That
is the stance auto's saved plan spec already took, now applied to the rest of
the run.
One thing inside a run directory is not ours to mode: worktrees/<name> is a
checkout of your own source, and git writes it at your umask. Only the
worktrees/ container is 0700 — which is enough, since a 0700 directory
denies traversal to everyone else regardless of what is under it.
Two things this deliberately does not do:
- It does not re-mode existing run directories.
MkdirAllreturns success on a directory that already exists without touching its mode, so a run from an older binary keeps its0755, andresumeandserveread it exactly as before. If you want the old ones narrowed, that is achmod -R go-rwx ~/.oh-my-graph/runsyou run yourself. The same applies per-file:O_CREATE's mode only applies when the file is created, so a resumed leg appends to an olderevents.jsonlwithout re-moding it. - It does not touch what
oh-my-graph initscaffolds. Those files are written into your own project, where0644is what a source file should be.
This narrows the at-rest exposure; it does not close the exposure of prompts.
The same prompt text is in the node's argv while it runs (below), and, because
session persistence is on by design, in the CLI's own session transcript under
~/.claude/projects, whose permissions are the CLI's to set, not ours.
A node's full prompt is passed to claude as an argv element (-p <prompt>, in
internal/runner/claude.go), so for the lifetime of that subprocess it is
readable from the process table — ps auxww, and on Linux
/proc/<pid>/cmdline, which is world-readable unless the machine sets hidepid.
Any process running as you can read it on any platform.
This is not an incidental leak of a short string. A prompt carries its
{{ inputs.* }} values, and because | inline is used pervasively across the
shipped graphs and fragments, it carries the inlined content of upstream
artifacts — that is, the text of earlier nodes' replies.
Known, and not currently fixed. The fix would be to feed the prompt on
stdin, and that is a change to the most lifecycle-sensitive seam in the repo:
ClaudeCLIRunner owns waitDelay, process-group kill and --output-format json parsing, writing to a child's stdin adds a deadlock surface, and the
interaction with --resume is unmeasured. It would need a real make smoke
measurement against a live CLI before anyone should believe it. Until then it is
documented rather than claimed closed. On a machine where you do not trust the
other local users, treat a running node's prompt as visible to them.
This is a young project. If you find a security issue, please open an issue describing it (omit any secrets) so it can be triaged in the open.