Skip to content

gateway: the .env credential loader searches parent directories upward; grapharc.toml deliberately does not #20

Description

@Shashankss1205

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-101find_env_file walks (here, *here.parents)
  • grapharc/gateway/config.py:104-117get_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

  1. 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.
  2. Keep the function's signature and None-when-absent contract so get_secret and the four backend accessors need no changes.
  3. 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.
  4. 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

  • find_env_file returns a .env from the start directory only; ancestors are never consulted
  • Process environment variables and explicit env_file= behave exactly as before
  • gateway/config.py docstring and README.md:501 no longer describe an upward search
  • The behaviour change is called out where a user will see it (README limits section / changelog)
  • uv run pytest stays green and uv run ruff check . is clean
  • Any README or cookbook sentence this changes is updated in the same pull request

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions