Skip to content

Item 3: upstream identity pinning - #177

Merged
rohanrkamath merged 3 commits into
mainfrom
v0.10/3-upstream-pinning
Sep 13, 2026
Merged

rohanrkamath merged 3 commits into
mainfrom
v0.10/3-upstream-pinning

Conversation

@arpanghoshal

@arpanghoshal arpanghoshal commented Sep 13, 2026

Copy link
Copy Markdown
Member

Item 3 of v0.10, SPEC-v0.10 §4. Stacked on #176 (which is stacked on #175's spec corrections).

The honest slice of ASI04 and nothing more: still deciding actions, still never inspecting a
package, a model, a registry or a build. What it adds is that an action entry may say which
server
it authorises itself against.

Two TLS keys, because round two showed one cannot work

§4.2 pinned by digest while §4.3 made the pinned certificates trust anchors. A digest cannot be a
trust anchor
load_verify_locations takes PEM. So tls_cert_file feeds check 3, where a
swapped server fails the handshake; tls_cert_sha256 feeds checks 1 and 2, which compare what was
observed. An entry pinning by digest alone gets two checks of three, stated as a limit rather than
discovered.

T491 proves check 3 against a real TLS listener with a CA-signed leaf (not self-signed — the
realistic shape, and the one that needs VERIFY_X509_PARTIAL_CHAIN to be an anchor at all): the
pinned server completes the handshake, a swapped one is refused. And check 3 is wired, not only
demonstrated: httpx_forwarder takes the policy and builds upstream.pinned_context from every
certificate any entry pins, so the gateway'"'"'s one outbound connection trusts those and nothing else.
T491b asserts the wiring, the TLS floor and the PARTIAL_CHAIN flag.

Check 2, and where it sits

Check 2 produces the DENY, and it sits above the approval gate on T446's argument: the pin
depends on nothing a human says, so asking one about an action pinned to an unverified server leaves
a granted approval behind for a call that cannot run.

The observation register is per process by construction — an observation is a fact about a
connection this process made, and a second process that has made none must refuse rather than
inherit somebody else's. That is upstream_unverified, the fail-closed half, and it is what stops
an upstream switching a pin off by never being seen.

In-process there is no upstream to observe, so a pinned action refuses on every call. The ACS
hook refuses at construction rather than at load, which §4.4 argues at length: ACS is advisory,
the platform runs the tool, the hook holds no connection — and one loader cannot know which surface
will run an action, while a load error would stop verify and scan reading a document that pins.

-41016, not -41013

SPEC-mcp-operator §9.3 already allocates -41013 to ctrlrun.not_a_human, plus -41014 and
-41015. There is one namespace; the range was walked before a number was taken.

Mutation table

Mutation Result
U1 nothing observed is admitted rather than refused caught, T493 and the pure-function test
U2 the in-process path stops refusing a pinned action caught, T497b
U3 enforce mode stops checking the pin entirely caught, 5 tests

All three caught on the first pass.

A flake I introduced, and what it was

One gate run failed and the next passed. The cause was mine: _serve bound an ephemeral port,
closed it, and rebound — a race another process can win, run twice per test because this file
stands up two servers. HTTPServer binds for us and server_port reports what it got, so there is
no gap to lose. Three consecutive clean runs after the fix.

G27

a swapped upstream is denied, 28 chars. Grades §4.3's check 2 and only check 2 — the one that
produces a DENY, which is what the title promises; check 3 produces NotExecuted with the effect
FAILED, a different outcome under a different name. It is also the only check verify can grade
without a network: the comparison is a pure function over two strings, so verify seeds an
observation and asserts the refusal, with no listener to stand up. §8.1 made the same move for G23.

Gate

4411 passed, 0 skipped with Postgres. ruff, mypy --strict clean.

What is not done, and it is an exit criterion

§7.3 requires a shipped example that pins an upstream, with G27 grading PASS on it. There is
none, so G27 is N/A on both
, and the CI pin moves AUTHORITY_NA 2 → 3.

The reason is a genuine tension round two flagged as a nitpick and I hit head-on: §4.4 makes a
pinned action refuse on every in-process call, and tests/test_examples.py runs each example in
a subprocess with every socket refused. So an example that pins can only ever demonstrate the
refusal, never a working call. That is a coherent example to ship, but it is a different shape
from the others and it belongs with the release item that owns the exit criteria. Flagged here
rather than silently left.

Not merging and not tagging.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 48 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 532776c8-d42b-4e2f-9407-bc91bc29ea5e

📥 Commits

Reviewing files that changed from the base of the PR and between 5c7daf4 and 60f47e5.

📒 Files selected for processing (12)
  • .github/workflows/ci.yml
  • src/ctrlrun/acs.py
  • src/ctrlrun/control.py
  • src/ctrlrun/gateway/__init__.py
  • src/ctrlrun/gateway/server.py
  • src/ctrlrun/gateway/transport.py
  • src/ctrlrun/policy.py
  • src/ctrlrun/upstream.py
  • src/ctrlrun/verify/guarantees.py
  • src/ctrlrun/verify/scenarios.py
  • tests/test_upstream_pinning.py
  • tests/test_verify_action.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread tests/test_upstream_pinning.py Fixed
Comment thread tests/test_upstream_pinning.py Fixed
Comment thread tests/test_upstream_pinning.py Fixed
SPEC-v0.10 section 4, the honest slice of ASI04 and nothing more: still
deciding actions, still never inspecting a package, a model, a registry or
a build. What it adds is that an action entry may say which SERVER it
authorises itself against.

upstream: is a ctrlrun.policy/v8 action-entry key carrying two TLS pins and
a tool-schema hash. Two, because a digest cannot be a trust anchor:
load_verify_locations takes PEM, so tls_cert_file feeds check 3 where the
pinned certificates become the connection's only trust anchors, and
tls_cert_sha256 feeds checks 1 and 2, which compare what was observed. An
entry pinning by digest alone gets two checks of three, stated as a limit.
The gate is the action-entry shape of _V4_ENTRY_KEYS and _V5_ENTRY_KEYS,
not require_v7's, which walks authority.grants because tasks: and budgets:
are grant keys and a standalone --authority document carries no action
entries at all.

Check 2 is the one that produces a DENY, and it sits above the approval
gate on T446's argument: the pin depends on nothing a human says, so asking
one about an action pinned to an unverified server leaves a granted
approval behind for a call that cannot run. The observation register is per
process by construction, because an observation is a fact about a
connection this process made and a second process that has made none must
refuse rather than inherit somebody else's. That is upstream_unverified,
the fail-closed half, and it is what stops an upstream switching a pin off
by never being seen.

In-process there is no upstream to observe, so a pinned action refuses on
every call. The ACS hook refuses at CONSTRUCTION rather than at load: ACS
is advisory, the platform runs the tool, and the hook holds no connection,
while one loader cannot know which surface will run an action and a load
error would stop verify and scan reading a document that pins.

-41016 and not -41013, which SPEC-mcp-operator already allocates to
ctrlrun.not_a_human. There is one namespace and the range was walked.

G27 grades check 2 and only check 2, because that is the one producing a
DENY and the only one verify can grade without a network: the comparison is
a pure function over two strings, so verify seeds an observation and
asserts the refusal.

Acceptance tests T489-T497b, including check 3 against a real TLS listener
with a CA-signed leaf and VERIFY_X509_PARTIAL_CHAIN. Mutation table in the
PR body: three mutations, all three caught.

One flaky gate run found a real race in my own test helper: it bound an
ephemeral port, closed it and rebound, twice per run. HTTPServer binds for
us and server_port reports what it got, so there is no gap to lose.

Gate with Postgres: 4411 passed, 0 skipped.

Signed-off-by: arpan <contact@arpanghoshal.com>
CodeQL reported two HIGH security alerts on this PR, both in my own test
file: ssl.SSLContext(PROTOCOL_TLS_SERVER) and PROTOCOL_TLS_CLIENT still
admit TLS 1.0 and 1.1 unless a floor is set. A listener in a test for a
pinning feature that negotiates a protocol the product would refuse is
testing something the product does not do, so both contexts now set
minimum_version explicitly. The third alert was a variable assigned twice
in T489, which was sloppiness.

Production had no exposure: jwt_identity and revocation both use
ssl.create_default_context(), which floors at TLS 1.2.

But checking that turned up a real gap in my own scope claim. The PR body
said T491 proves check 3, and it proved the MECHANISM while nothing wired
it: the gateway's forwarder built its client with httpx's ordinary
verification whatever the policy pinned. Check 3 is the only one of the
three that PREVENTS rather than attributing, so shipping section 4 with it
unimplemented would have overclaimed the section.

upstream.pinned_context builds the context: PARTIAL_CHAIN, because a real
upstream's leaf is CA-signed and OpenSSL wants a chain terminating at a
self-signed certificate unless told a trusted non-root may end it, and
without the flag a pinned leaf refuses every connection including the right
one. Plus the TLS floor the alert above taught me to set, here rather than
inherited. httpx_forwarder takes the policy and builds one where any entry
pins a certificate file. T491b asserts the wiring, the floor and the flag.

Gate with Postgres: 4412 passed, 0 skipped.

Signed-off-by: arpan <contact@arpanghoshal.com>
@arpanghoshal
arpanghoshal force-pushed the v0.10/3-upstream-pinning branch from 31ff1b1 to 516372a Compare September 13, 2026 17:39
Base automatically changed from v0.10/2-identity-and-receipt to main September 13, 2026 18:57
@rohanrkamath
rohanrkamath merged commit 09d77cf into main Sep 13, 2026
15 of 16 checks passed
@rohanrkamath
rohanrkamath deleted the v0.10/3-upstream-pinning branch September 13, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants