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
23 changes: 23 additions & 0 deletions Dockerfile.sandbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.11-slim

# Install git, curl, and npm (which pulls nodejs natively)
RUN apt-get update && apt-get install -y git curl npm && rm -rf /var/lib/apt/lists/*

# Install claude-code globally
RUN npm install -g @anthropic-ai/claude-code

# Install uv for fast Python packaging (requested by user)
RUN pip install uv

# Create a non-root runtime user (UID/GID 1000 for keep-id mapping)
RUN groupadd -g 1000 node && useradd -m -u 1000 -g 1000 node

# Prepare Claude config for non-interactive execution
RUN mkdir -p /home/node/.claude && \
echo '{"theme":"light","autoUpdaterStatus":"disabled","optedIntoTengu":true,"optedIntoMacApp":true}' > /home/node/.claude/config.json && \
chown -R node:node /home/node

USER node
ENV CI=1
ENV HOME=/home/node
WORKDIR /workspace
2 changes: 1 addition & 1 deletion config/settings.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ nightwire_assistant:
# Runs Claude task execution inside a Docker container for isolation
# sandbox:
# enabled: false
# image: "python:3.11-slim"
# image: "nightwire-sandbox:latest"
# network: false # Deny network access in sandbox
# memory_limit: "2g"
# cpu_limit: 2.0
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ services:
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./signal-data:/home/.local/share/signal-cli
- ./signal-data:/home/.local/share/signal-cli:Z
environment:
- MODE=json-rpc
4 changes: 2 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ if [ "$SKIP_SIGNAL" = false ]; then
--name signal-api \
--restart unless-stopped \
-p "$SIGNAL_BIND:8080:8080" \
-v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli" \
-v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli:Z" \
-e MODE=native \
bbernhard/signal-cli-rest-api:latest

Expand Down Expand Up @@ -817,7 +817,7 @@ if command -v docker &> /dev/null && docker info &> /dev/null; then
--name signal-api \
--restart unless-stopped \
-p "127.0.0.1:8080:8080" \
-v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli" \
-v "$SIGNAL_DATA_DIR:/home/.local/share/signal-cli:Z" \
-e MODE=json-rpc \
bbernhard/signal-cli-rest-api:latest

Expand Down
39 changes: 24 additions & 15 deletions nightwire/claude_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

class ErrorCategory(str, Enum):
"""Classification of Claude CLI errors for retry decisions."""

TRANSIENT = "transient"
PERMANENT = "permanent"
INFRASTRUCTURE = "infrastructure"
Expand Down Expand Up @@ -52,9 +53,15 @@ def classify_error(return_code: int, output: str, error_text: str) -> ErrorCateg
# Rate limit errors - check for subscription-level patterns first
if "rate limit" in combined or "429" in combined:
subscription_patterns = (
"usage limit", "daily limit", "capacity", "overloaded",
"too many requests", "try again later", "quota exceeded",
"hourly limit", "subscription",
"usage limit",
"daily limit",
"capacity",
"overloaded",
"too many requests",
"try again later",
"quota exceeded",
"hourly limit",
"subscription",
)
for pattern in subscription_patterns:
if pattern in combined:
Expand Down Expand Up @@ -101,6 +108,7 @@ def _load_guidelines(self) -> str:
def set_project(self, project_path: Path):
"""Set the current project directory."""
from .security import validate_project_path

validated = validate_project_path(str(project_path))
if validated is None:
raise ValueError(f"Project path validation failed: access denied")
Expand Down Expand Up @@ -132,6 +140,7 @@ async def run_claude(
"""
# Check cooldown before doing any work
from .rate_limit_cooldown import get_cooldown_manager

cooldown = get_cooldown_manager()
if cooldown.is_active:
state = cooldown.get_state()
Expand Down Expand Up @@ -170,14 +179,15 @@ async def run_claude(
"--print",
"--dangerously-skip-permissions",
"--verbose",
"--max-turns", str(self.config.claude_max_turns),
"--max-turns",
str(self.config.claude_max_turns),
]

logger.info(
"claude_run_start",
project=str(effective_project),
prompt_length=len(prompt),
timeout=timeout
timeout=timeout,
)

last_error = ""
Expand Down Expand Up @@ -271,9 +281,7 @@ async def send_progress_updates():
elapsed_min = elapsed // 60
if progress_callback:
try:
await progress_callback(
f"Still working... ({elapsed_min} min elapsed)"
)
await progress_callback(f"Still working... ({elapsed_min} min elapsed)")
except Exception as e:
logger.warning("progress_callback_error", error=str(e))

Expand All @@ -291,11 +299,12 @@ async def send_progress_updates():

# Optionally wrap in Docker sandbox
from .sandbox import build_sandbox_command, SandboxConfig

sandbox_settings = self.config.sandbox_config
if sandbox_settings.get("enabled", False):
sandbox_cfg = SandboxConfig(
enabled=True,
image=sandbox_settings.get("image", "python:3.11-slim"),
image=sandbox_settings.get("image", "nightwire-sandbox:latest"),
network=sandbox_settings.get("network", False),
memory_limit=sandbox_settings.get("memory_limit", "2g"),
cpu_limit=sandbox_settings.get("cpu_limit", 2.0),
Expand All @@ -308,18 +317,15 @@ async def send_progress_updates():
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subprocess_env
env=_subprocess_env,
)

if progress_callback:
progress_task = asyncio.create_task(send_progress_updates())

try:
stdout, stderr = await asyncio.wait_for(
self._running_process.communicate(
input=prompt.encode("utf-8")
),
timeout=timeout
self._running_process.communicate(input=prompt.encode("utf-8")), timeout=timeout
)
except asyncio.TimeoutError:
self._running_process.kill()
Expand Down Expand Up @@ -351,7 +357,10 @@ async def send_progress_updates():
category = classify_error(return_code, output, errors)

combined_output = output + errors
if "prompt is too long" in combined_output or "Conversation too long" in combined_output:
if (
"prompt is too long" in combined_output
or "Conversation too long" in combined_output
):
logger.warning("claude_token_limit", output=combined_output[:500])
return (
False,
Expand Down
37 changes: 25 additions & 12 deletions nightwire/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
@dataclass
class SandboxConfig:
"""Configuration for Docker sandbox."""

enabled: bool = False
image: str = "python:3.11-slim"
image: str = "nightwire-sandbox:latest"
network: bool = False
memory_limit: str = "2g"
cpu_limit: float = 2.0
Expand All @@ -33,29 +34,41 @@ def build_sandbox_command(
if not config.enabled:
return cmd

container_cmd = list(cmd)
# The host path to claude won't exist in the container.
# We assume 'claude' is in the container's PATH.
if "claude" in Path(container_cmd[0]).name:
container_cmd[0] = "claude"

docker_cmd = [
"docker", "run",
"docker",
"run",
"--rm",
"--interactive",
"--userns=keep-id",
f"--memory={config.memory_limit}",
f"--cpus={config.cpu_limit}",
"--tmpfs", f"/tmp:size={config.tmpfs_size}",
"-v", f"{project_path}:{project_path}:rw",
"-w", str(project_path),
"--tmpfs",
f"/tmp:size={config.tmpfs_size}",
"-v",
f"{project_path}:{project_path}:rw,z",
"-w",
str(project_path),
]

if not config.network:
docker_cmd.append("--network=none")

# Pass through essential env vars
docker_cmd.extend([
"-e", "HOME",
"-e", "PATH",
"-e", "ANTHROPIC_API_KEY",
])
# Pass through essential env vars but omit PATH to avoid overriding container's paths
docker_cmd.extend(
[
"-e",
"ANTHROPIC_API_KEY",
]
)

docker_cmd.append(config.image)
docker_cmd.extend(cmd)
docker_cmd.extend(container_cmd)

logger.info("sandbox_command_built", project=str(project_path), network=config.network)

Expand Down