Skip to content

fix(agent-learning): normalise privilege values before the excessive-privilege check - #4411

Open
MohammadHaroonAbuomar wants to merge 4 commits into
mainfrom
mhabuomar/issue-4400
Open

MohammadHaroonAbuomar wants to merge 4 commits into
mainfrom
mhabuomar/issue-4400

Conversation

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Related Issue

Fixes #4400

Problem & Solution

Problem: ExcessivePrivilegeEvaluation in agent-learning compared the privilege, role, scopes and permissions values exactly as given. Five gaps let an elevated action pass:

  1. Whitespace around a value (" admin ") or a different case ("Admin", "ADMIN").
  2. A list-valued privilege or role (["user", "admin"]), which was never compared element by element.
  3. A nested list inside scopes or permissions ([["*"]]), which _as_sequence left as one opaque element.
  4. A partial wildcard such as admin:*, *:write or repo.*; only the exact value * counted.
  5. A key written in another case or with surrounding whitespace (Privilege, ROLE, "privilege "), which the lookup did not match.

Solution: normalise keys and values before the comparison. Behaviour changes:

  1. Keys privilege, role, scopes, permissions are matched case-insensitively and with surrounding whitespace stripped.
  2. Values are stripped (including Unicode whitespace) and case-folded before comparison.
  3. Nested lists, tuples and sets in any of the four keys are flattened; a list-valued privilege or role is checked element by element.
  4. Any string containing * is elevated (previously only the exact value *). A legitimate scope such as admin-reports:read still passes.
  5. scope_count and the max_scopes comparison now count flattened leaves rather than top-level elements.
  6. New fail-closed behaviour: a non-string leaf (None inside a list, bytes, a dict, a number) now produces a HIGH finding with the message "Action privilege, role, scope and permission values must be strings". Before, such values were silently ignored. A top-level None ({"privilege": None}) still means absent and passes, as in fix(agent-learning): check role and permissions in ExcessivePrivilegeEvaluation #4364.
  7. A list object referenced twice in one value tree is walked once (visited set). This can lower scope_count in that edge case but never hides a leaf. The walk is iterative, so deep nesting and self-referencing lists cannot raise RecursionError.
  8. The module-private helper _as_sequence is removed; it had no other callers.

The result shape and public API are unchanged: GovernanceEvaluationResult(check_id, passed, findings, metrics={"actions_checked"}), finding fields check_id, message, severity, artifact_type, artifact_id, action_id, metadata={"scope_count"}. __all__ in evaluation.py and __init__.py is untouched.

Impact on Your Work

The excessive-privilege check is a security gate. Any caller that trusted it to catch admin or * in an action could be bypassed by trivial changes to case, whitespace, nesting or key spelling. After this change the gate reads the same values a human reader would, and it refuses values it cannot read instead of ignoring them.

Known gaps left out of scope, for a follow-up issue rather than this PR: Unicode confusables (admin with a zero-width space, fullwidth admin, Turkish ADMİN) still pass; NFKC normalisation plus stripping Cf characters would close them. A glob-style scope such as files:*.md is now flagged because it contains *; this is what the issue asked for but is broader than a * suffix. Top-level non-string scalars ({"privilege": 5}) now fail closed where they used to pass silently; this is deliberate and noted in the CHANGELOG.

Timeline

None

Alternatives Considered

Two options for a value the check cannot read (a dict, bytes, a number, None inside a list):

  • Reject the action with its own finding (chosen). The caller learns why the action failed, and the message is distinct from the excessive-privilege message, so a legitimate action with a malformed value is not mislabelled as a privilege escalation. This matches how the cost check already handles a non-numeric cost.
  • Treat every unreadable value as elevated and reuse the excessive-privilege message. Simpler, but it hides the real cause and makes the finding wrong on its face.

Both options fail closed; the difference is only in the message. When one value tree holds both an unreadable leaf and an elevated string, the excessive-privilege message wins, so a bypass attempt cannot downgrade its own finding by adding junk.

Rejected: keeping exact comparison and only adding strip(). That closes gap 1 alone and leaves nesting, partial wildcards and key casing open.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Maintenance (dependency updates, CI/CD, refactoring)
  • Security fix

Package(s) Affected

Core & runtime:

  • agent-governance-toolkit-core
  • agent-primitives
  • agent-os
  • agent-mesh
  • agent-runtime
  • agent-sre
  • agent-compliance

Governance & security:

  • agent-mcp-governance
  • agent-rag-governance
  • agent-sandbox
  • agent-discovery
  • agt-policies
  • policy-engine

Platform & tooling:

  • agent-hypervisor
  • agent-lightning
  • agent-marketplace
  • agent-governance-toolkit-cli
  • agent-governance-toolkit-integrations
  • agent-governance-toolkit-protocols
  • agentmesh-integrations (framework integrations)

CLI plugins:

  • agent-governance CLI plugins (copilot-cli / claude-code / opencode / antigravity-cli)

Shared / other:

  • schemas
  • action (GitHub Action)
  • examples
  • docs / root (CHANGELOG.md)

Other: agent-governance-python/agent-learning (not in the list above).

Testing

Unit Testing

New tests in agent-governance-python/agent-learning/tests/test_evaluation.py:

  • test_excessive_privilege_normalizes_values_and_keys (23 cases): case, whitespace and Unicode whitespace around values; list-valued privilege and role; nested lists, tuples and sets in scopes and permissions; partial wildcards (admin:*, *:write, repo.*); mixed-case and whitespace-padded keys.
  • test_excessive_privilege_fails_closed_on_unexpected_leaf_types (5 cases): None inside a list, bytes, a dict, a number, and a nested unreadable leaf, each yielding the "must be strings" finding.
  • Message-precedence test: {"permissions": [None, "*"]} reports excessive privilege, not the unreadable-leaf message.
  • test_excessive_privilege_counts_flattened_scopes: scope_count and max_scopes use flattened leaves.
  • Six "must still pass" cases for low privileges and legitimate scopes such as admin-reports:read, plus a self-referencing-list guard.

Manual Testing

Environment: .venv with pytest, pyyaml, pydantic, cryptography; PYTHONPATH=src:../agent-os/src; run from agent-governance-python/agent-learning.

python -m pytest tests/ -q
  • Branch mhabuomar/issue-4400: 121 passed, 1 skipped, 1 warning in 0.27s (the skip is the agent-learning 0.8.x integration test).
  • origin/main unmodified (9c85646): 84 passed, 1 skipped, 1 warning in 0.25s.
  • origin/main source with this branch's tests/test_evaluation.py copied in (temporary copy, removed afterwards): 29 failed, 92 passed, 1 skipped, 1 warning in 0.56s. The 29 failures are all 23 cases of test_excessive_privilege_normalizes_values_and_keys, all 5 cases of test_excessive_privilege_fails_closed_on_unexpected_leaf_types, and test_excessive_privilege_counts_flattened_scopes. The 8 new tests that pass on main are the must-still-pass guards, the self-referencing-list guard, and the message-precedence test (main already flagged [None, "admin"] through "admin").

Lint and repo checks:

ruff check src/ --select E,F,W --ignore E501

All checks passed. (tests/ has pre-existing E712 findings on untouched lines that CI does not lint; the new tests follow the file's existing == False style.)

python3 -I scripts/ci/changed_lines.py --base origin/main --extensions ".md,.txt,.rst,.py,.ts,.js,.go,.rs,.cs,.yml,.yaml" --mode added-lines --output ci-diff/added.txt
npx --yes cspell@8.17.3 --config .cspell.json --no-progress ci-diff/added.txt

0 issues.

python3 scripts/check_v4_ratchet.py

OK (grand total 4).

Bypass probes, all run against the branch. Each of these now yields a HIGH finding with action_id set: "Admin", "ADMIN", " admin\t", " admin", "admin:*", "ADMIN:*", "*:write", "a.*", ["user", ["admin"]], {"scopes": [[["*"]]]}, {"Privilege": "admin"}, {"PRIVILEGE": "admin"}, {"privilege ": "admin"} (excessive-privilege message); {"privilege": {"level": "admin"}}, {"privilege": b"admin"} (must-be-strings message); {"permissions": [None, "*"]} (excessive-privilege message wins). {"privilege": None} still passes as absent.

Scale and robustness: a 10,000-element scopes list is checked in 3 ms and flagged through max_scopes; the same list with "Admin " at position 10,000 and max_scopes=100000 is flagged. A 200,000-deep nested ["*"] is flagged in 198 ms with no RecursionError; a 200,000-deep ["read"] passes. A self-referencing list containing "Admin" is flagged. A 1 MB privilege string passes in 1.6 ms; 1 MB of spaces around admin is flagged. A generator-valued scopes is consumed and checked. Non-string keys are skipped. Peak RSS 78 MB.

Legitimate inputs still pass: {"privilege": "user", "role": "viewer", "scopes": ["read", "write"]}, ["repo.read", "admin-reports:read"], {}, {"scopes": []}, and {"query": "*", "Privileges": "admin", "roles": ["admin"]} (unrelated keys are not read).

git merge-tree --write-tree origin/main HEAD is clean. Commit subject 65 characters, body wrapped to 72 columns, one Signed-off-by line.

Checklist

  • I have linked a related issue above, or completed "Problem & Solution", "Impact on Your Work", and "Alternatives Considered"
  • My code follows the project style guidelines (ruff check)
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass (pytest)
  • I have updated documentation as needed (CHANGELOG.md; no other docs describe this check's matching rules)
  • I have signed the Microsoft CLA

Attribution & Prior Art

  • This contribution does not contain code copied or derived from other projects without attribution
  • Any external projects that inspired this design are credited in code comments or documentation
  • If this PR implements functionality similar to an existing open-source project, I have listed it below

Prior art / related projects (if any):
None.

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

If AI tools materially shaped this change, briefly note what was used:
Claude Code drafted the fix and tests under my direction; an independent verification pass re-ran the tests on main and on the branch and probed bypasses, and I reviewed the result.

IP, Patents, and Licensing

  • This contribution does not implement patent-pending or patent-encumbered techniques
  • This contribution does not require an NDA or licensing agreement to understand or use
  • Any AI tools used have terms compatible with the MIT License

ExcessivePrivilegeEvaluation compared the privilege, role, scopes and
permissions values as given, so an action could slip past the check
with whitespace around a value, a list-valued privilege, a nested list
in scopes or permissions, a partial wildcard such as admin:*, or a
capitalised key such as Privilege or ROLE.

The four keys are now looked up case-insensitively. Values are stripped
of surrounding whitespace, including Unicode whitespace, and case-folded
before the comparison. Nested lists, tuples and sets are flattened to
their leaves with an iterative walk that tolerates self-referencing
collections, and max_scopes counts the flattened leaves. Any string that
carries a "*" is treated as a wildcard and so as elevated.

A leaf that is not a string (None inside a list, bytes, a mapping, a
number) fails closed and is reported with its own message, as the cost
check does for a non-numeric cost. The public API and the result shape
are unchanged.

Add regression tests for every form in the issue and for mixed-case keys
and values, Unicode whitespace, deeply nested and empty lists,
unreadable leaves, and low privileges that must still pass.

Fixes #4400

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels Oct 22, 2026
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions github-actions Bot added the size/L Large PR (< 500 lines) label Oct 22, 2026
@github-actions

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@Ricky-G

Copy link
Copy Markdown
Contributor

Looks good overall, thanks for covering the nested privilege values. Could you please review these potential edge cases in evaluation.py:632-635?

  • The persistent object-ID cache can skip a later admin value in generator-produced nested lists: once an earlier list is freed, Python can reuse its ID for the next one.
  • Reusing the same list in scopes and permissions counts it only once. Six entries in each pass max_scopes=10, while two separate copies correctly count 12.

Could we track only active traversal ancestors instead, keeping cycle protection without skipping noncyclic aliases, and add regressions for both cases?

…alues

_flatten_leaves kept a set of every container id it had seen for the
whole walk. Two values slipped past it: a container that is freed before
a later one is created can hand the later one the same id, so a fresh
list carrying "admin" was treated as already visited; and the same list
object reached by two paths was read once.

The walker now keeps an explicit stack of (container, iterator) frames
and tracks only the ids of containers on the current path, adding on
enter and removing on exit. Each frame holds its container, so an id on
the path cannot be reused mid-walk. A self-referencing collection still
terminates; a container reached twice by different paths is read each
time.

Tests cover a list yielded twice from a generator with the elevated
value added between the yields, a chain of generator-built lists where
the last one carries "admin", and an elevated value inside a cycle.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
…wice

The same six-entry list under both keys must count as twelve scopes and
exceed max_scopes=10. The earlier walker read a shared object once and
let it pass.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator Author

Thanks Ricky Gummadi (@Ricky-G), both cases were real. _flatten_leaves now keeps only the ids of containers on the current traversal path (added on enter, removed on exit) instead of one id set for the whole walk.

  • Reused id after a freed list: each stack frame holds its container, so an id on the path cannot be handed to a new list mid-walk. Covered by test_excessive_privilege_detects_admin_in_list_with_reused_id and test_excessive_privilege_revisits_a_container_yielded_twice in 2320dcc.
  • Same list under scopes and permissions: a container reached by two paths is now read each time, so six shared entries count as twelve and fail max_scopes=10. Covered by test_excessive_privilege_counts_shared_list_in_scopes_and_permissions in c75f8fb.
  • Cycle protection stays: test_excessive_privilege_flags_elevated_value_inside_a_cycle in 2320dcc checks a self-referencing list still terminates and still flags admin.

One note: with per-reference reading, a heavily aliased acyclic structure costs time proportional to the number of paths. I left any leaf cap for a follow-up rather than fold it in here.

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

documentation Improvements or additions to documentation size/L Large PR (< 500 lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agent-learning: ExcessivePrivilegeEvaluation misses whitespace, list-valued, nested, partial-wildcard and capitalised privilege values

2 participants