refactor: unify tool approval into a single engine and cut prompt storms

Collapse the dual permission stack into one policy. The runtime-side
ToolPermissionResolver (own safe lists, own grant memory, bypassed the
ApprovalEngine whenever it said ALLOW) is deleted; runtime_v2 now consults
ApprovalEngine.predict(), a synchronous fast path reading the same config
and the same persisted allowlist as the async authorize pipeline, so a
grant given anywhere is honored everywhere. permissions.py keeps only a
policy-free adapter; the duplicated permissions_v2 config fields and the
runtime grant persistence loop are removed (stale YAML keys are ignored).

New shell_safety module becomes the single source of truth for shell
classification: flag-audited read-only commands (awk/od/jq/sed -n/diff/
git subcommand table/... auto-allow; find -delete, sort -o, curl -o/-d,
rg --pre still prompt even when the bare name is config-listed),
keyword-aware compound splitting (loop/branch headers no longer poison
grants), expansion-safe $() handling, and fail-closed treatment of
anything unparseable or substitution-bearing.

Grant semantics are rebuilt around derived word-boundary prefixes:
"python3 -c" instead of token bags, interpreter -c/-m kept in the prefix,
bash/eval/sudo never grantable as prefixes, read-only segments exempt
from the every-candidate-must-match rule so a granted command chained
with ls/echo verification passes, and approve-once now records the exact
candidates as a session grant so identical re-runs stop re-prompting.
The authorize heuristic also audits the original command text instead of
the quote-dropping preview (echo "<EOF>" no longer reads as redirection).

Validated live on zz_perm_probe1 (native minimal org): awk/od/ls/cat/
sha256sum ran with zero cards, python3 -c parked once and three different
python3 -c commands then passed via the persisted prefix grant, and an
agent-issued rm -f compound correctly re-prompted showing only the
segments needing approval. Full suite failures are byte-identical to the
pre-change HEAD baseline (27 pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LZH-YS1998
2026-07-08 18:43:27 +08:00
parent 447516d93c
commit 4b29b89371
12 changed files with 1386 additions and 1039 deletions
+8 -56
View File
@@ -691,23 +691,11 @@ class NativeSubagentProfileConfig(BaseModel):
allowed_tools: list[str] = Field(default_factory=list)
class ClassifierThresholdsConfig(BaseModel):
allow: float = 0.2
ask: float = 0.5
deny: float = 0.8
class DenialMemoryConfig(BaseModel):
enabled: bool = True
repeat_threshold: int = 2
class SandboxPolicyConfig(BaseModel):
treat_network_as_risky: bool = True
treat_external_paths_as_high_risk: bool = True
explicit_prefix_allowlist: list[str] = Field(default_factory=list)
class GuardianConfig(BaseModel):
enabled: bool = True
auto_allow_read_only: bool = True
@@ -717,57 +705,21 @@ class GuardianConfig(BaseModel):
class PermissionsV2Config(BaseModel):
"""Runtime knobs for the unified permission predictor (ApprovalEngine.predict).
Shell safe-command policy lives in ``autonomy.safe_command_prefixes`` plus
the built-in flag-audited classifier (``shell_safety.py``); legacy
duplicate fields (safe_shell_prefixes, classifier_*, sandbox_policy, ...)
from the removed runtime-side resolver are ignored on load.
"""
enabled: bool = True
fail_closed: bool = True
classifier_enabled: bool = True
shell_ast_validation: bool = True
llm_classifier_model: str = ""
classifier_thresholds: ClassifierThresholdsConfig = Field(default_factory=ClassifierThresholdsConfig)
denial_memory: DenialMemoryConfig = Field(default_factory=DenialMemoryConfig)
sandbox_policy: SandboxPolicyConfig = Field(default_factory=SandboxPolicyConfig)
candidate_extractors: list[str] = Field(default_factory=lambda: [
"path",
"file_path",
"directory",
"working_directory",
"target_output_dir",
"workspace_path",
"command",
"cmd",
"url",
])
default_scope: str = "once"
allow_scopes: list[str] = Field(default_factory=lambda: ["once", "session", "project", "global"])
allow_tools: list[str] = Field(default_factory=list)
deny_tools: list[str] = Field(default_factory=list)
allowed_paths: list[str] = Field(default_factory=list)
denied_paths: list[str] = Field(default_factory=list)
safe_shell_prefixes: list[str] = Field(default_factory=lambda: [
"ls",
"pwd",
"echo",
"rg",
"git status",
"git diff",
"curl",
"wget",
"yt-dlp",
"aria2c",
"ffmpeg",
"python -V",
"python3 -V",
"node -v",
"npm -v",
])
ask_shell_prefixes: list[str] = Field(default_factory=lambda: [
"git commit",
"git push",
"npm install",
"pip install",
"pnpm install",
"cargo test",
"pytest",
])
guardian: GuardianConfig = Field(default_factory=GuardianConfig)
dangerous_shell_patterns: list[str] = Field(default_factory=lambda: [
r"\brm\s+-rf\b",