diff --git a/.env.schema b/.env.schema index 066e491..745464b 100644 --- a/.env.schema +++ b/.env.schema @@ -1,5 +1,7 @@ # Rafiki environment contract for agents and local operators. -# Real values belong in an untracked .env/.env.local file or shell environment. +# Real values belong in user-managed or untracked local value files. +# @import(~/.agents/env/values/.env.shared.local, pick=[GOOGLE_API_KEY, OPENAI_API_KEY, FLOYO_KEY, REPLICATE_API_TOKEN, NOTION_API_KEY], allowMissing=true) +# @import(~/.agents/env/values/.env.rafiki.local, pick=[GOOGLE_API_KEY, OPENAI_API_KEY, FLOYO_KEY, REPLICATE_API_TOKEN, NOTION_API_KEY], allowMissing=true) # @defaultRequired=false # @defaultSensitive=false # --- diff --git a/.gitignore b/.gitignore index 8a3e1d5..521238c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Environment variables (contains API keys - NEVER commit) .env +.env.local +.env.*.local # macOS / local scratch .DS_Store diff --git a/AGENTS.md b/AGENTS.md index fc3dd69..efdd4db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,15 +3,20 @@ ## Environment Safety - Treat `.env.schema`, `.env.example`, committed documentation, and code - references as the only agent-readable environment contract. -- Never read, open, copy, search, or print `.env`, `.env.local`, or other - ignored environment value files. + references and sanitized fixtures as the only agent-readable environment + contract. +- Never read, open, copy, search, or print `.env*` value files, including the + user-managed files under `~/.agents/env/values/`. - Never run `env`, `printenv`, `varlock encrypt`, `varlock reveal`, raw `varlock load`, or code that dumps `process.env` or `os.environ`. - Every Varlock load must use `varlock load --agent`; add `--show-all` when a complete redacted validation report is needed. -- Run provider-capable child commands through +- Run secret-dependent child commands through `varlock run --inject vars -- `. +- Use `npm run env:audit` so generated and non-source paths stay excluded, and + use staged-only `npm run env:scan` without `--include-ignored`. +- Keep Rafiki's Node 20 application runtime unchanged. Run Varlock 1.10 with + Node 22.3+ or the standalone CLI. - Do not change provider or platform values, rotate credentials, deploy, or cross a `needs-human` gate. diff --git a/README.md b/README.md index 5e9696a..4c3c241 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,22 @@ python3 -m venv .venv ### 2. Add provider keys Rafiki commits `.env.schema` as the agent-readable environment contract. Keep -real values in your shell environment or an untracked `.env` file; agents should -validate the contract without reading `.env` directly. -The app itself still targets Node 20; run the Varlock CLI from an agent or -operator shell that has Varlock available on `PATH`. +real values in your shell environment, an untracked repo-local value file, or +the user-managed shared directory at `~/.agents/env/values/`. Agents validate +the contract without reading any value file directly. + +The schema optionally imports reusable provider credentials from +`.env.shared.local`, followed by `.env.rafiki.local` for repo-specific +overrides. Both imports use `pick` allowlists and `allowMissing=true`, so Rafiki +keeps its own declarations authoritative and cloud or CI checks do not depend +on this Mac. The imported allowlist is limited to `GOOGLE_API_KEY`, +`OPENAI_API_KEY`, `FLOYO_KEY`, `REPLICATE_API_TOKEN`, and `NOTION_API_KEY`. +Portal credentials, Notion database IDs, executable paths, and Rafiki-only +settings remain app-local. + +The shared directory and value files are created and maintained by the user, +not by agents. The canonical directory is mode `0700`; `.env.shared.local` and +optional `.env..local` files are mode `0600`. ```bash cp .env.example .env @@ -70,18 +82,25 @@ Add at least one of: Varlock is intentionally kept outside Rafiki's Node dependency graph because the app supports Node 20 while the Varlock CLI has its own runtime requirements. -Install Varlock separately, then inspect and audit the contract safely: +Keep the application runtime unchanged. Run Varlock 1.10 with Node 22.3+ or use +the standalone CLI, then inspect and audit the contract safely: ```bash npm run env:validate npm run env:audit +npm run env:scan npm run env:smoke +python3 -m pytest -q tests/test_varlock_contract.py ``` The smoke checks only that the schema's non-secret sentinel reaches a child -process. It does not inspect or print the process environment. +process. The audit command explicitly excludes generated output, dependency, +cache, and generated route-tree paths. The scan command checks staged files +only and never opts into ignored output. The focused test runs redacted load, +no-op run, and scan checks against sanitized present and missing import +fixtures; it never reads the user-managed shared directory. -When an agent needs to run a real provider-capable command, inject values +When an agent needs to run a secret-dependent command, inject values through Varlock instead of reading `.env`: ```bash @@ -358,7 +377,7 @@ python generate.py link-projects `varlock load --agent --show-all` instead of reading `.env` - agents must not run `env`, `printenv`, `varlock reveal`, raw `varlock load`, or any command that dumps the process environment -- Agent-invoked commands that may make real provider calls should be wrapped +- Agent-invoked commands that require resolved secrets should be wrapped with `varlock run --inject vars --` - `python generate.py serve` binds to `127.0.0.1` by default - `--public` refuses to start unless both `PORTAL_USERNAME` and diff --git a/SECURITY.md b/SECURITY.md index 09e35f9..d58f143 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,8 +5,10 @@ control plane. Provider API keys stay on the operator's machine. ## Secrets -- Keep keys in environment variables or an untracked `.env`. -- `.env` is intentionally gitignored. +- Keep keys in the user-managed `~/.agents/env/values/` convention or an + untracked repo-local value file. +- `.env`, `.env.local`, and `.env.*.local` are intentionally gitignored. Agents + must not inspect any of those value files. - If a key was ever committed, rotate it immediately even if it has since been removed from the working tree. @@ -15,7 +17,7 @@ Before changing repository visibility or publishing a release, run: ```bash gitleaks detect --source . --redact --no-banner --verbose trufflehog git file:///Users/kk/Code/rafiki --only-verified --no-update --json -git check-ignore -v .env node_modules output .venv +git check-ignore -v .env .env.local .env.rafiki.local node_modules output .venv ``` Known false positives should be documented in the dated release checklist and diff --git a/package.json b/package.json index f25cbe9..34850fa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "render": "node index.js --render", "doctor": "node index.js --doctor", "env:validate": "varlock load --agent --show-all", - "env:audit": "varlock audit", + "env:audit": "varlock audit . --ignore node_modules --ignore frontend/node_modules --ignore .venv --ignore .pytest_cache --ignore .ruff_cache --ignore __pycache__ --ignore output --ignore outputs --ignore assets --ignore prompts --ignore examples/images --ignore .rafiki-cache --ignore frontend/.output --ignore frontend/.vinxi --ignore frontend/.wrangler --ignore frontend/src/routeTree.gen.ts", "env:scan": "varlock scan --staged", "env:smoke": "varlock run --inject vars -- node scripts/varlock-injection-smoke.mjs", "test": "node scripts/run-pytest.js", diff --git a/tests/fixtures/varlock/missing/.env.schema b/tests/fixtures/varlock/missing/.env.schema new file mode 100644 index 0000000..7ddb52e --- /dev/null +++ b/tests/fixtures/varlock/missing/.env.schema @@ -0,0 +1,7 @@ +# @import(../values/.env.fixture-absent, pick=[RAFIKI_SHARED_FIXTURE], allowMissing=true) +# @defaultRequired=false +# @defaultSensitive=false +# --- + +# @sensitive +RAFIKI_SHARED_FIXTURE= diff --git a/tests/fixtures/varlock/present/.env.schema b/tests/fixtures/varlock/present/.env.schema new file mode 100644 index 0000000..2cb028d --- /dev/null +++ b/tests/fixtures/varlock/present/.env.schema @@ -0,0 +1,8 @@ +# @import(../values/.env.fixture-shared, pick=[RAFIKI_SHARED_FIXTURE], allowMissing=true) +# @import(../values/.env.fixture-rafiki, pick=[RAFIKI_SHARED_FIXTURE], allowMissing=true) +# @defaultRequired=false +# @defaultSensitive=false +# --- + +# @sensitive +RAFIKI_SHARED_FIXTURE= diff --git a/tests/fixtures/varlock/scan-target.txt b/tests/fixtures/varlock/scan-target.txt new file mode 100644 index 0000000..179c964 --- /dev/null +++ b/tests/fixtures/varlock/scan-target.txt @@ -0,0 +1 @@ +Sanitized fixture target for Varlock scan coverage. diff --git a/tests/fixtures/varlock/values/.env.fixture-rafiki b/tests/fixtures/varlock/values/.env.fixture-rafiki new file mode 100644 index 0000000..fd7cdde --- /dev/null +++ b/tests/fixtures/varlock/values/.env.fixture-rafiki @@ -0,0 +1 @@ +RAFIKI_SHARED_FIXTURE=fixture-rafiki-override-value diff --git a/tests/fixtures/varlock/values/.env.fixture-shared b/tests/fixtures/varlock/values/.env.fixture-shared new file mode 100644 index 0000000..95f0e1a --- /dev/null +++ b/tests/fixtures/varlock/values/.env.fixture-shared @@ -0,0 +1 @@ +RAFIKI_SHARED_FIXTURE=fixture-shared-contract-value diff --git a/tests/test_varlock_contract.py b/tests/test_varlock_contract.py new file mode 100644 index 0000000..3c699d5 --- /dev/null +++ b/tests/test_varlock_contract.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "tests" / "fixtures" / "varlock" +VARLOCK = shutil.which("varlock") +REUSABLE_KEYS = ( + "GOOGLE_API_KEY", + "OPENAI_API_KEY", + "FLOYO_KEY", + "REPLICATE_API_TOKEN", + "NOTION_API_KEY", +) +APP_LOCAL_KEYS = ( + "GEMINI_API_KEY", + "PUPPETEER_EXECUTABLE_PATH", + "CHROME_PATH", + "GOOGLE_CHROME_BIN", + "RAFIKI_DOCTOR_PYTHON", + "RAFIKI_E2E_ARTIFACT_DIR", + "PORTAL_USERNAME", + "PORTAL_PASSWORD", + "NOTION_DATABASE_ID", + "RAFIKI_VARLOCK_SMOKE", +) +SANITIZED_VALUES = ( + "fixture-shared-contract-value", + "fixture-rafiki-override-value", +) + + +def test_schema_selectively_imports_reusable_values() -> None: + schema = (ROOT / ".env.schema").read_text(encoding="utf-8") + picked = ", ".join(REUSABLE_KEYS) + imports = [line for line in schema.splitlines() if line.startswith("# @import(")] + + assert imports == [ + f"# @import(~/.agents/env/values/.env.shared.local, pick=[{picked}], allowMissing=true)", + f"# @import(~/.agents/env/values/.env.rafiki.local, pick=[{picked}], allowMissing=true)", + ] + for key in (*REUSABLE_KEYS, *APP_LOCAL_KEYS): + assert f"{key}=" in schema + for key in APP_LOCAL_KEYS: + assert all(key not in import_line for import_line in imports) + + +def test_audit_and_scan_scripts_exclude_non_source_artifacts() -> None: + package = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) + audit = package["scripts"]["env:audit"] + + for path in ( + "node_modules", + "frontend/node_modules", + ".venv", + "output", + "outputs", + "assets", + "prompts", + "examples/images", + ".rafiki-cache", + "frontend/.output", + "frontend/.vinxi", + "frontend/.wrangler", + "frontend/src/routeTree.gen.ts", + ): + assert f"--ignore {path}" in audit + assert package["scripts"]["env:scan"] == "varlock scan --staged" + + +def test_agent_guidance_requires_safe_varlock_commands() -> None: + instructions = (ROOT / "AGENTS.md").read_text(encoding="utf-8") + + assert "secret-dependent child commands" in instructions + assert "varlock run --inject vars" in instructions + assert "npm run env:audit" in instructions + assert "staged-only `npm run env:scan`" in instructions + assert "Node 22.3+ or the standalone CLI" in instructions + + +@pytest.mark.skipif(VARLOCK is None, reason="standalone Varlock CLI is not installed") +def test_sanitized_imports_load_run_and_scan_without_exposing_values() -> None: + version = subprocess.run( + [str(VARLOCK), "--version"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert tuple(int(part) for part in version.split(".")[:3]) >= (1, 10, 0) + + scan_target = FIXTURES / "scan-target.txt" + for fixture_name in ("present", "missing"): + fixture = FIXTURES / fixture_name + _run_varlock("load", "--agent", "--show-all", "--path", fixture) + _run_varlock( + "run", + "--inject", + "vars", + "--path", + fixture, + "--", + sys.executable, + "-c", + "pass", + ) + _run_varlock("scan", scan_target, cwd=fixture) + + _run_varlock( + "run", + "--inject", + "vars", + "--path", + FIXTURES / "present", + "--", + sys.executable, + "-c", + ( + "import os,sys;" + "sys.exit(os.environ.get('RAFIKI_SHARED_FIXTURE') != " + "'fixture-rafiki-override-value')" + ), + ) + _run_varlock( + "run", + "--inject", + "vars", + "--path", + FIXTURES / "missing", + "--", + sys.executable, + "-c", + "import os,sys;sys.exit(os.environ.get('RAFIKI_SHARED_FIXTURE') is not None)", + ) + + +def _run_varlock(*arguments: str | Path, cwd: Path = ROOT) -> None: + result = subprocess.run( + [str(VARLOCK), *(str(argument) for argument in arguments)], + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + output = result.stdout + result.stderr + + assert result.returncode == 0, output + assert not any(value in output for value in SANITIZED_VALUES)