mirror of
https://github.com/sligter/LandPPT.git
synced 2026-08-28 23:31:06 +08:00
Remove outdated design documents for Slide Edit Agent and Non-Root Container Security
This commit is contained in:
+2
-1
@@ -2,6 +2,7 @@
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
.agents/
|
||||
dist/
|
||||
lib/
|
||||
artifacts/
|
||||
@@ -11,6 +12,7 @@ research_reports/
|
||||
wheels/
|
||||
.claude/
|
||||
docs/
|
||||
data/
|
||||
*.egg-info
|
||||
requires.md
|
||||
prompts.md
|
||||
@@ -24,7 +26,6 @@ CLAUDE.md
|
||||
# Virtual environments
|
||||
.venv
|
||||
.claude
|
||||
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
|
||||
|
||||
@@ -1,876 +0,0 @@
|
||||
# Non-Root Container Security Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the LandPPT image and all long-lived application workloads run as fixed non-root UID/GID `10001`, while automatically migrating existing Compose volumes and Helm PVC permissions.
|
||||
|
||||
**Architecture:** The image declares `USER landppt` after all privileged build work. Docker Compose uses a capability-restricted, networkless one-shot service to migrate legacy volume ownership before web or worker startup; Kubernetes uses `fsGroup` with `OnRootMismatch`. Static regression tests lock the Dockerfile, entrypoint, Compose, migration script, and Helm manifests to the approved security contract.
|
||||
|
||||
**Tech Stack:** Dockerfile, POSIX shell, Docker Compose, Helm/Kubernetes YAML, Python 3.11, pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The runtime identity is fixed at UID/GID `10001` in the image, Compose migration, and Helm defaults.
|
||||
- Web, worker, health-check, and application migration commands must not run as UID `0`.
|
||||
- Existing repository-supported Compose named volumes and Helm PVCs must migrate without operator commands.
|
||||
- The Compose root helper must not mount `/app`, the source tree, the Docker socket, or any application network.
|
||||
- The Compose root helper drops all capabilities except `CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `SETUID`, and `SETGID`.
|
||||
- `.env` must remain writable by the application configuration flow but must not be mode `0666` in the image.
|
||||
- Volume migration is idempotent through `.landppt-permissions-v1` markers.
|
||||
- Permission validation fails closed and identifies the exact inaccessible path.
|
||||
- Direct hand-written `docker run` commands attaching legacy volumes are outside the automatic migration guarantee.
|
||||
- Do not dismiss or suppress Trivy `DS-0002`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `tests/test_container_security.py`
|
||||
- Owns static regression coverage for the Dockerfile, entrypoint, Compose services, migration script, and Helm templates.
|
||||
- Create `docker-permissions-init.sh`
|
||||
- Owns the one-shot migration of Compose-mounted `.env` and named volumes; it never starts LandPPT.
|
||||
- Modify `Dockerfile`
|
||||
- Creates fixed UID/GID `10001`, installs the migration script, sets secure ownership/modes, and declares `USER landppt`.
|
||||
- Modify `docker-entrypoint.sh`
|
||||
- Removes privileged repair behavior and performs non-root identity and path preflight checks.
|
||||
- Modify `docker-compose.yml`
|
||||
- Adds the production `permissions-init` service and makes web/worker wait for it.
|
||||
- Modify `docker-compose-dev.yaml`
|
||||
- Adds the source-mounted development stack's isolated `permissions-init` service without mounting the repository into it.
|
||||
- Modify `helm/landppt/values.yaml`
|
||||
- Supplies secure non-root pod/container defaults and PVC group migration policy.
|
||||
- Modify `helm/landppt/templates/worker-deployment.yaml`
|
||||
- Renders both pod and container security contexts for the worker, matching web and migration workloads.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Non-Root Runtime Image and Fail-Closed Entrypoint
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_container_security.py`
|
||||
- Modify: `Dockerfile:70-185`
|
||||
- Modify: `docker-entrypoint.sh:105-218`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: image account `landppt` with UID/GID `10001`
|
||||
- Produces: final Docker image metadata `Config.User=landppt`
|
||||
- Produces: entrypoint functions `check_runtime_identity()`, `check_env_permissions()`, and `create_directories()`
|
||||
- Consumes: existing runtime paths `/app/.env`, `/app/data`, `/app/uploads`, `/app/temp`, `/app/research_reports`, and `/app/lib`
|
||||
|
||||
- [ ] **Step 1: Write failing image and entrypoint security tests**
|
||||
|
||||
Create `tests/test_container_security.py` with:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read_repo_file(relative_path: str) -> str:
|
||||
return (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def production_stage(dockerfile: str) -> str:
|
||||
return dockerfile.split(
|
||||
"FROM python:3.11-slim-bookworm AS production", 1
|
||||
)[1]
|
||||
|
||||
|
||||
def test_runtime_image_uses_fixed_non_root_identity():
|
||||
stage = production_stage(read_repo_file("Dockerfile"))
|
||||
|
||||
assert "ARG LANDPPT_UID=10001" in stage
|
||||
assert "ARG LANDPPT_GID=10001" in stage
|
||||
assert "groupadd --gid \"${LANDPPT_GID}\" landppt" in stage
|
||||
assert "useradd --uid \"${LANDPPT_UID}\"" in stage
|
||||
assert "HOME=/home/landppt" in stage
|
||||
assert "chmod 640 /app/.env" in stage
|
||||
assert "chmod 666 /app/.env" not in stage
|
||||
assert "\nUSER landppt\n" in stage
|
||||
assert stage.rfind("USER landppt") < stage.find("HEALTHCHECK")
|
||||
|
||||
|
||||
def test_entrypoint_checks_identity_and_never_repairs_permissions():
|
||||
entrypoint = read_repo_file("docker-entrypoint.sh")
|
||||
|
||||
assert "check_runtime_identity()" in entrypoint
|
||||
assert 'if [ "$(id -u)" -eq 0 ]; then' in entrypoint
|
||||
assert "LandPPT must not run as root" in entrypoint
|
||||
assert "check_env_permissions()" in entrypoint
|
||||
assert "Required path is not writable" in entrypoint
|
||||
assert "fix_env_permissions" not in entrypoint
|
||||
assert "chmod " not in entrypoint
|
||||
assert "chown " not in entrypoint
|
||||
assert 'cp "/app/.env"' not in entrypoint
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify the current root image fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
```
|
||||
|
||||
Expected: two failures because the production stage has no fixed UID/GID or final `USER`, and the entrypoint still contains `fix_env_permissions()`.
|
||||
|
||||
- [ ] **Step 3: Give the production image a fixed non-root identity**
|
||||
|
||||
In `Dockerfile`, insert the identity arguments immediately after the production `FROM`:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim-bookworm AS production
|
||||
|
||||
ARG LANDPPT_UID=10001
|
||||
ARG LANDPPT_GID=10001
|
||||
```
|
||||
|
||||
Change the production environment's home entry to:
|
||||
|
||||
```dockerfile
|
||||
HOME=/home/landppt \
|
||||
```
|
||||
|
||||
Replace the current account creation block with:
|
||||
|
||||
```dockerfile
|
||||
# Create a stable non-root runtime identity.
|
||||
RUN groupadd --gid "${LANDPPT_GID}" landppt && \
|
||||
useradd --uid "${LANDPPT_UID}" \
|
||||
--gid landppt \
|
||||
--create-home \
|
||||
--home-dir /home/landppt \
|
||||
--shell /usr/sbin/nologin \
|
||||
landppt
|
||||
```
|
||||
|
||||
Change the final `.env` mode in the directory permission layer:
|
||||
|
||||
```dockerfile
|
||||
chmod 640 /app/.env
|
||||
```
|
||||
|
||||
Add the user switch immediately before `EXPOSE 8000`:
|
||||
|
||||
```dockerfile
|
||||
# Run the entrypoint, health check, web process, worker, and CLI as non-root.
|
||||
USER landppt
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace privileged entrypoint repair with explicit preflight checks**
|
||||
|
||||
Replace the existing `create_directories()` function in `docker-entrypoint.sh` with:
|
||||
|
||||
```bash
|
||||
# Create and verify runtime directories as the application user.
|
||||
create_directories() {
|
||||
log "Checking runtime directories..."
|
||||
|
||||
local dirs=(
|
||||
"/app/data"
|
||||
"/app/uploads"
|
||||
"/app/temp/ai_responses_cache"
|
||||
"/app/temp/style_genes_cache"
|
||||
"/app/temp/summeryanyfile_cache"
|
||||
"/app/temp/templates_cache"
|
||||
"/app/research_reports"
|
||||
"/app/lib/Linux"
|
||||
"/app/lib/MacOS"
|
||||
"/app/lib/Windows"
|
||||
)
|
||||
|
||||
for dir in "${dirs[@]}"; do
|
||||
if ! mkdir -p "$dir" 2>/dev/null; then
|
||||
error "Required path cannot be created: $dir"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -w "$dir" ] || [ ! -x "$dir" ]; then
|
||||
error "Required path is not writable: $dir"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
```
|
||||
|
||||
Replace the entire current `fix_env_permissions()` function with:
|
||||
|
||||
```bash
|
||||
# Refuse to start with a privileged identity.
|
||||
check_runtime_identity() {
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
error "LandPPT must not run as root"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Running as uid=$(id -u) gid=$(id -g)"
|
||||
}
|
||||
|
||||
# Validate .env access without trying to mutate mounted host files.
|
||||
check_env_permissions() {
|
||||
log "Checking .env file permissions..."
|
||||
|
||||
if [ ! -e "/app/.env" ]; then
|
||||
warn ".env file not found, using process environment only"
|
||||
return 0
|
||||
fi
|
||||
if [ ! -f "/app/.env" ]; then
|
||||
error "Required path is not a regular file: /app/.env"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -r "/app/.env" ]; then
|
||||
error "Required path is not readable: /app/.env"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -w "/app/.env" ]; then
|
||||
error "Required path is not writable: /app/.env"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log ".env file is readable and writable"
|
||||
}
|
||||
```
|
||||
|
||||
Update the initialization calls in `main()` to this order:
|
||||
|
||||
```bash
|
||||
check_runtime_identity
|
||||
check_environment
|
||||
check_env_permissions
|
||||
create_directories
|
||||
wait_for_dependencies
|
||||
import_templates
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the focused tests and shell syntax check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
bash -n docker-entrypoint.sh
|
||||
```
|
||||
|
||||
Expected: pytest reports `2 passed`; `bash -n` exits `0` with no output.
|
||||
|
||||
- [ ] **Step 6: Commit the non-root image boundary**
|
||||
|
||||
```bash
|
||||
git add Dockerfile docker-entrypoint.sh tests/test_container_security.py
|
||||
git commit -m "fix(docker): run LandPPT as non-root"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Automatic Compose Volume Permission Migration
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-permissions-init.sh`
|
||||
- Modify: `Dockerfile:164-174`
|
||||
- Modify: `docker-compose.yml:46-98`
|
||||
- Modify: `docker-compose-dev.yaml:53-110`
|
||||
- Modify: `tests/test_container_security.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `/usr/local/bin/docker-permissions-init.sh`
|
||||
- Produces: Compose service `permissions-init`
|
||||
- Produces: marker contract `<volume>/.landppt-permissions-v1`
|
||||
- Consumes: environment variables `LANDPPT_UID` and `LANDPPT_GID`, both defaulting to `10001`
|
||||
- Consumes: mounts `/mnt/landppt/env/.env`, `/mnt/landppt/data`, `/mnt/landppt/uploads`, `/mnt/landppt/reports`, `/mnt/landppt/cache`, and `/mnt/landppt/lib`
|
||||
|
||||
- [ ] **Step 1: Add failing Compose and migration-script tests**
|
||||
|
||||
Append this code to `tests/test_container_security.py`:
|
||||
|
||||
```python
|
||||
def compose_init_service(compose_text: str) -> str:
|
||||
return compose_text.split("\n permissions-init:\n", 1)[1].split(
|
||||
"\n landppt:\n", 1
|
||||
)[0]
|
||||
|
||||
|
||||
def compose_dependency_anchor(compose_text: str) -> str:
|
||||
return compose_text.split(
|
||||
"x-landppt-depends-on: &landppt-depends-on", 1
|
||||
)[1].split("\n\nservices:", 1)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compose_path", ["docker-compose.yml", "docker-compose-dev.yaml"])
|
||||
def test_compose_migrates_permissions_before_app_start(compose_path: str):
|
||||
compose_text = read_repo_file(compose_path)
|
||||
init_service = compose_init_service(compose_text)
|
||||
dependencies = compose_dependency_anchor(compose_text)
|
||||
|
||||
assert 'user: "0:0"' in init_service
|
||||
assert 'entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]' in init_service
|
||||
assert 'network_mode: "none"' in init_service
|
||||
assert "read_only: true" in init_service
|
||||
assert 'restart: "no"' in init_service
|
||||
assert "- ALL" in init_service
|
||||
for capability in ("CHOWN", "FOWNER", "DAC_OVERRIDE", "SETUID", "SETGID"):
|
||||
assert f"- {capability}" in init_service
|
||||
assert "no-new-privileges:true" in init_service
|
||||
|
||||
for mount in (
|
||||
"${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env",
|
||||
"landppt_data:/mnt/landppt/data",
|
||||
"landppt_uploads:/mnt/landppt/uploads",
|
||||
"landppt_reports:/mnt/landppt/reports",
|
||||
"landppt_cache:/mnt/landppt/cache",
|
||||
"landppt_lib:/mnt/landppt/lib",
|
||||
):
|
||||
assert mount in init_service
|
||||
|
||||
assert "/app" not in init_service
|
||||
assert "${LANDPPT_ENV_FILE:-./.env}:/app/.env" in compose_text
|
||||
assert "permissions-init:" in dependencies
|
||||
assert "condition: service_completed_successfully" in dependencies
|
||||
|
||||
|
||||
def test_permission_migration_script_is_idempotent_and_validates_as_target_user():
|
||||
script = read_repo_file("docker-permissions-init.sh")
|
||||
dockerfile = read_repo_file("Dockerfile")
|
||||
|
||||
assert ".landppt-permissions-v1" in script
|
||||
assert 'LANDPPT_UID:-10001' in script
|
||||
assert 'LANDPPT_GID:-10001' in script
|
||||
assert "os.setgid(gid)" in script
|
||||
assert "os.setuid(uid)" in script
|
||||
assert 'chown -R "${TARGET_UID}:${TARGET_GID}"' in script
|
||||
assert "chmod -R u+rwX" in script
|
||||
assert "docker-permissions-init.sh /usr/local/bin/" in dockerfile
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify Compose migration is missing**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
```
|
||||
|
||||
Expected: the two Task 1 tests pass; three new cases fail because `permissions-init` and `docker-permissions-init.sh` do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the one-shot permission migration script**
|
||||
|
||||
Create `docker-permissions-init.sh` with:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
TARGET_UID="${LANDPPT_UID:-10001}"
|
||||
TARGET_GID="${LANDPPT_GID:-10001}"
|
||||
MOUNT_ROOT="/mnt/landppt"
|
||||
MARKER_NAME=".landppt-permissions-v1"
|
||||
|
||||
log() {
|
||||
printf '[permissions-init] %s\n' "$1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[permissions-init] WARNING: %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[permissions-init] ERROR: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
validate_access() {
|
||||
target_path="$1"
|
||||
target_kind="$2"
|
||||
|
||||
if ! /opt/venv/bin/python - "$TARGET_UID" "$TARGET_GID" "$target_path" "$target_kind" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
uid = int(sys.argv[1])
|
||||
gid = int(sys.argv[2])
|
||||
path = sys.argv[3]
|
||||
kind = sys.argv[4]
|
||||
|
||||
os.setgroups([])
|
||||
os.setgid(gid)
|
||||
os.setuid(uid)
|
||||
|
||||
if kind == "file":
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_APPEND)
|
||||
os.close(descriptor)
|
||||
else:
|
||||
probe = os.path.join(path, f".landppt-write-test-{os.getpid()}")
|
||||
descriptor = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
os.close(descriptor)
|
||||
os.unlink(probe)
|
||||
PY
|
||||
then
|
||||
fail "UID ${TARGET_UID} cannot write ${target_path}"
|
||||
fi
|
||||
}
|
||||
|
||||
migrate_volume() {
|
||||
volume_path="$1"
|
||||
marker_path="${volume_path}/${MARKER_NAME}"
|
||||
|
||||
[ -d "$volume_path" ] || fail "Volume path is missing: ${volume_path}"
|
||||
|
||||
if [ ! -f "$marker_path" ]; then
|
||||
log "Migrating ${volume_path} to ${TARGET_UID}:${TARGET_GID}"
|
||||
chown -R "${TARGET_UID}:${TARGET_GID}" "$volume_path"
|
||||
chmod -R u+rwX "$volume_path"
|
||||
validate_access "$volume_path" directory
|
||||
: > "$marker_path"
|
||||
chown "${TARGET_UID}:${TARGET_GID}" "$marker_path"
|
||||
chmod 600 "$marker_path"
|
||||
else
|
||||
log "Migration marker found for ${volume_path}; validating"
|
||||
validate_access "$volume_path" directory
|
||||
fi
|
||||
}
|
||||
|
||||
configure_env_file() {
|
||||
env_path="${MOUNT_ROOT}/env/.env"
|
||||
|
||||
[ -e "$env_path" ] || fail "Mounted .env is missing: ${env_path}"
|
||||
[ -f "$env_path" ] || fail "Mounted .env is not a regular file: ${env_path}"
|
||||
|
||||
if ! chgrp "$TARGET_GID" "$env_path" 2>/dev/null; then
|
||||
warn "Could not change .env group; checking effective access"
|
||||
fi
|
||||
if ! chmod g+rw,o-rwx "$env_path" 2>/dev/null; then
|
||||
warn "Could not change .env mode; checking effective access"
|
||||
fi
|
||||
|
||||
validate_access "$env_path" file
|
||||
}
|
||||
|
||||
main() {
|
||||
[ "$(id -u)" -eq 0 ] || fail "Permission migration must run as root"
|
||||
|
||||
configure_env_file
|
||||
for volume_name in data uploads reports cache lib; do
|
||||
migrate_volume "${MOUNT_ROOT}/${volume_name}"
|
||||
done
|
||||
|
||||
log "Permission migration complete"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Install the migration script in the image**
|
||||
|
||||
Change the script copy line in `Dockerfile` to:
|
||||
|
||||
```dockerfile
|
||||
COPY docker-healthcheck.sh docker-entrypoint.sh docker-permissions-init.sh /usr/local/bin/
|
||||
```
|
||||
|
||||
Change the executable-mode command to:
|
||||
|
||||
```dockerfile
|
||||
chmod +x /usr/local/bin/docker-healthcheck.sh \
|
||||
/usr/local/bin/docker-entrypoint.sh \
|
||||
/usr/local/bin/docker-permissions-init.sh && \
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the production Compose initializer and dependency**
|
||||
|
||||
In `x-landppt-volumes` in `docker-compose.yml`, replace the fixed `.env` bind with this configurable bind so the application and initializer always operate on the same file:
|
||||
|
||||
```yaml
|
||||
- ${LANDPPT_ENV_FILE:-./.env}:/app/.env
|
||||
```
|
||||
|
||||
Add this dependency to `x-landppt-depends-on` in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
permissions-init:
|
||||
condition: service_completed_successfully
|
||||
```
|
||||
|
||||
Add this service immediately after `services:` and before `landppt`:
|
||||
|
||||
```yaml
|
||||
permissions-init:
|
||||
image: ${LANDPPT_IMAGE:-bradleylzh/landppt:latest}
|
||||
user: "0:0"
|
||||
entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]
|
||||
environment:
|
||||
LANDPPT_UID: "10001"
|
||||
LANDPPT_GID: "10001"
|
||||
volumes:
|
||||
- ${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env
|
||||
- landppt_data:/mnt/landppt/data
|
||||
- landppt_uploads:/mnt/landppt/uploads
|
||||
- landppt_reports:/mnt/landppt/reports
|
||||
- landppt_cache:/mnt/landppt/cache
|
||||
- landppt_lib:/mnt/landppt/lib
|
||||
network_mode: "none"
|
||||
read_only: true
|
||||
restart: "no"
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- FOWNER
|
||||
- DAC_OVERRIDE
|
||||
- SETUID
|
||||
- SETGID
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
disable: true
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the development Compose initializer without the source bind mount**
|
||||
|
||||
In `docker-compose-dev.yaml`, add an explicit `.env` bind immediately after the existing `./:/app` source bind. This makes the environment file overridable while the initializer remains isolated from the rest of the source tree:
|
||||
|
||||
```yaml
|
||||
- ${LANDPPT_ENV_FILE:-./.env}:/app/.env
|
||||
```
|
||||
|
||||
Add the same `permissions-init` dependency to `x-landppt-depends-on` in `docker-compose-dev.yaml`.
|
||||
|
||||
Add this service immediately after `services:` and before `landppt`:
|
||||
|
||||
```yaml
|
||||
permissions-init:
|
||||
build: *landppt-build
|
||||
image: ${LANDPPT_DEV_IMAGE:-landppt-dev:latest}
|
||||
user: "0:0"
|
||||
entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]
|
||||
environment:
|
||||
LANDPPT_UID: "10001"
|
||||
LANDPPT_GID: "10001"
|
||||
volumes:
|
||||
- ${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env
|
||||
- landppt_data:/mnt/landppt/data
|
||||
- landppt_uploads:/mnt/landppt/uploads
|
||||
- landppt_reports:/mnt/landppt/reports
|
||||
- landppt_cache:/mnt/landppt/cache
|
||||
- landppt_lib:/mnt/landppt/lib
|
||||
network_mode: "none"
|
||||
read_only: true
|
||||
restart: "no"
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- FOWNER
|
||||
- DAC_OVERRIDE
|
||||
- SETUID
|
||||
- SETGID
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
disable: true
|
||||
```
|
||||
|
||||
Do not reuse `*landppt-volumes` for this service: that anchor contains `./:/app` and would expose the whole repository to the root helper.
|
||||
|
||||
- [ ] **Step 7: Validate the script, Compose models, and focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bash -n docker-permissions-init.sh
|
||||
docker compose config --quiet
|
||||
docker compose -f docker-compose-dev.yaml config --quiet
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
```
|
||||
|
||||
Expected: both shell/Compose checks exit `0`; pytest reports `5 passed`.
|
||||
|
||||
- [ ] **Step 8: Commit the automatic Compose migration**
|
||||
|
||||
```bash
|
||||
git add Dockerfile docker-permissions-init.sh docker-compose.yml docker-compose-dev.yaml tests/test_container_security.py
|
||||
git commit -m "fix(docker): migrate volumes before non-root startup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Helm Non-Root Enforcement and PVC Group Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `helm/landppt/values.yaml:171-172`
|
||||
- Modify: `helm/landppt/templates/worker-deployment.yaml:24-83`
|
||||
- Modify: `tests/test_container_security.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `.Values.podSecurityContext` defaults for PVC ownership policy and seccomp
|
||||
- Produces: `.Values.securityContext` defaults for UID/GID, non-root enforcement, privilege escalation, and capabilities
|
||||
- Consumes: the image identity `10001:10001` created in Task 1
|
||||
|
||||
- [ ] **Step 1: Add failing Helm security-context tests**
|
||||
|
||||
Append this code to `tests/test_container_security.py`:
|
||||
|
||||
```python
|
||||
def test_helm_defaults_enforce_non_root_identity_and_volume_group():
|
||||
values = read_repo_file("helm/landppt/values.yaml")
|
||||
pod_context = values.split("podSecurityContext:", 1)[1].split(
|
||||
"\nsecurityContext:", 1
|
||||
)[0]
|
||||
container_context = values.split("\nsecurityContext:", 1)[1].split(
|
||||
"\nlivenessProbe:", 1
|
||||
)[0]
|
||||
|
||||
for expected in (
|
||||
"fsGroup: 10001",
|
||||
"fsGroupChangePolicy: OnRootMismatch",
|
||||
"type: RuntimeDefault",
|
||||
):
|
||||
assert expected in pod_context
|
||||
assert "runAsUser" not in pod_context
|
||||
|
||||
for expected in (
|
||||
"runAsNonRoot: true",
|
||||
"runAsUser: 10001",
|
||||
"runAsGroup: 10001",
|
||||
"allowPrivilegeEscalation: false",
|
||||
"- ALL",
|
||||
):
|
||||
assert expected in container_context
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template_path",
|
||||
[
|
||||
"helm/landppt/templates/deployment.yaml",
|
||||
"helm/landppt/templates/worker-deployment.yaml",
|
||||
"helm/landppt/templates/migration-job.yaml",
|
||||
],
|
||||
)
|
||||
def test_helm_workloads_render_pod_and_container_security_contexts(
|
||||
template_path: str,
|
||||
):
|
||||
template = read_repo_file(template_path)
|
||||
|
||||
assert "{{- with .Values.podSecurityContext }}" in template
|
||||
assert "{{- with .Values.securityContext }}" in template
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify Helm defaults and worker fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
```
|
||||
|
||||
Expected: Task 1 and Task 2 tests pass; the defaults test fails, and the worker template case fails because it renders neither security context.
|
||||
|
||||
- [ ] **Step 3: Set secure chart defaults**
|
||||
|
||||
Replace the two empty security-context objects in `helm/landppt/values.yaml` with:
|
||||
|
||||
```yaml
|
||||
podSecurityContext:
|
||||
fsGroup: 10001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render the pod security context in the worker deployment**
|
||||
|
||||
In `helm/landppt/templates/worker-deployment.yaml`, add this block after `imagePullSecrets` and before `initContainers`:
|
||||
|
||||
```yaml
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render the container security context in the worker deployment**
|
||||
|
||||
Add this block immediately after the worker's `imagePullPolicy`:
|
||||
|
||||
```yaml
|
||||
{{- with .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run focused tests and render the chart**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
helm lint helm/landppt
|
||||
helm template landppt helm/landppt --output-dir artifacts/helm-rendered
|
||||
```
|
||||
|
||||
Expected: pytest reports `9 passed`; Helm lint reports `0 chart(s) failed`; the rendered web, worker, and migration pod specs contain UID/GID `10001`, and persisted pods contain `fsGroup: 10001`.
|
||||
|
||||
- [ ] **Step 7: Commit the Helm security boundary**
|
||||
|
||||
```bash
|
||||
git add helm/landppt/values.yaml helm/landppt/templates/worker-deployment.yaml tests/test_container_security.py
|
||||
git commit -m "fix(helm): enforce non-root LandPPT workloads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Security Scan, Runtime Migration Exercise, and Final Regression
|
||||
|
||||
**Files:**
|
||||
- Verify: `Dockerfile`
|
||||
- Verify: `docker-entrypoint.sh`
|
||||
- Verify: `docker-permissions-init.sh`
|
||||
- Verify: `docker-compose.yml`
|
||||
- Verify: `docker-compose-dev.yaml`
|
||||
- Verify: `helm/landppt/values.yaml`
|
||||
- Verify: `helm/landppt/templates/worker-deployment.yaml`
|
||||
- Verify: `tests/test_container_security.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all artifacts from Tasks 1-3
|
||||
- Produces: verified Trivy result with no `DS-0002`
|
||||
- Produces: evidence that a root-owned legacy volume becomes writable by UID/GID `10001`
|
||||
|
||||
- [ ] **Step 1: Run the focused and full pytest suites**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run --extra dev pytest tests/test_container_security.py -q
|
||||
uv run --extra dev pytest -q
|
||||
```
|
||||
|
||||
Expected: focused tests report `9 passed`; the full suite passes. If an unrelated environment dependency blocks the full suite, preserve the exact command and error in the final handoff.
|
||||
|
||||
- [ ] **Step 2: Validate shell, Compose, and Helm syntax**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bash -n docker-entrypoint.sh docker-permissions-init.sh
|
||||
docker compose config --quiet
|
||||
docker compose -f docker-compose-dev.yaml config --quiet
|
||||
helm lint helm/landppt
|
||||
helm template landppt helm/landppt --output-dir artifacts/helm-rendered
|
||||
```
|
||||
|
||||
Expected: every command exits `0`; Helm reports `0 chart(s) failed`.
|
||||
|
||||
- [ ] **Step 3: Run the same Trivy configuration class that opened the alert**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
trivy config --severity HIGH,CRITICAL --exit-code 1 .
|
||||
```
|
||||
|
||||
Expected: exit `0` and no `DS-0002` finding for `Dockerfile`. If Trivy is not installed locally, record `trivy: command not found` and rely on the existing GitHub `security-scan` workflow after push; do not add an ignore rule.
|
||||
|
||||
- [ ] **Step 4: Build and inspect the image when a Docker daemon is available**
|
||||
|
||||
Run in PowerShell:
|
||||
|
||||
```powershell
|
||||
$image = "landppt-security-test:local"
|
||||
docker build -t $image .
|
||||
docker image inspect $image --format '{{.Config.User}}'
|
||||
docker run --rm --entrypoint /usr/bin/id $image -u
|
||||
docker run --rm --entrypoint /usr/bin/id $image -g
|
||||
```
|
||||
|
||||
Expected: build succeeds; inspection prints `landppt`; both `id` commands print `10001`.
|
||||
|
||||
- [ ] **Step 5: Exercise automatic migration against isolated root-owned volumes**
|
||||
|
||||
Run in PowerShell; these names are isolated from the developer's normal Compose project:
|
||||
|
||||
```powershell
|
||||
$project = "landppt-security-test"
|
||||
$image = "landppt-security-test:local"
|
||||
$envFile = ".tmp/landppt-security-test.env"
|
||||
New-Item -ItemType Directory -Force .tmp | Out-Null
|
||||
Copy-Item -LiteralPath .env.example -Destination $envFile -Force
|
||||
$env:LANDPPT_ENV_FILE = $envFile
|
||||
$env:LANDPPT_IMAGE = $image
|
||||
$volumeKeys = @("landppt_data", "landppt_uploads", "landppt_reports", "landppt_cache", "landppt_lib")
|
||||
docker compose -p $project create permissions-init
|
||||
foreach ($key in $volumeKeys) {
|
||||
$volume = "${project}_${key}"
|
||||
docker run --rm --user "0:0" --entrypoint /bin/sh -v "${volume}:/legacy" $image -c "mkdir -p /legacy/root-owned; echo legacy > /legacy/root-owned/value.txt; chown -R 0:0 /legacy"
|
||||
}
|
||||
docker compose -p $project run --rm --no-deps permissions-init
|
||||
docker compose -p $project run --rm --no-deps permissions-init
|
||||
docker run --rm --entrypoint /bin/sh -v "${project}_landppt_data:/legacy" $image -c "test -f /legacy/.landppt-permissions-v1 && test -w /legacy/root-owned/value.txt"
|
||||
```
|
||||
|
||||
Expected: both initializer runs exit `0`; the second run logs that markers were found; the final non-root write check exits `0`.
|
||||
|
||||
- [ ] **Step 6: Clean up only the isolated runtime-test resources**
|
||||
|
||||
Run in PowerShell:
|
||||
|
||||
```powershell
|
||||
docker compose -p landppt-security-test down -v
|
||||
docker image rm landppt-security-test:local
|
||||
Remove-Item Env:\LANDPPT_IMAGE -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\LANDPPT_ENV_FILE -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath .tmp\landppt-security-test.env -Force -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
Expected: only resources prefixed `landppt-security-test` and the local test image are removed. Do not run this cleanup with the repository's normal Compose project name.
|
||||
|
||||
- [ ] **Step 7: Review the final diff against the security contract**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff HEAD~3 -- Dockerfile docker-entrypoint.sh docker-permissions-init.sh docker-compose.yml docker-compose-dev.yaml helm/landppt/values.yaml helm/landppt/templates/worker-deployment.yaml tests/test_container_security.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- The final image user is `landppt` and no privileged runtime command follows `USER landppt`.
|
||||
- The helper mounts only `.env` and the five named volumes.
|
||||
- Long-lived Compose services do not override the image user.
|
||||
- Web, worker, and migration Helm workloads render non-root contexts.
|
||||
- No Trivy suppression, `privileged: true`, Docker socket mount, or world-writable image `.env` was added.
|
||||
|
||||
- [ ] **Step 8: Commit final fixes only if review or verification required changes**
|
||||
|
||||
If Steps 1-7 required corrections, stage only those corrections and commit:
|
||||
|
||||
```bash
|
||||
git add Dockerfile docker-entrypoint.sh docker-permissions-init.sh docker-compose.yml docker-compose-dev.yaml helm/landppt/values.yaml helm/landppt/templates/worker-deployment.yaml tests/test_container_security.py
|
||||
git commit -m "fix(security): harden non-root container migration"
|
||||
```
|
||||
|
||||
If no corrections were required, do not create an empty commit.
|
||||
|
||||
- [ ] **Step 9: Final repository status**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
git log --oneline -8
|
||||
```
|
||||
|
||||
Expected: the working tree is clean, and the Docker, Compose, and Helm commits appear above the design and plan commits.
|
||||
@@ -1,352 +0,0 @@
|
||||
# Slide Edit Agent Design
|
||||
|
||||
Date: 2026-07-05
|
||||
|
||||
## Goal
|
||||
|
||||
Convert the current sidebar AI editing assistant from a single-turn "generate HTML and apply" flow into an agent-style PPT editing assistant. The assistant should be able to reason over the current project, choose editing tools, produce validated slide changes, show its progress in the sidebar, and let the user confirm before changes are persisted.
|
||||
|
||||
The first implementation should prioritize safe, observable editing of existing slides over broad autonomous behavior.
|
||||
|
||||
## Current State
|
||||
|
||||
The current sidebar flow is split across these areas:
|
||||
|
||||
- `src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiChat.js`
|
||||
- Sends current slide context to `/api/ai/slide-edit/stream`.
|
||||
- Renders streamed assistant text.
|
||||
- Extracts returned HTML and adds a manual "apply changes" button.
|
||||
- `src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiApply.js`
|
||||
- Applies full-slide HTML into local `slidesData`, iframe preview, thumbnails, code editor, and server save.
|
||||
- `src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.quickAi.js`
|
||||
- Edits a selected element through `/api/ai/element-edit`.
|
||||
- Applies returned element HTML directly into the iframe DOM and saves the slide.
|
||||
- `src/landppt/web/route_modules/ai_edit_routes.py`
|
||||
- Contains `/api/ai/slide-edit`, `/api/ai/slide-edit/stream`, `/api/ai/element-edit`, and related AI routes.
|
||||
- `src/landppt/web/route_modules/slide_routes.py`
|
||||
- Contains `/api/projects/{project_id}/slides/{slide_index}/save`, the existing single-slide persistence path.
|
||||
|
||||
The existing behavior has three limits:
|
||||
|
||||
1. The model returns free-form text and possibly full HTML; the application then tries to extract a code block.
|
||||
2. There is no planning or tool observation loop, so multi-step requests are fragile.
|
||||
3. Validation and persistence are not separated from generation clearly enough for safe autonomous editing.
|
||||
|
||||
The project already has a ReAct-style pattern in `src/landppt/services/deep_research_service.py`. The edit agent should reuse that pattern conceptually: structured actions, bounded iterations, tool observations, and stream events.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use a server-side agent loop with explicit PPT editing tools and a user-confirmed apply step.
|
||||
|
||||
The agent may inspect slides, select elements, draft HTML changes, run validation, and emit a proposed patch. It must not persist the patch during the reasoning loop. Persistence happens only when the user confirms the proposal from the sidebar.
|
||||
|
||||
This gives the product an agentic workflow while preserving editor safety and existing save semantics.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not replace the whole slides editor.
|
||||
- Do not make the first version fully autonomous by default.
|
||||
- Do not build a new database persistence path for edited slides.
|
||||
- Do not require browser DOM access from the backend.
|
||||
- Do not remove the existing quick edit toolbar or manual HTML/code editor flows.
|
||||
|
||||
## User Experience
|
||||
|
||||
The sidebar remains the primary entry point. The user enters an editing instruction such as "make this slide more visual and simplify the text".
|
||||
|
||||
The sidebar then shows a task timeline instead of only token text:
|
||||
|
||||
1. Analyzing current slide and outline.
|
||||
2. Selecting relevant edit tools.
|
||||
3. Drafting changes.
|
||||
4. Validating HTML and layout constraints.
|
||||
5. Ready to preview.
|
||||
|
||||
When the agent emits a draft, the user sees these actions:
|
||||
|
||||
- Preview
|
||||
- Apply
|
||||
- Continue editing
|
||||
- Discard
|
||||
|
||||
Default behavior is confirmation before applying. A later settings-controlled "auto-apply simple edits" mode can be added after the first version is stable.
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
Add a new service:
|
||||
|
||||
`src/landppt/services/slide/slide_edit_agent_service.py`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Build compact editing context from project, slide, outline, selected element, uploaded images, and optional screenshots.
|
||||
- Run a bounded ReAct-style loop.
|
||||
- Parse model actions into structured tool calls.
|
||||
- Execute only registered editing tools.
|
||||
- Maintain a transcript of thought/action/observation summaries.
|
||||
- Produce a final draft with changed slide HTML, summary, validation result, and a stable proposal id.
|
||||
|
||||
Suggested models:
|
||||
|
||||
- `SlideEditAgentRequest`
|
||||
- `SlideEditAgentContext`
|
||||
- `SlideEditAction`
|
||||
- `SlideEditToolResult`
|
||||
- `SlideEditProposal`
|
||||
- `SlideEditValidationResult`
|
||||
|
||||
The agent should use the existing editor or vision role provider:
|
||||
|
||||
- Use `vision_analysis` when visual input is included.
|
||||
- Use `editor` otherwise.
|
||||
|
||||
## Backend Routes
|
||||
|
||||
Add these routes to `src/landppt/web/route_modules/ai_edit_routes.py` or a new imported module if the file grows too much:
|
||||
|
||||
### `POST /api/ai/slide-edit-agent/stream`
|
||||
|
||||
Runs the agent loop and streams events.
|
||||
|
||||
Request fields:
|
||||
|
||||
- `projectId`
|
||||
- `slideIndex`
|
||||
- `userRequest`
|
||||
- `chatHistory`
|
||||
- `mode`: `slide` or `element`
|
||||
- `selectedElementHtml`
|
||||
- `selectedElementId`
|
||||
- `slideScreenshot`
|
||||
- `elementScreenshot`
|
||||
- `images`
|
||||
- `maxIterations`
|
||||
|
||||
Response is Server-Sent Events.
|
||||
|
||||
Event types:
|
||||
|
||||
- `agent_start`
|
||||
- `agent_step`
|
||||
- `tool_call`
|
||||
- `tool_result`
|
||||
- `draft_ready`
|
||||
- `validation_result`
|
||||
- `needs_confirmation`
|
||||
- `error`
|
||||
|
||||
### `POST /api/ai/slide-edit-agent/apply`
|
||||
|
||||
Persists an approved proposal.
|
||||
|
||||
Request fields:
|
||||
|
||||
- `proposalId`
|
||||
- `projectId`
|
||||
- `slideIndex`
|
||||
- `expectedBaseHash`
|
||||
- `htmlContent`
|
||||
- `slideData`
|
||||
|
||||
The route must verify project ownership, verify that the current slide still matches `expectedBaseHash`, and then reuse the existing single-slide save path or the same `DatabaseProjectManager.save_single_slide()` behavior.
|
||||
|
||||
### `POST /api/ai/slide-edit-agent/cancel`
|
||||
|
||||
Cancels a running agent task if the implementation stores active jobs. If the first implementation runs per request without background task state, this route can return success for UI consistency and be wired to abort the browser request.
|
||||
|
||||
## Tool Function Design
|
||||
|
||||
The first version should expose a small, deterministic tool set.
|
||||
|
||||
Read tools:
|
||||
|
||||
- `get_project_context`
|
||||
- Returns title, topic, scenario, slide count, and current outline metadata.
|
||||
- `get_slide`
|
||||
- Returns one slide's title, type, content points, metadata, and HTML.
|
||||
- `list_slides`
|
||||
- Returns compact slide summaries for cross-slide requests.
|
||||
- `inspect_slide_html`
|
||||
- Returns a structural summary of headings, text blocks, images, tables, and candidate elements.
|
||||
- `select_elements`
|
||||
- Finds likely target elements by text, tag, role, alt text, or agent-assigned id.
|
||||
|
||||
Draft tools:
|
||||
|
||||
- `replace_slide_html`
|
||||
- Produces a new full-slide HTML draft.
|
||||
- `replace_element_html`
|
||||
- Produces a draft where one selected element is replaced.
|
||||
- `update_text`
|
||||
- Rewrites text content for one or more selected elements.
|
||||
- `update_style`
|
||||
- Applies whitelisted CSS properties to selected elements.
|
||||
- `insert_element`
|
||||
- Inserts an HTML element at a controlled location.
|
||||
- `delete_element`
|
||||
- Removes selected elements from the draft.
|
||||
- `generate_image_for_slide`
|
||||
- Calls existing image generation services and returns an image asset reference.
|
||||
- `auto_repair_layout`
|
||||
- Uses the existing auto layout repair workflow for one slide.
|
||||
|
||||
Validation and persistence tools:
|
||||
|
||||
- `validate_slide_html`
|
||||
- Rejects scripts, inline event handlers, invalid slide dimensions, missing root content, and obvious unsafe URLs.
|
||||
- `preview_patch`
|
||||
- Returns before/after summary, changed element count, and optional compact diff.
|
||||
- `save_slide`
|
||||
- Only available in the apply route, not inside the reasoning loop.
|
||||
|
||||
All write-like tools in the agent loop operate on an in-memory draft. They return observations and updated draft state, not persisted database writes.
|
||||
|
||||
## Agent Loop
|
||||
|
||||
Default maximum iterations: 6.
|
||||
|
||||
Allowed range: 2 to 12 for editing. This is intentionally lower than research because each edit loop carries large HTML context.
|
||||
|
||||
Each model response should be parsed as JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"thought": "short reason for next action",
|
||||
"action": "inspect_slide_html",
|
||||
"action_input": {
|
||||
"slide_index": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Final response:
|
||||
|
||||
```json
|
||||
{
|
||||
"thought": "why the draft is ready",
|
||||
"action": "final",
|
||||
"action_input": {
|
||||
"summary": "what changed",
|
||||
"changed_slide_indices": [1],
|
||||
"requires_confirmation": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The final proposal should include:
|
||||
|
||||
- `proposalId`
|
||||
- `baseHash`
|
||||
- `summary`
|
||||
- `changedSlides`
|
||||
- `htmlContent`
|
||||
- `validation`
|
||||
- `toolTranscript`
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
Update `projectSlidesEditor.aiChat.js`:
|
||||
|
||||
- Send requests to `/api/ai/slide-edit-agent/stream`.
|
||||
- Parse structured SSE events.
|
||||
- Render timeline cards for agent steps and tools.
|
||||
- Render draft controls when `draft_ready` arrives.
|
||||
- Keep existing chat history storage, but store agent summaries rather than raw hidden tool JSON.
|
||||
|
||||
Update `projectSlidesEditor.aiApply.js`:
|
||||
|
||||
- Add `applyAgentProposal(proposal)` that calls `/api/ai/slide-edit-agent/apply`.
|
||||
- Reuse the existing local update logic from `applyAIChanges()` after the backend accepts the proposal.
|
||||
- Save undo state before applying.
|
||||
|
||||
Update `projectSlidesEditor.quickAi.js`:
|
||||
|
||||
- Keep the current popover UI.
|
||||
- Route element edits to the same agent endpoint with `mode: "element"`.
|
||||
- Send `selectedElementHtml`, `selectedElementId`, and optional element screenshot.
|
||||
- Apply returned element proposal through the same proposal/apply flow.
|
||||
|
||||
Update the sidebar template in `project_slides_editor.html`:
|
||||
|
||||
- Keep the existing title and input area.
|
||||
- Add a timeline container inside `#aiChatMessages`.
|
||||
- Keep upload, vision, free-dialog, and clear-context buttons.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- The agent loop cannot persist changes.
|
||||
- The apply route must verify project ownership through the current authenticated user.
|
||||
- The apply route must verify `expectedBaseHash` to avoid overwriting newer user edits.
|
||||
- HTML validation removes or rejects:
|
||||
- `<script>` tags
|
||||
- inline event handler attributes
|
||||
- `javascript:` URLs
|
||||
- malformed empty HTML
|
||||
- Element edits must preserve the selected element id while drafting, then strip temporary agent ids before final save.
|
||||
- If validation fails, the sidebar should show the error and offer "continue editing" rather than "apply".
|
||||
|
||||
## Credits
|
||||
|
||||
Charge one `ai_edit` operation per completed agent run that reaches `draft_ready` or a final answer. Do not charge per internal tool call in the first version. If image generation is invoked, keep existing image operation billing rules.
|
||||
|
||||
## Persistence
|
||||
|
||||
The final save must reuse the existing single-slide persistence behavior:
|
||||
|
||||
- frontend local state update
|
||||
- iframe preview update
|
||||
- thumbnail update
|
||||
- code editor update
|
||||
- `/api/projects/{project_id}/slides/{slide_index}/save`
|
||||
- `DatabaseProjectManager.save_single_slide()`
|
||||
|
||||
The agent apply route can either call the same internal save logic directly or return an accepted proposal that the frontend saves through `saveSingleSlideToServer()`. The safer first implementation is backend apply plus frontend local sync, because the backend can enforce `baseHash` before writing.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Agent loop executes model-selected tools in order.
|
||||
- Unsupported tool names produce tool error observations.
|
||||
- Max iteration limit stops the loop predictably.
|
||||
- Draft tools do not call database persistence.
|
||||
- Apply route rejects mismatched `baseHash`.
|
||||
- Apply route saves only the requested slide.
|
||||
- HTML validator rejects scripts and inline event handlers.
|
||||
- Element mode preserves and then strips temporary element ids.
|
||||
|
||||
Frontend/manual checks:
|
||||
|
||||
- Sidebar shows agent step events.
|
||||
- Draft can be previewed before applying.
|
||||
- Applying updates preview, thumbnail, code editor, and saved slide.
|
||||
- Discard leaves current slide unchanged.
|
||||
- Element AI popover can edit a selected text block.
|
||||
- Vision mode still attaches screenshots.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
Phase 1:
|
||||
|
||||
- Add backend service, schemas, tools, and tests.
|
||||
- Add stream route and apply route.
|
||||
- Keep existing `/api/ai/slide-edit/stream` untouched as fallback.
|
||||
|
||||
Phase 2:
|
||||
|
||||
- Wire sidebar to the agent endpoint.
|
||||
- Render timeline events and proposal controls.
|
||||
- Keep old HTML extraction apply button behind a fallback path.
|
||||
|
||||
Phase 3:
|
||||
|
||||
- Route quick element AI through the agent.
|
||||
- Add optional "continue editing this draft" loop.
|
||||
- Add settings for max iterations and simple-edit auto-apply only after validation is reliable.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- A user can ask for a multi-step edit and see the assistant inspect, modify, validate, preview, and apply the result.
|
||||
- Agent changes are not persisted until the user confirms.
|
||||
- Existing manual editing, quick editing, image upload, and vision mode continue to work.
|
||||
- Tests cover the agent loop, tool dispatch, validation, and save boundary.
|
||||
@@ -1,173 +0,0 @@
|
||||
# Non-Root Container Security Design
|
||||
|
||||
Date: 2026-07-10
|
||||
|
||||
## Goal
|
||||
|
||||
Resolve GitHub code-scanning alert 1, Trivy rule `DS-0002` ("Image user should not be 'root'"), by making the LandPPT runtime image non-root by default without requiring operators to repair existing Docker volumes or Kubernetes PVCs manually.
|
||||
|
||||
The supported automatic-upgrade paths are the repository's production and development Docker Compose files and the LandPPT Helm chart.
|
||||
|
||||
## Current State
|
||||
|
||||
The production stage in `Dockerfile` creates a `landppt` account and gives it ownership of application directories, but it does not declare a final `USER`. The image therefore starts the entrypoint, web process, worker process, health check, and migration command as root.
|
||||
|
||||
This behavior was introduced in commit `c94c79b7` when `USER landppt` was removed to work around `.env` write permissions. The current entrypoint still contains root-oriented permission repair, and both Compose files bind-mount `.env` while mounting named volumes over the image-owned application directories.
|
||||
|
||||
Changing only the final Docker user would close the static alert, but it could break upgrades because prior root processes may have created root-owned content in:
|
||||
|
||||
- `/app/data`
|
||||
- `/app/uploads`
|
||||
- `/app/research_reports`
|
||||
- `/app/temp`
|
||||
- `/app/lib`
|
||||
- `/app/.env`
|
||||
|
||||
The Helm chart exposes `podSecurityContext` and `securityContext` values, but both default to empty objects. The web deployment and migration job consume these values; the worker deployment does not currently consume either security context.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The final image must declare a non-root user and satisfy Trivy `DS-0002`.
|
||||
- Web, worker, health-check, and migration processes must run as the same fixed non-root identity.
|
||||
- Existing Compose named volumes and Helm-managed PVCs must become writable automatically.
|
||||
- The Compose migration must not recursively change ownership of the source repository.
|
||||
- `.env` must remain writable by the application admin configuration flow without becoming world-writable.
|
||||
- Repeated starts must be idempotent and avoid repeated recursive ownership changes.
|
||||
- Permission failures must stop startup with an actionable path-specific error.
|
||||
- Direct, hand-written `docker run` commands that attach legacy volumes are outside the automatic migration guarantee.
|
||||
|
||||
## Considered Approaches
|
||||
|
||||
### 1. Non-root image with orchestrator-native migration
|
||||
|
||||
Use a restricted, one-shot Compose service to migrate named volumes and use Kubernetes `fsGroup` handling for PVCs. Run all long-lived application containers as non-root.
|
||||
|
||||
This is the selected approach. It confines elevated access to the minimum Compose migration step and uses Kubernetes' native volume ownership mechanism.
|
||||
|
||||
### 2. Root migration containers in both Compose and Kubernetes
|
||||
|
||||
Explicit init containers could recursively `chown` every mounted volume. This is deterministic for POSIX storage, but adds a root container to every supported orchestrator and can make large-volume startup slower.
|
||||
|
||||
### 3. Add only `USER landppt`
|
||||
|
||||
This is the smallest code change and closes the alert, but it can make existing deployments fail with permission errors. It does not meet the automatic-upgrade requirement.
|
||||
|
||||
## Image Identity and Filesystem
|
||||
|
||||
The production image will create `landppt` with fixed UID and GID `10001`. A fixed numeric identity makes Docker volume ownership, Helm security contexts, and runtime assertions consistent across image rebuilds.
|
||||
|
||||
The production-stage environment will set `HOME=/home/landppt`. Build-time package installation, browser installation, code copies, directory creation, and ownership changes will remain before the user switch. The final runtime instructions will declare:
|
||||
|
||||
```dockerfile
|
||||
USER landppt
|
||||
```
|
||||
|
||||
The image-owned `/app/.env` will be owned by `landppt:landppt` and writable by its owner, but it will no longer use mode `0666`. Application directories and the non-root home directory will be writable by `landppt`; the virtual environment and Playwright browser files need only be readable and executable.
|
||||
|
||||
The health check and existing entrypoint will consequently execute as `landppt`. The entrypoint will stop attempting privileged permission repair and will instead perform a preflight check before starting the requested command.
|
||||
|
||||
## Docker Compose Permission Migration
|
||||
|
||||
Add `docker-permissions-init.sh` to the runtime image and add a `permissions-init` service to both `docker-compose.yml` and `docker-compose-dev.yaml`.
|
||||
|
||||
The service will:
|
||||
|
||||
- Use the same LandPPT image so the expected account and UID/GID are always available.
|
||||
- Override the image user with `0:0` only for this one-shot task.
|
||||
- Override the normal entrypoint with `docker-permissions-init.sh`.
|
||||
- Mount only `.env` and the five LandPPT named volumes at dedicated paths below `/mnt/landppt`; it will not mount `/app` or the development source tree.
|
||||
- Disable networking and container restart.
|
||||
- Use a read-only root filesystem.
|
||||
- Drop all Linux capabilities, then add only those required for ownership repair and target-identity verification: `CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `SETUID`, and `SETGID`. The script uses the last two only to drop a validation child process to UID/GID `10001`; the long-lived containers receive none of these capabilities.
|
||||
|
||||
For each named volume, the script will look for a `.landppt-permissions-v1` marker. If absent, it will recursively set ownership to `10001:10001`, create the marker only after successful migration, and validate that UID/GID `10001` can write the volume. A present marker makes subsequent starts a fast validation path.
|
||||
|
||||
For the bind-mounted `.env`, the script will preserve the host-side owner, assign group `10001`, and grant group read/write access without granting access to other users. It will tolerate ownership operations that are unsupported by Docker Desktop file sharing only when an effective write check as UID/GID `10001` succeeds.
|
||||
|
||||
The web and worker services will depend on `permissions-init` with `condition: service_completed_successfully`, in addition to their existing database, cache, and object-storage dependencies. A failed migration therefore prevents either application process from starting.
|
||||
|
||||
## Kubernetes Security and PVC Migration
|
||||
|
||||
The Helm chart defaults will set the pod security context used for PVC ownership to:
|
||||
|
||||
```yaml
|
||||
fsGroup: 10001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
```
|
||||
|
||||
The LandPPT container security context will enforce `runAsNonRoot: true`, `runAsUser: 10001`, and `runAsGroup: 10001`, disallow privilege escalation, and drop all capabilities. Keeping the numeric identity at container level avoids changing the runtime identity of the chart's third-party BusyBox and MinIO initialization containers. The web deployment and migration job already render the configurable context; the worker deployment will be updated to render it as well.
|
||||
|
||||
For supported POSIX PVC implementations, `fsGroup` makes existing volume contents group-writable by the LandPPT process. `OnRootMismatch` performs the recursive adjustment when the volume root does not match and avoids repeating it after the first successful mount. This is the automatic migration path for the repository's current Helm deployment.
|
||||
|
||||
The chart will keep these security-context objects configurable for storage-specific requirements. The numeric UID/GID must remain aligned with the image; using another identity is supported only with a custom image built with the same identity.
|
||||
|
||||
## Startup and Error Handling
|
||||
|
||||
The application entrypoint will continue to create required directories and use `exec` for the final command so signal behavior does not change. Before execution, it will verify:
|
||||
|
||||
- `/app/.env` is a regular, readable, writable file when present.
|
||||
- The data, upload, report, cache, and library paths exist and are writable.
|
||||
- The current effective UID is non-zero.
|
||||
|
||||
A failed check will identify the exact path and exit non-zero. The entrypoint will not attempt `chmod`, `chown`, copying, or replacement because the long-lived application container must not need elevated access.
|
||||
|
||||
The Compose migration script will also fail closed. It will not write a migration marker until recursive ownership and effective-identity validation have both succeeded. This allows a later start to retry a previously interrupted migration safely.
|
||||
|
||||
## Security Properties
|
||||
|
||||
- The shipped image is non-root even when used outside Compose or Helm.
|
||||
- Long-lived web and worker containers have no root bootstrap phase.
|
||||
- The Compose root helper is short-lived, networkless, capability-restricted, and cannot see the application source tree.
|
||||
- Kubernetes enforces non-root execution independently of image metadata.
|
||||
- `.env` is no longer world-writable.
|
||||
- UID/GID stability prevents ownership changes caused by distribution account-allocation differences.
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
Add focused regression tests under `tests/` that verify:
|
||||
|
||||
- The final Docker stage creates fixed UID/GID `10001`, sets the non-root home, declares `USER landppt`, and does not make `.env` world-writable.
|
||||
- Both Compose files define the restricted initialization service with the expected mounts, capabilities, user override, and restart/network settings.
|
||||
- Web and worker services wait for successful permission initialization.
|
||||
- Helm defaults contain the fixed identity, `runAsNonRoot`, `fsGroup`, `OnRootMismatch`, and restricted container context.
|
||||
- Web, worker, and migration templates render the security context.
|
||||
- The migration script uses the version marker, validates target-identity access, and fails clearly.
|
||||
|
||||
Repository-level verification will run:
|
||||
|
||||
```text
|
||||
uv run --extra dev pytest tests/test_container_security.py
|
||||
uv run --extra dev pytest
|
||||
docker compose config
|
||||
docker compose -f docker-compose-dev.yaml config
|
||||
helm lint helm/landppt
|
||||
helm template landppt helm/landppt
|
||||
trivy config --severity HIGH,CRITICAL --exit-code 1 .
|
||||
```
|
||||
|
||||
When a Docker daemon is available, an isolated runtime test will:
|
||||
|
||||
1. Build the image.
|
||||
2. Create test volumes containing root-owned files.
|
||||
3. Start the Compose stack under an isolated project name.
|
||||
4. Verify the initializer exits successfully and is idempotent.
|
||||
5. Verify web and worker processes have effective UID `10001`.
|
||||
6. Verify the application can write all persisted paths and passes its health check.
|
||||
|
||||
If the local environment lacks Docker, the handoff will identify this runtime test as not executed; the static Compose, Helm, pytest, and Trivy checks remain required when their tools are available.
|
||||
|
||||
## Rollout
|
||||
|
||||
On the first Compose startup after upgrade, `permissions-init` may take longer while it migrates existing files. Subsequent starts use the marker-based fast path. On the first Helm rollout, kubelet or the CSI driver may spend additional time applying `fsGroup` to existing PVC contents; `OnRootMismatch` prevents that cost on later mounts.
|
||||
|
||||
The GitHub security alert should close after the updated Dockerfile is scanned on the default branch. No alert dismissal or suppression is part of this change.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Supporting automatic migration for arbitrary legacy `docker run` commands.
|
||||
- Making the entire application root filesystem read-only.
|
||||
- Refactoring application storage paths or configuration persistence.
|
||||
- Changing database, cache, MinIO, or third-party image users.
|
||||
- Dismissing or suppressing Trivy `DS-0002`.
|
||||
Reference in New Issue
Block a user