Skip to content

fix(policy-engine)!: remove AgentControl::new and make the host always carry its runtime parts - #4416

Open
MohammadHaroonAbuomar wants to merge 6 commits into
mainfrom
mhabuomar/issue-3941
Open

MohammadHaroonAbuomar wants to merge 6 commits into
mainfrom
mhabuomar/issue-3941

Conversation

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Related Issue

Fixes #3941

Problem & Solution

Problem. agent_control_spec::Runtime exposes no accessor for the annotator dispatcher or the Limits it was built with. A Rust AgentControl built through AgentControl::new(runtime) therefore could not recover them, and three paths failed open:

  1. A policy Transform was revalidated against Limits::default() instead of the caller's limits. A tightened max_snapshot_bytes did not apply to the transformed snapshot and the guarded body ran.
  2. with_telemetry(sink) could not rebuild the runtime, so it kept the old sink and said nothing. The installed sink received no events.
  3. The system_prompt_file rejection in the manifest constructors was bypassed, since new took any runtime.

Reproduced on origin/main (fb950bc) in a throwaway worktree at /tmp/verify-3941, with a repro module appended to policy-engine/sdk/rust/src/host/tests.rs using main's own helpers:

  • Test 1: AgentControl::new(Runtime::with_limits(run_manifest, NoopAnnotator, <transform of 100 bytes>, Limits { max_snapshot_bytes: 256 })), then run_with_options with a 180-byte ambient snapshot. Output: MAIN new()+with_limits(256): executed=true outcome_is_ok=false; the test panicked on assert!(!executed). The guarded body ran.
  • Test 2: AgentControl::new(runtime(run_manifest, <deny policy>)).with_telemetry(InMemoryTelemetrySink), then evaluate_intervention_point(Input, Enforce). Output: MAIN new()+with_telemetry: decision=Deny events=0; panicked on assert_eq!(events, 1).
  • test result: FAILED. 0 passed; 2 failed.
  • Pasting this branch's whole policy-engine: AgentControl::new(runtime) leaves parts=None, making with_telemetry a no-op and revalidating against Limits::default #3941 test block onto main's tests.rs fails to compile with 5 errors: E0599 no method with_perf_telemetry_level; E0599 no unwrap_or_else on AgentControl because with_telemetry returns Self, not Result; 3x E0425 build_control missing (the branch renamed it from runtime()). So the new tests cannot pass on main.
  • The worktree was removed afterwards (git worktree remove --force, git worktree prune).

Solution.

  • Remove AgentControl::new(runtime). A control is built from a manifest only: from_path, from_manifest, from_manifest_chain and their _with_dispatchers and _and_limits forms. Each keeps the dispatcher and Limits it was built with, and each rejects system_prompt_file.
  • AgentControl now always carries its runtime parts (manifest, dispatchers, limits, perf level), so transforms are revalidated against the configured limits and HostEvaluation receives them.
  • with_telemetry returns Result<AgentControl, RuntimeError>. It rebuilds the runtime with the new sink; a second call replaces the first sink. The error is reachable only if the upstream rebuild fails on a manifest that already built once, and it is surfaced rather than swallowed.
  • Add AgentControl::with_perf_telemetry_level(level) (Off, External, Full), the knob the FFI, Python and Node hosts already expose, so Rust keeps parity.
  • Migrate the 31 in-repo call sites (SDK tests, examples, integrations/rig, integrations/annotators, examples/coding_agent/app) to the manifest constructors.
  • Bump the Rust SDK to 0.4.0-beta.1 and document the change in CHANGELOG.md and BREAKING_CHANGES.md.

Why removal rather than guarding or deprecation. The retargeted 0.4.0-beta.0 crate is not on crates.io, so there is no external user to protect with a deprecation window, and a window would keep the two remaining holes (telemetry and system_prompt_file) open for its length. HostEvaluation::from_engine and the Python/Node bindings are a separate follow-up.

Impact on Your Work

Breaking for Rust hosts that built AgentControl from a prebuilt Runtime. The rewrites are in BREAKING_CHANGES.md (one line each for Runtime::new, Runtime::with_limits, and with_telemetry). All 31 in-repo call sites are migrated in this PR.

Version changes:

  • policy-engine/sdk/rust/Cargo.toml: 0.4.0-beta.0 -> 0.4.0-beta.1.
  • policy-engine/integrations/{annotators,mcp,openai,otel,rig}/Cargo.toml: -> 0.4.0-beta.1. Their SDK dependencies are path-only, so no version requirement needed updating.
  • agent-governance-rust/Cargo.toml: workspace dependency version = "0.4.0-beta.1".
  • policy-engine/Cargo.lock updated for the six crates; agent-governance-rust/Cargo.lock and policy-engine/examples/coding_agent/app/Cargo.lock updated.
  • benchmarks/prompt-injection/harness/agt-rules-baseline/Cargo.lock refreshed (SDK -> beta.1; agent-control-spec alpha.3 -> alpha.4 with serde-saphyr, serde_path_to_error, tempfile; thiserror 2.0.19 -> 2.0.21; serde_yaml and unsafe-libyaml removed). This lock was already behind main on agent-control-spec since feat(policy-engine): upgrade ACS and clarify upstream ownership #4224 and cargo metadata --locked refused it. The supply-chain-check workflow runs cargo-deny on that manifest without --locked, so CI would not have caught it, but the previous SDK bump (a6db33e) kept this lock in step and this PR does the same.

Intentionally untouched and still 0.4.0-beta.0: the sdk/python and sdk/node binding crates (agent_control_specification_py, agent-control-specification-node), the lib.rs deprecated(since = "0.4.0-beta.0") attributes, and the QUICKSTART.md and docs/dependency-audits references, which are historical kit and audit text.

Timeline

None

Alternatives Considered

  • A. Guard Transform only. Fixes the limits hole but leaves the silent telemetry failure and the system_prompt_file bypass in place.
  • B. Deprecate new and remove later. No external user exists for the unpublished crate, and a window keeps two fail-open paths alive for its length.
  • C. Fallible new with caller-supplied limits. Still cannot recover the dispatcher or enforce the system_prompt_file check, and it asks the caller to restate limits the runtime already holds, which invites drift.
  • D. Wait for upstream Runtime accessors. Leaves the holes open on an unknown schedule; the manifest constructors already give the host everything it needs.

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

Testing

Unit Testing

New tests in policy-engine/sdk/rust/src/host/tests.rs (all pass on this branch; they do not compile on main, see above):

  • Transform exactly at max_snapshot_bytes is admitted (canonical length asserted == 256); one byte over is blocked with host_error:transform_invalid.
  • Evaluate-only mode reports host_error:transform_invalid with transformed_policy_target None while the body runs. (EnforcementMode has only Enforce and EvaluateOnly; there is no observe variant, so this is the observe-style path.)
  • The approval resolver survives with_telemetry (escalate then allow, 2 events).
  • A second with_telemetry replaces the first sink (0 vs 1 events).
  • A cloned control keeps tightened limits.
  • with_perf_telemetry_level round-trips Off -> External -> Full via runtime().perf_telemetry() and survives with_telemetry.
  • system_prompt_file is rejected by all seven public constructors.
  • with_telemetry delivers exactly one deny event for every constructor (from_path, from_path_with_dispatchers, from_manifest, from_manifest_with_dispatchers, from_manifest_with_dispatchers_and_limits, from_manifest_chain, from_manifest_chain_with_dispatchers).

Extra boundary probes, added, run and reverted (tree clean):

  1. Manifest built with Manifest::from_yaml_chain over two documents, then from_manifest_with_dispatchers_and_limits with a 256-byte limit: oversized transform blocked, executed=false, reason host_error:transform_invalid. There is no from_manifest_chain_with_limits constructor, so chain plus limits takes this two-step route.
  2. Evaluate-only with an oversized transform on a control with an installed sink: reason host_error:transform_invalid, 3 events reached the sink.
  3. Clone then with_telemetry on the clone: the clone blocks (executed=false) and emits 2 events; the original still blocks and its runs add 0 events to the clone's sink. (A first attempt failed only because QueuePolicy is a shared queue the clone consumed; re-run with two queued responses passed.)
  4. The Debug impl prints only <runtime> and the resolver presence; no limits or dispatcher leak.

No defects found in the branch logic.

Manual Testing

All run in the foreground on this branch from policy-engine/ unless noted:

  • cargo test --locked -p agent_control_specification --lib: 57 passed, 0 failed.
  • cargo test --locked -p agent_control_specification --features bundled-dispatchers,opa --all-targets: lib 57, agent_loop_exercise 9, artifact_validation 1, conformance_corpus 3, fail_closed_error_parity 1, ffi_default_dispatchers 5, ffi_roundtrip 10, streaming_conformance 5, upstream_compatibility 6; examples basic_host and ifc_agent compiled (0 tests); all ok.
  • AGT_EXPECT_UPSTREAM_REGO=1 cargo test --locked -p agent_control_specification --features bundled-dispatchers,opa,agent-control-spec/rego --test upstream_compatibility: 6 passed.
  • cargo clippy --locked --workspace --all-targets -- -D warnings: failed on the first version of the branch (clippy::type_complexity on the every_constructor() return type at sdk/rust/src/host/tests.rs:1510); fixed in 4fb4975 by naming the tuple; re-run exits 0.
  • cargo fmt --all -- --check: exit 0 before and after that fix.
  • policy-engine/integrations/rig: cargo test, guarded_tool 17 passed.
  • policy-engine/integrations/annotators: cargo test, dispatcher 11 passed, moderation_flow 0, lib 0; ok.
  • policy-engine/examples/coding_agent/app: cargo build --locked, Finished (exit 0).
  • cargo build --locked -p agent_control_specification --example basic_host --example ifc_agent: Finished (exit 0).
  • agent-governance-rust: cargo build --locked -p agentmesh, Finished, compiled agent_control_specification v0.4.0-beta.1 (exit 0).
  • benchmarks/prompt-injection/harness/agt-rules-baseline: cargo build --locked --offline after the lock refresh, Finished (exit 0).
  • Spell check: 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 (701 lines), then npx --yes cspell@8.17.3 --config .cspell.json --no-progress ci-diff/added.txt: Issues found 0, run before and after the follow-up commits; ci-diff removed.
  • python3 scripts/check_v4_ratchet.py: v4 removal ratchet OK (grand total 4).
  • git merge-tree --write-tree origin/main HEAD: clean.

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 (cargo fmt --check and cargo clippy -D warnings; no Python changed, so ruff does not apply)
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass (cargo test; no Python changed, so pytest does not apply)
  • I have updated documentation as needed
  • 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 change and tests under my direction following the direction I posted on the issue; an independent verification pass re-ran the CI commands, confirmed the new tests fail on main, and probed the boundaries, 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

… fallible

AgentControl::new(runtime) wrapped a caller-built Runtime that exposes
no accessor for its annotator dispatcher or Limits, so the control
could not keep them. Three paths then failed open: a transform was
revalidated against Limits::default() instead of the caller's limits,
with_telemetry could not rebuild the runtime and kept the old sink
without saying so, and the system_prompt_file rejection in the
manifest constructors was bypassed.

Remove the constructor. RuntimeParts becomes a plain field that every
manifest constructor fills, with_telemetry returns
Result<Self, RuntimeError> so a failed rebuild surfaces, and
evaluate_intervention_point always revalidates against the retained
limits. Add with_perf_telemetry_level so the Rust surface keeps the
knob the FFI, Python and Node hosts already have.

Tests build through every public constructor and check that tightened
limits block an oversized transform (enforce and evaluate-only, at the
byte boundary, on a clone, and after a telemetry rebuild), that an
installed sink receives the decision event, that the perf level and
approval resolver survive the rebuild, that a second sink replaces the
first, and that system_prompt_file is rejected everywhere.

Refs #3941

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Every in-repo caller of AgentControl::new built Runtime::new(manifest,
annotator, policy) with default limits, so the rewrite to
AgentControl::from_manifest_with_dispatchers(manifest, Some(annotator),
Some(policy)) keeps behaviour. Covers the SDK integration tests, the
rig tests, the annotators example and the standalone coding_agent
example, which CI does not compile and was built by hand.

Refs #3941

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

Move agent_control_specification and the five integration crates to
0.4.0-beta.1, update the agent-governance-rust path dependency
requirement and the affected lockfiles. Record the removed constructor
and the fallible with_telemetry in BREAKING_CHANGES.md and CHANGELOG.md,
add the ? to the README telemetry table, and state in acs-retarget.md
that construction stays manifest-only until the upstream accessor lands.

Refs #3941

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
cargo clippy --workspace --all-targets -- -D warnings failed on the
six-field tuple every_constructor() returns (clippy::type_complexity).
Give it a ConstructorCase alias so the lint passes and the shape is
documented once.

Refs #3941

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
The harness depends on agentmesh and through it on the Rust SDK, so
its lockfile must follow the 0.4.0-beta.1 bump. It was also still on
agent-control-spec 0.4.0-alpha.4's predecessor from before #4224, so
cargo metadata --locked refused it. Regenerated offline; the harness
builds with --locked --offline.

Refs #3941

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 23, 2026
@github-actions

Copy link
Copy Markdown

Dependency Review

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

OpenSSF Scorecard

PackageVersionScoreDetails
cargo/agent-control-spec 0.4.0-alpha.4 UnknownUnknown
cargo/serde_path_to_error 0.1.20 🟢 4
Details
CheckScoreReason
Binary-Artifacts🟢 10no binaries found in the repo
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Code-Review⚠️ 0Found 0/26 approved changesets -- score normalized to 0
Packaging⚠️ -1packaging workflow not detected
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions🟢 10GitHub workflow tokens follow principle of least privilege
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 3security policy file detected
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0
cargo/thiserror 2.0.21 🟢 5
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Packaging⚠️ -1packaging workflow not detected
Maintained🟢 1017 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Code-Review⚠️ 0Found 0/28 approved changesets -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions🟢 10GitHub workflow tokens follow principle of least privilege
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
Security-Policy🟢 3security policy file detected
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0
cargo/thiserror-impl 2.0.21 🟢 5
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Packaging⚠️ -1packaging workflow not detected
Maintained🟢 1017 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Code-Review⚠️ 0Found 0/28 approved changesets -- score normalized to 0
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions🟢 10GitHub workflow tokens follow principle of least privilege
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
Security-Policy🟢 3security policy file detected
SAST⚠️ 0SAST tool is not run on all commits -- score normalized to 0

Scanned Files

  • benchmarks/prompt-injection/harness/agt-rules-baseline/Cargo.lock

@github-actions github-actions Bot added the size/XL Extra large PR (500+ lines) label Oct 23, 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

📦 Dependency diff (SBOM)

Comparing main → mhabuomar/issue-3941.

Summary: ➕ 0 added · ➖ 2 removed · 🔄 6 bumped

➖ Removed

cargo (2)

Package Version
serde_yaml 0.9.34%2Bdeprecated
unsafe-libyaml 0.2.11

🔄 Bumped

other (6)

Package From To
agent_control_specification 0.4.0-beta.0 0.4.0-beta.1
agent_control_specification_annotators 0.4.0-beta.0 0.4.0-beta.1
agent_control_specification_mcp 0.4.0-beta.0 0.4.0-beta.1
agent_control_specification_openai 0.4.0-beta.0 0.4.0-beta.1
agent_control_specification_otel 0.4.0-beta.0 0.4.0-beta.1
agent_control_specification_rig 0.4.0-beta.0 0.4.0-beta.1


[workspace.dependencies]
agent_control_specification = { path = "../policy-engine/sdk/rust", version = "0.4.0-beta.0" }
agent_control_specification = { path = "../policy-engine/sdk/rust", version = "0.4.0-beta.1" }

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.

CI failed because 4 Cargo lockfiles have changed without the required dated dependency-audit note.

Run chmod +x scripts/ci/vendored-patch-audit.sh
⚡ Lockfile changed: agent-governance-rust/Cargo.lock
benchmarks/prompt-injection/harness/agt-rules-baseline/Cargo.lock
policy-engine/Cargo.lock
policy-engine/examples/coding_agent/app/Cargo.lock
❌ vendored-patch-audit: lockfiles changed but no dependency audit doc found.

Rest looks good to me.

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator Author

Thanks Prayag (@prayagupa). Added the dated dependency-audit note for the four lockfiles in b6bf991; the vendored-patch audit passes locally against main now.

…fresh

The vendored-patch audit gate requires a dated note whenever a
lockfile changes. Record the four Cargo.lock updates that come with the
SDK version bump and the benchmark harness refresh.

Refs #3941

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

@Ricky-G Ricky Gummadi (Ricky-G) 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.

looks good no issues

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/XL Extra large PR (500+ lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

policy-engine: AgentControl::new(runtime) leaves parts=None, making with_telemetry a no-op and revalidating against Limits::default

3 participants