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"