Skip to content

feat(test): add Tier 1 safety judges for the confirm gate and personas - #346

Open
abhishekKokadwar wants to merge 4 commits into
kubeflow:mainfrom
abhishekKokadwar:feat/eval-tier1-safety-judges
Open

abhishekKokadwar wants to merge 4 commits into
kubeflow:mainfrom
abhishekKokadwar:feat/eval-tier1-safety-judges

Conversation

@abhishekKokadwar

@abhishekKokadwar abhishekKokadwar commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Description

Implements #325. No objection has come up on the issue since Oct 3, so I am marking this ready for review. Happy to adjust or close it if the direction should change.

ROADMAP.md (Phase 2) and ARCHITECTURE.md ("Eval Pipeline") list rule-based safety judges that run on every PR as Tier 1 of the eval pipeline. This adds them as plain pytest under tests/eval/: 41 checks, about 9 seconds locally, no cluster, no model, no new dependency and no workflow change. They are picked up by the existing make test-python.

What it checks

The per-tool unit tests check each preview one tool at a time. These checks cover the rules that hold across the tools of every registered client, through an in-memory MCP client:

  1. A tool is read-only exactly when it has no confirmed parameter, and confirmed defaults to False.
  2. confirmed=False returns a preview and sends no POST, PUT, PATCH or DELETE, for every mutating tool. A control checks that confirmed=True does write, so this cannot pass trivially.
  3. Each persona exposes exactly its allowlist, readonly exposes no mutating tool, and hidden tools cannot be called.
  4. Checks 2 and 3 again through execute_tool in progressive and semantic modes.
  5. Every mutating tool must be listed in GATE_ARGS, so a new mutating tool fails the suite until it is covered.

Kubernetes is faked at ApiClient.call_api, the one place both the SDK and the raw clients go through.

Scope and limits

  • Tests only. No tool name, schema or response changes, and no production code touched.
  • Every registered client is loaded. The tests build the server with all of CLIENT_MODULES, so a new client's tools are checked without editing the test file, and the suite fails until its mutating tools are listed in GATE_ARGS. On main only the trainer client registers tools today, so the count is still 41. A client that talks to its own API server instead of the Kubernetes API would also need a fake for that API.
  • "pre_flight runs before fine_tune" from the Tier 1 list in ARCHITECTURE.md is not included. It is agent behaviour across several calls, which a server-side check cannot see.

Relation to existing tests

Related Issue

Fixes #325

Checklist

  • I have read the CONTRIBUTING guide
  • Tests pass locally (make test-python)
  • Linting passes (make verify)
  • Documentation updated (if applicable)
  • My commits are signed off (git commit -s)

Testing

  • Unit tests

  • Integration tests

  • E2E tests

  • Manually tested (describe below)

  • On main at 4bdc6d0: the 41 new checks pass, and the full suite is 932 passed, 8 skipped. ruff check and ruff format --check are clean. I ran these directly on Windows instead of through make.

  • To show the checks fail when they should, I broke the server on purpose in 9 ways and ran the existing suite and these checks against each break. This was measured on the earlier base 65582b4, before the rebase:

deliberate break existing suite these checks
a mutating tool skips the gate (5 tools, one at a time) fails fails
fine_tune has confirmed default to True fails fails
fine_tune annotated read-only passes fails
persona filter bypassed in create_server passes fails
progressive/semantic modes expose every tool passes fails
  • Second commit (63c0c4d), checked against the Spark branch from feat: add spark client module for SparkConnect sessions #51 (4890fd4) with the test files copied in unchanged:

    • as is: 1 check fails with "update GATE_ARGS so every mutating tool is judged", because the two Spark mutating tools are not listed yet
    • with create_spark_session and delete_spark_session added to GATE_ARGS: 47 pass
    • with delete_spark_session made to skip the gate: 3 checks fail and name that tool

    Before this commit the checks built a trainer-only server, so they did not look at the Spark tools at all. On main the 41 checks still pass and still fail on three of the deliberate breaks above (persona filter bypassed, proxy modes expose every tool, a tool skipping the gate).

ARCHITECTURE.md lists rule-based safety judges as Tier 1 of the eval pipeline,
run on every PR. This adds them as plain pytest under tests/eval, with no
cluster, no LLM and no new dependency.

The per-tool unit tests check each preview one tool at a time. These judges
check the rules that hold across all tools, through an in-memory MCP client:

- a tool is read-only exactly when it has no confirmed parameter, and
  confirmed defaults to False
- confirmed=False returns a preview and sends no write request, for every
  mutating tool, with a control that confirmed=True does write
- each persona exposes exactly its allowlist, and hidden tools cannot be called
- the same checks through execute_tool in progressive and semantic modes
- every mutating tool must be listed in GATE_ARGS, so a new tool fails the
  suite until it is covered

Kubernetes is faked at ApiClient.call_api, which both the SDK and the raw
clients go through. No tool name, schema or response changes.

Signed-off-by: Abhishek <abhikokadwar2@gmail.com>
The judges built the server with the default client list, which is trainer
only. On the Spark branch (kubeflow#51) they did not look at the Spark tools at all,
and the persona check failed because the policy lists Spark tools that a
trainer-only server does not expose.

Build every server with all of CLIENT_MODULES. A new client's mutating tools
are then checked without editing the test file, and the suite fails until
their arguments are added to GATE_ARGS.

Nothing changes on main: 41 checks pass, since the optimizer and hub stubs
register no tools.

Signed-off-by: Abhishek <abhikokadwar2@gmail.com>
@abhishekKokadwar
abhishekKokadwar marked this pull request as ready for review October 8, 2026 09:21
Copilot AI balanced review requested due to automatic review settings October 8, 2026 09:21
@google-oss-prow google-oss-prow Bot removed the do-not-merge/work-in-progress Work in progress label Oct 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@abhishekKokadwar

Copy link
Copy Markdown
Member Author

/assign

@pratik-naik003 pratik-naik003 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @abhishekKokadwar, really useful checks. I ran them locally: all 41 pass, and they catch real breaks. Making update_training_job patch on preview fails 3 checks, and bypassing the persona filter fails 13. Two small non-blocking nits inline.

/lgtm

Comment thread tests/eval/safety_judges_test.py Outdated
async def test_persona_exposes_exactly_its_allowlist(persona):
exposed = set(await _list_tools(persona))
allowed = get_allowed_tools(persona)
if allowed is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: for platform-admin this returns without checking anything, so that case always passes. Could it assert that every registered tool is exposed, or use pytest.skip so it's visible?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that case checks nothing as written. I will make it assert that platform-admin sees every registered tool. Since this has lgtm I would rather not push and reset it, so I will send that as a small follow-up right after merge, unless you would prefer it here.

Comment thread tests/eval/safety_judges_test.py Outdated

from kubeflow_mcp.core.policy import get_allowed_tools
from kubeflow_mcp.core.server import CLIENT_MODULES, create_server
from tests.eval.conftest import GATE_ARGS, payload

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: importing from conftest.py is usually avoided, since pytest loads it itself and it can end up imported twice. Moving GATE_ARGS and payload to a small helper module would be cleaner.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I will move GATE_ARGS and payload into a small helper module in the same follow-up.

@google-oss-prow google-oss-prow Bot added the lgtm Looks good to me — approved by a reviewer label Oct 8, 2026
@abhishekKokadwar

Copy link
Copy Markdown
Member Author

/assign @abhijeet-dhumal

@abhijeet-dhumal

Copy link
Copy Markdown
Member

/ok-to-test

@abhishekKokadwar

Copy link
Copy Markdown
Member Author

/assign @jaiakash

@abhishekKokadwar

Copy link
Copy Markdown
Member Author

@jaiakash when you have a moment, could you take a look for /approve? Tests only (tests/eval/), lgtm from @pratik-naik003 since Oct 9, CI green, and it still merges cleanly with main. Happy to rebase or change anything.

@abhijeet-dhumal

Copy link
Copy Markdown
Member

/milestone v0.2

@google-oss-prow google-oss-prow Bot added this to the v0.2 milestone Oct 20, 2026
@abhijeet-dhumal

Copy link
Copy Markdown
Member

/retest

Comment thread tests/eval/safety_judges_test.py Outdated

@pytest.mark.parametrize("persona", ["readonly", "data-scientist", "ml-engineer"])
async def test_hidden_tools_cannot_be_called(persona, k8s):
hidden = sorted(set(GATE_ARGS) - get_allowed_tools(persona))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These cases only exercise hidden mutating tools because GATE_ARGS contains only mutators. A regression that allows a persona to call a hidden read-only tool would still pass. Could you include valid arguments for hidden read-only tools and assert they are rejected in full and proxy modes?

The hidden-tool checks only tried tools from GATE_ARGS, which holds mutating
tools, so a regression that let a persona call a hidden read-only tool
(inspect_crd, inspect_controller, wait_for_training) still passed.

READ_ARGS gives valid arguments for every read-only tool that some persona
hides, and a guard test fails when a new one is missing. Both hidden-tool
checks, full mode and through execute_tool, now call every hidden tool and
assert the fake API saw no request at all, reads included.

Signed-off-by: Abhishek <abhikokadwar2@gmail.com>
@abhishekKokadwar

Copy link
Copy Markdown
Member Author

Good catch @abhijeet-dhumal, thanks. Done in 36f2885.

  • READ_ARGS in tests/eval/conftest.py holds valid arguments for every read-only tool that some persona hides. On current main that is inspect_crd, inspect_controller and wait_for_training for readonly, and the first two for data-scientist (ml-engineer hides none). test_every_hidden_read_only_tool_has_arguments fails when a new one is missing, the same way GATE_ARGS works for mutating tools.
  • test_hidden_tools_cannot_be_called (full mode) and test_proxied_hidden_tools_cannot_be_called (progressive and semantic, through execute_tool) now call every hidden tool, mutating and read-only, and assert the fake API saw no request at all (k8s.calls == []), so a hidden read-only tool cannot read either.

To check the gap is closed I broke the persona filter two ways on current main (9424baf):

break old tests new tests
hidden read-only tools allowed in every mode 2 fail (only the list_tools allowlist check) 8 fail, including the call checks in full, progressive and semantic
hidden read-only tools allowed only behind execute_tool all 41 pass 4 fail (readonly and data-scientist, both proxy modes)

42 pass on this branch and on current main; ruff clean.

@google-oss-prow google-oss-prow Bot removed the lgtm Looks good to me — approved by a reviewer label Oct 21, 2026
@google-oss-prow

Copy link
Copy Markdown
Contributor

New changes are detected. LGTM label has been removed.

@google-oss-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please ask for approval from abhijeet-dhumal. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

NSTKrishna added a commit to NSTKrishna/mcp-server that referenced this pull request Oct 27, 2026
…ing tool

Replace the delete_training_job-only end-to-end test with one that
covers every mutating tool. For each tool it sends 23 confirmed values
(the string and numeric forms of true and false, plus "maybe", "",
"false ", None and []) through full mode and through execute_tool in
progressive and semantic mode, against a fake Kubernetes API that
records writes. The outcome (preview, error or success) and the writes
must match full mode, so a later change to execute_tool cannot drift
from it again.

Parity alone would pass if every mode were wrong the same way, so
false-like values must also preview and non-boolean values must be
rejected, both without a write. test_every_mutating_tool_has_gate_arguments
fails until a new mutating tool is added to GATE_ARGS.

Against main's dynamic_tools.py the test fails for all 8 tools.

GATE_ARGS and the fake API mirror tests/eval/conftest.py from kubeflow#346 so
the test can move onto that fixture once it lands. The fake returns {}
rather than a MagicMock: a mock ends up in tool responses and FastMCP
spends ~0.3s per call failing to serialize it, which made the test take
20s instead of 2s.

Signed-off-by: NSTKrishna <krishnagehlot936@gmail.com>

@Krishna-kg732 Krishna-kg732 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @abhishekKokadwar! I ran these on main with #348 merged and with #484 merged, and all 42 pass in both.

Since the last push already dropped the lgtm, could you fold in @pratik-naik003's two nits here instead of a follow-up? The platform-admin case still returns before asserting anything (line 125), and GATE_ARGS, READ_ARGS and payload are still imported from conftest.py (line 35).

Note: this review was assisted by Claude.

…t of conftest

Two follow-ups from review on kubeflow#346.

The persona allowlist check returned early for platform-admin, so that case
passed without checking anything. It now asserts that an unrestricted persona
sees every tool the loaded clients and the server layer register. With one
read-only tool hidden from unrestricted personas in create_server, the old
check passed and this one fails.

GATE_ARGS, READ_ARGS and payload move from conftest.py to tests/eval/helpers.py.
pytest loads conftest.py itself, so importing from it can load the module twice.
conftest.py keeps only the fixtures.

Signed-off-by: Abhishek <abhikokadwar2@gmail.com>
@abhishekKokadwar

Copy link
Copy Markdown
Member Author

Thanks @Krishna-kg732, done in 1cbf8de, so there is no separate follow-up.

  • test_persona_exposes_exactly_its_allowlist[platform-admin] no longer returns early. An unrestricted persona now has to see every tool the loaded clients and the server layer register. Hiding one read-only tool from unrestricted personas in create_server passes the old check and fails this one.
  • GATE_ARGS, READ_ARGS and payload moved to tests/eval/helpers.py; conftest.py keeps only the fixtures.

42 pass on this branch and with the tests on current main (bba6d67); ruff clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Approve CI for external contributors size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(test): Tier 1 safety judges for the confirm gate and personas

6 participants