Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
115 changes: 113 additions & 2 deletions containers/agent/docker-stub.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
#!/bin/bash
cat >&2 <<'EOF'
# SECURITY: Docker command interceptor for AWF (Agentic Workflow Firewall)
#
# When DinD is NOT enabled (default): blocks all Docker commands with a helpful error.
# When DinD IS enabled (AWF_DIND_ENABLED=1): intercepts docker run/create to force
# shared network namespace with the agent container, preventing proxy bypass.
#
# This ensures child containers inherit the agent's NAT rules and cannot make
# direct outbound requests that bypass the Squid proxy.

set -euo pipefail

# --- DinD disabled: block all Docker commands ---
if [ "${AWF_DIND_ENABLED:-}" != "1" ]; then
cat >&2 <<'EOF'
ERROR: Docker-in-Docker support was removed in AWF v0.9.1

Docker commands are no longer available inside the firewall container.
Expand All @@ -11,4 +24,102 @@ If you need to:

See PR #205: https://github.com/github/gh-aw-firewall/pull/205
EOF
exit 127
exit 127
fi

# --- DinD enabled: enforce shared network namespace ---

REAL_DOCKER="${AWF_REAL_DOCKER:-}"
if [ -z "$REAL_DOCKER" ] || [ ! -x "$REAL_DOCKER" ]; then
echo "ERROR: AWF_REAL_DOCKER is not set or not executable: '$REAL_DOCKER'" >&2
exit 127
fi

AGENT_CONTAINER="${AWF_AGENT_CONTAINER:-awf-agent}"

# Get the subcommand (first non-flag argument)
get_subcommand() {
for arg in "$@"; do
case "$arg" in
-*) continue ;;
*) echo "$arg"; return ;;
esac
done
}

SUBCOMMAND=$(get_subcommand "$@")

# Block commands that could attach containers to other networks
case "$SUBCOMMAND" in
"network")
# Check for 'docker network connect' which could bypass firewall
# Allow 'docker network ls', 'docker network inspect', etc.
shift # remove 'network'
NETWORK_SUBCMD=$(get_subcommand "$@")
if [ "$NETWORK_SUBCMD" = "connect" ]; then
echo "ERROR: 'docker network connect' is blocked by AWF firewall." >&2
echo "Child containers must share the agent's network namespace for security." >&2
exit 1
fi
exec "$REAL_DOCKER" network "$@"

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_subcommand() parser treats any non-flag token as the subcommand, which breaks when Docker global options take a value (e.g. --context foo run ..., --config dir ps, -H tcp://... info). In those cases it will mis-detect the subcommand and skip the enforcement logic, allowing run/create to pass through unmodified. Consider explicitly parsing Docker global flags (including those that consume the next arg) before determining the subcommand, or using a more robust subcommand detection approach.

This issue also appears on line 67 of the same file.

Suggested change
# Get the subcommand (first non-flag argument)
get_subcommand() {
for arg in "$@"; do
case "$arg" in
-*) continue ;;
*) echo "$arg"; return ;;
esac
done
}
SUBCOMMAND=$(get_subcommand "$@")
# Block commands that could attach containers to other networks
case "$SUBCOMMAND" in
"network")
# Check for 'docker network connect' which could bypass firewall
# Allow 'docker network ls', 'docker network inspect', etc.
shift # remove 'network'
NETWORK_SUBCMD=$(get_subcommand "$@")
if [ "$NETWORK_SUBCMD" = "connect" ]; then
echo "ERROR: 'docker network connect' is blocked by AWF firewall." >&2
echo "Child containers must share the agent's network namespace for security." >&2
exit 1
fi
exec "$REAL_DOCKER" network "$@"
# Get the Docker subcommand by skipping global flags, including those that
# consume the following argument.
docker_global_option_takes_value() {
case "$1" in
--config|\
-c|\
--context|\
-H|\
--host|\
-l|\
--log-level|\
--tlscacert|\
--tlscert|\
--tlskey)
return 0
;;
*)
return 1
;;
esac
}
find_subcommand_index() {
local args=("$@")
local i=0
local arg
while [ "$i" -lt "${#args[@]}" ]; do
arg="${args[$i]}"
case "$arg" in
--)
i=$((i + 1))
break
;;
--*=*)
;;
-*)
if docker_global_option_takes_value "$arg"; then
i=$((i + 1))
fi
;;
*)
echo "$i"
return 0
;;
esac
i=$((i + 1))
done
return 1
}
ARGS=("$@")
SUBCOMMAND_INDEX=""
if SUBCOMMAND_INDEX=$(find_subcommand_index "${ARGS[@]}"); then
SUBCOMMAND="${ARGS[$SUBCOMMAND_INDEX]}"
else
SUBCOMMAND=""
fi
# Block commands that could attach containers to other networks
case "$SUBCOMMAND" in
"network")
# Check for 'docker network connect' which could bypass firewall
# Allow 'docker network ls', 'docker network inspect', etc.
NETWORK_ARGS=("${ARGS[@]:$((SUBCOMMAND_INDEX + 1))}")
NETWORK_SUBCMD=""
if NETWORK_SUBCMD_INDEX=$(find_subcommand_index "${NETWORK_ARGS[@]}"); then
NETWORK_SUBCMD="${NETWORK_ARGS[$NETWORK_SUBCMD_INDEX]}"
fi
if [ "$NETWORK_SUBCMD" = "connect" ]; then
echo "ERROR: 'docker network connect' is blocked by AWF firewall." >&2
echo "Child containers must share the agent's network namespace for security." >&2
exit 1
fi
exec "$REAL_DOCKER" "${ARGS[@]}"

Copilot uses AI. Check for mistakes.
;;

"run"|"create")
# Intercept 'docker run' and 'docker create' to enforce shared network namespace
# This ensures child containers use the agent's NAT rules (traffic -> Squid proxy)
CMD="$1"
shift # remove 'run' or 'create'

FILTERED_ARGS=()
SKIP_NEXT=false

for arg in "$@"; do
if [ "$SKIP_NEXT" = true ]; then
SKIP_NEXT=false
continue
fi

case "$arg" in
# Strip --network=* and --net=* (combined flag=value form)
--network=*|--net=*)
echo "WARNING: AWF stripped '$arg' — child containers must share agent's network namespace" >&2
continue
;;
# Strip --network and --net (separate flag value form)
--network|--net)
echo "WARNING: AWF stripped '$arg' — child containers must share agent's network namespace" >&2
SKIP_NEXT=true
continue
;;
*)
FILTERED_ARGS+=("$arg")
;;
esac
done

# Build the extra flags to inject
INJECT_FLAGS=("--network" "container:${AGENT_CONTAINER}")

# Propagate host.docker.internal DNS to child containers when host access is enabled.
# The agent container gets this via Docker's extra_hosts in docker-compose.yml,
# but child containers spawned via 'docker run' don't inherit it automatically.
if [ "${AWF_ENABLE_HOST_ACCESS:-}" = "1" ]; then

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check only injects the --add-host host.docker.internal:host-gateway mapping when AWF_ENABLE_HOST_ACCESS is exactly "1". Elsewhere (e.g. setup-iptables.sh) host access is treated as enabled when the value is merely non-empty, and docker-manager has a code path that sets it to "true" as a safety net. To avoid inconsistent behavior, consider treating any non-empty value as enabled here (or normalize the env var to a single canonical value everywhere).

Suggested change
if [ "${AWF_ENABLE_HOST_ACCESS:-}" = "1" ]; then
if [ -n "${AWF_ENABLE_HOST_ACCESS:-}" ]; then

Copilot uses AI. Check for mistakes.
INJECT_FLAGS+=("--add-host" "host.docker.internal:host-gateway")
fi

exec "$REAL_DOCKER" "$CMD" "${INJECT_FLAGS[@]}" "${FILTERED_ARGS[@]}"
;;

"compose")
# For docker compose, we cannot easily rewrite compose files.
# Block it to prevent spawning services on separate networks.
echo "ERROR: 'docker compose' is blocked by AWF firewall." >&2
echo "Use 'docker run' instead — AWF will enforce shared network namespace." >&2
exit 1
;;

*)
# All other commands (ps, logs, inspect, exec, build, images, etc.) pass through
exec "$REAL_DOCKER" "$@"
;;
esac
5 changes: 5 additions & 0 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#!/bin/bash
set -e

# SECURITY: Lock down AWF control variables to prevent tampering by user code.
# These are set by the Docker Compose environment and must not be modified.
readonly AWF_ENABLE_HOST_ACCESS="${AWF_ENABLE_HOST_ACCESS:-}"
export AWF_ENABLE_HOST_ACCESS
Comment on lines +4 to +12

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readonly only protects the variable in the current shell instance; the attribute is not inherited by new shells. User code can still run a subshell (e.g. bash -c 'AWF_ENABLE_HOST_ACCESS=1 ...') and override the exported value for child processes. If this variable is used as a security boundary, consider an enforcement mechanism that can’t be overridden by spawning a new shell (e.g. derive host-access enablement from immutable container config or a root-owned file, rather than an exported env var).

Suggested change
# SECURITY: Lock down AWF control variables to prevent tampering by user code.
# These are set by the Docker Compose environment and must not be modified.
readonly AWF_ENABLE_HOST_ACCESS="${AWF_ENABLE_HOST_ACCESS:-}"
export AWF_ENABLE_HOST_ACCESS
# SECURITY: Do not use an exported environment variable as the enforcement source
# for host-access enablement. A child shell can override exported variables even
# if they were marked readonly in this shell. Persist the value in a root-owned
# runtime file instead and expose only the file path to child processes.
AWF_RUNTIME_DIR="/run/awf"
AWF_ENABLE_HOST_ACCESS_FILE="$AWF_RUNTIME_DIR/enable_host_access"
mkdir -p "$AWF_RUNTIME_DIR"
chmod 0755 "$AWF_RUNTIME_DIR"
printf '%s\n' "${AWF_ENABLE_HOST_ACCESS:-}" > "$AWF_ENABLE_HOST_ACCESS_FILE"
chmod 0644 "$AWF_ENABLE_HOST_ACCESS_FILE"
readonly AWF_RUNTIME_DIR
readonly AWF_ENABLE_HOST_ACCESS_FILE
export AWF_ENABLE_HOST_ACCESS_FILE
unset AWF_ENABLE_HOST_ACCESS
readonly AWF_ENABLE_HOST_ACCESS=""

Copilot uses AI. Check for mistakes.

echo "[entrypoint] Agentic Workflow Firewall - Agent Container"
echo "[entrypoint] =================================="

Expand Down
17 changes: 17 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,23 @@ describe('docker-manager', () => {

expect(env.AWF_ENABLE_HOST_ACCESS).toBeUndefined();
});

it('should propagate AWF_ENABLE_HOST_ACCESS to iptables-init container', () => {
const config = { ...mockConfig, enableHostAccess: true };
const result = generateDockerCompose(config, mockNetworkConfig);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const initService = result.services['iptables-init'] as any;

expect(initService.environment.AWF_ENABLE_HOST_ACCESS).toBe('1');
});

it('should pass empty AWF_ENABLE_HOST_ACCESS to iptables-init when host access is disabled', () => {
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const initService = result.services['iptables-init'] as any;

expect(initService.environment.AWF_ENABLE_HOST_ACCESS).toBe('');
});
});

describe('NO_PROXY baseline', () => {
Expand Down
Loading