Build a company-branded Claude Code plugin so developers run your namespace with your defaults:
/acme-appsec:create-threat-model
The package uses the upstream analysis code with your plugin name, org profile, default preset, and optional restrictions on included skills and hooks. See the org profile reference for profile fields.
This is the shortest local loop. Run it in an empty working directory. No CI, no tarball.
1. Set the versions used below.
$ APPSEC_ADVISOR_REF=v0.6.0-beta.1
$ INTERNAL_VERSION=0.6.0-localUse a pinned tag or branch for APPSEC_ADVISOR_REF. Avoid latest for internal packaging because it makes builds harder to reproduce.
2. Create a minimal org profile.
$ mkdir -p org-profile
$ cat > org-profile/org-profile.yaml <<'YAML'
api_version: appsec-advisor.org-profile/v2
organization: { id: myorg, name: My Org, profile_version: "1" }
compatibility: { core: ">=0.6 <0.7" }
default_preset: local-default
presets:
local-default:
base_mode: standard
outputs: { sarif: true }
guardrails: { soft_budget_usd: 10 }
YAMLWith this profile, a plain /my-appsec:create-threat-model uses standard depth, emits SARIF, and caps spend at $10. compatibility is required by the org-profile schema; it prevents packaging a profile with an unsupported upstream plugin version.
3. Clone the upstream release.
$ git clone --depth 1 --branch "$APPSEC_ADVISOR_REF" https://github.com/appsec-foundry/appsec-advisor upstream/appsec-advisor4. Build a local branded plugin directory.
$ python3 upstream/appsec-advisor/scripts/package_internal_plugin.py --source upstream/appsec-advisor --org-profile org-profile --name my-appsec --version "$INTERNAL_VERSION" --skip-archive5. Load it in Claude Code.
$ claude --plugin-dir build/my-appsec6. Run the branded command in Claude Code.
/my-appsec:create-threat-model
Continue with the steps below to build and publish the package for a team.
Create one small internal repo. It owns your profile and, later in Step 4, one CI pipeline file. It should not contain a copied upstream checkout.
First choose how the repo gets upstream:
| Option | Use when | How upstream appears | Trade-off |
|---|---|---|---|
| Option 1 - CI clones upstream | you want the simplest repo; this is what the examples use | local builds and CI clone APPSEC_ADVISOR_REF into ignored upstream/appsec-advisor/ |
pin lives in docs/CI variables, not in git history |
| Option 2 - Git submodule | you want the upstream pin visible and reviewable in your repo history | git tracks upstream/appsec-advisor/ as a submodule pointer |
more submodule ceremony for clone/update workflows |
The .gitignore difference follows from that choice: Option 1 ignores upstream/; Option 2 must not ignore upstream/ because the submodule pointer is tracked.
Run exactly one of the following setup blocks.
Use this unless you explicitly want a Git submodule. The packaging repo commits only your profile and CI file; upstream/appsec-advisor/ is created later by the local build command or CI pipeline and stays ignored.
# Create the packaging repo skeleton.
$ mkdir acme-appsec-plugin
$ cd acme-appsec-plugin
$ git init
$ mkdir -p org-profile/context org-profile/actors
$ printf 'build/\ndist/\nupstream/\n' > .gitignoreAfter Step 2, the repo has this shape:
acme-appsec-plugin/
├── .gitignore
├── org-profile/
│ ├── org-profile.yaml
│ ├── context/
│ │ └── organization.md
│ └── actors/
│ └── insiders.yaml
Step 4 adds either .github/workflows/package.yml or .gitlab-ci.yml.
Use this when the upstream plugin version should be pinned in your packaging repo history. Here upstream/appsec-advisor/ is tracked as a submodule pointer, so do not ignore upstream/.
# Create the packaging repo skeleton with a tracked upstream submodule.
$ mkdir acme-appsec-plugin
$ cd acme-appsec-plugin
$ git init
$ mkdir -p org-profile/context org-profile/actors
$ printf 'build/\ndist/\n' > .gitignore
$ APPSEC_ADVISOR_REF=v0.6.0-beta.1
$ git submodule add https://github.com/appsec-foundry/appsec-advisor upstream/appsec-advisor
$ git -C upstream/appsec-advisor checkout "$APPSEC_ADVISOR_REF"
$ git add .gitignore .gitmodules upstream/appsec-advisor
$ git commit -m "Pin upstream appsec-advisor"Step 4 still adds either .github/workflows/package.yml or .gitlab-ci.yml.
The organization packaging template is a ready-made Option 1 repository. It ships an org profile, a package policy, GitHub and GitLab pipelines, and build scripts, and it tracks upstream releases. Use it instead of assembling the repository by hand unless you need a shape it does not cover.
org-profile/org-profile.yaml is the company-owned runtime configuration surface. Start with two presets: one default CI-style scan and one deeper release review. Build-time package restrictions live next to it in org-profile/package-policy.yaml; they are covered after the profile example.
api_version: appsec-advisor.org-profile/v2
organization:
id: acme
name: Acme Corp
profile_version: "2026.05.1"
compatibility:
core: ">=0.6 <0.7"
default_preset: ci-standard
policy:
url_allowlist: [appsec.int.example.com] # restrict remote fetches to your hosts
requirements:
source:
requirements_yaml_url: "https://appsec.int.example.com/appsec-requirements.yaml"
label: "Acme AppSec Requirements"
cache: true
fail_mode: cache_fallback
create_threat_model:
default_active: true
quick_default_active: false
llm_context:
documents:
- id: organization
path: context/organization.md
purpose: organization_background
max_bytes: 50000
actors:
inherit_defaults: true
add: actors/*.yaml
presets:
ci-standard:
base_mode: standard
outputs:
sarif: true
requirements:
enabled: true
gate: # package the CI gate policy so devs don't pass flags
mode: enforce # gate by default (like --gate); advisory otherwise
gate_on: fail
priority_floor: MUST
guardrails:
max_wall_time: 1h
soft_budget_usd: 20
fail_on: high # headless run exits non-zero on new High+ threats
release-review:
base_mode: thorough
outputs:
sarif: true
pdf: true
pentest_tasks: true
requirements:
enabled: true
guardrails:
max_wall_time: 3h
soft_budget_usd: 80The requirements.gate block makes verify-requirements and
audit-security-requirements gate by your default, so developers don't pass
--gate themselves. See CI gates.
Add the referenced context file:
# Acme Corp - security context
Acme runs a B2B payments platform. Critical flows: merchant onboarding, payout settlement, and admin refunds. Staff use Okta SSO; merchants use tenant-scoped API keys.Context files are untrusted reference data. They inform the analysis, but they cannot change severity rules, QA gates, schemas, permissions, or tool behavior.
Custom actors are optional. If you keep actors.add: actors/*.yaml, actor files must contain a top-level actors: array:
actors:
- id: ACT-E-01
label: acme-privileged-contractor
access: [internal-network, ci-cd-secrets, staging-env]
capabilities:
sophistication: medium
dwell_time: weeks
surface_reach: [local, lateral]
motivation: financial
heatmap_slug: repo-readUse org-profile/package-policy.yaml when the internal plugin must not expose every upstream skill or hook. The packager auto-detects this file. You can also pass --package-policy <path> explicitly.
Package policy removes skills and hooks from the built artifact. Org-profile skill_toggles only block skills at runtime.
Prefer allowlists for enterprise packages so newly added upstream skills do not appear until your team reviews them:
plugin_surface:
skills:
include:
- create-threat-model # required
- status
- check-permissions
- clean-run-state
- fix-run-issues
- security-assessment-health
hooks:
include:
- agent-loggerUse denylists when you want to keep the upstream surface except for a small number of functions:
plugin_surface:
skills:
exclude:
- authnz-review
- export-security-assessment
hooks:
exclude:
- security-coachSupported hook IDs today:
| Hook ID | Registered script | Effect when removed |
|---|---|---|
security-coach |
scripts/analyzers/security_steering.py |
Removes prompt-time security coaching; hooks/steering_keywords.json is also omitted. |
agent-logger |
scripts/runtime/agent_logger.py |
Removes Claude hook event logging for tool/use/stop events. Run summaries may have less timing and token context. |
create-threat-model and its internal internal-threat-analysis-kernel dependency are required and cannot be removed by package policy. The kernel is preloaded only by focused core agents and is not advertised as a user command. Unknown names fail the build so typos do not silently produce the wrong internal artifact.
If the org profile declares an mcp block, a third surface, mcp_servers, narrows which of those servers are emitted into the packaged .mcp.json. Every declared server is included by default; use an allowlist to restrict them:
plugin_surface:
mcp_servers:
include:
- acme-sastThe included/removed servers are recorded in package-surface.json alongside skills and hooks, and the smoke test verifies them against the packaged .mcp.json.
An org can ship its own Claude Code hooks in the plugin, so one central artifact carries everything. Declare them in the org profile and drop the scripts under org-profile/hooks/:
# org-profile/org-profile.yaml
hooks:
block-risky-bash:
event: PreToolUse
matcher: Bash # PreToolUse / PostToolUse only
command: python3 ${CLAUDE_PLUGIN_ROOT}/org-profile/hooks/guard.pyThe packager merges each declared hook into the built hooks/hooks.json and records it in package-surface.json under hooks.org (separate from the upstream security-coach / agent-logger), so the artifact stays auditable. The smoke test then checks that every listed org hook is present in hooks.json and its script is packaged. plugin_surface.hooks can exclude an org hook by id, the same as an upstream one.
Rules:
- The
commandmust reference a script under the profile directory via${CLAUDE_PLUGIN_ROOT}/org-profile/...; the script must exist and its id must not reusesecurity-coachoragent-logger. - Org hooks run at Claude Code's event layer. They can add context or block tool calls, but cannot change the analysis pipeline's findings, severity rules, or schemas.
- The hook code is yours: it ships in your artifact and runs on your developers' machines, like a bundled MCP stdio server. Only bundle scripts you trust.
Make sure upstream/appsec-advisor/ exists. With Option 1 from Step 1, clone it locally:
# Option 1 only: create the ignored upstream checkout.
$ APPSEC_ADVISOR_REF=v0.6.0-beta.1
$ git clone --depth 1 --branch "$APPSEC_ADVISOR_REF" https://github.com/appsec-foundry/appsec-advisor upstream/appsec-advisorWith Option 2, the directory is the submodule checkout. Initialize it if this is a fresh clone of your packaging repo:
# Option 2 only: fetch the tracked submodule checkout.
$ git submodule update --init --recursiveBuild the packaged plugin:
$ INTERNAL_VERSION=0.6.0-acme.20260903
$ python3 upstream/appsec-advisor/scripts/package_internal_plugin.py \
--source upstream/appsec-advisor \
--org-profile org-profile \
--name acme-appsec \
--version "$INTERNAL_VERSION"The build writes the plugin to build/acme-appsec/ and creates dist/acme-appsec-${INTERNAL_VERSION}.tgz with a .sha256 checksum. It also validates the profile, package policy, and renamed command namespace.
Every build writes .claude-plugin/package-surface.json into the packaged tree. It records the included and removed skills, hooks, and MCP servers so CI and reviewers can verify the artifact surface without reverse-engineering the copied files.
/<namespace>:status shows the upstream version, revision, commit date, packaging date, and whether the source checkout was modified. These values are stored in .claude-plugin/plugin.json because the package has no .git directory. Pass --upstream-url to record the source repository explicitly; otherwise, the packager uses the checkout's origin remote.
Run the smoke test after every build. It checks the plugin identity, org-profile wiring, namespace rewrite, and the package-surface manifest when present:
$ python3 upstream/appsec-advisor/scripts/smoke_test_package.py build/acme-appsec --name acme-appsecThe packager excludes dependency trees, caches, run logs, generated report state, and other ignored local artifacts at any directory depth. The smoke test independently rejects those paths and personal home-directory paths, so package from a clean checkout and treat either finding as a build failure.
Use --skip-archive while editing locally:
$ INTERNAL_VERSION=0.6.0-dev
$ python3 upstream/appsec-advisor/scripts/package_internal_plugin.py --source upstream/appsec-advisor --org-profile org-profile --name acme-appsec --version "$INTERNAL_VERSION" --skip-archive
$ claude --plugin-dir build/acme-appsecThe --version must satisfy compatibility.core in the org profile.
Step 4 adds one pipeline file to the packaging repo:
- GitHub Actions:
.github/workflows/package.yml - GitLab CI:
.gitlab-ci.yml
The embedded examples below use Option 1 from Step 1, where CI clones upstream during the job. They do the same three operations as the local build:
- clone a pinned upstream ref into
upstream/appsec-advisor - run
scripts/package_internal_plugin.py - run
scripts/smoke_test_package.py
For Option 1, set these CI variables:
| Variable | Required | Meaning |
|---|---|---|
APPSEC_ADVISOR_URL |
yes | upstream or fork URL, for example https://github.com/appsec-foundry/appsec-advisor.git |
APPSEC_ADVISOR_REF |
yes | pinned tag or branch, for example v0.6.0-beta.1 |
INTERNAL_NAME |
no | plugin namespace, default acme-appsec |
VERSION |
no | package version; defaults to a CI snapshot version |
The example clone command uses git clone --depth 1 --branch "$APPSEC_ADVISOR_REF", which is correct for tags and branches. To pin an arbitrary commit SHA, replace that line with a clone plus git fetch --depth 1 origin <sha> and checkout.
For Option 2, remove the clone step from the example and configure your CI checkout to initialize submodules. Then run the same package and smoke-test commands against upstream/appsec-advisor/.
GitHub Actions pipeline
name: package-internal-plugin
on:
workflow_dispatch:
push:
tags:
- "v*"
permissions:
contents: read
env:
INTERNAL_NAME: ${{ vars.INTERNAL_NAME || 'acme-appsec' }}
PYTHONDONTWRITEBYTECODE: "1"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
jobs:
package:
runs-on: ubuntu-latest
steps:
- name: Checkout packaging repo
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build dependencies
run: pip install --quiet pyyaml jsonschema packaging
- name: Resolve version
id: ver
env:
VERSION: ${{ vars.VERSION }}
run: |
if [ -n "${VERSION}" ]; then
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "version=0.6.0-internal.${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
fi
- name: Clone pinned upstream
env:
APPSEC_ADVISOR_URL: ${{ vars.APPSEC_ADVISOR_URL }}
APPSEC_ADVISOR_REF: ${{ vars.APPSEC_ADVISOR_REF }}
run: |
test -n "${APPSEC_ADVISOR_URL}" || { echo "APPSEC_ADVISOR_URL not set"; exit 2; }
test -n "${APPSEC_ADVISOR_REF}" || { echo "APPSEC_ADVISOR_REF not set"; exit 2; }
git clone --depth 1 --branch "${APPSEC_ADVISOR_REF}" "${APPSEC_ADVISOR_URL}" upstream/appsec-advisor
- name: Package plugin
run: |
python3 upstream/appsec-advisor/scripts/package_internal_plugin.py \
--source upstream/appsec-advisor \
--org-profile org-profile \
--name "${INTERNAL_NAME}" \
--version "${{ steps.ver.outputs.version }}"
- name: Smoke-test package
run: |
python3 upstream/appsec-advisor/scripts/smoke_test_package.py \
"build/${INTERNAL_NAME}" --name "${INTERNAL_NAME}"
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: ${{ env.INTERNAL_NAME }}-${{ steps.ver.outputs.version }}
path: |
dist/${{ env.INTERNAL_NAME }}-${{ steps.ver.outputs.version }}.tgz
dist/${{ env.INTERNAL_NAME }}-${{ steps.ver.outputs.version }}.tgz.sha256
retention-days: 30The packaging template ships a maintained version of this pipeline in ci-templates/github/workflows/package.yml. It adds lint, upstream drift, and release stages, and it pins the runner image and the Python dependencies.
GitLab CI pipeline
stages:
- package
variables:
INTERNAL_NAME: "acme-appsec"
VERSION: "0.6.0-internal.${CI_COMMIT_SHORT_SHA}"
PYTHONDONTWRITEBYTECODE: "1"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
default:
image: python:3.11-slim
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git
- pip install --quiet pyyaml jsonschema packaging
package:
stage: package
script:
- test -n "$APPSEC_ADVISOR_URL" || { echo "APPSEC_ADVISOR_URL not set"; exit 2; }
- test -n "$APPSEC_ADVISOR_REF" || { echo "APPSEC_ADVISOR_REF not set"; exit 2; }
- git clone --depth 1 --branch "$APPSEC_ADVISOR_REF" "$APPSEC_ADVISOR_URL" upstream/appsec-advisor
- >
python3 upstream/appsec-advisor/scripts/package_internal_plugin.py
--source upstream/appsec-advisor
--org-profile org-profile
--name "$INTERNAL_NAME"
--version "$VERSION"
- python3 upstream/appsec-advisor/scripts/smoke_test_package.py "build/${INTERNAL_NAME}" --name "${INTERNAL_NAME}"
artifacts:
name: "${INTERNAL_NAME}-${VERSION}"
paths:
- "dist/${INTERNAL_NAME}-${VERSION}.tgz"
- "dist/${INTERNAL_NAME}-${VERSION}.tgz.sha256"
expire_in: 30 daysThe packaging template ships a maintained version of this pipeline in ci-templates/gitlab-ci.yml. It adds lint, upstream drift, and release stages, and it pins the runner image and the Python dependencies.
Publish dist/*.tgz through your normal internal artifact or software-distribution channel.
Developers install the approved artifact:
$ INTERNAL_VERSION=0.6.0-acme.20260903
$ mkdir -p ~/.claude/plugins
$ tar -xzf "acme-appsec-${INTERNAL_VERSION}.tgz" -C ~/.claude/plugins
$ claude --plugin-dir ~/.claude/plugins/acme-appsecThen they run:
/acme-appsec:create-threat-model
To use another bundled preset:
/acme-appsec:create-threat-model --preset release-review
When upstream releases a new version:
- update
APPSEC_ADVISOR_REFor your local clone/submodule pin - make sure
compatibility.corecovers the new package version - rebuild the package
- run the smoke test
- run one small dry run with the packaged plugin
- publish a new internal artifact
Keep packaging changes small. If you need to change analysis behavior, treat it as a real fork and test it as one.