tools: record the policy answers that let a call through

The command denylist, the private-host fetch policy and the skill write grant
asked the human and then said nothing about it in the result.

Each now records the answer for the running call: `user (process)` for this
call's override and `grant (process)` when an earlier one already covers the
program or host, `user (once|session)` / `grant (session)` / `config (policy)`
for a skill write. `request_directory_access` is unchanged: its own text already
names the level and scope it granted.
This commit is contained in:
2026-10-29 18:28:48 +08:00
parent f9ec812275
commit 05166f2f76
3 changed files with 24 additions and 2 deletions
+5
View File
@@ -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]:
+10 -2
View File
@@ -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)
+9
View File
@@ -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"))