Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,80 @@ jobs:
grep -q 'NOT INDEPENDENTLY REVIEWED' Examples/ledgerline/SUMMARY.html
# and it must link the full analysis rather than stand in for it
grep -q 'REPORT.html' Examples/ledgerline/SUMMARY.html

# The runtime lane that was missing. PR #9 claimed "runs on Node or Bun" and
# shipped broken on Node for four weeks, because every job above installs Bun and
# invokes the tools as `bun Tools/X.ts` — nothing ever ran `./stpa <verb>` past
# --help, and nothing ever ran Node at all.
#
# Note what the last step reproduces, because a clean checkout does NOT reproduce
# the bug: Node's module-syntax detection resolves these .ts tools as ESM just
# fine when nothing overrides it. The failure needs an ANCESTOR directory whose
# package.json says {"type":"commonjs"} — which is exactly where this repo gets
# installed, since `~/.claude/package.json` declares commonjs and skills clone
# into `~/.claude/skills/`. An ancestor declaration beats detection, so every
# import in Tools/ became a SyntaxError for anyone who installed it as a skill.
# A gate that only runs in a clean directory would have passed the whole time.
node:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}

- name: CLI loads under Node
run: chmod +x stpa && ./stpa --help

- name: Grid arithmetic under Node
run: |
node Tools/UcaGrid.ts init Fixtures/example-model.json -o /tmp/grid.json
node Tools/UcaGrid.ts status /tmp/grid.json

- name: Full pipeline end-to-end through the CLI
run: |
# `stpa run` exits non-zero by design on the worked example (it carries no
# independent peer review), so the gate is the artifacts, not the exit code.
cp -R Examples/ledgerline /tmp/run && rm -f /tmp/run/*.html
./stpa run /tmp/run 2>&1 | tee /tmp/runlog || true
! grep -qE 'SyntaxError|ReferenceError|ERR_MODULE_NOT_FOUND' /tmp/runlog
test -s /tmp/run/REPORT.html
test -s /tmp/run/SUMMARY.html

- name: Survives a CommonJS ancestor — the real install condition
run: |
set -e
# stage the repo under a parent that declares commonjs, as `~/.claude` does
mkdir -p /tmp/ancestor/skills
echo '{"name":"ancestor","type":"commonjs"}' > /tmp/ancestor/package.json
cp -R "$GITHUB_WORKSPACE" /tmp/ancestor/skills/stpa
cd /tmp/ancestor/skills/stpa
# our own package.json must shadow the ancestor for every tool
for f in Tools/*.ts; do
out=$(node "$f" --help 2>&1 || true)
case "$out" in
*"Cannot use import statement outside a module"*|*"require is not defined"*|*"ERR_MODULE_NOT_FOUND"*)
echo "::error file=$f::ancestor commonjs defeated module resolution"; echo "$out"; exit 1;;
esac
done
cp -R Examples/ledgerline /tmp/anc-run && rm -f /tmp/anc-run/*.html
./stpa run /tmp/anc-run 2>&1 | tee /tmp/anc-log || true
! grep -qE 'SyntaxError|ReferenceError|ERR_MODULE_NOT_FOUND' /tmp/anc-log
test -s /tmp/anc-run/REPORT.html
test -s /tmp/anc-run/SUMMARY.html

- name: The gate can fail — remove package.json and the ancestor wins
run: |
set -e
# A gate that cannot fail is decoration. Prove this one bites.
cd /tmp/ancestor/skills/stpa && rm -f package.json
if node Tools/RenderReport.ts --help 2>&1 | grep -q "Cannot use import statement outside a module"; then
echo "ok — without package.json the ancestor forces CommonJS and the tools break, as expected"
else
echo "::error::negative control did not reproduce; the ancestor step above proves nothing"
exit 1
fi
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ whichever runtime is executing it, so `./stpa` uses Node (≥22.6; types are str
natively from 22.18, and the flag is passed automatically below that) and `bun stpa` uses
Bun. There are no Bun-specific APIs anywhere in the toolkit.

The repo ships a dependency-free `package.json` whose only job is `"type": "module"`.
**Do not delete it.** Node's own module-syntax detection handles these files fine in
isolation — the declaration is there because an *ancestor* directory can override
detection, and the most common install location does exactly that: `~/.claude/package.json`
declares `{"type":"commonjs"}`, and skills clone into `~/.claude/skills/`. Under that
ancestor, and without this file, every `import` in `Tools/` is a `SyntaxError` and nothing
but `stpa --help` runs. CI reproduces that ancestor on Node 22 and 24 — and asserts the
check still fails when `package.json` is removed, because a gate that cannot fail is
decoration.

Everything is offline either way: no API keys, no network calls, no telemetry. Nothing is
downloaded at install or at analysis time.

Expand Down
2 changes: 1 addition & 1 deletion Tools/ComposeChains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
* id:tenant-key a tenant identifier used as a scoping or lookup key
*
* Usage:
* bun ComposeChains.ts [analysis-dir] [--check] [--max-depth N]
* stpa compose [analysis-dir] [--check] [--max-depth N]
*
* Reads remediation.json (+ grid.json for statements/labels)
* Writes 07-chains.json, 07-chains.md
Expand Down
4 changes: 2 additions & 2 deletions Tools/ControlInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* look, which is the actual failure mode.
*
* Usage:
* bun ControlInventory.ts <analysis-dir> [--warn-only]
* stpa controls <analysis-dir> [--warn-only]
*
* Reads candidates.json (from `stpa scan --json`), grid.json, remediation.json
* Writes control-inventory.json
Expand All @@ -43,7 +43,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
[
"ControlInventory.ts — cross-check absence claims against the guards the scan found",
"",
"Usage: bun ControlInventory.ts <analysis-dir> [--warn-only]",
"Usage: stpa controls <analysis-dir> [--warn-only]",
"",
"Requires candidates.json in the analysis dir:",
" stpa scan <repo> --depth deep --json > <dir>/candidates.json",
Expand Down
4 changes: 2 additions & 2 deletions Tools/ControlStructureScan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
* and unknown stacks still get the generic sweep.
*
* Usage:
* bun ControlStructureScan.ts <repo-path> [--json] [--max-per-category N] [--include-tests]
* stpa scan <repo-path> [--json] [--max-per-category N] [--include-tests]
*
* Default output is a human-readable report; --json emits the structured candidate
* set that feeds the ModelControlStructure workflow.
Expand Down Expand Up @@ -305,7 +305,7 @@ if (!root) {
[
"ControlStructureScan.ts — candidate extraction for STPA Step 2",
"",
"Usage: bun ControlStructureScan.ts <repo-path> [options]",
"Usage: stpa scan <repo-path> [options]",
"",
" --focus <lenses> comma-separated; only patterns carrying these tags",
" e.g. --focus authz,tenancy or --focus api",
Expand Down
2 changes: 1 addition & 1 deletion Tools/DiscoveryGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* registries, and dynamic execution primitives.
*
* Usage:
* bun DiscoveryGate.ts <repo> [analysis-dir] [--json] [--check]
* stpa discover <repo> [analysis-dir] [--json] [--check]
*
* Reads <repo> source, and <analysis-dir>/discovery.json if present
* Writes <analysis-dir>/discovery.json (a template, when absent)
Expand Down
4 changes: 2 additions & 2 deletions Tools/EvidenceGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
* disagreement.
*
* Usage:
* bun EvidenceGate.ts <analysis-dir> [--warn-only] [--fix-numbers]
* stpa evidence <analysis-dir> [--warn-only] [--fix-numbers]
*
* Exit: 0 pass · 2 bad input · 9 unresolved trust root or a wrong number in prose
*/
Expand All @@ -51,7 +51,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
[
"EvidenceGate.ts — trust-root provenance + derived-number consistency",
"",
"Usage: bun EvidenceGate.ts <analysis-dir> [--warn-only] [--fix-numbers]",
"Usage: stpa evidence <analysis-dir> [--warn-only] [--fix-numbers]",
"",
"Every processModels[].variables[] entry needs trustRoot ∈",
" " + [...ROOTS].join(" | "),
Expand Down
6 changes: 3 additions & 3 deletions Tools/MergePlanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
* of what showed up.
*
* Usage:
* bun MergePlanes.ts <analysis-dir> --expect <manifest.json> # merge + gate
* bun MergePlanes.ts <analysis-dir> --expect <manifest.json> --check
* stpa merge <analysis-dir> --expect <manifest.json> # merge + gate
* stpa merge <analysis-dir> --expect <manifest.json> --check
*
* manifest.json — written BEFORE dispatch, from the control-action inventory:
* { "planes": { "auth": { "file": "planes/auth.json",
Expand Down Expand Up @@ -91,7 +91,7 @@ if (argv.includes("--help") || argv.includes("-h"))
[
"MergePlanes.ts — reconciliation gate for parallel STPA analysis",
"",
"Usage: bun MergePlanes.ts <analysis-dir> --expect <manifest.json> [--check]",
"Usage: stpa merge <analysis-dir> --expect <manifest.json> [--check]",
"",
"Refuses to merge until every expected plane file exists and every expected",
"cell is present or explicitly declared incomplete. Exit 4 = gap detected.",
Expand Down
6 changes: 3 additions & 3 deletions Tools/Prioritize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
* CVSS-comparable. It orders THIS analysis's findings for THIS team.
*
* Usage:
* bun Prioritize.ts [analysis-dir] # writes 06-remediation.{json,md}
* bun Prioritize.ts [dir] --check # exit 1 if any finding lacks remediation
* stpa plan [analysis-dir] # writes 06-remediation.{json,md}
* stpa plan [dir] --check # exit 1 if any finding lacks remediation
*/

import { readFileSync, writeFileSync, existsSync } from "node:fs";
Expand Down Expand Up @@ -98,7 +98,7 @@ function die(m: string, c = 1): never {

const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h"))
die("Usage: bun Prioritize.ts [analysis-dir] [--check]\n\nReads grid.json + remediation.json, writes 06-remediation.{json,md}.", 2);
die("Usage: stpa plan [analysis-dir] [--check]\n\nReads grid.json + remediation.json, writes 06-remediation.{json,md}.", 2);

const dir = resolve(argv.find((a) => !a.startsWith("-")) ?? ".stpa");
const checkOnly = argv.includes("--check");
Expand Down
4 changes: 2 additions & 2 deletions Tools/RenderReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* whose integrity the rest of the skill works to protect.
*
* Usage:
* bun RenderReport.ts [analysis-dir] [-o out.html] [--title "..."]
* stpa report [analysis-dir] [-o out.html] [--title "..."]
*
* Inputs (all optional except grid.json — missing sections are simply omitted):
* model.json 01-scope.md 02-control-structure.md grid.json
Expand Down Expand Up @@ -241,7 +241,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
[
"RenderReport.ts — self-contained HTML report for an STPA analysis",
"",
"Usage: bun RenderReport.ts [analysis-dir] [-o out.html] [--title \"...\"]",
"Usage: stpa report [analysis-dir] [-o out.html] [--title \"...\"]",
"",
"Reads grid.json (required) plus model.json and any 0*.md artifacts present.",
].join("\n"),
Expand Down
4 changes: 2 additions & 2 deletions Tools/RenderSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* Every number here is computed from the artifacts. None is typed by hand — the
* evidence gate exists because a stale hand-typed count reached a deliverable once.
*
* Usage: RenderSummary.ts [analysis-dir] [-o out.html] [--title "..."]
* Usage: stpa summary [analysis-dir] [-o out.html] [--title "..."]
* Reads grid.json (required), plus 06-remediation.json, 01-scope.md, model.json and
* review-scorecard.json when present. Writes <dir>/SUMMARY.html.
*/
Expand Down Expand Up @@ -57,7 +57,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
[
"RenderSummary.ts — one-page executive summary for an STPA analysis",
"",
"Usage: RenderSummary.ts [analysis-dir] [-o out.html] [--title \"...\"]",
"Usage: stpa summary [analysis-dir] [-o out.html] [--title \"...\"]",
"",
"Reads grid.json (required) plus 06-remediation.json, 01-scope.md, model.json",
"and review-scorecard.json when present. Writes <dir>/SUMMARY.html.",
Expand Down
2 changes: 1 addition & 1 deletion Tools/ReportLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
* and print the one command that resolves it.
*
* Usage:
* bun ReportLink.ts [analysis-dir] [--copy-to <dir>] [--quiet]
* stpa link [analysis-dir] [--copy-to <dir>] [--quiet]
*
* Env:
* STPA_HOST_MAP comma-separated container=host prefix pairs, e.g.
Expand Down
4 changes: 2 additions & 2 deletions Tools/ScopeGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* cannot manufacture a clean percentage.
*
* Usage:
* bun ScopeGate.ts <analysis-dir> [--inventory N] [--warn-only]
* stpa scope <analysis-dir> [--inventory N] [--warn-only]
*/

import { existsSync, readFileSync } from "node:fs";
Expand All @@ -48,7 +48,7 @@ if (argv.includes("--help") || argv.includes("-h"))
[
"ScopeGate.ts — hold the analysis to the scope that was requested",
"",
"Usage: bun ScopeGate.ts <analysis-dir> [--inventory N] [--warn-only]",
"Usage: stpa scope <analysis-dir> [--inventory N] [--warn-only]",
"",
" --inventory N the target's real entry-point count, to sanity-check the",
" candidate denominator (e.g. number of API route files)",
Expand Down
20 changes: 11 additions & 9 deletions Tools/UcaGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
* cells. An unresolved cell is a hole in the analysis, and now you can count them.
*
* Usage:
* bun UcaGrid.ts init <model.json> [-o grid.json] # generate the empty grid
* bun UcaGrid.ts init <model.json> --merge <old-grid.json> -o grid.json
* stpa init <model.json> [-o grid.json] # generate the empty grid
* stpa init <model.json> --merge <old-grid.json> -o grid.json
* # re-analysis: carry resolved
* # cells forward, list only new ones
* bun UcaGrid.ts status <grid.json> # coverage report
* bun UcaGrid.ts markdown <grid.json> [-o grid.md] # analyst-facing checklist
* stpa status <grid.json> # coverage report
* stpa grid <grid.json> [-o grid.md] # analyst-facing checklist
*
* Input model.json shape (produced by ModelControlStructure workflow):
* {
Expand Down Expand Up @@ -53,6 +53,8 @@
* Coverage = (bound findings + reasoned tombstones) / total cells.
*/

import { readFileSync, writeFileSync } from "node:fs";

const UCA_TYPES = [
{ key: "not-provided", label: "Not providing causes hazard" },
{ key: "provided", label: "Providing causes hazard" },
Expand Down Expand Up @@ -133,7 +135,7 @@ function die(msg: string, code = 1): never {
/** Write through die() rather than letting a bad path dump a raw stack trace. */
function writeOut(path: string, content: string, note: string): void {
try {
require("node:fs").writeFileSync(path, content);
writeFileSync(path, content);
} catch (e) {
die(`cannot write ${path}: ${(e as Error).message}`);
}
Expand All @@ -146,9 +148,9 @@ function usage(): never {
"UcaGrid.ts — STPA Step 3 coverage grid",
"",
"Usage:",
" bun UcaGrid.ts init <model.json> [--merge <old-grid.json>] [-o <grid.json>]",
" bun UcaGrid.ts status <grid.json>",
" bun UcaGrid.ts markdown <grid.json> [-o <grid.md>]",
" stpa init <model.json> [--merge <old-grid.json>] [-o <grid.json>]",
" stpa status <grid.json>",
" stpa grid <grid.json> [-o <grid.md>]",
"",
"Cell states: open | uca | tombstone (tombstone requires a `reason`).",
"Coverage = (BOUND findings + reasoned tombstones) / totalCells.",
Expand All @@ -161,7 +163,7 @@ function usage(): never {
function readJson(path: string): unknown {
let text: string;
try {
text = require("node:fs").readFileSync(path, "utf8");
text = readFileSync(path, "utf8");
} catch {
die(`cannot read: ${path}`);
}
Expand Down
4 changes: 2 additions & 2 deletions Tools/VerifyGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* and refuses to pass if it is missing, self-reviewed, or incomplete.
*
* Usage:
* bun VerifyGate.ts <analysis-dir> [--warn-only]
* stpa verify <analysis-dir> [--warn-only]
*
* reviews.json (written by the adversarial-review pass):
* {
Expand Down Expand Up @@ -47,7 +47,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
[
"VerifyGate.ts — adversarial peer-review gate",
"",
"Usage: bun VerifyGate.ts <analysis-dir> [--warn-only]",
"Usage: stpa verify <analysis-dir> [--warn-only]",
"",
"Refuses to certify an analysis unless every UCA finding was reviewed by an",
"INDEPENDENT model and every confirmed-live finding names a deployed path.",
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "stpa",
"private": true,
"type": "module",
"description": "Control-theoretic threat modeling (STPA / STPA-Sec) for codebases and design documents. Zero dependencies. This file exists ONLY for the \"type\" field: Node's module-syntax detection reads these ESM tools correctly on its own, but an ancestor directory declaring {\"type\":\"commonjs\"} overrides detection — and the usual install path is ~/.claude/skills/, under a ~/.claude/package.json that declares exactly that. Without this file, every import in Tools/ is a SyntaxError there. Do not delete it."
}
13 changes: 9 additions & 4 deletions stpa
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@
* Everything is offline. No API keys, no network, no telemetry.
*/

const { spawnSync } = require("node:child_process");
const { existsSync, mkdirSync, writeFileSync } = require("node:fs");
const { join, resolve } = require("node:path");
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const HERE = __dirname;
// package.json declares "type": "module", so this file is ESM under both runtimes
// and __dirname does not exist. fileURLToPath is used rather than the newer
// import.meta.dirname so the launcher keeps working on the whole Node range the
// RUNTIME block below claims to support.
const HERE = dirname(fileURLToPath(import.meta.url));
const TOOLS = join(HERE, "tools");
const LEGACY_TOOLS = join(HERE, "Tools"); // tolerate either casing
const toolDir = existsSync(TOOLS) ? TOOLS : LEGACY_TOOLS;
Expand Down
Loading