@bugb-technologies/bravos 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +121 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
@@ -0,0 +1,209 @@
1
+ // @comment -- "The pre-PR gate behind bravos's opt-in PreToolUse hook, matching bravos-ship's rulings: recognises gh pr create / gh pr ready in a Bash command, runs guardlink validate, lint --since <base> --json, diff <base> --json (each new unmitigated exposure must be disclosed in the PR body by threat and asset id) and ci -f json (a confirmed claim on a changed file blocks unless that file carries a @ticket for it), and returns allow or block with a reason. A gate input that fails or does not parse blocks as unknown. Read-only: every command is spawned with an argv array, no shell, a per-spawn timeout and one shared deadline."
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+
6
+ const PR_COMMAND = /(?:^|[\s;&|(])gh\s+pr\s+(create|ready)(?![\w-])/;
7
+ const READY_ARG = /(?:^|[\s;&|(])gh\s+pr\s+ready\s+([^\s;&|)-][^\s;&|)]*)/;
8
+ const BODY_FILE = /(?:--body-file|-F)[=\s]+("([^"]+)"|'([^']+)'|(\S+))/;
9
+ // @comment -- "The hook is registered with a 180 s timeout; the whole gate shares 150 s so it always answers before the harness kills it."
10
+ export const GATE_DEADLINE_MS = 150000;
11
+ // @comment -- "The person's recorded proving preference (see autopilot.mjs readProving); a branch that changes it must disclose that in the PR body."
12
+ export const PROVING_FILE = '.guardlink/proving.md';
13
+ const SPAWN_CAP_MS = 60000;
14
+
15
+ /** Which gh pr action a shell command runs, or null when it runs neither. */
16
+ export function prAction(command) {
17
+ if (typeof command !== 'string') return null;
18
+ const m = PR_COMMAND.exec(command);
19
+ return m ? m[1] : null;
20
+ }
21
+
22
+ /** The PR number, URL or branch `gh pr ready <arg>` names, or null for the current branch's PR. */
23
+ export function readyTarget(command) {
24
+ const m = READY_ARG.exec(command ?? '');
25
+ return m ? m[1].replace(/^["']|["']$/g, '') : null;
26
+ }
27
+
28
+ /**
29
+ * The PR body text a `gh pr create` command carries: the command string itself (an inline --body or a
30
+ * heredoc is part of it) plus the file a --body-file / -F names, resolved under the project root.
31
+ */
32
+ // @comment -- "Reads the PR body from the command string (inline --body or heredoc) and from the --body-file / -F path it names, resolved under the project root; an unreadable file adds nothing."
33
+ export function commandBody(command, root) {
34
+ let body = typeof command === 'string' ? command : '';
35
+ const m = BODY_FILE.exec(body);
36
+ if (!m) return body;
37
+ const rel = m[2] ?? m[3] ?? m[4];
38
+ if (rel === '-') return body;
39
+ const path = isAbsolute(rel) ? rel : join(root, rel);
40
+ try {
41
+ if (existsSync(path)) body += '\n' + readFileSync(path, 'utf8');
42
+ } catch {
43
+ // unreadable: the command text alone is the body
44
+ }
45
+ return body;
46
+ }
47
+
48
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+
50
+ /** Whether `text` names the threat-model id `id` as a whole token (`#api` is not found in `#api-key`). */
51
+ export function mentionsId(text, id) {
52
+ if (typeof id !== 'string' || id === '') return false;
53
+ return new RegExp(`(?<![\\w#.-])${escapeRe(id)}(?![\\w-])`).test(text);
54
+ }
55
+
56
+ /** Default runner: argv array, no shell, stdin closed, timeout capped at 60 s. */
57
+ export function spawnRunner(cmd, args, { cwd, env, timeoutMs = SPAWN_CAP_MS }) {
58
+ const r = spawnSync(cmd, args, { cwd, env, timeout: Math.min(SPAWN_CAP_MS, timeoutMs), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
59
+ if (r.error) return { missing: r.error.code === 'ENOENT', error: r.error.code ?? r.error.message, status: null, stdout: '', stderr: '' };
60
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
61
+ }
62
+
63
+ /** Wraps a runner so every spawn gets min(60 s, what is left of the shared deadline); past the deadline nothing is spawned. */
64
+ // @comment -- "Shared-deadline runner, as briefing.mjs collect() does: each spawn is capped individually and by the time left, and a spawn after the deadline returns a timed-out result instead of running."
65
+ export function withDeadline(run, deadlineMs = GATE_DEADLINE_MS, now = Date.now) {
66
+ const deadline = now() + deadlineMs;
67
+ return (cmd, args, opts) => {
68
+ const left = deadline - now();
69
+ if (left <= 0) return { status: null, error: 'ETIMEDOUT', stdout: '', stderr: '' };
70
+ return run(cmd, args, { ...opts, timeoutMs: Math.min(SPAWN_CAP_MS, left) });
71
+ };
72
+ }
73
+
74
+ const tail = (s, n = 40) => s.trim().split('\n').slice(-n).join('\n');
75
+ const why = (r) => (r.error === 'ETIMEDOUT' ? 'timed out' : r.error ? r.error : `exited ${r.status}`);
76
+
77
+ /** Runs a command and parses its stdout as JSON: `{ ok: true, json }` or `{ ok: false, error }` naming what failed. */
78
+ function runJson(run, label, cmd, args, opts, okStatus = [0]) {
79
+ const r = run(cmd, args, opts);
80
+ if (r.error || !okStatus.includes(r.status)) return { ok: false, error: `${label} ${why(r)}`, detail: tail((r.stdout ?? '') + (r.stderr ?? ''), 10) };
81
+ try {
82
+ return { ok: true, json: JSON.parse(r.stdout) };
83
+ } catch {
84
+ return { ok: false, error: `${label} output was not JSON`, detail: tail(r.stdout ?? '', 10) };
85
+ }
86
+ }
87
+
88
+ /** The repository root for a working directory: git's top level, else CLAUDE_PROJECT_DIR, else the directory itself. */
89
+ // @comment -- "guardlink reports paths relative to the directory it runs in and git diff --name-only reports them relative to the top level, so the gate must run everything from the top level: a payload cwd in a subdirectory otherwise empties the confirmed-on-changed-file intersection and the gate passes."
90
+ export function resolveRoot(cwd, env = process.env, run = spawnRunner) {
91
+ const top = run('git', ['rev-parse', '--show-toplevel'], { cwd, env });
92
+ if (top.status === 0 && top.stdout.trim()) return top.stdout.trim();
93
+ return env.CLAUDE_PROJECT_DIR || cwd;
94
+ }
95
+
96
+ /** The merge base the gate judges against, or null when it cannot be found. */
97
+ export function findBase(root, env, run = spawnRunner) {
98
+ const head = run('git', ['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, env });
99
+ const def = head.status === 0 && head.stdout.trim() ? head.stdout.trim().replace(/^origin\//, '') : 'main';
100
+ for (const ref of [`origin/${def}`, def]) {
101
+ const mb = run('git', ['merge-base', 'HEAD', ref], { cwd: root, env });
102
+ if (mb.status === 0 && mb.stdout.trim()) return mb.stdout.trim();
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** Whether `file` (repo-relative, under root) has a `@ticket` line naming both `threat` and `asset`. */
108
+ // @comment -- "The ship gate's carve-out for a confirmed claim: a @ticket line in the same file naming that threat and that asset. Who wrote it is bravos-ship's check (blame), not this hook's."
109
+ export function hasTicket(root, file, threat, asset) {
110
+ let text;
111
+ try {
112
+ text = readFileSync(isAbsolute(file) ? file : join(root, file), 'utf8');
113
+ } catch {
114
+ return false;
115
+ }
116
+ return text.split('\n').some((l) => l.includes('@ticket') && mentionsId(l, threat) && mentionsId(l, asset));
117
+ }
118
+
119
+ /**
120
+ * Decide whether a gh pr action may proceed. Returns { decision: 'allow' | 'block', reason }.
121
+ * Fails open (allow, with the reason) only when it cannot judge at all: guardlink absent or no merge base.
122
+ * Every other input that fails or does not parse blocks as "unknown".
123
+ */
124
+ // @comment -- "validate, lint, the disclosure check and the confirmed check gate create and ready; an @exposes plus @audit stays listed by design and is reported, so disclosure by id in the PR body is the only way past a new exposure. Every command runs with cwd root; a --body-file is resolved against cwd, where the gh command would run. On ready the body comes from gh pr view, and a failed read blocks as unknown. action 'check' (a manual run before any PR exists) has no body: it lists the new exposures to disclose and blocks only on lint, confirmed claims and unknown inputs."
125
+ export function runGate({ root, action, command, cwd = root, env = process.env, run: baseRun = spawnRunner, deadlineMs = GATE_DEADLINE_MS }) {
126
+ const run = withDeadline(baseRun, deadlineMs);
127
+ const o = { cwd: root, env };
128
+ const validate = run('guardlink', ['validate', '.'], o);
129
+ if (validate.missing) return { decision: 'allow', reason: 'bravos pre-PR gate: guardlink not on PATH, nothing checked' };
130
+ if (validate.error) return { decision: 'block', reason: `bravos pre-PR gate: unknown: guardlink validate ${why(validate)}` };
131
+ if (validate.status !== 0) {
132
+ return { decision: 'block', reason: `bravos pre-PR gate: guardlink validate failed. Fix what it reports, or run bravos-ship.\n${tail(validate.stdout + validate.stderr)}` };
133
+ }
134
+
135
+ const base = findBase(root, env, run);
136
+ if (!base) return { decision: 'allow', reason: 'bravos pre-PR gate: no merge base with the default branch found, lint, diff and ci not run' };
137
+
138
+ const blocks = [];
139
+ const notes = [];
140
+ const unknown = (r) => blocks.push(`unknown: ${r.error}${r.detail ? `\n${r.detail}` : ''}`);
141
+
142
+ // (a) lint errors on the branch's claims. lint exits non-zero when it reports errors, so its JSON decides.
143
+ const lint = runJson(run, `guardlink lint --since ${base}`, 'guardlink', ['lint', '.', '--since', base, '--json'], o, [0, 1]);
144
+ const errors = lint.ok ? lint.json?.summary?.errors : undefined;
145
+ if (!lint.ok) unknown(lint);
146
+ else if (typeof errors !== 'number') unknown({ error: `guardlink lint --since ${base} JSON has no summary.errors` });
147
+ else if (errors > 0) blocks.push(`guardlink lint --since ${base} reports ${errors} error(s). Apply each violation's fix to lines this branch wrote (bravos-ship), then retry.`);
148
+
149
+ // (b) each new unmitigated exposure is disclosed in the PR body by its threat and asset ids.
150
+ const diff = runJson(run, `guardlink diff ${base}`, 'guardlink', ['diff', base, '--json'], o);
151
+ const fresh = diff.ok ? diff.json?.newUnmitigatedExposures : undefined;
152
+ if (!diff.ok) unknown(diff);
153
+ else if (!Array.isArray(fresh)) unknown({ error: `guardlink diff ${base} JSON has no newUnmitigatedExposures` });
154
+ else if (fresh.length > 0) {
155
+ const list = (xs) => xs.map((e) => ` - ${e?.threat ?? '?'} on ${e?.asset ?? '?'} — ${e?.location?.file ?? '?'}:${e?.location?.line ?? '?'}`).join('\n');
156
+ let body = null;
157
+ if (action === 'create') body = commandBody(command, cwd);
158
+ else if (action === 'ready') {
159
+ const target = readyTarget(command);
160
+ const view = runJson(run, 'gh pr view', 'gh', ['pr', 'view', ...(target ? [target] : []), '--json', 'body'], o);
161
+ if (view.ok && typeof view.json?.body === 'string') body = view.json.body;
162
+ else unknown(view.ok ? { error: 'gh pr view JSON has no body' } : { error: 'gh pr view failed', detail: view.error });
163
+ } else {
164
+ notes.push(`bravos pre-PR gate: ${fresh.length} new unmitigated exposure(s) against ${base}, to disclose in the PR body by threat and asset id:\n${list(fresh)}`);
165
+ }
166
+ if (body !== null) {
167
+ const undisclosed = fresh.filter((e) => !(mentionsId(body, e?.threat) && mentionsId(body, e?.asset)));
168
+ if (undisclosed.length > 0) {
169
+ blocks.push(`this branch adds unmitigated exposure against ${base} that the PR body does not disclose. Name each threat and asset id in the PR body's Security gate section (bravos-ship writes it), then retry:\n${list(undisclosed)}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ // (c) a confirmed-exploitable claim on a changed file blocks unless that file carries a @ticket for it.
175
+ const ci = runJson(run, 'guardlink ci -f json', 'guardlink', ['ci', '-f', 'json'], o);
176
+ const confirmed = ci.ok ? ci.json?.confirmed : undefined;
177
+ if (!ci.ok) unknown(ci);
178
+ else if (!Array.isArray(confirmed)) unknown({ error: 'guardlink ci -f json has no confirmed[]' });
179
+ else if (confirmed.length > 0) {
180
+ const names = run('git', ['diff', '--name-only', `${base}...HEAD`], o);
181
+ if (names.error || names.status !== 0) unknown({ error: `git diff --name-only ${base}...HEAD ${why(names)}` });
182
+ else {
183
+ const changed = new Set(names.stdout.split('\n').map((s) => s.trim()).filter(Boolean));
184
+ const hits = confirmed.filter((c) => changed.has(c?.location?.file) && !hasTicket(root, c.location.file, c?.threat, c?.asset));
185
+ if (hits.length > 0) {
186
+ const list = hits.map((c) => ` - ${c?.severity ?? 'unrated'} ${c?.threat ?? '?'} on ${c?.asset ?? '?'} — ${c.location.file}:${c?.location?.line ?? '?'}`);
187
+ blocks.push(`confirmed-exploitable claims sit on files this branch changes, with no @ticket for them in that file. A code change does not clear a confirmation; do not ship (bravos-ship step 7):\n${list.join('\n')}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ // (d) a change to the recorded proving preference is a team decision: the PR body must say so.
193
+ const touched = run('git', ['diff', '--name-only', `${base}...HEAD`], o);
194
+ if (touched.status === 0 && touched.stdout.split('\n').some((l) => l.trim() === PROVING_FILE)) {
195
+ let body = null;
196
+ if (action === 'create') body = commandBody(command, cwd);
197
+ else if (action === 'ready') {
198
+ const view = runJson(run, 'gh pr view', 'gh', ['pr', 'view', ...(readyTarget(command) ? [readyTarget(command)] : []), '--json', 'body'], o);
199
+ body = view.ok && typeof view.json?.body === 'string' ? view.json.body : null;
200
+ if (body === null) unknown(view.ok ? { error: 'gh pr view JSON has no body' } : { error: 'gh pr view failed', detail: view.error });
201
+ }
202
+ const say = `this branch changes ${PROVING_FILE}, how the team wants findings proven; say "proving preference" in the PR body with what changed, so reviewers see it`;
203
+ if (action === 'check') notes.push(`bravos pre-PR gate: ${say}.`);
204
+ else if (body !== null && !/proving preference/i.test(body)) blocks.push(`${say}, then retry.`);
205
+ }
206
+
207
+ if (blocks.length > 0) return { decision: 'block', reason: ['bravos pre-PR gate: blocked.', ...blocks.map((b) => `- ${b}`), ...notes].join('\n') };
208
+ return { decision: 'allow', reason: notes.join('\n') };
209
+ }
@@ -0,0 +1,207 @@
1
+ // @comment -- "Reads bravos-sast's runs under .bravos/sast/<run>/ (bravos-cli analyze's bravos.analyze-report/v1 JSON plus bravos's meta.json): the newest run's counts, reach and staleness for the briefing, and its reachable findings grouped as candidates (joined to a GuardLink claim, not yet in the threat model, dependencies, secrets) for annotate, templatize, prove and recheck. Read-only; a missing or malformed report reads as null, never as zero findings. Dependency-free so it can be copied into target repos."
2
+ import { spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
4
+ import { delimiter, dirname, join } from 'node:path';
5
+ import { status as licenceStatus } from './licence.mjs';
6
+
7
+ export const REPORT_SCHEMA = 'bravos.analyze-report/v1';
8
+ export const CANDIDATES_SCHEMA = 'bravos.sast-candidates/v1';
9
+ const SAST_DIR = join('.bravos', 'sast');
10
+
11
+ const arr = (v) => (Array.isArray(v) ? v : []);
12
+
13
+ /** The report file inside a run folder: bravos-cli writes JSON next to the SARIF; the name varies by version. */
14
+ function reportFile(dir) {
15
+ for (const name of ['report.json', 'analysis.json', 'bravos-analyze.json']) if (existsSync(join(dir, name))) return join(dir, name);
16
+ const json = readdirSync(dir).filter((n) => n.endsWith('.json') && n !== 'meta.json' && !n.startsWith('candidates'));
17
+ return json.length ? join(dir, json.sort()[0]) : null;
18
+ }
19
+
20
+ // @comment -- "Finds the newest run folder under .bravos/sast/ (by meta.json time, else folder mtime) and returns its parsed report and meta, or null when there is none or it is not a bravos.analyze-report/v1."
21
+ export function latestRun(root) {
22
+ const base = join(root, SAST_DIR);
23
+ if (!existsSync(base)) return null;
24
+ const runs = [];
25
+ for (const name of readdirSync(base)) {
26
+ const dir = join(base, name);
27
+ try {
28
+ if (!statSync(dir).isDirectory()) continue;
29
+ } catch {
30
+ continue;
31
+ }
32
+ let meta = null;
33
+ try {
34
+ meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
35
+ } catch {
36
+ meta = null;
37
+ }
38
+ const at = Date.parse(meta?.at ?? '') || statSync(dir).mtimeMs;
39
+ runs.push({ name, dir, meta, at });
40
+ }
41
+ runs.sort((a, b) => b.at - a.at);
42
+ for (const run of runs) {
43
+ const file = reportFile(run.dir);
44
+ if (!file) continue;
45
+ try {
46
+ const report = JSON.parse(readFileSync(file, 'utf8'));
47
+ if (report?.schema !== REPORT_SCHEMA || !Array.isArray(report.findings)) continue;
48
+ return { ...run, file, report };
49
+ } catch {
50
+ /* unreadable: try the next run */
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ const isSecret = (f) => f?.finding_class === 'secret';
57
+ const isDep = (f) => f?.finding_class === 'dependency';
58
+ const joined = (f) => f?.guardlink_join && f.guardlink_join !== 'unjoined' && f.guardlink_join !== 'none';
59
+
60
+ // @comment -- "Summarizes the newest run for the briefing: findings by class and severity, reachable, reachable but not in the threat model, suppressed, and how many commits the report is behind HEAD (from meta.json's sha). Null when there is no usable report."
61
+ export function readSast(root, env = process.env) {
62
+ const run = latestRun(root);
63
+ if (!run) return null;
64
+ const findings = arr(run.report.findings);
65
+ const live = findings.filter((f) => !f?.suppressed);
66
+ const reachable = live.filter((f) => f?.reachable === true);
67
+ const byClass = {};
68
+ for (const f of live) byClass[f?.finding_class ?? 'static_analysis'] = (byClass[f?.finding_class ?? 'static_analysis'] ?? 0) + 1;
69
+ let staleCommits = null;
70
+ if (typeof run.meta?.sha === 'string' && /^[0-9a-f]{7,40}$/.test(run.meta.sha)) {
71
+ const r = spawnSync('git', ['rev-list', '--count', `${run.meta.sha}..HEAD`], { cwd: root, env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
72
+ if (r.status === 0) staleCommits = Number(r.stdout.trim());
73
+ }
74
+ return {
75
+ run: run.name,
76
+ sha: run.meta?.sha ?? null,
77
+ exit: run.meta?.exit ?? null,
78
+ total: live.length,
79
+ reachable: reachable.length,
80
+ unjoinedReachable: reachable.filter((f) => !joined(f) && !isSecret(f)).length,
81
+ suppressed: findings.length - live.length,
82
+ byClass,
83
+ staleCommits,
84
+ };
85
+ }
86
+
87
+ const where = (f) => ({
88
+ rule: f?.rule_id ?? null,
89
+ title: f?.title ?? null,
90
+ severity: f?.severity ?? null,
91
+ file: f?.file ?? null,
92
+ line: f?.line ?? null,
93
+ route: f?.http_method || f?.http_path ? `${f.http_method ?? ''} ${f.http_path ?? ''}`.trim() : null,
94
+ entryPoint: f?.entry_point ?? null,
95
+ pathLength: f?.path_length ?? null,
96
+ function: f?.enclosing_function ?? null,
97
+ });
98
+
99
+ // @comment -- "Groups the newest run's non-suppressed findings for the other skills: joined to a claim (taint evidence for that threat), reachable but unjoined (leads for bravos-annotate), dependencies (with vulnerable-function reach) and secrets (never pentest candidates; the value is never included). Only reachable findings unless all is set."
100
+ export function sastCandidates(root, { all = false } = {}) {
101
+ const run = latestRun(root);
102
+ if (!run) return null;
103
+ const live = arr(run.report.findings).filter((f) => !f?.suppressed && (all || f?.reachable === true || isSecret(f)));
104
+ const out = { schema: CANDIDATES_SCHEMA, run: run.name, sha: run.meta?.sha ?? null, joined: [], unjoined: [], dependencies: [], secrets: [] };
105
+ for (const f of live) {
106
+ if (isSecret(f)) out.secrets.push({ rule: f?.rule_id ?? null, file: f?.file ?? null, line: f?.line ?? null, inHistory: !!f?.secret_in_history });
107
+ else if (isDep(f)) out.dependencies.push({ ...where(f), manifests: arr(f?.dep_manifests), devOnly: !!f?.dep_dev_only, vulnerableFunctionReached: f?.vulnerable_function_reachability ?? null });
108
+ else if (joined(f)) out.joined.push({ ...where(f), claimState: f.guardlink_join, confirmed: !!f?.guardlink_confirmed, accepted: !!f?.guardlink_accepted });
109
+ else out.unjoined.push(where(f));
110
+ }
111
+ return out;
112
+ }
113
+
114
+ // @comment -- "The Python bravos CLI (siete) on PATH, as `bravos` or renamed to `bravos-py`, and never this package's own `bravos` bin: an npm shim is a script that execs node on dist/cli.js, and skipping it keeps the hooks pointed at the Python install's analyzer and graph until phase 3 makes this CLI carry them."
115
+ export function pythonBravos(env = process.env) {
116
+ // Its own name first, then bravos-py: the name it keeps once it is renamed so `bravos` means this CLI.
117
+ for (const name of ['bravos', 'bravos-py']) {
118
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
119
+ if (!dir) continue;
120
+ const file = join(dir, name);
121
+ try {
122
+ if (!statSync(file).isFile()) continue;
123
+ accessSync(file, constants.X_OK);
124
+ const real = realpathSync(file);
125
+ if (real.endsWith('dist/cli.js') || readFileSync(real, 'utf8').includes('dist/cli.js')) continue;
126
+ return file;
127
+ } catch {
128
+ /* not here */
129
+ }
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function onPath(name, env) {
136
+ for (const dir of (env?.PATH ?? '').split(delimiter)) {
137
+ if (!dir) continue;
138
+ try {
139
+ accessSync(join(dir, name), constants.X_OK);
140
+ return join(dir, name);
141
+ } catch {
142
+ /* keep looking */
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // @comment -- "Finds bravos-cli the way the bravos launcher puts it on its own PATH: on PATH first, else <install>/analyzer/bravos-cli beside the real path of the bravos launcher (<install>/bin/bravos). Read-only; null when neither has it."
149
+ export function findAnalyzer(env = process.env) {
150
+ // The bundle bravos setup staged wins; then PATH; then the Python install's own copy.
151
+ const home = env.BRAVOS_HOME || (env.HOME ? join(env.HOME, '.bravos') : null);
152
+ if (home) {
153
+ const stagedBin = join(home, 'analyzer', 'current', 'bravos-cli');
154
+ try {
155
+ accessSync(stagedBin, constants.X_OK);
156
+ return realpathSync(stagedBin);
157
+ } catch {
158
+ /* not staged */
159
+ }
160
+ }
161
+ const direct = onPath('bravos-cli', env);
162
+ if (direct) return direct;
163
+ const launcher = pythonBravos(env);
164
+ if (!launcher) return null;
165
+ try {
166
+ const file = join(dirname(dirname(realpathSync(launcher))), 'analyzer', 'bravos-cli');
167
+ accessSync(file, constants.X_OK);
168
+ return file;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ // @comment -- "Where a Bravos licence is, read-only: BRAVOS_LICENCE / BRAVOS_LICENSE, else licence.jwt or license.jwt in $BRAVOS_HOME, ~/.bravos, or ~/.bugb (where bravos still keeps its state until ~/.bravos holds some). Returns a short label for the source, or null."
175
+ export function findLicence(env = process.env) {
176
+ if (env.BRAVOS_LICENCE) return 'BRAVOS_LICENCE';
177
+ if (env.BRAVOS_LICENSE) return 'BRAVOS_LICENSE';
178
+ const homes = [env.BRAVOS_HOME, env.HOME && join(env.HOME, '.bravos'), env.HOME && join(env.HOME, '.bugb')].filter(Boolean);
179
+ for (const home of homes) {
180
+ for (const name of ['licence.jwt', 'license.jwt']) {
181
+ if (existsSync(join(home, name))) return home === env.BRAVOS_HOME ? `$BRAVOS_HOME/${name}` : `${home.replace(env.HOME, '~')}/${name}`;
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ // @comment -- "Whether SAST can run here at all, read-only: bravos-cli found (findAnalyzer) and a licence that verifies to a paid tier (licence.mjs). On success it names the analyzer's path, which a skill uses when bravos-cli is only inside the bravos install."
188
+ export function sastAvailable(env = process.env) {
189
+ const analyzer = findAnalyzer(env);
190
+ if (!analyzer) return { ok: false, missing: 'bravos-cli' };
191
+ // Presence is not enough: the licence must verify and be a paid tier that is not expired.
192
+ const v = licenceStatus(env);
193
+ if (!v.licensed) return { ok: false, missing: 'licence', analyzer, state: v.state };
194
+ return { ok: true, analyzer, licence: v.source };
195
+ }
196
+
197
+ // @comment -- "The one SAST line the briefing and bravos sast status print: the newest run's counts and age, or why there is none (not run here, or unavailable and what is missing)."
198
+ export function sastLine(summary, available, licence = null) {
199
+ // An unlicensed machine is told once, by the plan line; here only that SAST is among the locked features.
200
+ if (licence && !licence.licensed && !summary) return 'SAST: on the Team plan (see Plan above); skills carry on without it';
201
+ if (summary) {
202
+ const age = summary.staleCommits === null ? '' : summary.staleCommits === 0 ? ', current' : `, ${summary.staleCommits} commit${summary.staleCommits === 1 ? '' : 's'} old`;
203
+ return `SAST: ${summary.total} findings (${summary.reachable} reachable, ${summary.unjoinedReachable} not in the threat model) at ${summary.sha ? summary.sha.slice(0, 9) : summary.run}${age} — bravos sast candidates`;
204
+ }
205
+ if (available?.ok) return `SAST: not run here — bravos-sast runs it (bravos-cli${available.analyzer && !available.analyzer.endsWith('/bin/bravos-cli') ? ` at ${available.analyzer}` : ''} and a licence are present)`;
206
+ return `SAST: unavailable here — ${available?.missing === 'licence' ? 'the person activates their Bravos licence (bravos account activate)' : 'bravos-sast installs bravos-cli with the person\'s go-ahead (bravos setup)'}; skills carry on without it`;
207
+ }
@@ -0,0 +1,107 @@
1
+ // @comment -- "bravos's setup score: which of the nine capabilities are switched on in this repo (intent, coverage, autopilot, pre-PR gate, code graph, proving preference, test target, CI, SAST), what each unlocks, how to switch it on in the harness's own words, and the next one to unlock. Pure over inputs collect() already gathered plus two file checks; read-only, no model."
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export const SETUP_SCHEMA = 'bravos.setup/v1';
6
+ const SIGIL = { claude: '/', codex: '$', omp: '/skill:', opencode: '/' };
7
+
8
+ /** A skill the person can invoke: /bravos-x, $bravos-x, /skill:bravos-x, or "the bravos-x skill" elsewhere. */
9
+ const skill = (name, harness) => (SIGIL[harness] ? `${SIGIL[harness]}${name}` : `the ${name} skill`);
10
+
11
+ // @comment -- "Builds the nine setup items in dependency order: the code graph first (a machine step every later skill reads), then intent (people), coverage (annotate reads intent and the graph), SAST (reads the annotations), how findings are proven and a test target (needed to test), then the gates (autopilot, pre-PR, CI). Each has its done-state, what it unlocks and how, with skills in the harness syntax. bravos assumes a licensed bravos-cli, so a missing one is a to-do, not 'not available'."
12
+ export function readSetup(root, i = {}, harness = 'other') {
13
+ const coverage = Number(i.guardlink?.ci?.summary?.coverage?.percent);
14
+ const items = [
15
+ {
16
+ id: 'graph',
17
+ on: i.codegraph === 'current' || i.codegraph === 'unknown' || i.codegraph === 'cli',
18
+ label: i.codegraph === 'not-built' ? 'rebuild the code graph for this commit' : 'build the code graph',
19
+ cta: `say "build the code graph"`,
20
+ unlocks: 'every later step reads it: real routes for intent, reach for annotate, call paths for templates, callers for rechecks',
21
+ how: `${i.codegraph === 'not-built' ? 'bravos has no graph for this commit (it keys a graph to the commit it was built at); ' : ''}ask "build the code graph" (${skill('bravos-codegraph', harness)}): the agent builds it with bravos graph build . --scip no-exec (no tokens, a few minutes; bravos setup first if bravos is missing, with your go-ahead) and asks it through bravos graph, no reconnect needed`,
22
+ },
23
+ {
24
+ id: 'intent',
25
+ on: i.intent?.status && i.intent.status !== 'none',
26
+ label: 'record what the app intends',
27
+ cta: skill('bravos-context', harness),
28
+ unlocks: 'severities judged against what the software is meant to do and which attackers are in scope',
29
+ how: `ask "learn what this app intends" (${skill('bravos-context', harness)})`,
30
+ },
31
+ {
32
+ id: 'coverage',
33
+ on: Number.isFinite(coverage) && coverage >= 80,
34
+ label: `cover the code${Number.isFinite(coverage) ? ` (now ${coverage}%)` : ''}`,
35
+ cta: skill('bravos-annotate', harness),
36
+ unlocks: 'the agent sees the security facts of the files it touches; SAST reads them',
37
+ how: `ask "annotate the uncovered files" (${skill('bravos-annotate', harness)}, or ${skill('bravos-campaign', harness)} for all of it)`,
38
+ },
39
+ {
40
+ id: 'sast',
41
+ on: !!i.sast?.summary,
42
+ label: 'run SAST',
43
+ cta: skill('bravos-sast', harness),
44
+ unlocks: 'dependency CVEs, secrets and request-to-sink taint feed annotate, templatize and prove',
45
+ how: i.sast?.available?.ok ? `ask "run SAST" (${skill('bravos-sast', harness)}), after annotating so it reads the threat model` : `ask "set up SAST" (${skill('bravos-sast', harness)}): the agent installs bravos-cli with your go-ahead (bravos setup); activate your licence with bravos account activate`,
46
+ },
47
+ {
48
+ id: 'proving',
49
+ on: i.proving?.status === 'set',
50
+ label: 'choose how findings are proven',
51
+ cta: 'say "prove the top 3"',
52
+ unlocks: '"prove the top 3" runs the way the team chose, without asking each time',
53
+ how: `ask "prove the top 3 threats" — the agent asks once how you want proving handled (${skill('bravos-prove', harness)})`,
54
+ },
55
+ {
56
+ id: 'target',
57
+ on: existsSync(join(root, '.guardlink', 'scope.yaml')) || !!i.proving?.target,
58
+ label: 'set up a test target',
59
+ cta: skill('bravos-env', harness),
60
+ unlocks: 'exposures can be tested for real instead of argued about',
61
+ how: `ask "bring up a test target" (${skill('bravos-env', harness)}); bravos sync --scope writes the loopback-only starter`,
62
+ },
63
+ {
64
+ id: 'autopilot',
65
+ on: !!i.autopilot,
66
+ label: 'turn on autopilot',
67
+ cta: 'bravos sync --autopilot',
68
+ unlocks: 'annotations and rechecks happen at the end of each turn without being asked',
69
+ how: 'bravos sync --autopilot (cheap; fine to switch on early)',
70
+ },
71
+ {
72
+ id: 'prepr',
73
+ on: !!i.prePr,
74
+ label: 'turn on the pre-PR gate',
75
+ cta: 'bravos sync --pre-pr-hook',
76
+ unlocks: 'no pull request goes out with undisclosed exposure or a confirmed exploit',
77
+ how: 'bravos sync --pre-pr-hook (autopilot turns it on too)',
78
+ },
79
+ {
80
+ id: 'ci',
81
+ on: existsSync(join(root, '.github', 'workflows', 'bravos-pr-gate.yml')),
82
+ label: 'add the CI gate and nightly',
83
+ cta: 'bravos sync --ci',
84
+ unlocks: 'the whole team is gated, and upkeep runs while laptops are closed',
85
+ how: 'bravos sync --ci, then commit .github/workflows/',
86
+ },
87
+ ].map((x) => ({ ...x, on: !!x.on }));
88
+ // Paid steps on an unlicensed machine say so in their `how` and are never the next step: the score
89
+ // counts what the person can do today, and the plan line (briefing, doctor, watch) says how to change it.
90
+ const licensed = !!i.licence?.licensed;
91
+ const paid = { sast: 'SAST', autopilot: 'autopilot', prepr: 'the pre-PR gate', ci: 'the CI gate and nightly' };
92
+ for (const x of items) {
93
+ if (!licensed && x.id in paid && !x.on) {
94
+ x.paid = true;
95
+ x.how = `${paid[x.id]} is on the Team plan: bravos account activate starts a 30-day trial (no card), then ${x.cta}`;
96
+ }
97
+ }
98
+ const done = items.filter((x) => x.on).length;
99
+ const next = items.find((x) => !x.on && !x.paid) ?? items.find((x) => !x.on) ?? null;
100
+ return { schema: SETUP_SCHEMA, done, total: items.length, items, next, plan: i.licence ? { licensed, state: i.licence.state, tier: i.licence.tier } : null };
101
+ }
102
+
103
+ /** The one Setup line the briefing gives the agent and the banner shows the person. */
104
+ export function setupLine(s) {
105
+ if (!s) return null;
106
+ return s.next ? `Setup ${s.done}/${s.total} — next: ${s.next.label} (${s.next.unlocks}): ${s.next.how}` : `Setup ${s.done}/${s.total} — everything bravos can use here is on`;
107
+ }
@@ -0,0 +1,78 @@
1
+ // @comment -- "Reads the bravos workspace snapshot (.bravos/workspace/workspace.json, written by bravos workspace) and its cached bravos export, for the briefing and watch: which application this repo belongs to, its sibling services, the cross-service edges into and out of it, and at the app folder a per-service summary. Read-only and dependency-free; a missing or foreign file reads as no workspace."
2
+ import { readFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ export const WORKSPACE_SCHEMA = 'bravos.workspace/v1';
6
+ export const EXPORT_SCHEMA = 'bravos.workspace-export/v1';
7
+ export const MAX_MEMBERS = 8;
8
+
9
+ const read = (file, schema) => {
10
+ try {
11
+ const j = JSON.parse(readFileSync(file, 'utf8'));
12
+ return j?.schema === schema ? j : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+
18
+ // @comment -- "The workspace this root belongs to: the snapshot, the cached export, whether root is the app folder, and which member it is (null at the app folder)."
19
+ export function readWorkspace(root) {
20
+ const dir = join(root, '.bravos', 'workspace');
21
+ const snap = read(join(dir, 'workspace.json'), WORKSPACE_SCHEMA);
22
+ if (!snap || !Array.isArray(snap.members)) return null;
23
+ const exp = read(join(dir, 'export.json'), EXPORT_SCHEMA);
24
+ const here = resolve(root);
25
+ const member = snap.members.find((m) => resolve(m.path) === here) ?? null;
26
+ return { name: snap.name, appFolder: snap.appFolder ?? null, isAppFolder: !!snap.appFolder && resolve(snap.appFolder) === here, member: member?.name ?? null, members: snap.members, export: exp, at: snap.at };
27
+ }
28
+
29
+ // @comment -- "Runs guardlink ci -f json in each member (at most MAX_MEMBERS, concurrently, through the caller's runner) and returns {name: {open, bySeverity, confirmed, stale, coverage} | null}; a member that fails or times out reads as null, never as zero."
30
+ export async function memberCounts(ws, runJson, env, timeoutMs) {
31
+ const out = {};
32
+ const members = ws.members.slice(0, MAX_MEMBERS);
33
+ const results = await Promise.all(members.map((m) => runJson('guardlink', ['ci', '-f', 'json'], { cwd: m.path, env, timeoutMs })));
34
+ members.forEach((m, k) => {
35
+ const r = results[k];
36
+ const s = r?.ok ? r.json?.summary : null;
37
+ out[m.name] = s
38
+ ? {
39
+ open: Number(s.exposures) || 0,
40
+ bySeverity: s.by_severity ?? {},
41
+ confirmed: Array.isArray(r.json.confirmed) ? r.json.confirmed.length : 0,
42
+ stale: Number(s.stale) || 0,
43
+ coverage: s.coverage ? Number(s.coverage.percent) || 0 : null,
44
+ }
45
+ : null;
46
+ });
47
+ return out;
48
+ }
49
+
50
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
51
+
52
+ // @comment -- "The briefing's workspace lines: in a member, the app, the other services and the cross-service calls into and out of it; at the app folder, one row per service and the edges, unresolved references and clashes; and where to work for each kind of question."
53
+ export function workspaceLines(ws, counts) {
54
+ if (!ws) return [];
55
+ const edges = ws.export?.edges ?? null;
56
+ const lines = [];
57
+ if (ws.isAppFolder) {
58
+ lines.push(`Workspace: ${ws.name} — this is the app folder for ${plural(ws.members.length, 'service')}; make changes inside a service's repo, ask questions that cross services here`);
59
+ for (const m of ws.members.slice(0, MAX_MEMBERS)) {
60
+ const c = counts?.[m.name];
61
+ const sev = c ? ['critical', 'high'].filter((k) => c.bySeverity?.[k]).map((k) => `${c.bySeverity[k]} ${k}`).join(', ') : '';
62
+ lines.push(` ${m.name}${m.url ? ` (${m.url})` : ''}: ${c ? `${c.open} open${sev ? ` (${sev})` : ''} · ${c.confirmed} confirmed · coverage ${c.coverage ?? '?'}%` : 'counts unknown (guardlink ci failed or timed out)'}`);
63
+ }
64
+ if (ws.members.length > MAX_MEMBERS) lines.push(` … and ${ws.members.length - MAX_MEMBERS} more (bravos workspace show)`);
65
+ } else {
66
+ const others = ws.members.filter((m) => m.name !== ws.member).map((m) => m.name);
67
+ const into = edges ? edges.filter((e) => e.dstMember === ws.member) : null;
68
+ const outof = edges ? edges.filter((e) => e.srcMember === ws.member) : null;
69
+ lines.push(`Workspace: ${ws.name} — this repo is the ${ws.member ?? '?'} service; the others are ${others.join(', ') || 'none'}`);
70
+ if (into && outof) {
71
+ const fmt = (e, dir) => (dir === 'in' ? `${e.srcMember}:${e.src} → ${e.dst}` : `${e.src} → ${e.dstMember}:${e.dst}`);
72
+ lines.push(` calls into this service: ${into.length ? into.map((e) => fmt(e, 'in')).join(', ') : 'none declared'}; calls out: ${outof.length ? outof.map((e) => fmt(e, 'out')).join(', ') : 'none declared'}`);
73
+ } else lines.push(' cross-service calls: unknown (run bravos workspace show)');
74
+ }
75
+ if (edges) lines.push(` ${plural(edges.length, 'cross-service edge')} declared${ws.export.unresolved?.length ? ` · ${ws.export.unresolved.length} unresolved reference${ws.export.unresolved.length === 1 ? '' : 's'}` : ''}${ws.export.clashes?.length ? ` · ${plural(ws.export.clashes.length, 'id clash')}` : ''}`);
76
+ lines.push(` Declare a call to another service as @flows #<this>-<x> -> #<other>-<y> via http|grpc|queue (plain ids; not the dotted #repo.tag form). The code graph links services only when built over the app folder${ws.appFolder ? ` (bravos graph build ${ws.appFolder} --scip no-exec)` : ''}.`);
77
+ return lines;
78
+ }