Summary
The credential loader discovers .env by walking up parent directories; the config layer refuses exactly that on principle. grapharc/gateway/config.py:
def find_env_file(start: Path | None = None) -> Path | None:
"""Nearest `.env` walking up from `start` (default: cwd)."""
here = (start or Path.cwd()).resolve()
for directory in (here, *here.parents):
candidate = directory / ".env"
if candidate.is_file():
return candidate
return None
while grapharc/cli/config.py states the house rule:
**It does not walk up parent directories, and that is deliberate.** git, npm and
cargo all search upward, and for a build tool that is right. For a tool whose
config decides which nodes may write which fields ... a run must never be
silently governed by a policy file in a directory you didn't know about.
README already names the contradiction (README.md:501): "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." Verified live — from a working directory three levels below a .env, the key resolves:
cwd: .../envdemo/deeply/nested/project
found .env at: .../envdemo/.env
key resolved: sk-or-parent-secret-1234567890
Why this matters
The asymmetry points the wrong way round. grapharc.toml — which only constrains a run — must sit in the working directory or be named with --config; but an OPENROUTER_API_KEY in any ancestor directory is picked up silently, so a run started in a scratch subdirectory spends against a key the operator never knew was in scope. Concretely: a shared box with a .env in $HOME bills every user's experiment to that key; a demo checked out under a client project directory quietly uses the client's key; and because redact() is the only thing that ever prints it, nothing in normal operation reveals which file paid. The rationale the config layer wrote down — "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 spends money than to the one that constrains it.
Where in the code
grapharc/gateway/config.py:94-101 — find_env_file walks (here, *here.parents)
grapharc/gateway/config.py:104-117 — get_secret calls it whenever no explicit env_file is passed, which is how every backend (openrouter_api_key, openai_api_key, ollama_api_key, the base-URL lookups) resolves
grapharc/cli/config.py:13-14 — the deliberate no-upward-search rule the loader contradicts
README.md:501 — the documented inconsistency this issue closes
Confirm it:
cd "$(mktemp -d)" && printf 'OPENROUTER_API_KEY=sk-or-parent-secret\n' > .env \
&& mkdir -p deeply/nested/project && cd deeply/nested/project \
&& uv run --project /path/to/GraphARC python -c \
"from grapharc.gateway.config import openrouter_api_key; print(openrouter_api_key())"
# prints sk-or-parent-secret, read from three directories above cwd
What to change
- Make
find_env_file look in the starting directory only (default: cwd) — no parent walk — matching the config layer's rule. The explicit escape hatches survive unchanged: real environment variables still win (get_secret checks os.environ first), and a caller can still pass env_file= to name a file anywhere.
- Keep the function's signature and
None-when-absent contract so get_secret and the four backend accessors need no changes.
- Update the module docstring in
gateway/config.py and the README.md:501 bullet — the inconsistency paragraph becomes a statement that both files follow the same discovery rule.
- Note the behaviour change loudly in the changelog/README: anyone relying on a parent-directory
.env must move it, export the variable, or pass env_file=.
Deliberately out of scope: changing grapharc.toml discovery (its no-upward-search stance is correct and stays), adding new key spellings, and any new "search boundary" mechanism (stopping at a git root is still an upward search).
How to verify
uv run pytest tests/test_gateway.py tests/test_config.py -q
uv run pytest -q
uv run ruff check .
New tests in the gateway config tests: (a) a .env in a parent of start is not found while one in start itself is; (b) get_secret with an explicit env_file= in an unrelated directory still resolves; (c) a process environment variable still beats a .env in cwd. Revert the one-line walk and watch (a) go red.
Acceptance criteria
Skill level — good first issue
Well bounded: one loop in one function, with the exact design rationale to follow already written down in the sibling file (grapharc/cli/config.py's module docstring) and the README sentence that needs rewording quoted above. The only judgement call — whether any upward search survives — is decided here: none does. Questions welcome on the issue.
Summary
The credential loader discovers
.envby walking up parent directories; the config layer refuses exactly that on principle.grapharc/gateway/config.py:while
grapharc/cli/config.pystates the house rule:README already names the contradiction (
README.md:501): "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." Verified live — from a working directory three levels below a.env, the key resolves:Why this matters
The asymmetry points the wrong way round.
grapharc.toml— which only constrains a run — must sit in the working directory or be named with--config; but anOPENROUTER_API_KEYin any ancestor directory is picked up silently, so a run started in a scratch subdirectory spends against a key the operator never knew was in scope. Concretely: a shared box with a.envin$HOMEbills every user's experiment to that key; a demo checked out under a client project directory quietly uses the client's key; and becauseredact()is the only thing that ever prints it, nothing in normal operation reveals which file paid. The rationale the config layer wrote down — "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 spends money than to the one that constrains it.Where in the code
grapharc/gateway/config.py:94-101—find_env_filewalks(here, *here.parents)grapharc/gateway/config.py:104-117—get_secretcalls it whenever no explicitenv_fileis passed, which is how every backend (openrouter_api_key,openai_api_key,ollama_api_key, the base-URL lookups) resolvesgrapharc/cli/config.py:13-14— the deliberate no-upward-search rule the loader contradictsREADME.md:501— the documented inconsistency this issue closesConfirm it:
What to change
find_env_filelook in the starting directory only (default: cwd) — no parent walk — matching the config layer's rule. The explicit escape hatches survive unchanged: real environment variables still win (get_secretchecksos.environfirst), and a caller can still passenv_file=to name a file anywhere.None-when-absent contract soget_secretand the four backend accessors need no changes.gateway/config.pyand theREADME.md:501bullet — the inconsistency paragraph becomes a statement that both files follow the same discovery rule..envmust move it, export the variable, or passenv_file=.Deliberately out of scope: changing
grapharc.tomldiscovery (its no-upward-search stance is correct and stays), adding new key spellings, and any new "search boundary" mechanism (stopping at a git root is still an upward search).How to verify
uv run pytest tests/test_gateway.py tests/test_config.py -q uv run pytest -q uv run ruff check .New tests in the gateway config tests: (a) a
.envin a parent ofstartis not found while one instartitself is; (b)get_secretwith an explicitenv_file=in an unrelated directory still resolves; (c) a process environment variable still beats a.envin cwd. Revert the one-line walk and watch (a) go red.Acceptance criteria
find_env_filereturns a.envfrom the start directory only; ancestors are never consultedenv_file=behave exactly as beforegateway/config.pydocstring andREADME.md:501no longer describe an upward searchuv run pyteststays green anduv run ruff check .is cleanSkill level — good first issue
Well bounded: one loop in one function, with the exact design rationale to follow already written down in the sibling file (
grapharc/cli/config.py's module docstring) and the README sentence that needs rewording quoted above. The only judgement call — whether any upward search survives — is decided here: none does. Questions welcome on the issue.