Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 3 additions & 3 deletions docs/rfc/userlevel-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ declarations while preserving the v0.1 fields. v0.3 wires lifecycle
dispatch for `before_invocation`, `after_invocation`, `on_error`, and
`on_cancel`; v0.4 adds the `before_tool_call` / `after_tool_call`
manifest schema and standalone firewall helper dispatch, but production
tool-call mediation is not wired through those helpers yet.
tool-call mediation is now wired through those helpers.

The `source.type` enum is `local_path | plugin_home | first_party`. Per
[Q00/ouroboros-plugins#8](https://github.com/Q00/ouroboros-plugins/issues/8),
Expand Down Expand Up @@ -331,8 +331,8 @@ the contract and keeps review scope small.
|---|---|---|---|---|---|
| `before_invocation` | After trust/confirmation, before `plugin.invoked` | Read-only inspection / policy | `fail_closed` for policy hooks, `fail_open` for observability-only hooks | `plugin:lifecycle:read` for read-only, `plugin:lifecycle:policy` for policy/veto decisions | **Included** |
| `after_invocation` | After `plugin.completed` / `plugin.failed` is known, before the wrapper returns to the caller | Observability / summary emission | `fail_open` | `plugin:lifecycle:read` | **Included** |
| `before_tool_call` | Before a plugin-mediated tool call is allowed to execute | Policy / possible mutation gate | `fail_closed` | tool-specific permission plus `plugin:tool:intercept` | Schema/helper available; production mediation not wired |
| `after_tool_call` | After a plugin-mediated tool call result is available | Observability or result annotation | `fail_open` | `plugin:tool:observe` | Schema/helper available; production mediation not wired |
| `before_tool_call` | Before a plugin-mediated tool call is allowed to execute | Policy / possible mutation gate | `fail_closed` | tool-specific permission plus `plugin:tool:intercept` | Schema/helper available; production invoke_plugin mediation wired |
| `after_tool_call` | After a plugin-mediated tool call result is available | Observability or result annotation | `fail_open` | `plugin:tool:observe` | Schema/helper available; production invoke_plugin mediation wired |
| `before_artifact_write` | Before artifact service writes plugin-provided output | Policy / mutation gate | `fail_closed` | artifact-specific write permission | Deferred |
| `after_artifact_write` | After artifact write completes | Observability | `fail_open` | `plugin:artifact:observe` | Deferred |
| `on_error` | When the wrapper sees a plugin/runtime error | Observability / recovery hint | `fail_open`; MUST NOT mask the original error | `plugin:lifecycle:read` | **Included** |
Expand Down
104 changes: 100 additions & 4 deletions src/ouroboros/plugin/firewall.py
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,70 @@ def _run_failed_invocation_observability_hooks() -> None:
events=tuple(emitted),
)
cmd_argv = parsed_argv + [command_name] + list(argv)
tool_call_invocation_id = hashlib.sha256(
json.dumps(
{
"correlation_id": correlation_id,
"namespace": namespace,
"command_name": command_name,
"argv": list(argv),
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8", errors="surrogateescape")
).hexdigest()
redacted_tool_argv, _ = _redact_argv([command_name] + list(argv))
tool_args_preview = shlex.join(redacted_tool_argv)
tool_args_digest = hashlib.sha256(
json.dumps([command_name] + list(argv), separators=(",", ":")).encode(
"utf-8", errors="surrogateescape"
)
).hexdigest()
tool_name = f"{namespace}.{command_name}" if namespace else command_name
command_permissions = tuple(getattr(command, "permissions", ()) or ())
tool_permissions = command_permissions or tuple(_required_permissions(manifest))
before_tool_decision = dispatch_before_tool_call(
manifest=manifest,
tool=tool_name,
args_digest=tool_args_digest,
args_preview=tool_args_preview,
correlation_id=correlation_id,
invocation_id=tool_call_invocation_id,
event_sink=_emit,
tool_permissions=tool_permissions,
namespace=namespace,
command_name=command_name,
trust_state=trust_state,
plugin_home=plugin_home,
subprocess_runner=runner,
)
if not before_tool_decision.allowed:
_emit(
_event_envelope(
event_type="plugin.failed",
manifest=manifest,
namespace=namespace,
command_name=command_name,
argv=argv,
trust_state=trust_state,
result={
"status": "blocked",
"message": before_tool_decision.message,
},
provenance={
"correlation_id": correlation_id,
"reason": "tool_call_blocked",
"tool_invocation_id": tool_call_invocation_id,
},
)
)
return InvocationResult(
status="blocked",
exit_code=None,
message=before_tool_decision.message,
events=tuple(emitted),
)

# Capture stdout/stderr as **bytes** rather than asking subprocess
# to decode them. The firewall only ever stores a sha256 hash of
# those streams (the RFC's bounded-payload contract), so we do
Expand Down Expand Up @@ -1214,6 +1278,7 @@ def _run_failed_invocation_observability_hooks() -> None:
stderr_bytes = _to_bytes(completed.stderr)
stdout_hash = hashlib.sha256(stdout_bytes).hexdigest()
stderr_hash = hashlib.sha256(stderr_bytes).hexdigest()
output_digest = hashlib.sha256(stdout_bytes + stderr_bytes).hexdigest()

terminal_provenance = {
"correlation_id": correlation_id,
Expand All @@ -1237,6 +1302,22 @@ def _run_failed_invocation_observability_hooks() -> None:
provenance=terminal_provenance,
)
)
dispatch_after_tool_call(
manifest=manifest,
tool=tool_name,
status="success",
output_digest=output_digest,
duration_ms=0,
correlation_id=correlation_id,
invocation_id=tool_call_invocation_id,
event_sink=_emit,
exit_code=0,
namespace=namespace,
command_name=command_name,
trust_state=trust_state,
plugin_home=plugin_home,
subprocess_runner=runner,
)
_run_lifecycle_hooks(HookKind.AFTER_INVOCATION)
return InvocationResult(
status="success",
Expand All @@ -1261,6 +1342,22 @@ def _run_failed_invocation_observability_hooks() -> None:
provenance=terminal_provenance,
)
)
dispatch_after_tool_call(
manifest=manifest,
tool=tool_name,
status="failed",
output_digest=output_digest,
duration_ms=0,
correlation_id=correlation_id,
invocation_id=tool_call_invocation_id,
event_sink=_emit,
exit_code=completed.returncode,
namespace=namespace,
command_name=command_name,
trust_state=trust_state,
plugin_home=plugin_home,
subprocess_runner=runner,
)
_run_lifecycle_hooks(HookKind.AFTER_INVOCATION)
# ``on_error`` runs strictly after the terminal ``plugin.failed`` event
# and after the v0.3 ``after_invocation`` hook, so a hook failure can
Expand Down Expand Up @@ -1291,10 +1388,9 @@ def _run_failed_invocation_observability_hooks() -> None:
# Unlike ``invoke_plugin`` (which wraps a single plugin command subprocess),
# tool-call hooks fire *during* a plugin-mediated tool invocation, so the
# dispatcher is a module-level helper a tool-mediation caller must invoke per
# tool call. ``invoke_plugin`` does not call these helpers yet, so v0.4
# manifests can declare the hooks and tests can exercise the helper contract,
# but production command dispatch remains inert until the real mediation path
# is wired through this boundary. The helpers correlate back to the parent
# tool call. The production ``invoke_plugin`` command boundary dispatches these
# helpers around the mediated command subprocess. The helpers correlate back
# to the parent
# ``plugin.invoked`` run via ``correlation_id`` and pair ``before``/``after``
# callbacks via ``invocation_id``.
# ---------------------------------------------------------------------------
Expand Down
17 changes: 8 additions & 9 deletions src/ouroboros/plugin/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ class HookKind(StrEnum):
#: scopes, failure policy, audit event names, and helper dispatch
#: semantics are locked in ``docs/rfc/plugin-tool-call-hook-contract.md``.
#: ``ouroboros.plugin.firewall`` exposes dispatcher helpers for
#: tool-mediation callers, but the production ``invoke_plugin`` path does
#: not call those helpers end-to-end yet.
#: tool-mediation callers, and production ``invoke_plugin`` dispatches
#: them around the mediated subprocess boundary.
BEFORE_TOOL_CALL = "before_tool_call"

#: Tool-call after-call observation hook promoted out of
Expand Down Expand Up @@ -235,7 +235,7 @@ class HookFailurePolicy(StrEnum):
#: scope MUST also hold the tool-specific permission declared in the
#: manifest's ``commands[].permissions`` / ``tools.allowed`` surface;
#: that enforcement remains the helper dispatcher's responsibility. The
#: current production ``invoke_plugin`` path is not yet wired through that
#: production ``invoke_plugin`` path is wired through that
#: tool-call boundary.
HOOK_TOOL_INTERCEPT_SCOPE: Final[str] = "plugin:tool:intercept"

Expand All @@ -255,10 +255,9 @@ class HookFailurePolicy(StrEnum):
#: Reserved audit event names for the tool-call hook family.
#: Locked in ``docs/rfc/plugin-tool-call-hook-contract.md`` § 6 and
#: vendored in the v0.4 JSON Schema's ``audit.events`` enum.
#: The standalone firewall dispatcher helpers emit these events when a
#: tool-mediation caller invokes them. They are not emitted by the production
#: ``invoke_plugin`` command path until that path is wired through the
#: tool-call dispatch boundary.
#: The firewall dispatcher helpers emit these events when a tool-mediation
#: caller invokes them, including the production ``invoke_plugin`` command path
#: around its mediated subprocess boundary.
HOOK_TOOL_INTERCEPT_REQUESTED_EVENT: Final[str] = "plugin.tool.intercept.requested"
HOOK_TOOL_INTERCEPT_COMPLETED_EVENT: Final[str] = "plugin.tool.intercept.completed"
HOOK_TOOL_INTERCEPT_BLOCKED_EVENT: Final[str] = "plugin.tool.intercept.blocked"
Expand Down Expand Up @@ -340,8 +339,8 @@ def is_tool_call_hook_kind(value: str) -> bool:
Use this in manifest validators and dispatcher helpers so the
boundary between lifecycle and tool-call hooks stays explicit. The
v0.4 schema accepts these names and the firewall exposes helper
dispatchers, but production command invocation remains inert unless
a tool-mediation caller explicitly invokes those helpers.
dispatchers, and production command invocation now invokes those helpers
around the mediated command subprocess.
"""
return value in TOOL_CALL_HOOK_NAMES

Expand Down
10 changes: 5 additions & 5 deletions src/ouroboros/plugin/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@
# tool-call hook family (``before_tool_call`` / ``after_tool_call``) into
# the same JSON Schema enum and reserves the matching ``plugin.tool.*``
# audit event names. Standalone dispatcher helpers exist in the firewall, but
# production command invocation is still not wired through the tool-call
# boundary; v0.4 manifests may declare these hooks, yet they remain inert until
# production command invocation is wired through the tool-call
# boundary; v0.4 manifests may declare these hooks, and they fire when
# a tool-mediation caller invokes the helpers.
SUPPORTED_SCHEMA_VERSIONS: tuple[str, ...] = ("0.1", "0.2", "0.3", "0.4")

Expand Down Expand Up @@ -212,9 +212,9 @@ def standard_events_for_schema(schema_version: str) -> AuditSpec:
# v0.4 keeps the v0.3 hook event family and additively
# reserves the four ``plugin.tool.*`` event names locked
# in ``docs/rfc/plugin-tool-call-hook-contract.md`` § 6.
# Firewall dispatcher helpers emit these events only when
# an explicit tool-mediation caller invokes them; production
# ``invoke_plugin`` command dispatch does not emit them yet.
# Firewall dispatcher helpers emit these events when an
# explicit tool-mediation caller invokes them; production
# ``invoke_plugin`` command dispatch now also emits them.
return AuditSpec(
events=(
*AuditSpec.standard_four_events().events,
Expand Down
4 changes: 2 additions & 2 deletions src/ouroboros/plugin/schemas/0.4/_source.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"source": "Q00/ouroboros",
"purpose": "Local plugin manifest schema v0.4: promotes the tool-call hook family (`before_tool_call` / `after_tool_call`) from the v0.3 deferred bucket into the v1 dispatch vocabulary so plugins can declare them in a manifest. This is the contract-only half of #939 PR F (PR F-1); standalone firewall dispatcher helpers land in PR F-2, while production tool-mediation wiring remains a follow-up.",
"note": "Iterates on local 0.3 by adding the two tool-call hook names to hooks[].name; adding plugin:tool:intercept / plugin:tool:observe to hooks[].permissions; constraining after_tool_call to failure_policy='fail_open' (mirrors after_invocation / on_error / on_cancel); allowing before_tool_call to declare fail_closed only when permissions contain plugin:tool:intercept (mirrors the lifecycle:policy gate); and expanding audit.events with the four reserved plugin.tool.* event names from docs/rfc/plugin-tool-call-hook-contract.md §6. v0.3 manifests are unaffected. Tool-call hooks are NOT enabled end-to-end by this schema bump: declaring a hook in a v0.4 manifest is inert unless a tool-mediation caller explicitly invokes the firewall dispatcher helpers. The production invoke_plugin command path does not yet do that. Deferred artifact/state hook names (before_artifact_write, after_artifact_write, before_state_commit, after_state_commit) and the excluded `on_event` / `on_rewind` family remain rejected at the v0.4 schema layer; their contract lives in docs/rfc/plugin-artifact-state-hook-contract.md and they will land under a future schema version owned by PR G.",
"note": "Iterates on local 0.3 by adding the two tool-call hook names to hooks[].name; adding plugin:tool:intercept / plugin:tool:observe to hooks[].permissions; constraining after_tool_call to failure_policy='fail_open' (mirrors after_invocation / on_error / on_cancel); allowing before_tool_call to declare fail_closed only when permissions contain plugin:tool:intercept (mirrors the lifecycle:policy gate); and expanding audit.events with the four reserved plugin.tool.* event names from docs/rfc/plugin-tool-call-hook-contract.md §6. v0.3 manifests are unaffected. Tool-call hooks were not enabled end-to-end by this schema bump alone: declaring a hook in a v0.4 manifest required a tool-mediation caller to explicitly invoke the firewall dispatcher helpers. The production invoke_plugin command path now does that. Deferred artifact/state hook names (before_artifact_write, after_artifact_write, before_state_commit, after_state_commit) and the excluded `on_event` / `on_rewind` family remain rejected at the v0.4 schema layer; their contract lives in docs/rfc/plugin-artifact-state-hook-contract.md and they will land under a future schema version owned by PR G.",
"local_extensions": {
"plugin.schema.json": [
"Bumped $id/title/schema_version const from 0.3 to 0.4.",
"Expanded hooks[].name enum to include 'before_tool_call' and 'after_tool_call' from src/ouroboros/plugin/hooks.py HookKind (promoted out of DeferredHookKind by the same PR F-1 slice).",
"Expanded hooks[].permissions enum to include 'plugin:tool:intercept' and 'plugin:tool:observe', and broadened the anyOf clause so a manifest hook permission list may contain any non-empty subset of lifecycle/tool scopes appropriate to its hook name.",
"Added an if/then rule constraining 'after_tool_call' hooks to failure_policy='fail_open' at the schema layer, matching the §5 contract: observation-only after_tool_call hooks must not mask the underlying tool result.",
"Refined the fail_closed gate: fail_closed lifecycle hooks must still declare plugin:lifecycle:policy; fail_closed tool-call hooks must declare plugin:tool:intercept. This is encoded as two parallel if/then clauses keyed on the hook name family so the v0.3 lifecycle invariant is preserved verbatim.",
"Added the four reserved tool-call audit event names (plugin.tool.intercept.requested, plugin.tool.intercept.completed, plugin.tool.intercept.blocked, plugin.tool.observe.recorded) to audit.events. Standalone dispatcher helpers emit these events when called directly by a tool-mediation boundary; the production invoke_plugin command path does not emit them yet.",
"Added the four reserved tool-call audit event names (plugin.tool.intercept.requested, plugin.tool.intercept.completed, plugin.tool.intercept.blocked, plugin.tool.observe.recorded) to audit.events. Standalone dispatcher helpers emit these events when called directly by a tool-mediation boundary; the production invoke_plugin command path also emits them around mediated command subprocesses.",
"Preserved every other v0.3 invariant verbatim: source/path sandboxing, command shape, capabilities/permissions structure, entrypoint contract, command-level AgentOS metadata, and x-* extension property pattern.",
"Deferred artifact/state hook names (before_artifact_write, after_artifact_write, before_state_commit, after_state_commit) and excluded names (on_event, on_rewind, before_runtime_start, after_runtime_start, before_state_commit, after_state_commit) remain rejected by the schema enum; see docs/rfc/plugin-artifact-state-hook-contract.md for the future PR G contract."
],
Expand Down
Loading