noah-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- noah_code/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Native macOS containment for NOOA's forked CodeAct worker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import ctypes
|
|
7
|
+
import ctypes.util
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import resource
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from multiprocessing.connection import Connection
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MacOSSandboxUnavailable(RuntimeError):
|
|
17
|
+
"""Raised when the native macOS sandbox cannot be installed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _profile_path(path: str) -> str:
|
|
21
|
+
"""Quote an absolute path as a sandbox profile string literal."""
|
|
22
|
+
return json.dumps(os.path.abspath(os.path.expanduser(path)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_macos_profile(read_paths: Iterable[str]) -> str:
|
|
26
|
+
"""Build a deny-by-default profile with read-only interpreter access.
|
|
27
|
+
|
|
28
|
+
File metadata remains visible so Python can resolve imports and symlinks,
|
|
29
|
+
while file contents are readable only below explicitly trusted runtime
|
|
30
|
+
paths. The active repository is intentionally absent: workspace access must
|
|
31
|
+
cross the parent-side approval broker.
|
|
32
|
+
"""
|
|
33
|
+
roots: set[str] = set()
|
|
34
|
+
for path in read_paths:
|
|
35
|
+
if not path:
|
|
36
|
+
continue
|
|
37
|
+
absolute = os.path.abspath(os.path.expanduser(path))
|
|
38
|
+
resolved = os.path.realpath(absolute)
|
|
39
|
+
if os.path.exists(resolved):
|
|
40
|
+
# macOS sandbox profiles match the path used by the operation, not
|
|
41
|
+
# only its canonical target. Keep both sides of symlinks such as
|
|
42
|
+
# /tmp -> /private/tmp and uv's versioned Python aliases.
|
|
43
|
+
roots.update({absolute, resolved})
|
|
44
|
+
roots.update(
|
|
45
|
+
path
|
|
46
|
+
for path in (
|
|
47
|
+
"/System/Library",
|
|
48
|
+
"/usr/lib",
|
|
49
|
+
"/private/var/db/dyld",
|
|
50
|
+
)
|
|
51
|
+
if os.path.exists(path)
|
|
52
|
+
)
|
|
53
|
+
ancestors: set[str] = {"/"}
|
|
54
|
+
for root in roots:
|
|
55
|
+
parent = os.path.dirname(root)
|
|
56
|
+
while parent and parent not in ancestors:
|
|
57
|
+
ancestors.add(parent)
|
|
58
|
+
next_parent = os.path.dirname(parent)
|
|
59
|
+
if next_parent == parent:
|
|
60
|
+
break
|
|
61
|
+
parent = next_parent
|
|
62
|
+
metadata_rules = "\n".join(
|
|
63
|
+
f" (literal {_profile_path(path)})" for path in sorted(ancestors)
|
|
64
|
+
)
|
|
65
|
+
read_rules = "\n".join(f" (subpath {_profile_path(path)})" for path in sorted(roots))
|
|
66
|
+
return f"""(version 1)
|
|
67
|
+
(deny default)
|
|
68
|
+
(allow file-read-metadata
|
|
69
|
+
{metadata_rules}
|
|
70
|
+
{read_rules}
|
|
71
|
+
(literal \"/dev/null\"))
|
|
72
|
+
(allow file-read-data
|
|
73
|
+
{read_rules}
|
|
74
|
+
(literal \"/dev/null\"))
|
|
75
|
+
(allow file-write-data (literal \"/dev/null\"))
|
|
76
|
+
(allow file-ioctl (literal \"/dev/null\"))
|
|
77
|
+
(allow sysctl-read)
|
|
78
|
+
(allow mach-lookup)
|
|
79
|
+
(allow ipc-posix-shm)
|
|
80
|
+
(allow signal (target self))
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _install_native_sandbox(profile: str) -> None:
|
|
85
|
+
library = ctypes.util.find_library("sandbox")
|
|
86
|
+
if not library:
|
|
87
|
+
raise MacOSSandboxUnavailable("libsandbox is not available")
|
|
88
|
+
sandbox = ctypes.CDLL(library)
|
|
89
|
+
sandbox.sandbox_init.argtypes = [
|
|
90
|
+
ctypes.c_char_p,
|
|
91
|
+
ctypes.c_uint64,
|
|
92
|
+
ctypes.POINTER(ctypes.c_char_p),
|
|
93
|
+
]
|
|
94
|
+
sandbox.sandbox_init.restype = ctypes.c_int
|
|
95
|
+
error = ctypes.c_char_p()
|
|
96
|
+
result = sandbox.sandbox_init(profile.encode(), 0, ctypes.byref(error))
|
|
97
|
+
if result == 0:
|
|
98
|
+
return
|
|
99
|
+
message = error.value.decode(errors="replace") if error.value else "unknown error"
|
|
100
|
+
free_error = getattr(sandbox, "sandbox_free_error", None)
|
|
101
|
+
if free_error is not None and error.value:
|
|
102
|
+
free_error(error)
|
|
103
|
+
raise MacOSSandboxUnavailable(f"sandbox_init failed: {message}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _apply_resource_limits(*, max_memory_mb: int, max_cpu_seconds: int) -> None:
|
|
107
|
+
# macOS maps shared regions into a normal Python process at virtual sizes
|
|
108
|
+
# far beyond RLIMIT_AS, while RLIMIT_DATA cannot be lowered reliably below
|
|
109
|
+
# the inherited process footprint. Keep the parent-enforced wall timeout
|
|
110
|
+
# and CPU limit; the argument remains explicit so this difference cannot be
|
|
111
|
+
# mistaken for Linux's enforceable address-space cap.
|
|
112
|
+
_ = max_memory_mb
|
|
113
|
+
if max_cpu_seconds > 0:
|
|
114
|
+
limit = max_cpu_seconds
|
|
115
|
+
_, hard = resource.getrlimit(resource.RLIMIT_CPU)
|
|
116
|
+
if hard != resource.RLIM_INFINITY:
|
|
117
|
+
limit = min(limit, hard)
|
|
118
|
+
resource.setrlimit(resource.RLIMIT_CPU, (limit, limit))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def macos_worker_main(
|
|
122
|
+
conn: Connection,
|
|
123
|
+
init: dict[str, Any],
|
|
124
|
+
profile: str,
|
|
125
|
+
max_memory_mb: int,
|
|
126
|
+
max_cpu_seconds: int,
|
|
127
|
+
) -> None: # pragma: no cover - executed in a forked worker
|
|
128
|
+
"""Install irreversible guards, then enter NOOA's normal worker loop."""
|
|
129
|
+
try:
|
|
130
|
+
_apply_resource_limits(
|
|
131
|
+
max_memory_mb=max_memory_mb,
|
|
132
|
+
max_cpu_seconds=max_cpu_seconds,
|
|
133
|
+
)
|
|
134
|
+
_install_native_sandbox(profile)
|
|
135
|
+
except BaseException as exc: # noqa: BLE001 - child must fail closed
|
|
136
|
+
with contextlib.suppress(Exception):
|
|
137
|
+
conn.send({"type": "fatal", "error": f"{type(exc).__name__}: {exc}"})
|
|
138
|
+
os._exit(3)
|
|
139
|
+
|
|
140
|
+
from nooa.runtime.sandbox.worker import worker_main
|
|
141
|
+
|
|
142
|
+
worker_main(conn, init)
|
noah_code/mcp_setup.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Optional MCP server attachment for CodingAgent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from noah_code.approvals import ApprovalBroker
|
|
9
|
+
from noah_code.config import NoahCodeConfig
|
|
10
|
+
from noah_code.permissions import PermissionCategory, PermissionEngine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def mcp_config_paths(workspace: Path) -> list[Path]:
|
|
14
|
+
return [
|
|
15
|
+
workspace / ".mcp.json",
|
|
16
|
+
workspace / ".noah-code" / "mcp.json",
|
|
17
|
+
Path.home() / ".config" / "noah-code" / "mcp.json",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def install_mcp(
|
|
22
|
+
agent: Any,
|
|
23
|
+
workspace: Path,
|
|
24
|
+
config: NoahCodeConfig,
|
|
25
|
+
*,
|
|
26
|
+
engine: PermissionEngine,
|
|
27
|
+
approvals: ApprovalBroker,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Attach configured MCP servers as agent attributes when available.
|
|
30
|
+
|
|
31
|
+
Permission category ``mcp`` is checked before first use via a thin wrapper
|
|
32
|
+
only if we can gate at attach time; otherwise servers are attached and
|
|
33
|
+
documented as requiring the mcp permission policy.
|
|
34
|
+
"""
|
|
35
|
+
servers = dict(config.mcp.get("servers") or {})
|
|
36
|
+
mcp_file: Path | None = None
|
|
37
|
+
for path in mcp_config_paths(workspace):
|
|
38
|
+
if path.is_file():
|
|
39
|
+
mcp_file = path
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if not servers and mcp_file is None:
|
|
43
|
+
return "mcp: none configured"
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from nooa.mcp import MCPManager
|
|
47
|
+
except ImportError:
|
|
48
|
+
return "mcp: nooa[mcp] not installed"
|
|
49
|
+
|
|
50
|
+
decision = engine.decide(PermissionCategory.MCP, "*")
|
|
51
|
+
if decision.denied:
|
|
52
|
+
return f"mcp: denied ({decision.reason})"
|
|
53
|
+
if decision.needs_ask:
|
|
54
|
+
try:
|
|
55
|
+
await approvals.require(decision)
|
|
56
|
+
except PermissionError as exc:
|
|
57
|
+
return f"mcp: {exc}"
|
|
58
|
+
|
|
59
|
+
attached: list[str] = []
|
|
60
|
+
try:
|
|
61
|
+
names = list(servers.keys()) if servers else MCPManager.list_servers(mcp_file=mcp_file)
|
|
62
|
+
except Exception as exc: # noqa: BLE001
|
|
63
|
+
return f"mcp: list failed ({exc})"
|
|
64
|
+
|
|
65
|
+
for name in names:
|
|
66
|
+
try:
|
|
67
|
+
spec = servers.get(name, {})
|
|
68
|
+
tool = MCPManager.create_from_server(
|
|
69
|
+
name,
|
|
70
|
+
mcp_file=mcp_file,
|
|
71
|
+
**{k: v for k, v in spec.items() if k != "name"},
|
|
72
|
+
)
|
|
73
|
+
# Sanitize attribute name.
|
|
74
|
+
attr = re_attr(name)
|
|
75
|
+
setattr(agent, attr, tool)
|
|
76
|
+
approved = getattr(agent, "_sandbox_approved_roots", None)
|
|
77
|
+
if isinstance(approved, set):
|
|
78
|
+
approved.add(attr)
|
|
79
|
+
attached.append(attr)
|
|
80
|
+
except Exception as exc: # noqa: BLE001
|
|
81
|
+
return f"mcp: attached={attached} error on {name}: {exc}"
|
|
82
|
+
return f"mcp: attached={attached}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def re_attr(name: str) -> str:
|
|
86
|
+
import re
|
|
87
|
+
|
|
88
|
+
cleaned = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
|
89
|
+
if cleaned and cleaned[0].isdigit():
|
|
90
|
+
cleaned = f"mcp_{cleaned}"
|
|
91
|
+
return cleaned or "mcp_server"
|
noah_code/permissions.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Deterministic allow/ask/deny permission engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from noah_code.config import PermissionRule
|
|
14
|
+
|
|
15
|
+
PermissionAction = Literal["allow", "ask", "deny"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionCategory(StrEnum):
|
|
19
|
+
READ = "read"
|
|
20
|
+
EDIT = "edit"
|
|
21
|
+
BASH = "bash"
|
|
22
|
+
EXTERNAL_DIRECTORY = "external_directory"
|
|
23
|
+
TASK = "task"
|
|
24
|
+
SKILL = "skill"
|
|
25
|
+
MCP = "mcp"
|
|
26
|
+
LSP = "lsp"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Patterns that are always denied regardless of mode / auto.
|
|
30
|
+
_ALWAYS_DENY_BASH = (
|
|
31
|
+
re.compile(r"\brm\s+(-[^\s]*\s+)*-?[rR]?[fF]?[rR]?[fF]?\s+(/|\.|~|\*)"),
|
|
32
|
+
re.compile(r"\brm\s+.*\s+(-[^\s]*r|-rf|-fr)\b"),
|
|
33
|
+
re.compile(r"\b(mkfs|dd\s+if=|/dev/sd|/dev/disk|shred\b|wipefs\b)\b"),
|
|
34
|
+
re.compile(r":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;:"), # fork bomb
|
|
35
|
+
re.compile(r"\bgit\s+push\b"),
|
|
36
|
+
re.compile(r"\bgit\s+clean\b"),
|
|
37
|
+
re.compile(r"\bgit\s+reset\s+--hard\b"),
|
|
38
|
+
re.compile(r"\bgit\s+filter-branch\b"),
|
|
39
|
+
re.compile(r"\bprintenv\b"),
|
|
40
|
+
re.compile(r"\benv\b(?!\s+\w+=)"),
|
|
41
|
+
re.compile(r"\bexport\s+-p\b"),
|
|
42
|
+
re.compile(r"\bcat\s+.*\.pem\b"),
|
|
43
|
+
re.compile(r"\bcat\s+.*id_rsa\b"),
|
|
44
|
+
re.compile(r"\bchmod\s+-R\s+777\b"),
|
|
45
|
+
re.compile(r"\bcurl\b.*\|\s*(ba)?sh\b"),
|
|
46
|
+
re.compile(r"\bwget\b.*\|\s*(ba)?sh\b"),
|
|
47
|
+
re.compile(r"\bsudo\b"),
|
|
48
|
+
re.compile(r"\bchmod\b.+\s+/"),
|
|
49
|
+
re.compile(r"\bchown\b.+\s+/"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Mutating patterns that always require ask (even if a broad allow matched earlier
|
|
53
|
+
# via auto) unless already an explicit session allow - handled by forcing ask
|
|
54
|
+
# when compound/uncertain; these bump deny-adjacent risk to ask minimum.
|
|
55
|
+
_ALWAYS_ASK_BASH = (
|
|
56
|
+
re.compile(r"\brm\b"),
|
|
57
|
+
re.compile(r"\bmv\b"),
|
|
58
|
+
re.compile(r"\bchmod\b"),
|
|
59
|
+
re.compile(r"\bchown\b"),
|
|
60
|
+
re.compile(r"\bkill\b"),
|
|
61
|
+
re.compile(r"\bpkill\b"),
|
|
62
|
+
re.compile(r"\bdocker\s+(rm|rmi|system\s+prune)\b"),
|
|
63
|
+
re.compile(r"\bnpm\s+(publish|unpublish)\b"),
|
|
64
|
+
re.compile(r"\bpip\s+install\b"),
|
|
65
|
+
re.compile(r"\buv\s+pip\s+install\b"),
|
|
66
|
+
re.compile(r"\bcurl\b"),
|
|
67
|
+
re.compile(r"\bwget\b"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_MUTATING_GIT = re.compile(
|
|
71
|
+
r"\bgit\s+(commit|add|push|pull|fetch|rebase|merge|reset|clean|checkout|stash|tag|remote)\b"
|
|
72
|
+
)
|
|
73
|
+
_READ_ONLY_PREFIXES = (
|
|
74
|
+
"git status",
|
|
75
|
+
"git diff",
|
|
76
|
+
"git log",
|
|
77
|
+
"git show",
|
|
78
|
+
"git branch",
|
|
79
|
+
"git rev-parse",
|
|
80
|
+
"rg ",
|
|
81
|
+
"grep ",
|
|
82
|
+
"egrep ",
|
|
83
|
+
"fgrep ",
|
|
84
|
+
"find ",
|
|
85
|
+
"ls ",
|
|
86
|
+
"pwd",
|
|
87
|
+
"head ",
|
|
88
|
+
"tail ",
|
|
89
|
+
"wc ",
|
|
90
|
+
"file ",
|
|
91
|
+
"stat ",
|
|
92
|
+
"test ",
|
|
93
|
+
"pytest --collect-only",
|
|
94
|
+
"python -m pytest --collect-only",
|
|
95
|
+
)
|
|
96
|
+
_READ_ONLY_GIT_SUBCOMMANDS = frozenset({"branch", "diff", "log", "rev-parse", "show", "status"})
|
|
97
|
+
|
|
98
|
+
_SECRET_BASENAMES = {
|
|
99
|
+
".env",
|
|
100
|
+
".env.local",
|
|
101
|
+
".env.production",
|
|
102
|
+
".env.development",
|
|
103
|
+
"credentials.json",
|
|
104
|
+
"service-account.json",
|
|
105
|
+
"id_rsa",
|
|
106
|
+
"id_ed25519",
|
|
107
|
+
"id_ecdsa",
|
|
108
|
+
}
|
|
109
|
+
_SECRET_SUFFIXES = (".pem", ".key", ".p12", ".pfx")
|
|
110
|
+
_SECRET_ALLOW = {".env.example", ".env.sample", ".env.template"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class PermissionDecision:
|
|
115
|
+
category: str
|
|
116
|
+
target: str
|
|
117
|
+
action: PermissionAction
|
|
118
|
+
matching_rule: PermissionRule | None
|
|
119
|
+
reason: str
|
|
120
|
+
remember_pattern: str
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def allowed(self) -> bool:
|
|
124
|
+
return self.action == "allow"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def denied(self) -> bool:
|
|
128
|
+
return self.action == "deny"
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def needs_ask(self) -> bool:
|
|
132
|
+
return self.action == "ask"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_secret_path(path: str | Path) -> bool:
|
|
136
|
+
p = Path(path)
|
|
137
|
+
name = p.name
|
|
138
|
+
if name in _SECRET_ALLOW:
|
|
139
|
+
return False
|
|
140
|
+
if name in _SECRET_BASENAMES:
|
|
141
|
+
return True
|
|
142
|
+
if name.startswith(".env.") and name not in _SECRET_ALLOW:
|
|
143
|
+
return True
|
|
144
|
+
if any(name.endswith(suf) for suf in _SECRET_SUFFIXES):
|
|
145
|
+
return True
|
|
146
|
+
if "id_rsa" in name or "id_ed25519" in name:
|
|
147
|
+
return True
|
|
148
|
+
parts = p.parts
|
|
149
|
+
if ".git" in parts:
|
|
150
|
+
return True
|
|
151
|
+
return name.endswith(".db") and "noah-code" in str(p)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _match_rule(rule: PermissionRule, category: str, target: str) -> bool:
|
|
155
|
+
cat_ok = rule.category in {"*", category}
|
|
156
|
+
if not cat_ok:
|
|
157
|
+
return False
|
|
158
|
+
return fnmatch.fnmatch(target, rule.pattern) or fnmatch.fnmatch(Path(target).name, rule.pattern)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PermissionEngine:
|
|
162
|
+
"""Ordered wildcard rules; last matching rule wins."""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
rules: list[PermissionRule] | None = None,
|
|
167
|
+
*,
|
|
168
|
+
mode: Literal["build", "plan"] = "build",
|
|
169
|
+
auto_approve: bool = False,
|
|
170
|
+
) -> None:
|
|
171
|
+
self.rules: list[PermissionRule] = list(rules or [])
|
|
172
|
+
self.mode = mode
|
|
173
|
+
self.auto_approve = auto_approve
|
|
174
|
+
self._session_rules: list[PermissionRule] = []
|
|
175
|
+
|
|
176
|
+
def add_session_rule(self, rule: PermissionRule) -> None:
|
|
177
|
+
self._session_rules.append(rule)
|
|
178
|
+
|
|
179
|
+
def snapshot_session_rules(self) -> list[dict]:
|
|
180
|
+
return [r.model_dump() for r in self._session_rules]
|
|
181
|
+
|
|
182
|
+
def load_session_rules(self, raw: list[dict] | None) -> None:
|
|
183
|
+
self._session_rules = [PermissionRule.model_validate(r) for r in (raw or [])]
|
|
184
|
+
|
|
185
|
+
def decide(self, category: str, target: str) -> PermissionDecision:
|
|
186
|
+
normalized = target.strip() or "*"
|
|
187
|
+
# Hard denies for secrets on read/edit.
|
|
188
|
+
if (
|
|
189
|
+
category in {PermissionCategory.READ, PermissionCategory.EDIT}
|
|
190
|
+
and is_secret_path(normalized)
|
|
191
|
+
and Path(normalized).name not in _SECRET_ALLOW
|
|
192
|
+
):
|
|
193
|
+
return PermissionDecision(
|
|
194
|
+
category=category,
|
|
195
|
+
target=normalized,
|
|
196
|
+
action="deny",
|
|
197
|
+
matching_rule=None,
|
|
198
|
+
reason="secret or credential path denied",
|
|
199
|
+
remember_pattern=normalized,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if category == PermissionCategory.BASH:
|
|
203
|
+
hard = self._hard_bash_deny(normalized)
|
|
204
|
+
if hard is not None:
|
|
205
|
+
return hard
|
|
206
|
+
|
|
207
|
+
if self.mode == "plan":
|
|
208
|
+
plan = self._plan_mode_gate(category, normalized)
|
|
209
|
+
if plan is not None:
|
|
210
|
+
return plan
|
|
211
|
+
|
|
212
|
+
matching: PermissionRule | None = None
|
|
213
|
+
for rule in [*self.rules, *self._session_rules]:
|
|
214
|
+
if _match_rule(rule, category, normalized):
|
|
215
|
+
matching = rule
|
|
216
|
+
|
|
217
|
+
if matching is None:
|
|
218
|
+
action: PermissionAction = "ask"
|
|
219
|
+
reason = "no matching rule; default ask"
|
|
220
|
+
else:
|
|
221
|
+
action = matching.action
|
|
222
|
+
reason = matching.reason or f"matched {matching.pattern}"
|
|
223
|
+
|
|
224
|
+
if category == PermissionCategory.BASH and action == "allow":
|
|
225
|
+
elevated = self._elevated_bash_ask(normalized)
|
|
226
|
+
if elevated is not None:
|
|
227
|
+
action = elevated.action
|
|
228
|
+
reason = elevated.reason
|
|
229
|
+
matching = elevated.matching_rule
|
|
230
|
+
|
|
231
|
+
if action == "ask" and self.auto_approve:
|
|
232
|
+
# --auto never overrides explicit deny; only ask → allow.
|
|
233
|
+
action = "allow"
|
|
234
|
+
reason = f"{reason} (auto-approved)"
|
|
235
|
+
|
|
236
|
+
return PermissionDecision(
|
|
237
|
+
category=category,
|
|
238
|
+
target=normalized,
|
|
239
|
+
action=action,
|
|
240
|
+
matching_rule=matching,
|
|
241
|
+
reason=reason,
|
|
242
|
+
remember_pattern=self._remember_pattern(category, normalized),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _remember_pattern(self, category: str, target: str) -> str:
|
|
246
|
+
if category == PermissionCategory.BASH:
|
|
247
|
+
try:
|
|
248
|
+
parts = shlex.split(target)
|
|
249
|
+
except ValueError:
|
|
250
|
+
parts = target.split()
|
|
251
|
+
if parts:
|
|
252
|
+
return f"{parts[0]} *"
|
|
253
|
+
return "*"
|
|
254
|
+
return target
|
|
255
|
+
|
|
256
|
+
def _hard_bash_deny(self, command: str) -> PermissionDecision | None:
|
|
257
|
+
try:
|
|
258
|
+
tokens = shlex.split(command)
|
|
259
|
+
except ValueError:
|
|
260
|
+
tokens = []
|
|
261
|
+
|
|
262
|
+
for index, token in enumerate(tokens):
|
|
263
|
+
if Path(token).name != "git":
|
|
264
|
+
continue
|
|
265
|
+
remaining = tokens[index + 1 :]
|
|
266
|
+
if any(part in {"push", "clean", "filter-branch"} for part in remaining) or (
|
|
267
|
+
"reset" in remaining and "--hard" in remaining
|
|
268
|
+
):
|
|
269
|
+
return PermissionDecision(
|
|
270
|
+
category=PermissionCategory.BASH,
|
|
271
|
+
target=command,
|
|
272
|
+
action="deny",
|
|
273
|
+
matching_rule=None,
|
|
274
|
+
reason="destructive git command denied",
|
|
275
|
+
remember_pattern=command,
|
|
276
|
+
)
|
|
277
|
+
if self.auto_approve and not self.is_readonly_command(command):
|
|
278
|
+
return PermissionDecision(
|
|
279
|
+
category=PermissionCategory.BASH,
|
|
280
|
+
target=command,
|
|
281
|
+
action="deny",
|
|
282
|
+
matching_rule=None,
|
|
283
|
+
reason="mutating or unrecognized git commands are not auto-approved",
|
|
284
|
+
remember_pattern=command,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
for pat in _ALWAYS_DENY_BASH:
|
|
288
|
+
if pat.search(command):
|
|
289
|
+
return PermissionDecision(
|
|
290
|
+
category=PermissionCategory.BASH,
|
|
291
|
+
target=command,
|
|
292
|
+
action="deny",
|
|
293
|
+
matching_rule=None,
|
|
294
|
+
reason="destructive or secret-exposing command denied",
|
|
295
|
+
remember_pattern=command,
|
|
296
|
+
)
|
|
297
|
+
lowered = command.lower()
|
|
298
|
+
if "aws_secret" in lowered or "private key" in lowered:
|
|
299
|
+
return PermissionDecision(
|
|
300
|
+
category=PermissionCategory.BASH,
|
|
301
|
+
target=command,
|
|
302
|
+
action="deny",
|
|
303
|
+
matching_rule=None,
|
|
304
|
+
reason="credential dump denied",
|
|
305
|
+
remember_pattern=command,
|
|
306
|
+
)
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def _elevated_bash_ask(self, command: str) -> PermissionDecision | None:
|
|
310
|
+
"""Force ask for risky commands unless a session allow rule already matched."""
|
|
311
|
+
session_allowed = any(
|
|
312
|
+
r.action == "allow" and _match_rule(r, PermissionCategory.BASH, command)
|
|
313
|
+
for r in self._session_rules
|
|
314
|
+
)
|
|
315
|
+
if session_allowed:
|
|
316
|
+
return None
|
|
317
|
+
for pat in _ALWAYS_ASK_BASH:
|
|
318
|
+
if pat.search(command):
|
|
319
|
+
return PermissionDecision(
|
|
320
|
+
category=PermissionCategory.BASH,
|
|
321
|
+
target=command,
|
|
322
|
+
action="ask",
|
|
323
|
+
matching_rule=None,
|
|
324
|
+
reason="elevated-risk shell command requires approval",
|
|
325
|
+
remember_pattern=self._remember_pattern(PermissionCategory.BASH, command),
|
|
326
|
+
)
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def _plan_mode_gate(self, category: str, target: str) -> PermissionDecision | None:
|
|
330
|
+
if category == PermissionCategory.EDIT:
|
|
331
|
+
return PermissionDecision(
|
|
332
|
+
category=category,
|
|
333
|
+
target=target,
|
|
334
|
+
action="deny",
|
|
335
|
+
matching_rule=None,
|
|
336
|
+
reason="plan mode forbids file edits",
|
|
337
|
+
remember_pattern=target,
|
|
338
|
+
)
|
|
339
|
+
if category == PermissionCategory.BASH and not self.is_readonly_command(target):
|
|
340
|
+
return PermissionDecision(
|
|
341
|
+
category=category,
|
|
342
|
+
target=target,
|
|
343
|
+
action="deny",
|
|
344
|
+
matching_rule=None,
|
|
345
|
+
reason="plan mode forbids mutating shell commands",
|
|
346
|
+
remember_pattern=target,
|
|
347
|
+
)
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def is_readonly_command(command: str) -> bool:
|
|
352
|
+
cmd = command.strip()
|
|
353
|
+
if not cmd:
|
|
354
|
+
return True
|
|
355
|
+
if _is_compound(cmd):
|
|
356
|
+
return False
|
|
357
|
+
lowered = cmd.lower()
|
|
358
|
+
if _MUTATING_GIT.search(lowered):
|
|
359
|
+
return False
|
|
360
|
+
try:
|
|
361
|
+
tokens = shlex.split(cmd)
|
|
362
|
+
except ValueError:
|
|
363
|
+
return False
|
|
364
|
+
if not tokens:
|
|
365
|
+
return True
|
|
366
|
+
program = Path(tokens[0]).name.lower()
|
|
367
|
+
if program == "git":
|
|
368
|
+
return len(tokens) > 1 and tokens[1].lower() in _READ_ONLY_GIT_SUBCOMMANDS
|
|
369
|
+
if program == "pwd":
|
|
370
|
+
return len(tokens) == 1
|
|
371
|
+
return any(lowered == p.strip() or lowered.startswith(p) for p in _READ_ONLY_PREFIXES[6:])
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def is_uncertain_shell(command: str) -> bool:
|
|
375
|
+
return _is_compound(command)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_compound(command: str) -> bool:
|
|
379
|
+
"""Detect pipes, chains, redirects, substitutions, heredocs."""
|
|
380
|
+
# Rough conservative scan - not a full shell parser.
|
|
381
|
+
specials = ("|", "&&", "||", ";", "`", "$(", "${", ">", "<", "<<", "\n")
|
|
382
|
+
in_single = False
|
|
383
|
+
in_double = False
|
|
384
|
+
i = 0
|
|
385
|
+
while i < len(command):
|
|
386
|
+
ch = command[i]
|
|
387
|
+
if ch == "'" and not in_double:
|
|
388
|
+
in_single = not in_single
|
|
389
|
+
elif ch == '"' and not in_single:
|
|
390
|
+
in_double = not in_double
|
|
391
|
+
elif not in_single and not in_double:
|
|
392
|
+
for sp in specials:
|
|
393
|
+
if command.startswith(sp, i):
|
|
394
|
+
return True
|
|
395
|
+
i += 1
|
|
396
|
+
try:
|
|
397
|
+
shlex.split(command)
|
|
398
|
+
except ValueError:
|
|
399
|
+
return True
|
|
400
|
+
return False
|