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 ADDED
@@ -0,0 +1,3 @@
1
+ """Noah Code: terminal coding harness on NVIDIA OO Agents."""
2
+
3
+ __version__ = "0.1.0"
noah_code/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m noah_code entry point."""
2
+
3
+ from noah_code.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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()