fix(credential): a subscription paid for the run, and one print line decided it

ANTHROPIC_API_KEY is now the ONLY accepted credential. Through v0.1.0 the
preflight cleared on CLAUDE_CODE_OAUTH_TOKEN, and run_s10 went further: an
unset key printed "note: relying on the CLI's own credentials" and carried
on. That note was not a warning, it was a decision - made silently, on the
operator's behalf, about who pays. Both paths are gone; a run with no key
refuses with exit 2 before anything is opened.

Red first, both halves: _check_credentials refuses an OAuth-only env, and
the run entrance is driven as a real subprocess with a deliberately missing
bundle, so the credential refusal must win the race against the bundle
error. Detach it and the process reaches navigate_bundle instead - a
different exit code, no refusal line, the fallback back in the output. The
positive control (key set) gets past the gate and fails on the bundle, so
the gate is a gate and not a wall. 997 -> 1002, offline, no key in env.

The SDK exception is now stated where a reader meets it, not implied: this
framework runs on the Claude Agent SDK, which starts the Claude Code CLI it
bundles as a subprocess. That is the SDK's intended use WITH an API key,
and it is a deliberate, stated exception to the owner's rule that his own
code never starts Claude Code. Rewriting to direct HTTP calls was weighed
and declined - measuring what the Agent SDK offers is the point of D7. The
repo is closed as a worked example.

Two prose claims were corrected rather than left standing: run_s10.py is no
longer byte-frozen (it carries exactly one change, and runs/s10/ is still
the v0.1.0 run), and its two round() call sites moved 110->118, 130->138.

The credential paragraph is prose under an existing heading, not a new
section: test_readme_anchors_loadbearing.py pins 14 heading ids MEASURED on
the published page and forbids re-deriving them. This order forbids push, so
a new heading could not have been honestly re-measured.

Version 0.1.1: pyproject.toml, uv.lock self-entry, CHANGELOG - 3 of 3. No
version badge in README, no constant in src. v0.1.0 stands as released.

Order 20260920T131502Z-7496226791-from-.claude. The older D7 mirroring order
20260913T053840Z-9473220509 is retired unexecuted: po closes at v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-20 15:24:55 +02:00
commit f92b04bf62
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 210 additions and 37 deletions

View file

@ -0,0 +1,81 @@
"""The ONLY accepted credential is an own API key — LOAD-BEARING (§11, §1).
The seam this file keeps alive: no run path in this framework may be paid for by
a consumer Claude Code subscription. Until v0.1.1 ``run_s10`` printed a ``note:``
when ``ANTHROPIC_API_KEY`` was unset and carried on, letting the bundled CLI
resolve its own login credentials. That silent fallback is the defect; the run
must REFUSE instead.
Why a subprocess and not an import: ``run_s10`` is the fasit run entrance and is
never imported by this suite. Driving it as a module proves the real entrance,
not a model of it and the probe is network-free by construction, because the
refusal is asserted to fire BEFORE the bundle is even opened.
Detach proof (RED when the refusal is removed): with no key and a bundle path
that does not exist, the refusal must win the race against the bundle error. Drop
the refusal and the process reaches ``navigate_bundle`` instead a different exit
code, no refusal line, and the ``note:`` fallback back in the output.
Positive control: the SAME invocation WITH a key set gets past the credential gate
and fails on the missing bundle instead so the gate is a gate, not a wall.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MISSING_BUNDLE = "/nonexistent-bundle-this-path-must-not-exist"
# Shape only; never validated online and never leaves the process — the run is
# asserted to stop on the missing bundle, long before any client is built.
_FAKE_KEY = "sk-ant-api03-drill-not-a-real-key"
def _run_s10(env_overrides: dict[str, str]) -> subprocess.CompletedProcess[str]:
env = dict(os.environ)
env.pop("ANTHROPIC_API_KEY", None)
env.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
env.update(env_overrides)
env["PYTHONPATH"] = str(REPO_ROOT / "src")
return subprocess.run(
[sys.executable, "-m", "portfolio_optimiser_claude.run_s10", "--bundle", MISSING_BUNDLE],
capture_output=True,
text=True,
cwd=REPO_ROOT,
env=env,
timeout=120,
)
class TestMissingApiKeyIsRefused:
def test_no_key_refuses_before_anything_else_runs(self) -> None:
proc = _run_s10({})
combined = proc.stdout + proc.stderr
assert proc.returncode == 2, combined
assert "ANTHROPIC_API_KEY" in combined
assert "index.md" not in combined # the bundle was never opened
def test_no_key_never_falls_back_to_the_cli_login(self) -> None:
combined_out = _run_s10({})
combined = combined_out.stdout + combined_out.stderr
assert "note:" not in combined
assert "CLI's own credentials" not in combined
def test_a_subscription_token_alone_is_not_enough(self) -> None:
proc = _run_s10({"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-real"})
combined = proc.stdout + proc.stderr
assert proc.returncode == 2, combined
assert "ANTHROPIC_API_KEY" in combined
class TestPositiveControl:
def test_a_key_clears_the_gate_and_the_run_proceeds_to_the_bundle(self) -> None:
# Proves the refusal is conditional on the key, not unconditional: with a
# key the process gets PAST the gate and fails on the missing bundle.
proc = _run_s10({"ANTHROPIC_API_KEY": _FAKE_KEY})
combined = proc.stdout + proc.stderr
assert proc.returncode != 2, combined
assert "index.md" in combined

View file

@ -16,7 +16,8 @@ present here.
What the harness then showed is sharper than "pinned only at the edge". Replacing the WHOLE
rounding expression with the constant ``999.0`` left all 984 tests green at three of the four
sites (``run.py``:159, ``run_s10.py``:110 and :130) those branches are never executed with a
sites (``run.py``:159, ``run_s10.py``:118 and :138 the two moved by +8 when v0.1.1
added the credential refusal above them) those branches are never executed with a
cost at all, so their green under a detach was never evidence about the rounding (økt 39,
Verifiseringsloven ansikt 4 applied to the measuring apparatus). Only ``costsim.py`` was
covered. The default branch WAS pinned (``getattr(..., None)`` -> ``0.0`` is red in

View file

@ -55,6 +55,8 @@ def _counting_factory() -> tuple[ClientFactory, list[ScriptedClient]]:
def _clear_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
# ANTHROPIC_API_KEY is the only accepted credential; the subscription token
# is cleared as env hygiene, so an ambient one cannot colour any result.
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)

View file

@ -64,10 +64,25 @@ class TestCredentialContract:
# the boundary; a bad key surfaces on the FIRST call, never here (§1).
assert _check_credentials({"ANTHROPIC_API_KEY": "obviously-not-a-real-key-42"}) == []
def test_the_bundled_cli_oauth_token_satisfies_the_credential(self) -> None:
# run_s10 relies on the bundled CLI's own credentials when no key is
# exported; refusing that would be a false alarm (§1 honesty).
assert _check_credentials({"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-real"}) == []
def test_the_bundled_cli_oauth_token_is_refused(self) -> None:
# A consumer-subscription token is NOT a credential this framework
# accepts. Until v0.1.1 it cleared the check, so a run could be paid for
# by the operator's Claude Code login instead of an own API key. The
# only accepted credential is ANTHROPIC_API_KEY (§1: the refusal says so).
refusals = _check_credentials({"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-real"})
assert any(r.check == "credential" for r in refusals)
assert any("ANTHROPIC_API_KEY" in r.detail for r in refusals)
def test_an_oauth_token_does_not_weaken_a_missing_key(self) -> None:
# The two are not interchangeable: setting BOTH is fine (the key decides),
# but the token alone never substitutes for the key.
assert _check_credentials({"ANTHROPIC_API_KEY": _REAL_KEY}) == []
assert (
_check_credentials(
{"ANTHROPIC_API_KEY": _REAL_KEY, "CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-real"}
)
== []
)
class TestPlaceholderModelIdRefused:
@ -195,6 +210,8 @@ class TestCli:
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", _REAL_KEY)
# Cleared as env hygiene only — it is not a credential here (see
# TestCredentialContract); an ambient token must not colour the result.
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
rc = main(["--profile", "anthropic"])
out = capsys.readouterr().out
@ -206,7 +223,9 @@ class TestCli:
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
# A subscription token SET is the interesting case: through v0.1.0 it
# cleared the CLI too, so this exit was 0. It must not rescue the run.
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-real")
rc = main(["--profile", "anthropic"])
out = capsys.readouterr().out
assert rc == 1

View file

@ -3,7 +3,9 @@
The seam this file keeps alive: the SHIPPABLE entrance (``run.py``) composes
the §5 sequence (merge inbox seed fold) and drives the same orchestration
the fasit run used so the README's inbox claim is true of a deliverable
path. ``run_s10.py`` stays byte-frozen and is still never imported here.
path. ``run_s10.py`` is still never imported here; it was byte-frozen through v0.1.0
and carries exactly one change since the credential refusal, proved by
``test_api_key_only_loadbearing.py`` against the real entrance as a subprocess.
Detach proofs: drop the merge call from the composition the inbox verdict
never reaches the composed context red. Drop the budget-stop persistence