Files
zk-data-agent/src/hook_policy.py
T
Abdelrahman Abdallah 3f31cee395 add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/background_runtime.py. The agent now has a real
persistent plan runtime with update_plan, plan_get, and plan_clear, plus plan-to-task sync wired through claw-code-agent/src/
  agent_tools.py, claw-code-agent/src/agent_runtime.py, claw-code-agent/src/agent_context.py, claw-code-agent/src/agent_prompting.py, and claw-code-agent/src/
  agent_slash_commands.py. New local plan slash commands are /plan and /planner.
2026-04-03 18:31:25 +02:00

340 lines
12 KiB
Python

from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class HookPolicyManifest:
path: str
trusted: bool | None = None
managed_settings: dict[str, Any] = field(default_factory=dict)
safe_env_names: tuple[str, ...] = ()
deny_tools: tuple[str, ...] = ()
deny_tool_prefixes: tuple[str, ...] = ()
before_prompt: tuple[str, ...] = ()
after_turn: tuple[str, ...] = ()
before_tool: dict[str, tuple[str, ...]] = field(default_factory=dict)
after_tool: dict[str, tuple[str, ...]] = field(default_factory=dict)
budget_overrides: dict[str, int | float] = field(default_factory=dict)
@dataclass
class HookPolicyRuntime:
manifests: tuple[HookPolicyManifest, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'HookPolicyRuntime':
manifests: list[HookPolicyManifest] = []
for path in _discover_policy_paths(cwd, additional_working_directories):
manifest = _load_policy_manifest(path)
if manifest is not None:
manifests.append(manifest)
return cls(manifests=tuple(manifests))
def is_trusted(self) -> bool:
explicit = [
manifest.trusted
for manifest in self.manifests
if manifest.trusted is not None
]
if not explicit:
return True
return explicit[-1]
def managed_settings(self) -> dict[str, Any]:
merged: dict[str, Any] = {}
for manifest in self.manifests:
merged.update(manifest.managed_settings)
return merged
def safe_env(self) -> dict[str, str]:
values: dict[str, str] = {}
for manifest in self.manifests:
for name in manifest.safe_env_names:
value = os.environ.get(name)
if value is not None:
values[name] = value
return values
def budget_overrides(self) -> dict[str, int | float]:
merged: dict[str, int | float] = {}
for manifest in self.manifests:
merged.update(manifest.budget_overrides)
return merged
def before_prompt_messages(self) -> tuple[str, ...]:
return tuple(
message
for manifest in self.manifests
for message in manifest.before_prompt
)
def after_turn_messages(self) -> tuple[str, ...]:
return tuple(
message
for manifest in self.manifests
for message in manifest.after_turn
)
def before_tool_messages(self, tool_name: str) -> tuple[str, ...]:
return self._tool_messages(tool_name, attr='before_tool')
def after_tool_messages(self, tool_name: str) -> tuple[str, ...]:
return self._tool_messages(tool_name, attr='after_tool')
def denied_tool_message(self, tool_name: str) -> str | None:
lowered = tool_name.lower()
for manifest in self.manifests:
if lowered in manifest.deny_tools:
return (
f'Workspace hook policy blocked tool {tool_name}. '
f'Policy file: {manifest.path}'
)
if any(lowered.startswith(prefix) for prefix in manifest.deny_tool_prefixes):
return (
f'Workspace hook policy blocked tool prefix match for {tool_name}. '
f'Policy file: {manifest.path}'
)
return None
def render_summary(self) -> str:
if not self.manifests:
return 'No local hook or policy manifests discovered.'
lines = [f'Local hook/policy manifests: {len(self.manifests)}']
lines.append(f'- trusted={self.is_trusted()}')
settings = self.managed_settings()
if settings:
lines.append(
'- managed_settings='
+ ', '.join(f'{key}={value}' for key, value in sorted(settings.items()))
)
env_values = self.safe_env()
if env_values:
lines.append(
'- safe_env='
+ ', '.join(f'{key}={value}' for key, value in sorted(env_values.items()))
)
for manifest in self.manifests:
details = [Path(manifest.path).name]
if manifest.trusted is not None:
details.append(f'trusted={manifest.trusted}')
if manifest.deny_tools:
details.append(f'deny_tools={len(manifest.deny_tools)}')
if manifest.deny_tool_prefixes:
details.append(f'deny_prefixes={len(manifest.deny_tool_prefixes)}')
if manifest.before_prompt:
details.append(f'before_prompt={len(manifest.before_prompt)}')
if manifest.after_turn:
details.append(f'after_turn={len(manifest.after_turn)}')
if manifest.before_tool:
details.append(f'before_tool={len(manifest.before_tool)}')
if manifest.after_tool:
details.append(f'after_tool={len(manifest.after_tool)}')
if manifest.budget_overrides:
details.append(f'budget_overrides={len(manifest.budget_overrides)}')
lines.append(f"- {'; '.join(details)}")
return '\n'.join(lines)
def _tool_messages(self, tool_name: str, *, attr: str) -> tuple[str, ...]:
lowered = tool_name.lower()
messages: list[str] = []
for manifest in self.manifests:
mapping = getattr(manifest, attr)
if not isinstance(mapping, dict):
continue
wildcard = mapping.get('*', ())
exact = mapping.get(lowered, ())
messages.extend(message for message in wildcard if message)
messages.extend(message for message in exact if message)
return tuple(messages)
def _discover_policy_paths(
cwd: Path,
additional_working_directories: tuple[str, ...],
) -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[Path] = set()
def remember(path: Path) -> None:
resolved = path.resolve()
if resolved in seen or not resolved.exists() or not resolved.is_file():
return
seen.add(resolved)
candidates.append(resolved)
roots: list[Path] = []
current = cwd.resolve()
while True:
roots.append(current)
if current.parent == current:
break
current = current.parent
roots.extend(Path(path).resolve() for path in additional_working_directories)
for root in roots:
remember(root / '.claw-policy.json')
remember(root / '.codex-policy.json')
remember(root / '.claw-hooks.json')
return tuple(candidates)
def _load_policy_manifest(path: Path) -> HookPolicyManifest | None:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
hooks = payload.get('hooks')
if not isinstance(hooks, dict):
hooks = {}
return HookPolicyManifest(
path=str(path),
trusted=(
payload.get('trusted')
if isinstance(payload.get('trusted'), bool)
else None
),
managed_settings=(
dict(payload.get('managedSettings'))
if isinstance(payload.get('managedSettings'), dict)
else dict(payload.get('managed_settings', {}))
if isinstance(payload.get('managed_settings'), dict)
else {}
),
safe_env_names=_extract_string_tuple(
payload.get('safeEnv')
if payload.get('safeEnv') is not None
else payload.get('safe_env')
),
deny_tools=tuple(
item.lower()
for item in _extract_string_tuple(
payload.get('denyTools')
if payload.get('denyTools') is not None
else payload.get('deny_tools')
)
),
deny_tool_prefixes=tuple(
item.lower()
for item in _extract_string_tuple(
payload.get('denyToolPrefixes')
if payload.get('denyToolPrefixes') is not None
else payload.get('deny_tool_prefixes')
)
),
before_prompt=_extract_hook_messages(
hooks.get('beforePrompt')
if hooks.get('beforePrompt') is not None
else hooks.get('before_prompt')
),
after_turn=_extract_hook_messages(
hooks.get('afterTurn')
if hooks.get('afterTurn') is not None
else hooks.get('after_turn')
),
before_tool=_extract_tool_hook_messages(
hooks.get('beforeTool')
if hooks.get('beforeTool') is not None
else hooks.get('before_tool')
),
after_tool=_extract_tool_hook_messages(
hooks.get('afterTool')
if hooks.get('afterTool') is not None
else hooks.get('after_tool')
),
budget_overrides=_extract_budget_overrides(payload.get('budget')),
)
def _extract_string_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(
item.strip()
for item in value
if isinstance(item, str) and item.strip()
)
def _extract_hook_messages(value: Any) -> tuple[str, ...]:
if isinstance(value, str) and value.strip():
return (value.strip(),)
if isinstance(value, list):
return tuple(
item.strip()
for item in value
if isinstance(item, str) and item.strip()
)
return ()
def _extract_tool_hook_messages(value: Any) -> dict[str, tuple[str, ...]]:
if not isinstance(value, dict):
return {}
extracted: dict[str, tuple[str, ...]] = {}
for key, raw in value.items():
if not isinstance(key, str) or not key.strip():
continue
messages = _extract_hook_messages(raw)
if messages:
extracted[key.strip().lower()] = messages
return extracted
def _extract_budget_overrides(value: Any) -> dict[str, int | float]:
if not isinstance(value, dict):
return {}
allowed_keys = {
'max_total_tokens',
'max_input_tokens',
'max_output_tokens',
'max_reasoning_tokens',
'max_total_cost_usd',
'max_tool_calls',
'max_delegated_tasks',
'max_model_calls',
'max_session_turns',
'maxTotalTokens',
'maxInputTokens',
'maxOutputTokens',
'maxReasoningTokens',
'maxTotalCostUsd',
'maxToolCalls',
'maxDelegatedTasks',
'maxModelCalls',
'maxSessionTurns',
}
normalized: dict[str, int | float] = {}
key_map = {
'maxTotalTokens': 'max_total_tokens',
'maxInputTokens': 'max_input_tokens',
'maxOutputTokens': 'max_output_tokens',
'maxReasoningTokens': 'max_reasoning_tokens',
'maxTotalCostUsd': 'max_total_cost_usd',
'maxToolCalls': 'max_tool_calls',
'maxDelegatedTasks': 'max_delegated_tasks',
'maxModelCalls': 'max_model_calls',
'maxSessionTurns': 'max_session_turns',
}
for key, raw in value.items():
if key not in allowed_keys:
continue
normalized_key = key_map.get(key, key)
if isinstance(raw, bool):
continue
if isinstance(raw, int):
normalized[normalized_key] = raw
elif isinstance(raw, float):
normalized[normalized_key] = raw
return normalized