@bugb-technologies/bravos 0.0.0-stage → 1.0.1

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 +133 -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,45 @@
1
+ // @comment -- "bravos autopilot Stop hook entry (Claude Code and Codex): reads the Stop payload from stdin with a bounded wait, resolves the repo as git's top level for the payload cwd, runs the end-of-turn check, and prints {} to allow or {decision:'block', reason} to send the agent back once, logging the nudge for bravos watch. Always exits 0; a failure allows."
2
+ import { entitled, status as licenceStatus } from './lib/licence.mjs';
3
+ import { resolveRoot } from './lib/pre-pr.mjs';
4
+ import { logWatchEvent, runAutopilot } from './lib/autopilot.mjs';
5
+
6
+ async function readPayload() {
7
+ if (process.stdin.isTTY) return {};
8
+ const read = (async () => {
9
+ const chunks = [];
10
+ for await (const c of process.stdin) chunks.push(c);
11
+ return Buffer.concat(chunks).toString('utf8');
12
+ })().catch(() => '');
13
+ const raw = await Promise.race([read, new Promise((r) => setTimeout(() => r(''), 1000).unref())]);
14
+ try {
15
+ const p = JSON.parse(raw);
16
+ return p && typeof p === 'object' ? p : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ async function main() {
23
+ let out = {};
24
+ // Autopilot is part of the pre-PR gate feature; without the licence the Stop hook answers nothing and the turn ends normally.
25
+ if (!entitled(licenceStatus(process.env), 'pre-pr')) {
26
+ process.stdout.write('{}\n');
27
+ process.exit(0);
28
+ }
29
+ try {
30
+ const payload = await readPayload();
31
+ const cwd = typeof payload.cwd === 'string' ? payload.cwd : process.env.CLAUDE_PROJECT_DIR || process.cwd();
32
+ const root = resolveRoot(cwd, process.env);
33
+ const r = await runAutopilot({ root, env: process.env, payload });
34
+ if (r.decision === 'block') {
35
+ out = { decision: 'block', reason: r.reason };
36
+ logWatchEvent(root, r);
37
+ }
38
+ } catch {
39
+ out = {};
40
+ }
41
+ process.stdout.write(JSON.stringify(out) + '\n');
42
+ process.exit(0);
43
+ }
44
+
45
+ main();
@@ -0,0 +1,191 @@
1
+ // @comment -- "bravos autopilot's end-of-turn check (Claude Code and Codex Stop hooks): from the working-tree changes since HEAD it finds changed files with no GuardLink annotation, confirmed or stale claims on changed files, and open exposures on changed files, using only the two fast guardlink calls (unannotated and ci, run concurrently; lint and diff are left to the pre-PR gate because they take a minute on a large repo), and returns block with a short reason naming the skill to run, or allow. A nudge, not a gate: a missing tool, a timeout or bad input allows. Each finding is raised once per session (its key is looked up in the harness's transcript, which bravos only reads). Read-only: argv spawns, no shell."
2
+ import { createHash } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, closeSync, fstatSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export const AUTOPILOT_DEADLINE_MS = 20000;
8
+ const OUTPUT_CAP = 64 * 1024 * 1024;
9
+ const TRANSCRIPT_TAIL = 4 * 1024 * 1024;
10
+ const MAX_LIST = 5;
11
+
12
+ const arr = (v) => (Array.isArray(v) ? v : []);
13
+
14
+ // @comment -- "Async spawn with the pre-PR runner's result shape: argv array, no shell, stdin closed, output capped, killed at timeoutMs; never rejects."
15
+ export function asyncRunner(cmd, args, { cwd, env, timeoutMs = AUTOPILOT_DEADLINE_MS }) {
16
+ return new Promise((resolve) => {
17
+ let child;
18
+ try {
19
+ child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'ignore'] });
20
+ } catch (e) {
21
+ resolve({ missing: e?.code === 'ENOENT', error: e?.code ?? String(e), status: null, stdout: '' });
22
+ return;
23
+ }
24
+ const chunks = [];
25
+ let size = 0;
26
+ let done = false;
27
+ const finish = (r) => {
28
+ if (done) return;
29
+ done = true;
30
+ clearTimeout(timer);
31
+ resolve(r);
32
+ };
33
+ child.stdout.on('data', (b) => {
34
+ if (size < OUTPUT_CAP) chunks.push(b);
35
+ size += b.length;
36
+ });
37
+ const timer = setTimeout(() => {
38
+ child.kill('SIGKILL');
39
+ finish({ status: null, error: 'ETIMEDOUT', stdout: '' });
40
+ }, timeoutMs);
41
+ child.on('error', (e) => finish({ missing: e.code === 'ENOENT', error: e.code ?? e.message, status: null, stdout: '' }));
42
+ child.on('close', (status) => finish({ status, stdout: Buffer.concat(chunks).toString('utf8') }));
43
+ });
44
+ }
45
+
46
+ async function json(run, args, o) {
47
+ const r = await run('guardlink', args, o);
48
+ if (r.missing || r.error) return null;
49
+ try {
50
+ return JSON.parse(r.stdout);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // @comment -- "Reads .guardlink/proving.md, the person's recorded answer to how findings should be proven (written by the agent from their words in bravos-env/bravos-prove): choice auto | ask | schedule | none, the loopback target, the start command, whether model-API generation is allowed, and who confirmed it. Anything without a named confirmation reads as not set."
57
+ export function readProving(root) {
58
+ let text;
59
+ try {
60
+ text = readFileSync(join(root, '.guardlink', 'proving.md'), 'utf8');
61
+ } catch {
62
+ return { status: 'not set' };
63
+ }
64
+ const field = (name) => text.match(new RegExp(`^${name}:\\s*(\\S[^\\n]*?)\\s*$`, 'mi'))?.[1]?.replace(/\s+\(.*\)$/, '') ?? null;
65
+ const by = text.match(/—\s*confirmed by\s+([^,\n]+?)\s*,\s*(\S+)/i);
66
+ const choice = (field('choice') ?? '').toLowerCase().split(/\s/)[0];
67
+ if (!by || !['auto', 'ask', 'schedule', 'none'].includes(choice)) return { status: 'not set' };
68
+ return {
69
+ status: 'set',
70
+ choice,
71
+ target: field('target'),
72
+ start: field('start'),
73
+ generation: /^(yes|true|allowed)/i.test(field('generation') ?? ''),
74
+ by: by[1],
75
+ date: by[2],
76
+ };
77
+ }
78
+
79
+ /** Files changed in the working tree since HEAD, tracked or not. */
80
+ export async function changedFiles(root, env, run) {
81
+ const o = { cwd: root, env };
82
+ const out = new Set();
83
+ const [diff, untracked] = await Promise.all([run('git', ['diff', '--name-only', 'HEAD'], o), run('git', ['ls-files', '--others', '--exclude-standard'], o)]);
84
+ if (diff.status === 0) for (const l of diff.stdout.split('\n')) if (l.trim()) out.add(l.trim());
85
+ if (untracked.status === 0) for (const l of untracked.stdout.split('\n')) if (l.trim()) out.add(l.trim());
86
+ return out;
87
+ }
88
+
89
+ /** The last few MB of the harness transcript, to see which findings this session was already told about. */
90
+ function transcriptTail(path) {
91
+ if (typeof path !== 'string' || !existsSync(path)) return '';
92
+ try {
93
+ const fd = openSync(path, 'r');
94
+ try {
95
+ const size = fstatSync(fd).size;
96
+ const len = Math.min(size, TRANSCRIPT_TAIL);
97
+ const buf = Buffer.alloc(len);
98
+ readSync(fd, buf, 0, len, size - len);
99
+ return buf.toString('utf8');
100
+ } finally {
101
+ closeSync(fd);
102
+ }
103
+ } catch {
104
+ return '';
105
+ }
106
+ }
107
+
108
+ const listOf = (all) => { const xs = [...new Set(all)]; return xs.slice(0, MAX_LIST).join(', ') + (xs.length > MAX_LIST ? ` and ${xs.length - MAX_LIST} more` : ''); };
109
+ const fileOf = (c) => c?.location?.file ?? c?.file;
110
+
111
+ /**
112
+ * Runs the end-of-turn check. `payload` is the Stop hook's stdin (stop_hook_active, transcript_path).
113
+ * Returns { decision: 'allow' } or { decision: 'block', reason }.
114
+ */
115
+ // @comment -- "Decides the Stop hook's answer: allow when autopilot is off, the turn was already continued by a Stop hook, guardlink is absent, nothing changed or nothing new is found; otherwise block once with one line per finding and the skill that handles it, and for new exposures the next step the recorded proving preference implies."
116
+ export async function runAutopilot({ root, env = process.env, payload = {}, run = asyncRunner, deadlineMs = AUTOPILOT_DEADLINE_MS }) {
117
+ if (env.BRAVOS_AUTOPILOT === 'off') return { decision: 'allow' };
118
+ if (payload?.stop_hook_active === true) return { decision: 'allow' };
119
+ if (!existsSync(join(root, '.guardlink'))) return { decision: 'allow' };
120
+ const o = { cwd: root, env, timeoutMs: deadlineMs };
121
+
122
+ const changed = await changedFiles(root, env, run);
123
+ if (changed.size === 0) return { decision: 'allow' };
124
+
125
+ // The two fast guardlink calls, side by side; a missing guardlink or a timeout reads as nothing found.
126
+ const [wl, ci] = await Promise.all([json(run, ['unannotated', '--json'], o), json(run, ['ci', '-f', 'json'], o)]);
127
+
128
+ const findings = [];
129
+ // A short, stable key per finding: the same finding is raised once per session, a changed one again.
130
+ const add = (key, line) => findings.push({ key: `[bravos-autopilot:${key.split(':')[0]}:${createHash('sha256').update(key).digest('hex').slice(0, 10)}]`, line });
131
+
132
+ const worklist = arr(wl?.worklist).map((w) => (typeof w === 'string' ? w : w?.file ?? w?.path)).filter((f) => changed.has(f));
133
+ if (worklist.length) add(`unannotated:${worklist.sort().join(',')}`, `${worklist.length} changed file(s) have no GuardLink annotation (${listOf(worklist)}): annotate the security-relevant code in them with bravos-annotate, or say why none is needed.`);
134
+
135
+ const confirmed = arr(ci?.confirmed).filter((c) => changed.has(fileOf(c)));
136
+ if (confirmed.length) {
137
+ const ids = confirmed.map((c) => `${c.threat ?? '?'} at ${fileOf(c)}:${c?.location?.line ?? c?.line ?? '?'}`);
138
+ add(`recheck:${ids.sort().join(',')}`, `you changed code under a confirmed exploit (${listOf(ids)}): run bravos-recheck to see whether the change fixed it, before finishing.`);
139
+ }
140
+ const stale = arr(ci?.stale).filter((c) => changed.has(fileOf(c)));
141
+ if (stale.length) {
142
+ const ids = stale.map((c) => `${fileOf(c)}:${c?.line ?? c?.location?.line ?? '?'}`);
143
+ add(`stale:${ids.sort().join(',')}`, `${stale.length} claim(s) went stale in files you changed (${listOf(ids)}): re-read and re-verify them with bravos-recheck.`);
144
+ }
145
+
146
+ // Open exposures on the files this turn changed: what a proving decision is about.
147
+ const confirmedKeys = new Set(confirmed.map((c) => `${c.threat}|${fileOf(c)}`));
148
+ const open = arr(ci?.exposures).filter((e) => changed.has(fileOf(e)) && !confirmedKeys.has(`${e.threat}|${fileOf(e)}`));
149
+ if (open.length) {
150
+ const ids = open.map((e) => `${e?.threat ?? '?'} on ${e?.asset ?? '?'}`);
151
+ const p = readProving(root);
152
+ let next;
153
+ if (p.status !== 'set') next = 'ask the person once how they want findings proven (automatically on their loopback target, each time you ask, on a schedule, or not now), and record their answer with bravos-prove';
154
+ else if (p.choice === 'auto') next = `prove the untested ones with bravos-prove on ${p.target ?? 'the recorded loopback target'}, as .guardlink/proving.md records (${p.by}, ${p.date})`;
155
+ else if (p.choice === 'ask') next = 'ask the person whether to prove them now (bravos-prove), as .guardlink/proving.md asks';
156
+ else if (p.choice === 'schedule') next = 'leave them for the scheduled run .guardlink/proving.md names, and say so';
157
+ else next = 'note them for the person; .guardlink/proving.md says not to prove for now';
158
+ add(`prove:${ids.sort().join(',')}`, `${open.length} open exposure(s) sit on files this turn changed (${listOf(ids)}): ${next}.`);
159
+ }
160
+
161
+ if (findings.length === 0) return { decision: 'allow' };
162
+ const seen = transcriptTail(payload?.transcript_path);
163
+ const fresh2 = findings.filter((f) => !seen.includes(f.key));
164
+ if (fresh2.length === 0) return { decision: 'allow' };
165
+ const reason = [
166
+ 'bravos autopilot, before you finish:',
167
+ ...fresh2.map((f) => `- ${f.line}`),
168
+ 'If something here does not apply, say why in one line and finish.',
169
+ fresh2.map((f) => f.key).join(' '),
170
+ ].join('\n');
171
+ const summary = `sent the agent back once: ${fresh2[0].line}${fresh2.length > 1 ? ` (+${fresh2.length - 1} more)` : ''}`;
172
+ return { decision: 'block', reason, summary, keys: fresh2.map((f) => f.key) };
173
+ }
174
+
175
+ export const WATCH_LOG_CAP = 500;
176
+
177
+ // @comment -- "Appends one autopilot nudge to .bravos/watch/events.jsonl for bravos watch to show: a local log, not a state store. The folder carries its own .gitignore of *, the file keeps its newest WATCH_LOG_CAP lines, and any failure is swallowed because the nudge itself already went out."
178
+ export function logWatchEvent(root, { summary, keys = [] }, now = new Date()) {
179
+ try {
180
+ const dir = join(root, '.bravos', 'watch');
181
+ mkdirSync(dir, { recursive: true });
182
+ const ignore = join(dir, '.gitignore');
183
+ if (!existsSync(ignore)) writeFileSync(ignore, '*\n');
184
+ const file = join(dir, 'events.jsonl');
185
+ appendFileSync(file, JSON.stringify({ at: now.toISOString(), text: String(summary ?? '').slice(0, 400), keys }) + '\n');
186
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
187
+ if (lines.length > WATCH_LOG_CAP) writeFileSync(file, lines.slice(-WATCH_LOG_CAP).join('\n') + '\n');
188
+ } catch {
189
+ /* the log is a convenience for bravos watch */
190
+ }
191
+ }