diff --git a/src/plyngent/tools/net/grants.py b/src/plyngent/tools/net/grants.py index 0961ed8..d9a50c6 100644 --- a/src/plyngent/tools/net/grants.py +++ b/src/plyngent/tools/net/grants.py @@ -89,7 +89,11 @@ async def ensure_host_allowed( raise FetchPolicyError(msg) # PRIVATE + from plyngent.agent import Approval + from plyngent.tools.context import record_approval + if has_private_grant(parsed.host, parsed.port, instance=inst): + record_approval(Approval("grant", "process")) return from plyngent.tools.workspace import get_policy_confirm_timeout @@ -116,6 +120,7 @@ async def ensure_host_allowed( ) raise FetchPolicyError(msg) _ = grant_private_host(parsed.host, parsed.port, instance=inst) + record_approval(Approval("user", "process")) def format_grant_preview(host: str, port: int, url: str) -> Sequence[str]: diff --git a/src/plyngent/tools/skills.py b/src/plyngent/tools/skills.py index d9711ed..c101795 100644 --- a/src/plyngent/tools/skills.py +++ b/src/plyngent/tools/skills.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, cast -from plyngent.agent import ToolTag, tool +from plyngent.agent import Approval, ToolTag, tool from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS from plyngent.skills import ( SKILL_FILE, @@ -30,6 +30,7 @@ from plyngent.skills import ( resolve_skill_file, validate_skill_name, ) +from plyngent.tools.context import record_approval from plyngent.tools.file.read import read_file as _read_file from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES from plyngent.tools.file.regex_files import regex_files as _regex_files @@ -329,8 +330,10 @@ def _prompt_write( allowed = decision is True or (isinstance(decision, SkillWriteDecision) and decision.allow) if not allowed: return f"error: skill write to {skill_dir} denied (declined or timed out after {policy.timeout:g}s)" - if isinstance(decision, SkillWriteDecision) and decision.session and session is not None: + keys_session = isinstance(decision, SkillWriteDecision) and decision.session + if keys_session and session is not None: session.skill_write_roots.add(skill_dir) + record_approval(Approval("user", "session" if keys_session else "once")) return None @@ -342,6 +345,9 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str) checked first and always wins; then a session grant, then ``[skills].allow_write``, then the host's confirm hook. Without a hook the write is denied rather than silently allowed. + + Each allow that skipped the prompt is recorded too, so the result says why + nobody was asked: ``grant (session)`` or ``config (policy)``. """ denied = _denied_by_policy(target, skill_dir) if denied is not None: @@ -350,6 +356,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str) session = get_session() if session is not None and skill_dir in session.skill_write_roots: + record_approval(Approval("grant", "session")) return None policy = get_skill_write_policy() if policy is None or (policy.confirm is None and not policy.allow_all): @@ -358,6 +365,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str) "set [skills].allow_write = true to allow skill writes" ) if policy.allow_all: + record_approval(Approval("config", "policy")) return None return _prompt_write(skill_dir, action=action, detail=detail, policy=policy, session=session) diff --git a/src/plyngent/tools/workspace.py b/src/plyngent/tools/workspace.py index 16b54c9..c93518c 100644 --- a/src/plyngent/tools/workspace.py +++ b/src/plyngent/tools/workspace.py @@ -565,7 +565,14 @@ def check_command_allowed(argv: list[str]) -> None: Denylisted programs are not hard-rejected when a policy confirm hook is installed: the human is asked (with a timeout; default deny). Session grants skip re-prompting for the same program. Independent of YOLO soft-confirm. + + Either way the call records how it got past the denylist, so the tool result + says it: ``user (process)`` for this call's answer, ``grant (process)`` when + an earlier one already covers this program. """ + from plyngent.agent import Approval + from plyngent.tools.context import record_approval + shape_error = argv_shape_error(argv) if shape_error is not None: msg = f"command {shape_error}" @@ -577,6 +584,7 @@ def check_command_allowed(argv: list[str]) -> None: for hit in match_command_denylist(argv, policy.command_denylist): binary = hit.program if binary in policy.policy_allowed_commands: + record_approval(Approval("grant", "process")) continue why = f": {hit.keyword!r}" if hit.keyword else "" hook = policy.policy_confirm_hook @@ -596,3 +604,4 @@ def check_command_allowed(argv: list[str]) -> None: ) raise WorkspaceError(msg) policy.policy_allowed_commands.add(binary) + record_approval(Approval("user", "process"))