Skip to content

Commit 1b2aecd

Browse files
Read .env from the working directory, as grapharc.toml already is (#85)
`find_env_file` walked up parent directories to `/`, while the config layer next door refuses exactly that on principle — so the file that *spends money* was discovered more eagerly than the one that *constrains* a run. A run started in a scratch subdirectory picked up an `OPENROUTER_API_KEY` from any ancestor: a `.env` in `$HOME` billed every user's experiment on a shared box to that key, and since `redact()` is the only thing that ever prints a key, nothing said which file paid. The start directory (default: cwd) is now the only directory consulted. The signature and the None-when-absent contract are unchanged, so `get_secret` and the four backend accessors needed no edit, and neither escape hatch moved: a real environment variable still beats any file, and `env_file=` still names a file anywhere. No search boundary replaces the walk — stopping at a git root would still be an upward search. Behaviour change: a parent-directory `.env` stops being read. It is called out in the README's limits list and the changelog, and the docstrings and cookbook sentences that described the walk now describe the rule that replaced it. Fixes #20 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 74a4de8 commit 1b2aecd

8 files changed

Lines changed: 82 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Entries are newest-last within a release, matching the order they were written.
1010
## Unreleased
1111

1212
- a run **stopped for overspending reported spending nothing**. Tokens were attributed from `end` events, and a node the budget interrupts emits `error` instead — so `grapharc metrics` answered `tokens: 0` for a run whose own enforcement message named the figure that stopped it (`max_tokens reached (51/5)`). The audit trail lost precisely the number the stop was about, and per-node attribution dropped the most expensive node in the run. Every `error` event is now stamped with what its node spent, exactly as `end` is, and both `summarize` and the cost report count it; sub-events inside a node remain a breakdown of its total rather than an addition, so the disjointness that kept `ends + orphans` from double-counting is unchanged, and `RunCost.tokens == RunMetrics.tokens` still holds.
13+
- the `.env` credential loader **walked up parent directories to `/`**, while the config layer next door refuses exactly that on principle — so the file that *spends money* was discovered more eagerly than the one that *constrains* a run. A run started in a scratch subdirectory picked up an `OPENROUTER_API_KEY` from any ancestor: a `.env` in `$HOME` billed every user's experiment on a shared box to that key, a demo checked out under a client project quietly used the client's key, and since `redact()` is the only thing that ever prints a key, nothing in normal operation said *which file paid*. The rationale `cli/config.py` wrote down for `grapharc.toml` — "a run must never be silently governed by a file in a directory you didn't know about" — applies with more force to the file that pays than to the file that restrains, so `find_env_file` now reads the start directory (default: the working directory) and no ancestor of it. **This is a behaviour change:** anyone relying on a parent-directory `.env` must move it into the directory they run from, `export` the variable, or pass `env_file=` naming the file. Neither escape hatch moved — a real environment variable still beats any file, and an explicit `env_file=` still reads a file anywhere on disk — and no "search boundary" was added in place of the walk, because stopping at a git root is still an upward search.
1314

1415
## 0.1.3
1516

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ Re-derived on 2026-07-28 by running each item, not by reading the commit log.
484484
- **A planning round is an envelope, not a measurement.** A `round` event used to carry the planner's `tokens` and the round's `duration_ms`, both of which `metrics`, `cost` and `replay` add on top of node totals — and the planner's spend was already reported by its own `plan` event, so it was counted twice, and a round's duration encloses the plan plus every node it ran. Neither is on the event now; both are on its `state_delta` as `round_tokens` / `round_iterations` / `round_duration_ms`, where no reader sums them. `RoundRecord.iterations` also holds a figure now rather than always `0`.
485485
- **The Claude CLI backend is completion-only, and an agent node on it is *delegated* rather than governed.** The CLI has no tool-calling wire format, so GraphARC cannot run its own gated loop over it. Rather than refuse, `AgentNode` hands the whole loop to Claude Code's headless agent — which means every tool Claude Code has, under its `bypassPermissions` mode: those calls are not checked by this graph's permission policy, not confined by the sandbox executor, and the token figure is the sub-agent's own rather than one GraphARC metered call by call. The workspace boundary and the wall-clock ceiling still hold. It warns on `DelegatedToolUseWarning` at construction and marks every trace event `executor=delegated`, so a run stays auditable as delegated; filter that warning to an error to get the old refusal back. Structured output still needs an OpenAI-wire backend: `openrouter`, `openai`, or a local `ollama`.
486486
- **A session turn is synchronous**, and a runner claim is a claim rather than a lease — nothing reclaims a session whose runner died holding it.
487-
- **`.env` is found by walking up parent directories; `grapharc.toml` is not.** The config layer refuses an upward search on purpose — a run must not be governed by a file you did not know about. The credential loader predates that decision and still searches upward, so the thing that *spends money* is discovered more eagerly than the thing that *constrains* it.
487+
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
488488
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.
489489

490490
**Verified this pass:** `pytest` → 1,754 passed, 12 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all 116 submodules in a clean virtualenv with `[all]`. The test count is a snapshot, not a property of the project — `pytest` re-derives it in one command, which is the only reason it is quoted.

docs/cookbook/02-models.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@ class Verdict(BaseModel):
316316

317317
# The dummy key is only so this snippet runs offline; nothing below opens a
318318
# socket. Drop `api_key=` and the backend reads OPENROUTER_API_KEY from the
319-
# environment or the nearest .env.
319+
# environment, or from a .env in the directory you run from — parent
320+
# directories are never searched.
320321
model = get_model("openrouter/openai/gpt-4o-mini", api_key="sk-or-not-a-real-key")
321322

322323
print(model._llm_type, "|", model.model_name)

docs/cookbook/07-slack.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ Configuration is environment-only, read once at startup:
114114
| `GRAPHARC_SLACK_LIVE_INTERVAL` | `2.5` | seconds between two edits of the status message |
115115
| `GRAPHARC_SLACK_LIVE_URL` | unset | base URL of a `grapharc serve --live-root` the requester can reach; posts a "watch live" link |
116116

117-
The bot reads tokens from the process environment only. The `.env`
118-
upward-directory search that the model gateway performs is deliberately not
119-
used here: a bot that a whole workspace can drive must not discover
120-
credentials in a file the operator did not point it at.
117+
The bot reads tokens from the process environment only. The model gateway's
118+
`.env` loader is deliberately not used here — even though it now reads the
119+
working directory alone rather than searching upward: a bot that a whole
120+
workspace can drive must not discover credentials in a file the operator did
121+
not point it at, and its working directory is somewhere other things write.
121122

122123
## Live progress
123124

grapharc/gateway/config.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
`open-router-api-key` cannot be a shell variable at all, so the file has to be
66
parsed rather than sourced.
77
8+
**The `.env` is read from one directory and no other** — the working directory,
9+
or whatever `start` names — which is the same rule `grapharc.toml` follows, and
10+
for a stronger reason. The config layer refuses an upward search because a run
11+
must never be silently governed by a policy file in a directory the operator did
12+
not know about; this file *spends money*, so a key discovered three directories
13+
up is the worse version of that failure. It used to walk to `/`, which meant a
14+
`.env` in `$HOME` billed every experiment on the box to that key, and `redact`
15+
being the only thing that ever prints a key meant nothing revealed which file
16+
paid. The escape hatches are explicit and unchanged: a real environment variable
17+
still wins over any file, and a caller can still name a file anywhere with
18+
`env_file=`.
19+
820
Secrets are returned, never logged. Anything that renders a config for humans
921
goes through `redact`.
1022
@@ -92,13 +104,9 @@ def _parse_env_file(path: Path) -> dict[str, str]:
92104

93105

94106
def find_env_file(start: Path | None = None) -> Path | None:
95-
"""Nearest `.env` walking up from `start` (default: cwd)."""
96-
here = (start or Path.cwd()).resolve()
97-
for directory in (here, *here.parents):
98-
candidate = directory / ".env"
99-
if candidate.is_file():
100-
return candidate
101-
return None
107+
"""The `.env` in `start` itself (default: cwd), or None. Parents are not read."""
108+
candidate = (start or Path.cwd()).resolve() / ".env"
109+
return candidate if candidate.is_file() else None
102110

103111

104112
def get_secret(names: tuple[str, ...], *, env_file: Path | None = None) -> str | None:

grapharc/slack/config.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""What the bot needs from its environment, read once at startup.
22
33
Tokens come from process environment variables only. The gateway's `.env`
4-
loader is deliberately not used here: it searches parent directories upward
5-
(the subject of issue #20), and a bot that anyone in a Slack workspace can
6-
drive must not pick up credentials from a file the operator did not point it
7-
at. `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are exported in the shell that
8-
starts the bot, and nowhere else.
4+
loader is deliberately not used here even now that it reads one directory
5+
rather than walking upward (issue #20): a bot that anyone in a Slack workspace
6+
can drive must not pick up credentials from a file the operator did not point
7+
it at, and the bot's working directory is somewhere other things write.
8+
`SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are exported in the shell that starts
9+
the bot, and nowhere else.
910
"""
1011

1112
from __future__ import annotations

tests/test_gateway_openai_ollama.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
@pytest.fixture
3333
def no_credentials(monkeypatch, tmp_path):
34-
"""No key in the environment and no .env anywhere up the tree."""
34+
"""No key in the environment, and a working directory holding no .env."""
3535
for name in (*OPENAI_ENV, *OLLAMA_ENV):
3636
monkeypatch.delenv(name, raising=False)
3737
monkeypatch.chdir(tmp_path)

tests/test_gateway_openrouter.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,56 @@ def test_process_env_beats_the_file(tmp_path, monkeypatch):
5353
assert config.openrouter_api_key(env_file=env) == "from-env"
5454

5555

56+
def test_a_parent_directory_dotenv_is_never_read(tmp_path, monkeypatch):
57+
"""The rule `grapharc.toml` follows, on the file that spends money.
58+
59+
The loader used to walk to `/`, so a run started three directories below a
60+
`.env` — a scratch subdirectory under a client checkout, a `$HOME` one on a
61+
shared box — silently billed against a key the operator never put in scope.
62+
Nothing prints which file paid, so there was no way to notice. The start
63+
directory is now the only directory consulted.
64+
"""
65+
for name in config.OPENROUTER_KEYS:
66+
monkeypatch.delenv(name, raising=False)
67+
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=sk-or-parent\n", encoding="utf-8")
68+
deep = tmp_path / "deeply" / "nested" / "project"
69+
deep.mkdir(parents=True)
70+
71+
assert config.find_env_file(deep) is None
72+
monkeypatch.chdir(deep)
73+
assert config.find_env_file() is None
74+
assert config.openrouter_api_key() is None
75+
76+
# ... and the one directory that *is* consulted still is.
77+
(deep / ".env").write_text("OPENROUTER_API_KEY=sk-or-here\n", encoding="utf-8")
78+
assert config.find_env_file() == (deep / ".env").resolve()
79+
assert config.openrouter_api_key() == "sk-or-here"
80+
81+
82+
def test_an_explicit_env_file_is_read_wherever_it_lives(tmp_path, monkeypatch):
83+
"""The escape hatch for a file outside the working directory: name it."""
84+
for name in config.OPENROUTER_KEYS:
85+
monkeypatch.delenv(name, raising=False)
86+
elsewhere = tmp_path / "secrets"
87+
elsewhere.mkdir()
88+
(elsewhere / ".env").write_text("OPENROUTER_API_KEY=sk-or-named\n", encoding="utf-8")
89+
run_from = tmp_path / "project"
90+
run_from.mkdir()
91+
monkeypatch.chdir(run_from)
92+
93+
assert config.openrouter_api_key(env_file=elsewhere / ".env") == "sk-or-named"
94+
95+
96+
def test_a_process_variable_beats_a_dotenv_in_the_working_directory(tmp_path, monkeypatch):
97+
"""Narrowing discovery did not reorder precedence: the environment wins."""
98+
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=sk-or-from-file\n", encoding="utf-8")
99+
monkeypatch.chdir(tmp_path)
100+
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-from-env")
101+
102+
assert config.find_env_file() == (tmp_path / ".env").resolve()
103+
assert config.openrouter_api_key() == "sk-or-from-env"
104+
105+
56106
def test_missing_key_returns_none_not_a_crash(tmp_path, monkeypatch):
57107
for name in config.OPENROUTER_KEYS:
58108
monkeypatch.delenv(name, raising=False)
@@ -72,7 +122,7 @@ def test_redact_never_leaks_a_usable_key(secret):
72122
def test_constructing_without_a_key_explains_how_to_fix_it(tmp_path, monkeypatch):
73123
for name in config.OPENROUTER_KEYS:
74124
monkeypatch.delenv(name, raising=False)
75-
monkeypatch.chdir(tmp_path) # no .env anywhere up the tree
125+
monkeypatch.chdir(tmp_path) # an empty directory, so no .env to find
76126
with pytest.raises(OpenRouterError, match="OPENROUTER_API_KEY"):
77127
OpenRouterChatModel("openai/gpt-4o-mini")
78128

0 commit comments

Comments
 (0)