Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .difyignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ __pycache__/
.venv
env/
venv/
.git
.git/
.gitignore
.DS_Store
.venv-test/
tests/
.github/
.pytest_cache/
.ruff_cache/
.claude/
requirements-dev.txt
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,44 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo

## [Unreleased]

## [0.0.3] - 2026-05-30

### Added
- `sign_action` now returns a structured `authorized` decision. On a permitted
action it returns `authorized=true` with the signature receipt, now including
`signature_b64`. On a policy block, emergency halt, delegation limit, or
agent quarantine it returns `authorized=false` with a machine `reason`
(`policy_blocked`, `emergency_halt`, `delegation_denied`, `quarantine`, or
`denied`) and a human-readable `detail`, so a workflow can branch on the
decision instead of failing. A policy block also passes through the signed
denial receipt (`signed_deny`), which is itself verifiable.
- pytest smoke suite covering the provider entrypoint and all three tools, with
network calls mocked so the suite runs offline.
- GitHub Actions CI (`.github/workflows/ci.yml`) running pytest on Python 3.12
(matching the plugin runner declared in `manifest.yaml`) for every PR and
push to `main`.
- `requirements-dev.txt` listing the test dependencies, plus a Development
section in the README explaining how to run the suite locally.
- `output_schema` declarations on all three tools (`sign_action`,
`verify_signature`, `request_action`) so downstream workflow nodes can
address each returned field by name.

### Changed
- Plugin user-facing text is now English-only (`en_US`). Removed the
untranslated `zh_Hans` and `pt_BR` locale keys that duplicated the English
strings across the manifest, provider, and tool definitions.
- README now documents the full output of each tool, including the structured
`authorized` decision and `signature_b64` from `sign_action`.

### Fixed
- Each tool now emits its result as individual workflow variables in addition
to the JSON message, so a Dify workflow node can address each field by name
(`signature_id`, `authorized`, `verified`, and so on) at runtime. A JSON
message alone left named selectors unresolved once wired in a workflow.
- The `sign_action` `timestamp` output is a Unix epoch number, not an ISO 8601
string; corrected its type and description in the tool schema.
- Capitalised the display label and credential prose to "Asqav" across the
manifest, provider, and tool definitions.

## [0.0.2] - 2026-05-11

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ The plugin inherits the SDK's `mode` behavior whenever it is invoked through the
## Tools

### Sign Action
Signs an agent action with ML-DSA-65. Provide an action type (e.g. "read:data", "tool:execute") and optional context. Returns a signature ID and public verification URL.
Signs an agent action with ML-DSA-65. Provide an action type (e.g. "read:data", "tool:execute") and optional context JSON.

Returns a JSON object with an `authorized` boolean so a workflow can branch on the decision instead of failing:

- When the action is permitted, `authorized` is `true` and the result includes `signature_id`, `action_id`, `timestamp`, `verification_url`, and the base64 ML-DSA-65 signature in `signature_b64`.
- When the action is blocked, `authorized` is `false` and the result includes a machine `reason` (`policy_blocked`, `emergency_halt`, `delegation_denied`, `quarantine`, or `denied`) plus a human-readable `detail`. A policy block also returns `attestation_hash` and a `signed_deny` envelope, which is itself a verifiable signed denial receipt.

### Verify Signature
Verifies a signature by its ID. This is a public endpoint - no authentication needed. Returns the verification status, signing agent, action details, and timestamp.
Verifies a signature by its ID. This is a public endpoint - no authentication needed. Returns `verified`, the signing `agent_id` and `agent_name`, the `action_type`, the `algorithm` (e.g. ML-DSA-65), the `signed_at` timestamp, and the `verification_url`.

### Request Action
Creates a multi-party signing session for high-risk actions. The action must be approved by enough signing entities before it is authorized. Use this as a pre-execution gate in workflows.
Creates a multi-party signing session for high-risk actions. The action must be approved by enough signing entities before it is authorized. Use this as a pre-execution gate in workflows. Returns the `session_id`, `status`, `approvals_required`, `signatures_collected`, `action_type`, `created_at`, and `expires_at`.

## Setup

Expand Down
10 changes: 3 additions & 7 deletions manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
version: 0.0.2
version: 0.0.3
type: plugin
author: asqav
name: asqav
label:
en_US: asqav
zh_Hans: asqav
pt_BR: asqav
en_US: Asqav
description:
en_US: AI agent governance via asqav.com. Audit trails, policy enforcement, and compliance. Sign agent actions, verify signatures, and request multi-party authorized actions.
zh_Hans: AI agent governance via asqav.com. Audit trails, policy enforcement, and compliance. Sign agent actions, verify signatures, and request multi-party authorized actions.
pt_BR: AI agent governance via asqav.com. Audit trails, policy enforcement, and compliance. Sign agent actions, verify signatures, and request multi-party authorized actions.
icon: icon.svg
resource:
memory: 268435456
Expand All @@ -23,7 +19,7 @@ plugins:
tools:
- provider/asqav.yaml
meta:
version: 0.0.2
version: 0.0.3
arch:
- amd64
- arm64
Expand Down
24 changes: 4 additions & 20 deletions provider/asqav.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@ identity:
author: asqav
name: asqav
label:
en_US: asqav
zh_Hans: asqav
pt_BR: asqav
en_US: Asqav
description:
en_US: AI agent governance, audit trails, and compliance. Sign agent actions with ML-DSA-65 via asqav.com.
zh_Hans: AI agent governance, audit trails, and compliance. Sign agent actions with ML-DSA-65 via asqav.com.
pt_BR: AI agent governance, audit trails, and compliance. Sign agent actions with ML-DSA-65 via asqav.com.
icon: icon.svg
tags:
- utilities
Expand All @@ -17,33 +13,21 @@ credentials_for_provider:
type: secret-input
required: true
label:
en_US: asqav API Key
zh_Hans: asqav API Key
pt_BR: asqav API Key
en_US: Asqav API Key
placeholder:
en_US: Enter your asqav API key (sk_...)
zh_Hans: Enter your asqav API key (sk_...)
pt_BR: Enter your asqav API key (sk_...)
en_US: Enter your Asqav API key (sk_...)
help:
en_US: Get your API key at asqav.com
zh_Hans: Get your API key at asqav.com
pt_BR: Get your API key at asqav.com
url: https://asqav.com
asqav_agent_id:
type: text-input
required: true
label:
en_US: Agent ID
zh_Hans: Agent ID
pt_BR: Agent ID
placeholder:
en_US: Your asqav agent ID (agent_...)
zh_Hans: Your asqav agent ID (agent_...)
pt_BR: Your asqav agent ID (agent_...)
en_US: Your Asqav agent ID (agent_...)
help:
en_US: The agent ID to use for signing actions
zh_Hans: The agent ID to use for signing actions
pt_BR: The agent ID to use for signing actions
tools:
- tools/sign_action.yaml
- tools/verify_signature.yaml
Expand Down
84 changes: 80 additions & 4 deletions tests/test_tools_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from dify_plugin.entities.tool import ToolInvokeMessage, ToolRuntime

from tools.request_action import RequestActionTool
from tools.sign_action import SignActionTool
from tools.sign_action import SignActionTool, _denied_result
from tools.verify_signature import VerifySignatureTool


Expand All @@ -31,6 +31,17 @@ def _make_tool(tool_cls, credentials: dict[str, Any]):
return tool


def _json_and_var_names(messages):
"""Split tool output into its single JSON message and the variable names emitted."""
json_msgs = [m for m in messages if m.type == ToolInvokeMessage.MessageType.JSON]
var_names = {
m.message.variable_name
for m in messages
if m.type == ToolInvokeMessage.MessageType.VARIABLE
}
return json_msgs, var_names


@pytest.fixture
def credentials() -> dict[str, Any]:
return {
Expand Down Expand Up @@ -77,7 +88,9 @@ def test_sign_action_posts_expected_body(
assert kwargs["json"]["action_type"] == "tool:execute"
assert kwargs["json"]["context"] == {"foo": "bar"}

assert len(messages) == 1
json_msgs, var_names = _json_and_var_names(messages)
assert len(json_msgs) == 1
assert {"authorized", "signature_id", "verification_url"} <= var_names


def test_sign_action_treats_invalid_json_context_as_raw(
Expand Down Expand Up @@ -132,7 +145,9 @@ def test_verify_signature_calls_public_endpoint(
fake_get.assert_called_once()
args, _ = fake_get.call_args
assert args[0].endswith("/verify/sig_123")
assert len(messages) == 1
json_msgs, var_names = _json_and_var_names(messages)
assert len(json_msgs) == 1
assert {"verified", "signature_id", "algorithm"} <= var_names


def test_request_action_builds_signing_session_body(
Expand Down Expand Up @@ -167,4 +182,65 @@ def test_request_action_builds_signing_session_body(
assert kwargs["json"]["agent_id"] == credentials["asqav_agent_id"]
assert kwargs["json"]["action_type"] == "transfer:funds"
assert kwargs["json"]["params"] == {"amount": 100}
assert len(messages) == 1
json_msgs, var_names = _json_and_var_names(messages)
assert len(json_msgs) == 1
assert {"session_id", "status", "approvals_required"} <= var_names


def test_sign_action_surfaces_403_without_raising(
mocker, credentials: dict[str, Any], fake_response_factory
) -> None:
"""A 403 policy decision is yielded as one structured message, never raised."""
mocker.patch(
"tools.sign_action.httpx.post",
return_value=fake_response_factory(
status_code=403,
json_data={
"detail": "Organization is under emergency halt; signing is disabled"
},
),
)

tool = _make_tool(SignActionTool, credentials)
messages = list(tool._invoke({"action_type": "refund:approve", "context": ""}))

json_msgs, var_names = _json_and_var_names(messages)
assert len(json_msgs) == 1
assert {"authorized", "reason"} <= var_names


@pytest.mark.parametrize(
("detail", "expected_reason"),
[
("Organization is under emergency halt; signing is disabled", "emergency_halt"),
("Action type is outside the agent's delegated scope", "delegation_denied"),
("Agent's delegation has expired", "delegation_denied"),
("Agent is in quarantine mode (read/verify only, signing disabled)", "quarantine"),
("Some unmapped refusal text", "denied"),
],
)
def test_denied_result_maps_string_details(
detail: str, expected_reason: str, fake_response_factory
) -> None:
"""String-form 403 details map to a stable machine reason."""
result = _denied_result(fake_response_factory(status_code=403, json_data={"detail": detail}))

assert result["authorized"] is False
assert result["reason"] == expected_reason
assert result["detail"] == detail
assert result["signed_deny"] is None


def test_denied_result_passes_through_signed_deny_envelope(fake_response_factory) -> None:
"""A policy/content block returns policy_blocked with its signed denial receipt."""
detail = {
"error": "action_denied",
"attestation_hash": "abc123",
"signed_deny": {"body": {}, "signature_b64": "ZmFrZQ==", "algorithm": "ML-DSA-65"},
}
result = _denied_result(fake_response_factory(status_code=403, json_data={"detail": detail}))

assert result["authorized"] is False
assert result["reason"] == "policy_blocked"
assert result["attestation_hash"] == "abc123"
assert result["signed_deny"]["signature_b64"] == "ZmFrZQ=="
19 changes: 19 additions & 0 deletions tools/_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from collections.abc import Generator
from typing import Any

from dify_plugin import Tool
from dify_plugin.entities.tool import ToolInvokeMessage


def emit(tool: Tool, payload: dict[str, Any]) -> Generator[ToolInvokeMessage]:
"""Emit a result as one JSON message plus one variable message per field.

A Dify workflow node can address a tool's individual outputs by name
(``{{#node.field#}}``) only when the tool emits variable messages; a JSON
message alone surfaces under the single ``json`` output and leaves named
selectors unresolved at runtime. Emitting both keeps the ``json`` blob and
makes every field resolvable as ``[node, key]``.
"""
yield tool.create_json_message(payload)
for key, value in payload.items():
yield tool.create_variable_message(key, value)
4 changes: 3 additions & 1 deletion tools/request_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from dify_plugin import Tool
from dify_plugin.entities.tool import ToolInvokeMessage

from tools._outputs import emit

API_BASE = "https://api.asqav.com/api/v1"


Expand Down Expand Up @@ -36,7 +38,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag
response.raise_for_status()
data = response.json()

yield self.create_json_message({
yield from emit(self, {
"session_id": data["session_id"],
"status": data["status"],
"approvals_required": data["approvals_required"],
Expand Down
36 changes: 24 additions & 12 deletions tools/request_action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,53 @@ identity:
author: asqav
label:
en_US: Request Action
zh_Hans: Request Action
pt_BR: Request Action
description:
human:
en_US: Request a multi-party authorized action. Creates a signing session that requires entity approvals before the action is authorized. Use this as a pre-execution gate for high-risk actions.
zh_Hans: Request a multi-party authorized action. Creates a signing session that requires entity approvals before the action is authorized. Use this as a pre-execution gate for high-risk actions.
pt_BR: Request a multi-party authorized action. Creates a signing session that requires entity approvals before the action is authorized. Use this as a pre-execution gate for high-risk actions.
llm: Request a multi-party authorized action for an agent. Creates a signing session that requires multiple entity approvals. Returns the session ID, status, required approvals count, and current signatures collected. Use this as a gate before executing high-risk actions.
parameters:
- name: action_type
type: string
required: true
label:
en_US: Action Type
zh_Hans: Action Type
pt_BR: Action Type
human_description:
en_US: Type of action requiring authorization, e.g. "deploy:production"
zh_Hans: Type of action requiring authorization, e.g. "deploy:production"
pt_BR: Type of action requiring authorization, e.g. "deploy:production"
llm_description: The action type that needs multi-party authorization. Examples - "deploy:production", "delete:resource", "transfer:funds".
form: llm
- name: params
type: string
required: false
label:
en_US: Parameters (JSON)
zh_Hans: Parameters (JSON)
pt_BR: Parameters (JSON)
human_description:
en_US: Optional JSON string with parameters for the action
zh_Hans: Optional JSON string with parameters for the action
pt_BR: Optional JSON string with parameters for the action
llm_description: Optional JSON string with parameters for the action. Example - {"target":"us-east-1","version":"2.0"}
form: llm
extra:
python:
source: tools/request_action.py
output_schema:
type: object
properties:
session_id:
type: string
description: ID of the multi-party signing session.
status:
type: string
description: Current session status, e.g. pending, approved, or expired.
approvals_required:
type: number
description: Number of entity approvals required before authorization.
signatures_collected:
type: number
description: Number of approvals collected so far.
action_type:
type: string
description: The action type requiring authorization.
created_at:
type: string
description: ISO 8601 timestamp when the session was created.
expires_at:
type: string
description: ISO 8601 timestamp when the session expires.
Loading
Loading