#!/usr/bin/env python3 """assert_no_write_token.py — fail closed if a GitHub *write* token is on the box. The agent-team apply/verify path mints its ``pull-requests: write`` GitHub App installation token **inside the CI runner** (via SHA-pinned ``actions/create-github-app-token``), from the ``AGENT_APPLY_APP_ID`` / ``AGENT_APPLY_APP_PRIVATE_KEY`` **Actions secrets**. By design the always-on R720 box holds **no standing write credential**: it triggers CI with the operator's host ``gh`` auth and reads results with a *read-only* token (``AGENT_TEAM_CI_READ_TOKEN`` / ``GITHUB_TOKEN``). See ``ci/README.md`` and ``docs/P3-PHASE0-DESIGN.md`` ("the box holds no standing write token"). This audit asserts that invariant. It is runnable both on the box (as a provisioning/runtime self-check) and in CI (as a regression guard). It: 1. scans the live process environment (``os.environ``) and the coordinator's environment-derived config for token-shaped variables that would grant ``pull-requests: write`` / ``contents: write``; 2. greps the box's local credential files (``~/secrev.env`` and ``~/orchestrator/.env`` by default; paths are configurable) for the App id and for any PEM private-key header (RSA / EC / OPENSSH / PKCS#8); and **exits non-zero with a clear message** if any are found, or exits ``0`` with a short summary otherwise. Usage:: python -m scripts.assert_no_write_token python scripts/assert_no_write_token.py --env-file ~/secrev.env --env-file ~/x.env Exit codes: 0 no write-shaped token / App secret / private key found 1 at least one finding (the box is mis-provisioned — remediate before deploy) """ from __future__ import annotations import argparse import os import re import sys from collections.abc import Mapping, Sequence from pathlib import Path # --------------------------------------------------------------------------- # # What "must never live on the box" looks like. # --------------------------------------------------------------------------- # # The GitHub App that holds ``pull-requests: write`` lives ONLY as Actions # secrets. Its id and private key must never appear on the box (env or files). APP_ID_ENV = "AGENT_APPLY_APP_ID" APP_PRIVATE_KEY_ENV = "AGENT_APPLY_APP_PRIVATE_KEY" # Env vars that are explicitly the App's write credentials. _FORBIDDEN_ENV_VARS: frozenset[str] = frozenset( { APP_ID_ENV, APP_PRIVATE_KEY_ENV, } ) # Env vars that are KNOWN-GOOD read-only / non-write and must NOT be flagged by # the heuristic name match below (they are tokens, but read-only by contract). _ALLOWED_TOKEN_ENV_VARS: frozenset[str] = frozenset( { "AGENT_TEAM_CI_READ_TOKEN", # read-only CI-result fetcher token "AGENT_TEAM_API_TOKEN", # read-only dashboard/API bearer "SLACK_APP_TOKEN", # Slack socket-mode app-level token (not GitHub) "SLACK_BOT_TOKEN", # Slack bot token (not GitHub) "CLAUDE_CODE_OAUTH_TOKEN", # Anthropic subscription OAuth (not GitHub) "ANTHROPIC_API_KEY", # model API key (not GitHub) } ) # A name-shaped heuristic for "this looks like a GitHub *write* token". We # deliberately scope to GitHub-write shapes so the generic read-only # ``GITHUB_TOKEN`` fallback (a runtime read token, not a standing write secret) # is not a false positive while the App's write material always is. _WRITE_TOKEN_NAME_RE = re.compile( r"(?:^|_)(?:GH|GITHUB)_(?:APP|PAT|WRITE|APPLY)_?(?:TOKEN|KEY|PRIVATE_KEY)?", re.IGNORECASE, ) # Value shapes that indicate a GitHub write-capable credential regardless of the # var's name. ``ghp_`` (classic PAT) and ``github_pat_`` (fine-grained PAT) can # both carry write scopes; an installation token (``ghs_``) is write-capable; a # user-to-server token (``ghu_``) acts with the user's write access; and a refresh # token (``ghr_``) mints fresh write-capable user-to-server tokens. All are # write-risk material that must not live on the box. _WRITE_TOKEN_VALUE_RE = re.compile( r"\b(?:ghp_|ghs_|ghu_|ghr_|github_pat_)[A-Za-z0-9_]{20,}\b" ) # Any PEM private-key header (RSA / EC / OPENSSH / generic PKCS#8). The App's # private key is a PEM block; finding ANY private key in a box credential file # is a finding. _PRIVATE_KEY_RE = re.compile(r"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----") # Default box credential files to grep. Configurable via --env-file / the # ``ASSERT_NO_WRITE_TOKEN_ENV_FILES`` env var so tests use temp files. _DEFAULT_ENV_FILES: tuple[str, ...] = ("~/secrev.env", "~/orchestrator/.env") # --------------------------------------------------------------------------- # # Scanners. Each returns a list of human-readable finding strings. # --------------------------------------------------------------------------- # def scan_environ(environ: Mapping[str, str]) -> list[str]: """Scan a process environment for write-shaped GitHub token material. Flags (a) the explicit App-credential env vars, (b) any var whose *name* matches the GitHub-write heuristic, and (c) any var whose *value* carries a write-capable GitHub token prefix. Known read-only tokens are exempt. """ findings: list[str] = [] for name, value in environ.items(): if name in _ALLOWED_TOKEN_ENV_VARS: continue if name in _FORBIDDEN_ENV_VARS: findings.append( f"environment variable {name!r} is set — the App write " "credential must live ONLY as an Actions secret, never on the box" ) continue if _WRITE_TOKEN_NAME_RE.search(name): findings.append( f"environment variable {name!r} has a GitHub write-token-shaped " "name; the box must hold no standing write token" ) continue if value and _WRITE_TOKEN_VALUE_RE.search(value): findings.append( f"environment variable {name!r} holds a write-capable GitHub " "token value (ghp_/ghs_/ghu_/ghr_/github_pat_ prefix)" ) return findings def scan_config(config: Mapping[str, object] | None) -> list[str]: """Scan a coordinator config mapping for write-shaped token material. The coordinator is environment-driven, so this is normally a thin pass over whatever config dict a caller hands in (string values only). It applies the same name/value heuristics as :func:`scan_environ`. """ if not config: return [] findings: list[str] = [] for key, raw in config.items(): name = str(key) if name in _ALLOWED_TOKEN_ENV_VARS: continue if name in _FORBIDDEN_ENV_VARS or _WRITE_TOKEN_NAME_RE.search(name): findings.append( f"coordinator config key {name!r} is a GitHub write-token-shaped " "key; the box config must hold no standing write token" ) continue if isinstance(raw, str) and _WRITE_TOKEN_VALUE_RE.search(raw): findings.append( f"coordinator config key {name!r} holds a write-capable GitHub " "token value (ghp_/ghs_/ghu_/ghr_/github_pat_ prefix)" ) return findings def scan_env_file(path: Path, *, app_id: str | None = None) -> list[str]: """Grep one credential file for the App id and any PEM private-key header. A non-existent file is **not** a finding (the box legitimately may not have every file). An unreadable-but-present file is reported as a finding so a permissions mistake can't silently mask leaked material. When ``app_id`` is given (the configured ``AGENT_APPLY_APP_ID`` value), the grep also flags that literal id appearing in the file. The ``AGENT_APPLY_APP_ID`` / ``AGENT_APPLY_APP_PRIVATE_KEY`` *names* are always flagged regardless. """ findings: list[str] = [] if not path.exists(): return findings try: text = path.read_text(encoding="utf-8", errors="replace") except OSError as exc: # present but unreadable — fail loud, not silent return [ f"could not read credential file {path} ({exc}); cannot prove it is clean" ] for lineno, line in enumerate(text.splitlines(), start=1): if APP_ID_ENV in line or APP_PRIVATE_KEY_ENV in line: findings.append( f"{path}:{lineno}: references the App credential " f"({APP_ID_ENV}/{APP_PRIVATE_KEY_ENV}); it must live ONLY as an " "Actions secret" ) if app_id and app_id in line: findings.append( f"{path}:{lineno}: contains the configured App id value; the App " "id must not be present on the box" ) if _PRIVATE_KEY_RE.search(text): findings.append( f"{path}: contains a PEM private-key block " "(-----BEGIN ... PRIVATE KEY-----); no private key may live on the box" ) return findings # --------------------------------------------------------------------------- # # Orchestration. # --------------------------------------------------------------------------- # def _resolve_env_files( cli_files: Sequence[str] | None, environ: Mapping[str, str] ) -> list[Path]: """Resolve the credential files to grep (CLI > env var > defaults).""" if cli_files: raw = list(cli_files) elif environ.get("ASSERT_NO_WRITE_TOKEN_ENV_FILES"): raw = [ p.strip() for p in environ["ASSERT_NO_WRITE_TOKEN_ENV_FILES"].split(os.pathsep) if p.strip() ] else: raw = list(_DEFAULT_ENV_FILES) return [Path(p).expanduser() for p in raw] def audit( *, environ: Mapping[str, str] | None = None, config: Mapping[str, object] | None = None, env_files: Sequence[str] | None = None, ) -> list[str]: """Run every scanner and return the combined list of findings (empty == clean).""" environ = os.environ if environ is None else environ findings: list[str] = [] findings += scan_environ(environ) findings += scan_config(config) app_id = environ.get(APP_ID_ENV) or None for path in _resolve_env_files(env_files, environ): findings += scan_env_file(path, app_id=app_id) return findings def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( description=( "Assert no pull-requests:write / contents:write GitHub token (or the " "apply App's id/private key) is present on this box." ) ) parser.add_argument( "--env-file", action="append", dest="env_files", metavar="PATH", help=( "Credential file to grep for the App id + private keys (repeatable). " "Defaults to ~/secrev.env and ~/orchestrator/.env, or the os.pathsep-" "separated ASSERT_NO_WRITE_TOKEN_ENV_FILES env var." ), ) args = parser.parse_args(argv) findings = audit(env_files=args.env_files) if findings: print( "FAIL: write-capable GitHub credential material found on the box " f"({len(findings)} finding(s)). The pull-requests:write App token " "must only ever live as an Actions secret:", file=sys.stderr, ) for f in findings: print(f" - {f}", file=sys.stderr) return 1 print( "OK: no pull-requests:write / contents:write token, App id, or private " "key found in the environment, coordinator config, or credential files. " "The box holds no standing write token." ) return 0 if __name__ == "__main__": raise SystemExit(main())