Download Code-agent-main/ycode/security/permissions.py from Yazhiniyan/YCode: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/Yazhiniyan/YCode/resolve/main/Code-agent-main/ycode/security/permissions.py
- Command line
-
hf download hf://Yazhiniyan/YCode/Code-agent-main/ycode/security/permissions.py
-
curl -L -o permissions.py https://huggingface.co/Yazhiniyan/YCode/resolve/main/Code-agent-main/ycode/security/permissions.py
19.2 kB
| """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 | |
| class Decision: | |
| level: RiskLevel | |
| reason: str = "" | |
| 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 ---------------------------------------------------------------- | |
| def _tokens(segment: str) -> list[str]: | |
| try: | |
| return shlex.split(segment, posix=True) | |
| except ValueError: | |
| return segment.split() | |
| 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 | |
| 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) | |
| def is_sensitive(path: Path) -> bool: | |
| return bool(_SENSITIVE_NAMES.search(path.name)) | |
| # --------------------------------------------------------------- approvals | |
| 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" | |
| 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" | |