Skip to content

feat(middleware): add on_check_permission hook - #2001

Merged
DavdGao merged 14 commits into
agentscope-ai:mainfrom
Miracle778:permission-decision-observability
Jul 27, 2026
Merged

DavdGao merged 14 commits into
agentscope-ai:mainfrom
Miracle778:permission-decision-observability

Conversation

@Miracle778

@Miracle778 Miracle778 commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Note

Scope update

This PR originally implemented structured decision-path observability and
separate read-only hooks for permission decisions and confirmations.

Following the maintainer feedback in #2000, the PR has been narrowed to one
standard onion-style on_check_permission hook. The structured evaluation
types and confirmation hook have been removed. Decision-path observability
will be discussed separately.

AgentScope Version

2.0.4.post1

Description

Closes #2000.

Adds a standard onion-style on_check_permission middleware hook around
permission checking.

The hook provides a public extension point for both permission auditing and
application-owned authorization policies, while keeping
PermissionEngine.check_permission as the sole built-in engine interface.

Background

AgentScope performs permission checking after tool resolution and input
validation, but before on_acting.

This means existing middleware cannot observe the permission decision itself:

  • ASK and DENY return before on_acting;
  • ALLOW reaches on_acting, but only as an already-permitted execution;
  • application policies based on user, role, or tenant cannot be composed around
    the built-in permission check through a public middleware interface.

Changes

on_check_permission middleware hook

Adds the following hook to MiddlewareBase:

async def on_check_permission(
    self,
    agent: Agent,
    input_kwargs: dict,
    next_handler: Callable[..., Awaitable[PermissionDecision]],
) -> PermissionDecision:
    ...

The hook runs after tool resolution and input validation, and before Agent
consumes the returned decision.

Its input_kwargs contains:

  • tool_call;
  • the resolved tool;
  • parsed and validated tool_input.

As a standard middleware onion, a middleware may:

  • delegate with next_handler(**input_kwargs);
  • observe or replace the downstream decision;
  • return a decision without delegating, short-circuiting the remaining chain.

The built-in PermissionEngine.check_permission is the innermost handler.
With no permission middleware registered, Agent follows the existing direct
engine path.

Confirmed tool calls

A tool call resumed after user confirmation still traverses
on_check_permission immediately before execution.

The innermost handler returns the already-confirmed ALLOW without re-evaluating
the built-in engine. This keeps application policies and auditing active while
preserving the existing confirmation behavior.

Input isolation

The middleware chain receives copies of tool_call and tool_input.

Middleware may forward modified copies to downstream permission middleware or
the built-in permission check, but those changes do not alter the tool call or
input eventually executed by Agent.

Runnable example

Adds examples/permission_middleware_service/ with:

  • PermissionAuditMiddleware, which records the final decision returned by
    the complete permission chain;
  • UserToolPolicyMiddleware, which denies PermissionDemoTool for a
    configured application user before invoking the built-in engine;
  • a side-effect-free PermissionDemoTool covering ASK, DENY, and ALLOW;
  • bilingual README files with setup instructions, security boundaries, and
    screenshots.

The example demonstrates both primary uses of the hook:

  1. observing the final permission decision for audit logging;
  2. extending authorization with an application-owned per-user tool policy.

Tests

Tests cover:

  • the no-middleware compatibility path;
  • middleware detection and registration;
  • onion execution order;
  • observing final ASK, DENY, and ALLOW decisions;
  • replacing a downstream decision;
  • short-circuiting before the built-in engine;
  • forwarding modified permission-check inputs;
  • exception propagation;
  • consumption of middleware-returned decisions by the tool lifecycle;
  • confirmed calls traversing middleware without engine re-evaluation;
  • the audit and per-user policy examples.

Backward compatibility

  • PermissionEngine.check_permission remains the sole engine interface.
  • Existing permission modes, rules, and PermissionDecision semantics are
    unchanged.
  • Applications without on_check_permission middleware retain the existing
    execution path.
  • No AgentEvent, message, storage, or UI schema is changed.

Security boundary

A middleware that replaces or short-circuits a permission decision becomes part
of the application's trusted authorization boundary.

Returning without calling next_handler intentionally bypasses the built-in
permission engine for that call. This behavior is documented explicitly in the
hook contract.

Out of scope

  • Permission-decision transformation tracing.
  • A dedicated permission-confirmation hook; confirmation input remains
    observable through on_reply.
  • Changes to built-in permission modes or rule evaluation.

How to test

python -m pytest \
  tests/middleware_test.py \
  tests/permission_middleware_example_test.py \
  -q
pre-commit run --files $(git diff --name-only origin/main...HEAD)

Run the example with Redis and an LLM configured:

cd examples/permission_middleware_service
python main.py

Then connect the existing examples/web_ui frontend to
http://localhost:8000.

Checklist

  • An issue has been created for this PR
  • I have read the CONTRIBUTING.md
  • Docstrings use the project style
  • Tests cover the middleware contract and runnable example
  • The example includes English and Chinese documentation
  • Code is ready for review

Add opt-in, read-only middleware hooks that make every permission
decision and user-confirmation outcome observable through
extra_agent_middlewares, closing the audit blind spot where ASK/DENY
and BYPASS-suppressed decisions were invisible to middleware.

## Permission decision observability (on_permission_decision)

- PermissionEvaluation dataclass (effective + candidate decision +
  PermissionResolution enum) returned by new evaluate_permission;
  check_permission delegates to it for backward compatibility.
- All five _check_<mode> methods preserve the candidate ASK when a mode
  transforms it: BYPASS_ASK_SUPPRESSED, ASK_CONVERTED_TO_DENY,
  ASK_OVERRIDDEN_BY_ALLOW_RULE, USER_CONFIRMED, DIRECT.
- on_permission_decision read-only hook fires before the agent consumes
  the decision; Agent covers the user-confirmed reuse path.

## Permission confirmation observability (on_permission_confirmation)

- Separate hook for user approval/rejection results. Confirmation is
  user input, not an engine decision, so it is kept out of
  PermissionEvaluation.
- Fires before state transition, add_rule, and execution.

## Read-only contract enforced by isolation

Both hooks deep-copy evaluation/tool_input/tool_call (decision) and
tool_call/rules (confirmation) per observer, so observers cannot mutate
the consumed result, executed input, or applied rules. agent and tool
are passed as-is (live runtime context and toolkit-shared instance,
matching every other middleware hook's convention).

## Tests

- Engine: per-mode evaluation semantics (effective/candidate/resolution)
  including BYPASS suppression, DONT_ASK conversion, and allow-rule
  override.
- Agent integration: ALLOW/DENY/ASK/BYPASS-suppress/USER_CONFIRMED/
  external/multi-call/no-observer/exception/mutation-isolation.
- check_permission backward-compat regression.
- No real destructive commands run — side-effect-free demo tools emit
  safety ASKs instead.

No changes to event/message/storage/UI schemas or permission behavior.

## Runnable permission audit example

- Includes examples/permission_audit_service and focused importlib-based tests demonstrating permission decision and confirmation audit records.
@Premsenareddy

Copy link
Copy Markdown
Contributor

I pulled this branch locally and did a quick validation pass for #2000.

Passing:

.venv/bin/python -m pytest -p no:cacheprovider tests/permission_engine_test.py tests/permission_mode_test.py tests/middleware_test.py tests/agent_basic_test.py tests/permission_audit_example_test.py -q
# 152 passed in 1.13s

One thing that looks pending before review/merge: file-scoped repo pre-commit currently fails locally:

.venv/bin/pre-commit run --files src/agentscope/agent/_agent.py src/agentscope/middleware/_base.py src/agentscope/permission/__init__.py src/agentscope/permission/_engine.py src/agentscope/permission/_evaluation.py tests/middleware_test.py tests/permission_audit_example_test.py tests/permission_engine_test.py tests/permission_mode_test.py

Observed failures:

  • add-trailing-comma / black rewrites tests/middleware_test.py
  • mypy reports missing type annotations in the new middleware/permission tests, plus module_from_spec receiving ModuleSpec | None in tests/permission_audit_example_test.py
  • pylint reports missing test docstrings in tests/permission_audit_example_test.py and one unnecessary-dunder-call

The behavior shape looks good from the first pass; this is mostly local gate hygiene since GitHub currently only shows the lightweight notify check on the PR.

Add type annotations to nested middleware test subclasses and example
hook overrides (on_permission_decision / on_acting /
on_permission_confirmation) to satisfy mypy --disallow-untyped-defs.
Add Google-style docstrings to permission_audit_example_test tests.
Replace a direct __call__ invocation with direct call (pylint C2801).
Assert spec is not None before module_from_spec. No functional logic
changed; 151 passed, 1 skipped.
@Miracle778

Copy link
Copy Markdown
Contributor Author

Thanks @Premsenareddy for the validation pass. The pre-commit issues you flagged are fixed in a6e1db0b:

  • add-trailing-comma / black — reformatted tests/middleware_test.py.
  • mypy (--disallow-untyped-defs) — added type annotations to the nested middleware test subclasses and the example hook overrides (on_permission_decision / on_acting / on_permission_confirmation); added assert spec is not None before module_from_spec.
  • pylint — added Google-style docstrings to the permission_audit_example_test tests; replaced a direct __call__ invocation with a direct call (C2801).

pre-commit run --files <changed> now passes clean; pytest still passes (152 passed). No functional logic changed.

@DavdGao

DavdGao commented Jul 7, 2026

Copy link
Copy Markdown
Member

@Miracle778 Plz see comments on issue #2000

@DavdGao

DavdGao commented Jul 17, 2026

Copy link
Copy Markdown
Member

@Miracle778, thanks for the detailed implementation. A few points after reviewing the PR and our discussion in #2000:

1. Hook type: use onion pattern, not read-only notification

Suggest making the hook an onion-pattern hook (with next_handler) consistent with existing hooks like on_acting and on_reasoning, and naming it on_check_permission to match the existing check_permission method. This keeps the middleware system uniform and leaves room for future use cases beyond audit logging.

2. Remove PermissionEvaluation, PermissionResolution, and evaluate_permission

Adding a parallel abstraction layer on top of PermissionDecision is too much API surface for this change. Please remove:

  • PermissionEvaluation dataclass
  • PermissionResolution enum
  • PermissionEngine.evaluate_permission() method

Keep check_permission as the sole engine interface.

3. Decision path observability: defer to a separate PR / issue

I agree that exposing the decision transformation path is valuable for debugging and attribution. However, this should not be part of this PR — let's keep this PR focused on the middleware hook only. Please open a new issue to discuss how to record decision paths (e.g., whether to add fields to PermissionDecision or another approach). We can design that separately.

4. on_permission_confirmation

As discussed, on_reply can already observe raw confirmation events. Let's defer this hook to keep the initial scope focused.

@Miracle778

Copy link
Copy Markdown
Contributor Author

Thanks @DavdGao, understood. I’ll revise this PR to:

  • replace the read-only notification with an onion-pattern on_check_permission hook;
  • keep PermissionEngine.check_permission as the sole engine interface;
  • remove PermissionEvaluation, PermissionResolution, and evaluate_permission;
  • remove on_permission_confirmation;
  • defer decision-path observability to a separate issue/PR.

I’ll update the implementation, tests, example, and PR description accordingly.

@Miracle778
Miracle778 marked this pull request as draft July 17, 2026 16:23
@Miracle778
Miracle778 marked this pull request as ready for review July 17, 2026 17:57
@Miracle778 Miracle778 changed the title feat(permission): observe permission decisions and confirmations feat(middleware): add on_check_permission hook Jul 17, 2026
@Miracle778

Copy link
Copy Markdown
Contributor Author

@DavdGao The requested scope update is now complete.

The current revision:

  • adds a standard onion-style on_check_permission hook;
  • keeps PermissionEngine.check_permission as the sole engine interface;
  • removes PermissionEvaluation, PermissionResolution, and
    evaluate_permission;
  • removes the dedicated confirmation hook;
  • defers decision-path observability to a separate issue;
  • updates the tests and runnable example to demonstrate both audit logging and
    application-owned permission policies.

I have also updated the issue and PR titles and descriptions to match the
revised scope.

The latest example includes a bilingual README and screenshots covering ASK
confirmation, permission audit logging, and per-user tool denial.

Ready for another review when you have time. Thanks!

Extract `_check_permission_impl` as a dedicated method mirroring
`_acting_impl` / `_compress_context_impl`, and restructure
`_check_permission` to the canonical `if not middlewares / else:
execute_chain()` shape (no-arg chain entry, deepcopy hoisted ahead of
the chain). Behavior and copy semantics are unchanged.

Also drop the permission middleware example and its dedicated test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@DavdGao DavdGao 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.

lgtm

@DavdGao
DavdGao merged commit c613a86 into agentscope-ai:main Jul 27, 2026
6 of 7 checks passed
@babyblueviper1

Copy link
Copy Markdown

Built and live-verified a small on_check_permission middleware exercising this hook for a use case the mode table doesn't currently cover: DONT_ASK converts every ASK (including bypass-immune safety asks) to DENY with no recourse, and BYPASS skips safety asks entirely — there's no built-in middle path for "unattended run, but a genuinely risky action still gets a real second opinion instead of a blanket deny."

The middleware calls next_handler first; if the resulting decision is ASK, it sends the tool call to a hosted review API (I used my own — api.babyblueviper.com/review, an independent signed-verdict service, but the pattern works with any reviewer) and converts to ALLOW only on a clean, high-confidence verdict (attaching the verdict's audit reference to decision_reason), otherwise falls through to DENY — same fail-closed behavior as DONT_ASK today, just with a real second opinion instead of an automatic denial.

Tested live against both cases from the tool description above (real HTTP calls, not mocked):

BENIGN case      -> PermissionBehavior.ALLOW | Approved by independent review (confidence=0.99).
DESTRUCTIVE case -> PermissionBehavior.DENY  | ... independent review did not clear the bar: verdict=reject confidence=1.0

Code: https://gist.github.com/babyblueviper1/cd6e4666bcf81f3ad139450fba53a0e2 (happy to open a PR against examples/ instead if that's preferred)

Small thing I noticed while building this — the PR description above lists examples/permission_middleware_service/ (PermissionAuditMiddleware, UserToolPolicyMiddleware, bilingual READMEs) as part of this change, but gh api repos/agentscope-ai/agentscope/pulls/2001/files only shows _agent.py, middleware/_base.py, and middleware_test.py in the merged diff — looks like the example got trimmed along with the scope-narrowing mentioned in the note at the top, but the description wasn't updated to match. Did that example land somewhere else, or is it still on the todo list?

ENCHIGO added a commit to ENCHIGO/agentscope that referenced this pull request Aug 6, 2026
…6-08-06

Core moved while the v1 draft sat; the big deltas:
- agentscope-ai#1995 closed 'core gap 1' (hard interrupt): stop reason now derives
  from ReplyEndEvent.finished_reason; parked replies abort via the new
  UserInterruptEvent input
- agentscope-ai#2117: DEFAULT-mode read-only fast path (Read never prompts), batch
  confirmation de-duplication, bypass-immune safety ASKs
- agentscope-ai#2001: on_check_permission middleware — documented as an alternative
  permission-bridge design; park/resume kept, with rationale
- op-id binding (invariant c) redesigned: consumer-side ContextVar
  timing cannot reach concurrent tool tasks; a tool middleware claims
  pending calls from inside the tool's own context instead
- corrections: Glob uses the bundled _glob_helper.py (never find);
  stop_on_reject is dead config; SDK pin -> 0.12.0 and v1 schema/SDK
  version facts fixed; workspace backend list extended; new channel
  subsystem (agentscope-ai#1997) added to the adapter framing
situgong pushed a commit to situgong/agentscope_ts that referenced this pull request Aug 28, 2026
---------

Co-authored-by: DavdGao <gaodawei.gdw@alibaba-inc.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(middleware): expose permission checking through middleware

4 participants