Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.schema
Original file line number Diff line number Diff line change
@@ -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
# ---
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Environment variables (contains API keys - NEVER commit)
.env
.env.local
.env.*.local

# macOS / local scratch
.DS_Store
Expand Down
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>`.
- 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.

Expand Down
35 changes: 27 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<repo-slug>.local` files are mode `0600`.

```bash
cp .env.example .env
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/varlock/missing/.env.schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @import(../values/.env.fixture-absent, pick=[RAFIKI_SHARED_FIXTURE], allowMissing=true)
# @defaultRequired=false
# @defaultSensitive=false
# ---

# @sensitive
RAFIKI_SHARED_FIXTURE=
8 changes: 8 additions & 0 deletions tests/fixtures/varlock/present/.env.schema
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions tests/fixtures/varlock/scan-target.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Sanitized fixture target for Varlock scan coverage.
1 change: 1 addition & 0 deletions tests/fixtures/varlock/values/.env.fixture-rafiki
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RAFIKI_SHARED_FIXTURE=fixture-rafiki-override-value
1 change: 1 addition & 0 deletions tests/fixtures/varlock/values/.env.fixture-shared
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RAFIKI_SHARED_FIXTURE=fixture-shared-contract-value
155 changes: 155 additions & 0 deletions tests/test_varlock_contract.py
Original file line number Diff line number Diff line change
@@ -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)
Loading