diff --git a/.env.example b/.env.example index 0384a3d..57cb427 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,19 @@ NISSE_GITEA_OPERATORS=your-gitea-login # --- Running the agent (the demo, not the tests) --- # The tests use NISSE_GITEA_TEST_REPO; a running agent uses these. +# +# Layout. NISSE_WORKSPACE is the *clone the agent works in*, not the directory +# the agent runs from — they must not be the same place: +# +# ~/nisse-demo-workspace/ deployment root, not a git repo +# ├── .env this file +# ├── state.db the A2 state store +# └── project/ NISSE_WORKSPACE, the clone +# +# The agent commits its workspace with `git add -A` and pushes it, so anything +# inside the clone reaches the project repository. A .env or state store placed +# there would publish credentials as a side effect of the agent working +# correctly; startup refuses to proceed if either is inside the workspace. NISSE_STATE_DB=~/nisse-demo-workspace/state.db NISSE_WORKSPACE=~/nisse-demo-workspace/project NISSE_PROJECT=demo diff --git a/nisse-dev/src/nisse_dev/config.py b/nisse-dev/src/nisse_dev/config.py index 452af10..1b3dd51 100644 --- a/nisse-dev/src/nisse_dev/config.py +++ b/nisse-dev/src/nisse_dev/config.py @@ -71,7 +71,7 @@ class AgentConfig: raise ConfigError( "cannot start: these are unset or empty — " + ", ".join(missing) ) - return cls( + config = cls( state_db=Path(env["NISSE_STATE_DB"]).expanduser(), workspace=Path(env["NISSE_WORKSPACE"]).expanduser(), project=env["NISSE_PROJECT"], @@ -86,6 +86,30 @@ class AgentConfig: gitea_operators=_people(env["NISSE_GITEA_OPERATORS"]), base_branch=env.get("NISSE_BASE_BRANCH", "main"), ) + config._refuse_secrets_inside_the_clone(env) + return config + + def _refuse_secrets_inside_the_clone(self, env: Mapping[str, str]) -> None: + """The workspace is a git clone the agent commits with ``git add -A`` + and pushes. Anything inside it reaches the project repository, so a + state store or an env file placed there would publish credentials as a + side effect of the agent working correctly — silently, and once. + """ + workspace = self.workspace.expanduser().resolve() + offenders = [] + for label, path in ( + ("NISSE_STATE_DB", self.state_db), + ("NISSE_ENV", Path(env.get("NISSE_ENV", ".env"))), + ): + resolved = path.expanduser().resolve() + if resolved == workspace or workspace in resolved.parents: + offenders.append(f"{label} ({resolved})") + if offenders: + raise ConfigError( + "refusing to start: these sit inside the workspace clone at " + f"{workspace} and would be committed and pushed to the project " + "repository — " + ", ".join(offenders) + ) def redacted(self) -> dict[str, str]: """What the agent may safely announce about how it is configured.""" diff --git a/nisse-dev/tests/test_config.py b/nisse-dev/tests/test_config.py index 57f91e5..87f8d92 100644 --- a/nisse-dev/tests/test_config.py +++ b/nisse-dev/tests/test_config.py @@ -89,3 +89,44 @@ def test_state_db_and_workspace_are_separate(tmp_path: Path) -> None: # A2: build artefacts must not share a volume with agent state. config = AgentConfig.from_env(COMPLETE) assert config.state_db.parent != config.workspace + + +def test_state_db_inside_the_clone_is_refused(tmp_path: Path) -> None: + # git add -A would commit it, and the push would publish the agent's + # own state alongside the project. + work = tmp_path / "project" + with pytest.raises(ConfigError, match="inside the workspace"): + AgentConfig.from_env( + { + **COMPLETE, + "NISSE_WORKSPACE": str(work), + "NISSE_STATE_DB": str(work / "state.db"), + } + ) + + +def test_env_file_inside_the_clone_is_refused(tmp_path: Path) -> None: + # The severe case: the agent would commit and push its own credentials + # as a side effect of working correctly. + work = tmp_path / "project" + with pytest.raises(ConfigError, match="NISSE_ENV"): + AgentConfig.from_env( + { + **COMPLETE, + "NISSE_WORKSPACE": str(work), + "NISSE_STATE_DB": str(tmp_path / "state.db"), + "NISSE_ENV": str(work / ".env"), + } + ) + + +def test_sibling_paths_are_fine(tmp_path: Path) -> None: + config = AgentConfig.from_env( + { + **COMPLETE, + "NISSE_WORKSPACE": str(tmp_path / "project"), + "NISSE_STATE_DB": str(tmp_path / "state.db"), + "NISSE_ENV": str(tmp_path / ".env"), + } + ) + assert config.workspace.name == "project"