Skip to content
Merged
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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,14 @@ PYTHON_RUNNER_IMAGE=clawix-python-runner:latest
PYTHON_POOL_IDLE_TIMEOUT_SEC=300
PYTHON_POOL_MAX_SIZE=20
PYTHON_NET_NETWORK_NAME=clawix-python-net-egress
# Comma-separated host[:port] entries permitted to bypass the RFC1918 block.
# Comma-separated host[:port] entries re-permitted past the per-container egress
# firewall (enforced by egress-firewall.sh baked into the runner image). IPv4
# only — resolved via getent ahostsv4; all other IPv6 egress is blocked.
# Example: PYTHON_INTERNAL_ALLOWLIST=admin.internal,grafana.internal:3000
PYTHON_INTERNAL_ALLOWLIST=
# Same, for shell_net's ephemeral containers on the egress network (IPv4, exact).
# Example: SHELL_NET_INTERNAL_ALLOWLIST=admin.internal,grafana.internal:3000
SHELL_NET_INTERNAL_ALLOWLIST=
# Which Plan-tier allowlist file the clawix-pypi-proxy mounts (prod compose only).
# Values: standard | extended | unrestricted. Defaults to extended.
PYTHON_ALLOWLIST_TIER=extended
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,8 @@ networks:
name: clawix-python-net-egress
driver: bridge
internal: false
# RFC1918/metadata egress is blocked PER-CONTAINER by the egress-firewall
# entrypoint baked into the runner images (routed private ranges + gateway +
# 169.254.169.254 dropped; public egress + DNS preserved). Used by
# python_run_net and shell_net ephemeral runners.
# See docs/specs/2026-07-16-egress-filter-design.md.
9 changes: 5 additions & 4 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ networks:
name: clawix-python-net-egress
driver: bridge
# External default: this network reaches the public internet by default.
# RFC1918 blocking is enforced by the deploy environment's firewall rules
# or a sidecar egress proxy. Used by python_run_net ephemeral runners and
# also carries proxy traffic from those runners to clawix-pypi-proxy.
# See docs/specs/2026-05-08-python-run-tool-design.md §Security.
# RFC1918/metadata egress is blocked PER-CONTAINER by the egress-firewall
# entrypoint baked into the runner images (routed private ranges + gateway +
# 169.254.169.254 dropped; public egress + DNS preserved). Used by
# python_run_net and shell_net ephemeral runners, and carries proxy traffic
# to clawix-pypi-proxy. See docs/specs/2026-07-16-egress-filter-design.md.
90 changes: 90 additions & 0 deletions infra/docker/egress-firewall.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/bin/sh
# Egress firewall for ephemeral containers on clawix-python-net-egress.
# Runs as PID 1 (ENTRYPOINT). Two boot modes, keyed off the effective uid,
# because ONE image (clawix-python-runner:latest) serves both:
#
# * Booted as root (egress path: --user 0:0 + --cap-add NET_ADMIN, set by the
# python_run_net / shell_net tools): program the L3/L4 blocks below, then
# drop to uid 1000 via gosu and exec "$@".
# * Booted as uid 1000 (the SAME image's other caller — the no-network
# python_run warm pool: --user 1000:1000, NO NET_ADMIN): apply NO firewall
# (uid 1000 cannot, and that path never had one) and exec "$@" unchanged.
# This keeps the no-net path byte-identical at runtime despite the shared
# image. Without this branch the first iptables call fails under set -eu as
# uid 1000, the container dies, and python_run breaks.
#
# Fail-closed: on the root path any rule failure aborts (set -eu) so the
# container never runs agent code with an unfiltered network.
#
# NOTE: 127.0.0.0/8 is deliberately NOT dropped. The container netns has its own
# loopback, isolated from the host's; the only lo service is Docker's embedded
# DNS (127.0.0.11), and blocking it would break name resolution for zero gain
# (127.0.0.1:<port> reaches only the agent's own processes). This is a conscious
# deviation from ssrf-protection.ts's isBlockedIpv4, which blocks loopback at the
# app layer where "loopback" means the host's.
set -eu

# 0. No-network path (uid 1000, no caps). The shared image is also the
# python_run warm-pool base, which boots unprivileged and never had a
# firewall. Keep it that way; iptables here would only fail (no NET_ADMIN).
if [ "$(id -u)" != 0 ]; then
[ "$#" -eq 0 ] && set -- sleep infinity
exec "$@"
fi

# ---- Egress path (root + NET_ADMIN): program the firewall, then drop to 1000. ----

# 1. Loopback (keeps Docker embedded DNS 127.0.0.11:53 reachable).
iptables -A OUTPUT -o lo -j ACCEPT

# 2. Return traffic. (v4 OUTPUT policy stays ACCEPT so this is belt-and-braces
# on v4 — kept for symmetry with the v6 DROP-policy side; harmless.)
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# 3. Operator allowlist — EGRESS_ALLOWLIST="host[:port],host[:port]".
# Resolved to IPv4 at boot; ACCEPT inserted before the blocks (step 4).
if [ -n "${EGRESS_ALLOWLIST:-}" ]; then
OLD_IFS=$IFS; IFS=','
for entry in $EGRESS_ALLOWLIST; do
IFS=$OLD_IFS
host=${entry%%:*}
port=""
case "$entry" in *:*) port=${entry##*:} ;; esac
[ -n "$host" ] || continue
# Resolve host to IPv4 addresses (getent works for names and literal IPs).
for ip in $(getent ahostsv4 "$host" 2>/dev/null | awk '{print $1}' | sort -u); do
if [ -n "$port" ]; then
iptables -A OUTPUT -d "$ip" -p tcp --dport "$port" -j ACCEPT
iptables -A OUTPUT -d "$ip" -p udp --dport "$port" -j ACCEPT
else
iptables -A OUTPUT -d "$ip" -j ACCEPT
fi
done
IFS=','
done
IFS=$OLD_IFS
fi

# 4. Block private / metadata / CGNAT / this-host destinations.
for net in 169.254.0.0/16 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 0.0.0.0/8; do
iptables -A OUTPUT -d "$net" -j DROP
done

# 5. Public internet — default OUTPUT policy stays ACCEPT.

# 6. IPv6 — only when the kernel has a v6 stack. /proc/net/if_inet6 exists iff
# IPv6 is enabled; if it's absent there is no v6 egress to leak, so skipping
# is safe and avoids a spurious fail-closed abort on v4-only hosts where
# ip6tables can't init its table. If v6 IS present and ip6tables fails,
# set -eu aborts (correct: never run with an unfiltered v6 path).
if [ -e /proc/net/if_inet6 ]; then
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
ip6tables -P OUTPUT DROP
fi

# 7. Drop root, run the workload as uid 1000. No args → idle.
if [ "$#" -eq 0 ]; then
set -- sleep infinity
fi
exec gosu 1000:1000 "$@"
19 changes: 16 additions & 3 deletions infra/docker/python-runner/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
FROM python:3.12-slim

# iptables + gosu back the egress-firewall entrypoint: iptables programs the L3
# blocks, gosu drops root -> uid 1000 once the rules are in place.
RUN apt-get update && apt-get install -y --no-install-recommends \
git jq \
git jq iptables gosu \
&& rm -rf /var/lib/apt/lists/*

# Configure pip to use the proxy. (At pre-bake time we install from upstream
# via the proxy too; at runtime, agent installs go through the same path.)
COPY pip.conf /etc/pip.conf
# Path is relative to the `infra/docker` build context (not this Dockerfile's
# dir), so it is `python-runner/pip.conf`. Build with:
# docker build -f infra/docker/python-runner/Dockerfile infra/docker
COPY python-runner/pip.conf /etc/pip.conf

# Pre-bake the common set with pinned majors for reproducibility.
RUN pip install --no-cache-dir --index-url https://pypi.org/simple/ \
Expand All @@ -19,6 +24,14 @@ RUN pip install --no-cache-dir --index-url https://pypi.org/simple/ \

RUN mkdir -p /workspace && chown -R 1000:1000 /workspace

USER 1000:1000
# Egress firewall runs as root at boot, then drops to uid 1000. Do NOT set
# `USER 1000` — the entrypoint needs root to program iptables on the egress
# path (python_run_net), and no-ops straight to `exec "$@"` (still uid 1000) on
# the no-network warm-pool path (python_run), so this shared image stays correct
# for BOTH callers.
COPY egress-firewall.sh /usr/local/bin/egress-firewall.sh
RUN chmod +x /usr/local/bin/egress-firewall.sh

WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/egress-firewall.sh"]
CMD ["sleep", "infinity"]
26 changes: 26 additions & 0 deletions infra/docker/shell-net-runner/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# shell_net runner — the agent image plus outbound tooling.
# Mirrors infra/docker/agent/Dockerfile except for the added network binaries
# and the git-hang fixes (writable HOME + GIT_TERMINAL_PROMPT=0).
# Build with the shared egress-firewall.sh in context:
# docker build -f infra/docker/shell-net-runner/Dockerfile infra/docker
FROM python:3.12-slim

# iptables + gosu back the egress-firewall entrypoint (see egress-firewall.sh).
RUN apt-get update && apt-get install -y --no-install-recommends \
git jq curl wget ca-certificates openssh-client iptables gosu \
&& rm -rf /var/lib/apt/lists/*

RUN mkdir -p /workspace /skills/builtin /skills/custom /app /home/agent \
&& chown -R 1000:1000 /workspace /skills /home/agent

ENV GIT_TERMINAL_PROMPT=0 HOME=/home/agent

# Egress firewall runs as root at boot, programs iptables, then drops to uid
# 1000 via gosu. Do NOT set `USER 1000:1000` — the entrypoint drops privilege
# itself; the runner passes --user 0:0 at start and --user 1000:1000 at exec.
COPY egress-firewall.sh /usr/local/bin/egress-firewall.sh
RUN chmod +x /usr/local/bin/egress-firewall.sh

WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/egress-firewall.sh"]
CMD ["sleep", "infinity"]
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^10.0.0",
"@google/genai": "^1.50.1",
"@grammyjs/runner": "^2.0.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"@nestjs/common": "^11.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- Add TOOL_APPROVAL to NotificationType enum
ALTER TYPE "NotificationType" ADD VALUE 'TOOL_APPROVAL';

-- Create ApprovalStatus enum
CREATE TYPE "ApprovalStatus" AS ENUM ('PENDING', 'APPROVED', 'DENIED', 'EXPIRED', 'CANCELLED');

-- Create ApprovalRequest table
CREATE TABLE "ApprovalRequest" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"agentRunId" TEXT NOT NULL,
"sessionId" TEXT,
"toolName" TEXT NOT NULL,
"paramsSummary" JSONB NOT NULL,
"status" "ApprovalStatus" NOT NULL DEFAULT 'PENDING',
"scope" TEXT,
"resolutionReason" TEXT,
"memoryStore" TEXT,
"memoryKey" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedAt" TIMESTAMP(3),

CONSTRAINT "ApprovalRequest_pkey" PRIMARY KEY ("id")
);

-- Create indexes for ApprovalRequest
CREATE INDEX "ApprovalRequest_userId_status_idx" ON "ApprovalRequest"("userId", "status");
CREATE INDEX "ApprovalRequest_agentRunId_idx" ON "ApprovalRequest"("agentRunId");

-- Create UserToolApproval table
CREATE TABLE "UserToolApproval" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"resourceKey" TEXT NOT NULL DEFAULT '',
"decision" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "UserToolApproval_pkey" PRIMARY KEY ("id")
);

-- Create unique constraint for UserToolApproval
CREATE UNIQUE INDEX "UserToolApproval_userId_toolName_resourceKey_key" ON "UserToolApproval"("userId", "toolName", "resourceKey");

-- Create index for UserToolApproval
CREATE INDEX "UserToolApproval_userId_idx" ON "UserToolApproval"("userId");

-- Add annotations column to McpTool
ALTER TABLE "McpTool" ADD COLUMN "annotations" JSONB;

-- Add approvalOverrides column to McpConnection
ALTER TABLE "McpConnection" ADD COLUMN "approvalOverrides" JSONB;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Policy" ADD COLUMN "allowShellNet" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "maxConcurrentShellNetRuns" INTEGER NOT NULL DEFAULT 2,
ADD COLUMN "maxShellNetCpuCores" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "maxShellNetMemoryMb" INTEGER NOT NULL DEFAULT 512,
ADD COLUMN "maxShellNetTimeoutSecs" INTEGER NOT NULL DEFAULT 60;
53 changes: 53 additions & 0 deletions packages/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ model Policy {
maxPythonTimeoutSecs Int @default(60)
maxPythonCpuCores Int @default(1)
maxConcurrentPythonRuns Int @default(2)
allowShellNet Boolean @default(false)
maxShellNetMemoryMb Int @default(512)
maxShellNetTimeoutSecs Int @default(60)
maxShellNetCpuCores Int @default(1)
maxConcurrentShellNetRuns Int @default(2)
maxSubAgentRunMs Int @default(300000) // wall-clock cap per spawned sub-agent run (ms)
allowMcp Boolean @default(false)
isActive Boolean @default(true)
Expand Down Expand Up @@ -542,6 +547,7 @@ enum NotificationType {
GROUP_INVITE_RESPONSE
PRIMARY_AGENT_ASSIGNED
MCP_SERVER_ATTENTION
TOOL_APPROVAL
}

model Notification {
Expand All @@ -558,6 +564,51 @@ model Notification {
@@index([recipientId, isRead])
}

enum ApprovalStatus {
PENDING
APPROVED
DENIED
EXPIRED
CANCELLED
}

/// One row per approval gate hit that was not silently served from memory-allow.
/// Rows for system outcomes (standing_deny, no_channel, subagent, restart_sweep)
/// are created pre-resolved with resolutionReason set. Append-mostly; only
/// status/scope/resolutionReason/memory fields and resolvedAt flip, exactly once.
model ApprovalRequest {
id String @id @default(cuid())
userId String
agentRunId String
sessionId String?
toolName String
paramsSummary Json // redacted/truncated display copy — never the raw args
status ApprovalStatus @default(PENDING)
scope String? // once | session | always — user's tier, or the deny tier that fired for standing/session-deny rows
resolutionReason String? // standing_deny | session_deny | timeout | no_channel | subagent | restart_sweep | run_aborted
memoryStore String? // 'session' | 'persistent' — set when resolution wrote or applied a memory entry
memoryKey String? // `${toolName}:${resourceKey}` — server-trusted /revoke target; never client-supplied
createdAt DateTime @default(now())
resolvedAt DateTime?

@@index([userId, status])
@@index([agentRunId])
}

/// Standing (always-scope) allow/deny decisions. Only run/resource-scoped
/// tools get rows; call-scoped tools never persist (chat-click ceiling).
model UserToolApproval {
id String @id @default(cuid())
userId String
toolName String
resourceKey String @default("") // '' = run-scope sentinel (NOT NULL: Postgres treats NULLs as distinct in uniques). Run-scoped tools always write '', resource-scoped always non-empty.
decision String // 'allowed' | 'denied'
createdAt DateTime @default(now())

@@unique([userId, toolName, resourceKey])
@@index([userId])
}

// ============================================================================
// System Settings (single-row, JSON + Zod)
// ============================================================================
Expand Down Expand Up @@ -633,6 +684,7 @@ model McpTool {
name String
description String
inputSchema Json
annotations Json? // MCP spec tool annotations (readOnlyHint etc.) captured at discovery — untrusted hints
scanFlagged Boolean @default(false) // description failed prompt-injection scan
scanReason String?
createdAt DateTime @default(now())
Expand All @@ -652,6 +704,7 @@ model McpConnection {
lastError String?
lastDiscoveredAt DateTime?
tiers Json? // { recommended: string[], optional: string[], off: string[] } — null until set
approvalOverrides Json? // { [toolName]: 'ask' | 'allow' } — sparse overlay; null until any tool is toggled. Only gates tools that are already bound; tier off ⇒ inert.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down
Loading
Loading