microsoft / microsoft/agent-governance-toolkit
RFC: Preserve Microsoft Agent Framework execution identity in AGT governance evidence
- Dominant language
- Python
- Stars
- 6.3k
- Forks
- 1.1k
- Avg merge
- 5d 11h
- Merged PRs (30d)
- 142
Description
### Summary
MAF writes the exact model tool-call ID into `FunctionInvocationContext.metadata["call_id"]` under the comment *"Always pass call_id to middleware for policy violation approval flow"* — the field exists for exactly the governance path this adapter implements. AGT's Python MAF adapter does not read it. Instead, `RuntimeGovernanceMiddleware` and `CapabilityGuardMiddleware` each mint a separate `int(time.time())` session ID, and the capability guard sends `maf-cap-{counter}` to policy evaluation. AGT's own .NET ACS Agent Framework extension already forwards the native `context.CallContent.CallId`, so preserving framework call identity is established practice in this repository — the Python adapter is the outlier.
What I am asking you to accept is small: pass the exact MAF call ID to policy evaluation instead of a counter, stop minting two adapter sessions per agent run, record the native MAF identifiers in adapter audit as validated evidence, and add one keyword-only argument so an already-typed audit field can be populated. That is the whole normative proposal, and §6 states it as a four-point checklist.
The rest of this document — an optional correlation resolver, a gap-record convention, a canonical audit envelope, and a real checkpoint/approval fixture — is design I have worked through and am **deliberately not asking you to adopt here**. §7 lists it as deferred. I have written it out because the correction only makes sense if you can see where it leads, not because it should all land at once.
Two things I am explicitly **not** proposing, because I could not establish that they are right: making the caller-supplied MAF session ID the `HostSession` cache key, and changing how per-session budgets are scoped. Both are noted as open questions rather than answered. This is an evidence-correlation change, not a new authorization protocol, and not a change to your accounting model.
### Motivation
MAF already supplies the exact identifiers an adapter needs:
- MAF rejects a missing tool-call ID (`raise KeyError(f'Function "{...}" is missing call_id.')`) and then writes the exact value to `FunctionInvocationContext.metadata["call_id"]`, annotated *"Always pass call_id to middleware for policy violation approval flow"* ([`_tools.py#L1571-L1576`](https://github.com/microsoft/agent-framework/blob/07511b80c9bd6369f1dab00981d744354e24d1a9/python/packages/core/agent_framework/_tools.py#L1571-L1576)). The identifier is mandatory on this path and is surfaced to middleware specifically for governance.
- `AgentContext` and `FunctionInvocationContext` expose `session`, whose `AgentSession.session_id` is serialized by `to_dict`/`from_dict` and therefore stable across session restore. It is typed `AgentSession | None`, so a sessionless run is legitimate MAF usage.
- Workflow checkpoints preserve definition identity, checkpoint ancestry, pending request information, metadata, and trace-bearing messages across a fresh workflow instance.
The current AGT Python adapter does not preserve that identity ([`maf_adapter.py`](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/agent-governance-python/agent-os/src/agent_os/integrations/maf_adapter.py)):
- `RuntimeGovernanceMiddleware` creates one `maf-mw-{int(time.time())}` session ([L260](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/agent-governance-python/agent-os/src/agent_os/integrations/maf_adapter.py#L260)).
- `CapabilityGuardMiddleware` creates a separate `maf-cap-{int(time.time())}` session ([L427](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/agent-governance-python/agent-os/src/agent_os/integrations/maf_adapter.py#L427)).
- The capability guard submits `maf-cap-{call_count + 1}` to `evaluate_pre_tool_call` instead of the exact MAF call ID ([L464](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/agent-governance-python/agent-os/src/agent_os/integrations/maf_adapter.py#L464)).
- Neither `context.session` nor `context.metadata["call_id"]` is read anywhere in the module.
- Its deny/start/complete audit rows contain the tool name but no exact MAF call/session identity or ACS action identity.
Two consequences follow, and the first is functional rather than evidential.
**Host session state is split across one agent run.** `NativeAdapterRuntime` caches `HostSession` by `key = ctx.session_id or ctx.agent_id` ([`_native_adapter_runtime.py#L237`](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/agent-governance-python/agent-os/src/agent_os/integrations/_native_adapter_runtime.py#L237)). Because the runtime and capability layers mint different session IDs, a single logical agent run is served by two distinct `HostSession` objects, so per-session counters and budgets are fragmented across them rather than accumulated for the run.
**Fabricated identity collides at one-second resolution.** Both session IDs derive from `int(time.time())` and are cached per middleware instance, so two instances first used within the same second receive identical session IDs. Combined with a per-instance `call_count`, two concurrent runs starting in the same second can produce the same `(session_id, call_id)` pair for different actions — no process boundary is required for the collision. In the other direction, an approved action resumed in a fresh instance receives a different fabricated identifier. Neither direction is a lossless join, and a durable audit join is left depending on timestamps, counters, or payload inference.
The core authorization design is not missing. ADR-0030 already defines action-bound approval, exact action digest, policy and chain versions, authenticated approval evidence, fail-closed revalidation, single consumption, and linked audit events. The narrower gap is that the MAF adapter discards native execution identity before that evidence reaches the framework boundary.
### Detailed Design
#### 1. Pass the exact call ID to policy evaluation
For `CapabilityGuardMiddleware`, pass the exact, non-empty `context.metadata["call_id"]` to `evaluate_pre_tool_call`. Do not derive a call ID from `call_count`, time, tool name, or arguments.
This is the correction the rest of the proposal rests on, and it is the one your .NET side already performs: the ACS Agent Framework extension forwards `context.CallContent.CallId` to `ProtectToolAsync` today. The Python adapter is the outlier.
MAF guarantees the value on this path — it raises `KeyError` rather than invoke a function without a call ID — so an absent or empty `context.metadata["call_id"]` in a pre-tool evaluation is a broken invariant rather than a supported configuration. My proposal is that the adapter block before `call_next` in that case instead of falling back to a fabricated identity. I want to flag that this is a stricter choice than .NET, which passes `null` for an empty call ID; the cross-language precedent supports forwarding the native value, not the fail-closed policy, and if you prefer to match .NET and pass `null` I have no strong counter-argument beyond wanting a governance evaluation never to run against an identity the adapter invented.
#### 2. Give one agent run one adapter execution state
I propose that `create_governance_middleware` mint **one** adapter-owned execution identity and hand it to both middleware layers, instead of each calling `_ensure_v5_context()` independently. The value should be a fresh unique identifier rather than a wall-clock second, since two instances first used in the same second currently receive identical session IDs.
This keeps the key space exactly where it is today — adapter-owned, one per factory call, bounded — and fixes only the split. It deliberately does **not** adopt the MAF session ID as the cache key; see §7.
#### 3. Record native MAF identity as evidence
The exact MAF `session_id` (when a session is present) and the exact `call_id` belong in the adapter's audit records for the evaluation, so that a later reader can join a governance decision to the framework execution that produced it. §4 describes the record shape.
Native identity is evidence here, not a cache key, a namespace, or an authenticated principal. That distinction matters because **both native identifiers are externally influenced**: `call_id` originates in model output, and `AgentSession.session_id` is documented in MAF as an *"opaque caller-selected session ID"*. MAF itself treats the session ID as untrusted input when it reaches an unsafe context — `_sessions.py` validates it against a filesystem-safety predicate and base64-encodes or hashes it before using it as a file stem.
So the adapter should validate them before use: bound both at 256 characters, restrict them to printable ASCII excluding control characters, store them verbatim without normalization so a host identifier is never silently rewritten, and never use either as a filesystem path, a metric label, or a cache key. A value failing those bounds is omitted from the record rather than written through. I would rather state this than let exact-but-unvalidated strings reach policy input and audit on the strength of the word "exact". Tell me if there is an existing validator in the repository I should use instead of a new one.
A missing session is not an error. `FunctionInvocationContext.session` is typed `AgentSession | None` and documented "if any", so a sessionless run is supported MAF usage that works today. It simply means cross-run correlation evidence is unavailable, and the audit record should say so rather than imply a join that does not exist.
Tests that invoke middleware directly should construct the same context shape MAF supplies, and should cover the sessionless path explicitly.
#### 4. Record the correlation object in adapter audit
Every capability deny, start, completion, and exception record for one evaluation should carry the same redaction-safe object:
```json
{
"framework": "microsoft-agent-framework",
"session_id": "",
"call_id": "",
"input_identity": "sha256:",
"enforced_identity": "sha256:"
}
```
Keep it in `data["correlation"]`. In the current v1.0 audit hash `data` is covered while the additive top-level `trace_id` and `session_id` fields are not, so the nested object is the canonical v1.0 evidence copy; project the session ID to the existing top-level field as well for query and CloudEvents compatibility. Add `session_id` as a keyword-only argument to `AuditLog.log`, because `AuditEntry` already defines that field but `AuditLog.log` cannot currently populate it.
The deny, start, and terminal records for one evaluation must carry the same call and session values, and the terminal record must reference the start entry. Completion and error logging should run in `finally` so an exception after a permitted evaluation does not leave an unclosed start record. When comparing across a later retry or resume, compare native MAF identity first: the ACS digest is not a stable retry key, because the canonical policy input also contains the session timestamp and running budgets, so the same MAF call can legitimately hash differently later.
No ACS telemetry change is proposed. `TelemetryEvent` already carries `policy_id` and `action_identity`, and the OTel metrics sink intentionally omits action identity and arbitrary metadata from metric labels. Recording the same `enforced_identity` in adapter audit gives a deterministic join for that evaluation without putting high-cardinality identifiers into metric labels.
#### 5. Proposed placement
- `agent-governance-python/agent-os/src/agent_os/integrations/maf_adapter.py` — §1, §2, §3, §4
- `agent-governance-python/agent-os/tests/test_maf_adapter.py` — fast mapping, audit, and failure tests using explicit context fixtures
- `agent-governance-python/agent-mesh/src/agentmesh/governance/audit.py` — keyword-only `session_id` forwarding to the already-existing `AuditEntry.session_id`
No MAF core change, new package, new protocol, cross-language approval redesign, policy-engine change, or new dependency is involved. `agent-framework` does not become an `agent-os` dev dependency, because nothing in the accepted scope needs a real MAF runtime to test.
#### 6. What I am asking you to accept
This is the whole normative proposal. Accepting the RFC means accepting these four points and nothing else:
1. `evaluate_pre_tool_call` receives the exact `context.metadata["call_id"]`, never a counter-derived value.
2. `create_governance_middleware` mints one adapter-owned execution identity shared by both middleware layers, replacing the two independent wall-clock ones, so one agent run maps to one `HostSession`.
3. Adapter audit records the exact native MAF `call_id` and, when present, `session_id`, validated as in §3 and shaped as in §4, inside hash-covered `data["correlation"]`.
4. `AuditLog.log` gains a keyword-only `session_id` argument so the already-typed `AuditEntry.session_id` field can be populated.
Points 1 and 2 are corrections to shipped behavior. Points 3 and 4 are the smallest additions that make the correction observable in audit. If you want to narrow further, point 1 alone is the defect and stands on its own.
#### 7. Deferred — designed but not proposed here
I worked these through and am not asking you to decide them now. Each is a separate proposal with its own maintenance cost, and bundling them would make "accept" ambiguous.
- **A typed correlation resolver** for workflow, checkpoint, request, and trace coordinates, supplied through one optional keyword-only factory argument, allowlisted to declared string fields, and treated as host-asserted audit-only evidence that never enters the action binding. This is the natural follow-up once §1–§4 land, but nothing in the correction requires it.
- **A `correlation_gap` record convention** naming why optional evidence is absent. Worth defining if and when the resolver exists; a bare omission is adequate for the accepted scope.
- **Canonical-versus-projected audit layout** beyond the minimum in §4 — in particular whether the v1.0 entry-hash boundary should be widened in v1.1 so that top-level `session_id` and `trace_id` become tamper-evident. That is your schema decision, not mine.
- **A real pinned-MAF checkpoint and approval conformance fixture** proving that a consequential action approved before a checkpoint, resumed in a fresh workflow instance, and executed afterwards can be joined to one authorization receipt, with a denied branch producing zero side effects. This is the scenario that motivated the investigation, and it is also the most expensive thing here: it needs a pinned external framework dependency, an owner for that pin, and a decision about whether its failure blocks a release. I would rather raise it separately, with those costs named, than smuggle it in.
**Two questions I could not answer and am therefore not proposing on:**
- Should the MAF session ID become the `HostSession` cache key? I initially assumed yes. Against it: `NativeAdapterRuntime._sessions` is a plain dict with no eviction, and `AgentSession.session_id` is caller-selected, so that change would convert a bounded adapter-owned key space into unbounded externally-supplied cardinality in a cache that never releases entries. §2 therefore keeps the key adapter-owned.
- What is the intended lifetime and scope of a `HostSession` relative to an agent run? Can one MAF session legitimately span several runs, workflows, or agents, and should restoring a session restore accumulated budgets? I do not know your intent here, and the answer determines whether native session identity may ever be an accounting boundary rather than only evidence. §2 changes no budget semantics precisely because I could not answer this.
#### 8. Decisions I am asking for
1. **The fail-closed choice in §1.** Block before `call_next` on an absent call ID, or match .NET and pass `null`? I lean toward blocking, but the cross-language precedent points the other way and this is your availability tradeoff.
2. **Staging for §2.** Merging two host sessions into one changes per-session accounting. Take it directly, or gate it behind a release of warnings first?
3. **The validation contract in §3.** Are the 256-character and printable-ASCII bounds on native identifiers right, or is there an existing repository-wide validator I should use instead?
4. **The plumbing and the deferred list.** Accept or reject `AuditLog.log(session_id=...)`, and say whether anything in §7 — particularly the conformance fixture and its dependency cost — is worth raising sooner rather than later.
If you accept the direction, I can implement §6 — the adapter changes, the audit plumbing, and the fast unit tests — and would rather agree the boundary here than open a speculative PR. I am equally happy to hand it to whoever owns this surface and help review instead.
### Alternatives Considered
1. **Keep time/counter-derived IDs.** Rejected: they are per-instance, collide at one-second resolution without needing a process boundary, and are not a lossless join to the framework action.
2. **Use timestamps or payload hashes to correlate records.** Rejected: timestamps are ambiguous under concurrency/retry, and payload-derived joins leak or depend on sensitive data.
3. **Put workflow coordinates into ACS telemetry metadata.** Rejected: event creation is inside the ACS control path, the adapter has no supported injection seam, and these values are high-cardinality. Existing enforced identity already joins one evaluation.
4. **Put checkpoint/request IDs into the policy action identity.** Rejected: execution location is evidence, not authority. Moving the same approved action across a valid checkpoint should not change its authorization binding.
5. **Change MAF core to add another identifier.** Rejected: MAF already supplies exact tool-call and serializable session identity plus host-owned checkpoint/request state.
6. **Add a generic cross-framework correlation envelope first.** Rejected as too broad. Prove the MAF contract in one integration; generalize only after a second adapter demonstrates the same stable fields and semantics.
7. **Documentation-only guidance.** Insufficient because the shipped adapter currently substitutes the identifiers before user code can correct the policy input.
8. **Stage the change behind a deprecation window** — keep the legacy values for one or more releases, warn, then switch. Rejected for §1, kept open for §2. On the call ID there is nothing coherent to stage: `maf-cap-{counter}` is per-instance and the paired session ID collides at one-second resolution, so a window would only extend the period in which the toolkit knowingly evaluates policy against an identity it invented. I do not claim nobody uses those values — an ambiguous identifier can still appear in a dashboard, a local join, or a cached decision — only that such use cannot be correct, and that a governance product is the wrong place to preserve it deliberately. The §2 consolidation is different: merging two host sessions into one is a real accounting change, and if you would rather gate it behind a release of warnings, that is a reasonable call and decision 2 is where to say so.
9. **Fix only the call ID and stop there.** Genuinely viable, and the narrowest thing that removes the defect. I did not lead with it because a corrected call ID that never reaches an audit record leaves the original question — which authorization produced this side effect — still unanswerable. If you want only §1, I will take it.
### Security Implications
- The exact call ID enters the existing ACS policy input and therefore its input/enforced identities. §1 proposes failing closed before the side effect when it is absent or invalid, so a governance evaluation never runs against an adapter-invented identity. Decision 1 flags that this is stricter than the .NET sibling and is yours to settle.
- **Both native identifiers are externally influenced and must be treated as untrusted strings.** `call_id` originates in model output; `AgentSession.session_id` is documented in MAF as an opaque caller-selected value, and MAF itself sanitizes it — validating against a filesystem-safety predicate and base64-encoding or hashing it — before using it as a file stem. Bound both at 256 characters and printable ASCII, store verbatim, and never use either as a filesystem path, metric label, or cache key. Exactness is not trustworthiness.
- A missing session is a supported MAF configuration. It costs correlation evidence, not enforcement, and must not block.
- Native identifiers are high-cardinality. They belong in audit evidence, not in metric labels.
- Keep the correlation object inside hash-covered audit `data`; the top-level `session_id` and `trace_id` fields are outside the v1.0 entry hash, so a projection there alone is not tamper-evident.
- Do not copy raw context metadata, prompts, arguments, results, approval content, trace baggage, secrets, or verification-pointer URLs into audit records. As far as I can see this path has no redaction backstop — `credential_redactor` is invoked from the MCP gateway, stateless, and response-scanner paths, not from `governance/audit.py` — so field selection is the only control here. Correct me if a sink downstream of `AuditLog` redacts.
- Recording an identifier proves what AGT recorded, not that the asserted value was true. Deployments needing stronger provenance need a separately reviewed signed-evidence design, which this RFC does not attempt.
### Migration / Backward Compatibility
`AuditLog.log(session_id=...)` is keyword-only and additive. Audit records gain a correlation object but lose no existing field. No persisted schema, policy, or MAF checkpoint migration is involved.
Two behavior changes are worth release notes.
**Policy input and audit identity change value.** ACS input and enforced identities will be computed over the exact call ID instead of `maf-cap-{counter}`. Anything that persisted the old value — an in-flight approval, an external consumer, a cached decision — should complete before upgrade or be reissued after it. I am not proposing a legacy fallback, because preserving an identifier that cannot distinguish two concurrent actions defeats the purpose of the change; alternative 8 gives my full reasoning, and overruling it is yours if you disagree. Separately, consumers should not treat the ACS digest as a stable retry key, since the canonical policy input also contains the session timestamp and running budgets.
**Per-session accounting consolidates.** Counters and budgets today spread across two host sessions for one agent run will accumulate in one, so a limit begins enforcing at its configured value rather than an effectively looser one. That corrects under-enforcement, but a deployment implicitly tuned against the split may reach a threshold earlier than before. This is the change most worth a warning release if you want one.
Runs without a MAF session are unaffected and gain no new requirement to attach one.
Add a regression test proving that two concurrent agent runs remain isolated while the runtime and capability middleware of a single run share one `HostSession`.
### Scope
Cross-package (2-3 packages)
### Target Placement
Integration (integrations/ directory)
### Prior Art
- [Framework Adapter Contract 1.0](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/docs/specs/FRAMEWORK-ADAPTER-CONTRACT-1.0.md): adapters mediate framework lifecycle events through `HostSession` and keep session counters in the host.
- [ADR-0030: action-bound, fail-closed approval](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/docs/adr/0030-action-bound-approval-protocol.md): owns authorization, approval, revalidation, consumption, and linked audit semantics.
- [AGT PR #3528](https://github.com/microsoft/agent-governance-toolkit/pull/3528): fixed the v4-to-v5 migrator and example manifests so that every intervention point an adapter evaluates is actually bound. It does not touch adapter identity, but it records that sixteen of the twenty adapters evaluate `pre_tool_call`, which is the surface the identity substitution described here sits on.
- [AGT PR #3190](https://github.com/microsoft/agent-governance-toolkit/pull/3190): added redaction-safe ACS decision telemetry, including policy/action identity and bounded OTel attributes.
- [MAF issue #4203](https://github.com/microsoft/agent-framework/issues/4203): concept-level external authorization/evidence proposal; it does not define or implement this AGT adapter mapping.
- [AGT PR #3342](https://github.com/microsoft/agent-governance-toolkit/pull/3342): external-checkpoint/verifier example, not MAF durable identity correlation.
- [AGT issue #3485](https://github.com/microsoft/agent-governance-toolkit/issues/3485): durable artifact IFC labels, not policy-decision/tool-call/checkpoint continuity.
- [`AgentControlAgentBuilderExtensions.cs#L49-L53`](https://github.com/microsoft/agent-governance-toolkit/blob/a311ab97b690972578d0c2f034b1368583d876d2/policy-engine/sdk/dotnet/src/AgentControlSpecification.AgentFramework/AgentControlAgentBuilderExtensions.cs#L49-L53): the .NET ACS Agent Framework extension forwards the native `context.CallContent.CallId` to `ProtectToolAsync`. This is the in-repository cross-language precedent for §1's core change — preserve the framework's own call identity rather than substitute one. It does **not** support §1's fail-closed rule: .NET passes `null` when the call ID is empty, which is the opposite choice, and decision 1 asks you to settle that difference rather than assume parity.
- [`kevros-agent-framework`](https://pypi.org/project/kevros-agent-framework/) is a third-party MAF governance middleware published on PyPI and described in the MAF #4203 thread. It is outside this repository and does not address the AGT adapter's identity mapping, but it indicates external interest in governing MAF tool calls.
### Checklist
- [x] I have searched existing issues and RFCs for duplicates
- [x] I have read the ADR index (adr/index.md) for related decisions
- [x] I am willing to implement this RFC or help review an implementation
Contributor guide
Research direction
Start with §6's four-point checklist, then read agent-governance-python/agent-os/src/agent_os/integrations/maf_adapter.py and the related agent-governance-python/agent-os/tests/test_maf_adapter.py. Compare the proposal with AuditLog.log and AuditEntry, including the session_id field. Done means the repository has agreed on the narrowly scoped identity, audit, and test changes while leaving the §7 design items deferred.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100