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
noah_code/__init__.py
ADDED
noah_code/__main__.py
ADDED
noah_code/agent.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""CodingAgent - InteractiveAgent for repository work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import multiprocessing as mp
|
|
7
|
+
import platform
|
|
8
|
+
import site
|
|
9
|
+
import sys
|
|
10
|
+
import sysconfig
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated, Any, Literal
|
|
14
|
+
|
|
15
|
+
from nooa import Context, hidden, strategy
|
|
16
|
+
from nooa.config import CodeActConfig
|
|
17
|
+
from nooa.interactive import (
|
|
18
|
+
InteractiveAgent,
|
|
19
|
+
RespondResult,
|
|
20
|
+
SummarizationConfig,
|
|
21
|
+
install_summarizer,
|
|
22
|
+
)
|
|
23
|
+
from nooa.runtime.restrictions import RESTRICTED_MODULES, RestrictionsConfig
|
|
24
|
+
from nooa.runtime.sandbox.config import FileRule, SandboxConfig, resolve_spec
|
|
25
|
+
from nooa.runtime.sandbox.executor import SandboxedExecutor
|
|
26
|
+
from nooa.strategies import CodeActStrategy
|
|
27
|
+
from nooa.tools import TodoManager
|
|
28
|
+
from nooa.tools.shell_tools import ShellTools
|
|
29
|
+
|
|
30
|
+
from noah_code.approvals import ApprovalBroker
|
|
31
|
+
from noah_code.config import NoahCodeConfig
|
|
32
|
+
from noah_code.macos_sandbox import build_macos_profile, macos_worker_main
|
|
33
|
+
from noah_code.permissions import PermissionEngine
|
|
34
|
+
from noah_code.snapshots import SnapshotJournal
|
|
35
|
+
from noah_code.tools.git_tools import GitTools
|
|
36
|
+
from noah_code.tools.workspace_tools import WorkspaceTools
|
|
37
|
+
from noah_code.workspace import Workspace
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _interpreter_read_rules() -> tuple[FileRule, ...]:
|
|
41
|
+
"""Paths needed by the sandboxed interpreter, excluding host data such as /etc."""
|
|
42
|
+
candidates: list[str] = [sys.prefix, sys.base_prefix, sys.exec_prefix]
|
|
43
|
+
for key in ("stdlib", "platstdlib", "purelib", "platlib"):
|
|
44
|
+
value = sysconfig.get_path(key)
|
|
45
|
+
if value:
|
|
46
|
+
candidates.append(value)
|
|
47
|
+
with suppress(AttributeError): # pragma: no cover - implementation-specific Python
|
|
48
|
+
candidates.extend(site.getsitepackages())
|
|
49
|
+
|
|
50
|
+
seen: set[str] = set()
|
|
51
|
+
rules: list[FileRule] = []
|
|
52
|
+
for raw in candidates:
|
|
53
|
+
expanded = Path(raw).expanduser().absolute()
|
|
54
|
+
for path in (str(expanded), str(expanded.resolve())):
|
|
55
|
+
if path not in seen and Path(path).exists():
|
|
56
|
+
seen.add(path)
|
|
57
|
+
rules.append(FileRule(path=path, access="read"))
|
|
58
|
+
if Path("/dev/null").exists():
|
|
59
|
+
rules.append(FileRule(path="/dev/null", access="read_write"))
|
|
60
|
+
return tuple(rules)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _codeact_config(config: NoahCodeConfig) -> CodeActConfig:
|
|
64
|
+
unsafe = config.unsafe_inprocess_code_execution
|
|
65
|
+
restricted_imports = RESTRICTED_MODULES | frozenset({"nooa", "nooa_cli", "noah_code"})
|
|
66
|
+
return CodeActConfig(
|
|
67
|
+
max_iterations=config.max_iterations,
|
|
68
|
+
cell_timeout=config.cell_timeout,
|
|
69
|
+
execution_backend="inprocess" if unsafe else "sandbox",
|
|
70
|
+
restrictions=RestrictionsConfig(restricted_imports=restricted_imports),
|
|
71
|
+
sandbox=SandboxConfig(
|
|
72
|
+
filesystem=True,
|
|
73
|
+
workspace=None,
|
|
74
|
+
allow=_interpreter_read_rules(),
|
|
75
|
+
system_paths=False,
|
|
76
|
+
network=False,
|
|
77
|
+
max_memory_mb=512,
|
|
78
|
+
max_cpu_seconds=max(1, int(config.cell_timeout)),
|
|
79
|
+
require=True,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _PermissionSandboxedExecutor(SandboxedExecutor):
|
|
85
|
+
"""Broker only permission-gated agent capabilities back into the parent."""
|
|
86
|
+
|
|
87
|
+
_EXACT_PATHS = frozenset(
|
|
88
|
+
{
|
|
89
|
+
("git", "diff"),
|
|
90
|
+
("git", "log"),
|
|
91
|
+
("git", "status"),
|
|
92
|
+
("message",),
|
|
93
|
+
("mode",),
|
|
94
|
+
("workspace_root",),
|
|
95
|
+
("ws", "list_files"),
|
|
96
|
+
("ws", "read"),
|
|
97
|
+
("ws", "replace"),
|
|
98
|
+
("ws", "run"),
|
|
99
|
+
("ws", "search"),
|
|
100
|
+
("ws", "write_file"),
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
_SAFE_SUBTREES = frozenset({("todos",), ("v",)})
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def _path_allowed(cls, path: tuple[str, ...]) -> bool:
|
|
107
|
+
if any(path[: len(prefix)] == prefix for prefix in cls._SAFE_SUBTREES):
|
|
108
|
+
return True
|
|
109
|
+
return path in cls._EXACT_PATHS or any(
|
|
110
|
+
allowed[: len(path)] == path for allowed in cls._EXACT_PATHS
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _walk_path(self, path: list[str]) -> Any:
|
|
114
|
+
normalized = tuple(path)
|
|
115
|
+
approved_roots = getattr(self._agent, "_sandbox_approved_roots", set())
|
|
116
|
+
dynamically_allowed = bool(normalized and normalized[0] in approved_roots)
|
|
117
|
+
if not normalized or not (self._path_allowed(normalized) or dynamically_allowed):
|
|
118
|
+
display = ".".join(path) or "<root>"
|
|
119
|
+
raise PermissionError(f"sandbox broker access denied: self.{display}")
|
|
120
|
+
return super()._walk_path(path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _MacOSPermissionSandboxedExecutor(_PermissionSandboxedExecutor):
|
|
124
|
+
"""NOOA brokered worker contained with macOS's native sandbox profile."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
agent: Any,
|
|
129
|
+
config: SandboxConfig,
|
|
130
|
+
*,
|
|
131
|
+
cell_timeout: float | None,
|
|
132
|
+
framework_builtins: dict[str, Any] | None = None,
|
|
133
|
+
restrictions: Any = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
# NOOA 0.0.8 probes specifically for Linux Landlock/seccomp. Preserve
|
|
136
|
+
# its worker and broker implementation, but install equivalent native
|
|
137
|
+
# macOS guards in the child before NOOA enters its execution loop.
|
|
138
|
+
worker_config = config.model_copy(
|
|
139
|
+
update={
|
|
140
|
+
"filesystem": False,
|
|
141
|
+
"network": True,
|
|
142
|
+
"max_memory_mb": 0,
|
|
143
|
+
"max_cpu_seconds": 0,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
self._agent = agent
|
|
147
|
+
self._config = worker_config
|
|
148
|
+
self._cell_timeout = cell_timeout
|
|
149
|
+
self._framework_builtins = framework_builtins or {}
|
|
150
|
+
self._restrictions = restrictions
|
|
151
|
+
self._spec = resolve_spec(worker_config)
|
|
152
|
+
self._degraded: list[str] = []
|
|
153
|
+
self._ctx = mp.get_context(config.start_method)
|
|
154
|
+
self._proc: mp.process.BaseProcess | None = None
|
|
155
|
+
self._conn: Any = None
|
|
156
|
+
self._lock = asyncio.Lock()
|
|
157
|
+
self._req_id = 0
|
|
158
|
+
self._closed = False
|
|
159
|
+
self._disabled = False
|
|
160
|
+
self._macos_profile = build_macos_profile(rule.path for rule in config.allow)
|
|
161
|
+
self._macos_max_memory_mb = config.max_memory_mb
|
|
162
|
+
self._macos_max_cpu_seconds = config.max_cpu_seconds
|
|
163
|
+
|
|
164
|
+
def _start_worker(self) -> None:
|
|
165
|
+
parent_conn, child_conn = self._ctx.Pipe(duplex=True)
|
|
166
|
+
init = {
|
|
167
|
+
"agent": self._agent,
|
|
168
|
+
"framework_builtins": self._framework_builtins,
|
|
169
|
+
"restrictions": self._restrictions,
|
|
170
|
+
"spec": self._spec,
|
|
171
|
+
}
|
|
172
|
+
proc = self._ctx.Process(
|
|
173
|
+
target=macos_worker_main,
|
|
174
|
+
args=(
|
|
175
|
+
child_conn,
|
|
176
|
+
init,
|
|
177
|
+
self._macos_profile,
|
|
178
|
+
self._macos_max_memory_mb,
|
|
179
|
+
self._macos_max_cpu_seconds,
|
|
180
|
+
),
|
|
181
|
+
daemon=True,
|
|
182
|
+
name="nooa-macos-sandbox-worker",
|
|
183
|
+
)
|
|
184
|
+
proc.start()
|
|
185
|
+
child_conn.close()
|
|
186
|
+
self._conn = parent_conn
|
|
187
|
+
self._proc = proc
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _PermissionCodeActStrategy(CodeActStrategy):
|
|
191
|
+
def _create_sandbox_executor(self, runtime: Any, call: Any, builtins: dict[str, Any]) -> Any:
|
|
192
|
+
framework_builtins = {**builtins, "_call": call}
|
|
193
|
+
executor_type = (
|
|
194
|
+
_MacOSPermissionSandboxedExecutor
|
|
195
|
+
if platform.system() == "Darwin"
|
|
196
|
+
else _PermissionSandboxedExecutor
|
|
197
|
+
)
|
|
198
|
+
return executor_type(
|
|
199
|
+
runtime.agent,
|
|
200
|
+
self.config.sandbox,
|
|
201
|
+
cell_timeout=self.config.cell_timeout,
|
|
202
|
+
framework_builtins=framework_builtins,
|
|
203
|
+
restrictions=self.config.restrictions,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class CodingAgent(InteractiveAgent):
|
|
208
|
+
"""Repository coding agent for noah-code.
|
|
209
|
+
|
|
210
|
+
Inspect, plan, edit, and verify inside a single workspace. Prefer
|
|
211
|
+
focused reads and Match-based edits. Never claim tests passed unless
|
|
212
|
+
observed. Mode and permissions are enforced in tool code, not only prompts.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
workspace_root: str = "."
|
|
216
|
+
mode: Literal["build", "plan"] = "build"
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
workspace: Workspace,
|
|
221
|
+
config: NoahCodeConfig,
|
|
222
|
+
*,
|
|
223
|
+
llm: Any = None,
|
|
224
|
+
storage: Any = None,
|
|
225
|
+
engine: PermissionEngine | None = None,
|
|
226
|
+
approvals: ApprovalBroker | None = None,
|
|
227
|
+
journal: SnapshotJournal | None = None,
|
|
228
|
+
**kwargs: Any,
|
|
229
|
+
) -> None:
|
|
230
|
+
super().__init__(llm=llm, storage=storage, **kwargs)
|
|
231
|
+
self.workspace_root = str(workspace.root)
|
|
232
|
+
self.mode = config.mode
|
|
233
|
+
self._config = config
|
|
234
|
+
|
|
235
|
+
self._engine = engine or PermissionEngine(
|
|
236
|
+
config.permission_rules,
|
|
237
|
+
mode=config.mode,
|
|
238
|
+
auto_approve=config.auto_approve,
|
|
239
|
+
)
|
|
240
|
+
self._engine.mode = config.mode
|
|
241
|
+
self._approvals = approvals or ApprovalBroker(self._engine)
|
|
242
|
+
self._journal = journal or SnapshotJournal(blob_limit=config.undo_blob_limit)
|
|
243
|
+
|
|
244
|
+
shell = ShellTools(cwd=str(workspace.root))
|
|
245
|
+
self._shell: Annotated[ShellTools, hidden] = shell
|
|
246
|
+
|
|
247
|
+
self.ws = WorkspaceTools(
|
|
248
|
+
workspace,
|
|
249
|
+
shell,
|
|
250
|
+
self._engine,
|
|
251
|
+
self._approvals,
|
|
252
|
+
self._journal,
|
|
253
|
+
max_output_chars=config.max_output_chars,
|
|
254
|
+
default_timeout=config.command_timeout,
|
|
255
|
+
)
|
|
256
|
+
self.todos = TodoManager()
|
|
257
|
+
self.git = GitTools(self.ws)
|
|
258
|
+
self._sandbox_approved_roots: set[str] = set()
|
|
259
|
+
|
|
260
|
+
from noah_code.skills_setup import install_skills
|
|
261
|
+
|
|
262
|
+
self._skills_status = install_skills(self, workspace.root, config)
|
|
263
|
+
|
|
264
|
+
# Apply instance CodeAct limits without mutating other agents' class attrs.
|
|
265
|
+
# CodeActConfig is frozen, so replace the strategy object on this method
|
|
266
|
+
# only when values differ from the class decorator defaults.
|
|
267
|
+
desired = _codeact_config(config)
|
|
268
|
+
current = getattr(type(self).handle, "_strategy_override", None)
|
|
269
|
+
if current is None or getattr(current, "config", None) != desired:
|
|
270
|
+
# Bound on the unbound function object - acceptable for a single-process CLI.
|
|
271
|
+
type(self).handle._strategy_override = _PermissionCodeActStrategy(config=desired)
|
|
272
|
+
|
|
273
|
+
# Bounded live context - not full trees/diffs.
|
|
274
|
+
self.context["workspace"] = Context(
|
|
275
|
+
expr="f'workspace={self.workspace_root}\\nmode={self.mode}'"
|
|
276
|
+
)
|
|
277
|
+
self.context["todos"] = Context(expr="self.todos.status()")
|
|
278
|
+
self.context["git"] = Context(expr="self._git_summary()")
|
|
279
|
+
|
|
280
|
+
if config.summarization.policy != "none":
|
|
281
|
+
install_summarizer(
|
|
282
|
+
SummarizationConfig(
|
|
283
|
+
policy=config.summarization.policy,
|
|
284
|
+
max_tokens=config.summarization.max_tokens,
|
|
285
|
+
preserve_recent=config.summarization.preserve_recent,
|
|
286
|
+
target_chars=config.summarization.target_chars,
|
|
287
|
+
),
|
|
288
|
+
self,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Discover AGENTS.md / README hints without dumping trees.
|
|
292
|
+
self.context["repo_instructions"] = Context(expr="self._repo_instructions()")
|
|
293
|
+
|
|
294
|
+
@hidden
|
|
295
|
+
def _git_summary(self) -> str:
|
|
296
|
+
import subprocess
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
status = subprocess.run(
|
|
300
|
+
["git", "status", "--short", "--branch"],
|
|
301
|
+
cwd=self.workspace_root,
|
|
302
|
+
capture_output=True,
|
|
303
|
+
text=True,
|
|
304
|
+
timeout=5,
|
|
305
|
+
check=False,
|
|
306
|
+
)
|
|
307
|
+
out = (status.stdout or status.stderr or "").strip()
|
|
308
|
+
lines = out.splitlines()
|
|
309
|
+
if len(lines) > 30:
|
|
310
|
+
return "\n".join(lines[:30]) + f"\n...[{len(lines) - 30} more]"
|
|
311
|
+
return out or "(not a git repo or empty status)"
|
|
312
|
+
except (OSError, subprocess.SubprocessError):
|
|
313
|
+
return "(git status unavailable)"
|
|
314
|
+
|
|
315
|
+
@hidden
|
|
316
|
+
def _repo_instructions(self) -> str:
|
|
317
|
+
root = Path(self.workspace_root)
|
|
318
|
+
chunks: list[str] = []
|
|
319
|
+
for name in ("AGENTS.md", "CLAUDE.md", ".noah-code/instructions.md"):
|
|
320
|
+
path = root / name
|
|
321
|
+
if path.is_file():
|
|
322
|
+
text = path.read_text(errors="replace")
|
|
323
|
+
if len(text) > 4000:
|
|
324
|
+
text = text[:4000] + "\n...(truncated)..."
|
|
325
|
+
chunks.append(f"## {name}\n{text}")
|
|
326
|
+
return "\n\n".join(chunks) if chunks else "(no repository instruction files found)"
|
|
327
|
+
|
|
328
|
+
@hidden
|
|
329
|
+
@property
|
|
330
|
+
def engine(self) -> PermissionEngine:
|
|
331
|
+
return self._engine
|
|
332
|
+
|
|
333
|
+
@hidden
|
|
334
|
+
@property
|
|
335
|
+
def approvals(self) -> ApprovalBroker:
|
|
336
|
+
return self._approvals
|
|
337
|
+
|
|
338
|
+
@hidden
|
|
339
|
+
@property
|
|
340
|
+
def journal(self) -> SnapshotJournal:
|
|
341
|
+
return self._journal
|
|
342
|
+
|
|
343
|
+
@hidden
|
|
344
|
+
def set_mode(self, mode: Literal["build", "plan"]) -> None:
|
|
345
|
+
self.mode = mode
|
|
346
|
+
self._engine.mode = mode
|
|
347
|
+
self.v.mode = mode
|
|
348
|
+
|
|
349
|
+
@hidden
|
|
350
|
+
@strategy(
|
|
351
|
+
_PermissionCodeActStrategy(config=CodeActConfig(max_iterations=40, cell_timeout=120.0))
|
|
352
|
+
)
|
|
353
|
+
async def handle(self, notification: dict[str, list]) -> RespondResult:
|
|
354
|
+
"""Handle one conversational turn for a coding task.
|
|
355
|
+
|
|
356
|
+
Read all user messages, slash-command results, and system messages
|
|
357
|
+
in the notification. Understand the requested end state before editing.
|
|
358
|
+
|
|
359
|
+
Workflow:
|
|
360
|
+
- Inspect relevant repository instructions and nearby code first.
|
|
361
|
+
- Prefer ``self.ws.search`` / focused ``self.ws.read`` over dumping large files.
|
|
362
|
+
- Use ``self.todos`` for genuinely multi-step tasks; keep todos current.
|
|
363
|
+
- Make the smallest coherent change with Match-based ``self.ws.replace``.
|
|
364
|
+
- Preserve unrelated user modifications.
|
|
365
|
+
- Run validation proportional to risk (focused tests, not entire suites).
|
|
366
|
+
- Never claim a command or test passed unless its successful result was observed.
|
|
367
|
+
- Report blockers concretely via ``self.message(...)``.
|
|
368
|
+
- In plan mode (see ``self.mode``), do not modify files or run mutating commands;
|
|
369
|
+
return an evidence-based plan with file references.
|
|
370
|
+
- Do not commit, push, publish, or create external resources unless explicitly asked.
|
|
371
|
+
- Do not read secrets or expose sensitive environment values.
|
|
372
|
+
|
|
373
|
+
Return exactly one valid RespondResult:
|
|
374
|
+
- DONE - request complete
|
|
375
|
+
- NEED_INPUT - user input genuinely required
|
|
376
|
+
- WAIT - a registered background job is still running
|
|
377
|
+
"""
|
|
378
|
+
...
|
noah_code/approvals.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Host-owned approval broker. The model cannot call this directly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from noah_code.config import PermissionRule
|
|
12
|
+
from noah_code.permissions import PermissionDecision, PermissionEngine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApprovalChoice(StrEnum):
|
|
16
|
+
ONCE = "once"
|
|
17
|
+
SESSION = "session"
|
|
18
|
+
REJECT = "reject"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ApprovalRequest:
|
|
23
|
+
id: str
|
|
24
|
+
decision: PermissionDecision
|
|
25
|
+
created_at: float
|
|
26
|
+
future: asyncio.Future[ApprovalChoice] = field(repr=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ApprovalHandler = Callable[[ApprovalRequest], Awaitable[ApprovalChoice]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApprovalBroker:
|
|
33
|
+
"""Serialize and resolve permission asks with stable IDs."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
engine: PermissionEngine,
|
|
38
|
+
*,
|
|
39
|
+
handler: ApprovalHandler | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._engine = engine
|
|
42
|
+
self._handler = handler
|
|
43
|
+
self._pending: dict[str, ApprovalRequest] = {}
|
|
44
|
+
self._lock = asyncio.Lock()
|
|
45
|
+
|
|
46
|
+
def set_handler(self, handler: ApprovalHandler | None) -> None:
|
|
47
|
+
self._handler = handler
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def pending(self) -> dict[str, ApprovalRequest]:
|
|
51
|
+
return dict(self._pending)
|
|
52
|
+
|
|
53
|
+
async def require(self, decision: PermissionDecision) -> None:
|
|
54
|
+
"""Raise PermissionError on deny; ask host on ask; no-op on allow."""
|
|
55
|
+
if decision.action == "allow":
|
|
56
|
+
return
|
|
57
|
+
if decision.action == "deny":
|
|
58
|
+
raise PermissionError(
|
|
59
|
+
f"denied [{decision.category}] {decision.target}: {decision.reason}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
choice = await self._ask(decision)
|
|
63
|
+
if choice == ApprovalChoice.REJECT:
|
|
64
|
+
raise PermissionError(
|
|
65
|
+
f"rejected [{decision.category}] {decision.target}: {decision.reason}"
|
|
66
|
+
)
|
|
67
|
+
if choice == ApprovalChoice.SESSION:
|
|
68
|
+
self._engine.add_session_rule(
|
|
69
|
+
PermissionRule(
|
|
70
|
+
category=decision.category,
|
|
71
|
+
pattern=decision.remember_pattern,
|
|
72
|
+
action="allow",
|
|
73
|
+
reason="remembered for session",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def _ask(self, decision: PermissionDecision) -> ApprovalChoice:
|
|
78
|
+
req_id = str(uuid.uuid4())
|
|
79
|
+
loop = asyncio.get_running_loop()
|
|
80
|
+
fut: asyncio.Future[ApprovalChoice] = loop.create_future()
|
|
81
|
+
request = ApprovalRequest(
|
|
82
|
+
id=req_id,
|
|
83
|
+
decision=decision,
|
|
84
|
+
created_at=loop.time(),
|
|
85
|
+
future=fut,
|
|
86
|
+
)
|
|
87
|
+
async with self._lock:
|
|
88
|
+
self._pending[req_id] = request
|
|
89
|
+
try:
|
|
90
|
+
if self._handler is None:
|
|
91
|
+
# Non-interactive without --auto: treat ask as deny.
|
|
92
|
+
return ApprovalChoice.REJECT
|
|
93
|
+
choice = await self._handler(request)
|
|
94
|
+
if not fut.done():
|
|
95
|
+
fut.set_result(choice)
|
|
96
|
+
return choice
|
|
97
|
+
finally:
|
|
98
|
+
async with self._lock:
|
|
99
|
+
self._pending.pop(req_id, None)
|
|
100
|
+
|
|
101
|
+
def cancel_all(self) -> None:
|
|
102
|
+
for req in list(self._pending.values()):
|
|
103
|
+
if not req.future.done():
|
|
104
|
+
req.future.set_result(ApprovalChoice.REJECT)
|
|
105
|
+
self._pending.clear()
|