diff --git a/docs/architecture.md b/docs/architecture.md index b91ebb9..d8ae578 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 74b8a38..28b023c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/superpowers/plans/2026-10-11-v3-issue-261.md b/docs/superpowers/plans/2026-10-11-v3-issue-261.md new file mode 100644 index 0000000..b86229d --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-v3-issue-261.md @@ -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. + diff --git a/docs/superpowers/plans/index.md b/docs/superpowers/plans/index.md new file mode 100644 index 0000000..f370a19 --- /dev/null +++ b/docs/superpowers/plans/index.md @@ -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. diff --git a/openkyrozen/agent/delegation.py b/openkyrozen/agent/delegation.py index 85b7c42..a03eac8 100644 --- a/openkyrozen/agent/delegation.py +++ b/openkyrozen/agent/delegation.py @@ -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: @@ -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: diff --git a/openkyrozen/agent/delegation_runtime.py b/openkyrozen/agent/delegation_runtime.py index aa15ea0..1b4a1e6 100644 --- a/openkyrozen/agent/delegation_runtime.py +++ b/openkyrozen/agent/delegation_runtime.py @@ -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) diff --git a/openkyrozen/agent/executor.py b/openkyrozen/agent/executor.py index d3c4bbc..8a01ba3 100644 --- a/openkyrozen/agent/executor.py +++ b/openkyrozen/agent/executor.py @@ -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": @@ -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, @@ -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())): @@ -183,8 +192,30 @@ 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) @@ -192,12 +223,24 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None 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: diff --git a/openkyrozen/agent/runtime.py b/openkyrozen/agent/runtime.py index 2dc483a..f6d51ff 100644 --- a/openkyrozen/agent/runtime.py +++ b/openkyrozen/agent/runtime.py @@ -283,6 +283,7 @@ def __init__(self, memory, tasks, interaction, workspace): self._default_turn = ExecutionContext() self._turn_context = ContextVar("agent_turn", default=None) self._session_context = ContextVar("agent_session", default=None) + self._surface_capability_override = ContextVar("agent_surface_capabilities", default=None) self._sessions = {} self._workspaces = {workspace.root: workspace} @@ -542,23 +543,147 @@ def _last_completion_tokens(self): def _last_completion_tokens(self, value): self.execution_context.last_completion_tokens = value - def execute(self, session, action, args, *, capabilities=None, operation_scope="", approve=None): + def execute(self, session, action, args, *, capabilities=None, operation_scope="", approve=None, + _surface_capability_bound=None): """All inbound tool surfaces share authorization, approvals and receipts.""" from openkyrozen.security.capabilities import issue_capability_token - from openkyrozen.security.tool_policy import resolve_capabilities + from openkyrozen.app.config import AgentConfigError with self.use_session(session): context_token = self._turn_context.set(copy.copy(self.execution_context)) approval_token = self._approval_callback.set(approve) + surface_token = self._surface_capability_override.set(_surface_capability_bound) try: - if capabilities is not None: - self._execution_capability_token = issue_capability_token( - f"surface:{self.surface}", resolve_capabilities(capabilities if isinstance(capabilities, str) else ",".join(capabilities), default="readonly")) + current = self._execution_capability_token + try: + granted = self._effective_execution_capabilities(capabilities) + except AgentConfigError: + granted = frozenset() + self._execution_capability_token = issue_capability_token( + current.subject, granted, + ) receipt = self._run_tool(action, args, return_receipt=True, operation_scope=operation_scope) self.memory_bank.store.append_event("execution.receipt", receipt.as_dict(), user_id=self.memory_bank.user_id, workspace_id=self.memory_bank.workspace_id, session_id=self.memory_bank.session_id) return receipt finally: + self._surface_capability_override.reset(surface_token) + self._approval_callback.reset(approval_token) + self._turn_context.reset(context_token) + + def _effective_execution_capabilities(self, requested=None): + from openkyrozen.app.config import effective_capabilities, load_agent_config + from openkyrozen.agent.modes import mode_capabilities + from openkyrozen.security.tool_policy import resolve_capabilities + + config = effective_capabilities(load_agent_config(self._get_workspace_root())) + default_surface = "full" if self._EXECUTION_SURFACE == "cli" else "workspace" + if self._EXECUTION_SURFACE == "tui" and self.permission_mode() in {"full_jev", "full"}: + default_surface = "full" + surface_bound = self._surface_capability_override.get() + surface_value = (surface_bound if surface_bound is not None else + self._surface_capabilities or default_surface) + surface = (frozenset(surface_value) if not isinstance(surface_value, str) + else resolve_capabilities(surface_value)) + granted = self._execution_capability_token.capabilities & config & surface + granted = mode_capabilities(granted, self._active_interaction_mode.get()) + if requested is not None: + requested = requested if isinstance(requested, str) else ",".join(requested) + granted &= resolve_capabilities(requested, default="readonly") if requested.strip() else frozenset() + return granted + + def execute_call(self, session, call, *, capabilities=None, operation_scope="", approve=None): + """Authorize and execute one structured provider call at the shared boundary.""" + from openkyrozen.providers.models import ToolCall + from openkyrozen.security.approvals import ApprovalRequest + from openkyrozen.security.capabilities import issue_capability_token + from openkyrozen.security.permissions import _call_approval_callback + from openkyrozen.app.config import AgentConfigError + from openkyrozen.persistence.models import utc_now + + if not isinstance(call, ToolCall): + raise TypeError("execute_call requires a ToolCall") + call_arguments = copy.deepcopy(call.arguments) + with self.use_session(session): + context_token = self._turn_context.set(copy.copy(self.execution_context)) + existing_approve = self._approval_callback.get() + user_approve = approve if approve is not None else existing_approve + approval_token = self._approval_callback.set(user_approve) + request_token = None + spec_token = None + started_at = utc_now() + try: + name = self.tool_registry.resolve_legacy_name(call.name) + try: + spec = self.tool_registry.get_spec(name) + except KeyError: + receipt = self._run_tool(name, call_arguments, return_receipt=True, + operation_scope=operation_scope) + self.memory_bank.store.append_event("execution.receipt", receipt.as_dict(), + user_id=self.memory_bank.user_id, workspace_id=self.memory_bank.workspace_id, + session_id=self.memory_bank.session_id) + return receipt + request = ApprovalRequest( + call_id=call.id, tool_name=name, arguments=call_arguments, + workspace_id=self.memory_bank.workspace_id, session_id=session.session_id, + tool_version=spec.version, tool_source=spec.source, capability=spec.capability, + risk=spec.risk, side_effects=spec.side_effects, + ) + request_token = self._approval_request.set(request) + spec_token = self._approval_tool_spec.set(spec) + + def bound_approval(_action, _details): + if not _call_approval_callback(self, user_approve, name, call_arguments, request=request): + return False + if call.arguments != call_arguments: + return False + current = self.tool_registry.specs.get(name) + if current is not spec or session.session_id != request.session_id: + return False + if self.memory_bank.workspace_id != request.workspace_id: + return False + try: + granted = self._effective_execution_capabilities() + except AgentConfigError: + return False + if spec.capability not in granted: + return False + try: + current_request = ApprovalRequest( + call_id=call.id, tool_name=name, arguments=call_arguments, + workspace_id=self.memory_bank.workspace_id, session_id=session.session_id, + tool_version=current.version, tool_source=current.source, + capability=current.capability, risk=current.risk, + side_effects=current.side_effects, + ) + except (TypeError, ValueError): + return False + if current_request.fingerprint != request.fingerprint: + return False + return True + + approval_token2 = self._approval_callback.set(bound_approval if callable(user_approve) else None) + try: + current = self._execution_capability_token + try: + granted = self._effective_execution_capabilities(capabilities) + except AgentConfigError: + granted = frozenset() + self._execution_capability_token = issue_capability_token(current.subject, granted) + receipt = self._run_tool(name, call_arguments, return_receipt=True, + operation_scope=operation_scope, + _typed_arguments=call_arguments, _expected_spec=spec) + self.memory_bank.store.append_event("execution.receipt", receipt.as_dict(), + user_id=self.memory_bank.user_id, workspace_id=self.memory_bank.workspace_id, + session_id=self.memory_bank.session_id) + return receipt + finally: + self._approval_callback.reset(approval_token2) + finally: + if request_token is not None: + self._approval_request.reset(request_token) + if spec_token is not None: + self._approval_tool_spec.reset(spec_token) self._approval_callback.reset(approval_token) self._turn_context.reset(context_token) diff --git a/openkyrozen/app/state.py b/openkyrozen/app/state.py index 3a74af8..5cb9247 100644 --- a/openkyrozen/app/state.py +++ b/openkyrozen/app/state.py @@ -250,6 +250,8 @@ def initialise(self): self._stream_event_callback: ContextVar[Any] = ContextVar("stream_event_callback", default=None) self._approval_callback: ContextVar[Any] = ContextVar("approval_callback", default=None) + self._approval_request: ContextVar[Any] = ContextVar("approval_request", default=None) + self._approval_tool_spec: ContextVar[Any] = ContextVar("approval_tool_spec", default=None) self._active_interaction_mode: ContextVar[str] = ContextVar("active_interaction_mode", default="agent") diff --git a/openkyrozen/interfaces/mcp/server.py b/openkyrozen/interfaces/mcp/server.py index 441fd64..ab5c18f 100644 --- a/openkyrozen/interfaces/mcp/server.py +++ b/openkyrozen/interfaces/mcp/server.py @@ -113,7 +113,11 @@ def execute(): runtime = service._agent session_id = service._normalise_session_id(params.get("session_id") or "surface:mcp") session = runtime.open_session(session_id, user_id=service._SERVER_ACTOR_ID) - return runtime.execute(session, tool_name, string_args, capabilities=service._server_capabilities("mcp"), operation_scope=f"mcp:{request_id}") + allowed = service._server_capabilities("mcp") + return runtime.execute( + session, tool_name, string_args, capabilities=allowed, + operation_scope=f"mcp:{request_id}", _surface_capability_bound=allowed, + ) receipt = await asyncio.to_thread(execute) result_text = receipt.result is_error = not receipt.success diff --git a/openkyrozen/interfaces/tui/turns.py b/openkyrozen/interfaces/tui/turns.py index acb2d98..5f2072b 100644 --- a/openkyrozen/interfaces/tui/turns.py +++ b/openkyrozen/interfaces/tui/turns.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import json import shlex import shutil import threading @@ -38,17 +39,43 @@ def set_api_key(self, api_key: str, request_id: str | None = None) -> None: error=f"{type(exc).__name__}: {_redact(exc)}") -def _approval(self, action: str, args: str) -> bool: - approval_id = uuid.uuid4().hex +def _approval(self, action: str, args: str | None = None) -> bool: + request = action if hasattr(action, "fingerprint") else None + approval_id = request.request_id if request is not None else uuid.uuid4().hex waiter = threading.Event() - decision = {"approved": False} + decision = {"approved": False, "details_complete": False} with self._approval_lock: self._pending_approvals[approval_id] = (waiter, decision) + if request is not None: + action = request.tool_name + from openkyrozen.security.permissions import _redacted_approval_value + args = json.dumps(_redacted_approval_value(request.arguments), ensure_ascii=False, sort_keys=True) + else: + from openkyrozen.security.permissions import _redacted_approval_text + args = _redacted_approval_text(args or "") + chunks = [args[index:index + 8000] for index in range(0, len(args or ""), 8000)] + chunked = len((args or "").encode("utf-8")) > 20_000 + decision["details_complete"] = not chunked + inline_args = chunks[0] if chunked and chunks else (args or "") + inline_chunks = [] if chunked else chunks self.emit( "prompt", kind="approval", request_id=approval_id, - masked=True, action=action, args=_redact(args, 600), + masked=True, action=action, args=_redact(inline_args), + args_chunks=inline_chunks, + args_truncated=chunked or len(args or "") > MAX_TEXT_CHARS, + details_complete=not chunked, + operation=_redacted_approval_value(request.arguments) if request is not None and not chunked and len(args or "") <= 3000 else None, + fingerprint=request.fingerprint if request is not None else None, + workspace_id=request.workspace_id if request is not None else None, + session_id=request.session_id if request is not None else None, message="This action may change local or remote state.", ) + if chunked: + for index, chunk in enumerate(chunks[1:], start=1): + self.emit("approval_detail", approval_id, index=index, total=len(chunks), text=chunk) + with self._approval_lock: + decision["details_complete"] = True + self.emit("approval_detail_complete", approval_id) waiter.wait(timeout=APPROVAL_TIMEOUT_SECONDS) with self._approval_lock: self._pending_approvals.pop(approval_id, None) @@ -61,10 +88,13 @@ def approval_response(self, payload: dict[str, Any]) -> None: return with self._approval_lock: pending = self._pending_approvals.get(approval_id) - if pending is None: - return - pending[1]["approved"] = bool(payload.get("approved", False)) - pending[0].set() + if pending is None: + return + approved = payload.get("approved") is True + if approved and not pending[1]["details_complete"]: + return + pending[1]["approved"] = approved + pending[0].set() def _stream_projection(self, request_id: str): diff --git a/openkyrozen/security/approvals.py b/openkyrozen/security/approvals.py new file mode 100644 index 0000000..b290fd8 --- /dev/null +++ b/openkyrozen/security/approvals.py @@ -0,0 +1,63 @@ +"""Immutable approval facts bound to one concrete tool invocation.""" +from __future__ import annotations + +import hashlib +import json +import uuid +from dataclasses import dataclass, field + + +def _json_copy(value: object) -> tuple[dict, str]: + if not isinstance(value, dict): + raise ValueError("Approval arguments must be a JSON object") + try: + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, + separators=(",", ":")) + copied = json.loads(encoded) + except (TypeError, ValueError, RecursionError) as exc: + raise ValueError("Approval arguments must contain only finite JSON values") from exc + if not isinstance(copied, dict): + raise ValueError("Approval arguments must be a JSON object") + return copied, encoded + + +@dataclass(frozen=True, init=False) +class ApprovalRequest: + request_id: str + call_id: str + tool_name: str + workspace_id: str + session_id: str + tool_version: str + tool_source: str + capability: str + risk: str + side_effects: str + fingerprint: str + _arguments_json: str = field(repr=False) + + def __init__(self, *, request_id: str | None = None, call_id: str, tool_name: str, + arguments: dict, workspace_id: str, session_id: str, tool_version: str, + tool_source: str, capability: str, risk: str, side_effects: str): + values = { + "call_id": call_id, "tool_name": tool_name, "workspace_id": workspace_id, + "session_id": session_id, "tool_version": tool_version, "tool_source": tool_source, + "capability": capability, "risk": risk, "side_effects": side_effects, + } + if any(not isinstance(value, str) or not value.strip() for value in values.values()): + raise ValueError("Approval identity and policy fields must be nonempty strings") + _, arguments_json = _json_copy(arguments) + fingerprint = hashlib.sha256(json.dumps( + {**values, "arguments": json.loads(arguments_json)}, ensure_ascii=False, + allow_nan=False, sort_keys=True, separators=(",", ":"), + ).encode("utf-8")).hexdigest() + object.__setattr__(self, "request_id", request_id or uuid.uuid4().hex) + for key, value in values.items(): + object.__setattr__(self, key, value) + object.__setattr__(self, "fingerprint", fingerprint) + object.__setattr__(self, "_arguments_json", arguments_json) + + @property + def arguments(self) -> dict: + """Return a fresh copy so approval UI code cannot mutate the request.""" + return json.loads(self._arguments_json) diff --git a/openkyrozen/security/permission_gate.py b/openkyrozen/security/permission_gate.py index e47bff0..23cc201 100644 --- a/openkyrozen/security/permission_gate.py +++ b/openkyrozen/security/permission_gate.py @@ -123,3 +123,23 @@ def requires_ask_approval(action: str, args: object, tools=None) -> bool: if risk_category(action, args, tools): return True return tool_capability(action, tools) in {"write", "shell", "git", "browser", "destructive", "dynamic"} + + +def git_read_arguments_allowed(action: str, args: object) -> bool: + """Reject output-writing and external-execution flags on read-labelled Git tools.""" + if action not in {"git_diff", "git_show", "git_log", "git_status"}: + return True + try: + words = shlex.split(_arguments(args)) + except ValueError: + return False + if any( + word in {"-o", "--output", "--ext-diff", "--textconv", "--external-diff", "--exec", "--upload-pack", "--receive-pack"} + or word.startswith(("--output=", "--exec=", "--upload-pack=", "--receive-pack=")) + for word in words + ): + return False + if action == "git_show": + if any(word.startswith("-") for word in words): + return False + return True diff --git a/openkyrozen/security/permissions.py b/openkyrozen/security/permissions.py index 3da4b42..628d3ab 100644 --- a/openkyrozen/security/permissions.py +++ b/openkyrozen/security/permissions.py @@ -5,6 +5,8 @@ import sys import datetime import math +import inspect +import json from pathlib import Path from openkyrozen.workspace.context import LaunchContext @@ -22,17 +24,39 @@ def _approval_log_path(self) -> Path: return Path(explicit).expanduser() if explicit else self._state_root() / "kyrozen_audit.log" +def _redacted_approval_value(value): + sensitive = re.compile(r"(?i)(api[_-]?key|secret|password|credential|authorization|token|private[_-]?key)") + if isinstance(value, dict): + return {key: "" if sensitive.search(key) else _redacted_approval_value(item) + for key, item in value.items()} + if isinstance(value, list): + return [_redacted_approval_value(item) for item in value] + if isinstance(value, str): + return _redacted_approval_text(value) + return value + + +def _redacted_approval_text(value): + text = re.sub(r"(?i)(token|password|secret|api[_-]?key)\s*[:=]\s*\S+", r"\1=", value) + text = re.sub(r"(?i)(\bbearer\s+)[^\s,'\"}]+", r"\1", text) + return re.sub(r"\bsk-[A-Za-z0-9_-]+", "sk-", text) + + def _record_tool_approval(self, action: str, decision: str, args: str = "") -> None: """Write a minimal local audit record without persisting obvious secrets.""" - safe_args = re.sub(r"(?i)(token|password|secret|api[_-]?key)\s*[:=]\s*\S+", r"\1=", str(args)) - safe_args = re.sub(r"\bsk-[A-Za-z0-9_-]+", "sk-", safe_args) + request = getattr(self, "_approval_request", None) + request = request.get() if request is not None else None + if request is not None: + args = json.dumps(_redacted_approval_value(request.arguments), ensure_ascii=False, sort_keys=True) + safe_args = _redacted_approval_text(str(args)) safe_args = safe_args.replace("\n", " ")[:240] try: ts = datetime.datetime.now(self.UTC).isoformat(timespec="seconds") audit_path = self._approval_log_path() audit_path.parent.mkdir(parents=True, exist_ok=True) with audit_path.open("a", encoding="utf-8") as audit_file: - audit_file.write(f"[{ts}] [local-cli] TOOL_{decision.upper()} | {action} | {safe_args}\n") + fingerprint = f" | fingerprint={request.fingerprint}" if request is not None else "" + audit_file.write(f"[{ts}] [local-cli] TOOL_{decision.upper()} | {action} | {safe_args}{fingerprint}\n") except OSError: pass @@ -49,7 +73,7 @@ def _confirm_tool_action(self, action: str, args: str = "", *, force: bool = Fal callback = self._approval_callback.get() if callable(callback): try: - approved = bool(callback(action, args)) + approved = _call_approval_callback(self, callback, action, args) except Exception: approved = False self._record_tool_approval(action, "approved" if approved else "denied", args) @@ -57,8 +81,11 @@ def _confirm_tool_action(self, action: str, args: str = "", *, force: bool = Fal if not sys.stdin.isatty(): self._record_tool_approval(action, "denied_noninteractive", args) return False + request_var = getattr(self, "_approval_request", None) + request = request_var.get() if request_var is not None else None + details = json.dumps(_redacted_approval_value(request.arguments), ensure_ascii=False, sort_keys=True) if request is not None else args prompt = ( - f"High-impact action: {action} {args[:180]}\n" + f"High-impact action: {action} {details}\n" "This may change remote or working-tree state. Continue? [y/N]: " ) try: @@ -113,9 +140,12 @@ def _jev_permission_check(self, action: str, category: str) -> bool: def _record_permission_decision(self, action: str, category: str, outcome: str) -> None: try: + request_var = getattr(self, "_approval_request", None) + request = request_var.get() if request_var is not None else None self.memory_bank.store.append_event( "permission.decision", {"action": action[:64], "category": category[:64], - "mode": self.permission_mode(), "outcome": outcome}, + "mode": self.permission_mode(), "outcome": outcome, + "approval_fingerprint": request.fingerprint if request is not None else None}, user_id=self.memory_bank.user_id, workspace_id=self.interaction_workspace_id(), ) except Exception: @@ -140,12 +170,37 @@ def _authorize_tool_action(self, action: str, args: object, *, approve=None) -> return True if mode == "ask" and not requires_ask_approval(action, args, self.AVAILABLE_TOOLS): return True - callback = approve or (lambda current, details: self._confirm_tool_action(current, details, force=True)) + callback = approve or self._approval_callback.get() or (lambda current, details: self._confirm_tool_action(current, details, force=True)) self._emit_stream_event({"event": "permission_check", "state": "approval_required", "category": risk or "workspace_change"}) - approved = bool(callback(action, str(args)[:600])) + request_var = getattr(self, "_approval_request", None) + request = request_var.get() if request_var is not None else None + approved = _call_approval_callback(self, callback, action, args, request=request) self._emit_stream_event({"event": "permission_check", "state": "allowed" if approved else "denied", "category": risk or "workspace_change"}) self._record_permission_decision(action, risk or "workspace_change", "user_approved" if approved else "user_denied") + self._record_tool_approval(action, "approved" if approved else "denied", str(args)) return approved + + +def _call_approval_callback(self, callback, action, args, *, request=None): + """Call structured callbacks once, while adapting established two-argument callbacks.""" + if request is None: + request_var = getattr(self, "_approval_request", None) + request = request_var.get() if request_var is not None else None + try: + signature = inspect.signature(callback) + try: + signature.bind(action, str(args)) + use_structured = False + except TypeError: + signature.bind(request) + use_structured = request is not None + except (TypeError, ValueError): + use_structured = False + try: + result = callback(request) if use_structured else callback(action, str(args)) + except Exception: + return False + return result is True diff --git a/openkyrozen/tools/git.py b/openkyrozen/tools/git.py index 3d2cd93..15bbbdf 100644 --- a/openkyrozen/tools/git.py +++ b/openkyrozen/tools/git.py @@ -51,7 +51,7 @@ def git_diff(self, args: str) -> str: """ try: extra_args = args.strip() - cmd = ["git", "-C", self._git_working_directory(), "diff"] + cmd = ["git", "-C", self._git_working_directory(), "diff", "--no-textconv", "--no-ext-diff"] if extra_args: # Support --cached, --staged, HEAD~N, commit hashes for part in extra_args.split(): @@ -74,7 +74,7 @@ def git_log(self, args: str) -> str: """ try: import shlex - cmd = ["git", "-C", self._git_working_directory(), "log", "--oneline", "--decorate"] + cmd = ["git", "-C", self._git_working_directory(), "log", "--no-textconv", "--no-ext-diff", "--oneline", "--decorate"] if args.strip(): try: extra = shlex.split(args.strip()) diff --git a/openkyrozen/tools/manifest.py b/openkyrozen/tools/manifest.py index 6d73f38..f80a567 100644 --- a/openkyrozen/tools/manifest.py +++ b/openkyrozen/tools/manifest.py @@ -201,7 +201,10 @@ def legacy_arguments(self, arguments): values.append(arguments[present]) if self.name in {'find_files','git_clone'} and not values[-1]: values.pop() - return '|'.join(values) + encoded='|'.join(values) + if encoded.split('|',len(values)-1)!=values: + raise ValueError('arguments cannot be represented without changing the legacy operation') + return encoded class ToolMapping(MutableMapping): diff --git a/scripts/tui_workflow_acceptance.py b/scripts/tui_workflow_acceptance.py index fdaeaeb..d7ff421 100644 --- a/scripts/tui_workflow_acceptance.py +++ b/scripts/tui_workflow_acceptance.py @@ -223,7 +223,9 @@ def submit(text): 'approval prompt') send(b'y' if decision else b'n') wait(lambda rows: response_count(rows) > count, 'approval result') - assert tracked.read_text() == ('committed\n' if decision else 'uncommitted\n') + expected = 'committed\n' if decision else 'uncommitted\n' + actual = tracked.read_text() + assert actual == expected, f"approval decision={decision}, expected={expected!r}, actual={actual!r}, recent_events={events()[-12:]}" submit('delegate audit') wait(lambda rows: sum(r.get('event') == 'subagent' and r.get('agent', {}).get('status') == 'succeeded' for r in rows) >= 2, 'live delegated worker and review updates') diff --git a/tests/test_approval_request.py b/tests/test_approval_request.py new file mode 100644 index 0000000..4d56ec3 --- /dev/null +++ b/tests/test_approval_request.py @@ -0,0 +1,383 @@ +import unittest + +from openkyrozen.security.approvals import ApprovalRequest + + +class ApprovalRequestTests(unittest.TestCase): + def test_ask_permission_mode_keeps_destructive_capability_for_explicit_approval(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_TUI_CAPABILITIES": "full"} + ): + app = build_application( + surface="tui", memory=build_memory(Path(directory) / "state.sqlite3"), + tools=ToolAdapters(directory), + ) + try: + app.runtime.set_permission_mode("ask") + self.assertIn("destructive", app.runtime._effective_execution_capabilities()) + finally: + app.close() + + def test_request_is_frozen_and_snapshots_arguments(self): + arguments = {"path": "src/a.py", "options": ["--write"]} + request = ApprovalRequest( + request_id="request-1", call_id="call-1", tool_name="write_file", + arguments=arguments, workspace_id="workspace-1", session_id="session-1", + tool_version="1.0.0", tool_source="builtin", capability="write", + risk="normal", side_effects="mutation", + ) + arguments["options"].append("--force") + self.assertEqual(request.arguments["options"], ["--write"]) + self.assertEqual(len(request.fingerprint), 64) + with self.assertRaises((AttributeError, TypeError)): + request.tool_name = "run_cmd" + + def test_fingerprint_binds_call_identity_arguments_and_scope(self): + values = dict( + call_id="call-1", tool_name="write_file", arguments={"path": "a", "content": "x"}, + workspace_id="workspace-1", session_id="session-1", tool_version="1.0.0", + tool_source="builtin", capability="write", risk="normal", side_effects="mutation", + ) + first = ApprovalRequest(request_id="request-1", **values) + self.assertEqual(ApprovalRequest(request_id="request-2", **values).fingerprint, first.fingerprint) + self.assertNotEqual(ApprovalRequest(request_id="request-3", **(values | {"arguments": {"path": "b", "content": "x"}})).fingerprint, first.fingerprint) + self.assertNotEqual(ApprovalRequest(request_id="request-4", **(values | {"workspace_id": "workspace-2"})).fingerprint, first.fingerprint) + self.assertNotEqual(ApprovalRequest(request_id="request-5", **(values | {"call_id": "call-2"})).fingerprint, first.fingerprint) + + def test_non_json_arguments_are_rejected(self): + with self.assertRaises(ValueError): + ApprovalRequest( + request_id="request-1", call_id="call-1", tool_name="write_file", + arguments={"bad": object()}, workspace_id="workspace-1", session_id="session-1", + tool_version="1.0.0", tool_source="builtin", capability="write", + risk="normal", side_effects="mutation", + ) + + def test_legacy_approval_callbacks_are_adapted_once_and_must_return_true(self): + from types import SimpleNamespace + from openkyrozen.security.permissions import _call_approval_callback + request = ApprovalRequest( + call_id="call-1", tool_name="write_file", arguments={"path": "a"}, + workspace_id="w", session_id="s", tool_version="1", tool_source="builtin", + capability="write", risk="normal", side_effects="mutation", + ) + calls = [] + callback = lambda action, args: calls.append((action, args)) or 1 + self.assertFalse(_call_approval_callback(SimpleNamespace(), callback, "write_file", {"path": "a"}, request=request)) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], "write_file") + self.assertTrue(_call_approval_callback(SimpleNamespace(), lambda value: value is request, + "write_file", {}, request=request)) + self.assertFalse(_call_approval_callback(SimpleNamespace(), lambda _value: (_ for _ in ()).throw(RuntimeError()), + "write_file", {}, request=request)) + + def test_optional_two_argument_approval_callback_uses_legacy_arguments(self): + from types import SimpleNamespace + from openkyrozen.security.permissions import _call_approval_callback + request = ApprovalRequest( + call_id="call-legacy", tool_name="write_file", arguments={"path": "a"}, + workspace_id="w", session_id="s", tool_version="1", tool_source="builtin", + capability="write", risk="normal", side_effects="mutation", + ) + seen = [] + + def legacy(action, details=None): + seen.append((action, details)) + return True + + self.assertTrue(_call_approval_callback( + SimpleNamespace(), legacy, "write_file", '{"path": "a"}', request=request, + )) + self.assertEqual(seen, [("write_file", '{"path": "a"}')]) + + def test_approval_display_redacts_sensitive_keys_and_inline_secrets(self): + from openkyrozen.security.permissions import _redacted_approval_value + self.assertEqual(_redacted_approval_value({ + "api_key": "private", "nested": {"authorization": "Bearer private"}, + "note": "token=private", + "command": "curl -H 'Authorization: Bearer abc123' https://example.test", + }), { + "api_key": "", "nested": {"authorization": ""}, + "note": "token=", + "command": "curl -H 'Authorization: Bearer ' https://example.test", + }) + + def test_runtime_requested_capabilities_cannot_widen_surface_token(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters, ToolSpec + from openkyrozen.tools.models import ToolResult + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WEB_CAPABILITIES": "workspace"} + ): + adapters = ToolAdapters(directory) + app = build_application(surface="web", memory=build_memory(Path(directory) / "state.sqlite3"), tools=adapters) + calls = [] + try: + app.runtime.tool_registry.register_spec(ToolSpec( + "authority_probe", lambda _args: calls.append("ran") or ToolResult(text="ran"), + description="Test authority.", input_schema={"type": "object", "properties": {}, "additionalProperties": False}, + capability="dynamic", side_effects="none", + )) + receipt = app.runtime.execute( + app.runtime.current_session, "authority_probe", {}, capabilities={"dynamic"}, + ) + self.assertFalse(receipt.success) + self.assertEqual(calls, []) + finally: + app.close() + + def test_empty_execute_call_capabilities_deny_read_tool(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers.models import ToolCall + from openkyrozen.tools import ToolAdapters, ToolSpec + from openkyrozen.tools.models import ToolResult + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WEB_CAPABILITIES": "full"} + ): + app = build_application( + surface="web", memory=build_memory(Path(directory) / "state.sqlite3"), + tools=ToolAdapters(directory), + ) + calls = [] + try: + app.runtime.tool_registry.register_spec(ToolSpec( + "empty_authority_probe", lambda _args: calls.append("ran") or ToolResult(text="ran"), + description="Test deny-all requested capabilities.", + input_schema={"type": "object", "properties": {}, "additionalProperties": False}, + capability="read", side_effects="none", + )) + receipt = app.runtime.execute_call( + app.runtime.current_session, + ToolCall("empty-authority", "empty_authority_probe", {}), + capabilities=set(), + ) + self.assertFalse(receipt.success) + self.assertEqual(calls, []) + finally: + app.close() + + def test_explicit_mcp_surface_bound_preserves_configured_dangerous_opt_in(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WEB_CAPABILITIES": "workspace"} + ): + app = build_application( + surface="web", memory=build_memory(Path(directory) / "state.sqlite3"), + tools=ToolAdapters(directory), + ) + try: + from openkyrozen.security.capabilities import issue_capability_token + app.runtime._execution_capability_token = issue_capability_token( + "test", {"read", "write", "shell", "network", "git", "browser", "destructive", "dynamic"}, + ) + self.assertNotIn("destructive", app.runtime._effective_execution_capabilities()) + self.assertEqual(app.runtime._effective_execution_capabilities(set()), frozenset()) + surface = app.runtime._surface_capability_override.set( + frozenset({"read", "write", "shell", "network", "git", "browser", "destructive", "dynamic"}) + ) + try: + self.assertIn("destructive", app.runtime._effective_execution_capabilities("full")) + finally: + app.runtime._surface_capability_override.reset(surface) + empty_surface = app.runtime._surface_capability_override.set(frozenset()) + try: + self.assertEqual(app.runtime._effective_execution_capabilities("full"), frozenset()) + finally: + app.runtime._surface_capability_override.reset(empty_surface) + from openkyrozen.tools.models import ToolResult + spec = app.runtime.tool_registry.get_spec("git_reset") + calls = [] + app.runtime.tool_registry.register_spec( + spec.replace(executor=lambda args: calls.append(args) or ToolResult(text="executed")), + replace=True, + ) + requested = {"read", "write", "shell", "network", "git", "browser", "destructive", "dynamic"} + denied = app.runtime.execute(app.runtime.current_session, "git_reset", "--hard HEAD", capabilities=requested) + self.assertFalse(denied.success) + self.assertEqual(calls, []) + allowed = app.runtime.execute( + app.runtime.current_session, "git_reset", "--hard HEAD", capabilities=requested, + _surface_capability_bound=requested, + ) + self.assertTrue(allowed.success, allowed.result) + self.assertEqual(calls, ["--hard HEAD"]) + finally: + app.close() + + def test_execute_call_approval_is_bound_to_the_structured_operation_and_spec(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers.models import ToolCall + from openkyrozen.tools import ToolAdapters, ToolSpec + from openkyrozen.tools.models import ToolResult + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} + ): + app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(directory)) + calls = [] + spec = ToolSpec( + "approval_probe", lambda value: calls.append(value) or ToolResult(text="ran"), + description="Test approval binding.", + input_schema={"type": "object", "properties": {"args": {"type": "string"}}, + "required": ["args"], "additionalProperties": False}, + capability="write", side_effects="mutation", risk="high", legacy_fields=("args",), + ) + try: + app.runtime.tool_registry.register_spec(spec) + long_value = "x" * 900 + seen = [] + call = ToolCall("call-1", "approval_probe", {"args": long_value}) + receipt = app.runtime.execute_call( + app.runtime.current_session, call, approve=lambda request: seen.append(request) or True, + ) + self.assertTrue(receipt.success) + self.assertEqual(seen[0].arguments, {"args": long_value}) + self.assertEqual(calls, [long_value]) + + calls.clear() + def replace_during_approval(_request): + app.runtime.tool_registry.register_spec(spec.replace(executor=lambda value: calls.append("replacement") or ToolResult(text="ran")), replace=True) + return True + denied = app.runtime.execute_call( + app.runtime.current_session, ToolCall("call-2", "approval_probe", {"args": "x"}), + approve=replace_during_approval, + ) + self.assertFalse(denied.success) + self.assertEqual(calls, []) + + mode_token = app.runtime._active_interaction_mode.set("agent") + try: + def narrow_mode(_request): + app.runtime._active_interaction_mode.set("plan") + return True + narrowed = app.runtime.execute_call( + app.runtime.current_session, ToolCall("call-3", "approval_probe", {"args": "x"}), + approve=narrow_mode, + ) + self.assertFalse(narrowed.success) + self.assertEqual(calls, []) + finally: + app.runtime._active_interaction_mode.reset(mode_token) + finally: + app.close() + + def test_execute_call_rejects_arguments_mutated_while_approval_waits(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers.models import ToolCall + from openkyrozen.tools import ToolAdapters, ToolSpec + from openkyrozen.tools.models import ToolResult + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} + ): + app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(directory)) + calls = [] + spec = ToolSpec( + "approval_mutation_probe", lambda value: calls.append(value) or ToolResult(text="ran"), + description="Test argument mutation.", + input_schema={"type": "object", "properties": {"args": {"type": "string"}}, + "required": ["args"], "additionalProperties": False}, + capability="write", side_effects="mutation", risk="high", legacy_fields=("args",), + ) + try: + app.runtime.tool_registry.register_spec(spec) + arguments = {"args": "approved"} + call = ToolCall("call-3", "approval_mutation_probe", arguments) + def mutate(_request): + call.arguments["args"] = "different" + return True + receipt = app.runtime.execute_call(app.runtime.current_session, call, approve=mutate) + self.assertFalse(receipt.success) + self.assertEqual(calls, []) + finally: + app.close() + + def test_legacy_adapter_rejects_argument_values_that_change_pipe_protocol_meaning(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers.models import ToolCall + from openkyrozen.tools import ToolAdapters + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} + ): + root = Path(directory) / "workspace" + root.mkdir() + app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(root)) + try: + receipt = app.runtime.execute_call( + app.runtime.current_session, + ToolCall("call-pipe", "write_file", {"path": "part|outside.txt", "content": "x"}), + ) + self.assertFalse(receipt.success) + self.assertFalse((root / "outside.txt").exists()) + finally: + app.close() + + def test_legacy_runtime_entry_rejects_registry_replacement_during_approval(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools.models import ToolResult + + with tempfile.TemporaryDirectory() as directory, patch.dict( + "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} + ): + app = build_application( + surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), + tools=ToolAdapters(directory), + ) + executed = [] + try: + original = app.runtime.tool_registry.get_spec("git_reset") + replacement = original.replace(executor=lambda _args: executed.append("replacement") or ToolResult(text="ran")) + + def replace_while_approving(_action, _args): + app.runtime.tool_registry.register_spec(replacement, replace=True) + return True + + approval = app.runtime._approval_callback.set(replace_while_approving) + try: + receipt = app.runtime._execute_turn_action( + "git_reset", "--hard HEAD", operation_scope="approval-replacement", + successful_operations=set(), + ) + finally: + app.runtime._approval_callback.reset(approval) + self.assertFalse(receipt.success) + self.assertEqual(executed, []) + finally: + app.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_delegation.py b/tests/test_delegation.py index 9845b4b..bab4521 100644 --- a/tests/test_delegation.py +++ b/tests/test_delegation.py @@ -72,6 +72,58 @@ def chat(inner, messages, model): return json.dumps(answer), {"prompt_tokens": 10, "completion_tokens": 5} return Provider() + def test_child_mutations_are_confined_to_assigned_file_tools(self): + from openkyrozen.agent.turn import ExecutionContext + from types import SimpleNamespace + + child_id = "scoped-child" + coordinator = SimpleNamespace( + access=WorkspaceAccess(), root=self.root.resolve(), + runs={child_id: {"assignment": brief()}}, + cancelled={child_id: threading.Event()}, check_cancelled=lambda _run_id: None, + ) + context_token = self.runtime._turn_context.set(ExecutionContext(child_run_id=child_id, coordinator=coordinator)) + try: + with self.assertRaises(PermissionError): + with self.runtime._delegation_tool_access("run_cmd", "echo unsafe"): + self.fail("arbitrary child shell execution must not run") + with self.assertRaises(PermissionError): + with self.runtime._delegation_tool_access("git_push", "origin main"): + self.fail("child Git mutations must not run") + with self.runtime._delegation_tool_access("write_file", "marker.txt|assigned"): + pass + with self.assertRaises(ValueError): + with self.runtime._delegation_tool_access("write_file", "outside.txt|denied"): + self.fail("child writes outside assigned files must not run") + (self.root / "other.txt").write_text("keep") + (self.root / "marker.txt").unlink() + (self.root / "marker.txt").symlink_to(self.root / "other.txt") + with self.assertRaises(ValueError): + with self.runtime._delegation_tool_access("write_file", "marker.txt|overwrite"): + self.fail("a post-assignment symlink must not retarget a child write") + self.assertEqual((self.root / "other.txt").read_text(), "keep") + finally: + self.runtime._turn_context.reset(context_token) + + def test_child_can_use_read_only_network_tools(self): + from openkyrozen.agent.turn import ExecutionContext + from types import SimpleNamespace + + child_id = "scoped-network-reader" + coordinator = SimpleNamespace( + access=WorkspaceAccess(), root=self.root.resolve(), + runs={child_id: {"assignment": brief()}}, + cancelled={child_id: threading.Event()}, check_cancelled=lambda _run_id: None, + ) + context_token = self.runtime._turn_context.set(ExecutionContext(child_run_id=child_id, coordinator=coordinator)) + try: + with self.runtime._delegation_tool_access("search_web", "specific research query"): + pass + with self.runtime._delegation_tool_access("read_webpage", "https://example.invalid"): + pass + finally: + self.runtime._turn_context.reset(context_token) + def manager(self, invoke, concurrency=4): coordinator = Delegation(self.runtime.memory_bank, invoke, profiles=self.runtime.subagent_manager.profiles, root=self.root, concurrency=concurrency) diff --git a/tests/test_permission_gate.py b/tests/test_permission_gate.py index f8fb69d..2f23a12 100644 --- a/tests/test_permission_gate.py +++ b/tests/test_permission_gate.py @@ -1,6 +1,6 @@ import unittest -from openkyrozen.security.permission_gate import requires_ask_approval, risk_category +from openkyrozen.security.permission_gate import git_read_arguments_allowed, requires_ask_approval, risk_category class PermissionGateTests(unittest.TestCase): @@ -48,6 +48,36 @@ def test_shell_classifier_checks_the_complete_command(self): "opaque_or_high_impact_command", ) + def test_read_only_git_tools_reject_output_and_external_execution_options(self): + for action, args in ( + ("git_diff", "--output=patch.txt"), ("git_diff", "--ext-diff"), + ("git_log", "--output patch.txt"), ("git_log", "--textconv"), + ("git_show", "--output=patch.txt"), ("git_show", "--exec=helper"), + ): + with self.subTest(action=action, args=args): + self.assertFalse(git_read_arguments_allowed(action, args)) + for args in ('"--output=patch.txt"', "'--ext-diff'", '"--textconv"', '"--exec=helper"'): + with self.subTest(args=args): + self.assertFalse(git_read_arguments_allowed("git_log", args)) + self.assertFalse(git_read_arguments_allowed("git_log", '"unterminated')) + self.assertTrue(git_read_arguments_allowed("git_diff", "--cached HEAD~1")) + self.assertTrue(git_read_arguments_allowed("git_log", "--oneline -5")) + + def test_read_only_git_executors_disable_external_diff_and_textconv(self): + from types import SimpleNamespace + from unittest.mock import patch + from openkyrozen.tools.git import git_diff, git_log + + tools = SimpleNamespace(_git_working_directory=lambda: "/tmp/workspace") + with patch("openkyrozen.tools.git.subprocess.run") as run: + run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") + git_diff(tools, "") + git_log(tools, "") + commands = [call.args[0] for call in run.call_args_list] + for command in commands: + self.assertIn("--no-textconv", command) + self.assertIn("--no-ext-diff", command) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tui_protocol.py b/tests/test_tui_protocol.py index 86928cc..a9941b4 100644 --- a/tests/test_tui_protocol.py +++ b/tests/test_tui_protocol.py @@ -413,6 +413,159 @@ def wait_for_approval(): self.assertFalse(thread.is_alive()) self.assertFalse(result["approved"]) + def test_legacy_approval_prompt_redacts_bearer_credentials(self): + result = {} + args = "curl -H 'Authorization: Bearer abc123' https://example.test" + thread = threading.Thread( + target=lambda: result.setdefault("approved", self.backend._approval("run_cmd", args)) + ) + thread.start() + deadline = time.monotonic() + 1 + while "request_id" not in self.output.getvalue() and time.monotonic() < deadline: + time.sleep(0.005) + prompt = json.loads(self.output.getvalue().splitlines()[0]) + self.assertNotIn("abc123", self.output.getvalue()) + self.assertIn("Bearer ", prompt["args"]) + self.backend.approval_response({"request_id": prompt["request_id"], "approved": False}) + thread.join(timeout=1) + self.assertFalse(thread.is_alive()) + + def test_approval_handshake_rejects_truthy_non_boolean_responses(self): + result = {} + thread = threading.Thread(target=lambda: result.setdefault("approved", self.backend._approval("git_push", "origin main"))) + thread.start() + deadline = time.monotonic() + 1 + while "request_id" not in self.output.getvalue() and time.monotonic() < deadline: + time.sleep(0.005) + prompt = json.loads(self.output.getvalue().splitlines()[0]) + self.backend.approval_response({"request_id": prompt["request_id"], "approved": "yes"}) + thread.join(timeout=1) + self.assertFalse(thread.is_alive()) + self.assertFalse(result["approved"]) + + def test_structured_approval_keeps_long_arguments_in_redacted_scroll_chunks(self): + from openkyrozen.security.approvals import ApprovalRequest + + request = ApprovalRequest( + call_id="call-long", tool_name="write_file", + arguments={"content": "x" * 20000, "api_key": "never-display-this"}, + workspace_id="workspace", session_id="session", tool_version="1", + tool_source="builtin", capability="write", risk="normal", side_effects="mutation", + ) + result = {} + thread = threading.Thread(target=lambda: result.setdefault("approved", self.backend._approval(request))) + thread.start() + deadline = time.monotonic() + 1 + while "request_id" not in self.output.getvalue() and time.monotonic() < deadline: + time.sleep(0.005) + events = [json.loads(line) for line in self.output.getvalue().splitlines()] + event = events[0] + self.assertEqual(event["request_id"], request.request_id) + encoded = event["args"] + "".join(item.get("text", "") for item in events[1:]) + displayed = json.loads(encoded) + self.assertEqual(displayed["content"], "x" * 20000) + self.assertEqual(displayed["api_key"], "") + self.assertTrue(event["args_truncated"]) + self.backend.approval_response({"request_id": event["request_id"], "approved": True}) + thread.join(timeout=1) + self.assertTrue(result["approved"]) + + def test_chunked_approval_ignores_response_until_details_are_complete(self): + from unittest.mock import patch + from openkyrozen.security.approvals import ApprovalRequest + + request = ApprovalRequest( + call_id="call-chunked", tool_name="write_file", arguments={"content": "x" * 22000}, + workspace_id="workspace", session_id="session", tool_version="1", + tool_source="builtin", capability="write", risk="normal", side_effects="mutation", + ) + events = [] + emit = self.backend.emit + + def capture(name, request_id=None, **fields): + emit(name, request_id, **fields) + events.append((name, fields)) + if name == "prompt": + self.backend.approval_response({"request_id": request_id, "approved": True}) + pending = self.backend._pending_approvals[request_id] + self.assertFalse(pending[1]["approved"]) + elif name == "approval_detail_complete": + self.backend.approval_response({"request_id": request_id, "approved": True}) + + with patch.object(self.backend, "emit", side_effect=capture), patch( + "openkyrozen.interfaces.tui.turns.APPROVAL_TIMEOUT_SECONDS", 0.1, + ): + self.assertTrue(self.backend._approval(request)) + + self.assertEqual(events[-1][0], "approval_detail_complete") + + def test_chunked_approval_can_be_denied_before_details_complete(self): + waiter = threading.Event() + self.backend._pending_approvals["pending-details"] = ( + waiter, {"approved": False, "details_complete": False}, + ) + self.backend.approval_response({"request_id": "pending-details", "approved": False}) + self.assertTrue(waiter.is_set()) + + def test_structured_approval_event_redacts_operation_object(self): + from openkyrozen.security.approvals import ApprovalRequest + + request = ApprovalRequest( + call_id="call-secret", tool_name="write_file", + arguments={"path": "config.json", "authorization": "Bearer top-secret"}, + workspace_id="workspace", session_id="session", tool_version="1", + tool_source="builtin", capability="write", risk="normal", side_effects="mutation", + ) + result = {} + thread = threading.Thread(target=lambda: result.setdefault("approved", self.backend._approval(request))) + thread.start() + deadline = time.monotonic() + 1 + while "request_id" not in self.output.getvalue() and time.monotonic() < deadline: + time.sleep(0.005) + event = json.loads(self.output.getvalue().splitlines()[0]) + self.assertEqual(event["operation"]["authorization"], "") + self.assertNotIn("top-secret", self.output.getvalue()) + self.backend.approval_response({"request_id": request.request_id, "approved": False}) + thread.join(timeout=1) + self.assertFalse(result["approved"]) + + def test_approval_details_are_split_into_protocol_sized_correlated_events(self): + from openkyrozen.security.approvals import ApprovalRequest + + request = ApprovalRequest( + call_id="call-huge", tool_name="write_file", + arguments={"content": "界" * 50000}, workspace_id="workspace", + session_id="session", tool_version="1", tool_source="builtin", + capability="write", risk="normal", side_effects="mutation", + ) + result = {} + thread = threading.Thread(target=lambda: result.setdefault("approved", self.backend._approval(request))) + thread.start() + deadline = time.monotonic() + 1 + events = [] + while time.monotonic() < deadline: + lines = self.output.getvalue().splitlines() + events = [json.loads(line) for line in lines] + if len(events) > 1 and len(events) >= events[1].get("total", len(events) + 1): + break + time.sleep(0.005) + lines = self.output.getvalue().splitlines() + events = [json.loads(line) for line in lines] + self.assertGreater(len(events), 1, "approval detail chunks were not emitted") + self.assertGreaterEqual(len(events), events[1]["total"], "approval detail chunks were incomplete") + self.assertGreater(len(lines), 1) + self.assertTrue(all(len(line.encode("utf-8")) <= tui_backend.MAX_LINE_BYTES for line in lines)) + self.assertEqual(events[0]["event"], "prompt") + self.assertFalse(events[0]["details_complete"]) + self.assertTrue(all(event["request_id"] == request.request_id for event in events)) + self.assertTrue(all(event["event"] in {"approval_detail", "approval_detail_complete"} for event in events[1:])) + self.assertEqual(events[-1]["event"], "approval_detail_complete") + shown = events[0]["args"] + "".join(event.get("text", "") for event in events[1:]) + self.assertEqual(json.loads(shown)["content"], "界" * 50000) + self.backend.approval_response({"request_id": request.request_id, "approved": True}) + thread.join(timeout=1) + self.assertTrue(result["approved"]) + def test_stream_projection_preserves_event_order_and_redacts_receipts(self): callback = self.backend._stream_projection("turn-1") callback({"event": "content", "chunk": "Answer"}) diff --git a/tui/dialogs.go b/tui/dialogs.go index a839eeb..da595c8 100644 --- a/tui/dialogs.go +++ b/tui/dialogs.go @@ -75,7 +75,7 @@ func (m model) modal(_ string) string { body = redStyle.Render("DELETE CONVERSATION") + "\n" + titleStyle.Render(m.pendingDeleteTitle) + "\n\n" + mutedStyle.Render("Saved conversation data and history snapshots will be removed. Workspace files will stay.") + "\n\n" + greenStyle.Render("Y / Enter delete") + " " + redStyle.Render("N / Esc cancel") } case screenApproval: - body = amberStyle.Render("! APPROVAL REQUIRED") + "\n" + titleStyle.Render("Confirm this action") + "\n\n" + softStyle.Render(m.approvalTool) + "\n" + softStyle.Render(m.approvalArgs) + "\n\n" + mutedStyle.Render("This may change local or remote state.") + "\n\n" + greenStyle.Render("Y / Enter approve") + " " + redStyle.Render("N / Esc deny") + body = amberStyle.Render("! APPROVAL REQUIRED") + "\n" + titleStyle.Render("Confirm this action") + "\n\n" + softStyle.Render(m.approvalTool) + "\n" + m.approvalView.View() + "\n" + mutedStyle.Render("↑↓ / PgUp PgDn inspect all arguments") + "\n\n" + mutedStyle.Render("This may change local or remote state.") + "\n\n" + greenStyle.Render("Y / Enter approve") + " " + redStyle.Render("N / Esc deny") case screenSelfLearning: if m.onboardingSelfLearning { localCursor, localStyle := mutedStyle.Render("·"), softStyle diff --git a/tui/events.go b/tui/events.go index a2555aa..4aa949e 100644 --- a/tui/events.go +++ b/tui/events.go @@ -2,6 +2,7 @@ package main import ( "charm.land/bubbles/v2/textinput" + "encoding/json" "fmt" "strings" "time" @@ -204,6 +205,16 @@ func (m *model) handleBackendEvent(event backendEvent) { m.completeOnboarding() case "prompt": m.handlePrompt(event) + case "approval_detail": + if stringValue(event, "request_id") != m.approvalID { + break + } + m.approvalArgs = safeApprovalArgs(m.approvalArgs + stringValue(event, "text")) + m.setApprovalContent() + case "approval_detail_complete": + if stringValue(event, "request_id") == m.approvalID { + m.approvalDetailsComplete = true + } case "error": m.setError(firstNonEmpty(stringValue(event, "error"), "Backend error")) case "exit": @@ -468,7 +479,28 @@ func (m *model) handlePrompt(event backendEvent) { case "approval": m.approvalID = stringValue(event, "request_id") m.approvalTool = stringValue(event, "action") - m.approvalArgs = safeApprovalArgs(stringValue(event, "args")) + m.approvalArgs = stringValue(event, "args") + m.approvalDetailsComplete = true + if complete, ok := event["details_complete"].(bool); ok { + m.approvalDetailsComplete = complete + } + if chunks, ok := event["args_chunks"].([]any); ok { + var joined strings.Builder + for _, chunk := range chunks { + if text, ok := chunk.(string); ok { + joined.WriteString(text) + } + } + m.approvalArgs = joined.String() + } + if operation, ok := event["operation"]; ok && operation != nil { + if encoded, err := json.MarshalIndent(operation, "", " "); err == nil { + m.approvalArgs = string(encoded) + } + } + m.approvalArgs = safeApprovalArgs(m.approvalArgs) + m.setApprovalContent() + m.approvalView.GotoTop() m.screen = screenApproval m.startTransition() case "self_learning": @@ -547,8 +579,7 @@ func (m *model) startTransition() { } func safeApprovalArgs(value string) string { - value = uiSensitiveArgRE.ReplaceAllString(value, "$1=") - return strings.TrimSpace(value) + return uiSensitiveArgRE.ReplaceAllString(value, "$1=") } func safeToolDetail(value string) string { diff --git a/tui/input.go b/tui/input.go index 2c2ecb3..2ee3a51 100644 --- a/tui/input.go +++ b/tui/input.go @@ -36,9 +36,19 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false } if m.screen == screenApproval { - if key == "y" || key == "Y" || key == "enter" { - m.send("approval_response", map[string]any{"request_id": m.approvalID, "approved": true}) - m.screen = screenChat + if key == "up" || key == "k" { + m.approvalView.ScrollUp(1) + } else if key == "down" || key == "j" { + m.approvalView.ScrollDown(1) + } else if key == "pgup" { + m.approvalView.PageUp() + } else if key == "pgdown" { + m.approvalView.PageDown() + } else if key == "y" || key == "Y" || key == "enter" { + if m.approvalDetailsComplete { + m.send("approval_response", map[string]any{"request_id": m.approvalID, "approved": true}) + m.screen = screenChat + } } else if key == "n" || key == "N" || key == "esc" { m.send("approval_response", map[string]any{"request_id": m.approvalID, "approved": false}) m.screen = screenChat diff --git a/tui/layout.go b/tui/layout.go index ceb9865..49c3d29 100644 --- a/tui/layout.go +++ b/tui/layout.go @@ -18,10 +18,18 @@ func (m *model) resize() { m.apiInput.SetWidth(maxInt(1, minInt(68, m.width-14))) m.view.SetWidth(mainWidth) m.view.SetHeight(m.historyHeight()) + m.setApprovalContent() + m.approvalView.SetHeight(maxInt(1, m.height-18)) m.planScroll = minInt(m.planScroll, m.planMaxScroll()) m.syncViewport() } +func (m *model) setApprovalContent() { + width := maxInt(1, minInt(70, m.width-14)) + m.approvalView.SetWidth(width) + m.approvalView.SetContent(lipgloss.Wrap(m.approvalArgs, width, "")) +} + func (m model) contentWidth() int { return maxInt(1, m.width-4) } diff --git a/tui/main_test.go b/tui/main_test.go index 7dd60bd..0ef2549 100644 --- a/tui/main_test.go +++ b/tui/main_test.go @@ -89,6 +89,108 @@ func TestReducerProjectsStreamingResponseTasksAndApproval(t *testing.T) { } } +func TestApprovalModalKeepsStructuredArgumentsScrollable(t *testing.T) { + m := initialModel(".", false) + m.width, m.height = 100, 30 + m.resize() + value := strings.Repeat("x", 1800) + m.handleBackendEvent(backendEvent{ + "event": "prompt", "kind": "approval", "request_id": "approval-long", "action": "write_file", + "operation": map[string]any{"path": "src/long.txt", "content": value}, + }) + if !strings.Contains(m.approvalArgs, value) { + t.Fatal("structured approval arguments were truncated") + } + m.approvalView.ScrollDown(20) + if m.approvalView.YOffset() == 0 { + t.Fatal("long approval arguments cannot be scrolled") + } +} + +func TestApprovalArgumentsRewrapAfterNarrowResize(t *testing.T) { + m := initialModel(".", false) + m.width, m.height = 120, 30 + m.approvalArgs = strings.Repeat("word ", 80) + m.resize() + wideLines := m.approvalView.TotalLineCount() + m.width = 50 + m.resize() + if m.approvalView.TotalLineCount() <= wideLines { + t.Fatalf("approval arguments did not rewrap after resize: wide=%d narrow=%d", wideLines, m.approvalView.TotalLineCount()) + } +} + +func TestApprovalModalReassemblesLongArgumentChunks(t *testing.T) { + m := initialModel(".", false) + m.width, m.height = 100, 30 + m.resize() + first, middle, last := strings.Repeat("a", 8000), strings.Repeat("b", 8000), strings.Repeat("c", 8000) + m.handleBackendEvent(backendEvent{ + "event": "prompt", "kind": "approval", "request_id": "approval-chunks", "action": "write_file", + "args": first, "args_chunks": []any{first, middle, last}, "args_truncated": true, + }) + if m.approvalArgs != first+middle+last { + t.Fatal("approval view did not reassemble the complete argument") + } + m.approvalView.ScrollDown(20) + if m.approvalView.YOffset() == 0 { + t.Fatal("chunked approval arguments cannot be scrolled") + } +} + +func TestApprovalDetailEventsAppendOnlyToMatchingRequest(t *testing.T) { + m := initialModel(".", false) + m.approvalID = "approval-1" + m.approvalArgs = "first" + m.handleBackendEvent(backendEvent{"event": "approval_detail", "request_id": "stale", "text": " ignored"}) + if m.approvalArgs != "first" { + t.Fatal("stale approval detail changed the active request") + } + m.handleBackendEvent(backendEvent{"event": "approval_detail", "request_id": "approval-1", "text": " second"}) + if m.approvalArgs != "first second" { + t.Fatalf("approval detail was not appended: %q", m.approvalArgs) + } +} + +func TestApprovalChunksPreserveWhitespaceAndWaitForCompleteDetails(t *testing.T) { + m := initialModel(".", false) + m.width, m.height = 100, 30 + m.resize() + first := "content:" + strings.Repeat(" ", 8000) + second := strings.Repeat(" ", 8000) + "tail" + m.handleBackendEvent(backendEvent{ + "event": "prompt", "kind": "approval", "request_id": "approval-stream", + "action": "write_file", "args": first, "details_complete": false, + }) + if m.approvalArgs != first || m.approvalDetailsComplete { + t.Fatal("incomplete approval prompt was altered or marked complete") + } + m.handleBackendEvent(backendEvent{ + "event": "approval_detail", "request_id": "approval-stream", "text": second, + }) + if m.approvalArgs != first+second || m.approvalDetailsComplete { + t.Fatal("approval detail chunks were not concatenated verbatim") + } + buffer := &bytes.Buffer{} + m.bridge = &bridge{stdin: bufio.NewWriter(buffer)} + _, quit := m.handleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + if quit || buffer.Len() != 0 { + t.Fatal("TUI sent approval before receiving complete details") + } + m.handleBackendEvent(backendEvent{"event": "approval_detail_complete", "request_id": "approval-stream"}) + if !m.approvalDetailsComplete { + t.Fatal("correlated completion event did not unlock approval") + } + m.handleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + var response map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buffer.Bytes()), &response); err != nil { + t.Fatal(err) + } + if response["command"] != "approval_response" || response["approved"] != true { + t.Fatalf("complete approval response was not sent: %#v", response) + } +} + func TestRestartEventQuitsForLauncherRelaunch(t *testing.T) { m := initialModel(".", false) updated, cmd := m.Update(backendLineMsg{event: backendEvent{"event": "restart"}}) diff --git a/tui/model.go b/tui/model.go index 70549aa..5c5610c 100644 --- a/tui/model.go +++ b/tui/model.go @@ -108,6 +108,7 @@ type model struct { width int height int view viewport.Model + approvalView viewport.Model input textarea.Model apiInput textinput.Model fastKeyInput bool @@ -187,6 +188,7 @@ type model struct { approvalID string approvalTool string approvalArgs string + approvalDetailsComplete bool errorText string splashFrame int splashStarted time.Time diff --git a/tui/startup.go b/tui/startup.go index cd6ca47..5f7f403 100644 --- a/tui/startup.go +++ b/tui/startup.go @@ -54,11 +54,15 @@ func initialModel(project string, global bool) model { transcript := viewport.New() transcript.MouseWheelEnabled = true transcript.MouseWheelDelta = mouseWheelScrollStep + approval := viewport.New() + approval.MouseWheelEnabled = true + approval.MouseWheelDelta = mouseWheelScrollStep return model{ bridge: newBridge(), project: project, global: global, view: transcript, + approvalView: approval, input: input, apiInput: apiInput, graphInput: graphInput, diff --git a/tui/update.go b/tui/update.go index 7e31c96..5242759 100644 --- a/tui/update.go +++ b/tui/update.go @@ -58,6 +58,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.scrollPlan(3) } + } else if m.screen == screenApproval { + if msg.Button == tea.MouseWheelUp { + m.approvalView.ScrollUp(mouseWheelScrollStep) + } else { + m.approvalView.ScrollDown(mouseWheelScrollStep) + } } else if m.screen == screenChat { switch msg.Button { case tea.MouseWheelUp: