Skip to content

fix(agt-policies): fail closed on non-object input.snapshot in migrated Rego - #3659

Open
AlgoVoi (Christopher Hopley) (chopmob-cloud) wants to merge 2 commits into
microsoft:mainfrom
chopmob-cloud:fix/agt-policies-snapshot-guard-3517
Open

fix(agt-policies): fail closed on non-object input.snapshot in migrated Rego#3659
AlgoVoi (Christopher Hopley) (chopmob-cloud) wants to merge 2 commits into
microsoft:mainfrom
chopmob-cloud:fix/agt-policies-snapshot-guard-3517

Conversation

@chopmob-cloud

Copy link
Copy Markdown
Contributor

Fixes #3517.

The migration tool renders Rego with default verdict := {"decision": "allow"} and inlines each rule condition as chained object.get(input.snapshot, ...) accessors. When input.snapshot is absent, null, or a non-object, every accessor is undefined, no _match_i rule fires, and evaluation falls through to the default-allow verdict, so a caller that omits or mistypes the snapshot root is allowed on a deny-side policy.

Fix

Render a highest-precedence fail-closed guard in _render_rego:

  • default _snapshot_valid := false plus _snapshot_valid if { is_object(input.snapshot) }
  • a verdict := {"decision": "deny", "reason": "runtime_error:snapshot_invalid", ...} if { not _snapshot_valid } branch
  • every rule branch is gated on _snapshot_valid

A defaulted helper is required because an inline not is_object(input.snapshot) is itself undefined when the reference is undefined, so the absent-snapshot case would otherwise leak through to default-allow. Gating every rule branch (including the always-matching fail-closed deny rendered for an unsupported operator) on _snapshot_valid keeps the guard mutually exclusive with them, so a rule branch and the guard can never both hold and OPA never raises a complete-rule conflict on verdict.

Valid object snapshots are unaffected: a well-formed snapshot that matches no rule still falls through to default-allow, and a matching rule still applies.

Testing

  • Full agt-policies suite: 266 passed, 2 skipped (native ACS SDK built via maturin, plus opa, matching the policy-engine CI runtime).
  • New tests/test_migrate_snapshot_guard.py evaluates the generated Rego through opa: absent, null, string, number, and array snapshots all deny; a valid object snapshot without a match allows; a matching rule denies; an unsupported-operator rule combined with a bad snapshot resolves to a single deny with no conflict; plus a render-level assertion.
  • Reverting the guard makes every malformed-snapshot case return allow, confirming the tests exercise the fail-open.
  • ruff check src/ --select E,F,W --ignore E501 is clean.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added tests size/M Medium PR (< 200 lines) labels Aug 10, 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.

@github-actions

Copy link
Copy Markdown

🔴 Contributor Check: HIGH

Check Result
Profile HIGH
Credential LOW
Overall HIGH

Automated check by AGT Contributor Check.

@github-actions github-actions Bot added the needs-review:HIGH Contributor reputation check flagged HIGH risk label Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read the diff rather than the description, because the interesting part of this fix is not the guard itself but why the obvious version of it would not work.

The failure is real and it is the worst kind. Migrated Rego renders default verdict := {"decision": "allow"} and reaches every field through object.get(input.snapshot, ...). With input.snapshot absent, null, or a non-object, every accessor is undefined, no _match_i fires, and evaluation falls through to default-allow. A caller who omits or mistypes the snapshot root is therefore allowed by a deny-side policy, and nothing in the output says the policy was never evaluated. Silent allow on malformed input is exactly the direction a policy engine must not fail in.

Two things in here are why this is right rather than merely plausible.

The defaulted helper. default _snapshot_valid := false instead of an inline not is_object(input.snapshot). An inline negation over a builtin with an undefined operand is itself undefined, so the naive form would leak the absent-snapshot case straight back to default-allow: the guard would appear to be there and would not fire in the case it was written for. Your comment says this at the site, which is the right place for it.

Gating every rule branch on _snapshot_valid. Without it the guard and an always-matching branch, including the fail-closed deny for an unsupported operator, could both hold and OPA would raise a complete-rule conflict on verdict. Turning a silent allow into a hard evaluation error would be a different bug. test_invalid_rule_and_bad_snapshot_do_not_conflict covers precisely that interaction.

The tests evaluate the rendered policy rather than asserting on the rendered text, which is the difference between testing the fix and testing the string that describes it. test_valid_object_snapshot_without_match_still_allows is the one that matters for regression: the guard must not turn a legitimate no-match into a deny.

MohammadHaroonAbuomar this closes #3517, which you filed. It is green and has been sitting unreviewed for two days.

I do not have the approve bit here, so this is a comment rather than an approval, but I would merge it as it stands.

…ed Rego

The migration tool renders Rego with `default verdict := {"decision": "allow"}` and inlines each rule condition as chained `object.get(input.snapshot, ...)` accessors. When `input.snapshot` is absent, null, or a non-object, every accessor is undefined, no `_match_i` rule fires, and evaluation falls through to the default-allow verdict, so a caller that omits or mistypes the snapshot root is allowed on a deny-side policy (issue microsoft#3517).

Render a highest-precedence fail-closed guard: `default _snapshot_valid := false` plus `_snapshot_valid if { is_object(input.snapshot) }`, and a `verdict := deny if { not _snapshot_valid }` branch. A defaulted helper is required because an inline `not is_object(input.snapshot)` is itself undefined when the reference is undefined, so the absent-snapshot case would otherwise leak through to default-allow. Every rule branch, including the always-matching fail-closed deny rendered for an unsupported operator, is gated on `_snapshot_valid`, so a rule branch and the guard can never both hold and OPA never raises a complete-rule conflict on `verdict`.

Valid object snapshots are unaffected: a well-formed snapshot that matches no rule still falls through to default-allow, and a matching rule still applies. Add opa-backed tests covering absent, null, string, number, and array snapshots (all deny), a valid snapshot without a match (allow), a matching rule (deny), and an unsupported-operator rule combined with a bad snapshot (single deny, no conflict).

Reported-by: @MohammadHaroonAbuomar
Signed-off-by: AlgoVoi <chopmob@gmail.com>
The new regression test for the snapshot-guard fix shipped without the
standard license header, which the changed-files header gate rejects. Add
the canonical Microsoft MIT header to match every sibling test file.

Signed-off-by: AlgoVoi <chopmob@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:HIGH Contributor reputation check flagged HIGH risk size/M Medium PR (< 200 lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agt migration rego rendering: absent/null/non-object input.snapshot falls to default allow

2 participants