mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-05 08:06:05 +00:00
1664 lines
60 KiB
Python
1664 lines
60 KiB
Python
"""Filesystem permissions.
|
|
|
|
The module owns dangerous-path checks, working-directory checks, internal-path
|
|
rules, and permission suggestions. Runtime-specific internal path checks are
|
|
gated behind ``_INTERNAL_PATH_PREDICATES`` so callers can wire concrete
|
|
implementations without touching the core control flow.
|
|
|
|
The local runtime does not invoke an LLM classifier from filesystem permission
|
|
checks. ``classifier_approvable`` is still surfaced on
|
|
:class:`DecisionReasonSafetyCheck` so callers can render or persist the same
|
|
decision shape without changing the permission result.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import os
|
|
import posixpath
|
|
import re
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
FrozenSet,
|
|
Iterable,
|
|
List,
|
|
Literal,
|
|
Mapping,
|
|
Optional,
|
|
Sequence,
|
|
Set,
|
|
Tuple,
|
|
)
|
|
|
|
try:
|
|
# Re-use the UNC detector the bash classifier already owns so the
|
|
# two stay aligned.
|
|
from ..security.flag_validation import (
|
|
contains_vulnerable_unc_path as _contains_vulnerable_unc_path,
|
|
)
|
|
except Exception: # pragma: no cover - safety net for circular imports
|
|
def _contains_vulnerable_unc_path(
|
|
path_or_command: str, *, force_check: bool = False
|
|
) -> bool:
|
|
return False
|
|
|
|
from .types import (
|
|
AddDirectoriesUpdate,
|
|
AddRulesUpdate,
|
|
DecisionReasonMode,
|
|
DecisionReasonOther,
|
|
DecisionReasonRule,
|
|
DecisionReasonSafetyCheck,
|
|
DecisionReasonWorkingDir,
|
|
PermissionAllow,
|
|
PermissionAsk,
|
|
PermissionBehavior,
|
|
PermissionDecisionReason,
|
|
PermissionDeny,
|
|
PermissionPassthrough,
|
|
PermissionResult,
|
|
PermissionRule,
|
|
PermissionRuleSource,
|
|
PermissionRuleValue,
|
|
PermissionUpdate,
|
|
ToolPermissionContext,
|
|
parse_rule_value,
|
|
)
|
|
|
|
__all__ = [
|
|
# constants
|
|
"DANGEROUS_FILES",
|
|
"DANGEROUS_DIRECTORIES",
|
|
"SENSITIVE_FILES",
|
|
"SENSITIVE_DIRECTORY_SEGMENTS",
|
|
"SENSITIVE_FILE_GLOBS",
|
|
"FILE_EDIT_TOOL_NAME",
|
|
"FILE_WRITE_TOOL_NAME",
|
|
"FILE_READ_TOOL_NAME",
|
|
"EDIT_RULE_TOOL_NAMES",
|
|
"READ_RULE_TOOL_NAMES",
|
|
# path helpers
|
|
"to_posix_path",
|
|
"normalize_case_for_comparison",
|
|
"expand_path",
|
|
"contains_path_traversal",
|
|
"relative_path",
|
|
"has_suspicious_windows_path_pattern",
|
|
# settings & sensitive-path predicates
|
|
"is_openspace_settings_path",
|
|
"is_sensitive_path",
|
|
"is_dangerous_file_path_to_auto_edit",
|
|
# working directories
|
|
"all_working_directories",
|
|
"path_in_working_path",
|
|
"path_in_allowed_working_path",
|
|
# rule matching
|
|
"matching_rule_for_input",
|
|
"normalize_patterns_to_path",
|
|
"get_file_read_ignore_patterns",
|
|
# safety & suggestions
|
|
"check_path_safety_for_auto_edit",
|
|
"generate_suggestions",
|
|
# internal path carve-outs
|
|
"check_editable_internal_path",
|
|
"check_readable_internal_path",
|
|
"register_internal_path_predicate",
|
|
# main entry points
|
|
"check_read_permission_for_tool",
|
|
"check_write_permission_for_tool",
|
|
]
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §0 Tool-name constants (OpenSpace canonicalisation, os lowercase rename)
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
# OpenSpace ``FILE_EDIT_TOOL_NAME = 'Edit'`` — the rule tool_name used when
|
|
# storing/matching edit permissions. OS uses lowercase per runtime constraints.6.
|
|
FILE_EDIT_TOOL_NAME: str = "edit"
|
|
FILE_WRITE_TOOL_NAME: str = "write"
|
|
FILE_READ_TOOL_NAME: str = "read"
|
|
|
|
# OS introduces ``write`` as a distinct tool alongside ``edit`` (OpenSpace had
|
|
# only ``Edit``). When matching edit-category rules we honour both so
|
|
# a user-authored ``write(/tmp/**)`` rule still takes effect. Read-side
|
|
# we also accept ``grep``/``glob``/``ls`` so operator-facing "grant read
|
|
# to this folder" rules apply across the read-family tools uniformly.
|
|
EDIT_RULE_TOOL_NAMES: Tuple[str, ...] = (FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME)
|
|
READ_RULE_TOOL_NAMES: Tuple[str, ...] = (
|
|
FILE_READ_TOOL_NAME,
|
|
"grep",
|
|
"glob",
|
|
"ls",
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §1 Dangerous & sensitive path constants (OpenSpace filesystem.ts L57-L79)
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
# OpenSpace ``DANGEROUS_FILES``. ``.claude.json`` is renamed ``.openspace.json``
|
|
# per the OpenSpace→os .claude/.openspace rebrand. Everything else is unchanged.
|
|
DANGEROUS_FILES: FrozenSet[str] = frozenset(
|
|
{
|
|
".gitconfig",
|
|
".gitmodules",
|
|
".bashrc",
|
|
".bash_profile",
|
|
".zshrc",
|
|
".zprofile",
|
|
".profile",
|
|
".ripgreprc",
|
|
".mcp.json",
|
|
".openspace.json", # Implementation: '.claude.json'
|
|
}
|
|
)
|
|
|
|
# OpenSpace ``DANGEROUS_DIRECTORIES``. ``.claude`` → ``.openspace``.
|
|
DANGEROUS_DIRECTORIES: FrozenSet[str] = frozenset(
|
|
{
|
|
".git",
|
|
".vscode",
|
|
".idea",
|
|
".openspace", # Implementation: '.claude'
|
|
}
|
|
)
|
|
|
|
# Extended sensitive files — force an ``ask`` even when the user has
|
|
# enabled ``bypassPermissions`` / ``acceptEdits``. Matched as the
|
|
# *final* path component of a path (case-insensitive). This is OS-
|
|
# specific hardening requested by the user contract; OpenSpace lets the
|
|
# dangerous-files set cover this.
|
|
SENSITIVE_FILES: FrozenSet[str] = frozenset(
|
|
{
|
|
".env",
|
|
".mcp.json",
|
|
".bashrc",
|
|
".zshrc",
|
|
".bash_profile",
|
|
".zprofile",
|
|
".profile",
|
|
".gitconfig",
|
|
".credential",
|
|
".credentials",
|
|
".secret",
|
|
".secrets",
|
|
".token",
|
|
".tokens",
|
|
".api_key",
|
|
".apikey",
|
|
".envrc",
|
|
"id_rsa",
|
|
"id_dsa",
|
|
"id_ecdsa",
|
|
"id_ed25519",
|
|
"private_key",
|
|
"private-key",
|
|
"key",
|
|
"certificate",
|
|
"cert",
|
|
"credential",
|
|
"credentials",
|
|
"credentials.json",
|
|
"secret",
|
|
"secrets",
|
|
"token",
|
|
"tokens",
|
|
"token.json",
|
|
"api_key",
|
|
"apikey",
|
|
}
|
|
)
|
|
|
|
# Glob-shaped sensitive filenames (e.g. ``.env.production``). Stored as
|
|
# ``fnmatch``-style patterns evaluated case-insensitively against the
|
|
# final path component.
|
|
SENSITIVE_FILE_GLOBS: Tuple[str, ...] = (
|
|
".env.*",
|
|
"*.pem",
|
|
"*.key",
|
|
"*.crt",
|
|
"*.cert",
|
|
"*.cer",
|
|
"*.p12",
|
|
"*.pfx",
|
|
"id_rsa*",
|
|
"id_dsa*",
|
|
"id_ecdsa*",
|
|
"id_ed25519*",
|
|
"private_key.*",
|
|
"private-key.*",
|
|
"*_private_key.*",
|
|
"*-private-key.*",
|
|
"*_key.*",
|
|
"*-key.*",
|
|
"certificate.*",
|
|
"cert.*",
|
|
"credential.*",
|
|
"credentials.*",
|
|
"secret.*",
|
|
"secrets.*",
|
|
"token.*",
|
|
"tokens.*",
|
|
"api_key.*",
|
|
"apikey.*",
|
|
)
|
|
|
|
# Extended sensitive directory segments. If *any* path component
|
|
# matches, force ``ask``.
|
|
SENSITIVE_DIRECTORY_SEGMENTS: FrozenSet[str] = frozenset(
|
|
{
|
|
".git",
|
|
".openspace",
|
|
".ssh",
|
|
".aws",
|
|
".gcloud",
|
|
".azure",
|
|
}
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §2 Path normalisation helpers (OpenSpace filesystem.ts L90-L192 + path.ts)
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def normalize_case_for_comparison(path: str) -> str:
|
|
"""OpenSpace ``normalizeCaseForComparison`` (filesystem.ts L90).
|
|
|
|
Always lowercase regardless of platform to harden against
|
|
case-insensitive filesystems (``.cLauDe/SeTtings.LoCaL.json``).
|
|
"""
|
|
return path.lower()
|
|
|
|
|
|
def to_posix_path(path: str) -> str:
|
|
"""OpenSpace ``toPosixPath`` (filesystem.ts L187).
|
|
|
|
Convert backslashes to forward slashes. On Linux/macOS this is a
|
|
no-op; on Windows it matches OpenSpace's ``windowsPathToPosixPath``.
|
|
"""
|
|
if not path:
|
|
return path
|
|
# Drop ``\\?\`` / ``\\.\`` long-path prefixes so they don't leak into
|
|
# gitignore-style pattern matching. OpenSpace's ``windowsPathToPosixPath``
|
|
# does the same.
|
|
stripped = path
|
|
for prefix in ("\\\\?\\", "\\\\.\\"):
|
|
if stripped.startswith(prefix):
|
|
stripped = stripped[len(prefix):]
|
|
break
|
|
return stripped.replace("\\", "/")
|
|
|
|
|
|
def expand_path(path: str) -> str:
|
|
"""OpenSpace ``expandPath`` — expand ``~`` and make absolute.
|
|
|
|
OpenSpace normalises case on case-insensitive filesystems elsewhere; here
|
|
we only resolve the tilde and make the path absolute. We avoid
|
|
``os.path.realpath`` because OpenSpace checks both the unresolved and the
|
|
symlink-resolved form separately (see
|
|
:func:`_paths_for_permission_check`).
|
|
"""
|
|
if not path:
|
|
return path
|
|
if path.startswith("~"):
|
|
path = os.path.expanduser(path)
|
|
if not os.path.isabs(path):
|
|
path = os.path.abspath(path)
|
|
# Normalise redundant separators but DO NOT follow symlinks.
|
|
return os.path.normpath(path)
|
|
|
|
|
|
def contains_path_traversal(relative: str) -> bool:
|
|
"""OpenSpace ``containsPathTraversal`` — relative path starts with ``..``."""
|
|
if not relative:
|
|
return False
|
|
posix = to_posix_path(relative)
|
|
if posix == "..":
|
|
return True
|
|
if posix.startswith("../"):
|
|
return True
|
|
# Embedded traversal (``a/../b``) is OK for gitignore matching but
|
|
# still rejected by OpenSpace for membership checks — mirror that here.
|
|
parts = posix.split("/")
|
|
return ".." in parts and parts[0] == ".."
|
|
|
|
|
|
def relative_path(from_path: str, to_path: str) -> str:
|
|
"""OpenSpace ``relativePath`` (filesystem.ts L170) — POSIX-style relpath."""
|
|
posix_from = to_posix_path(from_path)
|
|
posix_to = to_posix_path(to_path)
|
|
try:
|
|
return posixpath.relpath(posix_to, posix_from)
|
|
except ValueError:
|
|
# Different drive letters on Windows — treat as outside.
|
|
return posixpath.join("..", posix_to)
|
|
|
|
|
|
def _paths_for_permission_check(path: str) -> Tuple[str, ...]:
|
|
"""OpenSpace ``getPathsForPermissionCheck`` — original + symlink-resolved.
|
|
|
|
OpenSpace derives up to 3 canonical forms to prevent symlink-based
|
|
bypasses: the raw input, the lexically normalised absolute form,
|
|
and the ``realpath`` form (symlinks followed). OS preserves the
|
|
same fan-out but tolerates errors resolving symlinks (e.g. when
|
|
the target does not yet exist on the filesystem — common for
|
|
``write``).
|
|
"""
|
|
out: List[str] = []
|
|
if path:
|
|
out.append(path)
|
|
try:
|
|
expanded = expand_path(path)
|
|
if expanded and expanded not in out:
|
|
out.append(expanded)
|
|
except Exception:
|
|
expanded = path
|
|
try:
|
|
real = os.path.realpath(expanded)
|
|
if real and real not in out:
|
|
out.append(real)
|
|
except Exception:
|
|
pass
|
|
return tuple(out)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §3 Suspicious Windows path patterns (OpenSpace filesystem.ts L537-L602)
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
_SHORT_NAME_RE = re.compile(r"~\d")
|
|
_TRAILING_DOTSPACE_RE = re.compile(r"[.\s]+$")
|
|
_DOS_DEVICE_RE = re.compile(
|
|
r"\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", re.IGNORECASE
|
|
)
|
|
_TRIPLE_DOT_RE = re.compile(r"(^|/|\\)\.{3,}(/|\\|$)")
|
|
|
|
|
|
def _current_platform() -> str:
|
|
"""OpenSpace ``getPlatform`` — collapse to macos/linux/windows/wsl."""
|
|
plat = os.sys.platform # type: ignore[attr-defined]
|
|
if plat.startswith("win"):
|
|
return "windows"
|
|
if plat == "darwin":
|
|
return "macos"
|
|
# crude WSL sniff (``/proc/version`` mentions Microsoft)
|
|
try:
|
|
with open("/proc/version", "rb") as f:
|
|
if b"microsoft" in f.read().lower():
|
|
return "wsl"
|
|
except Exception:
|
|
pass
|
|
return "linux"
|
|
|
|
|
|
def has_suspicious_windows_path_pattern(path: str) -> bool:
|
|
"""OpenSpace ``hasSuspiciousWindowsPathPattern`` (filesystem.ts L537).
|
|
|
|
Detects patterns that could bypass string-level path checks on
|
|
Windows/NTFS:
|
|
|
|
- NTFS Alternate Data Streams (``file.txt::$DATA``) — Windows/WSL
|
|
- 8.3 short names (``CLAUDE~1``)
|
|
- Long-path prefixes (``\\\\?\\C:\\``, ``\\\\.\\``, ``//?/``, ``//./``)
|
|
- Trailing dots/spaces (``.git.``, ``.bashrc ``) that Windows
|
|
silently strips during resolution
|
|
- DOS device names (``.git.CON``)
|
|
- 3+ consecutive dots used as a path component (``.../x``)
|
|
- UNC paths (delegated to :func:`contains_vulnerable_unc_path`)
|
|
"""
|
|
if not path:
|
|
return False
|
|
|
|
platform = _current_platform()
|
|
|
|
# NTFS ADS — only interpreted by the Windows kernel. On non-WSL
|
|
# Linux/macOS, colons are valid filename characters.
|
|
if platform in ("windows", "wsl"):
|
|
colon_idx = path.find(":", 2)
|
|
if colon_idx != -1:
|
|
return True
|
|
|
|
if _SHORT_NAME_RE.search(path):
|
|
return True
|
|
|
|
if (
|
|
path.startswith("\\\\?\\")
|
|
or path.startswith("\\\\.\\")
|
|
or path.startswith("//?/")
|
|
or path.startswith("//./")
|
|
):
|
|
return True
|
|
|
|
if _TRAILING_DOTSPACE_RE.search(path):
|
|
return True
|
|
|
|
if _DOS_DEVICE_RE.search(path):
|
|
return True
|
|
|
|
if _TRIPLE_DOT_RE.search(path):
|
|
return True
|
|
|
|
if _contains_vulnerable_unc_path(path, force_check=True):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §4 Settings / sensitive-path detection
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def is_openspace_settings_path(path: str) -> bool:
|
|
"""OpenSpace ``isClaudeSettingsPath`` (filesystem.ts L200).
|
|
|
|
Match any ``.openspace/settings.json`` or
|
|
``.openspace/settings.local.json`` regardless of project — these are
|
|
always sensitive.
|
|
"""
|
|
if not path:
|
|
return False
|
|
expanded = expand_path(path)
|
|
lower = normalize_case_for_comparison(expanded)
|
|
posix = to_posix_path(lower)
|
|
return (
|
|
posix.endswith("/.openspace/settings.json")
|
|
or posix.endswith("/.openspace/settings.local.json")
|
|
)
|
|
|
|
|
|
def _match_sensitive_filename(filename: str) -> bool:
|
|
"""True iff the final path component is in the OS sensitive list."""
|
|
if not filename:
|
|
return False
|
|
lower = normalize_case_for_comparison(filename)
|
|
if lower in {name.lower() for name in SENSITIVE_FILES}:
|
|
return True
|
|
# Glob-shaped sensitive names (``.env.production``).
|
|
for glob_pat in SENSITIVE_FILE_GLOBS:
|
|
if fnmatch.fnmatchcase(lower, glob_pat.lower()):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_sensitive_directory_segment(path: str) -> bool:
|
|
"""True iff any path component matches the sensitive-dir list."""
|
|
if not path:
|
|
return False
|
|
expanded = expand_path(path)
|
|
segments = re.split(r"[\\/]+", expanded)
|
|
sensitive = {seg.lower() for seg in SENSITIVE_DIRECTORY_SEGMENTS}
|
|
for seg in segments:
|
|
if seg and seg.lower() in sensitive:
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_sensitive_path(path: str) -> bool:
|
|
"""OS-specific: True iff ``path`` is in the user-declared sensitive list.
|
|
|
|
The sensitive list is a superset of OpenSpace's ``DANGEROUS_*`` and is
|
|
enforced even in ``bypassPermissions`` / ``acceptEdits`` modes.
|
|
"""
|
|
if not path:
|
|
return False
|
|
expanded = expand_path(path)
|
|
basename = os.path.basename(expanded)
|
|
if _match_sensitive_filename(basename):
|
|
return True
|
|
if _has_sensitive_directory_segment(expanded):
|
|
return True
|
|
# ``settings.json`` is only sensitive when under ``.openspace/``.
|
|
if normalize_case_for_comparison(basename) in {"settings.json", "settings.local.json"}:
|
|
if is_openspace_settings_path(expanded):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_dangerous_file_path_to_auto_edit(path: str) -> bool:
|
|
"""OpenSpace ``isDangerousFilePathToAutoEdit`` (filesystem.ts L435).
|
|
|
|
Triggers ``ask`` for paths that live under ``DANGEROUS_DIRECTORIES``
|
|
or whose basename is in ``DANGEROUS_FILES``. Matches OpenSpace
|
|
case-insensitively and skips ``.openspace/worktrees`` as a
|
|
structural carve-out (Implementation: ``.claude/worktrees``).
|
|
"""
|
|
if not path:
|
|
return False
|
|
|
|
# Defence-in-depth UNC check (Implementation: block any \\ or // prefix up
|
|
# front — catches patterns that slip past the regex UNC detector).
|
|
if path.startswith("\\\\") or path.startswith("//"):
|
|
return True
|
|
|
|
expanded = expand_path(path)
|
|
segments = re.split(r"[\\/]+", expanded)
|
|
dangerous_dirs_lower = {d.lower() for d in DANGEROUS_DIRECTORIES}
|
|
|
|
for i, segment in enumerate(segments):
|
|
if not segment:
|
|
continue
|
|
segment_lower = segment.lower()
|
|
if segment_lower in dangerous_dirs_lower:
|
|
# OpenSpace carve-out: ``.openspace/worktrees/`` (Implementation: ``.claude/worktrees``)
|
|
# is a structural path used by the harness itself. Skip the
|
|
# ``.openspace`` segment when it's followed by ``worktrees``.
|
|
if segment_lower == ".openspace":
|
|
nxt = segments[i + 1] if i + 1 < len(segments) else ""
|
|
if nxt and nxt.lower() == "worktrees":
|
|
continue
|
|
return True
|
|
|
|
filename = segments[-1] if segments else ""
|
|
if filename:
|
|
filename_lower = filename.lower()
|
|
for dangerous in DANGEROUS_FILES:
|
|
if dangerous.lower() == filename_lower:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §5 Working directory membership
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def all_working_directories(context: ToolPermissionContext) -> Set[str]:
|
|
"""OpenSpace ``allWorkingDirectories`` (filesystem.ts L667).
|
|
|
|
OpenSpace returns ``new Set([getOriginalCwd(), ...ctx.additionalWorkingDirectories.keys()])``.
|
|
In OS the original cwd is stored inside
|
|
``context.additional_working_directories`` (see
|
|
``ToolPermissionContext.default``) so we just return its keys.
|
|
Callers that want to inject a process-wide cwd on top of the
|
|
context should pre-populate it there.
|
|
"""
|
|
return set(context.additional_working_directories.keys())
|
|
|
|
|
|
def _macos_symlink_unalias(p: str) -> str:
|
|
"""Collapse macOS ``/private/var`` → ``/var`` etc. (OpenSpace filesystem.ts L716)."""
|
|
if p.startswith("/private/var/"):
|
|
return "/var/" + p[len("/private/var/"):]
|
|
if p == "/private/tmp" or p.startswith("/private/tmp/"):
|
|
return "/tmp" + p[len("/private/tmp"):]
|
|
return p
|
|
|
|
|
|
def path_in_working_path(path: str, working_path: str) -> bool:
|
|
"""OpenSpace ``pathInWorkingPath`` (filesystem.ts L709).
|
|
|
|
Case-insensitive membership check robust to macOS ``/private/var``
|
|
and ``/private/tmp`` symlinks.
|
|
"""
|
|
if not path or not working_path:
|
|
return False
|
|
|
|
abs_path = _macos_symlink_unalias(expand_path(path))
|
|
abs_wp = _macos_symlink_unalias(expand_path(working_path))
|
|
|
|
# Normalise for case-insensitive comparison.
|
|
ln_path = normalize_case_for_comparison(abs_path)
|
|
ln_wp = normalize_case_for_comparison(abs_wp)
|
|
|
|
rel = relative_path(ln_wp, ln_path)
|
|
|
|
if rel == "" or rel == ".":
|
|
return True
|
|
if contains_path_traversal(rel):
|
|
return False
|
|
return not posixpath.isabs(rel)
|
|
|
|
|
|
def path_in_allowed_working_path(
|
|
path: str,
|
|
context: ToolPermissionContext,
|
|
precomputed_paths_to_check: Optional[Sequence[str]] = None,
|
|
) -> bool:
|
|
"""OpenSpace ``pathInAllowedWorkingPath`` (filesystem.ts L683)."""
|
|
paths_to_check = (
|
|
tuple(precomputed_paths_to_check)
|
|
if precomputed_paths_to_check is not None
|
|
else _paths_for_permission_check(path)
|
|
)
|
|
|
|
# Expand each working directory to both its lexical and symlink
|
|
# forms so comparisons are symmetric on macOS / WSL.
|
|
working_paths: List[str] = []
|
|
for wp in all_working_directories(context):
|
|
for resolved in _paths_for_permission_check(wp):
|
|
if resolved not in working_paths:
|
|
working_paths.append(resolved)
|
|
|
|
if not working_paths:
|
|
return False
|
|
|
|
# Every resolved input form must be inside *some* working dir.
|
|
for p in paths_to_check:
|
|
if not any(path_in_working_path(p, wp) for wp in working_paths):
|
|
return False
|
|
return True
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §6 Rule lookup helpers
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _iter_rules_for_behavior(
|
|
context: ToolPermissionContext, behavior: PermissionBehavior
|
|
) -> Iterable[PermissionRule]:
|
|
"""Flatten ``context.always_*_rules`` → iterable of :class:`PermissionRule`.
|
|
|
|
Parses the stored ``toolName(ruleContent)`` strings back into
|
|
``PermissionRuleValue`` and attaches the originating source.
|
|
Malformed entries are silently dropped (same as OpenSpace's parseRule).
|
|
"""
|
|
if behavior == "allow":
|
|
by_source = context.always_allow_rules
|
|
elif behavior == "deny":
|
|
by_source = context.always_deny_rules
|
|
else:
|
|
by_source = context.always_ask_rules
|
|
|
|
for source, raw_rules in by_source.items():
|
|
for raw in raw_rules or ():
|
|
try:
|
|
value = parse_rule_value(raw)
|
|
except ValueError:
|
|
continue
|
|
yield PermissionRule(
|
|
source=source, rule_behavior=behavior, rule_value=value
|
|
)
|
|
|
|
|
|
def _rules_for_tool_and_behavior(
|
|
context: ToolPermissionContext,
|
|
tool_names: Sequence[str],
|
|
behavior: PermissionBehavior,
|
|
) -> List[PermissionRule]:
|
|
"""All rules whose ``rule_value.tool_name`` is in ``tool_names``."""
|
|
tool_set = set(tool_names)
|
|
return [
|
|
rule
|
|
for rule in _iter_rules_for_behavior(context, behavior)
|
|
if rule.rule_value.tool_name in tool_set
|
|
]
|
|
|
|
|
|
# --- gitignore-ish matcher (OpenSpace uses the ``ignore`` npm package) ------
|
|
|
|
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
"""Translate a gitignore-style glob to a regex.
|
|
|
|
Supported metachars::
|
|
|
|
** → any depth (including zero segments)
|
|
* → any char except ``/``
|
|
? → any single char except ``/``
|
|
[..] → character class (pass-through)
|
|
|
|
A leading ``/`` anchors the pattern to the start of the relative
|
|
path; otherwise the pattern matches if it occurs at *any* depth
|
|
(gitignore semantics).
|
|
"""
|
|
anchored = pattern.startswith("/")
|
|
body = pattern[1:] if anchored else pattern
|
|
|
|
# Walk char-by-char to emit regex tokens.
|
|
out: List[str] = []
|
|
i = 0
|
|
n = len(body)
|
|
while i < n:
|
|
ch = body[i]
|
|
if ch == "*":
|
|
# Check for `**`
|
|
if i + 1 < n and body[i + 1] == "*":
|
|
# Handle `**/`, `/**`, `/**/`, or bare `**`.
|
|
if i + 2 < n and body[i + 2] == "/":
|
|
out.append("(?:.*/)?")
|
|
i += 3
|
|
continue
|
|
out.append(".*")
|
|
i += 2
|
|
continue
|
|
out.append("[^/]*")
|
|
i += 1
|
|
continue
|
|
if ch == "?":
|
|
out.append("[^/]")
|
|
i += 1
|
|
continue
|
|
if ch == "[":
|
|
# Pass the char class through with minimal escaping.
|
|
end = body.find("]", i + 1)
|
|
if end == -1:
|
|
out.append(re.escape(ch))
|
|
i += 1
|
|
continue
|
|
cls = body[i : end + 1]
|
|
out.append(cls)
|
|
i = end + 1
|
|
continue
|
|
out.append(re.escape(ch))
|
|
i += 1
|
|
|
|
body_regex = "".join(out)
|
|
|
|
if anchored:
|
|
# Must match from the start; allow a trailing ``/…`` so that
|
|
# ``/dir`` matches ``dir/file`` too (gitignore dir semantics).
|
|
full = f"^{body_regex}(?:/.*)?$"
|
|
else:
|
|
# Match the pattern at any depth — either as the entire string
|
|
# or as a suffix starting after a ``/``.
|
|
full = f"^(?:.*/)?{body_regex}(?:/.*)?$"
|
|
return re.compile(full)
|
|
|
|
|
|
def _gitignore_test(rel_path: str, pattern: str) -> bool:
|
|
"""True iff ``rel_path`` is ignored by ``pattern`` (OpenSpace ``ig.test``)."""
|
|
if not pattern or not rel_path:
|
|
return False
|
|
posix_path = to_posix_path(rel_path)
|
|
while posix_path.startswith("./"):
|
|
posix_path = posix_path[2:]
|
|
posix_path = posix_path.lstrip("/")
|
|
regex = _glob_to_regex(pattern)
|
|
return bool(regex.match(posix_path))
|
|
|
|
|
|
# --- patternWithRoot / getPatternsByRoot (OpenSpace filesystem.ts L853-L953) -
|
|
|
|
def _root_path_for_source(
|
|
context: ToolPermissionContext, source: PermissionRuleSource
|
|
) -> str:
|
|
"""OpenSpace ``rootPathForSource`` (filesystem.ts L746).
|
|
|
|
OS cannot ask the settings loader for the on-disk root of each
|
|
source (see 20.x Settings — not yet landed). We fall back to the
|
|
*first* additional working directory (OpenSpace uses ``getOriginalCwd()``)
|
|
for session/cliArg/command sources, and the same for
|
|
user/project/local settings since we don't track disk roots yet.
|
|
"""
|
|
wds = list(context.additional_working_directories.keys())
|
|
return wds[0] if wds else os.getcwd()
|
|
|
|
|
|
def _pattern_with_root(
|
|
context: ToolPermissionContext, pattern: str, source: PermissionRuleSource
|
|
) -> Tuple[str, Optional[str]]:
|
|
"""OpenSpace ``patternWithRoot`` (filesystem.ts L853).
|
|
|
|
Returns ``(relativePattern, root)`` where ``root=None`` means the
|
|
pattern may match anywhere.
|
|
"""
|
|
DIR_SEP = "/"
|
|
|
|
if pattern.startswith(DIR_SEP + DIR_SEP):
|
|
# ``//abs/path/**`` → root = ``/``, pattern = abs path.
|
|
without_double = pattern[1:]
|
|
return without_double, DIR_SEP
|
|
|
|
if pattern.startswith("~" + DIR_SEP):
|
|
home = os.path.expanduser("~")
|
|
return pattern[1:], home
|
|
|
|
if pattern.startswith(DIR_SEP):
|
|
return pattern, _root_path_for_source(context, source)
|
|
|
|
# No explicit root → anchor-free pattern; strip leading ``./``.
|
|
normalised = pattern
|
|
if pattern.startswith("." + DIR_SEP):
|
|
normalised = pattern[2:]
|
|
return normalised, None
|
|
|
|
|
|
def _get_patterns_by_root(
|
|
context: ToolPermissionContext,
|
|
tool_names: Sequence[str],
|
|
behavior: PermissionBehavior,
|
|
) -> Dict[Optional[str], Dict[str, PermissionRule]]:
|
|
"""OpenSpace ``getPatternsByRoot`` (filesystem.ts L919).
|
|
|
|
Returns ``{root: {relativePattern: rule}}``. ``root=None`` means
|
|
"pattern can match at any depth".
|
|
"""
|
|
rules = _rules_for_tool_and_behavior(context, tool_names, behavior)
|
|
result: Dict[Optional[str], Dict[str, PermissionRule]] = {}
|
|
for rule in rules:
|
|
content = rule.rule_value.rule_content
|
|
if not content:
|
|
# Tool-wide rule with no path pattern — store under the
|
|
# null root with a catch-all ``**``.
|
|
entry = result.setdefault(None, {})
|
|
entry["**"] = rule
|
|
continue
|
|
relative_pattern, root = _pattern_with_root(context, content, rule.source)
|
|
entry = result.setdefault(root, {})
|
|
entry[relative_pattern] = rule
|
|
return result
|
|
|
|
|
|
def matching_rule_for_input(
|
|
tool_name: str,
|
|
path: str,
|
|
context: ToolPermissionContext,
|
|
behavior: PermissionBehavior,
|
|
) -> Optional[PermissionRule]:
|
|
"""OpenSpace ``matchingRuleForInput`` (filesystem.ts L955).
|
|
|
|
Returns the first :class:`PermissionRule` that matches ``path`` at
|
|
``behavior`` for the given ``tool_name`` category (``'read'`` →
|
|
read-family tools; ``'edit'``/``'write'`` → edit-family tools).
|
|
|
|
OpenSpace signature::
|
|
|
|
matchingRuleForInput(path, ctx, toolType, behavior)
|
|
where toolType ∈ {'read','edit'}
|
|
|
|
We accept the actual tool name (``'read'``/``'grep'``/… or
|
|
``'edit'``/``'write'``) and broaden internally.
|
|
"""
|
|
if tool_name in EDIT_RULE_TOOL_NAMES:
|
|
tool_names: Sequence[str] = EDIT_RULE_TOOL_NAMES
|
|
elif tool_name in READ_RULE_TOOL_NAMES:
|
|
tool_names = READ_RULE_TOOL_NAMES
|
|
else:
|
|
# Unknown tool — match only rules with the exact tool name.
|
|
tool_names = (tool_name,)
|
|
|
|
abs_path = to_posix_path(expand_path(path))
|
|
if not abs_path:
|
|
return None
|
|
|
|
patterns_by_root = _get_patterns_by_root(context, tool_names, behavior)
|
|
# Deterministic iteration: explicit roots first (longest first so
|
|
# nested roots win), then the anchor-free bucket.
|
|
explicit_roots = sorted(
|
|
(r for r in patterns_by_root.keys() if r is not None),
|
|
key=lambda r: len(r or ""),
|
|
reverse=True,
|
|
)
|
|
|
|
cwd_fallback = (
|
|
next(iter(context.additional_working_directories.keys()), os.getcwd())
|
|
)
|
|
|
|
for root in explicit_roots + [None]:
|
|
pattern_map = patterns_by_root.get(root) or {}
|
|
if not pattern_map:
|
|
continue
|
|
if root is None:
|
|
ref_root = cwd_fallback
|
|
else:
|
|
ref_root = root
|
|
rel = relative_path(ref_root, abs_path)
|
|
if rel.startswith("../") or rel == "..":
|
|
continue
|
|
if not rel:
|
|
continue
|
|
for pattern, rule in pattern_map.items():
|
|
# OpenSpace strips trailing ``/**`` before feeding to ``ignore()``;
|
|
# we keep both forms to preserve OpenSpace's dual-lookup behaviour.
|
|
check_pattern = pattern
|
|
if check_pattern.endswith("/**"):
|
|
check_pattern = check_pattern[:-3]
|
|
if _gitignore_test(rel, check_pattern) or _gitignore_test(rel, pattern):
|
|
return rule
|
|
return None
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §7 Pattern normalisation helpers
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _normalize_pattern_to_path(
|
|
*,
|
|
pattern_root: str,
|
|
pattern: str,
|
|
root_path: str,
|
|
) -> Optional[str]:
|
|
"""OpenSpace ``normalizePatternToPath`` (filesystem.ts L765)."""
|
|
DIR_SEP = "/"
|
|
full_pattern = posixpath.join(pattern_root, pattern)
|
|
if pattern_root == root_path:
|
|
return DIR_SEP + pattern.lstrip(DIR_SEP)
|
|
if full_pattern.startswith(root_path + DIR_SEP):
|
|
relative_part = full_pattern[len(root_path):]
|
|
return DIR_SEP + relative_part.lstrip(DIR_SEP)
|
|
# Pattern is either outside the reference root or nested under a
|
|
# sibling.
|
|
try:
|
|
rel = posixpath.relpath(pattern_root, root_path)
|
|
except ValueError:
|
|
return None
|
|
if not rel or rel == ".." or rel.startswith(".." + DIR_SEP):
|
|
return None
|
|
relative_pattern = posixpath.join(rel, pattern)
|
|
return DIR_SEP + relative_pattern.lstrip(DIR_SEP)
|
|
|
|
|
|
def normalize_patterns_to_path(
|
|
patterns_by_root: Mapping[Optional[str], Sequence[str]],
|
|
root: str,
|
|
) -> List[str]:
|
|
"""OpenSpace ``normalizePatternsToPath`` (filesystem.ts L800)."""
|
|
result: List[str] = []
|
|
seen: Set[str] = set()
|
|
|
|
for pat in patterns_by_root.get(None, ()) or ():
|
|
if pat not in seen:
|
|
seen.add(pat)
|
|
result.append(pat)
|
|
|
|
for pattern_root, patterns in patterns_by_root.items():
|
|
if pattern_root is None:
|
|
continue
|
|
for pattern in patterns or ():
|
|
normalised = _normalize_pattern_to_path(
|
|
pattern_root=pattern_root, pattern=pattern, root_path=root
|
|
)
|
|
if normalised and normalised not in seen:
|
|
seen.add(normalised)
|
|
result.append(normalised)
|
|
return result
|
|
|
|
|
|
def get_file_read_ignore_patterns(
|
|
context: ToolPermissionContext,
|
|
) -> Dict[Optional[str], List[str]]:
|
|
"""OpenSpace ``getFileReadIgnorePatterns`` (filesystem.ts L837).
|
|
|
|
Aggregates all file-read deny patterns grouped by their root. Used
|
|
by GrepTool/GlobTool to hide files matched by read-deny rules.
|
|
"""
|
|
patterns_by_root = _get_patterns_by_root(
|
|
context, READ_RULE_TOOL_NAMES, "deny"
|
|
)
|
|
out: Dict[Optional[str], List[str]] = {}
|
|
for root, pattern_map in patterns_by_root.items():
|
|
out[root] = list(pattern_map.keys())
|
|
return out
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §8 Internal-path allow-lists
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
# OpenSpace weaves in half a dozen session-lifecycle predicates
|
|
# (``isSessionPlanFile``, ``isScratchpadPath``, ``isAgentMemoryPath``,
|
|
# ``isAutoMemPath``, ``getToolResultsDir``, ``getBundledSkillsRoot``,
|
|
# ``getProjectTempDir``, ``join(getClaudeConfigHomeDir(),'tasks')``,
|
|
# ``join(getClaudeConfigHomeDir(),'teams')`` …). OS has not yet
|
|
# ported the supporting runtime state (step 17.x session storage,
|
|
# 15.x agent memory, 23.x scratchpad, etc.), so this module exposes a
|
|
# registration API that downstream modules can populate without
|
|
# touching the control flow here.
|
|
|
|
InternalPathPredicate = Callable[[str], bool]
|
|
_INTERNAL_EDITABLE_PREDICATES: List[Tuple[str, InternalPathPredicate]] = []
|
|
_INTERNAL_READABLE_PREDICATES: List[Tuple[str, InternalPathPredicate]] = []
|
|
|
|
|
|
def register_internal_path_predicate(
|
|
*,
|
|
category: Literal["editable", "readable"],
|
|
reason: str,
|
|
predicate: InternalPathPredicate,
|
|
) -> None:
|
|
"""Register a carve-out for :func:`check_editable_internal_path` or
|
|
:func:`check_readable_internal_path`.
|
|
|
|
Example (future 17.1 session store wiring)::
|
|
|
|
register_internal_path_predicate(
|
|
category="editable",
|
|
reason="Plan files for current session are allowed for writing",
|
|
predicate=is_session_plan_file,
|
|
)
|
|
|
|
``predicate`` receives an absolute, normalised path.
|
|
"""
|
|
entry = (reason, predicate)
|
|
if category == "editable":
|
|
_INTERNAL_EDITABLE_PREDICATES.append(entry)
|
|
elif category == "readable":
|
|
_INTERNAL_READABLE_PREDICATES.append(entry)
|
|
else:
|
|
raise ValueError(
|
|
f"register_internal_path_predicate: unknown category {category!r}"
|
|
)
|
|
|
|
|
|
def check_editable_internal_path(
|
|
absolute_path: str,
|
|
input_payload: Optional[Mapping[str, Any]] = None,
|
|
) -> PermissionResult:
|
|
"""OpenSpace ``checkEditableInternalPath`` (filesystem.ts L1479).
|
|
|
|
OpenSpace branches (session plan file, scratchpad, job dir under
|
|
``TEMPLATES`` feature, agent memory, auto-mem, preview
|
|
``launch.json``) are delegated to predicates registered via
|
|
:func:`register_internal_path_predicate`. When no predicate has
|
|
been registered (the default until 15.x/17.1/23.x land) the
|
|
function returns :class:`PermissionPassthrough`, matching OpenSpace's
|
|
``{ behavior: 'passthrough' }`` fall-through.
|
|
"""
|
|
if not absolute_path:
|
|
return PermissionPassthrough(message="")
|
|
normalized = expand_path(absolute_path)
|
|
for reason, predicate in _INTERNAL_EDITABLE_PREDICATES:
|
|
try:
|
|
if predicate(normalized):
|
|
return PermissionAllow(
|
|
updated_input=dict(input_payload) if input_payload else None,
|
|
decision_reason=DecisionReasonOther(reason=reason),
|
|
)
|
|
except Exception: # pragma: no cover - defensive
|
|
continue
|
|
return PermissionPassthrough(message="")
|
|
|
|
|
|
def check_readable_internal_path(
|
|
absolute_path: str,
|
|
input_payload: Optional[Mapping[str, Any]] = None,
|
|
) -> PermissionResult:
|
|
"""OpenSpace ``checkReadableInternalPath`` (filesystem.ts L1611).
|
|
|
|
See :func:`check_editable_internal_path` for the registration
|
|
semantics. OpenSpace checks session-memory, project dir, plan file,
|
|
tool-results, scratchpad, project-temp, agent memory, auto-mem,
|
|
tasks dir, teams dir, bundled-skills root — all delegated to
|
|
registered predicates in OS.
|
|
"""
|
|
if not absolute_path:
|
|
return PermissionPassthrough(message="")
|
|
normalized = expand_path(absolute_path)
|
|
for reason, predicate in _INTERNAL_READABLE_PREDICATES:
|
|
try:
|
|
if predicate(normalized):
|
|
return PermissionAllow(
|
|
updated_input=dict(input_payload) if input_payload else None,
|
|
decision_reason=DecisionReasonOther(reason=reason),
|
|
)
|
|
except Exception: # pragma: no cover - defensive
|
|
continue
|
|
return PermissionPassthrough(message="")
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §9 Safety check (OpenSpace checkPathSafetyForAutoEdit L620-L665)
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
_SafetyReturn = Optional[Tuple[str, bool]]
|
|
|
|
|
|
def check_path_safety_for_auto_edit(
|
|
path: str,
|
|
context: Optional[ToolPermissionContext] = None,
|
|
precomputed_paths_to_check: Optional[Sequence[str]] = None,
|
|
) -> _SafetyReturn:
|
|
"""OpenSpace ``checkPathSafetyForAutoEdit`` (filesystem.ts L620).
|
|
|
|
Returns ``None`` when the path is safe, or
|
|
``(reason_message, classifier_approvable)`` when not.
|
|
|
|
Mirrors OpenSpace's three-step cascade and layers OS's sensitive-path set
|
|
on top so it fires in ``bypassPermissions`` / ``acceptEdits`` modes.
|
|
|
|
The ``context`` parameter is accepted for API symmetry with the
|
|
rest of the module but not consulted directly — the check is
|
|
purely path-based. ``precomputed_paths_to_check`` lets callers
|
|
reuse the original + symlink-resolved fan-out computed by
|
|
:func:`_paths_for_permission_check`.
|
|
"""
|
|
paths_to_check = (
|
|
tuple(precomputed_paths_to_check)
|
|
if precomputed_paths_to_check is not None
|
|
else _paths_for_permission_check(path)
|
|
)
|
|
|
|
# OpenSpace step 1 — suspicious Windows path patterns → classifier cannot
|
|
# approve (path canonicalisation risks).
|
|
for p in paths_to_check:
|
|
if has_suspicious_windows_path_pattern(p):
|
|
return (
|
|
f"OpenSpace requested permissions to write to {path}, which "
|
|
"contains a suspicious Windows path pattern that requires "
|
|
"manual approval.",
|
|
False,
|
|
)
|
|
|
|
# OS-extension — user-declared sensitive paths force an ask even
|
|
# under bypassPermissions (per user spec). Classifier-approvable
|
|
# because the content (not the shape) is what's sensitive.
|
|
for p in paths_to_check:
|
|
if is_sensitive_path(p):
|
|
return (
|
|
f"OpenSpace requested permissions to write to {path}, which "
|
|
"is a sensitive path (credentials, shell config, or "
|
|
"OpenSpace settings) — manual approval required.",
|
|
True,
|
|
)
|
|
|
|
# OpenSpace step 2 — OpenSpace config files (.openspace/settings.json,
|
|
# .openspace/commands/, .openspace/agents/, .openspace/skills/).
|
|
# Detected via :func:`is_openspace_settings_path` plus the broader
|
|
# ``.openspace/*`` segment check done by sensitive-paths above;
|
|
# keep the explicit message for UX parity with OpenSpace.
|
|
for p in paths_to_check:
|
|
if is_openspace_settings_path(p):
|
|
return (
|
|
f"OpenSpace requested permissions to write to {path}, but "
|
|
"you haven't granted it yet.",
|
|
True,
|
|
)
|
|
|
|
# OpenSpace step 3 — generic dangerous files/dirs
|
|
# (.git/, .vscode/, .idea/, .openspace/ + shell configs).
|
|
for p in paths_to_check:
|
|
if is_dangerous_file_path_to_auto_edit(p):
|
|
return (
|
|
f"OpenSpace requested permissions to edit {path} which is "
|
|
"a sensitive file.",
|
|
True,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §10 Suggestions for ask decisions (OpenSpace generateSuggestions L1414)
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _directory_for_path(path: str) -> str:
|
|
"""OpenSpace ``getDirectoryForPath`` — parent if file, self if dir."""
|
|
expanded = expand_path(path)
|
|
if os.path.isdir(expanded):
|
|
return expanded
|
|
return os.path.dirname(expanded) or expanded
|
|
|
|
|
|
def _create_read_rule_suggestion(
|
|
directory: str, destination: str = "session"
|
|
) -> Optional[AddRulesUpdate]:
|
|
"""OpenSpace ``createReadRuleSuggestion`` (PermissionUpdate.ts).
|
|
|
|
Wraps a directory in a ``read(<dir>/**)`` addRules update.
|
|
"""
|
|
if not directory:
|
|
return None
|
|
posix_dir = to_posix_path(directory).rstrip("/")
|
|
if not posix_dir:
|
|
return None
|
|
pattern = f"{posix_dir}/**"
|
|
# OpenSpace anchors absolute patterns with a leading ``//`` so they resolve
|
|
# relative to the filesystem root in ``patternWithRoot``. Follow
|
|
# suit so round-tripping works.
|
|
if pattern.startswith("/") and not pattern.startswith("//"):
|
|
pattern = "/" + pattern
|
|
return AddRulesUpdate(
|
|
destination=destination, # type: ignore[arg-type]
|
|
rules=(
|
|
PermissionRuleValue(
|
|
tool_name=FILE_READ_TOOL_NAME, rule_content=pattern
|
|
),
|
|
),
|
|
behavior="allow",
|
|
)
|
|
|
|
|
|
def _create_edit_rule_suggestion(
|
|
directory: str, destination: str = "session"
|
|
) -> Optional[AddRulesUpdate]:
|
|
"""OS-side helper mirroring ``createReadRuleSuggestion`` for edits."""
|
|
if not directory:
|
|
return None
|
|
posix_dir = to_posix_path(directory).rstrip("/")
|
|
if not posix_dir:
|
|
return None
|
|
pattern = f"{posix_dir}/**"
|
|
if pattern.startswith("/") and not pattern.startswith("//"):
|
|
pattern = "/" + pattern
|
|
return AddRulesUpdate(
|
|
destination=destination, # type: ignore[arg-type]
|
|
rules=(
|
|
PermissionRuleValue(
|
|
tool_name=FILE_EDIT_TOOL_NAME, rule_content=pattern
|
|
),
|
|
),
|
|
behavior="allow",
|
|
)
|
|
|
|
|
|
def generate_suggestions(
|
|
tool_name: str,
|
|
path: str,
|
|
context: ToolPermissionContext,
|
|
precomputed_paths_to_check: Optional[Sequence[str]] = None,
|
|
operation_type: Optional[Literal["read", "write", "create"]] = None,
|
|
) -> Tuple[PermissionUpdate, ...]:
|
|
"""OpenSpace ``generateSuggestions`` (filesystem.ts L1414).
|
|
|
|
Emits the ``PermissionUpdate`` list the UI surfaces as "always
|
|
allow" shortcuts. Typically:
|
|
|
|
- For reads outside working dirs: one ``addRules(read, <dir>/**)``
|
|
per canonical path form.
|
|
- For writes/creates: ``setMode=acceptEdits`` (when it would be an
|
|
upgrade) plus, if outside working dirs, ``addDirectories([<dir>])``.
|
|
- For reads inside working dirs: ``setMode=acceptEdits`` (no-op
|
|
for reads today but preserved for parity).
|
|
"""
|
|
# Derive operation type from tool_name if the caller didn't specify.
|
|
if operation_type is None:
|
|
if tool_name in EDIT_RULE_TOOL_NAMES:
|
|
operation_type = "write"
|
|
else:
|
|
operation_type = "read"
|
|
|
|
is_outside = not path_in_allowed_working_path(
|
|
path, context, precomputed_paths_to_check
|
|
)
|
|
|
|
# Reads outside the working directory get one addRules(read,…) per
|
|
# canonical dir form so subsequent checks against either the
|
|
# symlink or the resolved path pass.
|
|
if operation_type == "read" and is_outside:
|
|
dir_path = _directory_for_path(path)
|
|
dirs_to_add = _paths_for_permission_check(dir_path)
|
|
suggestions: List[PermissionUpdate] = []
|
|
for d in dirs_to_add:
|
|
sugg = _create_read_rule_suggestion(d, "session")
|
|
if sugg is not None:
|
|
suggestions.append(sugg)
|
|
return tuple(suggestions)
|
|
|
|
# ``setMode: acceptEdits`` only suggested from modes where it's an
|
|
# upgrade (Implementation: default / plan). bypassPermissions / acceptEdits
|
|
# already grant ≥ these rights; dontAsk is a deny-default mode
|
|
# where silently downgrading would surprise the user.
|
|
should_suggest_accept = context.mode in ("default", "plan")
|
|
|
|
if operation_type in ("write", "create"):
|
|
updates: List[PermissionUpdate] = []
|
|
if should_suggest_accept:
|
|
from .types import SetModeUpdate # local import to avoid cycles
|
|
|
|
updates.append(
|
|
SetModeUpdate(destination="session", mode="acceptEdits")
|
|
)
|
|
if is_outside:
|
|
dir_path = _directory_for_path(path)
|
|
dirs_to_add = _paths_for_permission_check(dir_path)
|
|
if dirs_to_add:
|
|
updates.append(
|
|
AddDirectoriesUpdate(
|
|
destination="session",
|
|
directories=tuple(dirs_to_add),
|
|
)
|
|
)
|
|
return tuple(updates)
|
|
|
|
# Reads inside working dirs → only the mode upgrade (when applicable).
|
|
if should_suggest_accept:
|
|
from .types import SetModeUpdate
|
|
|
|
return (SetModeUpdate(destination="session", mode="acceptEdits"),)
|
|
return ()
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
# §11 Top-level entry points
|
|
# ════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _ask_unc(path: str, operation: str) -> PermissionAsk:
|
|
"""OpenSpace step 1 in checkReadPermissionForTool — defence-in-depth UNC."""
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to {operation} from {path}, "
|
|
"which appears to be a UNC path that could access network resources."
|
|
),
|
|
blocked_path=path,
|
|
decision_reason=DecisionReasonOther(
|
|
reason="UNC path detected (defense-in-depth check)"
|
|
),
|
|
)
|
|
|
|
|
|
def _ask_suspicious_windows(path: str, operation: str) -> PermissionAsk:
|
|
"""OpenSpace step 2 in checkReadPermissionForTool — suspicious win patterns."""
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to {operation} from {path}, "
|
|
"which contains a suspicious Windows path pattern that requires "
|
|
"manual approval."
|
|
),
|
|
blocked_path=path,
|
|
decision_reason=DecisionReasonSafetyCheck(
|
|
reason=(
|
|
"Path contains suspicious Windows-specific patterns "
|
|
"(alternate data streams, short names, long path prefixes, "
|
|
"or three or more consecutive dots) that require manual "
|
|
"verification"
|
|
),
|
|
classifier_approvable=False,
|
|
),
|
|
)
|
|
|
|
|
|
def check_read_permission_for_tool(
|
|
tool_name: str,
|
|
input_path: str,
|
|
context: ToolPermissionContext,
|
|
internal_read_roots: Optional[Sequence[str]] = None,
|
|
) -> PermissionResult:
|
|
"""OpenSpace ``checkReadPermissionForTool`` (filesystem.ts L1030).
|
|
|
|
Faithful port of OpenSpace's 12-step cascade. Callers pass the final
|
|
path (resolved from ``tool.getPath(input)`` upstream) rather than
|
|
a ``Tool`` instance.
|
|
|
|
Returns a :class:`PermissionAllow` / :class:`PermissionAsk` /
|
|
:class:`PermissionDeny` — never :class:`PermissionPassthrough`.
|
|
"""
|
|
if not input_path:
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to use {tool_name}, but "
|
|
"you haven't granted it yet."
|
|
)
|
|
)
|
|
|
|
paths_to_check = _paths_for_permission_check(input_path)
|
|
|
|
# OpenSpace 1. Defence-in-depth UNC block.
|
|
for p in paths_to_check:
|
|
if p.startswith("\\\\") or p.startswith("//"):
|
|
return _ask_unc(input_path, "read")
|
|
|
|
# OpenSpace 2. Suspicious Windows patterns.
|
|
for p in paths_to_check:
|
|
if has_suspicious_windows_path_pattern(p):
|
|
return _ask_suspicious_windows(input_path, "read")
|
|
|
|
# OpenSpace 3. Read-specific deny rules (on every canonical path form).
|
|
# Explicit deny rules stay terminal even for paths that would otherwise
|
|
# trigger the sensitive-path ask below.
|
|
for p in paths_to_check:
|
|
deny_rule = matching_rule_for_input(FILE_READ_TOOL_NAME, p, context, "deny")
|
|
if deny_rule is not None:
|
|
return PermissionDeny(
|
|
message=f"Permission to read {input_path} has been denied.",
|
|
decision_reason=DecisionReasonRule(rule=deny_rule),
|
|
)
|
|
|
|
# Runtime-owned output is safe to read even when its session directory
|
|
# lives below the otherwise-sensitive .openspace tree. Keep explicit deny
|
|
# rules above this carve-out.
|
|
for root in internal_read_roots or ():
|
|
if root and all(path_in_working_path(p, root) for p in paths_to_check):
|
|
return PermissionAllow(
|
|
updated_input=None,
|
|
decision_reason=DecisionReasonOther(
|
|
reason="Path is inside a runtime-owned readable directory"
|
|
),
|
|
)
|
|
|
|
# OS-extension: runtime-owned internal files, such as background task
|
|
# stdout under the session task directory, must remain readable even when
|
|
# their parent lives under ``.openspace``. Keep explicit deny rules above.
|
|
internal = check_readable_internal_path(expand_path(input_path), None)
|
|
if not isinstance(internal, PermissionPassthrough):
|
|
return internal
|
|
|
|
# OS-extension — sensitive paths force an ask even when the user
|
|
# has installed a permissive read rule.
|
|
for p in paths_to_check:
|
|
if is_sensitive_path(p):
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to read from {input_path}, "
|
|
"which is a sensitive path (credentials, shell config, or "
|
|
"OpenSpace settings) — manual approval required."
|
|
),
|
|
blocked_path=input_path,
|
|
decision_reason=DecisionReasonSafetyCheck(
|
|
reason=(
|
|
"Path matches the OpenSpace sensitive-path list "
|
|
"(credentials / config / secrets)"
|
|
),
|
|
classifier_approvable=True,
|
|
),
|
|
)
|
|
|
|
# OpenSpace 4. Read-specific ask rules.
|
|
for p in paths_to_check:
|
|
ask_rule = matching_rule_for_input(FILE_READ_TOOL_NAME, p, context, "ask")
|
|
if ask_rule is not None:
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to read from {input_path}, "
|
|
"but you haven't granted it yet."
|
|
),
|
|
blocked_path=input_path,
|
|
decision_reason=DecisionReasonRule(rule=ask_rule),
|
|
)
|
|
|
|
# OpenSpace 5. Edit access implies read access (but only when no
|
|
# read-specific ask/deny rule matched — preserved above).
|
|
edit_result = check_write_permission_for_tool(
|
|
# Use the read tool name in downstream messaging when we're
|
|
# synthesising a write-check from a read-check — but delegate
|
|
# rule matching to edit-category rules by passing the caller's
|
|
# original tool name context via the write helper.
|
|
tool_name="edit",
|
|
input_path=input_path,
|
|
context=context,
|
|
precomputed_paths_to_check=paths_to_check,
|
|
)
|
|
if isinstance(edit_result, PermissionAllow):
|
|
return edit_result
|
|
|
|
# OpenSpace 6. Allow reads in working directories.
|
|
if path_in_allowed_working_path(input_path, context, paths_to_check):
|
|
return PermissionAllow(
|
|
updated_input=None,
|
|
decision_reason=DecisionReasonWorkingDir(
|
|
reason="Path is inside an allowed working directory"
|
|
),
|
|
)
|
|
|
|
# OpenSpace 7. Allow internal harness paths.
|
|
internal = check_readable_internal_path(expand_path(input_path), None)
|
|
if not isinstance(internal, PermissionPassthrough):
|
|
return internal
|
|
|
|
# OpenSpace 8. Read-allow rules (user-granted).
|
|
allow_rule = matching_rule_for_input(
|
|
FILE_READ_TOOL_NAME, input_path, context, "allow"
|
|
)
|
|
if allow_rule is not None:
|
|
return PermissionAllow(
|
|
updated_input=None,
|
|
decision_reason=DecisionReasonRule(rule=allow_rule),
|
|
)
|
|
|
|
# OpenSpace 12. Default → ask with suggestions.
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to read from {input_path}, but "
|
|
"you haven't granted it yet."
|
|
),
|
|
blocked_path=input_path,
|
|
suggestions=generate_suggestions(
|
|
tool_name=FILE_READ_TOOL_NAME,
|
|
path=input_path,
|
|
context=context,
|
|
precomputed_paths_to_check=paths_to_check,
|
|
operation_type="read",
|
|
),
|
|
decision_reason=DecisionReasonWorkingDir(
|
|
reason="Path is outside allowed working directories"
|
|
),
|
|
)
|
|
|
|
|
|
def check_write_permission_for_tool(
|
|
tool_name: str,
|
|
input_path: str,
|
|
context: ToolPermissionContext,
|
|
precomputed_paths_to_check: Optional[Sequence[str]] = None,
|
|
) -> PermissionResult:
|
|
"""OpenSpace ``checkWritePermissionForTool`` (filesystem.ts L1205).
|
|
|
|
Faithful port of OpenSpace's 5-step cascade, with OS extensions:
|
|
|
|
- ``plan`` mode → immediate :class:`PermissionDeny` (OS plan mode
|
|
is strictly read-only; OpenSpace's ``plan`` defers to the normal
|
|
pipeline).
|
|
- ``bypassPermissions`` → allow *unless* the path is in the
|
|
OS sensitive list (sensitive-paths always force ask).
|
|
- ``acceptEdits`` + inside working dir + not sensitive → allow.
|
|
"""
|
|
if not input_path:
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to use {tool_name}, but "
|
|
"you haven't granted it yet."
|
|
)
|
|
)
|
|
|
|
# OS extension — plan mode disallows all writes outright.
|
|
if context.mode == "plan":
|
|
return PermissionDeny(
|
|
message=(
|
|
"Plan mode is read-only; OpenSpace cannot edit "
|
|
f"{input_path} while the session is in plan mode."
|
|
),
|
|
decision_reason=DecisionReasonMode(mode="plan"),
|
|
)
|
|
|
|
paths_to_check = (
|
|
tuple(precomputed_paths_to_check)
|
|
if precomputed_paths_to_check is not None
|
|
else _paths_for_permission_check(input_path)
|
|
)
|
|
|
|
# OpenSpace 1. Edit-specific deny rules.
|
|
for p in paths_to_check:
|
|
deny_rule = matching_rule_for_input(
|
|
FILE_EDIT_TOOL_NAME, p, context, "deny"
|
|
)
|
|
if deny_rule is not None:
|
|
return PermissionDeny(
|
|
message=f"Permission to edit {input_path} has been denied.",
|
|
decision_reason=DecisionReasonRule(rule=deny_rule),
|
|
)
|
|
|
|
# OpenSpace 1.5. Internal editable paths (plan files, scratchpad, etc.).
|
|
# Must come BEFORE safety checks because ``.openspace`` is a
|
|
# dangerous directory but the harness legitimately writes under it.
|
|
absolute_for_edit = expand_path(input_path)
|
|
internal_edit = check_editable_internal_path(absolute_for_edit, None)
|
|
if not isinstance(internal_edit, PermissionPassthrough):
|
|
return internal_edit
|
|
|
|
# OpenSpace 1.7. Comprehensive safety validations.
|
|
# Must come BEFORE allow-rule / mode / bypass checks so the user
|
|
# can't accidentally grant write access to sensitive files.
|
|
safety = check_path_safety_for_auto_edit(
|
|
input_path, context, paths_to_check
|
|
)
|
|
if safety is not None:
|
|
message, classifier_approvable = safety
|
|
return PermissionAsk(
|
|
message=message,
|
|
blocked_path=input_path,
|
|
suggestions=generate_suggestions(
|
|
tool_name=FILE_EDIT_TOOL_NAME,
|
|
path=input_path,
|
|
context=context,
|
|
precomputed_paths_to_check=paths_to_check,
|
|
operation_type="write",
|
|
),
|
|
decision_reason=DecisionReasonSafetyCheck(
|
|
reason=message,
|
|
classifier_approvable=classifier_approvable,
|
|
),
|
|
)
|
|
|
|
# OpenSpace 2. Edit-specific ask rules.
|
|
for p in paths_to_check:
|
|
ask_rule = matching_rule_for_input(
|
|
FILE_EDIT_TOOL_NAME, p, context, "ask"
|
|
)
|
|
if ask_rule is not None:
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to write to {input_path}, "
|
|
"but you haven't granted it yet."
|
|
),
|
|
blocked_path=input_path,
|
|
decision_reason=DecisionReasonRule(rule=ask_rule),
|
|
)
|
|
|
|
in_working_dir = path_in_allowed_working_path(
|
|
input_path, context, paths_to_check
|
|
)
|
|
|
|
# OS extension — bypassPermissions allows everything that passed
|
|
# the safety cascade above.
|
|
if context.mode == "bypassPermissions":
|
|
return PermissionAllow(
|
|
updated_input=None,
|
|
decision_reason=DecisionReasonMode(mode="bypassPermissions"),
|
|
)
|
|
|
|
# OpenSpace 3. acceptEdits + in working dir → allow.
|
|
if context.mode == "acceptEdits" and in_working_dir:
|
|
return PermissionAllow(
|
|
updated_input=None,
|
|
decision_reason=DecisionReasonMode(mode="acceptEdits"),
|
|
)
|
|
|
|
# OpenSpace 4. Edit-allow rules.
|
|
allow_rule = matching_rule_for_input(
|
|
FILE_EDIT_TOOL_NAME, input_path, context, "allow"
|
|
)
|
|
if allow_rule is not None:
|
|
return PermissionAllow(
|
|
updated_input=None,
|
|
decision_reason=DecisionReasonRule(rule=allow_rule),
|
|
)
|
|
|
|
# OpenSpace 5. Default → ask.
|
|
ask_reason: Optional[PermissionDecisionReason] = None
|
|
if not in_working_dir:
|
|
ask_reason = DecisionReasonWorkingDir(
|
|
reason="Path is outside allowed working directories"
|
|
)
|
|
return PermissionAsk(
|
|
message=(
|
|
f"OpenSpace requested permissions to write to {input_path}, but "
|
|
"you haven't granted it yet."
|
|
),
|
|
blocked_path=input_path,
|
|
suggestions=generate_suggestions(
|
|
tool_name=FILE_EDIT_TOOL_NAME,
|
|
path=input_path,
|
|
context=context,
|
|
precomputed_paths_to_check=paths_to_check,
|
|
operation_type="write",
|
|
),
|
|
decision_reason=ask_reason,
|
|
)
|