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
11 changes: 11 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ Shared persistence models are allowed; concrete repositories belong at compositi
storage without importing interfaces or coordinating agent execution. New dependencies
must preserve these authority limits and avoid import-time cycles.

Tool authority is the intersection of configured, surface, interaction-mode, parent,
and request capabilities; a surface override or renewed token can only narrow that
bound. `AgentRuntime.execute_call` resolves canonical names and creates an
`ApprovalRequest` fingerprinted over call identity, arguments, workspace/session, and
the registered tool policy before asking the interface for approval. The executor
rechecks registration, scope, and authority after an approval wait, then crosses an
explicit legacy argument adapter only for tools not yet migrated to object arguments.
Approval is not plan acceptance or effect evidence. Delegated children may write only
their assigned files through workspace file tools; shell, Git, browser, dynamic, and
other unconfined mutation tools stay with the parent.

`scripts/check_architecture.py` enforces the existing static import policy across
`agent`, `tasks`, `memory` and `learning`: no legacy root implementation imports, no
interface package-root or descendant imports, and no listed concrete adapters or
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ This index covers the current product guides, developer references, and dated en
- [V3 provider error plan](superpowers/plans/2026-10-09-v3-issue-231.md) records typed failures, cancellation and bounded retry for issue #231.
- [V3 authoritative tool catalog plan](superpowers/plans/2026-10-09-v3-issue-232.md) records registry ownership and schema/policy migration for issue #232.
- [V3 typed tool results plan](superpowers/plans/2026-10-11-v3-issue-235.md) records typed execution outcomes, error codes, observer propagation, and compatibility for issue #235.
- [V3 security boundary plan](superpowers/plans/2026-10-11-v3-issue-261.md) records capability intersection, structured approvals, delegated scope, and verification for issue #261.
- [V3 plan index](superpowers/plans/index.md) links the issue-specific implementation plans and identifies their coverage.
- Machine-readable audit receipts and benchmark inputs remain beside their corresponding reports under `docs/` and `docs/benchmarks/`.

The repository's [English overview](../README.md) links here. Technical guides are maintained in English; README translations provide localized entry points.
106 changes: 106 additions & 0 deletions docs/superpowers/plans/2026-10-11-v3-issue-261.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# V3 Issue #261 — Secure Typed Tool Invocation Implementation Plan

> Issue-specific plan for [#261](https://github.com/EvanProgramming/OpenKyrozen/issues/261). Complete this work before #233.

**Goal:** Ensure every tool call is authorized against its exact arguments and cannot exceed the configured, surface, interaction, parent, workspace, or assigned-child authority.

**Architecture:** Keep policy in `openkyrozen/security`, enforcement and receipts in `openkyrozen/agent`, and schemas/executors in the existing `ToolRegistry`. Introduce one immutable approval request and typed runtime entry point; preserve current string APIs only through compatibility adapters until #233 migrates built-ins.

**Tech Stack:** Python 3.12–3.13, standard library, `unittest`, existing Go Bubble Tea TUI.

**Spec:** Approved V3 batch plan, issue #261 (R179–R182, R184–R186).

## Global Constraints

- Add no dependency, paid provider call, or persistence of executable source.
- Capability checks remain in the runtime boundary; registry invocation remains capability-only.
- Approval responses remain separate from Strategic Plan approval and user clarification.
- The active grant is the intersection of all authority bounds and cannot increase during renewal or delegation.
- Assigned children may mutate only explicitly assigned paths through enforceable file tools; opaque shell, Git, browser, and custom mutations are denied.
- Preserve existing text APIs and event fields while adding typed adapters.

## Review Focus

- Call arguments or registry policy change while approval is pending — bind the approved snapshot to execution and deny stale operations.
- Nested capability overrides or expired-token renewal — renew only the original bounded grant.
- Large or secret-bearing approval payloads — show complete nonsecret details without leaking secret values or silently truncating.
- Paths that resolve through symlinks or change during approval — resolve and recheck within the active workspace and child scope before execution.
- Read-labelled Git commands that write output or invoke external programs — reject those options before execution.

## Review follow-up (2026-10-12)

- [x] Redact bearer credentials embedded in structured string values before display and audit persistence.
- [x] Redact bearer credentials in legacy string TUI approval prompts before chunking.
- [x] Preserve every byte of the approved operation while assembling TUI detail chunks, and prevent approval until the complete correlated stream is received.
- [x] Treat an explicitly empty requested capability set as deny-all.
- [x] Keep optional two-argument approval callbacks on the legacy adapter path.
- [x] Re-run regressions on Python 3.12/3.13 and Go; full Python suite, check, lint and docs checks pass.

---

### Task 1: Add immutable typed approval data

**Files:** `openkyrozen/security/approvals.py`, `tests/test_tool_approvals.py`

**Interfaces:** `ApprovalRequest(request_id, call_id, tool_name, arguments, workspace_id, session_id, tool_version, tool_source, capability, risk, side_effects, fingerprint)` is frozen; arguments are a JSON-safe defensive snapshot. `fingerprint` is SHA-256 over canonical JSON for call identity, tool identity/version, workspace/session, and exact arguments.

- [ ] Test defensive copies, invalid/non-JSON arguments, and digest changes when any bound field changes; run and observe failure.
- [ ] Implement the value type with standard-library JSON/hash functions and no SDK imports.
- [ ] Run `python -m unittest tests.test_tool_approvals -v` and confirm the new cases pass.

### Task 2: Enforce monotonic capability bounds

**Files:** `openkyrozen/security/tool_policy.py`, `openkyrozen/security/capabilities.py`, `openkyrozen/agent/preparation.py`, `openkyrozen/agent/runtime.py`, `openkyrozen/agent/executor.py`, `tests/test_capabilities.py`

**Interfaces:** Add `effective_tool_capabilities(*bounds) -> frozenset[str]`; intersect configured, surface, interaction-mode, parent, and request grants. `AgentRuntime.execute_call(session, call: ToolCall, *, capabilities=None, operation_scope="", approve=None) -> ExecutionReceipt` receives a structured call. Keep `execute()` as a named compatibility adapter.

- [ ] Test surface overrides, expired-token renewal, interaction modes, and child grants never widen parent/configured grants; run red.
- [ ] Implement intersection and bounded renewal; preserve existing default profiles and errors.
- [ ] Test call capability denial before approval or executor invocation; run focused and existing capability suites.

### Task 3: Bind approval to the exact typed operation

**Files:** `openkyrozen/security/permissions.py`, `openkyrozen/security/permission_gate.py`, `openkyrozen/agent/executor.py`, `openkyrozen/agent/types.py`, `tests/test_permission_gate.py`, `tests/test_interaction.py`

**Interfaces:** Add `approve_tool_call(request: ApprovalRequest) -> bool`; retain old `(action, rendered_args)` callbacks through an adapter. Runtime canonicalizes aliases and validates the object before building the request. Bind the selected immutable `ToolSpec` and fingerprint through authorization; recheck current grant and request binding after approval before invoking it.

- [ ] Test typed callback receives the exact nontruncated operation, and changing arguments/spec/grant while pending prevents execution; run red.
- [ ] Test callback exceptions, non-boolean decisions, timeout, stale request IDs, and denied calls produce no executor effects.
- [ ] Implement one approval path that records redacted summaries and the fingerprint, preserving explicit existing approval modes.
- [ ] Keep the temporary legacy-executor conversion after the gate; reject conversions that do not represent the approved object.
- [ ] Run focused authorization/runtime suites and verify no retry or replay.

### Task 4: Enforce child scope and argument-sensitive policy

**Files:** `openkyrozen/agent/delegation_runtime.py`, `openkyrozen/security/permission_gate.py`, `tests/test_delegation.py`, `tests/test_permission_gate.py`

- [ ] Test writes outside assigned paths, symlink escapes, post-approval path changes, shell/Git/browser/custom mutations, and mutation with no assigned path; run red.
- [ ] Permit child file writes only when the canonical resolved path is explicitly assigned; recheck immediately before execution.
- [ ] Deny opaque child mutations and preserve read-only review plus cancellation behavior.
- [ ] Test Git diff/log/show output-writing and external-execution options are rejected; run focused delegation and permission suites.

### Task 5: Preserve compatibility at runtime entry points

**Files:** `openkyrozen/agent/executor.py`, `openkyrozen/agent/runtime.py`, existing MCP/web/TUI caller modules, `tests/test_server.py`, `tests/test_tui_protocol.py`

- [ ] Inventory and test callers using `execute()`, `_run_tool()`, `approve(action, args)`, and TUI approval responses.
- [ ] Keep existing string entry points as thin adapters that construct the same canonical approval request; do not change tool result semantics in this issue.
- [ ] Reject approval payloads whose `approved` value is not a boolean and ignore stale/unmatched correlation IDs.
- [ ] Run MCP, web, CLI, TUI protocol, and compatibility tests.

### Task 6: Display complete structured approvals in TUI

**Files:** `openkyrozen/interfaces/tui/turns.py`, `tui/events.go`, `tui/dialogs.go`, `tui/input.go`, `tests/test_tui_protocol.py`, `tui/main_test.go`

- [ ] Test the approval event carries canonical tool name, policy summary, full structured nonsecret arguments, and existing `action`/`args` fields; verify sensitive values stay redacted.
- [ ] Add a scrollable approval view so long structured arguments can be inspected without silent truncation; preserve approve/deny correlation behavior.
- [ ] Reject non-boolean response values in Python and Go protocol paths.
- [ ] Run Python TUI protocol tests and `cd tui && go test ./...`.

### Task 7: Review, verify, and deliver #261

- [ ] Run focused tests on Python 3.12 and 3.13, then `make test-core`, `make check`, `make lint`, `make docs-check`, Go tests, and offline agent/subagent/TUI acceptance.
- [ ] Run browser acceptance when Playwright is available; report missing browser binaries as BLOCKED, separately from failures.
- [ ] Review the complete diff independently; reproduce and fix material findings with red-green regressions.
- [ ] Commit with Evan’s GPG key; verify locally, push `Evan/v3-261-security-boundary`, create a PR against current `main`, and wait for final-head CI/reviews. Do not merge.

9 changes: 9 additions & 0 deletions docs/superpowers/plans/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Implementation plans

- [V3 #227 — Architecture boundaries](2026-10-07-v3-issue-227.md)
- [V3 #228 — Structured provider responses](2026-10-08-v3-issue-228.md)
- [V3 #231 — Provider errors, deadlines, and retries](2026-10-09-v3-issue-231.md)
- [V3 #232 — Authoritative tool registry](2026-10-09-v3-issue-232.md)
- [V3 #261 — Secure typed tool invocation](2026-10-11-v3-issue-261.md)

Add a linked issue-specific plan here before coding any later V3 issue.
6 changes: 4 additions & 2 deletions openkyrozen/agent/delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,9 @@ def _schedule(self):
self.futures[run_id].add_done_callback(lambda future: self._job_done())

def _owned_paths(self, run):
return set() if self.profiles[run["profile"]].capabilities == "readonly" else set(run["assignment"]["scope"])
return set() if self.profiles[run["profile"]].capabilities == "readonly" else {
str(self.root / path) for path in run["assignment"]["scope"]
}

def _job_done(self):
with self.changed:
Expand All @@ -382,7 +384,7 @@ def _invoke(self, run, *, review=False, feedback=None):

def _work(self, run_id):
run = self.runs[run_id]
paths = {str((self.root / path).resolve()) for path in run["assignment"]["scope"]}
paths = {str(self.root / path) for path in run["assignment"]["scope"]}
if self.profiles[run["profile"]].capabilities == "readonly":
paths = set()
try:
Expand Down
20 changes: 19 additions & 1 deletion openkyrozen/agent/delegation_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,27 @@ def _delegation_tool_access(self, action, args):
path = str(self.current_session.workspace.adapters._resolve_workspace_path(raw))
if coordinator:
coordinator.check_cancelled(context.child_run_id)
from openkyrozen.security.tool_policy import tool_policy
tools = getattr(self, "AVAILABLE_TOOLS", None)
policy = tool_policy(action, tools)
spec = getattr(getattr(self, "tool_registry", None), "specs", {}).get(action)
capability = spec.capability if spec is not None else (
policy.get("capability", "dynamic") if isinstance(policy, dict)
else getattr(policy, "capability", "dynamic")
)
effects = spec.side_effects if spec is not None else (
policy.get("side_effects", "unknown") if isinstance(policy, dict)
else getattr(policy, "side_effects", "unknown")
)
read_only_network = capability == "network" and effects == "external"
if action not in {"write_file", "edit_file"} and not read_only_network and (
capability in {"write", "shell", "git", "browser", "destructive", "dynamic"}
or effects not in {"none", "read"}
):
raise PermissionError(f"Sub-agent capability '{capability}' is not confined to assigned files")
if action in {"write_file", "edit_file"}:
brief = coordinator.runs[context.child_run_id]["assignment"]
owned = {str((coordinator.root / p).resolve()) for p in brief["scope"]}
owned = {str(coordinator.root / p) for p in brief["scope"]}
if path not in owned:
raise ValueError("Sub-agent write is outside its assigned files")
parent = getattr(self.current_session, "_delegation_parent", None)
Expand Down
55 changes: 49 additions & 6 deletions openkyrozen/agent/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,14 @@ def clean(item, remaining, depth=0):


def _run_tool(self, action: str, args: str, *, return_success: bool = False,
return_receipt: bool = False, operation_scope: str = "") -> str | tuple[str, bool] | ExecutionReceipt:
return_receipt: bool = False, operation_scope: str = "",
_typed_arguments: dict | None = None, _expected_spec=None) -> str | tuple[str, bool] | ExecutionReceipt:
started_at = utc_now()
action = self.TOOL_ALIASES.get(action, action)
if isinstance(args, dict):
receipt_args = _typed_arguments if _typed_arguments is not None else args
if _typed_arguments is not None:
args = _typed_arguments
elif isinstance(args, dict):
if action in ("run_cmd", "execute_terminal_command"):
args = args.get("cmd") or args.get("command") or ""
elif action == "write_file":
Expand All @@ -155,7 +159,7 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None
text = render_tool_result(outcome)
if return_receipt:
return self._make_execution_receipt(
action=action, args=args, authorized=authorized, started_at=started_at,
action=action, args=receipt_args, authorized=authorized, started_at=started_at,
success=outcome.success, result=text, operation_scope=operation_scope,
failure=outcome.error.code if outcome.error else None, raw_result=raw_result,
tool_error=outcome.error,
Expand All @@ -165,6 +169,11 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None
outcome = tool_failure("invalid_arguments", f"Error: unknown tool '{action}'")
self._notify_tool_execute(action, args, outcome)
return finish(outcome)
from openkyrozen.security.permission_gate import git_read_arguments_allowed
if not git_read_arguments_allowed(action, args):
outcome = tool_failure("invalid_arguments", "Error: read-only Git tools reject output-writing and external-execution options.")
self._notify_tool_execute(action, args, outcome)
return finish(outcome)
required_capability = tool_capability(action, self.AVAILABLE_TOOLS)
try:
if required_capability not in effective_capabilities(load_agent_config(self._get_workspace_root())):
Expand All @@ -183,21 +192,55 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None
outcome = tool_failure("permission_denied", f"Error: tool '{action}' requires capability '{required_capability}'")
self._notify_tool_execute(action, args, outcome)
return finish(outcome)
approved_spec = self.tool_registry.specs.get(action)
authorized = (self._authorize_tool_action(action, args) if self._EXECUTION_SURFACE == "tui"
else self._confirm_tool_action(action, str(args)))
if authorized:
try:
authorized = (self._execution_capability_token.allows(required_capability)
and required_capability in self._effective_execution_capabilities())
except AgentConfigError:
authorized = False
request_var = getattr(self, "_approval_request", None)
request = request_var.get() if request_var is not None else None
if authorized and self.tool_registry.specs.get(action) is not approved_spec:
authorized = False
if authorized and request is not None:
approved_spec = self._approval_tool_spec.get()
current_spec = self.tool_registry.specs.get(action)
authorized = (
current_spec is approved_spec
and request.tool_name == action
and request.arguments == _typed_arguments
and request.workspace_id == self.memory_bank.workspace_id
and request.session_id == self.current_session.session_id
and self._execution_capability_token.allows(current_spec.capability)
)
if not authorized:
outcome = tool_failure("approval_denied", f"Error: {action} requires confirmation. Approve it interactively or set KYROZEN_APPROVAL_MODE=never for an explicitly automated CLI.")
self._notify_tool_execute(action, args, outcome)
return finish(outcome)
start = time.time()
raw_result = None
try:
with self._delegation_tool_access(action, args):
execution_args = args
if _typed_arguments is not None:
if approved_spec is not _expected_spec or self._execution_capability_token.allows(approved_spec.capability) is not True:
outcome = tool_failure("permission_denied", "Error: tool authority or registration changed before execution.")
self._notify_tool_execute(action, args, outcome)
return finish(outcome)
try:
execution_args = approved_spec.legacy_arguments(_typed_arguments)
except (TypeError, ValueError) as exc:
outcome = tool_failure("invalid_arguments", f"Error: {exc}")
self._notify_tool_execute(action, args, outcome)
return finish(outcome)
with self._delegation_tool_access(action, execution_args):
if action in {"run_cmd", "execute_terminal_command"}:
raw_result = self.run_command(args)
raw_result = self.run_command(execution_args)
outcome = command_tool_result(raw_result)
else:
outcome = self.tool_registry._invoke_executor(self.tool_registry.get_spec(action), args)
outcome = self.tool_registry._invoke_executor(approved_spec, execution_args)
except KeyboardInterrupt:
outcome = tool_failure("cancelled", "Tool execution interrupted by user (Ctrl+C).")
except Exception as exc:
Expand Down
Loading
Loading