feat(docker): add hardened CLI test harness (#2625)

* feat(install): add hardened Docker test harness

* feat(docker): complete isolated CLI session lifecycle

* fix(docker): exercise packed public CLI offline

* fix(docker): close hardened harness review gaps

* test(docker): bound harness subprocesses
This commit is contained in:
haelyra
2026-08-06 16:52:39 -04:00
committed by GitHub
parent 28e53a0bc1
commit d791457aca
14 changed files with 1407 additions and 11 deletions
+44
View File
@@ -0,0 +1,44 @@
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
ARG OS_IMAGE=node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
FROM ${NODE_IMAGE} AS node-runtime
FROM ${OS_IMAGE}
ARG DISTRO=debian
ARG CLAUDE_CODE_VERSION=2.1.220
RUN apt-get update \
&& apt-get install --yes --no-install-recommends \
bash \
ca-certificates \
git \
libatomic1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=node-runtime /usr/local/ /usr/local/
RUN getent passwd 1000 >/dev/null \
&& getent group 1000 >/dev/null
RUN npm install --global --include=optional --ignore-scripts \
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
"@iarna/toml@2.2.5" \
"ajv@8.20.0" \
"sql.js@1.14.1" \
&& global_node_modules="$(npm root --global)" \
&& node "${global_node_modules}/@anthropic-ai/claude-code/install.cjs" \
&& npm cache clean --force \
&& claude --version
RUN mkdir -p /workspace \
&& chown 1000:1000 /workspace
ENV CLAUDE_CONFIG_DIR=/tmp/ecc-claude-config
ENV DISABLE_AUTOUPDATER=1
ENV HOME=/tmp/ecc-home
ENV NODE_PATH=/usr/local/lib/node_modules
WORKDIR /workspace
USER 1000:1000
LABEL org.opencontainers.image.title="ECC plugin setup test (${DISTRO})"
+91
View File
@@ -0,0 +1,91 @@
name: ecc-plugin-setup-test
x-node-image: &node-image node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
x-real-cli: &real-cli
working_dir: /workspace
network_mode: none
read_only: true
pids_limit: 256
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:rw,nosuid,nodev,exec,size=${ECC_TMPFS_SIZE:-2g},uid=1000,gid=1000,mode=0700
- /workspace:rw,nosuid,nodev,noexec,size=${ECC_WORKSPACE_SIZE:-1g},uid=1000,gid=1000,mode=0700
environment:
CLAUDE_CONFIG_DIR: /tmp/ecc-claude-config
DISABLE_AUTOUPDATER: "1"
HOME: /tmp/ecc-home
NPM_CONFIG_CACHE: /tmp/npm-cache
volumes:
- type: bind
source: ../..
target: /ecc
read_only: true
- type: bind
source: "${TEST_PROJECT:-../../tests/fixtures/docker-plugin-project}"
target: /source-project
read_only: true
stdin_open: true
tty: true
entrypoint:
- /bin/bash
- /ecc/docker/plugin-setup/run-real-cli.sh
command:
- dry-run
services:
fixture-tests:
image: *node-image
working_dir: /ecc
user: "1000:1000"
network_mode: none
read_only: true
pids_limit: 256
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:rw,nosuid,nodev,exec,size=256m
volumes:
- type: bind
source: ../..
target: /ecc
read_only: true
entrypoint:
- /bin/bash
- /ecc/docker/plugin-setup/run-fixture-tests.sh
real-cli:
<<: *real-cli
image: ecc-plugin-setup:debian
build:
context: .
dockerfile: Dockerfile
args:
NODE_IMAGE: *node-image
OS_IMAGE: *node-image
DISTRO: debian
CLAUDE_CODE_VERSION: 2.1.220
real-cli-networked:
<<: *real-cli
profiles:
- networked
network_mode: default
image: ecc-plugin-setup:debian
real-cli-ubuntu:
<<: *real-cli
image: ecc-plugin-setup:ubuntu
build:
context: .
dockerfile: Dockerfile
args:
NODE_IMAGE: *node-image
OS_IMAGE: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
DISTRO: ubuntu
CLAUDE_CODE_VERSION: 2.1.220
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const usage = `Usage: node docker/plugin-setup/interactive-plan.js [options] [-- command ...]
Emit the Docker side of the terminal-opener executable-plus-argv contract.
Options:
--container <name> Named running container (default: ecc-plugin-shell).
--workdir <path> Absolute container working directory (default: /workspace/project).
--json Emit compact JSON.
--help, -h Show this help.
-- command ... Interactive command (default: bash).
`;
function fail(message) {
const error = new Error(message);
error.exitCode = 2;
throw error;
}
function readValue(argv, index, option) {
const value = argv[index + 1];
if (!value || value === '--') {
fail(`Invalid ${option}: expected a value.`);
}
return value;
}
function parseArgs(argv) {
let container = 'ecc-plugin-shell';
let workdir = '/workspace/project';
let json = false;
let command = ['bash'];
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') {
command = argv.slice(index + 1);
if (command.length === 0) {
fail('Invalid command: expected at least one argv entry after --.');
}
break;
}
if (argument === '--container') {
container = readValue(argv, index, '--container');
index += 1;
} else if (argument === '--workdir') {
workdir = readValue(argv, index, '--workdir');
index += 1;
} else if (argument === '--json') {
json = true;
} else if (argument === '--help' || argument === '-h') {
return { help: true };
} else {
fail(`Invalid option: ${argument}`);
}
}
if (container.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(container)) {
fail('Invalid container name. Use Docker name characters only.');
}
const normalizedWorkdir = path.posix.normalize(workdir);
if (
!path.posix.isAbsolute(workdir)
|| /[\r\n\0]/.test(workdir)
|| (
normalizedWorkdir !== '/workspace'
&& !normalizedWorkdir.startsWith('/workspace/')
)
) {
fail('Invalid workdir. Use an absolute path within /workspace.');
}
if (command.some((entry) => entry.length === 0 || /\0/.test(entry))) {
fail('Invalid command argv entry.');
}
return { command, container, help: false, json, workdir };
}
function buildPlan(options) {
return {
contractVersion: 1,
executable: 'docker',
argv: [
'exec',
'-it',
'-w',
options.workdir,
options.container,
...options.command,
],
};
}
function main() {
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write(usage);
return;
}
const spacing = options.json ? 0 : 2;
process.stdout.write(`${JSON.stringify(buildPlan(options), null, spacing)}\n`);
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exitCode = error.exitCode || 1;
}
}
if (require.main === module) {
main();
}
module.exports = { buildPlan, parseArgs };
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
'use strict';
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const EXPECTED_NAME = 'ecc-universal';
const EXPECTED_BIN = 'scripts/ecc.js';
const CHILD_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
const REQUIRED_FILES = Object.freeze([
'scripts/ecc.js',
'manifests/install-components.json',
'manifests/install-modules.json',
'manifests/install-profiles.json',
]);
function fail(message) {
throw new Error(message);
}
function isWithin(root, candidate) {
const relative = path.relative(root, candidate);
return relative === '' || (
relative !== '..'
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative)
);
}
function requireRegularFile(packageRoot, relativePath) {
const resolvedPath = path.resolve(packageRoot, relativePath);
if (!isWithin(packageRoot, resolvedPath)) {
fail(`Package path escapes the extracted root: ${relativePath}`);
}
let file;
try {
file = fs.lstatSync(resolvedPath);
} catch {
fail(`Packed package is missing ${relativePath}.`);
}
if (!file.isFile() || file.isSymbolicLink()) {
fail(`Packed package path is not a regular file: ${relativePath}`);
}
return resolvedPath;
}
function validatePackedPackage(packageRoot) {
const resolvedRoot = path.resolve(packageRoot);
const packageJsonPath = requireRegularFile(resolvedRoot, 'package.json');
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (manifest.name !== EXPECTED_NAME) {
fail(`Unexpected packed package name: ${manifest.name || '<missing>'}.`);
}
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
fail('Packed package version is missing.');
}
if (!manifest.bin || manifest.bin.ecc !== EXPECTED_BIN) {
fail(`Packed package bin.ecc must map to ${EXPECTED_BIN}.`);
}
for (const requiredFile of REQUIRED_FILES) {
requireRegularFile(resolvedRoot, requiredFile);
}
const binTarget = path.resolve(resolvedRoot, manifest.bin.ecc);
if (!isWithin(resolvedRoot, binTarget)) {
fail('Packed package bin.ecc escapes the extracted package root.');
}
if (process.platform !== 'win32') {
fs.accessSync(binTarget, fs.constants.X_OK);
}
return binTarget;
}
function run(executable, argv, options = {}) {
const result = spawnSync(executable, argv, {
...options,
encoding: 'utf8',
shell: false,
timeout: CHILD_PROCESS_TIMEOUT_MS,
});
if (result.error) {
fail(`Unable to run ${executable}: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || '').trim();
fail(`${executable} exited with status ${result.status}${detail ? `: ${detail}` : ''}`);
}
return result;
}
function preparePackedCli(sourceRoot, outputRoot) {
const resolvedSource = path.resolve(sourceRoot);
const resolvedOutput = path.resolve(outputRoot);
if (resolvedSource !== '/ecc') {
fail('Package source must be the read-only /ecc checkout.');
}
if (resolvedOutput !== '/tmp' && !resolvedOutput.startsWith('/tmp/')) {
fail('Packed CLI output must remain under /tmp.');
}
fs.mkdirSync(resolvedOutput, { recursive: true, mode: 0o700 });
const workRoot = fs.mkdtempSync(path.join(resolvedOutput, 'artifact-'));
const childEnv = {
...process.env,
NPM_CONFIG_CACHE: '/tmp/npm-cache',
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_ignore_scripts: 'true',
npm_config_offline: 'true',
};
const packed = run('npm', [
'pack',
resolvedSource,
'--ignore-scripts',
'--pack-destination',
workRoot,
'--json',
], { env: childEnv });
let metadata;
try {
metadata = JSON.parse(packed.stdout);
} catch (error) {
fail(`npm pack returned invalid JSON: ${error.message}`);
}
const filename = metadata?.[0]?.filename;
if (
typeof filename !== 'string'
|| path.basename(filename) !== filename
|| !filename.endsWith('.tgz')
) {
fail('npm pack did not return a confined tarball filename.');
}
const archivePath = path.resolve(workRoot, filename);
if (!isWithin(workRoot, archivePath)) {
fail('npm pack tarball escaped the artifact directory.');
}
const extractRoot = path.join(workRoot, 'extracted');
fs.mkdirSync(extractRoot, { mode: 0o700 });
run('tar', ['-xzf', archivePath, '-C', extractRoot]);
const binTarget = validatePackedPackage(path.join(extractRoot, 'package'));
const binRoot = path.join(workRoot, 'bin');
fs.mkdirSync(binRoot, { mode: 0o700 });
const publicBin = path.join(binRoot, 'ecc');
fs.symlinkSync(binTarget, publicBin);
return publicBin;
}
function main() {
try {
const publicBin = preparePackedCli(process.argv[2], process.argv[3]);
process.stdout.write(`${publicBin}\n`);
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exitCode = 1;
}
}
if (require.main === module) main();
module.exports = { isWithin, preparePackedCli, validatePackedPackage };
@@ -0,0 +1,36 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const WORKSPACE_ROOT = '/workspace';
function resolveProjectDir(candidate) {
if (
typeof candidate !== 'string'
|| !path.posix.isAbsolute(candidate)
|| /[\0\r\n]/.test(candidate)
) {
throw new Error('ECC_PROJECT_DIR must be an absolute path within /workspace.');
}
const resolved = path.posix.resolve(candidate);
if (resolved === WORKSPACE_ROOT || !resolved.startsWith(`${WORKSPACE_ROOT}/`)) {
throw new Error('ECC_PROJECT_DIR must be a child path within /workspace.');
}
return resolved;
}
function main() {
try {
process.stdout.write(`${resolveProjectDir(process.argv[2])}\n`);
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exitCode = 2;
}
}
if (require.main === module) main();
module.exports = { resolveProjectDir };
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ECC_ROOT=/ecc
fixture_uid="$(id -u)"
readonly fixture_uid
fixture_gid="$(id -g)"
readonly fixture_gid
if [[ "$fixture_uid" != 1000 || "$fixture_gid" != 1000 ]]; then
printf 'Fixture tests must run as uid/gid 1000:1000 (got %s:%s)\n' \
"$fixture_uid" "$fixture_gid" >&2
exit 1
fi
cd "$ECC_ROOT"
exec node docker/plugin-setup/run-platform-tests.js
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..', '..');
const CHILD_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
const testFiles = [
'tests/lib/install-manifests.test.js',
'tests/lib/install-targets.test.js',
'tests/lib/install-executor.test.js',
];
const excludedGitEnvKeys = new Set([
'GIT_DIR',
'GIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_COMMON_DIR',
'GIT_PREFIX',
]);
const childEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !excludedGitEnvKeys.has(key))
);
console.log(`Running ECC install tests on ${process.platform}/${process.arch}`);
for (const testFile of testFiles) {
const result = spawnSync(process.execPath, [path.join(repoRoot, testFile)], {
cwd: repoRoot,
env: childEnv,
shell: false,
stdio: 'inherit',
timeout: CHILD_PROCESS_TIMEOUT_MS,
});
if (result.error) {
console.error(`Unable to run ${testFile}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
console.error(`${testFile} exited with status ${result.status}`);
process.exit(result.status ?? 1);
}
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ECC_ROOT=/ecc
readonly SOURCE_PROJECT=/source-project
readonly MODE="${1:-dry-run}"
readonly requested_project_dir="${ECC_PROJECT_DIR:-/workspace/project}"
NPM_CONFIG_CACHE=/tmp/npm-cache
export NPM_CONFIG_CACHE
readonly NPM_CONFIG_CACHE
usage() {
printf '%s\n' \
'Usage: docker compose run --rm real-cli <mode>' \
'' \
'Modes:' \
' dry-run Inspect a project-local ECC install without mutation (default).' \
' install Install ECC into the isolated project copy.' \
' plugin Launch Claude with the local ECC checkout via --plugin-dir.' \
' shell Open a shell in the isolated project copy.'
}
case "$MODE" in
dry-run|install|plugin|shell)
;;
help|--help|-h)
usage
exit 0
;;
*)
printf 'Unknown mode: %s\n\n' "$MODE" >&2
usage >&2
exit 2
;;
esac
if [[ ! -f "$ECC_ROOT/package.json" ]]; then
printf 'ECC checkout is not mounted at %s\n' "$ECC_ROOT" >&2
exit 2
fi
if [[ ! -d "$SOURCE_PROJECT" ]]; then
printf 'Source project is not mounted at %s\n' "$SOURCE_PROJECT" >&2
exit 2
fi
project_dir="$(
node "$ECC_ROOT/docker/plugin-setup/resolve-project-dir.js" \
"$requested_project_dir"
)"
readonly project_dir
mkdir -p "$HOME" "$CLAUDE_CONFIG_DIR" "$NPM_CONFIG_CACHE"
chmod 0700 "$HOME" "$CLAUDE_CONFIG_DIR" "$NPM_CONFIG_CACHE"
if [[ ! -e "$project_dir" ]]; then
mkdir -m 0700 "$project_dir"
cp -a "$SOURCE_PROJECT/." "$project_dir/"
elif [[ ! -d "$project_dir" ]]; then
printf 'ECC project path is not a directory: %s\n' "$project_dir" >&2
exit 2
fi
cd "$project_dir"
if [[ ! -d .git ]]; then
git init --quiet
fi
packed_cli=''
if [[ "$MODE" == dry-run || "$MODE" == install ]]; then
packed_cli="$(
node "$ECC_ROOT/docker/plugin-setup/prepare-packed-cli.js" \
"$ECC_ROOT" \
/tmp/ecc-packed-cli
)"
fi
readonly packed_cli
run_ecc() {
if [[ ! -x "$packed_cli" ]]; then
printf 'Packed ECC public executable is unavailable\n' >&2
return 1
fi
"$packed_cli" "$@"
}
run_install() {
run_ecc install \
--profile core \
--target claude-project \
"$@"
}
claude --version
printf 'Isolated project: %s\n' "$project_dir"
case "$MODE" in
dry-run)
plan_file="$(mktemp /tmp/ecc-install-plan.XXXXXX.json)"
run_install \
--dry-run \
--json > "$plan_file"
if [[ -e "$project_dir/.claude" ]]; then
printf 'Dry run unexpectedly mutated %s/.claude\n' "$project_dir" >&2
exit 1
fi
node "$ECC_ROOT/docker/plugin-setup/verify-install-plan.js" "$project_dir" --dry-run < "$plan_file"
cat "$plan_file"
;;
install)
run_install --json
if [[ ! -f "$project_dir/.claude/ecc/install-state.json" ]]; then
printf 'Install did not create confined install state\n' >&2
exit 1
fi
run_install --json
run_ecc list-installed --json
run_ecc doctor --target claude-project
;;
plugin)
exec claude --plugin-dir "$ECC_ROOT"
;;
shell)
exec /bin/bash
;;
esac
@@ -0,0 +1,71 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function fail(message) {
throw new Error(message);
}
function isWithin(root, candidate) {
const relative = path.relative(root, candidate);
return relative === '' || (
relative !== '..'
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative)
);
}
function validatePlan(payload, projectDir, requireDryRun) {
const expectedRoot = path.resolve(projectDir, '.claude');
if (!payload || typeof payload !== 'object' || !payload.plan) {
fail('Install output is missing a plan.');
}
if (requireDryRun && payload.dryRun !== true) {
fail('Install plan did not report dryRun=true.');
}
if (payload.plan.target !== 'claude-project') {
fail('Install plan target is not claude-project.');
}
if (
typeof payload.plan.installRoot !== 'string'
|| path.resolve(payload.plan.installRoot) !== expectedRoot
) {
fail('Install root is not confined to the isolated project.');
}
if (!Array.isArray(payload.plan.operations) || payload.plan.operations.length === 0) {
fail('Install plan has no operations.');
}
for (const operation of payload.plan.operations) {
if (
!operation
|| typeof operation.destinationPath !== 'string'
|| !isWithin(expectedRoot, path.resolve(operation.destinationPath))
) {
fail('Install plan contains an operation outside the isolated project root.');
}
}
}
function main() {
try {
const projectDir = process.argv[2];
if (!projectDir || !path.isAbsolute(projectDir)) {
fail('Expected an absolute isolated project path.');
}
const requireDryRun = process.argv.includes('--dry-run');
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
validatePlan(payload, projectDir, requireDryRun);
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = { isWithin, validatePlan };
+1
View File
@@ -450,6 +450,7 @@
"discussion:audit": "node scripts/discussion-audit.js",
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
"security:advisory-sources": "node scripts/ci/supply-chain-advisory-sources.js",
"test:plugin-setup-platform": "node docker/plugin-setup/run-platform-tests.js",
"claw": "node scripts/claw.js",
"orchestrate:status": "node scripts/orchestration-status.js",
"orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh",
+166 -11
View File
@@ -1,22 +1,12 @@
---
name: docker-patterns
description: Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration.
metadata:
origin: ECC
description: Docker and Docker Compose patterns for local development, hardened CLI installer harnesses, container security, networking, volumes, and multi-service orchestration. Use when creating or reviewing Dockerfiles and Compose services, testing installers across Linux distributions, or planning accurate native macOS and Windows validation.
---
# Docker Patterns
Docker and Docker Compose best practices for containerized development.
## When to Activate
- Setting up Docker Compose for local development
- Designing multi-container architectures
- Troubleshooting container networking or volume issues
- Reviewing Dockerfiles for security and size
- Migrating from local dev to containerized workflow
## Docker Compose for Local Development
### Standard Web App Stack
@@ -282,6 +272,171 @@ services:
# ENV API_KEY=sk-proj-xxxxx # NEVER DO THIS
```
## Hardened CLI Installer Harnesses
Use containers to test installer behavior against disposable project copies without allowing the test to mutate the source checkout.
### Respect the Platform Boundary
- Run real containers for Linux distributions such as Debian and Ubuntu.
- macOS cannot run as a Docker container because Docker shares a Linux kernel. Run the same shell-free test entry point natively on macOS.
- Windows containers require a Windows Docker engine. Run platform-independent logic on a native Windows CI runner and reserve Windows containers for a Windows host.
- Keep a native Ubuntu/macOS/Windows CI matrix for host-specific paths, command shims, quoting, and filesystem behavior.
Do not claim that a Linux container validates macOS or Windows behavior.
### Enforce the Isolation Contract
- Pin base images by immutable digest and pin installed CLI versions.
- Run as a non-root numeric UID/GID when distro account names differ.
- Mount the repository and source project read-only.
- Copy the source project into a writable `tmpfs` workspace before any mutation.
- Mount `/workspace` with `noexec`, UID/GID 1000, and `mode=0700` so only the
container user can inspect project data.
- Keep npm and npx's executable cache at `NPM_CONFIG_CACHE=/tmp/npm-cache` on
the executable `/tmp` mount. Its default size is 2 GiB and can be adjusted
with `ECC_TMPFS_SIZE`; `ECC_WORKSPACE_SIZE` separately controls the private
workspace mount.
- Set `read_only: true`, `no-new-privileges:true`, `cap_drop: [ALL]`, and a finite `pids_limit`.
- Keep the default real-CLI services on `network_mode: none`. Add network access
only through a visibly named opt-in service for an authenticated provider
session; never make it an accidental environment-driven default.
- Create only the writable temporary paths the tool needs.
- Do not pass host credentials into the container by default.
- Default to a dry run and whitelist only the explicit `dry-run`, `install`,
`plugin`, and `shell` modes.
- Use argument arrays or `spawnSync(..., { shell: false })` for cross-platform runners. Never interpolate project paths into a shell command.
### Exercise the ECC Plugin Setup Harness
Use `docker/plugin-setup/compose.yaml` as the reference implementation. It provides:
- `fixture-tests` for the focused install manifest, target, and executor suite.
- `real-cli` for the pinned Debian-based generic Linux image.
- `real-cli-ubuntu` for the pinned Ubuntu image.
Validate the Compose model before building:
```bash
docker compose -f docker/plugin-setup/compose.yaml config --quiet
```
Build both real Linux images:
```bash
docker compose -f docker/plugin-setup/compose.yaml \
build real-cli real-cli-ubuntu
```
Run the safe default flow in each image:
```bash
docker compose -p ecc-plugin-debian-test \
-f docker/plugin-setup/compose.yaml \
run --rm -T real-cli dry-run
docker compose -p ecc-plugin-ubuntu-test \
-f docker/plugin-setup/compose.yaml \
run --rm -T real-cli-ubuntu dry-run
```
The dry run executes the current public command contract:
```bash
ecc install --profile core --target claude-project --dry-run --json
```
Before that command runs, the container creates a locally packed npm artifact
from the read-only checkout with `npm pack --ignore-scripts`. It extracts the
self-created tarball under `/tmp`, validates the `ecc-universal` package name,
required install manifests, and the confined `package.json` `bin.ecc` mapping,
then invokes the extracted `ecc` executable. The runtime stays on
`network_mode: none`, does not execute package lifecycle scripts, and does not
rely on host `node_modules`; its exact pinned production dependencies are
already present in the image.
The harness rejects an empty plan, a non-`claude-project` target, any operation
outside `/workspace/project/.claude`, or any dry run that creates the target
directory. `install` performs the isolated apply twice, checks its managed
install state, lists the installed target, and runs `doctor`.
### Start, Open, Reconnect, and Clean Up a Named Session
Start a detached container without `--rm` so leaving a terminal does not remove
the session:
```bash
docker compose -p ecc-plugin-session \
-f docker/plugin-setup/compose.yaml \
run --detach --name ecc-plugin-shell real-cli shell
```
The container copies the read-only fixture to the stable private directory
`/workspace/project`. Confirm it is running, then emit the Docker side of the
terminal-opener v1 data contract:
```bash
docker inspect --format '{{.State.Running}}' ecc-plugin-shell
node docker/plugin-setup/interactive-plan.js \
--container ecc-plugin-shell \
--workdir /workspace/project \
--json \
-- bash
```
The JSON result has exactly an `executable` and `argv` boundary (plus
`contractVersion: 1`): the executable is `docker`, and argv begins with
`exec`, `-it`, and `-w`. Pass that data to the separate terminal-opener skill
when it is installed. This Docker harness deliberately does not import a
terminal adapter, interpolate a shell command, or manage a host GUI process.
Until then, open the same PTY in the current host terminal directly:
```bash
docker exec -it -w /workspace/project ecc-plugin-shell bash
```
Exit the shell without stopping the detached container. Reconnect with the
same `docker exec -it` command. When finished, remove the exact named container
and its Compose project resources:
```bash
docker rm --force ecc-plugin-shell
docker compose -p ecc-plugin-session \
-f docker/plugin-setup/compose.yaml \
down --remove-orphans
```
Host credentials are absent by default and credential directories are never
mounted. The default service also has no network access. When an authenticated
provider session genuinely needs a network, build `real-cli` first and then opt
in visibly with `docker compose --profile networked run real-cli-networked
shell`. Prefer authenticating inside that disposable session. If a CI run must
inherit a host environment credential, make that opt-in at invocation with an
explicit Compose `--env NAME` flag, understand that the value is inspectable
and can be exfiltrated for the container lifetime, and remove the exact named
container immediately after.
Run the same focused suite natively on the host:
```bash
npm run test:plugin-setup-platform
```
Inspect the produced identity and environment before trusting the image:
```bash
docker image inspect ecc-plugin-setup:debian ecc-plugin-setup:ubuntu
```
Clean each named test project without deleting unrelated volumes or images:
```bash
docker compose -p ecc-plugin-debian-test \
-f docker/plugin-setup/compose.yaml down --remove-orphans
docker compose -p ecc-plugin-ubuntu-test \
-f docker/plugin-setup/compose.yaml down --remove-orphans
```
## .dockerignore
```
+420
View File
@@ -0,0 +1,420 @@
'use strict';
const assert = require('assert');
const { spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.join(__dirname, '..', '..');
const harnessRoot = path.join(repoRoot, 'docker', 'plugin-setup');
const SUBPROCESS_TIMEOUT_MS = 30_000;
const files = {
ci: path.join(repoRoot, '.github', 'workflows', 'ci.yml'),
compose: path.join(harnessRoot, 'compose.yaml'),
dockerfile: path.join(harnessRoot, 'Dockerfile'),
fixtureProject: path.join(
repoRoot,
'tests',
'fixtures',
'docker-plugin-project',
'package.json'
),
fixtureRunner: path.join(harnessRoot, 'run-fixture-tests.sh'),
interactivePlan: path.join(harnessRoot, 'interactive-plan.js'),
packageJson: path.join(repoRoot, 'package.json'),
packedCliPreparer: path.join(harnessRoot, 'prepare-packed-cli.js'),
platformRunner: path.join(harnessRoot, 'run-platform-tests.js'),
planValidator: path.join(harnessRoot, 'verify-install-plan.js'),
projectDirResolver: path.join(harnessRoot, 'resolve-project-dir.js'),
realRunner: path.join(harnessRoot, 'run-real-cli.sh'),
};
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function read(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function runNode(argv, options = {}) {
const result = spawnSync(process.execPath, argv, {
...options,
shell: false,
timeout: SUBPROCESS_TIMEOUT_MS,
});
assert.ifError(result.error);
return result;
}
console.log('\n=== Docker plugin setup harness tests ===\n');
test('ships the focused Docker harness and default fixture project', () => {
for (const filePath of Object.values(files)) {
assert.ok(
fs.existsSync(filePath),
`Missing ${path.relative(repoRoot, filePath)}`
);
}
});
test('builds pinned Debian and Ubuntu images as a non-root user', () => {
const dockerfile = read(files.dockerfile);
const compose = read(files.compose);
assert.match(dockerfile, /node:22-bookworm-slim@sha256:[a-f0-9]{64}/);
assert.match(dockerfile, /ARG OS_IMAGE=/);
assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS node-runtime/);
assert.match(dockerfile, /FROM \$\{OS_IMAGE\}/);
assert.match(dockerfile, /COPY --from=node-runtime \/usr\/local\/ \/usr\/local\//);
assert.match(dockerfile, /ARG CLAUDE_CODE_VERSION=\d+\.\d+\.\d+/);
assert.match(dockerfile, /@anthropic-ai\/claude-code@\$\{CLAUDE_CODE_VERSION\}/);
assert.match(dockerfile, /@iarna\/toml@2\.2\.5/);
assert.match(dockerfile, /ajv@8\.20\.0/);
assert.match(dockerfile, /sql\.js@1\.14\.1/);
assert.match(dockerfile, /--ignore-scripts/);
assert.match(
dockerfile,
/@anthropic-ai\/claude-code\/install\.cjs/
);
assert.match(dockerfile, /ENV DISABLE_AUTOUPDATER=1/);
assert.match(dockerfile, /ENV HOME=\/tmp\/ecc-home/);
assert.match(dockerfile, /ENV NODE_PATH=\/usr\/local\/lib\/node_modules/);
assert.match(dockerfile, /chown 1000:1000 \/workspace/);
assert.match(dockerfile, /USER 1000:1000/);
assert.doesNotMatch(dockerfile, /:latest/);
assert.match(compose, /image:\s*ecc-plugin-setup:debian/);
assert.match(compose, /image:\s*ecc-plugin-setup:ubuntu/);
assert.match(compose, /ubuntu:24\.04@sha256:[a-f0-9]{64}/);
assert.match(compose, /real-cli-ubuntu:/);
assert.match(
compose,
/fixture-tests:[\s\S]*?user:\s*["']1000:1000["']/
);
assert.strictEqual(
(compose.match(/node:22-bookworm-slim@sha256:[a-f0-9]{64}/g) || []).length,
1,
'The pinned Node image must have one source of truth in Compose'
);
assert.match(compose, /x-node-image:\s*&node-image/);
assert.match(compose, /image:\s*\*node-image/);
assert.match(compose, /NODE_IMAGE:\s*\*node-image/);
assert.match(compose, /OS_IMAGE:\s*\*node-image/);
});
test('keeps checkout and source project read-only with hardened defaults', () => {
const compose = read(files.compose);
assert.match(compose, /network_mode:\s*none/);
assert.match(compose, /x-real-cli:[\s\S]*?network_mode:\s*none[\s\S]*?services:/);
assert.match(
compose,
/real-cli-networked:[\s\S]*?profiles:[\s\S]*?-\s*networked[\s\S]*?network_mode:\s*default/
);
assert.match(compose, /read_only:\s*true/);
assert.match(compose, /no-new-privileges:true/);
assert.match(compose, /cap_drop:\s*\n\s*-\s*ALL/);
assert.match(compose, /pids_limit:\s*256/);
assert.match(compose, /target:\s*\/ecc\s*\n\s*read_only:\s*true/);
assert.match(compose, /target:\s*\/source-project\s*\n\s*read_only:\s*true/);
assert.match(compose, /CLAUDE_CONFIG_DIR:\s*\/tmp\/ecc-claude-config/);
assert.match(
compose,
/\/tmp:rw,nosuid,nodev,exec,size=\$\{ECC_TMPFS_SIZE:-2g\},uid=1000,gid=1000,mode=0700/
);
assert.match(
compose,
/\/workspace:rw,nosuid,nodev,noexec,size=\$\{ECC_WORKSPACE_SIZE:-1g\},uid=1000,gid=1000,mode=0700/
);
assert.match(compose, /NPM_CONFIG_CACHE:\s*\/tmp\/npm-cache/);
assert.doesNotMatch(
compose,
/ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN|env_file:/
);
});
test('real runner copies into tmpfs and exposes only explicit safe modes', () => {
const runner = read(files.realRunner);
assert.match(runner, /ECC_PROJECT_DIR:-\/workspace\/project/);
assert.match(runner, /mkdir -p "\$HOME" "\$CLAUDE_CONFIG_DIR" "\$NPM_CONFIG_CACHE"/);
assert.match(runner, /dry-run\|install\|plugin\|shell/);
assert.match(runner, /--target claude-project/);
assert.match(runner, /--dry-run/);
assert.match(runner, /verify-install-plan\.js.*--dry-run/);
assert.match(runner, /resolve-project-dir\.js/);
assert.match(
runner,
/project_dir="\$\([\s\S]*?resolve-project-dir\.js[\s\S]*?\)"\s*\nreadonly project_dir/
);
assert.doesNotMatch(runner, /readonly project_dir="\$\(/);
assert.match(runner, /prepare-packed-cli\.js/);
assert.match(runner, /run_ecc install/);
assert.match(runner, /run_ecc list-installed --json/);
assert.match(runner, /run_ecc doctor --target claude-project/);
assert.match(runner, /\[\[ -e "\$project_dir\/\.claude" \]\]/);
assert.doesNotMatch(
runner,
/scripts\/ecc\.js" setup|--move-scope|\bmigrate\b/
);
assert.doesNotMatch(runner, /scripts\/ecc\.js" install/);
assert.doesNotMatch(runner, /\beval\b|rm\s+-rf/);
});
test('prepares a local npm artifact through the confined public bin contract', () => {
const preparer = read(files.packedCliPreparer);
assert.match(preparer, /spawnSync\(executable, argv/);
assert.match(preparer, /run\(['"]npm['"]/);
assert.match(preparer, /['"]pack['"]/);
assert.match(preparer, /['"]--ignore-scripts['"]/);
assert.match(preparer, /npm_config_offline:\s*['"]true['"]/);
assert.match(preparer, /run\(['"]tar['"]/);
assert.match(preparer, /shell:\s*false/g);
assert.match(
preparer,
/const CHILD_PROCESS_TIMEOUT_MS\s*=\s*5 \* 60 \* 1000;/
);
assert.match(preparer, /timeout:\s*CHILD_PROCESS_TIMEOUT_MS/);
assert.doesNotMatch(preparer, /execSync\(|\beval\b/);
const { validatePackedPackage } = require(files.packedCliPreparer);
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-cli-'));
function createFixture(name, options = {}) {
const packageRoot = path.join(fixtureRoot, name);
fs.mkdirSync(path.join(packageRoot, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(packageRoot, 'manifests'), { recursive: true });
fs.writeFileSync(
path.join(packageRoot, 'package.json'),
JSON.stringify({
name: options.packageName || 'ecc-universal',
version: '2.1.0',
bin: options.bin === undefined ? { ecc: 'scripts/ecc.js' } : options.bin,
})
);
fs.writeFileSync(path.join(packageRoot, 'scripts', 'ecc.js'), '#!/usr/bin/env node\n');
fs.chmodSync(path.join(packageRoot, 'scripts', 'ecc.js'), 0o755);
for (const manifest of [
'install-components.json',
'install-modules.json',
'install-profiles.json',
]) {
if (manifest !== options.omitManifest) {
fs.writeFileSync(path.join(packageRoot, 'manifests', manifest), '{}\n');
}
}
return packageRoot;
}
try {
const validRoot = createFixture('valid');
assert.strictEqual(
validatePackedPackage(validRoot),
path.join(validRoot, 'scripts', 'ecc.js')
);
for (const [name, options, pattern] of [
['wrong-name', { packageName: 'not-ecc' }, /package name/i],
['missing-bin', { bin: {} }, /bin\.ecc/i],
['escaping-bin', { bin: { ecc: '../escape.js' } }, /bin\.ecc/i],
['missing-manifest', { omitManifest: 'install-profiles.json' }, /missing/i],
]) {
assert.throws(() => validatePackedPackage(createFixture(name, options)), pattern);
}
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
test('normalizes the isolated project path before enforcing workspace containment', () => {
const valid = runNode([
files.projectDirResolver,
'/workspace/nested/../project',
], { encoding: 'utf8' });
assert.strictEqual(valid.status, 0, valid.stderr);
assert.strictEqual(valid.stdout.trim(), '/workspace/project');
for (const candidate of [
'/workspace',
'/workspace/../tmp/project',
'/tmp/project',
'workspace/project',
]) {
const invalid = runNode([
files.projectDirResolver,
candidate,
], { encoding: 'utf8' });
assert.strictEqual(invalid.status, 2, `${candidate}: ${invalid.stderr}`);
assert.match(invalid.stderr, /within \/workspace/i);
}
});
test('fixture runner delegates to the cross-platform test entry point', () => {
const runner = read(files.fixtureRunner);
assert.match(runner, /id -u/);
assert.match(runner, /id -g/);
assert.match(runner, /must run as uid\/gid 1000:1000/i);
assert.match(
runner,
/exec node docker\/plugin-setup\/run-platform-tests\.js/
);
});
test('uses one shell-free focused runner across Linux, macOS, and Windows', () => {
const ci = read(files.ci);
const packageJson = read(files.packageJson);
const platformRunner = read(files.platformRunner);
assert.match(
ci,
/os:\s*\[ubuntu-latest,\s*windows-latest,\s*macos-latest\]/
);
assert.match(
packageJson,
/"test:plugin-setup-platform":\s*"node docker\/plugin-setup\/run-platform-tests\.js"/
);
assert.match(platformRunner, /spawnSync\(/);
assert.match(platformRunner, /shell:\s*false/);
assert.match(
platformRunner,
/const CHILD_PROCESS_TIMEOUT_MS\s*=\s*5 \* 60 \* 1000;/
);
assert.match(platformRunner, /timeout:\s*CHILD_PROCESS_TIMEOUT_MS/);
assert.match(platformRunner, /Object\.fromEntries\(/);
assert.match(platformRunner, /Object\.entries\(process\.env\)\.filter/);
assert.doesNotMatch(platformRunner, /delete childEnv\[/);
assert.match(platformRunner, /tests\/lib\/install-manifests\.test\.js/);
assert.match(platformRunner, /tests\/lib\/install-targets\.test\.js/);
assert.match(platformRunner, /tests\/lib\/install-executor\.test\.js/);
assert.doesNotMatch(platformRunner, /\beval\b|execSync\(/);
});
test('emits docker exec as an executable plus argv integration contract', () => {
const result = runNode([
files.interactivePlan,
'--container', 'ecc-plugin-shell',
'--workdir', '/workspace/project',
'--json',
'--',
'node',
'-p',
'process.stdin.isTTY',
], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout), {
contractVersion: 1,
executable: 'docker',
argv: [
'exec',
'-it',
'-w',
'/workspace/project',
'ecc-plugin-shell',
'node',
'-p',
'process.stdin.isTTY',
],
});
});
test('keeps Docker session values as argv entries and validates boundaries', () => {
const literalArgument = '$(touch should-not-run)';
const result = runNode([
files.interactivePlan,
'--container', 'ecc.plugin-shell_1',
'--workdir', '/workspace/project with spaces',
'--json',
'--',
'printf',
'%s',
literalArgument,
], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout).argv.slice(-3), [
'printf',
'%s',
literalArgument,
]);
for (const args of [
['--container', '../escape', '--json'],
['--container', 'valid-name', '--workdir', 'relative/path', '--json'],
['--container', 'valid-name', '--workdir', '/workspace/../tmp', '--json'],
]) {
const invalid = runNode([files.interactivePlan, ...args], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.strictEqual(invalid.status, 2);
assert.match(invalid.stderr, /invalid/i);
}
});
test('validates dry-run target confinement and nonempty operations', () => {
const projectDir = path.join(repoRoot, 'workspace-project');
const installRoot = path.join(projectDir, '.claude');
const safePlan = {
dryRun: true,
plan: {
target: 'claude-project',
installRoot,
operations: [
{ destinationPath: path.join(installRoot, 'rules', 'ecc', 'base.md') },
],
},
};
const safe = runNode(
[files.planValidator, projectDir, '--dry-run'],
{ encoding: 'utf8', input: JSON.stringify(safePlan) }
);
assert.strictEqual(safe.status, 0, safe.stderr);
const unsafePlan = {
...safePlan,
plan: {
...safePlan.plan,
operations: [{ destinationPath: '/tmp/escape.md' }],
},
};
const unsafe = runNode(
[files.planValidator, projectDir, '--dry-run'],
{ encoding: 'utf8', input: JSON.stringify(unsafePlan) }
);
assert.strictEqual(unsafe.status, 1);
assert.match(unsafe.stderr, /outside/i);
for (const installRootValue of [undefined, 42, { path: installRoot }]) {
const invalidRootPlan = {
...safePlan,
plan: {
...safePlan.plan,
installRoot: installRootValue,
},
};
const invalidRoot = runNode(
[files.planValidator, projectDir, '--dry-run'],
{ encoding: 'utf8', input: JSON.stringify(invalidRootPlan) }
);
assert.strictEqual(invalidRoot.status, 1);
assert.match(invalidRoot.stderr, /install root is not confined/i);
assert.doesNotMatch(invalidRoot.stderr, /ERR_INVALID_ARG_TYPE|TypeError/);
}
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "ecc-docker-plugin-test-project",
"version": "0.0.0",
"private": true
}
+97
View File
@@ -0,0 +1,97 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
const skillPath = path.join(repoRoot, 'skills', 'docker-patterns', 'SKILL.md');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
const skill = fs.readFileSync(skillPath, 'utf8');
console.log('\n=== Docker patterns skill tests ===\n');
test('triggers for hardened installer and cross-platform harness work', () => {
const frontmatter = skill.match(/^---\n([\s\S]*?)\n---/);
assert.ok(frontmatter, 'SKILL.md frontmatter is missing');
assert.match(frontmatter[1], /description:.*installer/i);
assert.match(frontmatter[1], /description:.*macOS.*Windows/i);
});
test('documents the ECC plugin setup harness and safe operating modes', () => {
assert.match(skill, /docker\/plugin-setup\/compose\.yaml/);
assert.match(skill, /\breal-cli\b/);
assert.match(skill, /\breal-cli-ubuntu\b/);
assert.match(skill, /\bfixture-tests\b/);
assert.match(skill, /dry-run.*install.*plugin.*shell/is);
assert.doesNotMatch(skill, /explicit modes such as.*migrate/i);
});
test('requires hardened ephemeral installer execution', () => {
for (const pattern of [
/read[_ -]only/i,
/tmpfs/i,
/no-new-privileges/i,
/cap_drop/i,
/pids_limit/i,
/non-root/i,
/digest/i,
/credential/i,
]) {
assert.match(skill, pattern);
}
});
test('states the honest macOS and Windows validation boundary', () => {
assert.match(skill, /macOS cannot run as a Docker container/i);
assert.match(skill, /Windows containers require a Windows Docker engine/i);
assert.match(skill, /native.*ubuntu.*macOS.*Windows.*CI/is);
assert.doesNotMatch(skill, /macOS container image|simulate Windows/i);
});
test('provides a repeatable build, run, inspect, and cleanup sequence', () => {
assert.match(skill, /docker compose.*build.*real-cli.*real-cli-ubuntu/is);
assert.match(skill, /docker compose.*run.*real-cli.*dry-run/is);
assert.match(skill, /docker image inspect/is);
assert.match(skill, /down --remove-orphans/);
});
test('documents the private named-container lifecycle and terminal boundary', () => {
assert.match(skill, /ECC_TMPFS_SIZE/);
assert.match(skill, /\/workspace.*mode=0700/is);
assert.match(skill, /NPM_CONFIG_CACHE.*\/tmp\/npm-cache/is);
assert.match(skill, /docker compose.*run.*--detach.*--name/is);
assert.match(skill, /interactive-plan\.js/);
assert.match(skill, /executable.*argv/is);
assert.match(skill, /docker exec -it/);
assert.match(skill, /reconnect/i);
assert.match(skill, /docker rm.*ecc-plugin-shell/is);
assert.match(skill, /host credentials.*opt-in/is);
assert.doesNotMatch(skill, /skills\/docker-patterns\/scripts\/open-interactive\.js/);
});
test('requires the offline smoke to execute the locally packed public bin', () => {
assert.match(skill, /npm pack.*--ignore-scripts/is);
assert.match(skill, /package\.json.*bin\.ecc/is);
assert.match(skill, /locally packed/i);
assert.match(skill, /network_mode:\s*none/);
assert.match(skill, /does not\s+rely on.*host `node_modules`/is);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);