345 lines
15 KiB
Python
345 lines
15 KiB
Python
"""Compatibility secretary policy manager.
|
|
|
|
Secretary-authored durable rules are disabled. The class remains for older
|
|
call sites, skill import plumbing, and tests that construct it directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
import shlex
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
|
|
|
|
|
|
_DEFAULT_POLICY = {
|
|
"version": 1,
|
|
"memory_notes": [],
|
|
"authorization_rules": [],
|
|
"workspace_guardrails": [],
|
|
"skill_injection_rules": [],
|
|
}
|
|
|
|
_DEFAULT_RISKY_TOOLS = ["file_write", "file_edit", "shell_exec", "git_commit"]
|
|
|
|
|
|
class SecretaryPolicyManager:
|
|
"""No-op compatibility facade for legacy secretary policy storage."""
|
|
|
|
def __init__(self, opc_home: Path) -> None:
|
|
self.opc_home = opc_home
|
|
self.memory_store = MarkdownMemoryStore(opc_home)
|
|
|
|
def load_global(self) -> dict[str, Any]:
|
|
return self._empty_policy()
|
|
|
|
def load_project(self, project_id: str) -> dict[str, Any]:
|
|
_ = project_id
|
|
return self._empty_policy()
|
|
|
|
def load_merged(self, project_id: str | None = None) -> dict[str, Any]:
|
|
merged = self._empty_policy()
|
|
global_policy = self.load_global()
|
|
merged["memory_notes"].extend(global_policy["memory_notes"])
|
|
merged["authorization_rules"].extend(global_policy["authorization_rules"])
|
|
merged["workspace_guardrails"].extend(global_policy["workspace_guardrails"])
|
|
merged["skill_injection_rules"].extend(global_policy["skill_injection_rules"])
|
|
if project_id:
|
|
project_policy = self.load_project(project_id)
|
|
merged["memory_notes"].extend(project_policy["memory_notes"])
|
|
merged["authorization_rules"].extend(project_policy["authorization_rules"])
|
|
merged["workspace_guardrails"].extend(project_policy["workspace_guardrails"])
|
|
merged["skill_injection_rules"].extend(project_policy["skill_injection_rules"])
|
|
return merged
|
|
|
|
def add_rule(self, kind: str, rule: dict[str, Any], project_id: str | None = None) -> dict[str, Any]:
|
|
_ = (kind, rule, project_id)
|
|
return {}
|
|
|
|
def add_memory_note(self, note: str, project_id: str | None = None, source: str = "user") -> dict[str, Any]:
|
|
_ = (note, project_id, source)
|
|
return {}
|
|
|
|
def get_injected_skills(self, project_id: str | None, domains: list[str] | None = None) -> list[str]:
|
|
_ = (project_id, domains)
|
|
return []
|
|
|
|
def render_context(self, project_id: str | None = None, domains: list[str] | None = None) -> str:
|
|
_ = (project_id, domains)
|
|
return ""
|
|
|
|
def render_cross_project_skills(self, current_project_id: str | None = None) -> str:
|
|
"""Build a summary of skills available across all projects for cross-project recommendations."""
|
|
projects_dir = self.opc_home / "projects"
|
|
if not projects_dir.exists():
|
|
return ""
|
|
lines: list[str] = []
|
|
for child in sorted(projects_dir.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
pid = child.name
|
|
if pid == current_project_id:
|
|
continue
|
|
skills_dir = child / "skills"
|
|
if not skills_dir.exists():
|
|
continue
|
|
skill_items: list[str] = []
|
|
for skill_dir in sorted(skills_dir.iterdir()):
|
|
if not skill_dir.is_dir():
|
|
continue
|
|
skill_md = skill_dir / "SKILL.md"
|
|
if not skill_md.exists():
|
|
continue
|
|
try:
|
|
import re
|
|
text = skill_md.read_text(encoding="utf-8")
|
|
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
|
if fm_match:
|
|
fm = yaml.safe_load(fm_match.group(1)) or {}
|
|
name = fm.get("name", skill_dir.name)
|
|
desc = fm.get("description", "")
|
|
skill_items.append(f" - **{name}**: {desc} [{skill_md}]")
|
|
except Exception:
|
|
continue
|
|
if skill_items:
|
|
lines.append(f"### Project: {pid}")
|
|
lines.extend(skill_items)
|
|
if not lines:
|
|
return ""
|
|
return "## Skills from Other Projects\nThese skills can be referenced via `file_read`.\n\n" + "\n".join(lines)
|
|
|
|
def summarize_policies(self, project_id: str | None = None) -> str:
|
|
_ = project_id
|
|
return "Secretary durable policies are disabled."
|
|
|
|
def evaluate_tool_policy(
|
|
self,
|
|
project_id: str | None,
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
safe_command_prefixes: list[str] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
_ = (project_id, tool_name, arguments, safe_command_prefixes)
|
|
return None
|
|
|
|
def _evaluate_workspace_guardrails(
|
|
self,
|
|
rules: list[dict[str, Any]],
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
safe_command_prefixes: list[str],
|
|
) -> dict[str, Any] | None:
|
|
for rule in rules:
|
|
if not rule.get("enabled", True):
|
|
continue
|
|
risky_tool_names = [str(item).strip() for item in rule.get("risky_tool_names", []) if str(item).strip()]
|
|
if risky_tool_names and tool_name not in risky_tool_names:
|
|
continue
|
|
if tool_name == "shell_exec" and not self._command_is_risky(str(arguments.get("command", "")), safe_command_prefixes):
|
|
continue
|
|
allowed_roots = [self._normalize_path(value) for value in rule.get("allowed_roots", []) if str(value).strip()]
|
|
if not allowed_roots:
|
|
continue
|
|
target_paths = self._extract_target_paths(tool_name, arguments)
|
|
if not target_paths and tool_name == "shell_exec" and rule.get("require_working_directory_for_risky_shell", True):
|
|
return {
|
|
"effect": rule.get("outside_allowed_action", "escalate"),
|
|
"reason": "Secretary guardrail requires an explicit working directory for risky shell commands.",
|
|
"rule_id": rule.get("id", ""),
|
|
"policy_type": "workspace_guardrail",
|
|
}
|
|
if target_paths and all(not self._path_within(path, allowed_roots) for path in target_paths):
|
|
return {
|
|
"effect": rule.get("outside_allowed_action", "escalate"),
|
|
"reason": (
|
|
"Secretary guardrail blocks risky actions outside approved roots: "
|
|
+ ", ".join(allowed_roots[:3])
|
|
),
|
|
"rule_id": rule.get("id", ""),
|
|
"policy_type": "workspace_guardrail",
|
|
}
|
|
return None
|
|
|
|
def _evaluate_authorization_rules(
|
|
self,
|
|
rules: list[dict[str, Any]],
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
for rule in rules:
|
|
if not rule.get("enabled", True):
|
|
continue
|
|
if str(rule.get("tool_name", "")).strip() != tool_name:
|
|
continue
|
|
path_prefixes = [self._normalize_path(value) for value in rule.get("path_prefixes", []) if str(value).strip()]
|
|
target_paths = self._extract_target_paths(tool_name, arguments)
|
|
if path_prefixes and target_paths:
|
|
if not all(self._path_within(path, path_prefixes) for path in target_paths):
|
|
continue
|
|
return {
|
|
"effect": rule.get("action", "auto_allow"),
|
|
"reason": str(rule.get("rationale", "")).strip() or "Matched a secretary authorization rule.",
|
|
"rule_id": rule.get("id", ""),
|
|
"policy_type": "authorization_rule",
|
|
}
|
|
return None
|
|
|
|
def _extract_target_paths(self, tool_name: str, arguments: dict[str, Any]) -> list[str]:
|
|
if tool_name in {"file_read", "file_write", "file_edit"}:
|
|
path = str(arguments.get("path", "")).strip()
|
|
return [self._normalize_path(path)] if path else []
|
|
if tool_name == "shell_exec":
|
|
cwd = str(arguments.get("working_directory", "")).strip()
|
|
return [self._normalize_path(cwd)] if cwd else []
|
|
return []
|
|
|
|
def _command_is_risky(self, command: str, safe_command_prefixes: list[str]) -> bool:
|
|
cleaned = command.strip()
|
|
if not cleaned:
|
|
return False
|
|
if self._command_has_redirection(cleaned):
|
|
return True
|
|
segments = self._split_shell_command_segments(cleaned)
|
|
if len(segments) != 1:
|
|
return True
|
|
segment = " ".join(segments[0]).strip().casefold()
|
|
for prefix in safe_command_prefixes:
|
|
candidate = str(prefix or "").strip().casefold()
|
|
if not candidate:
|
|
continue
|
|
if segment == candidate or segment.startswith(f"{candidate} "):
|
|
return False
|
|
return True
|
|
|
|
def _split_shell_command_segments(self, command: str) -> list[list[str]]:
|
|
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
|
if not text:
|
|
return []
|
|
try:
|
|
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|")
|
|
lexer.whitespace_split = True
|
|
lexer.commenters = ""
|
|
tokens = list(lexer)
|
|
except ValueError:
|
|
try:
|
|
tokens = shlex.split(text)
|
|
except ValueError:
|
|
tokens = text.split()
|
|
|
|
segments: list[list[str]] = []
|
|
current: list[str] = []
|
|
for token in tokens:
|
|
if token in {"&&", "||", ";", "|", "&"}:
|
|
if current:
|
|
segments.append(current)
|
|
current = []
|
|
continue
|
|
current.append(token)
|
|
if current:
|
|
segments.append(current)
|
|
return segments
|
|
|
|
def _command_has_redirection(self, command: str) -> bool:
|
|
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
|
if not text:
|
|
return False
|
|
try:
|
|
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|<>")
|
|
lexer.whitespace_split = True
|
|
lexer.commenters = ""
|
|
tokens = list(lexer)
|
|
except ValueError:
|
|
return any(marker in text for marker in (">", "<"))
|
|
return any(token in {">", ">>", "<", "<<"} for token in tokens)
|
|
|
|
def _normalize_policy(self, payload: dict[str, Any], scope: str) -> dict[str, Any]:
|
|
data = self._empty_policy()
|
|
data.update(payload or {})
|
|
data["memory_notes"] = [
|
|
self._normalize_memory_note(item, scope=scope)
|
|
for item in list(data.get("memory_notes", []))
|
|
if isinstance(item, dict) or str(item).strip()
|
|
]
|
|
data["authorization_rules"] = [
|
|
self._normalize_rule("authorization_rules", item, scope=scope)
|
|
for item in list(data.get("authorization_rules", []))
|
|
if isinstance(item, dict)
|
|
]
|
|
data["workspace_guardrails"] = [
|
|
self._normalize_rule("workspace_guardrails", item, scope=scope)
|
|
for item in list(data.get("workspace_guardrails", []))
|
|
if isinstance(item, dict)
|
|
]
|
|
data["skill_injection_rules"] = [
|
|
self._normalize_rule("skill_injection_rules", item, scope=scope)
|
|
for item in list(data.get("skill_injection_rules", []))
|
|
if isinstance(item, dict)
|
|
]
|
|
return data
|
|
|
|
def _normalize_memory_note(self, item: dict[str, Any] | str, scope: str) -> dict[str, Any]:
|
|
if isinstance(item, str):
|
|
return {
|
|
"id": f"note-{datetime.now().strftime('%Y%m%d%H%M%S%f')}",
|
|
"text": item.strip(),
|
|
"scope": scope,
|
|
"source": "imported",
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
note = dict(item)
|
|
note.setdefault("id", f"note-{datetime.now().strftime('%Y%m%d%H%M%S%f')}")
|
|
note["text"] = str(note.get("text", "")).strip()
|
|
note.setdefault("scope", scope)
|
|
note.setdefault("source", "user")
|
|
note.setdefault("created_at", datetime.now().isoformat())
|
|
return note
|
|
|
|
def _normalize_rule(self, kind: str, item: dict[str, Any], scope: str) -> dict[str, Any]:
|
|
rule = deepcopy(item)
|
|
rule.setdefault("id", f"{kind[:-1]}-{datetime.now().strftime('%Y%m%d%H%M%S%f')}")
|
|
rule.setdefault("scope", scope)
|
|
rule.setdefault("enabled", True)
|
|
rule.setdefault("created_at", datetime.now().isoformat())
|
|
if kind == "authorization_rules":
|
|
rule["tool_name"] = str(rule.get("tool_name", "")).strip()
|
|
rule["action"] = str(rule.get("action", "auto_allow")).strip() or "auto_allow"
|
|
rule["path_prefixes"] = [self._normalize_path(value) for value in rule.get("path_prefixes", []) if str(value).strip()]
|
|
rule["rationale"] = str(rule.get("rationale", "")).strip()
|
|
elif kind == "workspace_guardrails":
|
|
rule["allowed_roots"] = [self._normalize_path(value) for value in rule.get("allowed_roots", []) if str(value).strip()]
|
|
tool_names = [str(item).strip() for item in rule.get("risky_tool_names", []) if str(item).strip()]
|
|
rule["risky_tool_names"] = tool_names or list(_DEFAULT_RISKY_TOOLS)
|
|
rule["outside_allowed_action"] = str(rule.get("outside_allowed_action", "escalate")).strip() or "escalate"
|
|
rule["require_working_directory_for_risky_shell"] = bool(
|
|
rule.get("require_working_directory_for_risky_shell", True)
|
|
)
|
|
rule["rationale"] = str(rule.get("rationale", "")).strip()
|
|
elif kind == "skill_injection_rules":
|
|
rule["domains"] = [str(value).strip() for value in rule.get("domains", []) if str(value).strip()]
|
|
rule["skill_names"] = [str(value).strip() for value in rule.get("skill_names", []) if str(value).strip()]
|
|
rule["rationale"] = str(rule.get("rationale", "")).strip()
|
|
return rule
|
|
|
|
def _normalize_path(self, value: str) -> str:
|
|
try:
|
|
return str(Path(str(value).strip()).expanduser().resolve(strict=False))
|
|
except Exception:
|
|
return str(value).strip()
|
|
|
|
def _path_within(self, candidate: str, allowed_roots: list[str]) -> bool:
|
|
normalized = self._normalize_path(candidate)
|
|
for root in allowed_roots:
|
|
if normalized == root:
|
|
return True
|
|
if normalized.startswith(root.rstrip("/") + "/"):
|
|
return True
|
|
return False
|
|
|
|
def _empty_policy(self) -> dict[str, Any]:
|
|
return deepcopy(_DEFAULT_POLICY)
|