diff --git a/nisse-dev/src/nisse_dev/config.py b/nisse-dev/src/nisse_dev/config.py index e4d414c..a5c7919 100644 --- a/nisse-dev/src/nisse_dev/config.py +++ b/nisse-dev/src/nisse_dev/config.py @@ -233,12 +233,14 @@ class AgentConfig: workspace: Path project: str llm_base_url: str - llm_token: str + # Credentials never appear in a repr: a failing test or a log line + # would print them. + llm_token: str = field(repr=False) roles: RoleMap matrix: MatrixConfig operators: frozenset[str] gitea_url: str - gitea_token: str + gitea_token: str = field(repr=False) gitea_repo: str gitea_operators: frozenset[str] build_host: BuildHost @@ -248,7 +250,7 @@ class AgentConfig: # (push_user, push_token), or the path to a deploy key (push_key). push_key: Path | None = None push_user: str | None = None - push_token: str | None = None + push_token: str | None = field(default=None, repr=False) max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S tuning: Tuning = field(default_factory=Tuning) diff --git a/nisse-dev/src/nisse_dev/git.py b/nisse-dev/src/nisse_dev/git.py index 56f5596..8b0203b 100644 --- a/nisse-dev/src/nisse_dev/git.py +++ b/nisse-dev/src/nisse_dev/git.py @@ -18,7 +18,7 @@ Pull requests are opened from the agent's own host with its Gitea API token """ import shlex -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from nisse_dev.execution import HostUnavailable, SSHHost @@ -67,14 +67,15 @@ class HTTPSToken: """A push-only account's token, for HTTPS remotes (D4).""" username: str - token: str + # Never in a repr: a failing test or a log line would print it. + token: str = field(repr=False) @dataclass(frozen=True) class SSHKey: """A deploy key's private half, for SSH remotes (D4).""" - private_key: str + private_key: str = field(repr=False) PushCredential = HTTPSToken | SSHKey diff --git a/nisse-dev/src/nisse_dev/matrix.py b/nisse-dev/src/nisse_dev/matrix.py index ca94ce9..76ee235 100644 --- a/nisse-dev/src/nisse_dev/matrix.py +++ b/nisse-dev/src/nisse_dev/matrix.py @@ -16,7 +16,7 @@ import re import threading import uuid from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from urllib.parse import unquote import httpx @@ -40,7 +40,7 @@ class TransportError(RuntimeError): class MatrixConfig: homeserver: str user_id: str - token: str + token: str = field(repr=False) # never in a repr or a log line room_id: str diff --git a/nisse-dev/tests/test_config.py b/nisse-dev/tests/test_config.py index 4b3943f..596d5e2 100644 --- a/nisse-dev/tests/test_config.py +++ b/nisse-dev/tests/test_config.py @@ -360,3 +360,10 @@ def test_a_push_token_needs_its_user_and_excludes_a_key() -> None: build(gitea__push_user="pusher") with pytest.raises(ConfigError, match="not both"): build(gitea__push_user="p", gitea__push_token="t", gitea__push_key="~/k") + + +def test_no_credential_appears_in_the_config_repr() -> None: + config = build(gitea__push_user="pusher", gitea__push_token="${TEST_GITEA_TOKEN}") + text = repr(config) + for secret in SECRETS.values(): + assert secret not in text diff --git a/nisse-dev/tests/test_git.py b/nisse-dev/tests/test_git.py index 369b23a..1a81524 100644 --- a/nisse-dev/tests/test_git.py +++ b/nisse-dev/tests/test_git.py @@ -135,10 +135,25 @@ def test_a_failed_push_still_kills_its_agent( def _agents() -> set[str]: + """Live ssh-agents. A killed one lingers as a zombie in a CI container, + where nothing reaps orphans, so zombies don't count.""" out = subprocess.run( ["pgrep", "-x", "ssh-agent"], capture_output=True, text=True, check=False ).stdout - return set(out.split()) + live = set() + for pid in out.split(): + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except FileNotFoundError: + continue + if stat.rsplit(")", 1)[1].split()[0] != "Z": + live.add(pid) + return live + + +def test_credentials_never_appear_in_a_repr() -> None: + assert "s3cr3t" not in repr(HTTPSToken("pusher", "s3cr3t")) + assert "PRIVATE" not in repr(SSHKey("-----BEGIN OPENSSH PRIVATE KEY-----")) def test_a_remote_url_is_quoted_not_interpreted(repo: GitRepo, tmp_path: Path) -> None: