ASK KNOX
beta
LESSON 235

Permission Drift from Automation

AI agent sessions create files with restrictive permissions that silently break Docker builds. The invisible bomb pattern.

9 min read·Infrastructure Drift

Twenty Python files. All readable on the host. All committed to git. All imported successfully in local tests. Inside the Docker container, they caused a crash loop on startup.

The cause: . The AI agent session that created the files had a restrictive umask. The files were mode 600 — readable only by their owner. The container ran as appuser. appuser was not the owner. Python could not import the modules.

The permission was invisible until the container tried to use it.

How umask Works

Every process has a — a bitmask that is subtracted from default permissions when new files are created. The standard system default is 022:

Default file permissions: 666 (rw-rw-rw-)
Minus umask 022:          644 (rw-r--r--)
Result:                   644 (owner read/write, others read)

A restrictive umask of 077 removes group and other permissions entirely:

Default file permissions: 666 (rw-rw-rw-)
Minus umask 077:          600 (rw-------)
Result:                   600 (owner read/write only)

AI agent sessions — Claude Code, automated tooling, CI runners — often run with restrictive umasks. This is a security-conscious default: processes creating temporary files should not expose them to other users on the system. The problem arises when those files are not temporary. When an agent session writes production source code, the restrictive umask follows those files into the repo.

The Semantic Memory Layer Permission Bomb

During a coding session, an AI agent wrote and edited approximately twenty Python files across the Semantic Memory Layer repo. The agent session had umask 077. Every file it touched — new files it created, and files it modified — inherited that umask.

The files looked normal:

$ git status
# Nothing suspicious — all files tracked, no unexpected changes

$ cat memory_service/query/discover.py
# Reads fine — you are the owner

git status does not show permission modes by default. cat works because the developer running the command is the file owner. Local tests pass because pytest runs as the same user. The bomb is invisible to every tool in the normal development workflow.

The Docker container ran as appuser — a non-root user created specifically to avoid running the service as root. appuser was not the owner of the files. When Python tried to import memory_service.query.discover, it encountered mode 600 and threw:

PermissionError: [Errno 13] Permission denied: '/app/memory_service/query/discover.py'

The container entered a crash loop. Docker marked it unhealthy after three failed restarts. The service was down.

The Cache Trap

The first fix attempt was to chmod the affected files and rebuild:

chmod 644 memory_service/**/*.py
docker compose build memory-service
docker compose up -d memory-service

The container still crashed.

The cause: Docker build layer caching. The COPY . /app instruction in the Dockerfile had been executed and cached when the files had mode 600. On the builder in this incident, running docker compose build did not invalidate the cache — the file bytes were unchanged, and that builder's COPY cache key did not account for the permission change. The COPY layer was served from cache. The appuser inside the container still saw mode 600.

Whether a permission-only change busts the COPY cache depends on the builder version — current BuildKit releases do include permission bits in the cache key, while older builders did not. Never assume either way: read the build output and treat a CACHED marker on the COPY step as proof that your change did not land.

A second rebuild attempt with --pull (to refresh the base image) also left the COPY layer CACHED--pull does not invalidate it. Two rebuilds in a row had failed. The fix required bypassing the cache entirely:

docker compose build --no-cache memory-service
docker compose up -d memory-service

With --no-cache, Docker re-executed every layer including COPY. The new 644 permissions were captured. The container started successfully on the third attempt. (The same cache trap is dissected in depth in the Docker Cache Hides Fixes lesson, where it surfaced again on the service's config files.)

Defense Layer 1: chmod in Dockerfile

The most robust fix embeds the permission correction in the Dockerfile itself. Regardless of what permissions the host files have when they enter the image, the Dockerfile normalizes them:

FROM python:3.12-slim

WORKDIR /app

# Create non-root user
RUN useradd -r -u 1001 -s /sbin/nologin appuser

# Copy source
COPY . /app

# Normalize permissions regardless of host umask
RUN find /app -type f -name "*.py" -exec chmod 644 {} \; && \
    find /app -type d -exec chmod 755 {} \;

# Switch to non-root user
USER appuser

CMD ["python", "-m", "uvicorn", "memory_service.main:app", "--host", "0.0.0.0", "--port", "8080"]

The RUN chmod step executes inside the container layer, after COPY. It normalizes Python file permissions to 644 and directory permissions to 755. The running user's identity is irrelevant at this layer — the RUN step executes as root, which can chmod anything.

This defense is unconditional. It does not matter what umask the agent had, what the developer's local umask is, or whether someone ran chmod before building. The Dockerfile enforces the correct permissions on every build.

Defense Layer 2: Pre-Build Scan

A pre-build scan catches the problem before it enters the container. Add a script to CI and to the local deploy checklist:

#!/bin/bash
# scripts/check-permissions.sh
# Run before docker compose build

VIOLATIONS=()
while IFS= read -r file; do
    mode=$(stat -f "%Lp" "$file" 2>/dev/null || stat -c "%a" "$file" 2>/dev/null)
    if [ "$mode" = "600" ] || [ "$mode" = "700" ]; then
        VIOLATIONS+=("$file (mode: $mode)")
    fi
done < <(find . -type f -name "*.py" ! -path "./.git/*")

if [ ${#VIOLATIONS[@]} -gt 0 ]; then
    echo "PERMISSION VIOLATIONS:"
    for v in "${VIOLATIONS[@]}"; do
        echo "  $v"
    done
    echo ""
    echo "Run: find . -name '*.py' -exec chmod 644 {} \\;"
    exit 1
fi

echo "Permissions OK"
exit 0

Integrate this into the deploy workflow:

# In deploy script or Makefile
deploy:
    bash scripts/check-permissions.sh
    docker compose build memory-service
    docker compose up -d memory-service

The scan adds under one second to the build process. It catches the problem on the host, where the fix is trivial — a single chmod command — rather than inside the container, where the diagnosis takes minutes.

Defense Layer 3: CI Permission Gate

Add a permission check to the CI workflow so that files with restrictive modes never merge to main:

# .github/workflows/test.yml
- name: Check file permissions
  run: |
    VIOLATIONS=$(find . -type f -name "*.py" ! -path "./.git/*" \
      ! -perm -044 -print)
    if [ -n "$VIOLATIONS" ]; then
      echo "Files with owner-only permissions:"
      echo "$VIOLATIONS"
      exit 1
    fi

The ! -perm -044 test matches files that are missing group read or other read — a mode 600 file matches (violation), while a normal 644 file does not. This check fails the PR if any Python file lacks group or other read permissions. The AI agent's restrictive umask is caught before the branch is ever merged, before a container is ever built.

Detecting Agent Sessions as the Source

When debugging a permission problem, check whether the affected files were all modified in the same coding session:

git log --format="%H %ae %aI" -- memory_service/query/discover.py

If the files share a commit author and timestamp that corresponds to an agent session, the umask is the likely culprit. Check whether the commit author is the Claude Code agent identity.

You can also check what umask the agent's environment is using:

# Linux: read a running process's umask directly (process must be running)
cat /proc/$(pgrep -f claude)/status | grep Umask

# macOS has no per-process umask getter. Instead, run `umask` in the same
# shell or session that launches the agent:
umask

# Or infer it from the modes of files the agent just created:
stat -f "%Lp %N" memory_service/query/*.py   # macOS (use stat -c "%a %n" on Linux)

Key Takeaways

  • AI agent sessions running with restrictive umask (e.g., 077) create Python files with mode 600 — readable only by the owner.
  • These files look completely normal on the host because the developer is the owner. They detonate when a Docker container running as a different user tries to import them.
  • The first rebuild after fixing permissions can fail silently due to Docker layer caching on some builder versions. Check the build output for a CACHED marker on the COPY step; --no-cache guarantees the fix lands regardless of builder version.
  • The most reliable defense is a RUN chmod step in the Dockerfile, after COPY, which normalizes permissions unconditionally on every build.
  • A pre-build scan (find . -name "*.py" ! -perm -044) and CI gate catch the problem before it reaches the container.

What's Next

Permission drift is one source of invisible accumulation. In the next lesson, we examine process accumulation — zombie processes, orphaned test runners, and duplicate service instances that pile up silently and consume resources without any alert firing.