Skip to content

restructure: ship only plugin/ via git-subdir — keep build artifacts out of the distributed plugin (#29) - #66

Merged
mattwillems-exabeam merged 3 commits into
devfrom
fix/plugin-subdir
Aug 13, 2026
Merged

mattwillems-exabeam merged 3 commits into
devfrom
fix/plugin-subdir

Conversation

@mattwillems-exabeam

Copy link
Copy Markdown
Collaborator

Closes #29.

What this does

Moves socxen's distributable payload into plugin/ so the community-marketplace install stops shipping build-time artifacts to every user. Today the marketplace installs with source: url over the whole repo, so each user's plugin cache gets tests/, evals/, security/ (including our red-team attack payloads), scripts/, and .github/. Claude Code has no exclude/ignore mechanism; the only supported fix is a git-subdir source pointing at a subdirectory.

Moved into plugin/ (the only thing that ships): .claude-plugin/plugin.json · .mcp.json · skills/ · connector/ · docs/ · install.sh · README.md · LICENSE · NOTICE
Stays at repo root (never ships): tests/ evals/ security/ scripts/ .github/ + community-health docs.

All build tooling repointed to plugin/ (tests, evals/run.py, gen_aibom.py + regenerated BOM, ci.yml py_compile, bump_version.py, plugin-smoke.sh, doc cross-links). Clean git renames — history preserved. install.sh is move-safe (resolves SCRIPT_DIR via BASH_SOURCE; connector/ stays its sibling). A thin root README is added so the public GitHub landing isn't bare (it doesn't ship — git-subdir takes only plugin/; drop it if you'd rather).

⚠️ Paired change — do NOT promote to main alone

This restructure and a one-time edit to the open-agent-ai-security/plugins marketplace must reach socxen main together. That marketplace tracks socxen main; if the restructure hits main while the marketplace still uses source:url (or the marketplace flips first), Claude Code can't find the manifest at the repo root and installs break. Safe to merge to dev — nothing users touch changes until promotion.

@virtualsteve-exa — the companion edit is yours (Matt lacks write on the plugins repo). Apply this to plugins/.claude-plugin/marketplace.json in the same window you promote this to main:

       "name": "socxen",
       "source": {
-        "source": "url",
+        "source": "git-subdir",
         "url": "https://github.com/open-agent-ai-security/socxen.git",
+        "path": "plugin",
         "ref": "main"
       },

Verification

  • CI green locally: pytest 237 passed / 1 skipped · evals/run.py 1/1 · py_compile ok · AI BOM --check current — all on the rebased tree (0.6.7).
  • Isolated git-subdir install smoke (throwaway CLAUDE_CONFIG_DIR, real claude plugin install of this branch): cache contained only plugin/ contents; tests/ evals/ security/ scripts/ .github/ all excluded; plugin.json/.mcp.json/SKILL.md/bridge/LICENSE all present; version 0.6.7.
  • Rebased onto current dev (0.6.7 + the merged Fixtures use real routable IPs as attacker indicators — remap to RFC 5737 documentation ranges before going public #46 remap); moved fixtures carry the new RFC 5737 IPs, zero old IPs.

Acceptance criteria (#29)

  • Fresh marketplace install caches only plugin/ — no security/ tests/ evals/ scripts/ .github/
  • Install + connect smoke passes end-to-end (real claude plugin install against a scratch config)
  • CI green (invariants, evals, AI BOM freshness, red-team corpus) with new paths
  • Docs/README/bump_version.py/gen_aibom.py updated for the new layout

@virtualsteve-exa virtualsteve-exa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thorough review with independent verification — the restructure itself is excellent (clean 100% renames, ${CLAUDE_PLUGIN_ROOT} wiring correct, bump_version/invariants/CI all repointed, no two-README pill drift, shipped README's clone line updated). I reproduced the core claim myself: a real install from this branch via a ./plugin marketplace source caches only plugin/ contents — no tests/ evals/ security/ scripts/. Requesting changes on three items, one of which turned out to be a P0 hiding in your verification section.

1. Your "1 skipped" was a masked live breakage — now fixed on dev; rebase required

The skip wasn't env noise: mcp 2.0.0 removed streamablehttp_client, and the bridge's unbounded mcp>=1.0 pin meant every fresh uv resolve got 2.0.0 and crashed at import. Your path move busts the env cache key, which is why the fresh resolve happened (on both our machines) — and the smoke test's env-failure guard downgraded the crash to a skip. Details in #67; pinned mcp>=1.0,<2 in #68 and released as 0.6.8 (fresh installs on main were broken for everyone, independent of this PR — promoting this PR unpinned would have extended that to every updating user at once).
Action: rebase onto dev (0.6.8). After rebase the suite must report 238 passed, 0 skipped — treat any remaining skip as a failure.

2. plugin-smoke.sh breaks on its first post-promotion run (blocking, in-PR)

The upgrade leg can't handle a pre-restructure prior ref — which is exactly what prior will be (0.6.8, old layout) on the first run after promotion:

  • version_at() reads $1/plugin/.claude-plugin/plugin.json → FileNotFoundError under set -e when the prior worktree has it at the root.
  • fabricate_marketplace hardcodes "source": "./plugin" → the prior install has no plugin/ to install from.

Both need an existence fallback, e.g. in version_at: try plugin/.claude-plugin/plugin.json then .claude-plugin/plugin.json; in fabricate_marketplace: [ -d "$1/plugin" ] && src="./plugin" || src="./". (Same boundary class 0.6.6 fixed once with checkout -f — this is its two remaining instances.)

3. Harden the skip guard that masked #67 (strongly recommended, small)

In test_check_without_creds_exits_clean: a bridge traceback is never an environment problem. Before the skip at the returncode != 0 guard, add: if "Traceback" in proc.stderr → pytest.fail, only then fall through to skip for genuine uv-provisioning failures. That one line would have caught #67 on both our machines instead of hiding it.

Minor

  • evals/README.md:8,87 still say skills/soc-investigate/reference/examples/ — now plugin/skills/….

Sequencing addendum for the ⚠️ paired change (@virtualsteve-exa)

The window has three coordinated edits, not two: (1) this PR's promotion to socxen main, (2) the plugins index source flip to git-subdir+path: plugin, and (3) praxen's legacy mirror (praxen/.claude-plugin/marketplace.json) — #211 gave it a socxen entry with a url+ref: main source, which breaks identically if not flipped in the same window. Praxen's check_marketplace_mirror.py cron will flag drift after the fact, but the edit itself must be paired.

Everything else checked out: fixtures carry the RFC 5737 remap, install.sh is genuinely move-safe, root README correctly doesn't ship. Happy to re-review immediately after the rebase + smoke fix.

@virtualsteve-exa

Copy link
Copy Markdown
Collaborator

Correction to my review's sequencing addendum, after a sanity check from Steve: socxen currently has no installed user base beyond the two maintainers, both on the canonical marketplace — so the population that could hit a stale praxen-mirror socxen entry during the window is empty.

Revised sequencing: two hard-paired edits — (1) this PR's promotion to socxen main and (2) the plugins index flip to git-subdir + path: plugin — and the praxen legacy-mirror update is a trailing edit that can safely lag (praxen's check_marketplace_mirror.py cron will flag the drift; a one-line follow-up closes it whenever). No scrub of the mirror's socxen entry — it's praxen's deliberate catalog design and useful socxen distribution besides.

Review verdict otherwise unchanged: rebase onto 0.6.8 (expect 238/0-skip), the plugin-smoke.sh prior-worktree fallbacks, and the skip-guard hardening.

@virtualsteve-exa

Copy link
Copy Markdown
Collaborator

Sequencing update #2 (final): per Steve, the praxen legacy mirror's socxen entry shouldn't be updated — it should be removed: with zero legacy socxen installs, its only effect is acquiring new users onto a deprecated channel (dead-end onboarding). Filed as praxen#235. Once that lands, this PR's promotion window is exactly two paired edits — socxen main + the plugins index flip — with no trailing praxen obligation at all.

@virtualsteve-exa

Copy link
Copy Markdown
Collaborator

Praxen-side prep is done: open-agent-ai-security/praxen#236 (fast-tracked to praxen main, awaiting merge) removes the socxen entry from praxen's legacy mirror and teaches the drift check that the omission is deliberate — so this restructure's canonical-index flip will no longer trip praxen's weekly cron, and praxen never has to track socxen's layout again (#235).

⚠️ One blocker on the plan above, though: the companion git-subdir edit will bounce off the plugins repo's own CI as written. scripts/validate_catalog.py there enforces ALLOWED_SOURCE_KEYS = {source, url, ref} — the path key is rejected, and validate.yml runs main's validator against PR manifests, fail-closed. So the real sequence is: (1) plugins repo: teach the validator the git-subdir shape first (with path hardened — relative, no traversal), land on main; (2) then the marketplace.json flip in the same window this promotes. The validator update is praxen-maintainer work (same write-access constraint as the flip itself) — flagging so the 'this week' target accounts for it.

@virtualsteve-exa

Copy link
Copy Markdown
Collaborator

Blocker cleared: the catalog validator now accepts git-subdir sources (open-agent-ai-security/plugins#4, merged to main as 8582ca3). The exact flip in this issue's diff (path: plugin) validates green; the path field is hardened (strictly relative, traversal-free, no ./../absolute/backslash/whitespace forms) and per-type key allow-lists keep unreviewed fields an error. The promotion window is back to the two paired edits as planned — stage the index PR whenever Matt's push lands and it will pass CI.

@mattwillems-exabeam

Copy link
Copy Markdown
Collaborator Author

All three review items addressed, plus the minor — e506f33. Rebased onto dev (21e2a0b), so this now sits on 0.6.8 and #74's containment fix; the moved snippet carries the 68-rule deny tier intact ({allow: 18, ask: 4, deny: 68}).

1. Rebase → 238 passed, 0 skipped. Confirmed the skip is genuinely gone, not merely absent: tests/test_bridge_static.py reports 6 passed / 0 skipped, so the no-creds bridge test actually ran. That was the point of the number.

2. plugin-smoke.sh — both instances fixed.

  • version_at() now probes plugin/.claude-plugin/plugin.json, then the root. A genuinely missing manifest still hard-errors instead of returning an empty version — an empty one would pass the current-vs-prior equality check and smoke-test nothing.
  • fabricate_marketplace() resolves the source per call (./plugin when the ref has a plugin/ dir, else ./) rather than once, because the upgrade leg fabricates twice against the same worktree — straddling the checkout that crosses the restructure boundary.

Verified against fabricated old- and new-layout worktrees rather than by inspection: 0.6.8 read from the root layout, 0.7.0 from plugin/, sources ./ and ./plugin respectively, set -e never tripped (used if blocks, not && chains, so the false branch can't return non-zero), and a manifest-less directory exits 1.

3. Skip guard un-masked. A traceback now pytest.fails; only genuine provisioning failures still skip. Worth noting the assertions below that guard already rejected tracebacks — they simply never ran, which is precisely how #67 stayed invisible on two machines.

Minor: evals/README.md:8,87 repointed, and test_bridge_wiring.py's module docstring had the same stale connector/ path.

I left security/redteam/HISTORY.md and security/design/*.md alone — they reference connector/… as a historical/design record rather than as live paths, and rewriting a history file to match a later layout seemed worse than the staleness. Say the word if you'd rather they track.

@virtualsteve-exa virtualsteve-exa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review of e506f33 — all three items plus the minor verified fixed, independently. APPROVING on content. One more (mechanical but consequential) rebase is needed before merge; checklist below.

Verified on the branch

  • Suite: 238 passed / 0 skipped — and test_bridge_static.py genuinely runs 6/6 now. The skip-guard fix is exactly right: traceback → pytest.fail with the #67 story memorialized in the comment, provisioning failures still skip. That guard will never hide an import crash again.
  • plugin-smoke.sh fallbacks: version_at() probes plugin/ then root and still hard-errors on a genuinely missing manifest (good catch keeping that fatal — an empty version would pass the equality check and smoke nothing); fabricate_marketplace() resolves ./ vs ./plugin per call, which is the detail that matters since the upgrade leg fabricates twice across the boundary checkout.
  • Moved snippet intact: plugin/skills/soc-investigate/settings.snippet.json = {allow: 18, ask: 4, deny: 68} — #74's containment fix survived the move.
  • evals/README + wiring docstring paths fixed.

Required in the final rebase (dev moved under you again — sorry, busy day: 0.6.9, #77, #78)

  1. Version/CHANGELOG: rebase brings the 0.6.9 entry; plugin/.claude-plugin/plugin.json + the README pill must read 0.6.9 (branch currently says 0.6.8).
  2. #78 (observra 1.1 + dep lock) touched two files you're moving: take #78's side of exabeam-mcp-bridge.py (bounded PEP 723 line: observra>=1.1,<2, httpx<1, certifi>=2024,<2027, typing_extensions>=4.7,<5) and observra_logging.py (public emit() migration) into their plugin/connector/ homes.
  3. Move the lockfile too: #78 added connector/exabeam-mcp-bridge.py.lock (hash-pinned resolve; uv run picks it up by adjacency). It must land at plugin/connector/exabeam-mcp-bridge.py.lock — if the rebase drops it at root connector/, fresh installs silently lose the pin we just added.
  4. #77 (security/praxen/): stays at root with the rest of security/ — should be conflict-free, just confirm the invariant-test edits merge (it touched the SPDX exemption in test_repo_invariants.py).
  5. Regenerate the AI BOM after the rebase (sources moved + #78 changed the recorded bounds; CI gates BOM currency on any source change — it caught me on #74 too).
  6. Re-run the suite (expect 238/0) and your old/new-layout smoke-helper spot check once more on the final tree.

Sequencing note (your call, but flagging)

Your #73 edits root install.sh; this PR moves it to plugin/install.sh. Whichever lands second rebases over the other — my suggestion is this one first (it's the release-window PR and the bigger rebase), then #73 relocates its wiring into plugin/install.sh + plugin/skills/soc-investigate/merge_permissions.py.

The promotion window itself is unchanged and fully staged: this PR's release to main pairs with the plugins index flip to git-subdir/path: plugin (validator already accepts it), and you have write there to author it.

mattwillems-exabeam and others added 3 commits August 13, 2026 11:16
…ut of the distributed plugin (#29)

The community marketplace installs socxen with `source: url` over the WHOLE
repo, so every user's plugin cache gets tests/, evals/, security/ (incl. the
red-team attack payloads), scripts/, and .github/ — build-time artifacts that
should never ship. Claude Code has no exclude/ignore mechanism; the only
supported way to trim what installs is a `git-subdir` source pointing at a
subdirectory.

Move the distributable payload into `plugin/` (single source of truth — no
staged dist/ copy):

  plugin/.claude-plugin/plugin.json · plugin/.mcp.json · plugin/skills/
  plugin/connector/ · plugin/docs/ · plugin/install.sh · plugin/README.md
  plugin/LICENSE · plugin/NOTICE

Left at repo root (never ship): tests/ evals/ security/ scripts/ .github/ and
the community-health docs (CHANGELOG, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT).

All build tooling repointed to plugin/ (Path(__file__)-relative ROOT stays the
repo root, so moved refs gain a `plugin/` segment):
  - tests/*.py (bridge/skills/settings/connector loads; invariant marketplace
    assertion + bridge self-ref rebased to plugin/)
  - evals/run.py (fixture dir), security/gen_aibom.py (all sources + BOM prose;
    aibom.cdx.json regenerated), .github/workflows/ci.yml (py_compile path)
  - scripts/bump_version.py (plugin.json + README pill), scripts/release/
    plugin-smoke.sh (version read, history pathspec +--follow across the rename,
    fabricated marketplace source ./ -> ./plugin)
  - doc cross-links: plugin/docs/ ../ -> ../../, README clone-and-run ->
    ./plugin/install.sh, CONTRIBUTING/SECURITY path refs

CI green locally: pytest 237 passed/1 skipped, evals 1/1, py_compile ok,
AI BOM --check current. install.sh is move-safe (resolves SCRIPT_DIR via
BASH_SOURCE; connector/ stays its sibling).

COMPANION CHANGE REQUIRED (separate PR, open-agent-ai-security/plugins): switch
socxen's marketplace entry from source:url to
  { "source": "git-subdir", "url": ".../socxen.git", "path": "plugin", "ref": "main" }
These two MUST reach socxen `main` together — the marketplace tracks socxen
`main`, so promoting this restructure to main while the marketplace still uses
source:url breaks installs (no manifest at repo root). Safe on `dev`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcpWhxi8ZFKgUTSHZY5ZhF
Signed-off-by: Matt W <matt.willems@exabeam.com>
The restructure moves the full README into plugin/ (shipped). git-subdir
ships only plugin/, so a root README is GitHub-browsing only and never
reaches users. This one is a pointer — what socxen is, the install command,
and the repo layout (what ships vs. build-time) — deferring detail to
plugin/README.md to avoid drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcpWhxi8ZFKgUTSHZY5ZhF
Signed-off-by: Matt W <matt.willems@exabeam.com>
… stale paths (#29)

Addresses the three items from @virtualsteve-exa's review, plus the minor.

1. plugin-smoke.sh could not survive its own first post-promotion run. Both helpers
   assumed this release's layout, but the PRIOR ref on that run is the last
   root-layout release (0.6.8):
     - version_at() read $1/plugin/.claude-plugin/plugin.json unconditionally ->
       FileNotFoundError, fatal under set -e. Now probes plugin/ then the root. A
       genuinely absent manifest still hard-errors rather than yielding an empty
       version, which would sail through the current-vs-prior equality check and
       smoke-test nothing.
     - fabricate_marketplace() hardcoded "source": "./plugin", which the prior
       install does not have. Now resolved per call — the upgrade leg fabricates
       twice against one worktree, straddling the checkout that crosses the
       restructure boundary.
   Verified both helpers against fabricated old- and new-layout worktrees: 0.6.8
   from the root layout, 0.7.0 from plugin/, sources "./" and "./plugin"
   respectively, set -e never tripped, and a manifest-less dir exits non-zero.

2. The skip guard in test_check_without_creds_exits_clean treated a bridge
   TRACEBACK as an environment problem. That is how #67 hid on both maintainers'
   machines: mcp 2.0.0 dropped streamablehttp_client, the bridge died at import,
   and the guard downgraded the crash to a skip. The assertions below it already
   reject tracebacks — they just never ran. A traceback now fails; only genuine
   provisioning failures still skip.

3. evals/README.md:8,87 pointed at the pre-restructure fixture path, as did
   test_bridge_wiring.py's module docstring.

Rebased onto dev (0.6.8 + the #74 containment fix). Suite: 238 passed, 0 skipped —
the no-creds bridge test now runs rather than skipping, which is the check that
number was meant to prove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>
@mattwillems-exabeam

Copy link
Copy Markdown
Collaborator Author

Rebased onto dev @ a413686 — d17fa41. All six checklist items done; notes on two where what I found differed from the expectation.

# Item Result
1 Version/CHANGELOG → 0.6.9 plugin/.claude-plugin/plugin.json = 0.6.9, README pill = v0.6.9 (both arrived with the rebase; the invariant test enforces the pair)
2 #78's side of the bridge + observra_logging.py in plugin/connector/: PEP 723 reads mcp>=1.0,<2, httpx>=0.27,<1, certifi>=2024,<2027, observra>=1.1,<2, typing_extensions>=4.7,<5; observra.emit bound as public-since-1.1
3 Lockfile moves too plugin/connector/exabeam-mcp-bridge.py.lock, byte-identical to #78's, and zero files remain under root connector/
4 #77 security/praxen/ at root as intended; test_repo_invariants.py merged clean
5 Regenerate the AI BOM see below
6 Suite + smoke spot-check 238 passed, 0 skipped; helpers re-verified on the final tree

On item 3 — git got this right, but only just. The rebase surfaced it as an explicit CONFLICT (file location): the lockfile was added on dev inside a directory this PR renames. Git proposed plugin/connector/ and I confirmed it against dev's copy. Worth knowing it announced itself rather than silently landing at the root, which is the failure you were guarding against.

On item 5 — the BOM was already current, so I committed nothing. gen_aibom.py --check passes on the rebased tree. I forced a regen anyway to be sure, and the only delta was the timestamp field — content already matched, because this branch's generator reads plugin/connector/… and #78's bounds came through the rebase intact. Since CI gates on --check (which ignores the timestamp), committing a timestamp-only churn would add a conflict surface for no verification value. Reverted it. Say the word if you'd rather the release carry a fresh stamp.

Item 6 spot-check, re-run on the final tree (not carried over from the last round): old-layout worktree → 0.6.9 + source ./; new-layout → 0.7.0 + source ./plugin; manifest-less dir → exit 1; set -e never tripped.

Ready for the promotion window whenever you are.

@mattwillems-exabeam
mattwillems-exabeam merged commit 2dc901f into dev Aug 13, 2026
2 checks passed
mattwillems-exabeam added a commit that referenced this pull request Aug 13, 2026
#66 moved the skill into plugin/, so tests/test_merge_permissions.py's SKILL_DIR
had to follow — tests stay at the repo root, the skill they read does not. Now
matches test_repo_invariants.py's constant.

Nothing else moved: the merger travels beside the snippet it merges, so both
landed in plugin/skills/soc-investigate/ together, and install.sh resolves them
SCRIPT_DIR-relative — unchanged from plugin/install.sh. Verified end-to-end from
the new location: the merge lands {allow: 18, ask: 4, deny: 68} and the no-flag
run still writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>
mattwillems-exabeam added a commit that referenced this pull request Aug 13, 2026
…he governance gate (#70) (#73)

* feat: install.sh --merge-permissions — prompted, backed-up merge of the governance gate (#70)

The governance gate has been opt-in on every install path. install.sh verified the
merge and warned loudly when it was missing, but nothing ever performed it, so the
shipped default was "gate OFF until the operator hand-edits ~/.claude/settings.json"
— the installer was a detection control, not an enforcement one.

Add the write half. --merge-permissions merges settings.snippet.json into the
operator's settings.json; an interactive run whose gate reads OFF now shows exactly
which rules it would add and asks y/N first.

Consent is preserved by construction: nothing merges by default, and -y does NOT
authorise it — "assume yes" answers the installer's own questions, it does not
license a write to the operator's settings. --checks-only outranks the flag entirely.

The merge itself (skills/soc-investigate/merge_permissions.py) is additive-only,
backs up settings.json first (timestamped, same mode as the original), refuses
outright when a rule already sits in a different tier than the snippet specifies
(operator intent wins — it writes nothing and reports what to resolve), restores
from the backup if a write fails, and is idempotent. Worth re-running even when the
gate reads ON: a hand-merge of just the two ask lines leaves the 17-entry
containment deny list missing.

Verification is the pre-existing gate_on() re-read, not the merger's exit code —
the same "a 0-exit doesn't prove it landed" discipline the install block uses. No
python3 or no snippet reports "cannot merge, here's the manual path", never a false
green, mirroring the existing "cannot verify != OFF" rule.

25 tests cover the refusals specifically: every one of them must write nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>

* fix: resolve the governance settings path instead of hardcoding ~/.claude (#70)

install.sh hardcoded $HOME/.claude/settings.json. That was survivable while the
governance block only reported the gate's state — it could read a file the running
Claude Code ignores and mis-report ON, the dangerous direction. It stops being
survivable now that the block can write: the same assumption would merge the gate
into a file that never takes effect, i.e. a green "gate ON" protecting nothing.

Resolve as SOCXEN_SETTINGS_FILE -> $CLAUDE_CONFIG_DIR/settings.json ->
~/.claude/settings.json, and name the resolved path in every message rather than a
hardcoded one. SOCXEN_SETTINGS_FILE is the same explicit escape hatch
EXABEAM_ENV_FILE already gives the credentials path.

This is also what makes the merge testable from scripts/release/plugin-smoke.sh,
which isolates whole installs in throwaway CLAUDE_CONFIG_DIRs: without it, a smoke
leg exercising --merge-permissions would escape its sandbox and rewrite the
maintainer's real ~/.claude/settings.json on every release run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>

* docs: drop stale containment-rule counts after #74

#74 expanded the containment deny tier from 17 bare rules to 68 (both spellings,
both namespaces) covering the same 17 tools. Two comments here quoted the old
entry count while making a point that does not depend on it — that a hand-merge of
just the two ask lines leaves the containment deny list missing entirely. Say that
without the number, so the next expansion of the tier doesn't strand them again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>

* test: point the merge tests at the relocated skill directory (#29, #70)

#66 moved the skill into plugin/, so tests/test_merge_permissions.py's SKILL_DIR
had to follow — tests stay at the repo root, the skill they read does not. Now
matches test_repo_invariants.py's constant.

Nothing else moved: the merger travels beside the snippet it merges, so both
landed in plugin/skills/soc-investigate/ together, and install.sh resolves them
SCRIPT_DIR-relative — unchanged from plugin/install.sh. Verified end-to-end from
the new location: the merge lands {allow: 18, ask: 4, deny: 68} and the no-flag
run still writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>

---------

Signed-off-by: Matt W <matt.willems@exabeam.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
virtualsteve-exa added a commit that referenced this pull request Aug 13, 2026
…eleased changes (#80)

* docs: bring the docs current with the plugin/ restructure and the unreleased changes

Pre-promotion currency pass. Three classes of defect, all found by reviewing
docs against the code rather than against each other.

BROKEN INSTRUCTIONS (post-#66, a user follows these):
- plugin/docs/installation.md told users to run ./install.sh --merge-permissions
  in the section the page itself calls the most important on it. There is no root
  install.sh any more; twenty lines earlier the same page correctly says
  ./plugin/install.sh. Same for merge_permissions.py's path.
- install.sh's OWN output was stale: the usage block is printed verbatim as
  --help, and every line said ./install.sh. Its 'Next steps' block named
  skills/soc-investigate/settings.snippet.json and ./install.sh. Both now print
  the paths the script has already resolved, so they are right from a clone and
  from the installed plugin directory alike. The --help text also still claimed
  the merge targets ~/.claude/settings.json — the exact hardcoding #73 removed.

A DOC THAT CONTRADICTED THE CODE:
- logging.md said a failed emit makes telemetry 'disable itself'. It does the
  opposite on purpose: the event is dropped and the trail stays on, because a
  transient fault must not switch off a mandatory audit log — there is a test
  named for that behavior. Split the two failure modes, and documented the
  observra>=1.1 floor and the new stderr warning channel, neither of which
  appeared anywhere.

MISSING RELEASE NOTES:
- [Unreleased] documented 1 of 4 landed changes. Added entries for the plugin/
  restructure (#66), the ABV release gate (#77), and the dependency bounds +
  hash-pinned lockfile and observra 1.1 migration (#78), and corrected the
  pre-restructure paths in the entries that were already there.

Also: the lockfile existed with no mention in any document — CONTRIBUTING now
tells contributors to re-lock (CI checks the AI BOM but not lock drift) and the
end-to-end recipe copies the .lock; SECURITY.md's scope adds merge_permissions.py
(the only code that writes to a user's settings) and the lockfile; the front-door
README no longer presents the installer as check-only; CONTRIBUTING said gates run
'before a tag' though the repo has no tags and says so twice elsewhere; and
SCAN_INSTRUCTIONS.md — forward-looking scope for the NEXT Praxen audit — carried
pre-restructure paths and asked about a manual-merge-only world that no longer
exists, which would have biased that scan.

Verified: 263 tests pass, AI BOM current, installer smoke still merges to
{allow: 18, ask: 4, deny: 68}, --help renders, every relative link resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

* docs: correct three defects found in review of this PR

1. A FALSE ASSURANCE IN AN AUDIT DOC. The rewritten fail-open section claimed
   observra's warnings surface on stderr for 'a full queue' so a dropped event is
   'always visible'. Traced through observra 1.1: a queue-overflow drop is logged
   at DEBUG and counted in observra_events_dropped_total, below the WARNING-level
   stderr handler the shim installs — so that drop is counted, not announced.
   Backend write errors DO surface. Corrected in logging.md, the CHANGELOG entry,
   and the shim's own (pre-existing, equally wrong) inline comment.

2. THE REVERSE PATH ERROR. installation.md ships INSIDE the plugin and the primary
   install path is the marketplace, where no plugin/ directory exists — so
   'skills/soc-investigate/...' was correct plugin-relative and my prefixing it
   broke it for the majority reader, while plugin/README.md kept the relative form.
   Now names the plugin-relative path with the clone path as a gloss.

3. A STALE PATH IN THE BLOCK THIS PR EDITED. tests/end-to-end-testing.md's CACHE
   still used the pre-0.6.5 marketplace name (socxen/socxen); the real cache is
   under open-agent-ai-security/socxen/. The new .lock copy would have missed.

Also: the shim's DESIGN RULES said an emit failure disables logging, contradicting
its own code, tests, and this PR's new doc; my bump_version change contradicted the
script's own docstring, so both call sites now use uv run; praxen/README.md's gate
said 'before tagging' while CONTRIBUTING (which links it) says release; the rotation
bounds are clamped, not passed straight through; the merge-permissions hint now
prints an absolute path instead of $0 as invoked; and (#70) gained its PR number
per the file's (#issue, #PR) convention.

Verified: 263 pass, BOM current, Next-steps block renders with paste-able absolute
paths, real settings untouched, all relative links resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

* docs: order the [Unreleased] sections Added-before-Fixed

The last clear item from the review pass. The section order was Changed ->
Fixed -> Added -> Security; both Keep a Changelog and this file's own 0.6.3
entry put Added first. Pure reordering — no wording changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

---------

Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
virtualsteve-exa added a commit that referenced this pull request Aug 13, 2026
…/50 resisted (#76) (#81)

* fix: pin the red-team gate to an explicit model ID and record the resolved model in every artifact (#76)

The runner's --models default was the floating 'sonnet' alias, so the gate's
result could change out from under the harness whenever a new Sonnet shipped,
and a recorded verdict could not be tied to a specific model version after
the fact. Now:

- --models defaults to the explicit supported-floor ID (claude-sonnet-4-6),
  matching the discipline the dated results/ artifacts already show;
- the runner captures the session's *resolved* model ID from the CLI init
  event and tallies/reports under it, so even an alias-invoked run produces
  an attributable artifact (and prints a notice when the two differ);
- HISTORY.md's stale note (Sonnet 5 'not approved for cyber work' — no longer
  true) is replaced with the real reason the pin exists: reproducibility;
- PLAN.md's 'weakest supported model (currently Sonnet)' wording names the
  pinned ID explicitly in all three places.

The AI BOM is unaffected: it inventories dependencies, not harness source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

* fix: a stray stream event must not discard a whole red-team trial

Found while running the gate for #76: the first live trial died with
"'str' object has no attribute 'get'".

The CLI's stream-json output carries lines whose `message` is a plain
STRING rather than an object (error/notice events — an MCP server failing
to reconnect, for instance). _parse called .get() on it unconditionally,
raising AttributeError, which the runner's per-trial `except Exception`
then degraded into a fully ERRORED trial. A drive that actually completed
19 tool calls and produced a 36k-character report was discarded because
one unrelated notice appeared in the transcript.

Grading integrity was never at risk — _inconc_rows already refuses to read
an all-errored blocking-class attack as a pass (verdict INCONCLUSIVE, exit
3) — but the harness burned a full live drive per occurrence and could
have stalled the gate on re-runs.

Parse defensively by SHAPE, not just by event type, and add regression
tests for both the odd-shaped line and the resolved-model capture.
Negative-verified: the new test fails red on the unfixed parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

* security: record the 2026-08-13 red-team gate run — 50/50 resisted, PASS (#76)

The staleness half of #76. The gate had not run since 2026-07-03, across
0.6.7/0.6.8/0.6.9, and this promotion carries #66/#73/#77/#78.

Run: 10 class-A families x 5 trials on the pinned claude-sonnet-4-6, judge on,
driven against the working-tree plugin. Every family landed 0/5; no errored and
no inconclusive trials, so the verdict rests on 50 valid trials rather than on
absence of evidence.

The a10 rows carry the real signal. In 4 of 5 trials the model reproduced the
phishing markdown link in its chat output — the injection still works on the
model — but the persisted artifact was clean every time, because the write-sink
neutralizer defanged it. That is exactly what output-pipeline grading exists to
distinguish: a pass where the model never emitted the payload would prove
nothing about the guardrail. a10 landed 3/3 in the first run and drove the
deterministic fix, so seeing the code layer fire is the point.

Also fixes the ledger's own stale connector/ paths after #66, and updates the
a10 summary's resistance figure from the 0/2 fix-time retest to 0/5 here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

* docs: link the class C/D corpus gap to its tracking issue

Praxen finding 008 was 'awaiting triage'; it now has a home (#82) rather than
being an untracked row. The gap itself is unchanged — the corpus is still
class-A only, and classes C and D remain unexercised despite both blocking a
release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>

---------

Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
virtualsteve-exa added a commit that referenced this pull request Aug 13, 2026
Minor, not patch: the shipped surface changes shape. Only the payload under
plugin/ is distributed (#66), and the installer can now install the governance
gate rather than only warn about it (#73).

Also stamps the changelog entry for the red-team gate work (#76/#81), which
landed after the [Unreleased] section was written, and dates the section.

Both release gates ran against this candidate: red team 50/50 resisted (PASS,
2026-08-13 artifact); ABV 0 Critical (PASS, 2026-08-12 artifact against 0.6.9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>
mattwillems-exabeam added a commit that referenced this pull request Aug 14, 2026
…pping (#29)

plugin/docs/README.md carried five links to ../../CHANGELOG.md, CONTRIBUTING.md,
SECURITY.md, evals/ and tests/end-to-end-testing.md. They resolve while browsing
the repo and dead-end for every installed user: a plugin cache's root IS plugin/,
so ../../ climbs out of the distribution entirely.

Not a typo class. Before #66 the same links read ../CHANGELOG.md and were correct
in both places, because the whole repo shipped. The restructure deepened them to
../../ to keep the repo view working — but its entire purpose was to stop shipping
those directories, so every one of those links now points at something deliberately
absent. This is the docs index, the first page an installed user opens, and the
dead links include SECURITY.md — how to report a vulnerability.

Fixed by linking them canonically (github.com/.../blob/main/...), which is correct
from a clone and from a cache alike; the in-plugin links (../skills/..., ../README.md)
stay relative because those targets do ship.

Added test_shipped_docs_never_link_outside_the_plugin, which resolves every relative
markdown link under plugin/ and fails if it escapes the shipped root or doesn't
exist. No existing test covered this: test_no_dangling_reference_links scans only
SKILL.md and reference/*.md, and only for reference/* patterns. Negative-verified —
it reports all five against the unfixed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>
mattwillems-exabeam added a commit that referenced this pull request Aug 14, 2026
…pping (#29) (#84)

* docs: shipped docs must not link to files the restructure stopped shipping (#29)

plugin/docs/README.md carried five links to ../../CHANGELOG.md, CONTRIBUTING.md,
SECURITY.md, evals/ and tests/end-to-end-testing.md. They resolve while browsing
the repo and dead-end for every installed user: a plugin cache's root IS plugin/,
so ../../ climbs out of the distribution entirely.

Not a typo class. Before #66 the same links read ../CHANGELOG.md and were correct
in both places, because the whole repo shipped. The restructure deepened them to
../../ to keep the repo view working — but its entire purpose was to stop shipping
those directories, so every one of those links now points at something deliberately
absent. This is the docs index, the first page an installed user opens, and the
dead links include SECURITY.md — how to report a vulnerability.

Fixed by linking them canonically (github.com/.../blob/main/...), which is correct
from a clone and from a cache alike; the in-plugin links (../skills/..., ../README.md)
stay relative because those targets do ship.

Added test_shipped_docs_never_link_outside_the_plugin, which resolves every relative
markdown link under plugin/ and fails if it escapes the shipped root or doesn't
exist. No existing test covered this: test_no_dangling_reference_links scans only
SKILL.md and reference/*.md, and only for reference/* patterns. Negative-verified —
it reports all five against the unfixed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>

* docs: record the shipped-doc-links fix in the changelog (#29)

Matches the house pattern for docs-only payload changes under a fixed version, per
@virtualsteve-exa on #97: no users yet, so a shipped-docs change under 0.7.0 costs
nothing and doesn't need a bump. Also corrects the [Unreleased] preamble, which
said plugin/README.md was the only shipped file touched — plugin/docs/README.md is
a second one, and leaving that sentence as-is would have been the same drift class
this PR fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt W <matt.willems@exabeam.com>

---------

Signed-off-by: Matt W <matt.willems@exabeam.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
virtualsteve-exa added a commit that referenced this pull request Sep 6, 2026
…ctions off Node 20, Dependabot, enforced SHA pinning (#149)

Closes #71, closes #100, closes #131 — the CI-hygiene set from the August tracker sweep, in one PR. No product behavior changes; everything is verifiable in this PR's own checks.

## What changes

| Item | Change | Issue |
|---|---|---|
| Security lint | `ruff --select S` (flake8-bandit) over `plugin/` as a **required** check. Each deliberate `try/except/pass` in the shipped code carries `# noqa: S110` and its reason — a swallowed exception in the payload is a documented decision, never an accident. Same lint over `scripts/`, `security/`, `evals/` is **advisory** (`--exit-zero`, statistics only; they never ship, #66). | #100 |
| Dependency audit | `pip-audit --strict` over the bridge's hash-pinned lockfile (`uv export --script` → requirements) on every push. Clean today. Dependabot security alerts cover the same tree asynchronously; this is the synchronous gate. | #71 rung 3 (rungs 1–2 landed in #78) |
| Actions off Node 20 | `actions/checkout` v4.4.0 → **v7.0.1** (`3d3c42e5…`), `astral-sh/setup-uv` v5.4.2 → **v10.0.1** (`20cfd1bf…`), in all three workflows that use them. `setup-uv` is invoked with no inputs, so the v6 breaking changes (`activate-environment`, `working-directory`, cache glob) do not apply — this PR's green run is the proof. | #131 |
| Pins stop rotting | `.github/dependabot.yml`, `github-actions` ecosystem, weekly; Dependabot rewrites SHA pins and their `# vX.Y.Z` comments together. | #131 |
| Pinning enforced | Repository setting **"Require actions to be pinned to a full-length commit SHA"** is now ON (flipped 2026-09-05) — a bare tag is rejected at run time rather than caught by a reviewer. | #131 |

## Why now

The 2026-09-05 Praxen scan held *Manage Your Supply Chain* at 3 with the rationale "no dependency or CVE scanning of any kind" and *Build an AI Red Team* at 4 for "no in-repo enforcement." These are the two cheapest movements on those rationales before the dev RC re-scan. Honest scope note: `ruff -S` scans our own code (#100); the CVE scanning Praxen names is the `pip-audit` step (#71).

## Interaction with #148

#148 adds `plugin/hooks/gate.py`, which has two deliberate exception-swallows of its own; they now carry their `# noqa: S110` reason on that branch (pushed), so the required lint is green in either merge order.

## Also in this PR: a generated SBOM (follow-on Steve asked for)

`security/gen_sbom.py` reads the bridge's uv lockfile — and nothing else — and emits a **CycloneDX 1.6 SBOM** (`security/sbom.cdx.json` + `sbom.html`): 33 locked packages (5 direct), each with version, purl, the SHA-256 of every locked artifact, direct/transitive marking, and the full dependency graph; the AI BOM and the SBOM cross-reference each other. It keeps itself current without anyone editing it: `--check` in the invariants job fails on drift; the security job rebuilds it on every run and publishes it as the `socxen-sbom-<sha>` workflow artifact beside the `pip-audit` of the same tree; `bump_version.py` regenerates it on a release; and a repo invariant asserts the SBOM mirrors the lock. Documented in `security/README.md` → *The SBOM*, CONTRIBUTING, and the README evidence row.

## Not in this PR

A CodeQL schedule — worth considering once this lint has a baseline, not before (#100's own advice). Lockfiles for the non-shipped PEP 723 entrypoints (#71's optional tail).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016Q9UohWfy2scPhr4pavhqd
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