Skip to content

[P3] engine_preflight silently mutates .env — make the write announced, opt-in, and non-destructive #14

Description

@anombyte93

Problem

Running engine_preflight (the mcp__atlas-engine__engine_preflight MCP tool / engine-preflight CLI subcommand) silently appends keys to the project's .env file. A "preflight"/"doctor"/"check" step is expected to be read-only — it should inspect state, not mutate user files without warning. Today the only way to stop the .env write is the all-or-nothing --no-configure flag, which also disables the wanted .taskmaster/config.json provider setup. The write should be (a) off by default during preflight, (b) separately opt-in from the rest of provider configuration, and (c) loudly announced whenever it does happen.

Current Behavior

engine_preflight is dispatched via cmd_engine_preflightrun_engine_preflight(configure=...) in prd_taskmaster/batch.py. When configure=True (the default; only flipped off by --no-configure) AND a .taskmaster/ project already exists, run_engine_preflight calls run_configure_providers() (batch.py:85-89).

Inside run_configure_providers (prd_taskmaster/providers.py:151-254), when the research role resolves to the local Perplexity-free proxy (_is_local_perplexity_free(...) true — provider in {openai-compatible, perplexity} plus a 127.0.0.1:8765 / localhost:8765 / perplexity-api-free baseURL), it appends two keys to .env via _ensure_env_entry(Path(".env"), ...):

  • OPENAI_COMPATIBLE_API_KEY="local-perplexity-api-free" (providers.py:223-224)
  • PERPLEXITY_API_BASE_URL="<local proxy url>" (providers.py:225-226)

and mirrors both into .env.example if that file exists (providers.py:227-241).

The low-level primitive _ensure_env_entry (prd_taskmaster/lib.py:275-284) opens the file in append mode and writes KEY="value". It is already non-clobbering — guarded by _env_file_has_key (lib.py:268-272), it returns False/no-op if the key already exists (lib.py:277), and it appends a trailing newline safely (lib.py:280-283). So "never overwrite an existing value" is already satisfied.

The genuine defects:

  1. ON by default — the write fires during a normal engine_preflight with no opt-in.
  2. Not separately opt-in — only --no-configure (cli.py:143) disables it, and that also suppresses the wanted config.json writes.
  3. Silent / unannounced — the only signal is a ".env:OPENAI_COMPATIBLE_API_KEY" / ".env:PERPLEXITY_API_BASE_URL" string buried in the changed list inside the returned providers_configured dict. The human-readable summary (batch.py:94-115) never mentions that .env was touched. (Note: the docstring at batch.py:75 even claims the call is "Read-only on a bare directory" — but it is not read-only once a project exists.)

The values written are non-secret (a dummy local-perplexity-api-free key and a localhost URL), and the trigger is narrow, but the mutation being on-by-default and invisible is the issue.

Expected Behavior

engine_preflight should be read-only with respect to the user's .env. It should still detect and report that the research role resolves to the local Perplexity-free proxy, and surface the would-be .env change as a pending/planned action in the human-readable summary, naming the exact keys and the command to run to apply it. The actual .env write should only happen via an explicit, opt-in step (a dedicated flag or the standalone configure-providers subcommand), and when it does run it must print an explicit "Wrote .env: KEY1, KEY2" line. The non-clobbering behavior (_ensure_env_entry skips existing keys) must be preserved.

Files to Touch

  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/providers.pyrun_configure_providers (L151-254). Add an apply_env: bool = True parameter. Guard the .env/.env.example write block (L222-241) on apply_env; when apply_env is False, push a ".env:PENDING:OPENAI_COMPATIBLE_API_KEY" / ".env:PENDING:PERPLEXITY_API_BASE_URL" marker into changed instead of writing. PRIMARY change site.
  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/batch.pyrun_engine_preflight (L72-126). Call run_configure_providers(apply_env=False) so preflight is read-only on .env. In the summary builder (L94-115), detect .env:PENDING:* markers in providers_configured["changed"] and append a human line naming the keys + the apply command. Also add an env_writes / env_pending field to the returned dict. Thread any new opt-in flag from cmd_engine_preflight (L129-133).
  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/cli.py — engine-preflight subparser (L142-143). If using the dedicated-flag approach, register --write-env (or --no-write-env) next to --no-configure and thread args.write_env through cmd_engine_preflight. The standalone configure-providers subcommand (DISPATCH ~L302-309, cmd_configure_providers at providers.py:257) keeps apply_env=True so it performs the real write.
  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/lib.py_ensure_env_entry (L275-284), _env_file_has_key (L268-272), _is_local_perplexity_free (L320-334). No change required unless you choose to add logging at this chokepoint; already non-clobbering. Reference only.
  • /home/anombyte/Shade_Gen/Projects/prd-taskmaster-plugin/mcp-server/pipeline.pypreflight (L179-234). NOT the code to change; the stale atlas-go plugin's read-only preflight, included only to disambiguate (it never touches .env). Flag if the team wants to retire this duplicate.

Researched Fix Approaches

1. [Recommended] — Make engine_preflight read-only; move the .env write into the explicit configure step (with announce) (confidence: 86%)

  • Library/Config: stdlib argparse (cli.py); no new dependency. Verified task-master-ai convention: it reads .env only and never auto-writes it.
  • Pattern: plan/preview vs apply separation (Terraform planapply, Ansible --check, clig.dev). A preflight/doctor/check command must be read-only; surface the .env change as a planned action, write it only via the existing standalone configure-providers subcommand.
  • Why: Restores parity with the tool being wrapped — task-master-ai only reads .env, so the wrapper's silent append is behavior the upstream tool does not have. The escape hatch already exists (configure-providers is a separate subcommand), giving the write a natural opt-in home preflight need not trigger.
  • Risk: Behavioral change — callers/MCP hosts that relied on preflight auto-writing .env (so the local-perplexity-free path "just works" after one preflight) now need an explicit apply step. Mitigate by making the preflight summary print the exact command to run. Note: the config.json write at providers.py:244 is also a mutation during preflight, but it is lower-stakes (tool-owned dir, not user .env) and out of scope here.
  • Implementation hint:
# providers.py — run_configure_providers signature + guard
def run_configure_providers(economy: str | None = None, apply_env: bool = True) -> dict:
    ...
    if _is_local_perplexity_free(models.get("research", {})):
        if apply_env:
            if _ensure_env_entry(Path(".env"), "OPENAI_COMPATIBLE_API_KEY", "local-perplexity-api-free"):
                changed.append(".env:OPENAI_COMPATIBLE_API_KEY")
            if _ensure_env_entry(Path(".env"), "PERPLEXITY_API_BASE_URL", local_proxy_url):
                changed.append(".env:PERPLEXITY_API_BASE_URL")
            # ... .env.example mirror unchanged ...
        else:
            changed.append(".env:PENDING:OPENAI_COMPATIBLE_API_KEY")
            changed.append(".env:PENDING:PERPLEXITY_API_BASE_URL")

# batch.py — run_engine_preflight
providers_configured = run_configure_providers(apply_env=False)
...
pending = [m for m in (providers_configured or {}).get("changed", []) if m.startswith(".env:PENDING:")]
if pending:
    keys = ", ".join(m.split(":", 2)[2] for m in pending)
    summary.append(f"Pending .env: {keys} (run `configure-providers` to write)")
# cmd_configure_providers keeps apply_env=True (default) so the standalone command really writes.

2. [Alternative] — Dedicated, independent --write-env / --no-write-env opt-in flag (default OFF), always announce (confidence: 80%)

  • Library/Config: stdlib argparse only. Flag naming verified against clig.dev (--dry-run/-n, --no-<x> negation, explicit --write/--apply for mutations).
  • Pattern: explicit opt-in side-effect flag, orthogonal to the existing --no-configure. Register --write-env (action="store_true", default False) on the engine-preflight subparser; thread args.write_envcmd_engine_preflightrun_engine_preflight(write_env=...)run_configure_providers(apply_env=write_env). Print "Wrote .env: KEY1, KEY2" whenever a write occurs.
  • Why: Directly fixes all three named defects: ON-by-default → OFF, all-or-nothing → its own flag orthogonal to --no-configure (you can still get config.json without the .env append), silent → always announced. Lowest blast radius — config.json behavior untouched.
  • Risk: A purist reading of "preflight must be read-only" dislikes that preflight technically CAN still mutate (when --write-env is passed). Requires the separate MCP host (which shells script.py engine-preflight) to learn the new flag for the write to ever fire from MCP — confirm the host can thread flags, or the write becomes unreachable from MCP.
  • Implementation hint:
# cli.py
p.add_argument("--write-env", action="store_true",
    help="Opt in to appending OPENAI_COMPATIBLE_API_KEY / PERPLEXITY_API_BASE_URL to .env (local Perplexity-free proxy only)")
# cmd_engine_preflight
run_engine_preflight(configure=not args.no_configure, write_env=getattr(args, "write_env", False))
# batch.py summary, after a real write:
summary.append(f"Wrote .env: {', '.join(written_keys)}")

3. [Fallback] — Announce-only: keep auto-write but surface every .env mutation in the summary + a structured env_writes field (confidence: 58%)

  • Library/Config: stdlib only; relies on the existing changed markers at providers.py:224/226/234/241.
  • Pattern: non-destructive-by-virtue-of-already-idempotent + loud logging. No default change, no new flag — batch.py reads providers_configured["changed"], extracts .env:* markers, adds an explicit summary line and an env_writes top-level field.
  • Why: Smallest, lowest-risk diff resolving the single most-cited defect (silent/unannounced). The write is already non-clobbering and idempotent and the values are non-secret, so "never clobber" already holds — only the announcement is missing.
  • Risk: Does NOT satisfy the opt-in requirement; the write stays ON by default during a preflight, which violates the read-only convention and upstream parity. Likely rejected if the intent is "preflight must not write user files at all." Treat as a stopgap or complement to Approach 1/2.
  • Implementation hint:
# batch.py — run_engine_preflight, after computing providers_configured
env_writes = [m for m in (providers_configured or {}).get("changed", []) if m.startswith(".env")]
if env_writes:
    summary.append("Modified " + ", ".join(env_writes))
# add 'env_writes': env_writes to the returned dict

Reference

The wrapped tool task-master-ai (claude-task-master by eyaltoledano) is the decisive reference: it reads API keys EXCLUSIVELY from .env (CLI) or the MCP env block and — verified against its docs and .env.example on main — NEVER auto-writes the user's .env. Its setup/models flow creates only .taskmaster/config.json; keys/baseURLs are documented for the user to add themselves. So Atlas's engine_preflight is MORE intrusive than the tool it wraps. Aligning the wrapper (preflight = read-only; .env writes only via an explicit, announced step) restores parity. Broader CLI convention (clig.dev, Terraform planapply, Ansible --check, AWS --dry-run): doctor/preflight/check commands are read-only, mutations need a distinct apply/--write step or confirmation, and every file write must be announced. NOTE: upstream task-master-ai does NOT document OPENAI_COMPATIBLE_API_KEY (the key the Atlas wrapper writes) — verify what task-master's openai-compatible provider actually reads before relying on that key name.

Sources: task-master-ai .env.example and configuration-advanced docs; clig.dev; Terraform plan/apply; "In Praise of Dry Run" (henrikwarne.com).

Acceptance Criteria

  • Running engine_preflight (default flags) on a project where .taskmaster/config.json resolves research to the local Perplexity-free proxy does NOT append OPENAI_COMPATIBLE_API_KEY or PERPLEXITY_API_BASE_URL to .env — verify .env byte-for-byte unchanged before/after (git diff --exit-code .env).
  • In that same default-flags run, the human-readable summary (and/or a structured env_pending/env_writes field in the returned dict) explicitly names the two keys and tells the operator how to apply them (e.g. references configure-providers or --write-env).
  • The opt-in path (standalone configure-providers, OR engine-preflight --write-env, per chosen approach) DOES write both keys to .env and prints an explicit "Wrote .env: ..." style line naming the keys.
  • Re-running the opt-in write a second time is a no-op (idempotent): _ensure_env_entry still returns False for existing keys, .env is unchanged, and no spurious "Wrote .env" line is emitted.
  • A pre-existing OPENAI_COMPATIBLE_API_KEY or PERPLEXITY_API_BASE_URL with a different value in .env is NEVER overwritten by either path (non-clobber preserved).
  • engine_preflight --no-configure still suppresses ALL provider configuration including the config.json writes (existing behavior unchanged), and the new .env opt-in is orthogonal to it (you can get config.json configuration without the .env write).
  • When apply_env=False/preview, .taskmaster/config.json is still written/updated as before — only the .env mutation is deferred.
  • parse_prd succeeds and writes tasks to a fresh tag when invoked from inside a Claude Code / MCP session after running the read-only engine_preflight followed by the explicit configure step (end-to-end the research path still works once .env is applied).
  • The misleading docstring at batch.py:75 ("Read-only on a bare directory") is updated to accurately reflect that preflight no longer mutates .env.

Complexity: S

Trust Level: HINT (not specification)

The researched approaches above are starting points. Before implementing:

  1. Verify the library/config exists as stated (e.g. task-master models, read .taskmaster/config.json; confirm whether the MCP host that exposes mcp__atlas-engine__engine_preflight can pass CLI flags before choosing Approach 2).
  2. Check that imports/keys match reality (especially whether task-master's openai-compatible provider reads OPENAI_COMPATIBLE_API_KEY).
  3. Try the recommended approach — if it works in 1-2 attempts, use it.
  4. If it fails, do NOT keep retrying — research why, explore alternatives.
  5. The acceptance criteria are the real spec, not the approach.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    configProvider / config handlingdxDeveloper experienceenhancementNew feature or requestpreflightPreflight / setup validation

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions