From eab57ace516c6dbe2e3d5c89d02a53021b1d9b30 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 5 Jun 2026 16:24:18 +0530 Subject: [PATCH 01/10] fix(agents): correct egress + supply-chain drift across presets Found by the audit-egress workflow, each verified against upstream source: - amp: @sourcegraph/amp -> @ampcode/cli (old name is now a thin alias that depends on the new package; new pkg keeps the `amp` bin) - crush: catwalk.charm.sh -> catwalk.charm.land (Charm domain migration; the stale SNI was silently blocking Crush's model catalog) - aider: avoid two off-allowlist runtime fetches without widening the allowlist - LITELLM_LOCAL_MODEL_COST_MAP=True (use litellm's bundled price map) and --no-check-update (skip aider's pypi.org version ping) - cursor: add the api5 agent fleet (.api5.cursor.sh) + auth hosts, drop the unused api.cursor.com (not used by the CLI per Cursor's network doc) - codex: correct a misleading comment - Codex's default Statsig telemetry to ab.chatgpt.com stays blocked by design, not allowlisted - opencode: autoupdate:false pins the baked version against opencode's launch-time self-upgrade (which would drift from `sluice lock`) --- agents/aider.config.sh | 5 +++-- agents/amp.config.sh | 2 +- agents/codex.config.sh | 3 ++- agents/crush.config.sh | 4 ++-- agents/cursor.config.sh | 5 +++-- agents/opencode.config.sh | 3 ++- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/agents/aider.config.sh b/agents/aider.config.sh index f4cf480..4931b99 100644 --- a/agents/aider.config.sh +++ b/agents/aider.config.sh @@ -14,5 +14,6 @@ SLUICE_ENV="OPENAI_API_KEY ANTHROPIC_API_KEY" # Aider's chat history lives in-repo (.aider.*, already persisted via the mount); this keeps # its model-metadata cache across runs too. (Not .local - that's where pip --user installs it.) SLUICE_STATE_DIRS=".aider" -# --yes-always auto-confirms edits/commands. Drop it for interactive. -SLUICE_RUN_CMD='export PATH="$HOME/.local/bin:$PATH"; aider --yes-always' +# --yes-always auto-confirms edits/commands; --no-check-update skips aider's pypi.org version ping; +# LITELLM_LOCAL_MODEL_COST_MAP uses litellm's bundled price map (else it fetches one off-allowlist). Drop --yes-always for interactive. +SLUICE_RUN_CMD='export PATH="$HOME/.local/bin:$PATH"; LITELLM_LOCAL_MODEL_COST_MAP=True aider --yes-always --no-check-update' diff --git a/agents/amp.config.sh b/agents/amp.config.sh index f1efada..8fb490f 100644 --- a/agents/amp.config.sh +++ b/agents/amp.config.sh @@ -5,7 +5,7 @@ # YOLO is fine here: the sluice contains it (non-root, this dir only, egress locked). It can # still rewrite this dir and use any forwarded creds, so commit your work first. # Auth: export AMP_API_KEY (from ampcode.com/settings) on the HOST (forwarded, never baked). -SLUICE_EXTRA_NPM="@sourcegraph/amp" +SLUICE_EXTRA_NPM="@ampcode/cli" # Amp proxies models through ampcode.com; static.ampcode.com is the update/version check. SLUICE_ALLOW_DOMAINS="ampcode.com static.ampcode.com" SLUICE_DESC="Amp (Sourcegraph)" diff --git a/agents/codex.config.sh b/agents/codex.config.sh index 645d828..69dd71e 100644 --- a/agents/codex.config.sh +++ b/agents/codex.config.sh @@ -6,7 +6,8 @@ # still rewrite this dir and use any forwarded creds, so commit your work first. # Auth: export OPENAI_API_KEY on the HOST before running (forwarded, never baked). SLUICE_EXTRA_NPM="@openai/codex" -# API-key path only. ChatGPT sign-in (adds auth.openai.com chatgpt.com) can't complete headless. +# API-key path only. ChatGPT sign-in (auth.openai.com chatgpt.com) can't complete headless; Codex's +# default telemetry to ab.chatgpt.com stays blocked too (set [otel] metrics_exporter="none" to silence). SLUICE_ALLOW_DOMAINS="api.openai.com" SLUICE_DESC="Codex CLI (OpenAI)" SLUICE_ENV="OPENAI_API_KEY" diff --git a/agents/crush.config.sh b/agents/crush.config.sh index 943669f..e37e5e4 100644 --- a/agents/crush.config.sh +++ b/agents/crush.config.sh @@ -6,9 +6,9 @@ # still rewrite this dir and use any forwarded creds, so commit your work first. # Auth: export your provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / ...) on the HOST. SLUICE_EXTRA_NPM="@charmland/crush" -# api.anthropic.com / api.openai.com cover the common providers; catwalk.charm.sh is Crush's +# api.anthropic.com / api.openai.com cover the common providers; catwalk.charm.land is Crush's # model catalog. For another provider, add its host (or run `sluice learn`). -SLUICE_ALLOW_DOMAINS="api.anthropic.com api.openai.com catwalk.charm.sh" +SLUICE_ALLOW_DOMAINS="api.anthropic.com api.openai.com catwalk.charm.land" SLUICE_DESC="Crush (Charm)" SLUICE_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY" # Persist Crush's sessions/db across runs (its data dir). NOT .config/crush - that's just config. diff --git a/agents/cursor.config.sh b/agents/cursor.config.sh index 9a46d8f..dc863ec 100644 --- a/agents/cursor.config.sh +++ b/agents/cursor.config.sh @@ -8,8 +8,9 @@ # cursor-agent installs via Cursor's own script (it is NOT an npm package); the installer drops # the binary under ~/.local, so we symlink it onto PATH. Runs at build, pre-firewall (free egress). SLUICE_SETUP_CMDS='curl https://cursor.com/install -fsS | bash && mkdir -p "$HOME/.npm-global/bin" && ln -sf "$HOME/.local/bin/cursor-agent" "$HOME/.npm-global/bin/cursor-agent"' -# Cursor proxies models through its own backend; downloads.cursor.com is the binary self-update. -SLUICE_ALLOW_DOMAINS="cursor.com api2.cursor.sh api.cursor.com downloads.cursor.com" +# api2.cursor.sh = most API; .api5.cursor.sh = agent requests + regional agent.* subdomains; authenticate/ +# authenticator/.authentication.cursor.sh = login; downloads.cursor.com = self-update. cursor.sh carries the model/agent stream (laundering surface, THREAT_MODEL #2/#4). +SLUICE_ALLOW_DOMAINS="cursor.com api2.cursor.sh .api5.cursor.sh authenticate.cursor.sh authenticator.cursor.sh .authentication.cursor.sh downloads.cursor.com" SLUICE_DESC="Cursor CLI (cursor-agent)" SLUICE_ENV="CURSOR_API_KEY" # Persist cursor-agent's config/auth across runs (.cursor holds cli-config.json). NOT .local - diff --git a/agents/opencode.config.sh b/agents/opencode.config.sh index 45a3af2..76a82ac 100644 --- a/agents/opencode.config.sh +++ b/agents/opencode.config.sh @@ -8,7 +8,8 @@ SLUICE_EXTRA_NPM="opencode-ai" # opencode has no stable --yolo flag yet, so YOLO via a global allow-all permission config # (baked at build as the sluice user). Remove this to get opencode's default prompts. -SLUICE_SETUP_CMDS='mkdir -p /home/sluice/.config/opencode && printf "{\"permission\":{\"*\":\"allow\"}}\n" > /home/sluice/.config/opencode/opencode.json' +# autoupdate:false pins the baked version - opencode self-upgrades on launch by default, drifting from `sluice lock`. +SLUICE_SETUP_CMDS='mkdir -p /home/sluice/.config/opencode && printf "{\"permission\":{\"*\":\"allow\"},\"autoupdate\":false}\n" > /home/sluice/.config/opencode/opencode.json' # api.anthropic.com / api.openai.com cover the common providers; models.dev is opencode's # model catalog. If you use another provider, add its host (or run `sluice learn`). SLUICE_ALLOW_DOMAINS="api.anthropic.com api.openai.com models.dev" From c1cfa6d1a46438181421eb6ec1ecc2706f2e3dbf Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 5 Jun 2026 16:30:25 +0530 Subject: [PATCH 02/10] fix(agents): document blocked telemetry (gemini) + restore statsig host (claude) Follow-up to the egress-drift pass, verified against upstream: - gemini: note that usage-stats telemetry (Clearcut, play.googleapis.com) is left blocked; privacy.usageStatisticsEnabled=false silences the per-run warning - claude: re-add statsig.anthropic.com - commit 6cc6077 dropped it as dead, but upstream init-firewall.sh still allowlists it alongside statsig.com; both are feature-flag/metrics hosts whose flags can affect behavior. sentry.io error reporting stays blocked. --- agents/claude.config.sh | 4 +++- agents/gemini.config.sh | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/agents/claude.config.sh b/agents/claude.config.sh index 4f6c861..09cbaaf 100644 --- a/agents/claude.config.sh +++ b/agents/claude.config.sh @@ -7,7 +7,9 @@ # Auth: export ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN) on the HOST before running - # it's forwarded into the box, never baked. Browser OAuth can't complete headless, use a key. SLUICE_EXTRA_NPM="@anthropic-ai/claude-code" -SLUICE_ALLOW_DOMAINS="api.anthropic.com platform.claude.com claude.ai statsig.com" +# statsig.{com,anthropic.com} are Claude Code's feature-flag/metrics hosts (flags can affect behavior, +# so both are allowed, matching upstream init-firewall). sentry.io error reporting is left blocked. +SLUICE_ALLOW_DOMAINS="api.anthropic.com platform.claude.com claude.ai statsig.com statsig.anthropic.com" SLUICE_DESC="Claude Code (Anthropic)" SLUICE_ENV="ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN" # Persist Claude Code's sessions/history/auth-cache across runs (host-side, per project). diff --git a/agents/gemini.config.sh b/agents/gemini.config.sh index 487bde2..832f33e 100644 --- a/agents/gemini.config.sh +++ b/agents/gemini.config.sh @@ -7,6 +7,8 @@ # Auth: export GEMINI_API_KEY (from Google AI Studio) on the HOST (forwarded, never baked). SLUICE_EXTRA_NPM="@google/gemini-cli" # API-key path only. The free "login with Google" OAuth tier needs a browser (not headless). +# Gemini's usage-stats telemetry (Clearcut, to play.googleapis.com) is left blocked; disable it with +# privacy.usageStatisticsEnabled=false in .gemini/settings.json to drop the per-run warning. SLUICE_ALLOW_DOMAINS="generativelanguage.googleapis.com" SLUICE_DESC="Gemini CLI (Google)" SLUICE_ENV="GEMINI_API_KEY GOOGLE_API_KEY" From af31330fc24caac221dba0659d15d9e1457099a2 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 5 Jun 2026 19:00:22 +0530 Subject: [PATCH 03/10] feat(workflows): add Claude multi-agent workflows; apply audit-egress findings Add .claude/workflows/ - Claude Code multi-agent orchestration scripts (not GitHub Actions): - review-launcher: review src/*.sh across security / bash-3.2 / docker-podman portability, each finding adversarially verified - audit-egress: audit each agents/*.config.sh preset's egress against the allowlist + THREAT_MODEL.md, then verify each finding against upstream source - triage-tests: run the bats suites, cluster failures, root-cause per cluster Apply the audit-egress findings (all verified against upstream) to the remaining presets: - amp: add auth.ampcode.com + production.ampworkers.com - the auth handshake and the Amp client's WebSocket were blocked at runtime (both per ampcode.com/security) - crush: note that Crush's default PostHog telemetry to data.charm.land is left blocked - qwen: note that the inherited Gemini-CLI Clearcut telemetry to play.googleapis.com is left blocked - cursor: attribute the model/agent stream to the .api5 agent hosts, not bare cursor.sh --- .claude/workflows/README.md | 17 ++++ .claude/workflows/audit-egress.js | 114 +++++++++++++++++++++++++++ .claude/workflows/review-launcher.js | 90 +++++++++++++++++++++ .claude/workflows/triage-tests.js | 101 ++++++++++++++++++++++++ agents/amp.config.sh | 5 +- agents/crush.config.sh | 3 +- agents/cursor.config.sh | 2 +- agents/qwen.config.sh | 3 +- 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 .claude/workflows/README.md create mode 100644 .claude/workflows/audit-egress.js create mode 100644 .claude/workflows/review-launcher.js create mode 100644 .claude/workflows/triage-tests.js diff --git a/.claude/workflows/README.md b/.claude/workflows/README.md new file mode 100644 index 0000000..f5e6412 --- /dev/null +++ b/.claude/workflows/README.md @@ -0,0 +1,17 @@ +# Claude workflows + +Multi-agent orchestration scripts for Claude Code (not GitHub Actions — those live in +`.github/workflows/`). Each `*.js` here fans work out across subagents. + +Run one by asking Claude to "run the `review-launcher` workflow" or via `/review-launcher`. +Watch live progress with `/workflows`. Running a workflow spawns many agents and uses a lot +of tokens, so Claude only starts one when you explicitly ask. + +| Workflow | What it does | +|----------|--------------| +| `review-launcher` | Reviews `src/*.sh` across security / bash-3.2 / docker-vs-podman, adversarially verifies each finding. | +| `audit-egress` | Cross-checks every `agents/*.config.sh` preset's egress hosts against the allowlist + `THREAT_MODEL.md`, then adversarially verifies each finding against upstream before reporting it. | +| `triage-tests` | Runs the bats suites, clusters failures, root-causes each cluster in parallel. Pass `args.suite` to scope. | + +Anatomy: a pure-literal `export const meta = {...}` (name, description, phases) then a body using +`agent()` / `parallel()` / `pipeline()` / `phase()` / `log()`. Copy any file here as a template. diff --git a/.claude/workflows/audit-egress.js b/.claude/workflows/audit-egress.js new file mode 100644 index 0000000..8f71c8d --- /dev/null +++ b/.claude/workflows/audit-egress.js @@ -0,0 +1,114 @@ +export const meta = { + name: 'audit-egress', + description: 'Audit every agent preset (agents/*.config.sh) for egress drift, then adversarially verify each finding against the real preset + upstream source before reporting it. Findings are leads until a skeptic confirms them.', + whenToUse: 'Periodically or before a release, to catch agent-preset egress drift before a user hits a blocked host. A deeper one-shot companion to the weekly agents-smoke.yml gate.', + phases: [ + { title: 'Map' }, + { title: 'Audit' }, + { title: 'Verify' }, + ], +} + +const MAP_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['presets'], + properties: { + presets: { + type: 'array', + items: { type: 'string', description: 'preset name without path or .config.sh, e.g. claude' }, + }, + }, +} + +const AUDIT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['preset', 'declaredHosts', 'issues'], + properties: { + preset: { type: 'string' }, + declaredHosts: { type: 'array', items: { type: 'string' } }, + issues: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['kind', 'detail'], + properties: { + kind: { + type: 'string', + enum: ['unlisted-host', 'moved-host', 'renamed-pkg', 'removed-binary', 'threat-model-gap', 'other'], + }, + host: { type: 'string' }, + detail: { type: 'string' }, + }, + }, + }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['status', 'reason'], + properties: { + // confirmed = real AND specifics check out; refuted = wrong / not on this preset's path; + // uncertain = couldn't verify (the honest default - never upgrade a guess to confirmed). + status: { type: 'string', enum: ['confirmed', 'refuted', 'uncertain'] }, + reason: { type: 'string' }, + // If the direction is right but a specific is wrong (the lesson from hand-verifying these: + // audits state plausible-but-wrong flags/hosts/dates), the corrected value goes here. + correction: { type: 'string' }, + }, +} + +// Discover presets at run time (not hardcoded) so the audit fans out over whatever is actually in +// agents/ - the point of the workflow is to catch drift, including added/removed presets. +phase('Map') +const map = await agent(`List every agent preset in this repo. Run: ls agents/*.config.sh +Return just the preset names - the filename without the agents/ prefix and without the .config.sh suffix.`, + { label: 'map-presets', phase: 'Map', schema: MAP_SCHEMA }) + +const presets = map.presets || [] +log(`Auditing ${presets.length} agent presets, then verifying each finding`) + +// Pipeline (no barrier): a preset's findings stream into Verify as soon as its audit lands, so the +// fast presets' findings get refuted while the slow presets are still being audited. +const auditPrompt = (p) => `Audit the agent preset agents/${p}.config.sh for egress drift. +1. Read agents/${p}.config.sh. List every host/domain it relies on - SLUICE_ALLOW_DOMAINS plus any hosts implied by its setup/prefetch/run commands and the agent CLI's own API endpoints. +2. Read THREAT_MODEL.md and the base-allowlist notes in sluice.config.example.sh (npm/yarn registries + GitHub git/release hosts are allowed by default, so the preset need not re-list those). +3. Flag drift: a host not covered by base+preset allowlist, a host that looks renamed or moved, a package whose registry changed, a binary the preset installs that no longer exists upstream, or an egress need the threat model doesn't account for. +Be concrete - name the host and what's wrong. If the preset is clean, return an empty issues array.` + +const verifyPrompt = (preset, issue) => `You are a skeptical verifier. An automated audit flagged this issue on agents/${preset}.config.sh. Your job is to REFUTE it. + +Issue kind: ${issue.kind} +Host: ${issue.host || '(none)'} +Detail: ${issue.detail} + +Check it against BOTH (a) the actual agents/${preset}.config.sh in this repo, and (b) the real upstream source/docs for that tool - curl the source, npm view the package, fetch the vendor's network doc. Audits routinely state plausible specifics that are WRONG (a flag that doesn't exist, a host that moved, a removal date that's invented), so verify every concrete claim: exact host name, exact package name, exact CLI flag, any date. +- status=confirmed ONLY if the issue is real on this preset's actual run path AND its specifics check out. +- status=refuted if it's wrong, or the host/flag isn't reachable on the path this preset uses. +- status=uncertain if you cannot verify it from source/docs - default here rather than guessing. +If the direction is right but a specific (flag, host, package, date) is wrong, put the corrected value in 'correction'.` + +const results = await pipeline( + presets, + p => agent(auditPrompt(p), { label: `audit:${p}`, phase: 'Audit', schema: AUDIT_SCHEMA }), + (audit) => parallel((audit.issues || []).map(issue => () => + agent(verifyPrompt(audit.preset, issue), { + label: `verify:${audit.preset}:${issue.host || issue.kind}`, + phase: 'Verify', + schema: VERDICT_SCHEMA, + }).then(v => ({ ...issue, preset: audit.preset, verdict: v })))), +) + +const all = results.flat().filter(Boolean) +const confirmed = all.filter(i => i.verdict && i.verdict.status === 'confirmed') +const uncertain = all.filter(i => i.verdict && i.verdict.status === 'uncertain') +const refuted = all.filter(i => i.verdict && i.verdict.status === 'refuted') +log(`${confirmed.length} confirmed, ${uncertain.length} uncertain, ${refuted.length} refuted (of ${all.length} raw findings)`) + +// confirmed = act on these; uncertain = worth a human look; refuted dropped from the headline but +// returned so a run is auditable (you can see what the skeptic killed and why). +return { confirmed, uncertain, refuted } diff --git a/.claude/workflows/review-launcher.js b/.claude/workflows/review-launcher.js new file mode 100644 index 0000000..b57baa5 --- /dev/null +++ b/.claude/workflows/review-launcher.js @@ -0,0 +1,90 @@ +export const meta = { + name: 'review-launcher', + description: 'Multi-dimension review of the sluice launcher (src/*.sh -> bin/sluice) across security, bash-3.2 correctness, and docker/podman portability; each finding is adversarially verified before it survives.', + whenToUse: 'Before merging changes to src/*.sh or bin/sluice, or to audit the launcher for egress-bypass / shell-portability bugs.', + phases: [ + { title: 'Review' }, + { title: 'Verify' }, + ], +} + +// bin/sluice is GENERATED from the ordered src/*.sh slices via `make build`. Reviewers read the +// slices (the real source), not the assembled launcher. + +const FINDINGS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['findings'], + properties: { + findings: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['title', 'file', 'severity', 'detail'], + properties: { + title: { type: 'string' }, + file: { type: 'string', description: 'path:line, e.g. src/10-egress-helpers.sh:42' }, + severity: { type: 'string', enum: ['high', 'medium', 'low'] }, + detail: { type: 'string' }, + suggestion: { type: 'string' }, + }, + }, + }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['isReal', 'reason'], + properties: { + isReal: { type: 'boolean' }, + reason: { type: 'string' }, + }, +} + +const DIMENSIONS = [ + { + key: 'security', + prompt: `Review the sluice launcher for security holes that would let a sandboxed process escape the egress allowlist or firewall. +Read the source slices in src/*.sh (bin/sluice is GENERATED from them - review the slices). Focus on src/10-egress-helpers.sh, src/40-runtime.sh, src/70-build-run.sh, src/50-init.sh. +Look for: allowlist bypass, host/IP laundering, DNS-sink exfil gaps, SLUICE_ALLOW_IPS port-scoping mistakes, SSL-bump misconfig, unquoted expansions that let a hostile config inject flags, anything that weakens the default-DROP egress posture. +Return concrete findings, each with a src/:line location.`, + }, + { + key: 'bash32', + prompt: `Review the sluice launcher for bash 3.2 correctness. bin/sluice must run under macOS's stock bash 3.2 AND modern Linux bash. Read src/*.sh. +Look for: bash-4+ only constructs (associative arrays, \${var,,}/\${var^^}, |&, mapfile/readarray), a case ')' inside $(...) (mis-parses under 3.2 and 'bash -n' will NOT catch it - the highest-value bug class here), [[ =~ ]] quirks, and 'local x=$(...)' masking the command's exit status. +Return concrete findings, each with a src/:line location.`, + }, + { + key: 'portability', + prompt: `Review the sluice launcher for docker vs rootless-podman divergence. Read src/*.sh, especially src/40-runtime.sh and the firewall/init path. +Only docker + rootless podman are supported; rootful podman (netavark) is out of scope. Look for: engine-specific flags assumed present on both backends, network/DNS setup that only works on one, sysctl/iptables assumptions, anything that silently no-ops on podman. +Return concrete findings, each with a src/:line location.`, + }, +] + +phase('Review') +log(`Reviewing the launcher across ${DIMENSIONS.length} dimensions`) + +// Pipeline: each dimension's findings verify as soon as that dimension finishes reviewing - +// no barrier, so the bash32 verifies don't wait on the slower security review. +const results = await pipeline( + DIMENSIONS, + d => agent(d.prompt, { label: `review:${d.key}`, phase: 'Review', schema: FINDINGS_SCHEMA }), + (review, d) => parallel((review.findings || []).map(f => () => + agent(`You are a skeptical reviewer. Try to REFUTE this finding about the sluice launcher. Read the cited file and surrounding code. Default to isReal=false unless you can clearly confirm the bug is real and reachable. + +Finding: ${f.title} +Location: ${f.file} +Severity: ${f.severity} +Detail: ${f.detail}`, + { label: `verify:${d.key}:${f.file}`, phase: 'Verify', schema: VERDICT_SCHEMA }) + .then(v => ({ ...f, dimension: d.key, verdict: v })))), +) + +const confirmed = results.flat().filter(Boolean).filter(f => f.verdict && f.verdict.isReal) +log(`${confirmed.length} confirmed findings`) +return { confirmed } diff --git a/.claude/workflows/triage-tests.js b/.claude/workflows/triage-tests.js new file mode 100644 index 0000000..be513aa --- /dev/null +++ b/.claude/workflows/triage-tests.js @@ -0,0 +1,101 @@ +export const meta = { + name: 'triage-tests', + description: 'Run the sluice bats gate suites, cluster the failures by likely shared cause, and spawn one agent per cluster to root-cause and propose a fix (does not apply it).', + whenToUse: 'When the bats suites are red and you want failures grouped and root-caused in parallel rather than read one at a time.', + phases: [ + { title: 'Run' }, + { title: 'Cluster' }, + { title: 'Root-cause' }, + ], +} + +// args.suite (optional): a specific .bats path or glob to run instead of the full gate suite. +// e.g. Workflow({ name: 'triage-tests', args: { suite: 'test/verify-security-dns.bats' } }) + +const FAILURES_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['passed', 'failures'], + properties: { + passed: { type: 'boolean' }, + failures: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['test', 'file', 'message'], + properties: { + test: { type: 'string' }, + file: { type: 'string', description: 'the .bats file' }, + message: { type: 'string', description: 'the assertion / error excerpt' }, + }, + }, + }, + }, +} + +const CLUSTER_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['clusters'], + properties: { + clusters: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['label', 'tests', 'hypothesis'], + properties: { + label: { type: 'string' }, + tests: { type: 'array', items: { type: 'string' } }, + hypothesis: { type: 'string' }, + }, + }, + }, + }, +} + +const ROOTCAUSE_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['cluster', 'rootCause', 'fix', 'confidence'], + properties: { + cluster: { type: 'string' }, + rootCause: { type: 'string' }, + fix: { type: 'string', description: 'concrete proposed change with file:line' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + }, +} + +phase('Run') +const suite = args && args.suite ? args.suite : null +const cmd = suite ? `test/bats/bin/bats --print-output-on-failure ${suite}` : 'make test' +log(`Running ${suite ? suite : 'the gate suites (make test)'}`) + +const run = await agent(`Run the sluice bats suites and report failures. +Command: ${cmd} +Note: the acceptance and verify-security suites need a working Docker engine; if Docker is unavailable, report that as the failure rather than guessing. Parse the bats output and return each failing test with its .bats file and the assertion/error excerpt. If everything passes, set passed=true and failures=[].`, + { label: 'run-suites', phase: 'Run', schema: FAILURES_SCHEMA }) + +if (run.passed || !run.failures.length) { + log('All suites passed - nothing to triage') + return { passed: true, failures: [] } +} + +phase('Cluster') +log(`${run.failures.length} failing tests - clustering by likely shared cause`) +const clustered = await agent(`Here are the failing bats tests: +${JSON.stringify(run.failures, null, 2)} +Group them into clusters that likely share a single root cause (same suite, same error signature, same subsystem). Give each cluster a short label and a one-line hypothesis. A failure can belong to only one cluster.`, + { label: 'cluster', phase: 'Cluster', schema: CLUSTER_SCHEMA }) + +phase('Root-cause') +const diagnoses = await parallel((clustered.clusters || []).map(c => () => + agent(`Root-cause this cluster of failing sluice bats tests and propose a concrete fix. Do NOT apply it. +Cluster: ${c.label} +Tests: ${c.tests.join(', ')} +Hypothesis: ${c.hypothesis} +Read the named .bats files under test/ and the relevant src/*.sh slices (bin/sluice is generated from src/ via 'make build', so fixes go in the slices). Find the actual cause and propose a specific change with a file:line.`, + { label: `rootcause:${c.label}`, phase: 'Root-cause', schema: ROOTCAUSE_SCHEMA }))) + +return { failures: run.failures, diagnoses: diagnoses.filter(Boolean) } diff --git a/agents/amp.config.sh b/agents/amp.config.sh index 8fb490f..fa046e7 100644 --- a/agents/amp.config.sh +++ b/agents/amp.config.sh @@ -6,8 +6,9 @@ # still rewrite this dir and use any forwarded creds, so commit your work first. # Auth: export AMP_API_KEY (from ampcode.com/settings) on the HOST (forwarded, never baked). SLUICE_EXTRA_NPM="@ampcode/cli" -# Amp proxies models through ampcode.com; static.ampcode.com is the update/version check. -SLUICE_ALLOW_DOMAINS="ampcode.com static.ampcode.com" +# ampcode.com = service + installer; static.ampcode.com = binary/version updates; auth.ampcode.com = +# auth handshake; production.ampworkers.com = the Amp client's WebSocket (all per ampcode.com/security). +SLUICE_ALLOW_DOMAINS="ampcode.com static.ampcode.com auth.ampcode.com production.ampworkers.com" SLUICE_DESC="Amp (Sourcegraph)" SLUICE_ENV="AMP_API_KEY" # Persist amp's settings/auth across runs (host-side, per project). diff --git a/agents/crush.config.sh b/agents/crush.config.sh index e37e5e4..fe2bff9 100644 --- a/agents/crush.config.sh +++ b/agents/crush.config.sh @@ -7,7 +7,8 @@ # Auth: export your provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / ...) on the HOST. SLUICE_EXTRA_NPM="@charmland/crush" # api.anthropic.com / api.openai.com cover the common providers; catwalk.charm.land is Crush's -# model catalog. For another provider, add its host (or run `sluice learn`). +# model catalog. For another provider, add its host (or run `sluice learn`). Crush's default PostHog +# telemetry to data.charm.land is left blocked. SLUICE_ALLOW_DOMAINS="api.anthropic.com api.openai.com catwalk.charm.land" SLUICE_DESC="Crush (Charm)" SLUICE_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY" diff --git a/agents/cursor.config.sh b/agents/cursor.config.sh index dc863ec..ef2d61c 100644 --- a/agents/cursor.config.sh +++ b/agents/cursor.config.sh @@ -9,7 +9,7 @@ # the binary under ~/.local, so we symlink it onto PATH. Runs at build, pre-firewall (free egress). SLUICE_SETUP_CMDS='curl https://cursor.com/install -fsS | bash && mkdir -p "$HOME/.npm-global/bin" && ln -sf "$HOME/.local/bin/cursor-agent" "$HOME/.npm-global/bin/cursor-agent"' # api2.cursor.sh = most API; .api5.cursor.sh = agent requests + regional agent.* subdomains; authenticate/ -# authenticator/.authentication.cursor.sh = login; downloads.cursor.com = self-update. cursor.sh carries the model/agent stream (laundering surface, THREAT_MODEL #2/#4). +# authenticator/.authentication.cursor.sh = login; downloads.cursor.com = self-update. the .api5 agent hosts carry the model/agent stream (laundering surface, THREAT_MODEL #2/#4). SLUICE_ALLOW_DOMAINS="cursor.com api2.cursor.sh .api5.cursor.sh authenticate.cursor.sh authenticator.cursor.sh .authentication.cursor.sh downloads.cursor.com" SLUICE_DESC="Cursor CLI (cursor-agent)" SLUICE_ENV="CURSOR_API_KEY" diff --git a/agents/qwen.config.sh b/agents/qwen.config.sh index dd406f2..219f578 100644 --- a/agents/qwen.config.sh +++ b/agents/qwen.config.sh @@ -8,7 +8,8 @@ # never baked). The run cmd points it at DashScope's OpenAI-compatible endpoint. SLUICE_EXTRA_NPM="@qwen-code/qwen-code" # DashScope's OpenAI-compatible API. The intl endpoint is the default; for mainland China swap -# OPENAI_BASE_URL to dashscope.aliyuncs.com (drop -intl). Both hosts are allowlisted. +# OPENAI_BASE_URL to dashscope.aliyuncs.com (drop -intl). Both hosts are allowlisted. Qwen Code is a +# Gemini-CLI fork; its inherited Clearcut telemetry to play.googleapis.com is left blocked. SLUICE_ALLOW_DOMAINS="dashscope-intl.aliyuncs.com dashscope.aliyuncs.com" SLUICE_DESC="Qwen Code (Alibaba)" SLUICE_ENV="OPENAI_API_KEY DASHSCOPE_API_KEY" From 29ea799087c6f8d24a413b011251f130fde84f8d Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Fri, 5 Jun 2026 19:49:26 +0530 Subject: [PATCH 04/10] fix(egress): flag raw.githubusercontent.com as a laundering host raw.githubusercontent.com serves arbitrary bytes from any user's repo/branch, so an allowlisted box can launder data out through it (THREAT_MODEL item 2). laundering_host() already flagged gist.githubusercontent.com but missed its sibling. Surgical add, not *.githubusercontent.com - a wildcard would noisily flag the base-allowlisted objects.githubusercontent.com (release assets) on every run. Regression test added (Docker-free). --- bin/sluice | 2 +- src/10-egress-helpers.sh | 2 +- test/verify-security-laundering.bats | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bin/sluice b/bin/sluice index cf76743..779f7a8 100755 --- a/bin/sluice +++ b/bin/sluice @@ -342,7 +342,7 @@ allowed_domains() { printf '%s %s' "${SLUICE_ALLOW_DOMAINS:-}" "$(base_domains)" laundering_host() { case "$1" in *s3.amazonaws.com|*.s3.*.amazonaws.com|storage.googleapis.com|*.blob.core.windows.net|*.r2.cloudflarestorage.com|*.digitaloceanspaces.com) return 0 ;; - gist.github.com|gist.githubusercontent.com|*pastebin.com|paste.*|transfer.sh|0x0.st|file.io|*.tmpfiles.org) return 0 ;; + gist.github.com|gist.githubusercontent.com|raw.githubusercontent.com|*pastebin.com|paste.*|transfer.sh|0x0.st|file.io|*.tmpfiles.org) return 0 ;; webhook.site|*.ngrok.io|*.ngrok-free.app|hooks.slack.com|*.requestbin.com|*.pipedream.net) return 0 ;; api.openai.com|api.anthropic.com|generativelanguage.googleapis.com|api.cohere.ai) return 0 ;; esac diff --git a/src/10-egress-helpers.sh b/src/10-egress-helpers.sh index 8718bf9..f00376e 100644 --- a/src/10-egress-helpers.sh +++ b/src/10-egress-helpers.sh @@ -163,7 +163,7 @@ allowed_domains() { printf '%s %s' "${SLUICE_ALLOW_DOMAINS:-}" "$(base_domains)" laundering_host() { case "$1" in *s3.amazonaws.com|*.s3.*.amazonaws.com|storage.googleapis.com|*.blob.core.windows.net|*.r2.cloudflarestorage.com|*.digitaloceanspaces.com) return 0 ;; - gist.github.com|gist.githubusercontent.com|*pastebin.com|paste.*|transfer.sh|0x0.st|file.io|*.tmpfiles.org) return 0 ;; + gist.github.com|gist.githubusercontent.com|raw.githubusercontent.com|*pastebin.com|paste.*|transfer.sh|0x0.st|file.io|*.tmpfiles.org) return 0 ;; webhook.site|*.ngrok.io|*.ngrok-free.app|hooks.slack.com|*.requestbin.com|*.pipedream.net) return 0 ;; api.openai.com|api.anthropic.com|generativelanguage.googleapis.com|api.cohere.ai) return 0 ;; esac diff --git a/test/verify-security-laundering.bats b/test/verify-security-laundering.bats index c96a00c..43b9891 100644 --- a/test/verify-security-laundering.bats +++ b/test/verify-security-laundering.bats @@ -33,6 +33,14 @@ cfg() { printf 'SLUICE_NAME="sectest-laundering"\nSLUICE_ALLOW_DOMAINS="gist.git refute_output --partial "refusing" } +@test "laundering: raw.githubusercontent.com is flagged (write-capable via any repo)" { + printf 'SLUICE_NAME="sectest-laundering"\nSLUICE_ALLOW_DOMAINS="raw.githubusercontent.com"\nSLUICE_RUN_CMD="true"\n' > "$WORK/p/sluice.config.sh" + run bash -c "cd '$WORK/p' && SLUICE_ENGINE=false '$SLUICE' run true" + assert_output --partial "laundered" + assert_output --partial "raw.githubusercontent.com" + refute_output --partial "refusing" +} + @test "laundering: a non-laundering allowlist is silent" { printf 'SLUICE_NAME="sectest-laundering"\nSLUICE_ALLOW_DOMAINS="api.example.com"\nSLUICE_RUN_CMD="true"\n' > "$WORK/p/sluice.config.sh" run bash -c "cd '$WORK/p' && SLUICE_ENGINE=false '$SLUICE' run true" From 3f4c6c6f50fa41edfc368a56f120a3d0518c3d48 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Wed, 10 Jun 2026 20:12:17 +0530 Subject: [PATCH 05/10] feat(mask): SLUICE_MASK shadows in-repo secrets from the box New knob: space-separated project-root-relative globs, expanded at launch. A matching file gets an empty read-only bind, a matching dir an empty tmpfs - the box sees the path exists but cannot read it. Stays in force during 'learn --audit' (the open-egress run). Agent presets default to SLUICE_MASK=".env*" (set it empty to disable). sluice doctor lists the active mask + match count, warns with names when secret-looking files (.env*, *.pem, *key*.json, ...) are readable and unmasked, and doctor --json gains a mask object. THREAT_MODEL documents the honest limits: launch-time evaluation (later files unmasked), path existence still visible, unmatched/nested paths unprotected. Also fixes two doctor --json bugs the new tests surfaced: RUNNER was unbound under a live daemon (resolve_runner was never called), and _json_arr dropped a final line without a trailing newline, losing registry.yarnpkg.com from the base array. Tests: verify-doctor-checks.bats runs engine-free (gate); verify-security-mask.bats needs a box (written, gated on an engine). --- README.md | 10 ++- THREAT_MODEL.md | 22 ++++-- agents/aider.config.sh | 2 + agents/amp.config.sh | 2 + agents/claude.config.sh | 2 + agents/codex.config.sh | 2 + agents/crush.config.sh | 2 + agents/cursor.config.sh | 2 + agents/gemini.config.sh | 2 + agents/opencode.config.sh | 2 + agents/qwen.config.sh | 2 + bin/sluice | 138 +++++++++++++++++++++++++++++++-- sluice.config.example.sh | 10 +++ src/00-prelude.sh | 5 +- src/30-doctor-ls.sh | 77 +++++++++++++++++- src/70-build-run.sh | 49 ++++++++++++ src/80-learn.sh | 7 ++ test/verify-doctor-checks.bats | 81 +++++++++++++++++++ test/verify-security-mask.bats | 83 ++++++++++++++++++++ 19 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 test/verify-doctor-checks.bats create mode 100644 test/verify-security-mask.bats diff --git a/README.md b/README.md index 61d6fce..cc78395 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ Presets ship for **claude**, **codex**, **gemini**, **aider**, **cursor**, **ope **qwen**, and **crush** (see [`agents/`](agents/)); each is a normal `sluice.config.sh` declaring the tool, its API hosts, and which auth env var to forward - so adding an agent is just adding a file. Run `sluice agent` with no name to list them (each with its auth var and whether it's set on your host). -If the agent hits a blocked host, `sluice learn` surfaces it. +If the agent hits a blocked host, `sluice learn` surfaces it. Every preset also masks `.env*` files +by default (`SLUICE_MASK`), so the agent can't read in-repo env secrets - set `SLUICE_MASK=""` in +your project's config to disable. Each agent runs in the project's box (named for the directory), so a repo holds **one agent at a time** - `sluice agent codex` in a repo already set up for claude reuses the claude config (sluice @@ -228,6 +230,7 @@ Everything is driven by `sluice.config.sh`. Copy [`sluice.config.example.sh`](sl | `SLUICE_ALLOW_IPS` | runtime egress IPs/CIDRs for non-HTTP services | | `SLUICE_PORTS` | TCP ports to publish (firewall opens a matching inbound rule) | | `SLUICE_ENV` | host env var names to forward into the session | +| `SLUICE_MASK` | in-repo secret globs shadowed from the box (agent presets default `.env*`) | The rest - build-time setup, a central egress policy (`SLUICE_POLICY_URL`), scoped TLS interception (`SLUICE_BUMP_DOMAINS`/`SLUICE_BUMP_URLS`), persisted state, credential staging @@ -253,7 +256,10 @@ The guardrail that makes running untrusted code defensible: host must fail; a base host must work). - **Non-root** (uid 1000) with only `NET_ADMIN`/`NET_RAW`; no Docker-in-Docker. - **Filesystem isolation:** only the project dir is mounted (plus its git common dir - when it's a worktree). The sluice can't see the rest of your machine. + when it's a worktree). The sluice can't see the rest of your machine. Secrets *inside* the + repo can be shadowed too: `SLUICE_MASK` globs (e.g. `.env* *.pem`) are masked out of the + mount at launch - the box sees the path exists but can't read it - and `sluice doctor` + warns when secret-looking files are present and unmasked. - The allowlist is **host-granular** (not per-URL); keep it tight, and avoid allowing shared cloud hosts that could double as an exfil path - sluice flags such a host at run, and `SLUICE_EGRESS_MAX_BYTES` caps a run's egress volume. For a host you control, diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 2a7bbb2..ee02294 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -16,7 +16,8 @@ deliberately malicious code next to other tenants. That job needs a microVM - **Host credentials/secrets** the sluice can reach: env vars forwarded via `SLUICE_ENV`, token files mounted via `SLUICE_MOUNTS`, anything `SLUICE_PRELAUNCH` stages, and the - project's own secrets (`.env`, keys in the working tree). + project's own secrets (`.env`, keys in the working tree) - the in-repo ones can be + shadowed with `SLUICE_MASK` (limits below). - **The rest of your machine:** other directories, other projects, your home dir. - **Your network position:** internal/LAN services the host could otherwise reach. @@ -84,6 +85,16 @@ The guarantees below hold only while these do: re-allows a DoH resolver; `SLUICE_DNS_OPEN=1` restores forward-all resolution (both weaken this). - **Reading/altering the rest of your machine** -> only the project dir (and its git common dir, for worktrees) is mounted. Nothing else is visible. +- **Reading in-repo secrets (opt-in mask)** -> the project dir is mounted read-write, + *including* its own `.env`/key files - "can't read your secrets" historically meant files + *outside* the repo. `SLUICE_MASK` closes the in-repo gap: matching files get an empty + read-only bind, matching dirs an empty tmpfs, so the box cannot read them (the agent + presets mask `.env*` by default; it also stays in force during `learn --audit`). Honest + limits: patterns are expanded **when the container starts** - a secret written later in + the run is NOT masked (and survives until the next launch); the masked path's *existence* + (its name) is still visible; and an unmatched path - a different name, or nested deeper + than the pattern reaches - is not protected. `sluice doctor` warns when secret-looking + files (`.env*`, `*.pem`, `*key*.json`, ...) are present in the mount and unmasked. - **Host privilege escalation** -> sessions run non-root (uid 1000) with **no effective capabilities**; no Docker socket, no Docker-in-Docker, no in-box `sudo` (setuid). The container drops ALL capabilities and adds back only what the root entrypoint needs at boot (chown the mount, @@ -195,7 +206,8 @@ audit possible, and `SLUICE_EGRESS_MAX_BYTES` can gate CI on volume. --- -_Last reviewed 2026-06-05 against sluice 0.8.0 (released) + the post-release hardening on main: seccomp -(default-superset / browser / audit) and the egress work (allowlist-scoped DNS, port-scoped -`SLUICE_ALLOW_IPS`, laundering-host gate, durable egress receipt + `SLUICE_EGRESS_MAX_BYTES`). Revisit -when the egress path, mount model, or runtime options change._ +_Last reviewed 2026-06-10 against sluice 0.8.0 (released) + the post-release hardening on main: seccomp +(default-superset / browser / audit), the egress work (allowlist-scoped DNS, port-scoped +`SLUICE_ALLOW_IPS`, laundering-host gate, durable egress receipt + `SLUICE_EGRESS_MAX_BYTES`), and +in-repo secret masking (`SLUICE_MASK`). Revisit when the egress path, mount model, or runtime options +change._ diff --git a/agents/aider.config.sh b/agents/aider.config.sh index 4931b99..7b1b5c1 100644 --- a/agents/aider.config.sh +++ b/agents/aider.config.sh @@ -10,6 +10,8 @@ SLUICE_EXTRA_PKGS="python-3.12 py3.12-pip" SLUICE_SETUP_CMDS='pip install --user --no-input aider-chat' SLUICE_ALLOW_DOMAINS="api.openai.com api.anthropic.com" SLUICE_DESC="Aider (pair programmer)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="OPENAI_API_KEY ANTHROPIC_API_KEY" # Aider's chat history lives in-repo (.aider.*, already persisted via the mount); this keeps # its model-metadata cache across runs too. (Not .local - that's where pip --user installs it.) diff --git a/agents/amp.config.sh b/agents/amp.config.sh index fa046e7..9b8de7c 100644 --- a/agents/amp.config.sh +++ b/agents/amp.config.sh @@ -10,6 +10,8 @@ SLUICE_EXTRA_NPM="@ampcode/cli" # auth handshake; production.ampworkers.com = the Amp client's WebSocket (all per ampcode.com/security). SLUICE_ALLOW_DOMAINS="ampcode.com static.ampcode.com auth.ampcode.com production.ampworkers.com" SLUICE_DESC="Amp (Sourcegraph)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="AMP_API_KEY" # Persist amp's settings/auth across runs (host-side, per project). SLUICE_STATE_DIRS=".config/amp" diff --git a/agents/claude.config.sh b/agents/claude.config.sh index 09cbaaf..4156678 100644 --- a/agents/claude.config.sh +++ b/agents/claude.config.sh @@ -11,6 +11,8 @@ SLUICE_EXTRA_NPM="@anthropic-ai/claude-code" # so both are allowed, matching upstream init-firewall). sentry.io error reporting is left blocked. SLUICE_ALLOW_DOMAINS="api.anthropic.com platform.claude.com claude.ai statsig.com statsig.anthropic.com" SLUICE_DESC="Claude Code (Anthropic)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN" # Persist Claude Code's sessions/history/auth-cache across runs (host-side, per project). SLUICE_STATE_DIRS=".claude" diff --git a/agents/codex.config.sh b/agents/codex.config.sh index 69dd71e..039eeb9 100644 --- a/agents/codex.config.sh +++ b/agents/codex.config.sh @@ -10,6 +10,8 @@ SLUICE_EXTRA_NPM="@openai/codex" # default telemetry to ab.chatgpt.com stays blocked too (set [otel] metrics_exporter="none" to silence). SLUICE_ALLOW_DOMAINS="api.openai.com" SLUICE_DESC="Codex CLI (OpenAI)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="OPENAI_API_KEY" # Persist Codex's sessions/history/auth across runs (host-side, per project). SLUICE_STATE_DIRS=".codex" diff --git a/agents/crush.config.sh b/agents/crush.config.sh index fe2bff9..a9ac00c 100644 --- a/agents/crush.config.sh +++ b/agents/crush.config.sh @@ -11,6 +11,8 @@ SLUICE_EXTRA_NPM="@charmland/crush" # telemetry to data.charm.land is left blocked. SLUICE_ALLOW_DOMAINS="api.anthropic.com api.openai.com catwalk.charm.land" SLUICE_DESC="Crush (Charm)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY" # Persist Crush's sessions/db across runs (its data dir). NOT .config/crush - that's just config. SLUICE_STATE_DIRS=".local/share/crush" diff --git a/agents/cursor.config.sh b/agents/cursor.config.sh index ef2d61c..e5db933 100644 --- a/agents/cursor.config.sh +++ b/agents/cursor.config.sh @@ -12,6 +12,8 @@ SLUICE_SETUP_CMDS='curl https://cursor.com/install -fsS | bash && mkdir -p "$HOM # authenticator/.authentication.cursor.sh = login; downloads.cursor.com = self-update. the .api5 agent hosts carry the model/agent stream (laundering surface, THREAT_MODEL #2/#4). SLUICE_ALLOW_DOMAINS="cursor.com api2.cursor.sh .api5.cursor.sh authenticate.cursor.sh authenticator.cursor.sh .authentication.cursor.sh downloads.cursor.com" SLUICE_DESC="Cursor CLI (cursor-agent)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="CURSOR_API_KEY" # Persist cursor-agent's config/auth across runs (.cursor holds cli-config.json). NOT .local - # that's where the installed binary lives, and a mount would shadow it. diff --git a/agents/gemini.config.sh b/agents/gemini.config.sh index 832f33e..c7960eb 100644 --- a/agents/gemini.config.sh +++ b/agents/gemini.config.sh @@ -11,6 +11,8 @@ SLUICE_EXTRA_NPM="@google/gemini-cli" # privacy.usageStatisticsEnabled=false in .gemini/settings.json to drop the per-run warning. SLUICE_ALLOW_DOMAINS="generativelanguage.googleapis.com" SLUICE_DESC="Gemini CLI (Google)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="GEMINI_API_KEY GOOGLE_API_KEY" # Persist Gemini's sessions/history/auth across runs (host-side, per project). SLUICE_STATE_DIRS=".gemini" diff --git a/agents/opencode.config.sh b/agents/opencode.config.sh index 76a82ac..ba3211b 100644 --- a/agents/opencode.config.sh +++ b/agents/opencode.config.sh @@ -14,6 +14,8 @@ SLUICE_SETUP_CMDS='mkdir -p /home/sluice/.config/opencode && printf "{\"permissi # model catalog. If you use another provider, add its host (or run `sluice learn`). SLUICE_ALLOW_DOMAINS="api.anthropic.com api.openai.com models.dev" SLUICE_DESC="opencode (multi-provider)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="ANTHROPIC_API_KEY OPENAI_API_KEY" # Persist opencode's auth + sessions (its data dir) across runs. NOT .config/opencode - that # holds the baked allow-all config above, and a mount would shadow it. diff --git a/agents/qwen.config.sh b/agents/qwen.config.sh index 219f578..53390f9 100644 --- a/agents/qwen.config.sh +++ b/agents/qwen.config.sh @@ -12,6 +12,8 @@ SLUICE_EXTRA_NPM="@qwen-code/qwen-code" # Gemini-CLI fork; its inherited Clearcut telemetry to play.googleapis.com is left blocked. SLUICE_ALLOW_DOMAINS="dashscope-intl.aliyuncs.com dashscope.aliyuncs.com" SLUICE_DESC="Qwen Code (Alibaba)" +# In-repo secrets: .env* files are shadowed (unreadable in the box); SLUICE_MASK="" to disable. +SLUICE_MASK=".env*" SLUICE_ENV="OPENAI_API_KEY DASHSCOPE_API_KEY" # Persist Qwen Code's sessions/settings across runs (host-side, per project). SLUICE_STATE_DIRS=".qwen" diff --git a/bin/sluice b/bin/sluice index 779f7a8..2f2ebc8 100755 --- a/bin/sluice +++ b/bin/sluice @@ -21,8 +21,9 @@ die() { echo "${E_RED:-}[sluice]${E_RST:-} $*" >&2; exit 1; } # minimal JSON emit (host jq is not assumed; fields here are short/flat) # Escape a string for a JSON value: backslash + doublequote, and flatten stray control chars. _json_esc() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/}"; printf '%s' "$s"; } -# Emit a JSON array of strings from newline-separated stdin (blank lines skipped). -_json_arr() { local first=1 line; printf '['; while IFS= read -r line; do [ -n "$line" ] || continue; [ "$first" = 1 ] && first=0 || printf ','; printf '"%s"' "$(_json_esc "$line")"; done; printf ']'; } +# Emit a JSON array of strings from newline-separated stdin (blank lines skipped; a final line +# without a trailing newline still counts - base_domains emits one). +_json_arr() { local first=1 line; printf '['; while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue; [ "$first" = 1 ] && first=0 || printf ','; printf '"%s"' "$(_json_esc "$line")"; done; printf ']'; } # color: gated on a stdout TTY + NO_COLOR, so piped/redirected output stays plain ASCII # (the --json paths print no color regardless; the TTY gate also blanks these when piped.) @@ -708,6 +709,60 @@ cmd_scan() { } # `sluice doctor`: one-shot health + why-egress-is-blocked report +# --- in-repo protection scans (SLUICE_MASK; read by doctor here and mounted by the run path) ------ + +# Expand SLUICE_MASK (space-separated, project-root-relative globs) to the paths matching RIGHT NOW, +# one per line. Plain shell glob semantics: a slash-less pattern matches root-level entries only +# ("packages/*/.env" reaches deeper). Symlink matches are skipped - a mount over a link would shadow +# its TARGET, not the link. Invalid patterns (absolute, ..) are skipped here so doctor still reports +# the rest; the run path dies on them (mask_validate). +mask_matches() { + [ -n "${SLUICE_MASK:-}" ] || return 0 + ( cd "$PROJECT_DIR" 2>/dev/null || exit 0 + set -f # keep the PATTERNS literal while splitting; glob only in the inner loop + for pat in ${SLUICE_MASK}; do + case "$pat" in /*|*..*) continue ;; esac + set +f + for m in $pat; do + [ -L "$m" ] && continue + if [ -f "$m" ] || [ -d "$m" ]; then printf '%s\n' "$m"; fi + done + set -f + done ) | sort -u +} + +# True when some SLUICE_MASK pattern covers $1 (a project-relative path), mirroring the launch +# semantics above: a slash-less pattern only ever matches a root-level entry. +mask_covers() { + local rel="$1" pat rc=1 + set -f # the patterns must stay literal (case still glob-MATCHES under set -f) + for pat in ${SLUICE_MASK:-}; do + # shellcheck disable=SC2254 # $pat IS a glob - unquoted on purpose + case "$pat" in + /*|*..*) continue ;; + */*) case "$rel" in $pat) rc=0; break ;; esac ;; + *) case "$rel" in */*) ;; $pat) rc=0; break ;; esac ;; + esac + done + set +f + return "$rc" +} + +# Secret-looking files in the mount that no SLUICE_MASK pattern covers - doctor warns on these. +# Bounded so doctor stays fast: depth 3, vendor dirs pruned, first 50 hits. .example/.sample/ +# .template variants are scaffolding, not secrets. +unmasked_secrets() { + find "$PROJECT_DIR" -maxdepth 3 \ + \( -name .git -o -name node_modules -o -name vendor -o -name .venv -o -name venv \) -prune \ + -o -type f \( -name '.env*' -o -name '*.pem' -o -name '*key*.json' -o -name 'id_rsa*' \ + -o -name 'id_ed25519*' -o -name '*.p12' -o -name '*.pfx' \) \ + ! -name '*.example' ! -name '*.sample' ! -name '*.template' -print 2>/dev/null \ + | head -50 | while IFS= read -r f; do + f="${f#"$PROJECT_DIR"/}" + mask_covers "$f" || printf '%s\n' "$f" + done +} + _doc() { printf ' %-10s %s\n' "$1" "$2"; } cmd_doctor() { [ "${1:-}" = --json ] && { cmd_doctor_json; return $?; } @@ -739,6 +794,15 @@ cmd_doctor() { [ -n "${SLUICE_DESC:-}" ] && _doc desc "$SLUICE_DESC" if [ -n "${SLUICE_MOUNTS:-}" ]; then _doc mount "$PROJECT_DIR ${C_DIM}(+ extra mounts)${C_RST}"; else _doc mount "$PROJECT_DIR"; fi + # SLUICE_MASK posture: what's shadowed now, and secret-looking files the box CAN still read. + if [ -n "${SLUICE_MASK:-}" ]; then + local _nm; _nm="$(mask_matches 2>/dev/null | grep -c . || true)" + _doc mask "$SLUICE_MASK ${C_DIM}($_nm path(s) masked at launch)${C_RST}" + fi + local _unm + _unm="$(unmasked_secrets 2>/dev/null | head -6 | tr '\n' ' ' | sed 's/ *$//' || true)" + [ -n "$_unm" ] && _doc "" "${C_YEL}note${C_RST}: secret-looking file(s) readable in the box - $_unm - shadow them: SLUICE_MASK=\".env*\" (sluice.config.example.sh)" + if [ -n "$eng" ]; then if "$eng" image inspect "$tag" >/dev/null 2>&1; then if [ "$("$eng" image inspect -f '{{ index .Config.Labels "sluice.confighash" }}' "$tag" 2>/dev/null || true)" = "$(config_hash)" ]; then @@ -820,7 +884,7 @@ cmd_doctor_json() { elif command -v docker >/dev/null 2>&1; then eng=docker elif command -v podman >/dev/null 2>&1; then eng=podman; fi if [ -n "$eng" ] && command -v "$eng" >/dev/null 2>&1; then - ENGINE="$eng"; engine_ver="$("$eng" --version 2>/dev/null | head -1)" + ENGINE="$eng"; resolve_runner; engine_ver="$("$eng" --version 2>/dev/null | head -1)" "$eng" info >/dev/null 2>&1 && daemon=true || eng="" else eng=""; fi @@ -858,13 +922,21 @@ cmd_doctor_json() { done auth_json="$auth_json]" + local mask_pats mask_hits mask_unm + # tr (not word-splitting) keeps the glob patterns literal - no pathname expansion against $PWD + mask_pats="$(printf '%s' "${SLUICE_MASK:-}" | tr ' \t' '\n\n' | _json_arr)" + mask_hits="$(mask_matches 2>/dev/null | _json_arr || true)" + mask_unm="$(unmasked_secrets 2>/dev/null | _json_arr || true)" + # shellcheck disable=SC2086 - printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"egress":{"running":%s,"blocked":%s}}\n' \ + printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"egress":{"running":%s,"blocked":%s}}\n' \ "$(_json_esc "$engine_ver")" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \ "$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \ "$(printf '%s\n' ${SLUICE_ALLOW_DOMAINS:-} | _json_arr)" "$(base_domains | tr ' ' '\n' | _json_arr)" \ "$(printf '%s\n' ${SLUICE_PORTS:-} | _json_arr)" "$(printf '%s\n' ${SLUICE_ALLOW_IPS:-} | _json_arr)" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \ - "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" "$running_b" "$(printf '%s\n' $blocked | _json_arr)" + "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" \ + "$mask_pats" "$mask_hits" "$mask_unm" \ + "$running_b" "$(printf '%s\n' $blocked | _json_arr)" } # `sluice ls`: a derived, read-only table of every built sluice box on this machine @@ -1763,6 +1835,47 @@ image_stale() { [ -n "$have" ] && [ "$have" != "$want" ] } +# SLUICE_MASK launch wiring: validate the patterns (die early; the doctor-side expander skips bad +# ones), then build the mount flags that shadow each CURRENT match - an empty read-only bind for a +# file, a tmpfs for a dir. The box still sees the path exists; it cannot read the contents. The +# empty source file lives in the sluice state root (stable across reboots, unlike a mktemp). +mask_validate() { + local pat + set -f # validate the PATTERNS, not whatever they happen to glob to in $PWD + for pat in ${SLUICE_MASK:-}; do + case "$pat" in /*|*..*) die "SLUICE_MASK pattern must be a relative glob inside the project (no leading /, no ..): $pat" ;; esac + done + set +f +} +# Fills MASK_ARGS (engine mount flags) + MASKED_PATHS (display list) from the current matches. +mask_build_args() { + MASK_ARGS=(); MASKED_PATHS="" + [ -n "${SLUICE_MASK:-}" ] || return 0 + mask_validate + local matches mp empty + matches="$(mask_matches 2>/dev/null || true)" + [ -n "$matches" ] || return 0 + empty="${XDG_STATE_HOME:-$HOME/.local/state}/sluice/.mask-empty" + mkdir -p "${empty%/*}" 2>/dev/null || true + [ -f "$empty" ] || : > "$empty" 2>/dev/null || true + chmod 0444 "$empty" 2>/dev/null || true + while IFS= read -r mp; do + [ -n "$mp" ] || continue + # Overlay workspace: mask the read-only original too, or the entrypoint's seed copy reads it. + if [ -d "$PROJECT_DIR/$mp" ]; then + MASK_ARGS+=(--tmpfs "$PROJECT_DIR/$mp") + [ "${SLUICE_WORKSPACE:-}" = overlay ] && MASK_ARGS+=(--tmpfs "/mnt/sluice-orig/$mp") + else + MASK_ARGS+=(-v "$empty":"$PROJECT_DIR/$mp":ro) + [ "${SLUICE_WORKSPACE:-}" = overlay ] && MASK_ARGS+=(-v "$empty":"/mnt/sluice-orig/$mp":ro) + fi + MASKED_PATHS="$MASKED_PATHS $mp" + done </dev/null 2>&1 || true @@ -1900,6 +2013,14 @@ EOF run_args+=(-e "SLUICE_STATE_PATHS=$state_paths") fi + # SLUICE_MASK: shadow in-repo secrets (empty ro bind / tmpfs over each match). Evaluated NOW - + # a file created later in the run is not masked (THREAT_MODEL.md). + mask_build_args + if [ "${#MASK_ARGS[@]}" -gt 0 ]; then + run_args+=("${MASK_ARGS[@]}") + echo "[sluice] masking (unreadable in the box): $MASKED_PATHS" + fi + # Publish declared ports on host loopback only; init-firewall.sh opens the inbound ACCEPT. for p in ${SLUICE_PORTS:-}; do run_args+=(-p "127.0.0.1:$p:$p") @@ -2223,6 +2344,13 @@ EOF fi fi + # Egress is OPEN for this run - keep SLUICE_MASK shadowing in force so in-repo secrets stay unreadable. + mask_build_args + if [ "${#MASK_ARGS[@]}" -gt 0 ]; then + run_args+=("${MASK_ARGS[@]}") + echo "[sluice] masking (unreadable in the box): $MASKED_PATHS" + fi + echo "[sluice] starting ephemeral audit container $audit_container ..." runtime_sync_image runtime_run --name "$audit_container" "${run_args[@]}" "$tag" >/dev/null diff --git a/sluice.config.example.sh b/sluice.config.example.sh index 28ecca2..580f24a 100644 --- a/sluice.config.example.sh +++ b/sluice.config.example.sh @@ -125,6 +125,16 @@ SLUICE_READONLY_ROOT="" # with `sluice diff`, write back with `sluice apply`. Set to "overlay" to enable. SLUICE_WORKSPACE="" +# In-repo secret masking: space-separated glob patterns, relative to the project root, shadowed from +# the box at launch - a matching file is covered by an empty read-only bind, a matching dir by an +# empty tmpfs. The box still sees the path EXISTS; it cannot read the contents. Honest limits: +# patterns are expanded when the container starts (a file created later is NOT masked), a slash-less +# pattern matches root-level entries only (mask "packages/*/.env*" explicitly for nested ones), and +# symlink matches are skipped. The agents/ presets default to ".env*"; set SLUICE_MASK="" there to +# disable. `sluice doctor` warns when secret-looking files are present and unmasked. +# e.g. ".env* *.pem service-account*.json" +SLUICE_MASK="" + # --- serving -------------------------------------------------------------------- # TCP ports to publish to the host (bound to 127.0.0.1 -> reach via localhost only). diff --git a/src/00-prelude.sh b/src/00-prelude.sh index be9bf30..922ad71 100644 --- a/src/00-prelude.sh +++ b/src/00-prelude.sh @@ -21,8 +21,9 @@ die() { echo "${E_RED:-}[sluice]${E_RST:-} $*" >&2; exit 1; } # minimal JSON emit (host jq is not assumed; fields here are short/flat) # Escape a string for a JSON value: backslash + doublequote, and flatten stray control chars. _json_esc() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/}"; printf '%s' "$s"; } -# Emit a JSON array of strings from newline-separated stdin (blank lines skipped). -_json_arr() { local first=1 line; printf '['; while IFS= read -r line; do [ -n "$line" ] || continue; [ "$first" = 1 ] && first=0 || printf ','; printf '"%s"' "$(_json_esc "$line")"; done; printf ']'; } +# Emit a JSON array of strings from newline-separated stdin (blank lines skipped; a final line +# without a trailing newline still counts - base_domains emits one). +_json_arr() { local first=1 line; printf '['; while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue; [ "$first" = 1 ] && first=0 || printf ','; printf '"%s"' "$(_json_esc "$line")"; done; printf ']'; } # color: gated on a stdout TTY + NO_COLOR, so piped/redirected output stays plain ASCII # (the --json paths print no color regardless; the TTY gate also blanks these when piped.) diff --git a/src/30-doctor-ls.sh b/src/30-doctor-ls.sh index 905d366..c36b72c 100644 --- a/src/30-doctor-ls.sh +++ b/src/30-doctor-ls.sh @@ -1,3 +1,57 @@ +# --- in-repo protection scans (SLUICE_MASK; read by doctor here and mounted by the run path) ------ + +# Expand SLUICE_MASK (space-separated, project-root-relative globs) to the paths matching RIGHT NOW, +# one per line. Plain shell glob semantics: a slash-less pattern matches root-level entries only +# ("packages/*/.env" reaches deeper). Symlink matches are skipped - a mount over a link would shadow +# its TARGET, not the link. Invalid patterns (absolute, ..) are skipped here so doctor still reports +# the rest; the run path dies on them (mask_validate). +mask_matches() { + [ -n "${SLUICE_MASK:-}" ] || return 0 + ( cd "$PROJECT_DIR" 2>/dev/null || exit 0 + set -f # keep the PATTERNS literal while splitting; glob only in the inner loop + for pat in ${SLUICE_MASK}; do + case "$pat" in /*|*..*) continue ;; esac + set +f + for m in $pat; do + [ -L "$m" ] && continue + if [ -f "$m" ] || [ -d "$m" ]; then printf '%s\n' "$m"; fi + done + set -f + done ) | sort -u +} + +# True when some SLUICE_MASK pattern covers $1 (a project-relative path), mirroring the launch +# semantics above: a slash-less pattern only ever matches a root-level entry. +mask_covers() { + local rel="$1" pat rc=1 + set -f # the patterns must stay literal (case still glob-MATCHES under set -f) + for pat in ${SLUICE_MASK:-}; do + # shellcheck disable=SC2254 # $pat IS a glob - unquoted on purpose + case "$pat" in + /*|*..*) continue ;; + */*) case "$rel" in $pat) rc=0; break ;; esac ;; + *) case "$rel" in */*) ;; $pat) rc=0; break ;; esac ;; + esac + done + set +f + return "$rc" +} + +# Secret-looking files in the mount that no SLUICE_MASK pattern covers - doctor warns on these. +# Bounded so doctor stays fast: depth 3, vendor dirs pruned, first 50 hits. .example/.sample/ +# .template variants are scaffolding, not secrets. +unmasked_secrets() { + find "$PROJECT_DIR" -maxdepth 3 \ + \( -name .git -o -name node_modules -o -name vendor -o -name .venv -o -name venv \) -prune \ + -o -type f \( -name '.env*' -o -name '*.pem' -o -name '*key*.json' -o -name 'id_rsa*' \ + -o -name 'id_ed25519*' -o -name '*.p12' -o -name '*.pfx' \) \ + ! -name '*.example' ! -name '*.sample' ! -name '*.template' -print 2>/dev/null \ + | head -50 | while IFS= read -r f; do + f="${f#"$PROJECT_DIR"/}" + mask_covers "$f" || printf '%s\n' "$f" + done +} + _doc() { printf ' %-10s %s\n' "$1" "$2"; } cmd_doctor() { [ "${1:-}" = --json ] && { cmd_doctor_json; return $?; } @@ -29,6 +83,15 @@ cmd_doctor() { [ -n "${SLUICE_DESC:-}" ] && _doc desc "$SLUICE_DESC" if [ -n "${SLUICE_MOUNTS:-}" ]; then _doc mount "$PROJECT_DIR ${C_DIM}(+ extra mounts)${C_RST}"; else _doc mount "$PROJECT_DIR"; fi + # SLUICE_MASK posture: what's shadowed now, and secret-looking files the box CAN still read. + if [ -n "${SLUICE_MASK:-}" ]; then + local _nm; _nm="$(mask_matches 2>/dev/null | grep -c . || true)" + _doc mask "$SLUICE_MASK ${C_DIM}($_nm path(s) masked at launch)${C_RST}" + fi + local _unm + _unm="$(unmasked_secrets 2>/dev/null | head -6 | tr '\n' ' ' | sed 's/ *$//' || true)" + [ -n "$_unm" ] && _doc "" "${C_YEL}note${C_RST}: secret-looking file(s) readable in the box - $_unm - shadow them: SLUICE_MASK=\".env*\" (sluice.config.example.sh)" + if [ -n "$eng" ]; then if "$eng" image inspect "$tag" >/dev/null 2>&1; then if [ "$("$eng" image inspect -f '{{ index .Config.Labels "sluice.confighash" }}' "$tag" 2>/dev/null || true)" = "$(config_hash)" ]; then @@ -110,7 +173,7 @@ cmd_doctor_json() { elif command -v docker >/dev/null 2>&1; then eng=docker elif command -v podman >/dev/null 2>&1; then eng=podman; fi if [ -n "$eng" ] && command -v "$eng" >/dev/null 2>&1; then - ENGINE="$eng"; engine_ver="$("$eng" --version 2>/dev/null | head -1)" + ENGINE="$eng"; resolve_runner; engine_ver="$("$eng" --version 2>/dev/null | head -1)" "$eng" info >/dev/null 2>&1 && daemon=true || eng="" else eng=""; fi @@ -148,13 +211,21 @@ cmd_doctor_json() { done auth_json="$auth_json]" + local mask_pats mask_hits mask_unm + # tr (not word-splitting) keeps the glob patterns literal - no pathname expansion against $PWD + mask_pats="$(printf '%s' "${SLUICE_MASK:-}" | tr ' \t' '\n\n' | _json_arr)" + mask_hits="$(mask_matches 2>/dev/null | _json_arr || true)" + mask_unm="$(unmasked_secrets 2>/dev/null | _json_arr || true)" + # shellcheck disable=SC2086 - printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"egress":{"running":%s,"blocked":%s}}\n' \ + printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"egress":{"running":%s,"blocked":%s}}\n' \ "$(_json_esc "$engine_ver")" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \ "$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \ "$(printf '%s\n' ${SLUICE_ALLOW_DOMAINS:-} | _json_arr)" "$(base_domains | tr ' ' '\n' | _json_arr)" \ "$(printf '%s\n' ${SLUICE_PORTS:-} | _json_arr)" "$(printf '%s\n' ${SLUICE_ALLOW_IPS:-} | _json_arr)" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \ - "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" "$running_b" "$(printf '%s\n' $blocked | _json_arr)" + "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" \ + "$mask_pats" "$mask_hits" "$mask_unm" \ + "$running_b" "$(printf '%s\n' $blocked | _json_arr)" } # `sluice ls`: a derived, read-only table of every built sluice box on this machine diff --git a/src/70-build-run.sh b/src/70-build-run.sh index dacadd0..e03911a 100644 --- a/src/70-build-run.sh +++ b/src/70-build-run.sh @@ -80,6 +80,47 @@ image_stale() { [ -n "$have" ] && [ "$have" != "$want" ] } +# SLUICE_MASK launch wiring: validate the patterns (die early; the doctor-side expander skips bad +# ones), then build the mount flags that shadow each CURRENT match - an empty read-only bind for a +# file, a tmpfs for a dir. The box still sees the path exists; it cannot read the contents. The +# empty source file lives in the sluice state root (stable across reboots, unlike a mktemp). +mask_validate() { + local pat + set -f # validate the PATTERNS, not whatever they happen to glob to in $PWD + for pat in ${SLUICE_MASK:-}; do + case "$pat" in /*|*..*) die "SLUICE_MASK pattern must be a relative glob inside the project (no leading /, no ..): $pat" ;; esac + done + set +f +} +# Fills MASK_ARGS (engine mount flags) + MASKED_PATHS (display list) from the current matches. +mask_build_args() { + MASK_ARGS=(); MASKED_PATHS="" + [ -n "${SLUICE_MASK:-}" ] || return 0 + mask_validate + local matches mp empty + matches="$(mask_matches 2>/dev/null || true)" + [ -n "$matches" ] || return 0 + empty="${XDG_STATE_HOME:-$HOME/.local/state}/sluice/.mask-empty" + mkdir -p "${empty%/*}" 2>/dev/null || true + [ -f "$empty" ] || : > "$empty" 2>/dev/null || true + chmod 0444 "$empty" 2>/dev/null || true + while IFS= read -r mp; do + [ -n "$mp" ] || continue + # Overlay workspace: mask the read-only original too, or the entrypoint's seed copy reads it. + if [ -d "$PROJECT_DIR/$mp" ]; then + MASK_ARGS+=(--tmpfs "$PROJECT_DIR/$mp") + [ "${SLUICE_WORKSPACE:-}" = overlay ] && MASK_ARGS+=(--tmpfs "/mnt/sluice-orig/$mp") + else + MASK_ARGS+=(-v "$empty":"$PROJECT_DIR/$mp":ro) + [ "${SLUICE_WORKSPACE:-}" = overlay ] && MASK_ARGS+=(-v "$empty":"/mnt/sluice-orig/$mp":ro) + fi + MASKED_PATHS="$MASKED_PATHS $mp" + done </dev/null 2>&1 || true @@ -217,6 +258,14 @@ EOF run_args+=(-e "SLUICE_STATE_PATHS=$state_paths") fi + # SLUICE_MASK: shadow in-repo secrets (empty ro bind / tmpfs over each match). Evaluated NOW - + # a file created later in the run is not masked (THREAT_MODEL.md). + mask_build_args + if [ "${#MASK_ARGS[@]}" -gt 0 ]; then + run_args+=("${MASK_ARGS[@]}") + echo "[sluice] masking (unreadable in the box): $MASKED_PATHS" + fi + # Publish declared ports on host loopback only; init-firewall.sh opens the inbound ACCEPT. for p in ${SLUICE_PORTS:-}; do run_args+=(-p "127.0.0.1:$p:$p") diff --git a/src/80-learn.sh b/src/80-learn.sh index 56a1d27..b732f0e 100644 --- a/src/80-learn.sh +++ b/src/80-learn.sh @@ -244,6 +244,13 @@ EOF fi fi + # Egress is OPEN for this run - keep SLUICE_MASK shadowing in force so in-repo secrets stay unreadable. + mask_build_args + if [ "${#MASK_ARGS[@]}" -gt 0 ]; then + run_args+=("${MASK_ARGS[@]}") + echo "[sluice] masking (unreadable in the box): $MASKED_PATHS" + fi + echo "[sluice] starting ephemeral audit container $audit_container ..." runtime_sync_image runtime_run --name "$audit_container" "${run_args[@]}" "$tag" >/dev/null diff --git a/test/verify-doctor-checks.bats b/test/verify-doctor-checks.bats new file mode 100644 index 0000000..15ca712 --- /dev/null +++ b/test/verify-doctor-checks.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats +# `sluice doctor` project scans that need no box (work with or without an engine daemon): +# SLUICE_MASK posture + the unmasked-secret warning. Each @test gets its own temp project. +load test_helper/common + +setup() { WORK="$(mktemp -d)"; } +teardown() { rm -rf "$WORK"; } + +@test "doctor: warns on a secret-looking file that is not masked" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + echo "SECRET=1" > "$WORK/.env" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + assert_output --partial "secret-looking" + assert_output --partial ".env" + assert_output --partial "SLUICE_MASK" +} + +@test "doctor: lists active masks and stops warning once the file is covered" { + printf 'SLUICE_MASK=".env*"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + echo "SECRET=1" > "$WORK/.env" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + assert_output --partial "mask" + assert_output --partial "1 path(s) masked" + refute_output --partial "secret-looking" +} + +@test "doctor: a nested secret is NOT covered by a root-level pattern (still warns)" { + printf 'SLUICE_MASK=".env*"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + mkdir -p "$WORK/packages/api" + echo "SECRET=1" > "$WORK/packages/api/.env" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + assert_output --partial "secret-looking" + assert_output --partial "packages/api/.env" +} + +@test "doctor: .env.example is scaffolding, not a secret (no warning)" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + echo "SECRET=" > "$WORK/.env.example" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + refute_output --partial "secret-looking" +} + +@test "doctor: secret scan prunes vendor dirs (node_modules .env ignored)" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + mkdir -p "$WORK/node_modules/pkg" + echo "SECRET=1" > "$WORK/node_modules/pkg/.env" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + refute_output --partial "secret-looking" +} + +@test "doctor --json: mask patterns / masked / unmasked_secrets" { + printf 'SLUICE_MASK=".env*"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + echo "SECRET=1" > "$WORK/.env" + echo "key-material" > "$WORK/server.pem" + run bash -c "cd '$WORK' && '$SLUICE' doctor --json 2>/dev/null" + assert_success + echo "$output" | python3 -c " +import sys, json +d = json.load(sys.stdin) +m = d['mask'] +assert m['patterns'] == ['.env*'], m +assert m['masked'] == ['.env'], m +assert m['unmasked_secrets'] == ['server.pem'], m +" +} + +@test "doctor --json: no mask configured -> empty arrays, still valid JSON" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + run bash -c "cd '$WORK' && '$SLUICE' doctor --json 2>/dev/null" + assert_success + echo "$output" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert d['mask'] == {'patterns': [], 'masked': [], 'unmasked_secrets': []}, d['mask'] +" +} diff --git a/test/verify-security-mask.bats b/test/verify-security-mask.bats new file mode 100644 index 0000000..a11d132 --- /dev/null +++ b/test/verify-security-mask.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# SLUICE_MASK: a masked file reads empty + write-rejected, a masked dir reads empty, the path still +# EXISTS in the box (name visible, content shadowed), unmasked siblings and the host stay untouched. +load test_helper/common + +setup_file() { + export WORK; WORK="$(mktemp -d)"; mkdir -p "$WORK/mask/secrets" + echo "SECRET=hunter2" > "$WORK/mask/.env" + echo "key-material" > "$WORK/mask/secrets/private.pem" + echo "readable" > "$WORK/mask/normal.txt" + cat > "$WORK/mask/sluice.config.sh" </dev/null 2>&1 || true +} + +teardown_file() { + chown_back_tree sluice-sectest-mask "$WORK" + ( cd "$WORK/mask" 2>/dev/null && "$SLUICE" stop ) >/dev/null 2>&1 || true + "$ENG" rm -f -v sluice-sectest-mask sluice-sectest-mask-off >/dev/null 2>&1 || true + "$ENG" rmi -f sluice-sectest-mask sluice-sectest-mask-off >/dev/null 2>&1 || true + rm -rf "$WORK" +} + +@test "mask: a masked file reads empty in the box" { + run bash -c "cd '$WORK/mask' && '$SLUICE' run sh -c 'wc -c < .env' 2>/dev/null" + assert_output --partial "0" +} + +@test "mask: a masked file is read-only (write rejected)" { + run bash -c "cd '$WORK/mask' && '$SLUICE' run sh -c 'echo leak > .env'" + assert_failure +} + +@test "mask: a masked dir reads empty (its files are gone)" { + run bash -c "cd '$WORK/mask' && '$SLUICE' run sh -c 'ls -A secrets | wc -l' 2>/dev/null" + assert_output --partial "0" +} + +@test "mask: the masked path still exists in the box (name visible, content shadowed)" { + run bash -c "cd '$WORK/mask' && '$SLUICE' run sh -c 'test -e .env && test -d secrets && echo present' 2>/dev/null" + assert_output "present" +} + +@test "mask: an unmasked sibling is untouched" { + run bash -c "cd '$WORK/mask' && '$SLUICE' run cat normal.txt 2>/dev/null" + assert_output "readable" +} + +@test "mask: the host files keep their contents" { + run grep -q "hunter2" "$WORK/mask/.env" + assert_success + run grep -q "key-material" "$WORK/mask/secrets/private.pem" + assert_success +} + +@test "mask: launch output lists the active masks" { + ( cd "$WORK/mask" && "$SLUICE" stop ) >/dev/null 2>&1 || true + run bash -c "cd '$WORK/mask' && '$SLUICE' run true 2>&1" + assert_output --partial "masking" + assert_output --partial ".env" + assert_output --partial "secrets" +} + +@test "mask: an absolute pattern is rejected (die)" { + mkdir -p "$WORK/mask-bad" + printf 'SLUICE_NAME="sectest-mask"\nSLUICE_MASK="/etc"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/mask-bad/sluice.config.sh" + ( cd "$WORK/mask-bad" && "$SLUICE" stop ) >/dev/null 2>&1 || true + run bash -c "cd '$WORK/mask-bad' && '$SLUICE' run true" + assert_failure + assert_output --partial "SLUICE_MASK" +} + +@test "mask: explicitly empty SLUICE_MASK disables masking" { + mkdir -p "$WORK/mask-off" + echo "SECRET=visible" > "$WORK/mask-off/.env" + printf 'SLUICE_NAME="sectest-mask-off"\nSLUICE_MASK=""\nSLUICE_RUN_CMD="bash"\n' > "$WORK/mask-off/sluice.config.sh" + run bash -c "cd '$WORK/mask-off' && '$SLUICE' run cat .env 2>/dev/null" + assert_output "SECRET=visible" + ( cd "$WORK/mask-off" 2>/dev/null && "$SLUICE" rm ) >/dev/null 2>&1 || true +} From 8082a06c4732f812014c9d058042baa26d5fa9c1 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Wed, 10 Jun 2026 20:16:06 +0530 Subject: [PATCH 06/10] feat(doctor): warn on symlinks that resolve outside the mounted scope A symlink whose target leaves the project dir (plus the git common dir, for worktrees) works on the host but dangles inside the box - the real case was .claude/CLAUDE.md -> ~/.claude/shared/... breaking silently so the agent ran without its project instructions. doctor now lists each such link ('will be broken inside the box'); doctor --json gains broken_symlinks. Scope comparison is physical-path based (macOS /var is itself a symlink; git reports the common dir physically). The scan is bounded: depth 6, .git/node_modules/vendor/build dirs pruned, first 200 links considered. --- README.md | 4 +- bin/sluice | 64 ++++++++++++++++++++++++++++++-- src/30-doctor-ls.sh | 64 ++++++++++++++++++++++++++++++-- test/verify-doctor-checks.bats | 68 ++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cc78395..deeaab5 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ and any it tried but the firewall blocked - so you see at a glance everything yo agent talked to, and a failed fetch points you straight at `sluice learn`. `sluice egress` shows the same per-host on demand, and `sluice doctor` reports the engine, the mounted project dir, image freshness, published ports, the effective allowlist, auth env, and the hosts your last run was -blocked from - even before anything is built. +blocked from - even before anything is built. It also warns about what would silently misbehave +in-box: unmasked secret-looking files (see `SLUICE_MASK`) and symlinks that point outside the +mounted scope (they work on the host but dangle inside the box).

sluice doctor prints a one-screen health panel: the container engine, the mounted project dir (the box's only host path), image freshness (config current), the supply-chain lock (in sync), the published port, the auth env var (set), and the hosts the last run was blocked from (api.openai.com) with a 'sluice learn' hint - green for ok, red for blocked

diff --git a/bin/sluice b/bin/sluice index 2f2ebc8..03ed364 100755 --- a/bin/sluice +++ b/bin/sluice @@ -763,6 +763,51 @@ unmasked_secrets() { done } +# Squash //, /./ and resolve .. TEXTUALLY (no symlink deref - the target may not exist). Enough to +# decide inside-vs-outside the mount; set -f keeps odd path segments from globbing. +_canon_path() { + local p="$1" out="" seg oldIFS="$IFS" + set -f; IFS=/ + for seg in $p; do + case "$seg" in ''|.) ;; ..) out="${out%/*}" ;; *) out="$out/$seg" ;; esac + done + IFS="$oldIFS"; set +f + printf '%s' "${out:-/}" +} + +# Symlinks in the project dir whose target resolves OUTSIDE the mounted scope (the project dir, plus +# the git common dir when this is a worktree) - they work on the host but are broken inside the box +# (real case: .claude/CLAUDE.md -> ~/.claude/shared/... dangled silently and the agent ran without +# its instructions). Emits "reltarget". Bounded so doctor stays fast: depth 6, .git/vendor dirs +# pruned, first 200 links considered. +symlinks_outside_scope() { + local proj common="" l tgt abs d TAB; TAB="$(printf '\t')" + # Compare PHYSICAL paths throughout - on macOS /var is itself a symlink, and git reports the + # common dir in physical form while $PROJECT_DIR/link targets may be logical. + proj="$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P || printf '%s' "$PROJECT_DIR")" + if command -v git >/dev/null 2>&1 && git -C "$PROJECT_DIR" rev-parse --git-common-dir >/dev/null 2>&1; then + common="$(git -C "$PROJECT_DIR" rev-parse --git-common-dir)" + case "$common" in /*) ;; *) common="$PROJECT_DIR/$common";; esac + common="$(cd "$common" 2>/dev/null && pwd -P || true)" + case "$common/" in "$proj"/*) common="" ;; esac # inside the project: already in scope + fi + find "$PROJECT_DIR" -maxdepth 6 \ + \( -name .git -o -name node_modules -o -name vendor -o -name .venv -o -name venv \ + -o -name target -o -name dist -o -name build -o -name .next -o -name __pycache__ \) -prune \ + -o -type l -print 2>/dev/null \ + | head -200 | while IFS= read -r l; do + tgt="$(readlink "$l" 2>/dev/null)" || continue + [ -n "$tgt" ] || continue + case "$tgt" in /*) abs="$tgt" ;; *) abs="$(dirname "$l")/$tgt" ;; esac + # physical resolution when the target's parent exists; textual squash for dangling targets + if d="$(cd "$(dirname "$abs")" 2>/dev/null && pwd -P)"; then abs="$d/$(basename "$abs")" + else abs="$(_canon_path "$abs")"; fi + case "$abs/" in "$proj"/*|"$PROJECT_DIR"/*) continue ;; esac + if [ -n "$common" ]; then case "$abs/" in "$common"/*) continue ;; esac; fi + printf '%s%s%s\n' "${l#"$PROJECT_DIR"/}" "$TAB" "$tgt" + done +} + _doc() { printf ' %-10s %s\n' "$1" "$2"; } cmd_doctor() { [ "${1:-}" = --json ] && { cmd_doctor_json; return $?; } @@ -803,6 +848,18 @@ cmd_doctor() { _unm="$(unmasked_secrets 2>/dev/null | head -6 | tr '\n' ' ' | sed 's/ *$//' || true)" [ -n "$_unm" ] && _doc "" "${C_YEL}note${C_RST}: secret-looking file(s) readable in the box - $_unm - shadow them: SLUICE_MASK=\".env*\" (sluice.config.example.sh)" + # Symlinks that leave the mounted scope work on the host but dangle inside the box - warn. + local _links _nl _lp _lt _TAB; _TAB="$(printf '\t')" + _links="$(symlinks_outside_scope 2>/dev/null || true)" + if [ -n "$_links" ]; then + _nl="$(printf '%s\n' "$_links" | grep -c . || true)" + _doc symlinks "${C_YEL}$_nl link(s) point outside the box mount${C_RST} - will be broken inside the box:" + printf '%s\n' "$_links" | head -10 | while IFS="$_TAB" read -r _lp _lt; do + printf ' %s -> %s\n' "$_lp" "$_lt" + done + [ "$_nl" -gt 10 ] && _doc "" "${C_DIM}(+ $((_nl - 10)) more)${C_RST}" + fi + if [ -n "$eng" ]; then if "$eng" image inspect "$tag" >/dev/null 2>&1; then if [ "$("$eng" image inspect -f '{{ index .Config.Labels "sluice.confighash" }}' "$tag" 2>/dev/null || true)" = "$(config_hash)" ]; then @@ -922,20 +979,21 @@ cmd_doctor_json() { done auth_json="$auth_json]" - local mask_pats mask_hits mask_unm + local mask_pats mask_hits mask_unm links_json # tr (not word-splitting) keeps the glob patterns literal - no pathname expansion against $PWD mask_pats="$(printf '%s' "${SLUICE_MASK:-}" | tr ' \t' '\n\n' | _json_arr)" mask_hits="$(mask_matches 2>/dev/null | _json_arr || true)" mask_unm="$(unmasked_secrets 2>/dev/null | _json_arr || true)" + links_json="$(symlinks_outside_scope 2>/dev/null | cut -f1 | _json_arr || true)" # shellcheck disable=SC2086 - printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"egress":{"running":%s,"blocked":%s}}\n' \ + printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \ "$(_json_esc "$engine_ver")" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \ "$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \ "$(printf '%s\n' ${SLUICE_ALLOW_DOMAINS:-} | _json_arr)" "$(base_domains | tr ' ' '\n' | _json_arr)" \ "$(printf '%s\n' ${SLUICE_PORTS:-} | _json_arr)" "$(printf '%s\n' ${SLUICE_ALLOW_IPS:-} | _json_arr)" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \ "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" \ - "$mask_pats" "$mask_hits" "$mask_unm" \ + "$mask_pats" "$mask_hits" "$mask_unm" "$links_json" \ "$running_b" "$(printf '%s\n' $blocked | _json_arr)" } diff --git a/src/30-doctor-ls.sh b/src/30-doctor-ls.sh index c36b72c..df096e5 100644 --- a/src/30-doctor-ls.sh +++ b/src/30-doctor-ls.sh @@ -52,6 +52,51 @@ unmasked_secrets() { done } +# Squash //, /./ and resolve .. TEXTUALLY (no symlink deref - the target may not exist). Enough to +# decide inside-vs-outside the mount; set -f keeps odd path segments from globbing. +_canon_path() { + local p="$1" out="" seg oldIFS="$IFS" + set -f; IFS=/ + for seg in $p; do + case "$seg" in ''|.) ;; ..) out="${out%/*}" ;; *) out="$out/$seg" ;; esac + done + IFS="$oldIFS"; set +f + printf '%s' "${out:-/}" +} + +# Symlinks in the project dir whose target resolves OUTSIDE the mounted scope (the project dir, plus +# the git common dir when this is a worktree) - they work on the host but are broken inside the box +# (real case: .claude/CLAUDE.md -> ~/.claude/shared/... dangled silently and the agent ran without +# its instructions). Emits "reltarget". Bounded so doctor stays fast: depth 6, .git/vendor dirs +# pruned, first 200 links considered. +symlinks_outside_scope() { + local proj common="" l tgt abs d TAB; TAB="$(printf '\t')" + # Compare PHYSICAL paths throughout - on macOS /var is itself a symlink, and git reports the + # common dir in physical form while $PROJECT_DIR/link targets may be logical. + proj="$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P || printf '%s' "$PROJECT_DIR")" + if command -v git >/dev/null 2>&1 && git -C "$PROJECT_DIR" rev-parse --git-common-dir >/dev/null 2>&1; then + common="$(git -C "$PROJECT_DIR" rev-parse --git-common-dir)" + case "$common" in /*) ;; *) common="$PROJECT_DIR/$common";; esac + common="$(cd "$common" 2>/dev/null && pwd -P || true)" + case "$common/" in "$proj"/*) common="" ;; esac # inside the project: already in scope + fi + find "$PROJECT_DIR" -maxdepth 6 \ + \( -name .git -o -name node_modules -o -name vendor -o -name .venv -o -name venv \ + -o -name target -o -name dist -o -name build -o -name .next -o -name __pycache__ \) -prune \ + -o -type l -print 2>/dev/null \ + | head -200 | while IFS= read -r l; do + tgt="$(readlink "$l" 2>/dev/null)" || continue + [ -n "$tgt" ] || continue + case "$tgt" in /*) abs="$tgt" ;; *) abs="$(dirname "$l")/$tgt" ;; esac + # physical resolution when the target's parent exists; textual squash for dangling targets + if d="$(cd "$(dirname "$abs")" 2>/dev/null && pwd -P)"; then abs="$d/$(basename "$abs")" + else abs="$(_canon_path "$abs")"; fi + case "$abs/" in "$proj"/*|"$PROJECT_DIR"/*) continue ;; esac + if [ -n "$common" ]; then case "$abs/" in "$common"/*) continue ;; esac; fi + printf '%s%s%s\n' "${l#"$PROJECT_DIR"/}" "$TAB" "$tgt" + done +} + _doc() { printf ' %-10s %s\n' "$1" "$2"; } cmd_doctor() { [ "${1:-}" = --json ] && { cmd_doctor_json; return $?; } @@ -92,6 +137,18 @@ cmd_doctor() { _unm="$(unmasked_secrets 2>/dev/null | head -6 | tr '\n' ' ' | sed 's/ *$//' || true)" [ -n "$_unm" ] && _doc "" "${C_YEL}note${C_RST}: secret-looking file(s) readable in the box - $_unm - shadow them: SLUICE_MASK=\".env*\" (sluice.config.example.sh)" + # Symlinks that leave the mounted scope work on the host but dangle inside the box - warn. + local _links _nl _lp _lt _TAB; _TAB="$(printf '\t')" + _links="$(symlinks_outside_scope 2>/dev/null || true)" + if [ -n "$_links" ]; then + _nl="$(printf '%s\n' "$_links" | grep -c . || true)" + _doc symlinks "${C_YEL}$_nl link(s) point outside the box mount${C_RST} - will be broken inside the box:" + printf '%s\n' "$_links" | head -10 | while IFS="$_TAB" read -r _lp _lt; do + printf ' %s -> %s\n' "$_lp" "$_lt" + done + [ "$_nl" -gt 10 ] && _doc "" "${C_DIM}(+ $((_nl - 10)) more)${C_RST}" + fi + if [ -n "$eng" ]; then if "$eng" image inspect "$tag" >/dev/null 2>&1; then if [ "$("$eng" image inspect -f '{{ index .Config.Labels "sluice.confighash" }}' "$tag" 2>/dev/null || true)" = "$(config_hash)" ]; then @@ -211,20 +268,21 @@ cmd_doctor_json() { done auth_json="$auth_json]" - local mask_pats mask_hits mask_unm + local mask_pats mask_hits mask_unm links_json # tr (not word-splitting) keeps the glob patterns literal - no pathname expansion against $PWD mask_pats="$(printf '%s' "${SLUICE_MASK:-}" | tr ' \t' '\n\n' | _json_arr)" mask_hits="$(mask_matches 2>/dev/null | _json_arr || true)" mask_unm="$(unmasked_secrets 2>/dev/null | _json_arr || true)" + links_json="$(symlinks_outside_scope 2>/dev/null | cut -f1 | _json_arr || true)" # shellcheck disable=SC2086 - printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"egress":{"running":%s,"blocked":%s}}\n' \ + printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \ "$(_json_esc "$engine_ver")" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \ "$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \ "$(printf '%s\n' ${SLUICE_ALLOW_DOMAINS:-} | _json_arr)" "$(base_domains | tr ' ' '\n' | _json_arr)" \ "$(printf '%s\n' ${SLUICE_PORTS:-} | _json_arr)" "$(printf '%s\n' ${SLUICE_ALLOW_IPS:-} | _json_arr)" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \ "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" \ - "$mask_pats" "$mask_hits" "$mask_unm" \ + "$mask_pats" "$mask_hits" "$mask_unm" "$links_json" \ "$running_b" "$(printf '%s\n' $blocked | _json_arr)" } diff --git a/test/verify-doctor-checks.bats b/test/verify-doctor-checks.bats index 15ca712..e1f2b1e 100644 --- a/test/verify-doctor-checks.bats +++ b/test/verify-doctor-checks.bats @@ -79,3 +79,71 @@ d = json.load(sys.stdin) assert d['mask'] == {'patterns': [], 'masked': [], 'unmasked_secrets': []}, d['mask'] " } + +# --- dangling-symlink check --------------------------------------------------------------------- + +@test "doctor: warns on a symlink that resolves outside the mounted scope" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + OUTSIDE="$(mktemp -d)" + echo shared > "$OUTSIDE/shared.md" + mkdir -p "$WORK/.claude" + ln -s "$OUTSIDE/shared.md" "$WORK/.claude/CLAUDE.md" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + assert_output --partial "broken inside the box" + assert_output --partial ".claude/CLAUDE.md" + rm -rf "$OUTSIDE" +} + +@test "doctor: warns on a dangling out-of-scope symlink (target gone)" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + ln -s /nonexistent/elsewhere "$WORK/dangler" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + assert_output --partial "broken inside the box" + assert_output --partial "dangler" +} + +@test "doctor: an in-repo symlink is fine (no warning)" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + echo real > "$WORK/real.txt" + ln -s real.txt "$WORK/alias.txt" + mkdir -p "$WORK/sub" + ln -s ../real.txt "$WORK/sub/up.txt" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + refute_output --partial "broken inside the box" +} + +@test "doctor: a worktree symlink into the git common dir is in scope (no warning)" { + command -v git >/dev/null 2>&1 || skip "git not present" + ( cd "$WORK" && git init -q main && cd main \ + && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init \ + && git worktree add -q "$WORK/wt" >/dev/null 2>&1 ) + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/wt/sluice.config.sh" + ln -s "$WORK/main/.git/HEAD" "$WORK/wt/head-link" + run bash -c "cd '$WORK/wt' && '$SLUICE' doctor" + assert_success + refute_output --partial "head-link" +} + +@test "doctor: symlink scan prunes vendor dirs (node_modules .bin links ignored)" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + mkdir -p "$WORK/node_modules/.bin" + ln -s /usr/bin/true "$WORK/node_modules/.bin/fake" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + refute_output --partial "broken inside the box" +} + +@test "doctor --json: broken_symlinks lists the project-relative link path" { + printf 'SLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + ln -s /nonexistent/elsewhere "$WORK/dangler" + run bash -c "cd '$WORK' && '$SLUICE' doctor --json 2>/dev/null" + assert_success + echo "$output" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert d['broken_symlinks'] == ['dangler'], d['broken_symlinks'] +" +} From c1b15a96e39e776886b37142952e7bec63af9837 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Wed, 10 Jun 2026 20:21:25 +0530 Subject: [PATCH 07/10] feat(overlay-dirs): SLUICE_OVERLAY_DIRS gives platform-specific dirs a box-local volume New knob: space-separated project-relative dirs (e.g. node_modules) each mounted over the project bind with a per-box named volume - the Linux box keeps its own contents while the host's stay untouched, ending the macOS-host vs Linux-box install flip-flop. The volume starts empty, persists across container recreation, and the entrypoint chowns a fresh one to the sluice user (named volumes over a bind path never auto-init). Entries are validated (relative only, no ..). Volumes are labeled sluice.box= at creation so cleanup needs no config sourcing: 'sluice rm' (incl. orphan -b rm) and 'sluice prune' remove them; 'stop' keeps them like persisted state. doctor lists overlay dirs (+ --json overlay_dirs), the launch line names them, and 'ls --json' surfaces them via a new sluice.overlays image label. Tests: doctor surfacing runs engine-free (gate); verify-security-overlaydirs.bats needs a box (written, gated on an engine). --- README.md | 1 + bin/sluice | 65 +++++++++++++++---- core/entrypoint.sh | 7 ++- sluice.config.example.sh | 8 +++ src/30-doctor-ls.sh | 23 ++++--- src/40-runtime.sh | 10 +++ src/60-main-flow.sh | 7 ++- src/70-build-run.sh | 19 ++++++ src/90-dispatch.sh | 6 +- test/verify-doctor-checks.bats | 21 +++++++ test/verify-security-overlaydirs.bats | 89 +++++++++++++++++++++++++++ 11 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 test/verify-security-overlaydirs.bats diff --git a/README.md b/README.md index deeaab5..855b364 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ Everything is driven by `sluice.config.sh`. Copy [`sluice.config.example.sh`](sl | `SLUICE_PORTS` | TCP ports to publish (firewall opens a matching inbound rule) | | `SLUICE_ENV` | host env var names to forward into the session | | `SLUICE_MASK` | in-repo secret globs shadowed from the box (agent presets default `.env*`) | +| `SLUICE_OVERLAY_DIRS` | project dirs given a box-local volume (e.g. `node_modules`) - host contents untouched | The rest - build-time setup, a central egress policy (`SLUICE_POLICY_URL`), scoped TLS interception (`SLUICE_BUMP_DOMAINS`/`SLUICE_BUMP_URLS`), persisted state, credential staging diff --git a/bin/sluice b/bin/sluice index 03ed364..f124ab4 100755 --- a/bin/sluice +++ b/bin/sluice @@ -896,6 +896,8 @@ cmd_doctor() { _doc state "$nsd dir(s) persisted at ${XDG_STATE_HOME:-$HOME/.local/state}/sluice/$slug" fi + [ -n "${SLUICE_OVERLAY_DIRS:-}" ] && _doc overlays "$SLUICE_OVERLAY_DIRS ${C_DIM}(box-local volume per dir, host contents untouched; 'sluice rm' deletes)${C_RST}" + [ -n "${SLUICE_PORTS:-}" ] && _doc ports "$SLUICE_PORTS ${C_DIM}(published on 127.0.0.1)${C_RST}" _doc allowlist "${SLUICE_ALLOW_DOMAINS:-(none beyond base)}" _doc "" "base: $(base_domains)" @@ -985,14 +987,16 @@ cmd_doctor_json() { mask_hits="$(mask_matches 2>/dev/null | _json_arr || true)" mask_unm="$(unmasked_secrets 2>/dev/null | _json_arr || true)" links_json="$(symlinks_outside_scope 2>/dev/null | cut -f1 | _json_arr || true)" + local overlays_json + overlays_json="$(printf '%s' "${SLUICE_OVERLAY_DIRS:-}" | tr ' \t' '\n\n' | _json_arr)" # shellcheck disable=SC2086 - printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \ + printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"overlay_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \ "$(_json_esc "$engine_ver")" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \ "$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \ "$(printf '%s\n' ${SLUICE_ALLOW_DOMAINS:-} | _json_arr)" "$(base_domains | tr ' ' '\n' | _json_arr)" \ "$(printf '%s\n' ${SLUICE_PORTS:-} | _json_arr)" "$(printf '%s\n' ${SLUICE_ALLOW_IPS:-} | _json_arr)" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \ - "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" \ + "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$overlays_json" "$auth_json" \ "$mask_pats" "$mask_hits" "$mask_unm" "$links_json" \ "$running_b" "$(printf '%s\n' $blocked | _json_arr)" } @@ -1031,8 +1035,8 @@ cmd_ls() { # Gather labels + container state into parallel arrays, applying filters as we go. (Kept as a plain # while-loop, NOT a $(...) capture: a case pattern's ) inside command substitution mis-parses on bash 3.2.) - local names=() stats=() projs=() stacks=() descs=() curs=() orphs=() allows=() ports_=() locks=() blocks=() - local name proj stack desc status cur orphan allowcount portslbl lock blocked + local names=() stats=() projs=() stacks=() descs=() curs=() orphs=() allows=() ports_=() locks=() blocks=() ovls=() + local name proj stack desc status cur orphan allowcount portslbl lock blocked ovl while IFS= read -r name; do [ -n "$name" ] || continue proj="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.project" }}' "$name" 2>/dev/null || true)" @@ -1040,11 +1044,13 @@ cmd_ls() { desc="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.desc" }}' "$name" 2>/dev/null || true)" allowcount="$("$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.allowcount" }}' "$name" 2>/dev/null || true)" portslbl="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.ports" }}' "$name" 2>/dev/null || true)" + ovl="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.overlays" }}' "$name" 2>/dev/null || true)" case "$proj" in "") proj="" ;; esac case "$stack" in "") stack="" ;; esac case "$desc" in "") desc="" ;; esac case "$allowcount" in "") allowcount="" ;; esac case "$portslbl" in "") portslbl="" ;; esac + case "$ovl" in "") ovl="" ;; esac orphan=false; [ -n "$proj" ] && [ ! -d "$proj" ] && orphan=true if "$RUNNER" ps --filter "name=$name" --filter status=running --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then status=running elif "$RUNNER" ps -a --filter "name=$name" --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then status=stopped @@ -1057,7 +1063,7 @@ cmd_ls() { lock="-"; [ -n "$proj" ] && [ -f "$proj/sluice.lock" ] && lock=locked blocked=""; [ -n "$egress" ] && [ "$status" = running ] && blocked="$(box_blocked_count "$name")" # opt-in: execs into the box names+=("$name"); stats+=("$status"); projs+=("$proj"); stacks+=("$stack"); descs+=("$desc"); curs+=("$cur"); orphs+=("$orphan") - allows+=("$allowcount"); ports_+=("$portslbl"); locks+=("$lock"); blocks+=("$blocked") + allows+=("$allowcount"); ports_+=("$portslbl"); locks+=("$lock"); blocks+=("$blocked"); ovls+=("$ovl") done < at +# creation, so no config sourcing is needed - prune and orphan rm work too). Echoes the count removed. +remove_box_volumes() { + local v n=0 + for v in $("$RUNNER" volume ls -q --filter "label=sluice.box=$1" 2>/dev/null || true); do + "$RUNNER" volume rm -f "$v" >/dev/null 2>&1 && n=$((n+1)) || true + done + echo "$n" +} + # Map a -b/--box to a built box: accept the short slug (qwen) or the full image (sluice-qwen), # verify it's a real sluice box, and stash its recorded project dir for the box-aware find_config below. resolve_box_target() { @@ -1684,7 +1701,10 @@ EOF elif [ "${SLUICE_YES:-}" != 1 ]; then echo "[sluice] non-interactive: re-run with SLUICE_YES=1 to confirm pruning."; return 0 fi - for i in $imgs; do "$RUNNER" rm -f "$i" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$i" >/dev/null 2>&1 || true; done + for i in $imgs; do + "$RUNNER" rm -f "$i" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$i" >/dev/null 2>&1 || true + remove_box_volumes "$i" >/dev/null # per-box SLUICE_OVERLAY_DIRS volumes go with the box + done echo "[sluice] ${C_GRN}pruned $(printf '%s\n' "$imgs" | grep -c .) box(es).${C_RST}" } if [ "${1:-}" = prune ]; then @@ -1741,7 +1761,7 @@ if ! PROJECT_CONFIG="$(find_config)"; then container="$SLUICE_BOX_IMAGE"; tag="$SLUICE_BOX_IMAGE" case "${1:-run-default}" in stop) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; echo "[sluice] $container stopped"; exit 0 ;; - rm) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true; echo "[sluice] removed $container (container + image)"; exit 0 ;; + rm) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true; remove_box_volumes "$container" >/dev/null; echo "[sluice] removed $container (container + image + overlay volumes)"; exit 0 ;; *) die "box '$SLUICE_BOX_SLUG' is an orphan (project dir ${BOX_PROJECT:-?} is gone) - 'sluice -b $SLUICE_BOX_SLUG rm' to remove it" ;; esac fi @@ -1860,6 +1880,7 @@ build() { --label "sluice.stack=$(config_stack)" --label "sluice.allowcount=$(printf '%s' "${SLUICE_ALLOW_DOMAINS:-}" | wc -w | tr -d ' ')" --label "sluice.ports=${SLUICE_PORTS:-}" + --label "sluice.overlays=${SLUICE_OVERLAY_DIRS:-}" --label "sluice.desc=${SLUICE_DESC:-}" "$@") # extra flags, e.g. --no-cache if [ -n "${SLUICE_BASE_IMAGE:-}" ]; then verify_base "$SLUICE_BASE_IMAGE" @@ -2071,6 +2092,24 @@ EOF run_args+=(-e "SLUICE_STATE_PATHS=$state_paths") fi + # SLUICE_OVERLAY_DIRS: a per-box named volume over each project-relative dir, so the box keeps its + # own contents (e.g. Linux-built node_modules) while the host's stay untouched. The volume starts + # EMPTY (install in the box), persists across container recreation, and is labeled for cleanup + # ('sluice rm'/'prune'). The entrypoint chowns a fresh volume to the sluice user (SLUICE_OVERLAY_PATHS). + if [ -n "${SLUICE_OVERLAY_DIRS:-}" ]; then + local od ovol opaths="" odirs="" + for od in ${SLUICE_OVERLAY_DIRS}; do + case "$od" in /*|*..*) die "SLUICE_OVERLAY_DIRS entry must be a relative path inside the project (no leading /, no ..): $od" ;; esac + od="${od%/}" + ovol="sluice-$slug-ov-$(printf '%s' "$od" | tr '[:upper:]' '[:lower:]' | tr -C 'a-z0-9' '-')" + "$RUNNER" volume create --label "sluice.box=$container" "$ovol" >/dev/null 2>&1 || true + run_args+=(-v "$ovol":"$PROJECT_DIR/$od") + opaths="$opaths $PROJECT_DIR/$od"; odirs="$odirs $od" + done + run_args+=(-e "SLUICE_OVERLAY_PATHS=$opaths") + echo "[sluice] overlay dirs (box-local volumes, host contents untouched):$odirs" + fi + # SLUICE_MASK: shadow in-repo secrets (empty ro bind / tmpfs over each match). Evaluated NOW - # a file created later in the run is not masked (THREAT_MODEL.md). mask_build_args @@ -2447,7 +2486,11 @@ case "${1:-run-default}" in apply) cmd_workspace_apply; exit $? ;; rebuild) build; start; exit 0 ;; stop) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; echo "[sluice] $container stopped"; exit 0 ;; - rm) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true; echo "[sluice] removed $container (container + image)"; exit 0 ;; + rm) + "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true + _nov="$(remove_box_volumes "$container")" # SLUICE_OVERLAY_DIRS volumes ride the box's lifecycle + _ovmsg=""; [ "${_nov:-0}" -gt 0 ] && _ovmsg=" + $_nov overlay volume(s)" + echo "[sluice] removed $container (container + image$_ovmsg)"; exit 0 ;; logs) "$RUNNER" logs -f "$container" ;; smoke) maybe_build diff --git a/core/entrypoint.sh b/core/entrypoint.sh index 52cae45..f8068f2 100755 --- a/core/entrypoint.sh +++ b/core/entrypoint.sh @@ -178,9 +178,10 @@ fi mkdir -p /home/sluice/.npm-global chown sluice:sluice /home/sluice/.npm-global 2>/dev/null || true -# chown the mounted repo (and any persisted SLUICE_STATE_DIRS) to sluice when not already -# (Linux bind mounts keep the host uid; no-op at uid 1000 / Docker Desktop). -for d in "${SLUICE_WORKDIR:-}" "${SLUICE_GITDIR:-}" ${SLUICE_STATE_PATHS:-}; do +# chown the mounted repo (and any persisted SLUICE_STATE_DIRS / SLUICE_OVERLAY_DIRS volumes) to +# sluice when not already (Linux bind mounts keep the host uid; a fresh named volume mounted over a +# bind path is root-owned and never auto-initialized; no-op at uid 1000 / Docker Desktop). +for d in "${SLUICE_WORKDIR:-}" "${SLUICE_GITDIR:-}" ${SLUICE_STATE_PATHS:-} ${SLUICE_OVERLAY_PATHS:-}; do if [ -n "$d" ] && [ -d "$d" ]; then if [ "$(stat -c %u "$d" 2>/dev/null || echo 0)" != 1000 ]; then chown -R sluice:sluice "$d" 2>/dev/null || true diff --git a/sluice.config.example.sh b/sluice.config.example.sh index 580f24a..abb390c 100644 --- a/sluice.config.example.sh +++ b/sluice.config.example.sh @@ -167,6 +167,14 @@ SLUICE_MOUNTS="" # Space/newline-separated. e.g. ".claude" or ".myagent .config/myagent" SLUICE_STATE_DIRS="" +# Project-relative dirs to OVERLAY with a per-box named volume (mounted over the project bind), so +# the box keeps its own contents while the host's stay untouched - for platform-specific dirs like +# node_modules when the host OS differs from the box (no more host-vs-box install flip-flop). The +# volume starts EMPTY on first run (install inside the box), persists across container recreation, +# and is removed by `sluice rm` / `sluice prune`. Relative paths only (no leading / or ..). +# Space-separated. e.g. "node_modules" or "node_modules .venv" +SLUICE_OVERLAY_DIRS="" + # Name of a shell function defined in THIS file, run on the host before launch - use # it to mint/stage short-lived credentials (write a token file, then expose its path # via SLUICE_MOUNTS, or export an env var named in SLUICE_ENV). Keeps cred plumbing in the diff --git a/src/30-doctor-ls.sh b/src/30-doctor-ls.sh index df096e5..f11fc04 100644 --- a/src/30-doctor-ls.sh +++ b/src/30-doctor-ls.sh @@ -185,6 +185,8 @@ cmd_doctor() { _doc state "$nsd dir(s) persisted at ${XDG_STATE_HOME:-$HOME/.local/state}/sluice/$slug" fi + [ -n "${SLUICE_OVERLAY_DIRS:-}" ] && _doc overlays "$SLUICE_OVERLAY_DIRS ${C_DIM}(box-local volume per dir, host contents untouched; 'sluice rm' deletes)${C_RST}" + [ -n "${SLUICE_PORTS:-}" ] && _doc ports "$SLUICE_PORTS ${C_DIM}(published on 127.0.0.1)${C_RST}" _doc allowlist "${SLUICE_ALLOW_DOMAINS:-(none beyond base)}" _doc "" "base: $(base_domains)" @@ -274,14 +276,16 @@ cmd_doctor_json() { mask_hits="$(mask_matches 2>/dev/null | _json_arr || true)" mask_unm="$(unmasked_secrets 2>/dev/null | _json_arr || true)" links_json="$(symlinks_outside_scope 2>/dev/null | cut -f1 | _json_arr || true)" + local overlays_json + overlays_json="$(printf '%s' "${SLUICE_OVERLAY_DIRS:-}" | tr ' \t' '\n\n' | _json_arr)" # shellcheck disable=SC2086 - printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \ + printf '{"engine":"%s","daemon":%s,"config":"%s","project_dir":"%s","name":"%s","desc":"%s","image":{"tag":"%s","built":%s,"stale":%s},"lock":"%s","allowlist":%s,"base":%s,"ports":%s,"allow_ips":%s,"base_image":"%s","policy_url":"%s","state_dirs":%s,"overlay_dirs":%s,"auth":%s,"mask":{"patterns":%s,"masked":%s,"unmasked_secrets":%s},"broken_symlinks":%s,"egress":{"running":%s,"blocked":%s}}\n' \ "$(_json_esc "$engine_ver")" "$daemon" "$(_json_esc "$PROJECT_CONFIG")" "$(_json_esc "$PROJECT_DIR")" "$(_json_esc "$tag")" "$(_json_esc "${SLUICE_DESC:-}")" \ "$(_json_esc "$tag")" "$img_built" "$img_stale" "$lock" \ "$(printf '%s\n' ${SLUICE_ALLOW_DOMAINS:-} | _json_arr)" "$(base_domains | tr ' ' '\n' | _json_arr)" \ "$(printf '%s\n' ${SLUICE_PORTS:-} | _json_arr)" "$(printf '%s\n' ${SLUICE_ALLOW_IPS:-} | _json_arr)" "$(_json_esc "${SLUICE_BASE_IMAGE:-}")" \ - "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$auth_json" \ + "$(_json_esc "${SLUICE_POLICY_URL:-}")" "$nsd" "$overlays_json" "$auth_json" \ "$mask_pats" "$mask_hits" "$mask_unm" "$links_json" \ "$running_b" "$(printf '%s\n' $blocked | _json_arr)" } @@ -320,8 +324,8 @@ cmd_ls() { # Gather labels + container state into parallel arrays, applying filters as we go. (Kept as a plain # while-loop, NOT a $(...) capture: a case pattern's ) inside command substitution mis-parses on bash 3.2.) - local names=() stats=() projs=() stacks=() descs=() curs=() orphs=() allows=() ports_=() locks=() blocks=() - local name proj stack desc status cur orphan allowcount portslbl lock blocked + local names=() stats=() projs=() stacks=() descs=() curs=() orphs=() allows=() ports_=() locks=() blocks=() ovls=() + local name proj stack desc status cur orphan allowcount portslbl lock blocked ovl while IFS= read -r name; do [ -n "$name" ] || continue proj="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.project" }}' "$name" 2>/dev/null || true)" @@ -329,11 +333,13 @@ cmd_ls() { desc="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.desc" }}' "$name" 2>/dev/null || true)" allowcount="$("$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.allowcount" }}' "$name" 2>/dev/null || true)" portslbl="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.ports" }}' "$name" 2>/dev/null || true)" + ovl="$( "$ENGINE" image inspect -f '{{ index .Config.Labels "sluice.overlays" }}' "$name" 2>/dev/null || true)" case "$proj" in "") proj="" ;; esac case "$stack" in "") stack="" ;; esac case "$desc" in "") desc="" ;; esac case "$allowcount" in "") allowcount="" ;; esac case "$portslbl" in "") portslbl="" ;; esac + case "$ovl" in "") ovl="" ;; esac orphan=false; [ -n "$proj" ] && [ ! -d "$proj" ] && orphan=true if "$RUNNER" ps --filter "name=$name" --filter status=running --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then status=running elif "$RUNNER" ps -a --filter "name=$name" --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then status=stopped @@ -346,7 +352,7 @@ cmd_ls() { lock="-"; [ -n "$proj" ] && [ -f "$proj/sluice.lock" ] && lock=locked blocked=""; [ -n "$egress" ] && [ "$status" = running ] && blocked="$(box_blocked_count "$name")" # opt-in: execs into the box names+=("$name"); stats+=("$status"); projs+=("$proj"); stacks+=("$stack"); descs+=("$desc"); curs+=("$cur"); orphs+=("$orphan") - allows+=("$allowcount"); ports_+=("$portslbl"); locks+=("$lock"); blocks+=("$blocked") + allows+=("$allowcount"); ports_+=("$portslbl"); locks+=("$lock"); blocks+=("$blocked"); ovls+=("$ovl") done < at +# creation, so no config sourcing is needed - prune and orphan rm work too). Echoes the count removed. +remove_box_volumes() { + local v n=0 + for v in $("$RUNNER" volume ls -q --filter "label=sluice.box=$1" 2>/dev/null || true); do + "$RUNNER" volume rm -f "$v" >/dev/null 2>&1 && n=$((n+1)) || true + done + echo "$n" +} + # Map a -b/--box to a built box: accept the short slug (qwen) or the full image (sluice-qwen), # verify it's a real sluice box, and stash its recorded project dir for the box-aware find_config below. resolve_box_target() { diff --git a/src/60-main-flow.sh b/src/60-main-flow.sh index de9a4e3..3489bc2 100644 --- a/src/60-main-flow.sh +++ b/src/60-main-flow.sh @@ -49,7 +49,10 @@ EOF elif [ "${SLUICE_YES:-}" != 1 ]; then echo "[sluice] non-interactive: re-run with SLUICE_YES=1 to confirm pruning."; return 0 fi - for i in $imgs; do "$RUNNER" rm -f "$i" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$i" >/dev/null 2>&1 || true; done + for i in $imgs; do + "$RUNNER" rm -f "$i" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$i" >/dev/null 2>&1 || true + remove_box_volumes "$i" >/dev/null # per-box SLUICE_OVERLAY_DIRS volumes go with the box + done echo "[sluice] ${C_GRN}pruned $(printf '%s\n' "$imgs" | grep -c .) box(es).${C_RST}" } if [ "${1:-}" = prune ]; then @@ -106,7 +109,7 @@ if ! PROJECT_CONFIG="$(find_config)"; then container="$SLUICE_BOX_IMAGE"; tag="$SLUICE_BOX_IMAGE" case "${1:-run-default}" in stop) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; echo "[sluice] $container stopped"; exit 0 ;; - rm) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true; echo "[sluice] removed $container (container + image)"; exit 0 ;; + rm) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true; remove_box_volumes "$container" >/dev/null; echo "[sluice] removed $container (container + image + overlay volumes)"; exit 0 ;; *) die "box '$SLUICE_BOX_SLUG' is an orphan (project dir ${BOX_PROJECT:-?} is gone) - 'sluice -b $SLUICE_BOX_SLUG rm' to remove it" ;; esac fi diff --git a/src/70-build-run.sh b/src/70-build-run.sh index e03911a..f1750cc 100644 --- a/src/70-build-run.sh +++ b/src/70-build-run.sh @@ -47,6 +47,7 @@ build() { --label "sluice.stack=$(config_stack)" --label "sluice.allowcount=$(printf '%s' "${SLUICE_ALLOW_DOMAINS:-}" | wc -w | tr -d ' ')" --label "sluice.ports=${SLUICE_PORTS:-}" + --label "sluice.overlays=${SLUICE_OVERLAY_DIRS:-}" --label "sluice.desc=${SLUICE_DESC:-}" "$@") # extra flags, e.g. --no-cache if [ -n "${SLUICE_BASE_IMAGE:-}" ]; then verify_base "$SLUICE_BASE_IMAGE" @@ -258,6 +259,24 @@ EOF run_args+=(-e "SLUICE_STATE_PATHS=$state_paths") fi + # SLUICE_OVERLAY_DIRS: a per-box named volume over each project-relative dir, so the box keeps its + # own contents (e.g. Linux-built node_modules) while the host's stay untouched. The volume starts + # EMPTY (install in the box), persists across container recreation, and is labeled for cleanup + # ('sluice rm'/'prune'). The entrypoint chowns a fresh volume to the sluice user (SLUICE_OVERLAY_PATHS). + if [ -n "${SLUICE_OVERLAY_DIRS:-}" ]; then + local od ovol opaths="" odirs="" + for od in ${SLUICE_OVERLAY_DIRS}; do + case "$od" in /*|*..*) die "SLUICE_OVERLAY_DIRS entry must be a relative path inside the project (no leading /, no ..): $od" ;; esac + od="${od%/}" + ovol="sluice-$slug-ov-$(printf '%s' "$od" | tr '[:upper:]' '[:lower:]' | tr -C 'a-z0-9' '-')" + "$RUNNER" volume create --label "sluice.box=$container" "$ovol" >/dev/null 2>&1 || true + run_args+=(-v "$ovol":"$PROJECT_DIR/$od") + opaths="$opaths $PROJECT_DIR/$od"; odirs="$odirs $od" + done + run_args+=(-e "SLUICE_OVERLAY_PATHS=$opaths") + echo "[sluice] overlay dirs (box-local volumes, host contents untouched):$odirs" + fi + # SLUICE_MASK: shadow in-repo secrets (empty ro bind / tmpfs over each match). Evaluated NOW - # a file created later in the run is not masked (THREAT_MODEL.md). mask_build_args diff --git a/src/90-dispatch.sh b/src/90-dispatch.sh index 95dad61..21ae5ef 100644 --- a/src/90-dispatch.sh +++ b/src/90-dispatch.sh @@ -4,7 +4,11 @@ case "${1:-run-default}" in apply) cmd_workspace_apply; exit $? ;; rebuild) build; start; exit 0 ;; stop) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; echo "[sluice] $container stopped"; exit 0 ;; - rm) "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true; echo "[sluice] removed $container (container + image)"; exit 0 ;; + rm) + "$RUNNER" rm -f -v "$container" >/dev/null 2>&1 || true; "$ENGINE" rmi -f "$tag" >/dev/null 2>&1 || true + _nov="$(remove_box_volumes "$container")" # SLUICE_OVERLAY_DIRS volumes ride the box's lifecycle + _ovmsg=""; [ "${_nov:-0}" -gt 0 ] && _ovmsg=" + $_nov overlay volume(s)" + echo "[sluice] removed $container (container + image$_ovmsg)"; exit 0 ;; logs) "$RUNNER" logs -f "$container" ;; smoke) maybe_build diff --git a/test/verify-doctor-checks.bats b/test/verify-doctor-checks.bats index e1f2b1e..47a4811 100644 --- a/test/verify-doctor-checks.bats +++ b/test/verify-doctor-checks.bats @@ -147,3 +147,24 @@ d = json.load(sys.stdin) assert d['broken_symlinks'] == ['dangler'], d['broken_symlinks'] " } + +# --- SLUICE_OVERLAY_DIRS surfacing -------------------------------------------------------------- + +@test "doctor: lists overlay dirs" { + printf 'SLUICE_OVERLAY_DIRS="node_modules"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + run bash -c "cd '$WORK' && '$SLUICE' doctor" + assert_success + assert_output --partial "overlays" + assert_output --partial "node_modules" +} + +@test "doctor --json: overlay_dirs from the config" { + printf 'SLUICE_OVERLAY_DIRS="node_modules .venv"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/sluice.config.sh" + run bash -c "cd '$WORK' && '$SLUICE' doctor --json 2>/dev/null" + assert_success + echo "$output" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert d['overlay_dirs'] == ['node_modules', '.venv'], d['overlay_dirs'] +" +} diff --git a/test/verify-security-overlaydirs.bats b/test/verify-security-overlaydirs.bats new file mode 100644 index 0000000..f70e8a5 --- /dev/null +++ b/test/verify-security-overlaydirs.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# SLUICE_OVERLAY_DIRS: an overlaid dir is a box-local named volume - the host's contents are +# invisible in-box and untouched by in-box writes, the volume persists across container recreation, +# is owned by the sluice user, surfaces in ls --json, and is removed by `sluice rm`. +load test_helper/common + +setup_file() { + export WORK; WORK="$(mktemp -d)"; mkdir -p "$WORK/ovl/node_modules" + echo host-built > "$WORK/ovl/node_modules/host-marker.txt" + cat > "$WORK/ovl/sluice.config.sh" </dev/null 2>&1 || true +} + +teardown_file() { + chown_back_tree sluice-sectest-ovl "$WORK" + ( cd "$WORK/ovl" 2>/dev/null && "$SLUICE" rm ) >/dev/null 2>&1 || true + "$ENG" rm -f -v sluice-sectest-ovl >/dev/null 2>&1 || true + "$ENG" rmi -f sluice-sectest-ovl >/dev/null 2>&1 || true + "$ENG" volume ls -q --filter label=sluice.box=sluice-sectest-ovl 2>/dev/null \ + | xargs -r "$ENG" volume rm -f >/dev/null 2>&1 || true + rm -rf "$WORK" +} + +@test "overlay: the host's dir contents are not visible in the box" { + run bash -c "cd '$WORK/ovl' && '$SLUICE' run sh -c 'test -e node_modules/host-marker.txt && echo visible || echo hidden' 2>/dev/null" + assert_output "hidden" +} + +@test "overlay: the box can write its own contents (volume owned by the sluice user)" { + run bash -c "cd '$WORK/ovl' && '$SLUICE' run sh -c 'echo box-built > node_modules/box-marker.txt && cat node_modules/box-marker.txt' 2>/dev/null" + assert_output "box-built" +} + +@test "overlay: in-box writes never reach the host dir" { + [ ! -e "$WORK/ovl/node_modules/box-marker.txt" ] +} + +@test "overlay: the host's own contents are untouched" { + run cat "$WORK/ovl/node_modules/host-marker.txt" + assert_output "host-built" +} + +@test "overlay: the volume persists across container recreation" { + ( cd "$WORK/ovl" && "$SLUICE" stop ) >/dev/null 2>&1 || true + run bash -c "cd '$WORK/ovl' && '$SLUICE' run cat node_modules/box-marker.txt 2>/dev/null" + assert_output "box-built" +} + +@test "overlay: the volume is labeled for this box" { + run bash -c "'$ENG' volume ls -q --filter label=sluice.box=sluice-sectest-ovl" + assert_output --partial "sluice-sectest-ovl-ov-node-modules" +} + +@test "overlay: ls --json surfaces overlay_dirs" { + run bash -c "cd '$WORK/ovl' && '$SLUICE' ls --json 2>/dev/null" + assert_success + echo "$output" | python3 -c " +import sys, json +boxes = {b['name']: b for b in json.load(sys.stdin)} +assert boxes['sluice-sectest-ovl']['overlay_dirs'] == ['node_modules'], boxes['sluice-sectest-ovl'] +" +} + +@test "overlay: launch output names the overlaid dirs" { + ( cd "$WORK/ovl" && "$SLUICE" stop ) >/dev/null 2>&1 || true + run bash -c "cd '$WORK/ovl' && '$SLUICE' run true 2>&1" + assert_output --partial "overlay dirs" + assert_output --partial "node_modules" +} + +@test "overlay: sluice rm removes the volume" { + ( cd "$WORK/ovl" && "$SLUICE" rm ) >/dev/null 2>&1 || true + run bash -c "'$ENG' volume ls -q --filter label=sluice.box=sluice-sectest-ovl" + assert_output "" +} + +@test "overlay: a '..' entry is rejected (die)" { + mkdir -p "$WORK/ovl-bad" + printf 'SLUICE_NAME="sectest-ovl-bad"\nSLUICE_OVERLAY_DIRS="../escape"\nSLUICE_RUN_CMD="bash"\n' > "$WORK/ovl-bad/sluice.config.sh" + run bash -c "cd '$WORK/ovl-bad' && '$SLUICE' run true" + assert_failure + assert_output --partial "SLUICE_OVERLAY_DIRS" + "$ENG" rm -f -v sluice-sectest-ovl-bad >/dev/null 2>&1 || true + "$ENG" rmi -f sluice-sectest-ovl-bad >/dev/null 2>&1 || true +} From 8e1bc8b24c1e9b09dceb3f790b6e05d637438270 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Wed, 10 Jun 2026 20:24:34 +0530 Subject: [PATCH 08/10] feat(agent): union the detected stack's registry hosts into the scaffolded config When 'sluice agent ' scaffolds the project config from a preset, sniff the project's manifests and extend the written SLUICE_ALLOW_DOMAINS with the stack's package-registry hosts (commented '# from stack detection: '), so the agent's first pip/bundle/yarn install doesn't trip the firewall into a learn cycle. Always the full registry set - an agent installs deps at runtime, so init's prefetch shortcut doesn't apply. Preset files themselves stay tool-only, and the single assignment line keeps 'sluice learn' rewrites working. The which-agent-is-this-repo-set-up-for note now matches on the preset's first-line banner instead of a verbatim cmp - the old check went blind the moment the config differed from the preset (now by design; before, on any learn edit). --- README.md | 5 ++- bin/sluice | 64 ++++++++++++++++++++++++++-- src/60-main-flow.sh | 64 ++++++++++++++++++++++++++-- test/verify-agent-scaffold.bats | 74 +++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 test/verify-agent-scaffold.bats diff --git a/README.md b/README.md index 855b364..1ef9c2e 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,10 @@ tool, its API hosts, and which auth env var to forward - so adding an agent is j `sluice agent` with no name to list them (each with its auth var and whether it's set on your host). If the agent hits a blocked host, `sluice learn` surfaces it. Every preset also masks `.env*` files by default (`SLUICE_MASK`), so the agent can't read in-repo env secrets - set `SLUICE_MASK=""` in -your project's config to disable. +your project's config to disable. When scaffolding the config, sluice also unions the detected +stack's package-registry hosts into the allowlist (marked `# from stack detection: ...`), so the +agent's first `pip install` / `bundle install` doesn't trip the firewall into a learn cycle - the +preset files themselves stay tool-only. Each agent runs in the project's box (named for the directory), so a repo holds **one agent at a time** - `sluice agent codex` in a repo already set up for claude reuses the claude config (sluice diff --git a/bin/sluice b/bin/sluice index f124ab4..fcf8b17 100755 --- a/bin/sluice +++ b/bin/sluice @@ -1711,6 +1711,46 @@ if [ "${1:-}" = prune ]; then case "${2:-}" in ""|--orphans) cmd_prune "${2:-}"; exit $? ;; *) die "usage: sluice prune [--orphans]" ;; esac fi +# Package-registry hosts for the stack detected in $PWD (a manifest sniff mirroring cmd_init's +# per-stack allowlists - kept lean here, full detection lives in cmd_init). Always the full registry +# set: an agent installs deps at RUNTIME inside the box, so the init prefetch shortcut doesn't apply. +# Echoes "