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) raise FetchPolicyError(msg)
# PRIVATE # PRIVATE
from plyngent.agent import Approval
from plyngent.tools.context import record_approval
if has_private_grant(parsed.host, parsed.port, instance=inst): if has_private_grant(parsed.host, parsed.port, instance=inst):
record_approval(Approval("grant", "process"))
return return
from plyngent.tools.workspace import get_policy_confirm_timeout from plyngent.tools.workspace import get_policy_confirm_timeout
@@ -116,6 +120,7 @@ async def ensure_host_allowed(
) )
raise FetchPolicyError(msg) raise FetchPolicyError(msg)
_ = grant_private_host(parsed.host, parsed.port, instance=inst) _ = 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]: 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 pathlib import Path
from typing import TYPE_CHECKING, cast 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.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.skills import ( from plyngent.skills import (
SKILL_FILE, SKILL_FILE,
@@ -30,6 +30,7 @@ from plyngent.skills import (
resolve_skill_file, resolve_skill_file,
validate_skill_name, 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.read import read_file as _read_file
from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES
from plyngent.tools.file.regex_files import regex_files as _regex_files 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) allowed = decision is True or (isinstance(decision, SkillWriteDecision) and decision.allow)
if not allowed: if not allowed:
return f"error: skill write to {skill_dir} denied (declined or timed out after {policy.timeout:g}s)" 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) session.skill_write_roots.add(skill_dir)
record_approval(Approval("user", "session" if keys_session else "once"))
return None 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 checked first and always wins; then a session grant, then
``[skills].allow_write``, then the host's confirm hook. Without a hook the ``[skills].allow_write``, then the host's confirm hook. Without a hook the
write is denied rather than silently allowed. 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) denied = _denied_by_policy(target, skill_dir)
if denied is not None: if denied is not None:
@@ -350,6 +356,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
session = get_session() session = get_session()
if session is not None and skill_dir in session.skill_write_roots: if session is not None and skill_dir in session.skill_write_roots:
record_approval(Approval("grant", "session"))
return None return None
policy = get_skill_write_policy() policy = get_skill_write_policy()
if policy is None or (policy.confirm is None and not policy.allow_all): 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" "set [skills].allow_write = true to allow skill writes"
) )
if policy.allow_all: if policy.allow_all:
record_approval(Approval("config", "policy"))
return None return None
return _prompt_write(skill_dir, action=action, detail=detail, policy=policy, session=session) 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 Denylisted programs are not hard-rejected when a policy confirm hook is
installed: the human is asked (with a timeout; default deny). Session grants installed: the human is asked (with a timeout; default deny). Session grants
skip re-prompting for the same program. Independent of YOLO soft-confirm. 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) shape_error = argv_shape_error(argv)
if shape_error is not None: if shape_error is not None:
msg = f"command {shape_error}" 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): for hit in match_command_denylist(argv, policy.command_denylist):
binary = hit.program binary = hit.program
if binary in policy.policy_allowed_commands: if binary in policy.policy_allowed_commands:
record_approval(Approval("grant", "process"))
continue continue
why = f": {hit.keyword!r}" if hit.keyword else "" why = f": {hit.keyword!r}" if hit.keyword else ""
hook = policy.policy_confirm_hook hook = policy.policy_confirm_hook
@@ -596,3 +604,4 @@ def check_command_allowed(argv: list[str]) -> None:
) )
raise WorkspaceError(msg) raise WorkspaceError(msg)
policy.policy_allowed_commands.add(binary) policy.policy_allowed_commands.add(binary)
record_approval(Approval("user", "process"))