Code-derived threat modeling plugin: the architecture model is derived from the repository, not maintained by hand. Provides AppSec-focused agents and skills…

⚠️ Beta: not production ready.
appsec-advisoris under active development. Interfaces, schemas, and output may change without notice.
appsec-advisor is a Claude Code plugin for automated, code-derived threat modeling. It reconstructs application components, data flows, and trust boundaries from code and configuration, then applies STRIDE to identify threats in the implemented architecture. Findings reference repository evidence and include remediation guidance.
The Threat Analyst covers the design side: on request, it analyzes a planned feature or a selected code change for threats and asks the team for missing design facts. The plugin also includes requirements audits, change reviews, and CI gates.
Why appsec-advisor? · Security · Quick start · Threat Modeler · Threat Analyst · Requirements Audit · Developer tools · Report a failed run · Enterprise rollout · Documentation · Product requirements · What's new in 0.6.0-beta.5 · Contributing
appsec-advisor prepares threat models from code and configuration. It does not replace collaborative threat modeling with the people who own the system. You do not need an existing threat model or a workshop to start.
| Threat Modeler | Collaborative threat modeling | |
|---|---|---|
| Works from | Code, configuration, and supplied context | Design intent, business context, and knowledge of runtime and production controls |
| Delivers | Architecture, trust boundaries, and threats; every finding cites repository evidence | Judgment on which threats matter and which assumptions hold |
| Strength | Covers the whole repository the same way on every run | Sees what the code cannot show |
| Leaves open | Business impact, risk acceptance, and controls outside the repository | Consistent coverage of a large or fast-changing codebase |
Run the Threat Modeler for a first assessment and again when code or configuration changes. Before code exists, the experimental Threat Analyst prepares the team discussion of a planned feature with threat scenarios, protection assumptions, and open questions. It also reviews single changes and reports evidence-backed findings.
The code cannot tell the tool which networks you trust or which data is critical to your business. You add that as business context and trust-boundary declarations, and the analysis then treats them as stated assumptions.
Results come as Markdown and YAML, with optional PDF, HTML, SARIF, Threat Dragon, and pentest-task exports. Organizations can add their own requirements and tools without maintaining a fork of the core analysis pipeline. See Enterprise rollout.
The assessment uses evidence from the analyzed repository and supplied context, including configured related-repository models. It cannot verify runtime behavior or production-only controls. An AppSec engineer or security architect should validate findings before they drive remediation or risk acceptance.
[!IMPORTANT] Treat scanned repositories as untrusted input. Repository content enters the LLM context and may attempt prompt injection. Untrusted mode is the default; keep it enabled and use a container or VM for third-party or vendor code. See Security: Untrusted repositories.
Data handling. Source, manifests, and configuration for analyzed components are sent to Anthropic. Surfaced secrets are masked. The plugin requires api.anthropic.com, cannot run air-gapped, and uses provider-side prompt caching.
Python renders reports from validated structured data. If a run artifact contains an unmasked secret, the run fails before its outputs are considered publishable.
Requires Claude Code, Python 3.10+, and git on PATH. Optional Mermaid dependencies provide stricter diagram validation; see the Threat Modeler reference.
For most repositories, run the Claude Code session on Sonnet 4.6. The orchestration session remains active for the full assessment and therefore has the largest effect on cost. Agent models are routed separately: a standard scan uses Sonnet 5.5 for judgment and report authoring, while STRIDE discovery remains on Sonnet 4.6. A multi-repository assessment runs STRIDE on Sonnet 5.5 instead. Very large repositories may require a Sonnet 5.5 session for the larger context window. See when to use the latest Sonnet.
Add the marketplace and install the plugin. This installs the current release from main and needs no checkout. If GitHub SSH access is not configured, run export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in your shell before installing or updating to use HTTPS for both the marketplace and plugin downloads:
claude plugin marketplace add appsec-foundry/appsec-advisor
claude plugin install appsec-advisor@appsec-foundry
Later releases arrive with claude plugin update appsec-advisor@appsec-foundry, which takes effect after a restart. /appsec-advisor:status --check-updates compares the core version against the release branch.
Then start Claude Code from the repository you want to assess:
cd /path/to/repository-to-assess
claude
To run the development branch instead of a release, clone the repository and start Claude Code with the checkout:
git clone --branch dev https://github.com/appsec-foundry/appsec-advisor.git /path/to/appsec-advisor
cd /path/to/repository-to-assess
claude --plugin-dir /path/to/appsec-advisor
Run the one-time permission setup:
/appsec-advisor:check-permissions --update
The command adds the missing rules, including Bash(*), to this repository's .claude/settings.local.json. Without them the assessment stops before it starts, unless your settings set defaultMode to auto or bypassPermissions.
Restart or reload Claude Code, then create the model:
/appsec-advisor:create-threat-model
The assessment writes threat-model.md and threat-model.yaml to docs/security/.
# Ask about the model in plain language
what are the most critical findings?
how well is authentication protected?
what should I fix first?
# Fix findings, accept their risk, or build a remediation plan
/appsec-advisor:review-threat-model
# Analyze a planned feature or your current changes
/appsec-advisor:analyze-threats Let support staff export customer data as CSV.
/appsec-advisor:analyze-threats --worktree
# Reassess after code changes while preserving history
/appsec-advisor:create-threat-model --full
# Optionally publish a reviewed model to version control
/appsec-advisor:publish-threat-model
Updates preserve finding IDs. Review decisions are stored separately. Run /appsec-advisor:help for the complete command list.
Run /appsec-advisor:create-threat-model to get:
docs/security/abuse-cases/;threat-model.md and threat-model.yaml, with optional PDF, HTML, SARIF, Threat Dragon, and pentest-task exports.The report links findings to the OWASP Top 10:2025. If the repository contains an LLM or agentic application, it also checks the relevant OWASP LLM and Agentic Applications categories.
The Threat Modeler guide covers depth, focused scans, repository context, exports, costs, and limits.
Example: Read a thorough assessment of OWASP Juice Shop or browse more examples.
Figure 1a shows the runtime components, data flows, and attack paths identified in OWASP Juice Shop.
<img src="docs/images/figure1-example.svg" alt="Figure 1a of the Juice Shop threat model">
For repositories with a build pipeline, Figure 1b links build inputs and release artifacts to the most severe supply-chain attacks and the relevant controls.
<img src="docs/images/figure1b-example.svg" alt="Figure 1b of the Juice Shop threat model">
Figure 2 connects threat actors through example attack routes and the linked weaknesses to the potential business harm.
<img src="docs/images/figure2-example.svg" alt="Figure 2 of the Juice Shop threat model">
Assessments consume model tokens and usually take tens of minutes; thorough runs may exceed an hour. See assessment depth and cost control for measured costs.
An abuse case describes something an attacker or a user must not be able to do. The plugin ships two kinds:
Each run finds the code behind these operations and checks whether the rule holds. The result changes the findings:
You can add your own cases. For a rule in one repository, put a YAML file into docs/security/abuse-cases/. One sentence and a file-name pattern are enough:
schema_version: 2
abuse_cases:
- id: REPO-AC-001
kind: descriptive
title: Employee approves their own expense report
check: Check whether an employee can approve an expense report they submitted.
scope_qualifier: {path_patterns: ["*expense*"]}
The next run checks this rule in files such as ExpenseController.java or below expenses/. Repository abuse cases explains when a case runs and how its result is reported. To apply cases to every repository of your organization, add them to an organization profile. Abuse cases in organization profiles also covers technical attack chains, turning off built-in cases, and failing a CI run on a confirmed case.
/appsec-advisor:abuse-cases lists the defined technical and business abuse cases with their origin: plugin, organization profile, or repository. /appsec-advisor:analyze-threats --abuse-case <ID> checks one case against the code without a full assessment, with the existing threat model as context or, with --isolated, without it. /appsec-advisor:create-threat-model --only-abuse-case <ID> runs a new full assessment that checks only the selected cases; it replaces the existing threat model. /appsec-advisor:ask-threat-model reports how each case turned out.
This mode is experimental: expect rough edges, and review its results more closely than those of a single-repository scan.
To assess several local checkouts as one system, repeat --repo in the headless runner. Every selected repository is scanned freshly, and the run produces one threat model, one report and one Figure 1. Run it from the plugin checkout and replace the example paths with your checkouts and report location:
./scripts/run-headless.sh \
--repo /repos/frontend \
--repo /repos/backend \
--repo /repos/worker \
--output /reports/order-system \
--soft-budget 40
A combined run has these requirements:
--output directory outside every selected checkout.--soft-budget or --hard-budget. The run stops when the budget is exhausted.A combined run uses Sonnet 5.5 for STRIDE at quick and standard depth instead of Sonnet 4.6, because Sonnet 4.6 returns no STRIDE result in this mode. STRIDE calls therefore cost more than in a single-repository scan. The run prints each role's model at the start and warns about this change. See Assess several repositories as one system.
Components, findings and exports name the repository they come from, for example Order API [backend] and backend/src/orders.ts:42. The order of the --repo flags does not matter, and no repository takes precedence. A connection between repositories appears only when source evidence supports it, and repository membership never creates a trust boundary. The run supports full assessments and --resume of an interrupted run whose sources and settings are unchanged; --incremental, --rerender and --rebuild are not available for combined runs. See non-interactive mode for details and limits.
To keep separate reports instead, run one assessment per repository with its own --output directory. Configure docs/related-repos.yaml in a repository to include existing dependency models as cross-repository context. These imports inform the local analysis; they do not rescan dependency source code.
Run /appsec-advisor:analyze-threats before or while you build a feature. It reports:
The report is advisory and does not change threat-model.yaml. The Threat Analyst is experimental.
| When | Command |
|---|---|
| Before coding | /appsec-advisor:analyze-threats Let support staff export customer data as CSV. |
| During implementation | /appsec-advisor:analyze-threats --worktree --question "Can a tenant read another tenant's export?" |
| Before merging | /appsec-advisor:analyze-threats --base <target-branch> --head HEAD |
Add --explore when the answer depends on code you cannot name: the analysis then reads a redacted copy of the tracked files, without tools that run commands or write files. The Threat Analyst guide covers exploration, staged changes, saved answers, the advisory CI job, and limitations.
/appsec-advisor:audit-security-requirements checks the repository against an AppSec requirements catalog. It provides a faster control assessment for pull-request gates, compliance dashboards, and audit preparation.
# Use the configured catalog
/appsec-advisor:audit-security-requirements
# Use a catalog URL for this run
/appsec-advisor:audit-security-requirements --requirements https://appsec.int.example.com/appsec-requirements.yaml
If you do not have a catalog, adapt data/appsec-requirements-fallback.yaml or use the requirements harvester. See the Requirements Audit reference for setup and options.
| Tool | Use |
|---|---|
| Secure-coding baseline | Install, update, verify, or remove secure-coding instructions with install-baseline, update-baseline, verify-baseline, and remove-baseline. |
| Security Coach (experimental) | Add security guidance while writing security-sensitive code. |
| appsec-reviewer (experimental) | Embed change review in Claude Code or an Agent SDK workflow. |
| verify-requirements (experimental) | Review an interactive diff against the requirements catalog. |
| appsec-reviewer-cli (experimental) | Run the same change review in CI or other automation. |
See the developer tools guide for commands and configuration.
The security score checks a repository without building a threat model. It returns a score from 0 to 100, undetermined if too few checks apply, or incomplete without a score if a required scanner fails or emits invalid output. Findings and diagnostics remain visible in every verdict. You need Python 3.10+, PyYAML, jsonschema, and git.
/appsec-advisor:security-score
For CI or other automation, run the script from a plugin checkout:
python3 /path/to/appsec-advisor/scripts/analyzers/security_score.py --repo /path/to/project
Both accept the same options. --repo also accepts an HTTPS GitHub or GitLab Git URL. Use --json or --yaml for structured output validated against schemas/security-score.schema.yaml. Exit codes are 0 for a score, 2 for insufficient coverage, and 1 for incomplete execution or an error. Compare commits only with matching scoring versions, catalog fingerprints, and applicable coverage in comparability. Findings without a scored baseline and findings excluded by severity policy are disclosed separately.
Run the scanner for findings, endpoints, and detected technologies without building a threat model. You need Python 3.10+, PyYAML, jsonschema, and git.
python3 /path/to/appsec-advisor/scripts/analyzers/repo_scan.py --repo /path/to/project
--repo accepts a local directory or an HTTPS GitHub or GitLab Git URL. Use --high to show only High and Critical findings, or --json scan.json to save a JSON report. See --help for scan selection.
Investigate a suspected plugin error and prepare a minimal issue draft with:
/appsec-advisor:report-error
The skill checks the plugin cause locally and distinguishes source inspection from an executed neutral reproduction. It shows the complete issue draft before asking for permission to publish it to appsec-foundry/appsec-advisor. Raw logs, project source, findings, and diagnostic attachments are excluded. Review the draft for remaining internal names; your GitHub account remains visible as the issue author. Publication requires an existing GitHub CLI login.
Use /appsec-advisor:report-error --bundle-only for the local diagnostic archive workflow. Log scrubbing is best effort; inspect the entire archive before sharing it manually. Unattended scans never ask questions or publish issues.
AppSec and Platform teams can supply organization-specific requirements, defaults, guardrails, skills, hooks, and MCP servers. The organization packaging template keeps this configuration in a separate internal package built from a pinned upstream release. Core agent definitions remain upstream-owned.
<img src="docs/images/orgpackaging.svg" alt="Example rollout from an upstream release to an Acme-branded plugin">
See Internal Plugin Packaging and Organization Profiles.
| Goal | Start here |
|---|---|
| Run or configure a threat model | Threat Modeler |
| Analyze a planned feature or selected code change | Threat Analyst |
| Configure models, cost, or logging | Configuration and Model Selection |
| Configure requirements audits | Requirements Audit |
| Run an existing plugin checkout without interaction | Non-interactive Mode |
Agents inspect the repository and perform the security analysis. Deterministic Python validates structured artifacts, renders reports, generates exports, and enforces release gates. Schemas define the data exchanged between pipeline stages.
The main directories are agents/, skills/, scripts/, schemas/, templates/, and tests/. See the repository layout for the complete map and the tests required for each kind of change.
| Project | Primary scope | Relation to appsec-advisor |
|---|---|---|
| tachi | Multi-agent analysis of an architecture description. | Tachi treats the description as its primary input; appsec-advisor derives the model from code and configuration. |
| stride-gpt | Provider-independent STRIDE analysis from a description or codebase. | stride-gpt supports several model providers; appsec-advisor runs in Claude Code and adds schema validation and stable finding IDs. |
| OWASP pytm | Threat models authored and maintained as Python code. | pytm requires developers to declare the model; appsec-advisor derives it from the
hooks/progress.tsx 341 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Band, Live, Progress } from '../types/progress'
5
6const PANE = 'threat-model-progress'
7const TITLE = 'Threat model progress'
8const KEEP = 200
9const POLL_MS = 1000
10// A resumed step replays earlier phases within seconds; no toast for those.
11const REPLAY_S = 10
12// The controller's default heartbeat threshold, for a lock that states no stale_after=.
13const LIVE_STALE_S = 300
14const REPORT_DIR = 'docs/security'
15
16const progress = atom(
17 { plugin: 'appsec-advisor', key: 'progress' } as const,
18 { file: undefined, lines: [] } as Progress,
19)
20
21const NO_BAND: Band = { file: undefined, phase: undefined, seenAt: 0, runBase: 0, dismissed: false }
22
23const band = atom({ plugin: 'appsec-advisor', key: 'band' } as const, NO_BAND)
24
25const now = atom({ plugin: 'appsec-advisor', key: 'now' } as const, 0)
26
27const live = atom({ plugin: 'appsec-advisor', key: 'live' } as const, null as Live | null)
28
29export type Row = { k: number; n: number; name: string; event: string; elapsed: number | undefined; detail: string }
30
31export type Step = {
32 phase: { k: number; n: number; name: string } | undefined
33 event: string
34 detail: string
35 elapsed: number | undefined
36 done: number[]
37 outcome: 'running' | 'paused' | 'failed' | 'complete'
38 message: string
39}
40
41const ROW = /^\d\d:\d\d:\d\d {2}(\d+)\/(\d+) (.+?) {2,}(start|done|·|⚠|↻)(.*)$/
42const DURATION = /^(<1m|\d+h(\d\dm)?|\d+m)$/
43
44export const isStepCommand = (command: string): boolean =>
45 command.includes('run-headless.sh') && command.includes('--interactive-launch')
46
47export const outputFileOf = (text: string | undefined): string | undefined =>
48 text?.match(/Output is being written to: (\S+\.output)/)?.[1]
49
50export const tailOf = (text: string, keep: number): string[] =>
51 text.replace(/\r/g, '').split('\n').filter(line => line.trim() !== '').slice(-keep)
52
53export const secondsOf = (elapsed: string): number =>
54 elapsed.startsWith('<') ? 0 : Number(elapsed.match(/(\d+)h/)?.[1] ?? 0) * 3600 + Number(elapsed.match(/(\d+)m/)?.[1] ?? 0) * 60
55
56export const durationOf = (seconds: number): string => {
57 const s = Math.max(0, Math.round(seconds))
58 const h = Math.floor(s / 3600)
59 const m = Math.floor((s % 3600) / 60)
60 const rest = String(s % 60).padStart(2, '0')
61 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
62
63 return m > 0 ? `${m}m${rest}s` : `${s}s`
64}
65
66export const rowOf = (line: string): Row | undefined => {
67 const match = line.match(ROW)
68 if (match === null) return undefined
69 // Cells after EVENT: PROGRESS, ELAPSED, LEFT (may be blank), DETAIL.
70 const cells = match[5].trim().split(/\s{2,}/).filter(Boolean).slice(1)
71 const durations = cells.filter(cell => DURATION.test(cell))
72
73 return {
74 k: Number(match[1]),
75 n: Number(match[2]),
76 name: match[3].trim(),
77 event: match[4],
78 elapsed: durations[0] === undefined ? undefined : secondsOf(durations[0]),
79 detail: cells.filter(cell => !DURATION.test(cell)).join(' '),
80 }
81}
82
83export const stepOf = (lines: string[]): Step => {
84 const step: Step = { phase: undefined, event: '', detail: '', elapsed: undefined, done: [], outcome: 'running', message: '' }
85 for (const line of lines) {
86 const row = rowOf(line)
87 if (row !== undefined) {
88 if (row.event === 'start') step.elapsed = row.elapsed
89 step.phase = { k: row.k, n: row.n, name: row.name }
90 step.event = row.event
91 step.detail = row.detail
92 if (row.event === 'done') {
93 step.done.push(row.k)
94 if (row.k === row.n) step.outcome = 'complete'
95 }
96 } else if (line.startsWith('⏸')) {
97 step.outcome = 'paused'
98 step.message = line
99 } else if (line.startsWith('✗')) {
100 step.outcome = 'failed'
101 step.message = line.replace(/^✗\s*/, '')
102 }
103 }
104
105 return step
106}
107
108export const phaseKeyOf = (step: Step): string | undefined =>
109 step.phase === undefined ? undefined : `${step.phase.k}/${step.phase.n}`
110
111export const toastOf = (before: Step, after: Step, phaseSeconds: number, totalSeconds: number): string | undefined => {
112 if (before.outcome !== 'running') return undefined
113 if (after.outcome === 'failed') return `✗ Threat model: ${after.message}`
114 if (after.outcome === 'complete') return `✓ Threat model complete (${durationOf(totalSeconds)})`
115 const phase = before.phase
116 if (phase === undefined || before.done.includes(phase.k) || !after.done.includes(phase.k)) return undefined
117 if (phaseSeconds < REPLAY_S) return undefined
118
119 return `✓ Phase ${phase.k}/${phase.n} ${phase.name} done (${durationOf(phaseSeconds)})`
120}
121
122// `.appsec-lock`: pid, heartbeat (epoch seconds), run id.
123export const lockOf = (text: string): { heartbeat: number; runId: string; staleAfter: number } | undefined => {
124 const lines = text.split('\n').map(line => line.trim())
125 const heartbeat = lines[1]
126 if (heartbeat === undefined || !/^\d+$/.test(heartbeat)) return undefined
127 const runId = lines[2] !== undefined && !lines[2].includes('=') ? lines[2] : ''
128 const staleAfter = Number(lines.find(line => line.startsWith('stale_after='))?.slice('stale_after='.length))
129
130 return { heartbeat: Number(heartbeat), runId, staleAfter: staleAfter > 0 ? staleAfter : LIVE_STALE_S }
131}
132
133export const roleOf = (name: string): string => name.replace(/^\d+-/, '').replace(/\.jsonl$/, '')
134
135// A host-events line is `<seconds>\t<stream-json event>`.
136export const eventOf = (line: string): { activity: string; retrying: boolean } | undefined => {
137 let event: Record<string, unknown>
138 try {
139 event = JSON.parse(line.slice(line.indexOf('\t') + 1))
140 } catch {
141 return undefined
142 }
143 if (event.subtype === 'api_retry') {
144 const error = event.error_status ?? event.error
145 const why = error === null || error === undefined ? '' : ` (${String(error)})`
146
147 return { activity: `API retry ${String(event.attempt)}/${String(event.max_retries)}${why}`, retrying: true }
148 }
149 if (event.type === 'assistant') return { activity: 'answering', retrying: false }
150 if (event.type === 'result') return { activity: 'answered', retrying: false }
151
152 return { activity: 'waiting for the model', retrying: false }
153}
154
155const shortRun = (runId: string): string => runId.replace(/^assessment-/, '').slice(0, 7)
156
157// A killed controller never writes its final status; with its lock gone stale a
158// state that still says `running` describes a run that died.
159export const endedOf = (runId: string, status: string): string =>
160 `Threat model run ${shortRun(runId)} ended${status === '' ? '' : `: ${status === 'running' ? 'interrupted (process gone)' : status}`}`
161
162const pollLive = async ($: EngineInterface, t: number): Promise<void> => {
163 const dir = `${await $.session.root()}/${REPORT_DIR}`
164 const text = await $.fs.read(`${dir}/.appsec-lock`).catch(() => undefined)
165 const lock = text === undefined ? undefined : lockOf(text)
166 const before = await read($, live)
167 if (lock === undefined || t / 1000 - lock.heartbeat > lock.staleAfter) {
168 if (before === null) return
169 await update($, live, () => null)
170 const state = await $.fs.read(`${dir}/.assessment-state.json`).catch(() => undefined)
171 const status = state === undefined ? '' : String(JSON.parse(state).status ?? '')
172 $.ui.toast(endedOf(before.runId, status))
173 return
174 }
175 const state = await $.fs.read(`${dir}/.assessment-state.json`).catch(() => undefined)
176 const startedAt = state === undefined ? lock.heartbeat : Number(JSON.parse(state).started_at ?? lock.heartbeat)
177 const entries = await $.fs.list(`${dir}/.assessment-work/host-events`).catch(() => [])
178 const newest = entries
179 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
180 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
181 let role: string | undefined
182 let activity = ''
183 let retrying = false
184 // Host events of an earlier run stay in the folder; only this run's count.
185 if (newest !== undefined && newest.mtimeMs / 1000 >= startedAt) {
186 role = roleOf(newest.name)
187 const events = await $.fs.read(`${dir}/.assessment-work/host-events/${newest.name}`).catch(() => '')
188 const last = eventOf(tailOf(events, 1)[0] ?? '')
189 activity = last?.activity ?? ''
190 retrying = last?.retrying ?? false
191 }
192 await update($, live, () => ({
193 runId: lock.runId,
194 startedAt,
195 heartbeat: lock.heartbeat,
196 role,
197 activity,
198 retrying,
199 eventAt: newest?.mtimeMs ?? 0,
200 }))
201}
202
203export const register: Register = on => {
204 on('session.start', async ($, e, next) => {
205 await $.command.register({
206 name: 'threat-model-progress',
207 description: 'Show the running create-threat-model2 step in a pane',
208 })
209 $.clock.every(POLL_MS, async () => {
210 const t = await $.clock.now()
211 await pollLive($, t).catch(() => undefined)
212 const { file, lines } = await read($, progress)
213 const before = stepOf(lines)
214 if ((file !== undefined && before.outcome === 'running') || (await read($, live)) !== null) {
215 await update($, now, () => t)
216 }
217 if (file === undefined) return
218 const text = await $.fs.read(file).catch(() => undefined)
219 if (text === undefined) return
220 const tail = tailOf(text, KEEP)
221 if (tail.length === lines.length && tail.at(-1) === lines.at(-1)) return
222 const after = stepOf(tail)
223 const b = await read($, band)
224 const toast = toastOf(before, after, (t - b.seenAt) / 1000, b.runBase + (t - b.seenAt) / 1000)
225 if (toast !== undefined) $.ui.toast(toast)
226 await update($, progress, p => (p.file === file ? { file, lines: tail } : p))
227 const phase = phaseKeyOf(after)
228 if (phase !== b.phase) {
229 await update($, band, x => (x.file === file ? { ...x, phase, seenAt: t, runBase: after.elapsed ?? x.runBase } : x))
230 }
231 })
232
233 return next(e)
234 })
235
236 on('command.run', { command: 'threat-model-progress' }, async $ => {
237 await $.ui.open({ id: PANE, title: TITLE })
238 const { file } = await read($, progress)
239
240 return { text: file === undefined ? 'No assessment step seen yet in this session.' : `Showing ${file}` }
241 })
242
243 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
244 const ran = await next(e)
245 if (!e.run_in_background || !isStepCommand(e.command)) return ran
246 const file = outputFileOf(ran.text)
247 if (file === undefined) return ran
248 const t = await $.clock.now()
249 await update($, progress, () => ({ file, lines: [] }))
250 await update($, band, () => ({ ...NO_BAND, file, seenAt: t }))
251 void $.ui.open({ id: PANE, title: TITLE })
252
253 return ran
254 })
255
256 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
257 if (e.props.hasSurvey) return next(e)
258 const { file, lines } = await read($, progress)
259 const b = await read($, band)
260 const run = await read($, live)
261 const { Box, Button, Text } = $.ui.resolve(e)
262 const activity =
263 run === null || run.activity === ''
264 ? undefined
265 : `${run.role === undefined ? '' : `${run.role}: `}${run.activity}`
266 if (file === undefined || b.file !== file || b.dismissed) {
267 // A run this session did not launch: lock, state and host events only.
268 if (run === null) return next(e)
269 const t = await read($, now)
270
271 return (
272 <Text wrap="truncate-end">
273 <Text color="blue">▶ Threat model</Text>
274 <Text> · run {shortRun(run.runId)} · {durationOf(t / 1000 - run.startedAt)}</Text>
275 {activity !== undefined && <Text color={run.retrying ? 'yellow' : undefined} dimColor={!run.retrying}> · {activity}</Text>}
276 </Text>
277 )
278 }
279 const step = stepOf(lines)
280 const phaseS = ((await read($, now)) - b.seenAt) / 1000
281 const total = durationOf(b.runBase + phaseS)
282 const phase = step.phase === undefined ? 'starting' : `${step.phase.k}/${step.phase.n} ${step.phase.name}`
283 const dismiss = <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => update($, band, x => ({ ...x, dismissed: true }))} />
284
285 if (step.outcome === 'failed') {
286 return (
287 <Box>
288 <Text color="red" wrap="truncate-end">✗ Threat model · {phase} failed · {step.message} </Text>
289 {dismiss}
290 </Box>
291 )
292 }
293 if (step.outcome === 'complete') {
294 return (
295 <Box>
296 <Text color="green">✓ Threat model complete · {total} total </Text>
297 {dismiss}
298 </Box>
299 )
300 }
301 if (step.outcome === 'paused') {
302 return <Text dimColor>⏸ Threat model · paused after {phase} · {total} total</Text>
303 }
304 const warn = run?.retrying === true || (activity === undefined && (step.event === '⚠' || step.event === '↻'))
305 const detail = activity ?? step.detail
306
307 return (
308 <Text wrap="truncate-end">
309 <Text color="blue">▶ Threat model</Text>
310 <Text> · {phase} · {durationOf(phaseS)} in phase · {total} total</Text>
311 {detail !== '' && <Text color={warn ? 'yellow' : undefined} dimColor={!warn}> · {detail}</Text>}
312 </Text>
313 )
314 })
315
316 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
317 const { Box, Text } = $.ui.resolve(e)
318 const { file, lines } = await read($, progress)
319 const room = Math.max(1, (e.viewport?.rows ?? 24) - 3)
320
321 return (
322 <Box flexDirection="column">
323 {file === undefined && <Text dimColor>No assessment step running.</Text>}
324 {file !== undefined && lines.length === 0 && <Text dimColor>Waiting for output…</Text>}
325 {lines.slice(-room).map(line => (
326 <Text color={colorOf(line)} wrap="truncate-end">
327 {line}
328 </Text>
329 ))}
330 </Box>
331 )
332 })
333}
334
335const colorOf = (line: string): 'red' | 'green' | 'yellow' | undefined => {
336 if (/^✗|Traceback|interrupted/.test(line)) return 'red'
337 if (/^⏸|\bdone\b/.test(line)) return 'green'
338 if (/⚠|↻/.test(line)) return 'yellow'
339 return undefined
340}
341types/progress.d.ts 30 lines1export type Progress = { file: string | undefined; lines: string[] }
2
3// The current phase of the watched file, when it was first seen and the run's
4// elapsed seconds its start row reported.
5export type Band = {
6 file: string | undefined
7 phase: string | undefined
8 seenAt: number
9 runBase: number
10 dismissed: boolean
11}
12
13// A run that holds docs/security in the session's project, whoever launched it:
14// its lock heartbeat and the newest model call's last host event.
15export type Live = {
16 runId: string
17 startedAt: number
18 heartbeat: number
19 role: string | undefined
20 activity: string
21 retrying: boolean
22 eventAt: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'appsec-advisor': { progress: Progress; band: Band; now: number; live: Live | null }
28 }
29}
30