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

@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.1] - 2026-09-20
### Removed
- **A Claude Code subscription is no longer a way to pay for a run.** Through `0.1.0` the
preflight accepted `CLAUDE_CODE_OAUTH_TOKEN` — the bundled CLI's own login token — as a
credential, and `run_s10` went further: with no `ANTHROPIC_API_KEY` set it printed a
`note:` and carried on, letting the CLI resolve whatever credentials it found. Both paths
are gone. `ANTHROPIC_API_KEY` is now the only accepted credential; an OAuth token alone is
a structured refusal, and a run entrance with no key exits non-zero before it opens
anything. `0.1.0` stands as released — this is stated, not rewritten.
### Added
- **The SDK exception is stated where a reader meets it.** README and CONTRIBUTING now say
plainly that this framework runs on the Claude Agent SDK, that the SDK starts the Claude
Code CLI it bundles as a subprocess, that this is the SDK's intended use *with an API
key*, and that the repo is closed as a worked example. Rewriting to direct HTTP API calls
was considered and deliberately not done: measuring what the Agent SDK offers is the point
of the D7 sibling.
- Load-bearing proof for both refusals (`tests/test_api_key_only_loadbearing.py`): 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. The positive control (a key set) gets past the gate and
fails on the bundle, so the gate is a gate and not a wall. 997 → 1002 tests, still offline
and still with no key in the environment.
### Changed
- **The SDK pin now has a proof behind it, not only a permission.** `claude-agent-sdk`

View file

@ -72,6 +72,15 @@ Pakkehåndtering: `uv`. Pydantic for kontrakts-validering.
- **Kostnadsdisiplin (D6):** alt utvikles offline/deterministisk; den ENE ekte API-kjøringen
i programmet er S10 (mikro-bundle, billigste egnede modell — per model_map Haiku 4.5 —
harde tak, kost loggføres).
- **Kun egen API-nøkkel (v0.1.1):** `ANTHROPIC_API_KEY` er ENESTE godtatte legitimasjon.
Et abonnements-token (`CLAUDE_CODE_OAUTH_TOKEN`) AVVISES av preflight og av kjøre-
inngangen, som ender i exit ≠ 0 — aldri et stille tilbakefall på CLI-ens innlogging.
- **Uttalt unntak (ikke skjult):** repoet er bygget på Claude Agent SDK, som selv starter
den medfølgende Claude Code-CLI-en som underprosess. Det er Anthropics tiltenkte bruk MED
API-nøkkel, og står som et UTTALT unntak fra eierens regel om at egen kode ikke starter
Claude Code. Omskriving til rene API-kall er vurdert og valgt bort — poenget med D7 er å
måle hva Agent SDK-en tilbyr. **Repoet er AVSLUTTET som eksempel:** ingen videre utvikling
er planlagt.
- **STATE.md er local-only** (gitignored — repoet skal publiseres; STATE når aldri et
offentlig speil).

View file

@ -58,8 +58,10 @@ uv run ruff format --check .
uv run mypy src
```
Tests run offline without an API key; live runs use the Claude API — keep `ANTHROPIC_API_KEY` in
your environment, never commit it.
Tests run offline without an API key. A live run requires your own `ANTHROPIC_API_KEY` — keep it
in your environment, never commit it. It is the only accepted credential: a consumer Claude Code
subscription is not supported and must not be used to pay for a run, so a `CLAUDE_CODE_OAUTH_TOKEN`
is refused by the preflight and by the run entrance.
## Questions?

View file

@ -54,6 +54,23 @@ credentials are needed. Nothing runs the suite automatically: this forge has no
so that one command from a clean clone is the verification, and there is no CI badge above
pretending otherwise.
**Credentials — your own API key, and nothing else.** A live run requires
`ANTHROPIC_API_KEY`, an API key you hold yourself. It is the **only** credential this
framework accepts. A consumer Claude Code subscription is not a way to pay for a run here:
a `CLAUDE_CODE_OAUTH_TOKEN` is refused by the preflight, and a run entrance with no key
set refuses with a non-zero exit instead of falling back to whatever login the bundled CLI
happens to find. Through `v0.1.0` that fallback existed; `v0.1.1` removed it.
**A stated exception, not a hidden one.** This framework is built on the Claude Agent SDK,
and the SDK starts the Claude Code CLI it bundles as a subprocess — that is how the SDK is
designed to work, and it is Anthropic's intended use *with an API key*. The owner of this
repo otherwise holds a rule that his own code never starts Claude Code; this repo is a
deliberate, stated exception to it, which is why the credential rule above is enforced in
code rather than left to discipline. Rewriting the framework to call the HTTP API directly
was considered and deliberately not done — the point of this sibling implementation (D7) is
to measure what the Agent SDK offers. The repo is **closed as a worked example**: no further
development is planned.
## Non-goals
- **Not a portfolio reallocator.** It finds savings *inside* each project; it never moves
@ -213,9 +230,10 @@ description, never from its code)
cost less). `uv run python -m portfolio_optimiser_claude.costsim`.
- `preflight.py` — the SDK/API preflight (**offline** — the boundary the operator crosses
*before* any spend): everything that can be validated without a model call is checked here,
so a broken config stops cheaply instead of on the first billed call. Four checks — a
credential is present and not a placeholder form (`ANTHROPIC_API_KEY`, or the bundled CLI's
own `CLAUDE_CODE_OAUTH_TOKEN`; the value itself is *never* validated online), the model_map
so a broken config stops cheaply instead of on the first billed call. Four checks — your own
`ANTHROPIC_API_KEY` is set and is not a placeholder form (it is the **only** accepted
credential: a Claude Code subscription token is refused, never a fallback; the value
itself is *never* validated online), the model_map
profile exists and every id it resolves to is real, `claude_agent_sdk` imports and its
bundled CLI is present on disk, and the §8 stop/budget contract is set. It never calls the
API — a green preflight implies no more than that (§1). Each deficiency is a structured,
@ -449,8 +467,9 @@ claims more live behaviour than that one documented run.
## Stack
Python ≥3.10 · [`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk/) ≥0.2
(bundles the Claude Code CLI; an API key is needed only at actual `query()` time) ·
`uv` · Pydantic for contract validation.
(bundles the Claude Code CLI, which it starts as a subprocess; the offline suite needs no
credential, and a live run requires your own `ANTHROPIC_API_KEY` — a subscription token is
refused) · `uv` · Pydantic for contract validation.
## Development

View file

@ -1,6 +1,6 @@
[project]
name = "portfolio-optimiser-claude"
version = "0.1.0"
version = "0.1.1"
description = "Sibling implementation of the portfolio-optimiser method on the Claude Agent SDK (D7)"
readme = "README.md"
requires-python = ">=3.10"

View file

@ -10,10 +10,12 @@ broken config stops here, cheaply, instead of on the first billed call.
Four offline checks (for a given backend profile, ``anthropic`` today):
* **credential** a credential is available (``ANTHROPIC_API_KEY``, or the
bundled CLI's own ``CLAUDE_CODE_OAUTH_TOKEN``) and is not a placeholder form.
The value itself is NEVER checked online a bad key surfaces on the first
call, never here (§1 honesty: the preflight claims no more than it proves).
* **credential** an own API key (``ANTHROPIC_API_KEY``) is set and is not a
placeholder form. It is the ONLY accepted credential: a consumer Claude Code
subscription is never a way to pay for a run here, so the bundled CLI's own
login token is refused. The value itself is NEVER checked online a bad key
surfaces on the first call, never here (§1 honesty: the preflight claims no
more than it proves).
* **model_map** the requested profile exists, and every id it resolves to
(THROUGH ``resolve_model``, so the ``default`` fall-through is covered) is a
real id, not a placeholder left in the config.
@ -107,15 +109,15 @@ def _looks_like_placeholder(value: str) -> bool:
def _check_credentials(env: Mapping[str, str]) -> list[Refusal]:
"""A usable credential is present (§10) — the VALUE is never checked online.
"""An own API key is present (§10) — the VALUE is never checked online.
``ANTHROPIC_API_KEY`` is the named contract; the bundled CLI's own
``CLAUDE_CODE_OAUTH_TOKEN`` also satisfies it (run_s10 relies on the CLI's
credentials when no key is exported refusing that would be a false alarm).
A credential set to a placeholder form is refused; NEITHER set is refused.
``ANTHROPIC_API_KEY`` is the ONLY accepted credential. A consumer Claude Code
subscription is not one: the bundled CLI's ``CLAUDE_CODE_OAUTH_TOKEN`` was
accepted through v0.1.0 and is refused from v0.1.1 on, so no run here can be
paid for by an operator's personal subscription. A key set to a placeholder
form is refused; an unset key is refused.
"""
key = env.get("ANTHROPIC_API_KEY", "").strip()
oauth = env.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if key:
if _looks_like_placeholder(key):
return [
@ -126,14 +128,13 @@ def _check_credentials(env: Mapping[str, str]) -> list[Refusal]:
)
]
return []
if oauth and not _looks_like_placeholder(oauth):
return [] # the bundled CLI authenticates via its OAuth token
return [
Refusal(
"credential",
"no API credential: export ANTHROPIC_API_KEY (or authenticate the bundled "
"CLI, which sets CLAUDE_CODE_OAUTH_TOKEN). The value is never checked online "
"— only that a credential is present.",
"no API credential: export ANTHROPIC_API_KEY. It is the only accepted "
"credential — a Claude Code subscription token (CLAUDE_CODE_OAUTH_TOKEN) is "
"NOT accepted and must not be used to pay for a run. The value is never "
"checked online — only that the key is set.",
)
]

View file

@ -1,7 +1,8 @@
"""The shippable run entrance (C2.0): merge inbox → seed → fold → run (§3, §5).
Where ``run_s10.py`` is the byte-frozen fasit of the programme's ONE live run
(never imported by the suite), this module is the generic, deliverable
Where ``run_s10.py`` is the fasit of the programme's ONE live run (never
imported by the suite; byte-frozen through v0.1.0, and since v0.1.1 carrying
exactly one change the credential refusal), this module is the generic, deliverable
entrance the README's inbox claim points at. The composition
(``compose_run_context``) is pure config/file logic and offline-testable: it
ingests the inbox READ-only (role split §3 Step 7), seeds from the bundle, and

View file

@ -53,10 +53,18 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--top-k", type=int, default=3)
args = parser.parse_args(argv)
if not os.environ.get("ANTHROPIC_API_KEY"):
# The bundled CLI resolves its own credentials (Claude Code login) when
# no key is exported; an unauthenticated run fails on the FIRST call.
print("note: ANTHROPIC_API_KEY not set — relying on the CLI's own credentials.")
if not os.environ.get("ANTHROPIC_API_KEY", "").strip():
# REFUSAL, never a fallback: an own API key is the only credential this
# run accepts. Through v0.1.0 an unset key merely printed a note and the
# run carried on, letting the bundled CLI resolve its own login — which
# would have charged a consumer subscription. The gate fires FIRST, so
# nothing is opened, built or spent before it.
print(
"REFUSED: ANTHROPIC_API_KEY is not set. It is the only accepted credential; "
"a Claude Code subscription token (CLAUDE_CODE_OAUTH_TOKEN) is NOT accepted "
"and must not be used to pay for a run. No model call was made."
)
return 2
# §10: ALL startup contracts schema-validated BEFORE any model client exists.
contracts = load_contracts(

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

2
uv.lock generated
View file

@ -590,7 +590,7 @@ wheels = [
[[package]]
name = "portfolio-optimiser-claude"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "claude-agent-sdk" },