Skip to content

Commit 3f0f816

Browse files
Mossakaclaude
andauthored
feat(docker): separate iptables setup into init container (#1281)
* feat(security): separate iptables setup into init container Add awf-iptables-init service that shares the agent's network namespace via network_mode: "service:agent" and runs setup-iptables.sh before signaling readiness. The agent container never receives NET_ADMIN capability, eliminating the startup window where privileged capabilities were held. Key changes: - Add iptables-init service to docker-compose with NET_ADMIN + cap_drop ALL - Remove NET_ADMIN from agent container's cap_add - Agent entrypoint waits for /tmp/awf-init/ready signal (30s timeout) - Init container uses same image as agent, exits after iptables setup - Update cleanup scripts to handle awf-iptables-init container Fixes #375 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add agent healthcheck to prevent init container race condition The iptables-init container uses network_mode: service:agent to share the agent's network namespace. With depends_on: service_started, Docker may try to look up the agent's PID in /proc before it's fully visible, causing "lstat /proc/PID/ns/net: no such file or directory". Adding a healthcheck to the agent and using service_healthy ensures the PID is stable before the init container starts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pre-build Docker images in Test Examples CI The init container architecture requires the agent image to have the updated entrypoint that waits for the init container's ready signal. Without pre-building, examples use GHCR images with the old entrypoint, causing the agent to exit because it tries to run setup-iptables.sh without NET_ADMIN capability. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pass SQUID_PROXY_HOST/PORT to init container for DNS resolution setup-iptables.sh reads SQUID_PROXY_HOST (not AWF_SQUID_HOST), but the init container only passed AWF_SQUID_HOST. Since the init container uses network_mode: service:agent, it may not have DNS resolution for compose service names, causing getent hosts to fail and the script to exit before writing the ready signal. Use the direct IP address to avoid DNS issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: skip DNS reverse lookup when SQUID_HOST is already an IP address The init container passes SQUID_PROXY_HOST as a direct IP (172.30.0.10) to bypass DNS resolution. But setup-iptables.sh runs getent hosts on it, which does a reverse DNS lookup that fails in Docker containers, causing the init container to exit before writing the ready signal. The agent then times out after 30s waiting for /tmp/awf-init/ready. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add NET_RAW capability to init container and improve diagnostics The iptables init container was hanging because cap_drop: ALL removed NET_RAW which iptables needs for netfilter socket operations. Also removed no-new-privileges which can block iptables binary execution. Added diagnostic output logging: setup-iptables.sh output is written to /tmp/awf-init/output.log (shared volume), and on timeout the entrypoint displays the log for easier CI debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: override init container entrypoint to prevent deadlock The init container uses the same Docker image as the agent, which has ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]. The entrypoint.sh contains an "init container wait" loop that waits for /tmp/awf-init/ready to appear. When the init container runs through this same entrypoint, it deadlocks waiting for itself to signal readiness. Fix: Set entrypoint: ['/bin/bash'] on the init container to bypass entrypoint.sh and run setup-iptables.sh directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: set AWF_API_PROXY_IP before init container definition The init container's environment object captures values at definition time (JavaScript object literal evaluation). AWF_API_PROXY_IP was set on line 1196 (inside the enableApiProxy block) but read on line 1076 (init container definition), so the init container always got an empty string. This caused setup-iptables.sh to skip adding ACCEPT rules for the API proxy IP (172.30.0.30), blocking agent→api-proxy connectivity and failing the API proxy health check. Move the assignment before the init container definition so the value is available when the object literal is evaluated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 221cc8f commit 3f0f816

File tree

6 files changed

+183
-29
lines changed

6 files changed

+183
-29
lines changed

.github/workflows/test-examples.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ jobs:
3838
- name: Install awf globally
3939
run: sudo npm link
4040

41+
- name: Build Docker images locally
42+
run: |
43+
# Build agent and squid images from source and tag as GHCR images
44+
# so examples that use default GHCR images get the PR's code
45+
docker build -t ghcr.io/github/gh-aw-firewall/agent:latest containers/agent/
46+
docker build -t ghcr.io/github/gh-aw-firewall/squid:latest containers/squid/
47+
4148
- name: Pre-test cleanup
4249
run: sudo ./scripts/ci/cleanup.sh
4350

containers/agent/entrypoint.sh

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,28 @@ if [ "${AWF_SSL_BUMP_ENABLED}" = "true" ]; then
129129
fi
130130
fi
131131

132-
# Setup iptables rules
133-
/usr/local/bin/setup-iptables.sh
132+
# Wait for iptables init container to complete setup
133+
# The awf-iptables-init container shares our network namespace and runs
134+
# setup-iptables.sh, then writes a ready signal file. This ensures the agent
135+
# container NEVER needs NET_ADMIN capability.
136+
echo "[entrypoint] Waiting for iptables initialization from init container..."
137+
INIT_TIMEOUT=300 # 300 * 0.1s = 30 seconds
138+
INIT_ELAPSED=0
139+
while [ ! -f /tmp/awf-init/ready ]; do
140+
if [ "$INIT_ELAPSED" -ge "$INIT_TIMEOUT" ]; then
141+
echo "[entrypoint][ERROR] Timed out waiting for iptables init container after 30s"
142+
if [ -f /tmp/awf-init/output.log ]; then
143+
echo "[entrypoint] Init container output:"
144+
cat /tmp/awf-init/output.log
145+
else
146+
echo "[entrypoint] No init container output log found"
147+
fi
148+
exit 1
149+
fi
150+
sleep 0.1
151+
INIT_ELAPSED=$((INIT_ELAPSED + 1))
152+
done
153+
echo "[entrypoint] iptables initialization complete"
134154

135155
# Run API proxy health checks (verifies credential isolation and connectivity)
136156
# This must run AFTER iptables setup (which allows api-proxy traffic) but BEFORE user command
@@ -289,15 +309,19 @@ runuser -u awfuser -- git config --global --add safe.directory '*' 2>/dev/null |
289309
echo "[entrypoint] =================================="
290310

291311
# Determine which capabilities to drop
292-
# - CAP_NET_ADMIN is always dropped (prevents iptables bypass)
312+
# - CAP_NET_ADMIN is NOT present (never granted to agent container - iptables setup
313+
# is handled by the separate awf-iptables-init container)
293314
# - CAP_SYS_CHROOT is dropped when chroot mode is enabled (prevents user code from using chroot)
294315
# - CAP_SYS_ADMIN is dropped when chroot mode is enabled (was needed for mounting procfs)
295316
if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
296-
CAPS_TO_DROP="cap_net_admin,cap_sys_chroot,cap_sys_admin"
297-
echo "[entrypoint] Chroot mode enabled - dropping CAP_NET_ADMIN, CAP_SYS_CHROOT, and CAP_SYS_ADMIN"
317+
CAPS_TO_DROP="cap_sys_chroot,cap_sys_admin"
318+
echo "[entrypoint] Chroot mode enabled - dropping CAP_SYS_CHROOT and CAP_SYS_ADMIN"
298319
else
299-
CAPS_TO_DROP="cap_net_admin"
300-
echo "[entrypoint] Dropping CAP_NET_ADMIN capability"
320+
# In non-chroot mode, no capabilities need to be dropped
321+
# NET_ADMIN is never granted (init container handles iptables)
322+
# SYS_CHROOT and SYS_ADMIN are only needed/dropped in chroot mode
323+
CAPS_TO_DROP=""
324+
echo "[entrypoint] No capabilities to drop (NET_ADMIN never granted to agent)"
301325
fi
302326

303327
# Function to unset sensitive tokens from the entrypoint's environment
@@ -664,7 +688,12 @@ else
664688
# SECURITY: Run agent command in background, then unset tokens from parent shell
665689
# This prevents tokens from being accessible via /proc/1/environ after agent starts
666690
# The one-shot-token library caches tokens in the agent process, so agent can still read them
667-
capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" &
691+
if [ -n "$CAPS_TO_DROP" ]; then
692+
capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" &
693+
else
694+
# No capabilities to drop - just switch to unprivileged user
695+
gosu awfuser "$@" &
696+
fi
668697
AGENT_PID=$!
669698

670699
# Wait for agent to initialize and cache tokens (5 seconds)

containers/agent/setup-iptables.sh

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,21 @@ SQUID_PORT="${SQUID_PROXY_PORT:-3128}"
4444
echo "[iptables] Squid proxy: ${SQUID_HOST}:${SQUID_PORT}"
4545

4646
# Resolve Squid hostname to IP
47-
# Use awk's NR to get first line to avoid host binary dependency in chroot mode
48-
SQUID_IP=$(getent hosts "$SQUID_HOST" | awk 'NR==1 { print $1 }')
49-
if [ -z "$SQUID_IP" ]; then
50-
echo "[iptables] ERROR: Could not resolve Squid proxy hostname: $SQUID_HOST"
51-
exit 1
47+
# If SQUID_HOST is already a valid IPv4 address, use it directly (no DNS lookup needed).
48+
# This is important for the init container which passes a direct IP via SQUID_PROXY_HOST
49+
# because getent hosts with an IP does a reverse DNS lookup that fails in Docker.
50+
if is_valid_ipv4 "$SQUID_HOST"; then
51+
SQUID_IP="$SQUID_HOST"
52+
echo "[iptables] Squid host is already an IP address: $SQUID_IP"
53+
else
54+
# Use awk's NR to get first line to avoid host binary dependency in chroot mode
55+
SQUID_IP=$(getent hosts "$SQUID_HOST" | awk 'NR==1 { print $1 }')
56+
if [ -z "$SQUID_IP" ]; then
57+
echo "[iptables] ERROR: Could not resolve Squid proxy hostname: $SQUID_HOST"
58+
exit 1
59+
fi
60+
echo "[iptables] Squid IP resolved to: $SQUID_IP"
5261
fi
53-
echo "[iptables] Squid IP resolved to: $SQUID_IP"
5462

5563
# Clear existing NAT rules (both IPv4 and IPv6)
5664
iptables -t nat -F OUTPUT 2>/dev/null || true

scripts/ci/cleanup.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ echo "==========================================="
1212

1313
# First, explicitly remove containers by name (handles orphaned containers)
1414
echo "Removing awf containers by name..."
15-
docker rm -f awf-squid awf-agent awf-api-proxy 2>/dev/null || true
15+
docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy 2>/dev/null || true
1616

1717
# Cleanup diagnostic test containers
1818
echo "Stopping docker compose services..."

src/docker-manager.test.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -727,11 +727,12 @@ describe('docker-manager', () => {
727727
expect(volumes).toContain(`${homeDir}/.copilot:/host${homeDir}/.copilot:rw`);
728728
});
729729

730-
it('should add SYS_CHROOT and SYS_ADMIN capabilities', () => {
730+
it('should add SYS_CHROOT and SYS_ADMIN capabilities but NOT NET_ADMIN', () => {
731731
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
732732
const agent = result.services.agent;
733733

734-
expect(agent.cap_add).toContain('NET_ADMIN');
734+
// NET_ADMIN is NOT on the agent - it's on the iptables-init container
735+
expect(agent.cap_add).not.toContain('NET_ADMIN');
735736
expect(agent.cap_add).toContain('SYS_CHROOT');
736737
// SYS_ADMIN is needed to mount procfs at /host/proc for dynamic /proc/self/exe
737738
expect(agent.cap_add).toContain('SYS_ADMIN');
@@ -1062,14 +1063,37 @@ describe('docker-manager', () => {
10621063
expect(depends['squid-proxy'].condition).toBe('service_healthy');
10631064
});
10641065

1065-
it('should add NET_ADMIN capability to agent for iptables setup', () => {
1066-
// NET_ADMIN is required at container start for setup-iptables.sh
1067-
// The capability is dropped before user command execution via capsh
1068-
// (see containers/agent/entrypoint.sh)
1066+
it('should NOT add NET_ADMIN to agent (handled by iptables-init container)', () => {
1067+
// NET_ADMIN is NOT granted to the agent container.
1068+
// iptables setup is performed by the awf-iptables-init service which shares
1069+
// the agent's network namespace.
10691070
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
10701071
const agent = result.services.agent;
10711072

1072-
expect(agent.cap_add).toContain('NET_ADMIN');
1073+
expect(agent.cap_add).not.toContain('NET_ADMIN');
1074+
});
1075+
1076+
it('should add iptables-init service with NET_ADMIN capability', () => {
1077+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
1078+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1079+
const initService = result.services['iptables-init'] as any;
1080+
1081+
expect(initService).toBeDefined();
1082+
expect(initService.container_name).toBe('awf-iptables-init');
1083+
expect(initService.cap_add).toEqual(['NET_ADMIN', 'NET_RAW']);
1084+
expect(initService.cap_drop).toEqual(['ALL']);
1085+
expect(initService.network_mode).toBe('service:agent');
1086+
expect(initService.depends_on).toEqual({
1087+
'agent': { condition: 'service_healthy' },
1088+
});
1089+
// Entrypoint is overridden to bypass agent's entrypoint.sh (which has init wait loop)
1090+
expect(initService.entrypoint).toEqual(['/bin/bash']);
1091+
expect(initService.command).toEqual([
1092+
'-c',
1093+
'/usr/local/bin/setup-iptables.sh > /tmp/awf-init/output.log 2>&1 && touch /tmp/awf-init/ready',
1094+
]);
1095+
expect(initService.security_opt).toBeUndefined();
1096+
expect(initService.restart).toBe('no');
10731097
});
10741098

10751099
it('should apply container hardening measures', () => {
@@ -1419,7 +1443,7 @@ describe('docker-manager', () => {
14191443
expect(result.services.agent.working_dir).toBe('/custom/workdir');
14201444
// Verify other config is still present
14211445
expect(result.services.agent.container_name).toBe('awf-agent');
1422-
expect(result.services.agent.cap_add).toContain('NET_ADMIN');
1446+
expect(result.services.agent.cap_add).toContain('SYS_CHROOT');
14231447
});
14241448

14251449
it('should handle empty string containerWorkDir by not setting working_dir', () => {
@@ -2435,7 +2459,7 @@ describe('docker-manager', () => {
24352459

24362460
expect(mockExecaFn).toHaveBeenCalledWith(
24372461
'docker',
2438-
['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy'],
2462+
['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy'],
24392463
{ reject: false }
24402464
);
24412465
});

src/docker-manager.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,12 @@ export function generateDockerCompose(
549549
// Only mount the workspace directory ($GITHUB_WORKSPACE or current working directory)
550550
// to prevent access to credential files in $HOME
551551
const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd();
552+
// Create init-signal directory for iptables init container coordination
553+
const initSignalDir = path.join(config.workDir, 'init-signal');
554+
if (!fs.existsSync(initSignalDir)) {
555+
fs.mkdirSync(initSignalDir, { recursive: true });
556+
}
557+
552558
const agentVolumes: string[] = [
553559
// Essential mounts that are always included
554560
'/tmp:/tmp:rw',
@@ -557,6 +563,8 @@ export function generateDockerCompose(
557563
`${workspaceDir}:${workspaceDir}:rw`,
558564
// Mount agent logs directory to workDir for persistence
559565
`${config.workDir}/agent-logs:${effectiveHome}/.copilot/logs:rw`,
566+
// Init signal volume for iptables init container coordination
567+
`${initSignalDir}:/tmp/awf-init:rw`,
560568
];
561569

562570
// Volume mounts for chroot /host to work properly with host binaries
@@ -945,13 +953,15 @@ export function generateDockerCompose(
945953
condition: 'service_healthy',
946954
},
947955
},
948-
// NET_ADMIN is required for iptables setup in entrypoint.sh.
956+
// SECURITY: NET_ADMIN is NOT granted to the agent container.
957+
// iptables setup is performed by the awf-iptables-init service which shares
958+
// the agent's network namespace via network_mode: "service:agent".
949959
// SYS_CHROOT is required for chroot operations.
950960
// SYS_ADMIN is required to mount procfs at /host/proc (required for
951961
// dynamic /proc/self/exe resolution needed by .NET CLR and other runtimes).
952-
// Security: All capabilities are dropped before running user commands
953-
// via 'capsh --drop=cap_net_admin,cap_sys_chroot,cap_sys_admin' in entrypoint.sh.
954-
cap_add: ['NET_ADMIN', 'SYS_CHROOT', 'SYS_ADMIN'],
962+
// Security: SYS_CHROOT and SYS_ADMIN are dropped before running user commands
963+
// via 'capsh --drop=cap_sys_chroot,cap_sys_admin' in entrypoint.sh.
964+
cap_add: ['SYS_CHROOT', 'SYS_ADMIN'],
955965
// Drop capabilities to reduce attack surface (security hardening)
956966
cap_drop: [
957967
'NET_RAW', // Prevents raw socket creation (iptables bypass attempts)
@@ -976,6 +986,17 @@ export function generateDockerCompose(
976986
cpu_shares: 1024, // Default CPU share
977987
stdin_open: true,
978988
tty: config.tty || false, // Use --tty flag, default to false for clean logs
989+
// Healthcheck ensures the agent process is alive and its PID is visible in /proc
990+
// before the iptables-init container tries to join via network_mode: service:agent.
991+
// Without this, there's a race where the init container tries to look up the agent's
992+
// PID in /proc/PID/ns/net before the kernel has made it visible.
993+
healthcheck: {
994+
test: ['CMD-SHELL', 'true'],
995+
interval: '1s',
996+
timeout: '1s',
997+
retries: 3,
998+
start_period: '1s',
999+
},
9791000
// Escape $ with $$ for Docker Compose variable interpolation
9801001
command: ['/bin/bash', '-c', config.agentCommand.replace(/\$/g, '$$$$')],
9811002
};
@@ -1040,10 +1061,75 @@ export function generateDockerCompose(
10401061
agentService.image = agentImage;
10411062
}
10421063

1064+
// Pre-set API proxy IP in environment before the init container definition.
1065+
// The init container's environment object captures values at definition time,
1066+
// so AWF_API_PROXY_IP must be set before the init container is defined.
1067+
// Without this, the init container gets an empty AWF_API_PROXY_IP and
1068+
// setup-iptables.sh never adds ACCEPT rules for the API proxy, blocking connectivity.
1069+
if (config.enableApiProxy && networkConfig.proxyIp) {
1070+
environment.AWF_API_PROXY_IP = networkConfig.proxyIp;
1071+
}
1072+
1073+
// SECURITY: iptables init container - sets up NAT rules in a separate container
1074+
// that shares the agent's network namespace but NEVER gives NET_ADMIN to the agent.
1075+
// This eliminates the window where the agent holds NET_ADMIN during startup.
1076+
const iptablesInitService: any = {
1077+
container_name: 'awf-iptables-init',
1078+
// Share agent's network namespace so iptables rules apply to agent's traffic
1079+
network_mode: 'service:agent',
1080+
// Only mount the init signal volume and the iptables setup script
1081+
volumes: [
1082+
`${initSignalDir}:/tmp/awf-init:rw`,
1083+
],
1084+
environment: {
1085+
// Pass through environment variables needed by setup-iptables.sh
1086+
// IMPORTANT: setup-iptables.sh reads SQUID_PROXY_HOST/PORT (not AWF_ prefixed).
1087+
// Use the direct IP address since the init container (network_mode: service:agent)
1088+
// may not have DNS resolution for compose service names.
1089+
SQUID_PROXY_HOST: `${networkConfig.squidIp}`,
1090+
SQUID_PROXY_PORT: String(SQUID_PORT),
1091+
AWF_DNS_SERVERS: environment.AWF_DNS_SERVERS || '',
1092+
AWF_BLOCKED_PORTS: environment.AWF_BLOCKED_PORTS || '',
1093+
AWF_ENABLE_HOST_ACCESS: environment.AWF_ENABLE_HOST_ACCESS || '',
1094+
AWF_API_PROXY_IP: environment.AWF_API_PROXY_IP || '',
1095+
AWF_DOH_PROXY_IP: environment.AWF_DOH_PROXY_IP || '',
1096+
AWF_SSL_BUMP_ENABLED: environment.AWF_SSL_BUMP_ENABLED || '',
1097+
AWF_SSL_BUMP_INTERCEPT_PORT: environment.AWF_SSL_BUMP_INTERCEPT_PORT || '',
1098+
},
1099+
depends_on: {
1100+
'agent': {
1101+
condition: 'service_healthy',
1102+
},
1103+
},
1104+
// NET_ADMIN is required for iptables rule manipulation.
1105+
// NET_RAW is required by iptables for netfilter socket operations.
1106+
cap_add: ['NET_ADMIN', 'NET_RAW'],
1107+
cap_drop: ['ALL'],
1108+
// Override entrypoint to bypass the agent's entrypoint.sh, which contains an
1109+
// "init container wait" loop that would deadlock (the init container waiting for itself).
1110+
// The init container only needs to run setup-iptables.sh directly.
1111+
entrypoint: ['/bin/bash'],
1112+
// Run setup-iptables.sh then signal readiness; log output to shared volume for diagnostics
1113+
command: ['-c', '/usr/local/bin/setup-iptables.sh > /tmp/awf-init/output.log 2>&1 && touch /tmp/awf-init/ready'],
1114+
// Resource limits (init container exits quickly)
1115+
mem_limit: '128m',
1116+
pids_limit: 50,
1117+
// Restart policy: never restart (init container runs once)
1118+
restart: 'no',
1119+
};
1120+
1121+
// Use the same image/build as the agent container for the iptables init service
1122+
if (agentService.image) {
1123+
iptablesInitService.image = agentService.image;
1124+
} else if (agentService.build) {
1125+
iptablesInitService.build = agentService.build;
1126+
}
1127+
10431128
// API Proxy sidecar service (Node.js) - optionally deployed
10441129
const services: Record<string, any> = {
10451130
'squid-proxy': squidService,
10461131
'agent': agentService,
1132+
'iptables-init': iptablesInitService,
10471133
};
10481134

10491135
// Add Node.js API proxy sidecar if enabled
@@ -1491,7 +1577,7 @@ export async function startContainers(workDir: string, allowedDomains: string[],
14911577
// This handles orphaned containers from failed/interrupted previous runs
14921578
logger.debug('Removing any existing containers with conflicting names...');
14931579
try {
1494-
await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy'], {
1580+
await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy'], {
14951581
reject: false,
14961582
});
14971583
} catch {

0 commit comments

Comments
 (0)