Skip to content
Open
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
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
156 changes: 152 additions & 4 deletions src/ouroboros/plugin/firewall.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import re
import shlex
import subprocess
import time
from typing import Literal

from ouroboros.plugin.digest import (
Expand Down Expand Up @@ -1093,6 +1094,73 @@ 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 = (
"sha256:"
+ 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 All @@ -1109,6 +1177,37 @@ def _run_failed_invocation_observability_hooks() -> None:
if plugin_home is not None:
run_kwargs["cwd"] = str(plugin_home)

tool_call_started_at = time.perf_counter()

def _tool_call_duration_ms() -> int:
return max(1, int(round((time.perf_counter() - tool_call_started_at) * 1000)))

def _output_digest(stdout_bytes: bytes, stderr_bytes: bytes) -> str:
return "sha256:" + hashlib.sha256(stdout_bytes + stderr_bytes).hexdigest()

def _dispatch_failed_after_tool_call(
*,
output_digest: str,
duration_ms: int,
exit_code: int | None,
) -> None:
dispatch_after_tool_call(
manifest=manifest,
tool=tool_name,
status="failed",
output_digest=output_digest,
duration_ms=duration_ms,
correlation_id=correlation_id,
invocation_id=tool_call_invocation_id,
event_sink=_emit,
exit_code=exit_code,
namespace=namespace,
command_name=command_name,
trust_state=trust_state,
plugin_home=plugin_home,
subprocess_runner=runner,
)

try:
completed = runner(cmd_argv, **run_kwargs)
except FileNotFoundError as exc:
Expand All @@ -1127,6 +1226,11 @@ def _run_failed_invocation_observability_hooks() -> None:
provenance={"correlation_id": correlation_id},
)
)
_dispatch_failed_after_tool_call(
output_digest=_output_digest(b"", message.encode("utf-8", errors="surrogateescape")),
duration_ms=_tool_call_duration_ms(),
exit_code=127,
)
_run_failed_invocation_observability_hooks()
return InvocationResult(
status="failed",
Expand All @@ -1144,6 +1248,7 @@ def _run_failed_invocation_observability_hooks() -> None:
stderr_bytes = _to_bytes(exc.stderr)
stdout_hash = hashlib.sha256(stdout_bytes).hexdigest()
stderr_hash = hashlib.sha256(stderr_bytes).hexdigest()
output_digest = _output_digest(stdout_bytes, stderr_bytes)
message = (
f"entrypoint timed out after "
f"{DEFAULT_PLUGIN_INVOCATION_TIMEOUT_SECONDS:g}s: {cmd_argv[0]!r}"
Expand All @@ -1167,6 +1272,11 @@ def _run_failed_invocation_observability_hooks() -> None:
},
)
)
_dispatch_failed_after_tool_call(
output_digest=output_digest,
duration_ms=_tool_call_duration_ms(),
exit_code=124,
)
_run_failed_invocation_observability_hooks()
return InvocationResult(
status="failed",
Expand Down Expand Up @@ -1202,6 +1312,11 @@ def _run_failed_invocation_observability_hooks() -> None:
},
)
)
_dispatch_failed_after_tool_call(
output_digest=_output_digest(b"", message.encode("utf-8", errors="surrogateescape")),
duration_ms=_tool_call_duration_ms(),
exit_code=126,
)
_run_failed_invocation_observability_hooks()
return InvocationResult(
status="failed",
Expand All @@ -1214,6 +1329,8 @@ 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 = _output_digest(stdout_bytes, stderr_bytes)
duration_ms = _tool_call_duration_ms()

terminal_provenance = {
"correlation_id": correlation_id,
Expand All @@ -1237,6 +1354,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=duration_ms,
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 +1394,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=duration_ms,
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 +1440,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
Loading