File size: 19,167 Bytes
5ef3875 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 | """Command classification, path confinement and approval handling.
This is a guardrail, not a sandbox: it stops the agent from casually running
destructive commands or touching files outside the project, and makes the
user explicitly approve risky actions. Code the agent writes and then runs
(``python script.py``) is still arbitrary code, exactly as if you ran it.
"""
from __future__ import annotations
import enum
import os
import re
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from ycode.errors import PathSecurityError
class RiskLevel(enum.IntEnum):
SAFE = 0
REQUIRES_APPROVAL = 1
BLOCKED = 2
@dataclass(frozen=True)
class Decision:
level: RiskLevel
reason: str = ""
@property
def allowed_without_asking(self) -> bool:
return self.level is RiskLevel.SAFE
SAFE = Decision(RiskLevel.SAFE)
# --------------------------------------------------------------- vocabulary
# Commands that are always refused.
_BLOCKED_COMMANDS = {
"mkfs", "shutdown", "reboot", "halt", "poweroff", "fdisk", "parted", "format",
"diskpart", "wipefs",
}
# Commands that always need explicit approval.
_APPROVAL_COMMANDS = {
"rm": "deletes files",
"rmdir": "deletes directories",
"shred": "destroys files",
"unlink": "deletes files",
"sudo": "runs with elevated privileges",
"su": "switches user",
"doas": "runs with elevated privileges",
"chmod": "changes file permissions",
"chown": "changes file ownership",
"chgrp": "changes file group",
"mv": "moves/overwrites files",
"dd": "low-level disk/file writes",
"truncate": "truncates files",
"kill": "terminates processes",
"killall": "terminates processes",
"pkill": "terminates processes",
"crontab": "modifies scheduled jobs",
"systemctl": "controls system services",
"service": "controls system services",
"launchctl": "controls system services",
"mount": "mounts filesystems",
"umount": "unmounts filesystems",
"iptables": "changes firewall rules",
"ssh": "connects to remote hosts",
"scp": "copies files to/from remote hosts",
"rsync": "synchronises/deletes files",
"eval": "evaluates arbitrary shell code",
"gh": "acts on GitHub on your behalf",
"reg": "modifies the Windows registry",
"del": "deletes files",
"rd": "deletes directories",
}
# Read-only commands that are fine even in strict mode.
_READ_ONLY = {
"ls", "dir", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "where",
"type", "file", "stat", "du", "df", "tree", "grep", "rg", "ag", "find", "fd",
"sort", "uniq", "cut", "diff", "cmp", "less", "more", "basename", "dirname",
"realpath", "date", "whoami", "uname", "env", "printenv", "true", "false", "test",
}
_GIT_READ_ONLY = {
"status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe",
"shortlog", "grep",
}
_GIT_LISTING = {"branch", "tag", "remote", "stash"}
_GIT_LIST_FLAGS = {"-a", "-r", "-v", "-vv", "--list", "list", "--show-current", "--all"}
_SUBCOMMAND_RULES: dict[str, dict[str, str]] = {
"npm": {"publish": "publishes a package", "unpublish": "unpublishes a package",
"deprecate": "deprecates a package", "owner": "changes package owners"},
"yarn": {"publish": "publishes a package"},
"pnpm": {"publish": "publishes a package"},
"cargo": {"publish": "publishes a crate", "yank": "yanks a crate"},
"twine": {"upload": "publishes a package"},
"pip": {"uninstall": "removes packages"},
"pip3": {"uninstall": "removes packages"},
"docker": {"rm": "removes containers", "rmi": "removes images", "prune": "prunes data",
"kill": "kills containers", "system": "system-level docker operations",
"volume": "manages volumes", "push": "pushes images"},
"kubectl": {"delete": "deletes cluster resources", "apply": "changes cluster resources",
"drain": "drains nodes", "scale": "scales workloads"},
"terraform": {"apply": "changes infrastructure", "destroy": "destroys infrastructure"},
"helm": {"uninstall": "removes releases", "delete": "removes releases"},
}
_WRAPPERS = {"nohup", "time", "nice", "command", "builtin", "exec", "xargs", "timeout", "stdbuf"}
_SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
_SEPARATOR_RE = re.compile(r"\|\||&&|[;|&\n]")
_SUBSTITUTION_RE = re.compile(r"\$\(([^()]*)\)|`([^`]*)`")
_REDIRECT_RE = re.compile(r"(?:^|[^<>&0-9])(?:[0-9]?>{1,2}|&>)\s*([^\s;&|]+)")
_FORK_BOMB_RE = re.compile(r":\s*\(\s*\)\s*\{.*:\s*\|\s*:")
_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
def _max(a: Decision, b: Decision) -> Decision:
return b if b.level > a.level else a
class CommandClassifier:
"""Classifies a shell command line as SAFE, REQUIRES_APPROVAL or BLOCKED."""
def __init__(self, workspace: Path | None = None) -> None:
self.workspace = workspace.resolve() if workspace else None
# Public API -------------------------------------------------------------
def classify(self, command: str) -> Decision:
command = command.strip()
if not command:
return Decision(RiskLevel.BLOCKED, "empty command")
if _FORK_BOMB_RE.search(command):
return Decision(RiskLevel.BLOCKED, "fork bomb")
decision = SAFE
# Command substitutions are classified on their own content.
for match in _SUBSTITUTION_RE.finditer(command):
inner = match.group(1) if match.group(1) is not None else match.group(2)
decision = _max(decision, self.classify(inner) if inner.strip() else SAFE)
if re.search(r"\|\s*(sudo\s+)?(ba|z|da|k)?sh\b", command):
decision = _max(
decision, Decision(RiskLevel.REQUIRES_APPROVAL, "pipes data into a shell")
)
for target in _REDIRECT_RE.findall(command):
decision = _max(decision, self._classify_write_target(target))
for segment in _SEPARATOR_RE.split(command):
if segment.strip():
decision = _max(decision, self._classify_segment(segment))
if decision.level is RiskLevel.BLOCKED:
break
return decision
def is_read_only(self, command: str) -> bool:
"""True when every segment is a known read-only command."""
if _SUBSTITUTION_RE.search(command) or _REDIRECT_RE.search(command):
return False
for segment in _SEPARATOR_RE.split(command):
tokens = self._tokens(segment)
if not tokens:
continue
name = self._command_name(tokens[0])
if name == "git":
rest = [t for t in tokens[1:]]
sub = next((t for t in rest if not t.startswith("-")), "")
after = rest[rest.index(sub) + 1:] if sub in rest else []
if sub in _GIT_READ_ONLY:
continue
if sub in _GIT_LISTING and all(t in _GIT_LIST_FLAGS for t in after):
continue
return False
if name == "find" and any(t in ("-delete", "-exec", "-execdir", "-ok") for t in tokens):
return False
if name not in _READ_ONLY:
return False
return True
# Internals ----------------------------------------------------------------
@staticmethod
def _tokens(segment: str) -> list[str]:
try:
return shlex.split(segment, posix=True)
except ValueError:
return segment.split()
@staticmethod
def _command_name(token: str) -> str:
name = os.path.basename(token).lower()
return name[:-4] if name.endswith(".exe") else name
def _classify_write_target(self, target: str) -> Decision:
if target.startswith("/dev/"):
if target in ("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"):
return SAFE
return Decision(RiskLevel.BLOCKED, f"writes directly to device {target}")
if self.workspace is None:
return SAFE
expanded = os.path.expanduser(os.path.expandvars(target))
path = Path(expanded)
if not path.is_absolute():
path = self.workspace / path
try:
resolved = path.resolve()
except OSError:
return Decision(RiskLevel.REQUIRES_APPROVAL, f"writes to {target}")
if not _is_within(resolved, self.workspace):
return Decision(RiskLevel.REQUIRES_APPROVAL, f"writes outside the project ({target})")
return SAFE
def _classify_segment(self, segment: str) -> Decision:
tokens = self._tokens(segment)
# Strip leading env assignments and harmless wrappers.
while tokens and (_ENV_ASSIGN_RE.match(tokens[0]) or self._command_name(tokens[0]) in _WRAPPERS
or self._command_name(tokens[0]) == "env"):
name = self._command_name(tokens[0])
tokens = tokens[1:]
if name == "timeout" and tokens and re.match(r"^\d", tokens[0]):
tokens = tokens[1:]
while name in ("env", "xargs", "nice", "timeout", "stdbuf") and tokens and tokens[0].startswith("-"):
tokens = tokens[1:]
if not tokens:
return SAFE
name = self._command_name(tokens[0])
args = tokens[1:]
if name in _BLOCKED_COMMANDS or name.startswith("mkfs"):
return Decision(RiskLevel.BLOCKED, f"`{name}` is never run by YCode")
if name in _SHELLS:
if "-c" in args:
idx = args.index("-c")
if idx + 1 < len(args):
return self.classify(args[idx + 1])
return SAFE
if name in ("rm", "del", "rd") or (name == "sudo" and "rm" in args):
targets = [a for a in args if not a.startswith("-")]
recursive = any(
a == "--recursive" or (a.startswith("-") and not a.startswith("--") and "r" in a.lower())
for a in args
)
for target in targets:
if self._is_catastrophic_target(target) and (recursive or name != "rm"):
return Decision(RiskLevel.BLOCKED, f"recursive delete of {target}")
if name == "dd" and any(a.startswith("of=/dev/") for a in args):
return Decision(RiskLevel.BLOCKED, "dd onto a device")
if name == "chmod" and "-R" in args and any(self._is_catastrophic_target(a) for a in args):
return Decision(RiskLevel.BLOCKED, "recursive chmod of a system path")
if any(_SENSITIVE_NAMES.search(os.path.basename(a)) for a in args if not a.startswith("-")):
sensitive = Decision(RiskLevel.REQUIRES_APPROVAL, "may expose secrets (e.g. .env, keys) to the model")
else:
sensitive = SAFE
if name in _APPROVAL_COMMANDS:
return Decision(RiskLevel.REQUIRES_APPROVAL, f"`{name}` {_APPROVAL_COMMANDS[name]}")
if name == "git":
return self._classify_git(args)
if name == "find" and any(a in ("-delete", "-exec", "-execdir", "-ok") for a in args):
return Decision(RiskLevel.REQUIRES_APPROVAL, "`find` with -delete/-exec can modify files")
rules = _SUBCOMMAND_RULES.get(name)
if rules:
sub = next((a for a in args if not a.startswith("-")), "")
if sub in rules:
return Decision(RiskLevel.REQUIRES_APPROVAL, f"`{name} {sub}` {rules[sub]}")
if name in ("curl", "wget") and any(
a in ("-T", "--upload-file", "-d", "--data", "-F", "--form", "-X", "--post-data") for a in args
):
return Decision(RiskLevel.REQUIRES_APPROVAL, f"`{name}` sends data to a remote server")
return sensitive
def _classify_git(self, args: list[str]) -> Decision:
# Skip global options such as `-C dir` or `-c key=value`.
i = 0
while i < len(args) and args[i].startswith("-"):
i += 2 if args[i] in ("-C", "-c", "--git-dir", "--work-tree") else 1
if i >= len(args):
return SAFE
sub, rest = args[i], args[i + 1:]
def approval(reason: str) -> Decision:
return Decision(RiskLevel.REQUIRES_APPROVAL, f"`git {sub}` {reason}")
if sub == "push":
return approval("publishes commits to a remote")
if sub == "reset" and any(a in ("--hard", "--merge", "--keep") for a in rest):
return approval("discards local changes")
if sub == "clean":
return approval("deletes untracked files")
if sub in ("commit", "merge", "rebase", "cherry-pick", "revert", "am"):
return approval("rewrites or adds history")
if sub == "checkout" and ("--" in rest or "." in rest or "-f" in rest or "--force" in rest):
return approval("discards working-tree changes")
if sub == "restore":
return approval("discards working-tree changes")
if sub == "switch" and any(a in ("-f", "--force", "--discard-changes") for a in rest):
return approval("discards working-tree changes")
if sub == "branch" and any(a in ("-D", "-d", "--delete", "-f", "--force", "-M", "-m") for a in rest):
return approval("deletes or rewrites branches")
if sub == "stash" and rest and rest[0] in ("drop", "clear"):
return approval("deletes stashed changes")
if sub == "tag" and any(a in ("-d", "--delete", "-f") for a in rest):
return approval("deletes or moves tags")
if sub in ("filter-branch", "filter-repo", "update-ref", "gc", "prune", "rm", "mv"):
return approval("modifies the repository")
if sub == "reflog" and rest and rest[0] in ("expire", "delete"):
return approval("deletes reflog entries")
if sub == "remote" and rest and rest[0] in ("add", "remove", "rm", "set-url", "rename"):
return approval("changes remotes")
if sub == "config" and "--global" in rest and len([a for a in rest if not a.startswith("-")]) > 1:
return approval("changes global git configuration")
return SAFE
@staticmethod
def _is_catastrophic_target(target: str) -> bool:
cleaned = target.rstrip("/") or "/"
return cleaned in ("/", "/*", "~", "~/*", "$HOME", "${HOME}", "$HOME/*", "*", ".", "..",
"/usr", "/etc", "/bin", "/var", "/home", "/System", "C:", "C:\\")
# ------------------------------------------------------------------- paths
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
_SENSITIVE_NAMES = re.compile(
r"(^\.env(\..+)?$|\.pem$|\.key$|^id_(rsa|dsa|ecdsa|ed25519)$|^\.netrc$|^\.npmrc$|^\.pypirc$|credentials)",
re.IGNORECASE,
)
class PathGuard:
"""Resolves user/model-supplied paths and confines them to the workspace."""
def __init__(self, workspace: Path, *, allow_outside: bool = False) -> None:
self.workspace = workspace.resolve()
self.allow_outside = allow_outside
def resolve(self, raw: str, *, for_write: bool = False) -> Path:
if not isinstance(raw, str) or not raw.strip():
raise PathSecurityError("A non-empty path is required.")
if "\x00" in raw:
raise PathSecurityError("Path contains a NUL byte.")
candidate = Path(os.path.expanduser(raw.strip()))
if not candidate.is_absolute():
candidate = self.workspace / candidate
# resolve(strict=False) follows symlinks for the parts that exist,
# so a symlink pointing outside the project is caught here too.
resolved = candidate.resolve()
if not self.allow_outside and not _is_within(resolved, self.workspace):
raise PathSecurityError(
f"Path '{raw}' is outside the project directory ({self.workspace}). "
"Access outside the workspace is disabled."
)
if for_write and _is_within(resolved, self.workspace / ".git"):
raise PathSecurityError("Writing inside the .git directory is not allowed.")
return resolved
def relative(self, path: Path) -> str:
try:
return path.relative_to(self.workspace).as_posix() or "."
except ValueError:
return str(path)
@staticmethod
def is_sensitive(path: Path) -> bool:
return bool(_SENSITIVE_NAMES.search(path.name))
# --------------------------------------------------------------- approvals
@dataclass(frozen=True)
class ApprovalRequest:
kind: str # "command" | "write" | "read"
subject: str # the command line or file path
reason: str = ""
# Returns "yes", "no" or "always".
Approver = Callable[[ApprovalRequest], str]
def deny_all(_: ApprovalRequest) -> str:
return "no"
@dataclass
class PermissionManager:
"""Decides whether an action may proceed, asking the user when needed."""
workspace: Path
mode: str = "normal" # strict | normal | auto
approver: Approver = deny_all
allow_outside_workspace: bool = False
_always: set[tuple[str, str]] = field(default_factory=set)
def __post_init__(self) -> None:
self.workspace = self.workspace.resolve()
self.classifier = CommandClassifier(self.workspace)
self.paths = PathGuard(self.workspace, allow_outside=self.allow_outside_workspace)
def check_command(self, command: str) -> tuple[bool, Decision]:
"""Returns (allowed, decision). Asks the user when approval is needed."""
decision = self.classifier.classify(command)
if decision.level is RiskLevel.BLOCKED:
return False, decision
if decision.level is RiskLevel.SAFE and self.mode == "strict" and not self.classifier.is_read_only(command):
decision = Decision(RiskLevel.REQUIRES_APPROVAL, "strict mode: commands need approval")
if decision.level is RiskLevel.SAFE:
return True, decision
if self.mode == "auto":
return True, decision
return self._ask(ApprovalRequest("command", command, decision.reason)), decision
def check_write(self, path: Path) -> bool:
if self.mode != "strict":
return True
return self._ask(ApprovalRequest("write", self.paths.relative(path), "strict mode: file writes need approval"))
def check_read(self, path: Path) -> bool:
if not PathGuard.is_sensitive(path) or self.mode == "auto":
return True
return self._ask(ApprovalRequest(
"read", self.paths.relative(path),
"this file may contain secrets that would be sent to the model",
))
def _ask(self, request: ApprovalRequest) -> bool:
key = (request.kind, request.subject)
if key in self._always:
return True
answer = self.approver(request)
if answer == "always":
self._always.add(key)
return True
return answer == "yes"
|