Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions packages/nooa-cli/src/nooa_cli/commands/start_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ def _find_pid_on_port(port: int) -> str | None:

@click.command()
@click.option("--port", "-p", type=int, default=5001, help="Port number (default: 5001).")
@click.option("--host", "-h", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0).")
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1).")
@click.option(
"--auth-token",
envvar="NOOA_VIEWER_AUTH_TOKEN",
default=None,
help="Bearer token required for remote viewer access. Required for non-loopback binds.",
)
@click.option(
"--db",
"db_path_opt",
Expand All @@ -73,7 +79,7 @@ def _find_pid_on_port(port: int) -> str | None:
"(or $NOOA_TRACE_DB if set). Pass an explicit path to run a second viewer "
"side-by-side with the default one.",
)
def command(port: int, host: str, db_path_opt: str | None):
def command(port: int, host: str, auth_token: str | None, db_path_opt: str | None):
"""Start the unified trace + evaluation viewer."""
import os
from pathlib import Path
Expand All @@ -95,8 +101,11 @@ def command(port: int, host: str, db_path_opt: str | None):
os.environ["NOOA_TRACE_DB"] = str(db_path)
os.environ["NEMO_OO_TRACE_DB"] = str(db_path)

if auth_token:
os.environ["NOOA_VIEWER_AUTH_TOKEN"] = auth_token

try:
from nooa.viewer.main import app
from nooa.viewer.main import app, ensure_viewer_bind_is_safe
except ImportError:
click.secho(
"Error: viewer dependencies are not installed.\n"
Expand All @@ -106,6 +115,11 @@ def command(port: int, host: str, db_path_opt: str | None):
)
raise SystemExit(1) from None

try:
ensure_viewer_bind_is_safe(host)
except ValueError as exc:
raise click.ClickException(str(exc)) from None

import copy

import uvicorn
Expand All @@ -123,6 +137,8 @@ def command(port: int, host: str, db_path_opt: str | None):
click.secho(" NVIDIA OO Agents Viewer", fg="cyan", bold=True)
click.echo(f" URL: http://localhost:{port}")
click.echo(f" DB: {db_path}")
if auth_token:
click.echo(" Auth: bearer token required")
click.echo()

try:
Expand Down
9 changes: 4 additions & 5 deletions src/nooa/config/strategy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _migrate_synthetic_reasoning(cls, v: str) -> str:
return "synthetic_comment"
return v

cell_timeout: float | None = None
cell_timeout: float | None = 30.0
max_tokens: int | None = None
temperature: float | None = None
top_p: float | None = None
Expand All @@ -71,13 +71,12 @@ def _migrate_synthetic_reasoning(cls, v: str) -> str:
restrictions: RestrictionsConfig = RestrictionsConfig()

# Execution backend for execute_python cells:
# * "inprocess" (default) — run cells in the agent's own process/event loop.
# Zero behavior change; the historical path.
# * "sandbox" — run each cell in a locked-down worker process with
# * "sandbox" (default) — run each cell in a locked-down worker process with
# OS-enforced guardrails (hard timeout, memory/CPU caps, filesystem
# confinement, network off). Turns ``cell_timeout`` into a *hard* bound.
# * "inprocess" — explicit compatibility escape hatch for trusted code only.
# The ``sandbox`` sub-config below is ignored unless this is "sandbox".
execution_backend: Literal["inprocess", "sandbox"] = "inprocess"
execution_backend: Literal["inprocess", "sandbox"] = "sandbox"
sandbox: SandboxConfig = SandboxConfig()

# Method-local deterministic validators (see nooa.strategy_validation).
Expand Down
9 changes: 7 additions & 2 deletions src/nooa/runtime/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1440,7 +1440,8 @@ async def _core_exec(ctx: ExecutePythonContext) -> ExecutePythonContext:
# 2. Strip blocked modules and their members from exec_globals
effective_restrictions = restrictions or RestrictionsConfig()
exec_globals = _strip_blocked_modules(
exec_globals, effective_restrictions.blocked_modules
exec_globals,
effective_restrictions.blocked_modules | effective_restrictions.restricted_imports,
)

# 3. Strip redundant imports (from typing import Literal, etc.)
Expand Down Expand Up @@ -1505,7 +1506,11 @@ async def _core_exec(ctx: ExecutePythonContext) -> ExecutePythonContext:
# still wrap the cell on the parent. The worker owns the namespace,
# stdout capture and wrapper, so we skip the in-process exec below.
if sandbox_executor is not None:
result = await sandbox_executor.run_cell(code, execution_count=execution_count)
result = await sandbox_executor.run_cell(
code,
execution_count=execution_count,
builtins=builtins,
)
return result

# Set up stdout/stderr capture BEFORE ast.parse/compile so that
Expand Down
30 changes: 23 additions & 7 deletions src/nooa/runtime/restrictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,27 @@ def get_restricted_imports() -> frozenset[str] | None:
}


# Tier 2: restricted — denied at AST import validation but not stripped from namespace.
# Empty by default (all imports allowed in sandboxed environments).
# Developers can set DEFAULT_RESTRICTED_IMPORTS for a small deny list,
# or RESTRICTED_MODULES for strict lockdown.
DEFAULT_RESTRICTED_IMPORTS: frozenset[str] = frozenset()
# Tier 2: restricted — denied at AST import validation and stripped from the
# generated-code namespace by default. These modules provide direct host
# capability access (filesystem, process state, dynamic import, network clients)
# and should only be re-enabled deliberately with an explicit config override.
DEFAULT_RESTRICTED_IMPORTS: frozenset[str] = frozenset(
{
"builtins",
"ctypes",
"glob",
"httpx",
"importlib",
"inspect",
"io",
"os",
"pathlib",
"requests",
"shutil",
"sys",
"tempfile",
}
)


class RestrictionsConfig(BaseModel):
Expand All @@ -100,8 +116,8 @@ class RestrictionsConfig(BaseModel):
Event-loop hazards: subprocess, socket, etc.

Tier 2 — **restricted** (soft block):
Blocked at AST import validation only.
Modules with side effects that shouldn't be casually imported.
Blocked at AST import validation and removed from generated-code globals.
Modules with direct host capabilities that require explicit opt-in.

Tier 3 — **allowed** (everything else):
Any installed module can be imported freely.
Expand Down
89 changes: 85 additions & 4 deletions src/nooa/runtime/sandbox/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import asyncio
import concurrent.futures as futures
import inspect
import logging
import multiprocessing as mp
import os
Expand All @@ -37,6 +38,9 @@
logger = logging.getLogger(__name__)

_CAPS_CACHE: Capabilities | None = None
_BROKER_PROTOCOL_DUNDERS = frozenset(
{"__getitem__", "__setitem__", "__delitem__", "__contains__", "__len__"}
)


def _capabilities() -> Capabilities:
Expand Down Expand Up @@ -217,7 +221,13 @@ def _next_id(self) -> int:
return self._req_id

# --- running a cell ----------------------------------------------------
async def run_cell(self, code: str, *, execution_count: int = 1) -> ExecutionResult:
async def run_cell(
self,
code: str,
*,
execution_count: int = 1,
builtins: dict[str, Any] | None = None,
) -> ExecutionResult:
"""Execute one cell in the worker and return an ``ExecutionResult``."""
if self._closed:
raise WorkerDiedError("sandbox executor is closed")
Expand All @@ -234,9 +244,17 @@ async def run_cell(self, code: str, *, execution_count: int = 1) -> ExecutionRes
await self._ensure_worker()
assert self._conn is not None
req_id = self._next_id()
namespace_updates, out_events = self._namespace_updates(builtins or {})
try:
self._conn.send(
{"op": "run", "id": req_id, "code": code, "execution_count": execution_count}
{
"op": "run",
"id": req_id,
"code": code,
"execution_count": execution_count,
"namespace_updates": namespace_updates,
"out_events": out_events,
}
)
except (BrokenPipeError, OSError) as exc:
# The worker died between _ensure_worker and this send (e.g. an
Expand All @@ -259,6 +277,26 @@ async def run_cell(self, code: str, *, execution_count: int = 1) -> ExecutionRes
dto: ResultDTO = response["result"]
return dto_to_result(dto, signal_factory=self._signal_factory)

@staticmethod
def _namespace_updates(
builtins: dict[str, Any],
) -> tuple[dict[str, Any], list[tuple[int, Any]] | None]:
"""Return picklable per-cell namespace refreshes and an ``Out`` snapshot."""
updates: dict[str, Any] = {}
out_events: list[tuple[int, Any]] | None = None
for name, value in builtins.items():
if name == "Out" and hasattr(value, "_get_output_events"):
events = value._get_output_events()
out_events = [
(event.execution_count, event.value)
for event in events
if is_picklable(event.value)
]
continue
if is_picklable(value):
updates[name] = value
return updates, out_events

def _recv_until_result(
self, req_id: int, deadline: float | None, loop: asyncio.AbstractEventLoop
) -> dict[str, Any]:
Expand Down Expand Up @@ -333,11 +371,53 @@ def _service_tool_call(self, msg: dict[str, Any], loop: asyncio.AbstractEventLoo

def _walk_path(self, path: list[str]) -> Any:
"""Resolve a dotted attribute path (``["memory", "remember"]``) on the agent."""
self._validate_broker_path(path)
obj: Any = self._agent
for part in path:
obj = getattr(obj, part)
return obj

def _is_visible_root_attr(self, name: str) -> bool:
"""Mirror agentdoc visibility before brokering any root ``self.*`` access."""
from nooa.agentdoc._metadata import get_field_metadata
from nooa.agentdoc.visibility import is_hidden_field, is_hidden_method

if not name or (name.startswith("__") and name.endswith("__")):
return False

try:
raw = inspect.getattr_static(self._agent, name)
except AttributeError:
raw = None

if is_hidden_field(self._agent, name) or (raw is not None and is_hidden_method(raw)):
return False

if name.startswith("_"):
explicitly_shown = get_field_metadata(self._agent, name).get("hidden") is False
explicitly_shown = explicitly_shown or (
raw is not None and getattr(raw, "_agentdoc_hidden", None) is False
)
if not explicitly_shown:
return False
return True

def _validate_broker_path(self, path: list[str]) -> None:
"""Reject worker-supplied paths that cross hidden/private boundaries."""
if (
not isinstance(path, list)
or not path
or not all(isinstance(p, str) and p for p in path)
):
raise AttributeError("invalid sandbox broker path")
if not self._is_visible_root_attr(path[0]):
raise AttributeError(f"sandbox broker cannot access hidden attribute self.{path[0]}")
for part in path[1:]:
if part.startswith("_") and part not in _BROKER_PROTOCOL_DUNDERS:
raise AttributeError(
f"sandbox broker cannot access private nested attribute {part}"
)

async def _dispatch_tool_call(self, msg: dict[str, Any]) -> dict[str, Any]:
"""Run a brokered ``self.<path>`` access against the parent's live agent."""
from nooa.events import ExecutionSignal
Expand All @@ -346,9 +426,10 @@ async def _dispatch_tool_call(self, msg: dict[str, Any]) -> dict[str, Any]:
display = ".".join(path)
kind = msg.get("kind")
try:
self._validate_broker_path(path)
if kind == "setattr":
# self.<path> = value on the parent's live agent.
obj = self._walk_path(path[:-1])
obj = self._agent if len(path) == 1 else self._walk_path(path[:-1])
setattr(obj, path[-1], msg.get("value"))
return {"ok": True, "result": None}
if kind == "iter":
Expand All @@ -367,7 +448,7 @@ async def _dispatch_tool_call(self, msg: dict[str, Any]) -> dict[str, Any]:
target = self._walk_path(path)
if kind == "attr":
# Picklable state crosses; a live object becomes a nested proxy.
if is_picklable(target):
if is_picklable(target) and not isinstance(target, (bytearray, dict, list, set)):
return {"ok": True, "result": target}
return {"ok": True, "result": None, "proxy": True}
value = target(*msg.get("args", ()), **msg.get("kwargs", {}))
Expand Down
44 changes: 42 additions & 2 deletions src/nooa/runtime/sandbox/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,54 @@
# through ``__getattr__`` against the agent (which has no such attribute) and
# fails, rather than handing the cell the live pipe — closing the direct
# ``self._broker._conn.send(<pickle bomb>)`` escape. Legitimately-exposed private
# agent attributes (``self._foo``) still broker normally, matching in-process.
# agent attributes (``self._foo``) still broker normally, matching in-process. The
# parent also enforces agentdoc visibility on every broker path, so hidden/private
# agent state does not become reachable merely because the worker can name it.
# NB: the parent<->worker channel still uses pickle, so a *fully adversarial*
# in-process cell that reaches framework internals is out of scope here — that is
# the OS-layer (separate uid/namespace) sandbox's job; this layer's contract is
# OS-enforced action containment.
_PROXY_STATE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
_SAFE_ENV_KEYS = frozenset({"LANG", "LC_ALL", "LC_CTYPE", "TZ", "PYTHONHASHSEED"})


def _scrub_worker_environment() -> None:
"""Remove parent process secrets before any model-authored cell can run."""
safe = {key: value for key, value in os.environ.items() if key in _SAFE_ENV_KEYS}
os.environ.clear()
os.environ.update(safe)


class ParentToolError(RuntimeError):
"""Raised in the worker when a parent-brokered ``self.*`` call fails."""


class _OutSnapshot:
"""Worker-local Jupyter-style output accessor refreshed before each cell."""

def __init__(self, events: list[tuple[int, Any]]):
self._events = events

def __getitem__(self, index: int) -> Any:
if index < 0:
try:
return self._events[index][1]
except IndexError:
raise IndexError(
f"Out index {index} out of range (have {len(self._events)} outputs)"
) from None
for execution_count, value in self._events:
if execution_count == index:
return value
raise KeyError(f"No output for execution {index}")

def __len__(self) -> int:
return len(self._events)

def __contains__(self, index: int) -> bool:
return any(execution_count == index for execution_count, _ in self._events)


class ChildBroker:
"""Child-side RPC client: forward ``self.*`` access to the parent's agent.

Expand Down Expand Up @@ -395,7 +431,7 @@ def build_namespace(
from nooa.runtime.actor import _strip_blocked_modules

effective = restrictions or RestrictionsConfig()
ns = _strip_blocked_modules(ns, effective.blocked_modules)
ns = _strip_blocked_modules(ns, effective.blocked_modules | effective.restricted_imports)
return ns


Expand All @@ -410,6 +446,7 @@ def worker_main(conn: Connection, init: dict[str, Any]) -> None: # pragma: no c
namespace = build_namespace(
init["agent"], init.get("framework_builtins") or {}, proxy, init.get("restrictions")
)
_scrub_worker_environment()
# Guards go on AFTER the (trusted) namespace build and BEFORE the op loop,
# so every cell — and nothing else — runs under the full lock-down.
install_guards(init["spec"])
Expand Down Expand Up @@ -449,6 +486,9 @@ def worker_main(conn: Connection, init: dict[str, Any]) -> None: # pragma: no c
def _run_one(
loop: asyncio.AbstractEventLoop, namespace: dict[str, Any], request: dict[str, Any]
) -> ResultDTO:
namespace.update(request.get("namespace_updates") or {})
if request.get("out_events") is not None:
namespace["Out"] = _OutSnapshot(request["out_events"])
result = loop.run_until_complete(
run_cell_source(
request["code"],
Expand Down
1 change: 1 addition & 0 deletions src/nooa/strategies/codeact.py
Original file line number Diff line number Diff line change
Expand Up @@ -2525,6 +2525,7 @@ async def _execute_code(
with code_exec_context(code):
return await runtime.execute_code(
code,
builtins={**builtins, **session.session_locals},
validate=True, # run restrictions/cell-guard validation on the parent
wrap_in_function=True,
timeout=self.config.cell_timeout,
Expand Down
Loading