@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,54 @@
1
+ // @comment -- "bravos's pre-PR gate entry, two ways. As the opt-in PreToolUse hook it reads the hook payload from stdin, gates only Bash commands that run gh pr create or gh pr ready, and exits 2 with the reason on stderr to block; every other command, unparseable input and BRAVOS_PRE_PR=off exit 0. Run by hand (--check, or no stdin / a TTY) it gates the current branch before any PR exists: exit 2 on lint errors, a confirmed claim on a changed file without a @ticket, or an input that fails; new exposures are printed to disclose, not blocked on. The repo root is git's top level for the working directory, never the payload cwd as given."
2
+ import { entitled, refusal, status as licenceStatus } from './lib/licence.mjs';
3
+ import { prAction, resolveRoot, runGate } from './lib/pre-pr.mjs';
4
+
5
+ async function readStdin() {
6
+ const chunks = [];
7
+ for await (const c of process.stdin) chunks.push(c);
8
+ return Buffer.concat(chunks).toString('utf8');
9
+ }
10
+
11
+ function finish({ decision, reason }, check) {
12
+ if (decision === 'block') {
13
+ process.stderr.write(reason + '\n');
14
+ process.exit(2);
15
+ }
16
+ if (check) process.stdout.write((reason ? reason + '\n' : '') + 'bravos pre-PR gate: pass\n');
17
+ else if (reason) process.stderr.write(reason + '\n');
18
+ process.exit(0);
19
+ }
20
+
21
+ async function main() {
22
+ const env = process.env;
23
+ if (env.BRAVOS_PRE_PR === 'off') process.exit(0);
24
+ // Without a Team licence the gate stands down: one line on stderr (never a block), so a teammate
25
+ // without a seat still opens their PR and knows why nothing was checked.
26
+ const lic = licenceStatus(env);
27
+ if (!entitled(lic, 'pre-pr')) {
28
+ process.stderr.write(`bravos pre-PR gate: skipped — ${refusal(lic, 'pre-pr', 'the pre-PR gate')}\n`);
29
+ process.exit(0);
30
+ }
31
+ let check = process.argv.includes('--check') || process.stdin.isTTY === true;
32
+ let raw = '';
33
+ if (!check) {
34
+ raw = await readStdin();
35
+ if (raw.trim() === '') check = true;
36
+ }
37
+ if (check) {
38
+ const cwd = process.cwd();
39
+ finish(runGate({ root: resolveRoot(cwd, env), cwd, action: 'check', command: '', env }), true);
40
+ }
41
+ let input;
42
+ try {
43
+ input = JSON.parse(raw);
44
+ } catch {
45
+ process.exit(0);
46
+ }
47
+ const command = input?.tool_input?.command;
48
+ const action = prAction(command);
49
+ if (!action) process.exit(0);
50
+ const cwd = input?.cwd || env.CLAUDE_PROJECT_DIR || process.cwd();
51
+ finish(runGate({ root: resolveRoot(cwd, env), cwd, action, command, env }), false);
52
+ }
53
+
54
+ main();
@@ -0,0 +1,82 @@
1
+ // @comment -- "SessionStart hook entry (--global: the user-level copy, silent where the repo has its own bravos hook or no .guardlink/; --briefing prints only the briefing text, for a skill that runs it by hand): reads the hook payload from stdin (bounded wait, bad input ignored; source, and Antigravity's workspacePaths), awaits collect() (read-only guardlink, git and gh facts under a shared 20 s deadline), and prints one JSON envelope: the briefing as additionalContext for the model, plus on a fresh start a banner as systemMessage for the human (brand-coloured; a three-line summary for Codex). With --harness agy it prints Antigravity's injectSteps instead. Always exits 0 so a session never fails to start."
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { buildBanner, buildBriefing, buildSummary, collect, readVersion } from './lib/briefing.mjs';
6
+
7
+ const STDIN_WAIT_MS = 1000;
8
+
9
+ /** The hook payload as an object ({} for no, empty or bad input). */
10
+ async function readPayload() {
11
+ if (process.stdin.isTTY) return {};
12
+ const read = (async () => {
13
+ const chunks = [];
14
+ for await (const c of process.stdin) chunks.push(c);
15
+ return Buffer.concat(chunks).toString('utf8');
16
+ })().catch(() => '');
17
+ const raw = await Promise.race([read, new Promise((r) => setTimeout(() => r(''), STDIN_WAIT_MS).unref())]);
18
+ try {
19
+ const p = JSON.parse(raw);
20
+ return p && typeof p === 'object' ? p : {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function harnessArg(argv, env) {
27
+ const i = argv.indexOf('--harness');
28
+ const v = i === -1 ? null : argv[i + 1];
29
+ if (v === 'claude' || v === 'codex' || v === 'omp' || v === 'agy') return v;
30
+ return env.CLAUDE_PROJECT_DIR ? 'claude' : 'other';
31
+ }
32
+
33
+ async function main() {
34
+ const env = process.env;
35
+ const harness = harnessArg(process.argv, env);
36
+ if (process.argv.includes('--briefing')) {
37
+ // Plain text for an agent whose harness ran no hook: no JSON to parse, no banner to read past.
38
+ let t;
39
+ try {
40
+ t = buildBriefing(await collect(env.CLAUDE_PROJECT_DIR || process.cwd(), env));
41
+ } catch (e) {
42
+ t = `bravos briefing failed: ${e instanceof Error ? e.message : String(e)}`;
43
+ }
44
+ process.stdout.write(t + '\n');
45
+ process.exit(0);
46
+ }
47
+ const out = {};
48
+ let text;
49
+ try {
50
+ const payload = await readPayload();
51
+ const source = typeof payload.source === 'string' ? payload.source : null;
52
+ // Antigravity sends the workspace in the payload; Claude Code sets CLAUDE_PROJECT_DIR; others run in the repo.
53
+ const ws = Array.isArray(payload.workspacePaths) && typeof payload.workspacePaths[0] === 'string' ? payload.workspacePaths[0] : null;
54
+ const root = env.CLAUDE_PROJECT_DIR || ws || (typeof payload.cwd === 'string' ? payload.cwd : null) || process.cwd();
55
+ // User-level hook (bravos init --global): a repo's own bravos hook wins, and a repo with no
56
+ // threat model gets nothing, so the user hook never doubles the briefing or adds noise.
57
+ if (process.argv.includes('--global') && (existsSync(join(root, '.bravos', 'hooks', 'session-start.mjs')) || !existsSync(join(root, '.guardlink')))) {
58
+ process.exit(0);
59
+ }
60
+ const inputs = await collect(root, env);
61
+ text = buildBriefing(inputs);
62
+ if (env.BRAVOS_BANNER !== 'off' && (source === null || source === 'startup') && harness !== 'agy') {
63
+ const version = readVersion(dirname(fileURLToPath(import.meta.url)));
64
+ const color = !env.NO_COLOR && env.BRAVOS_BANNER_COLOR !== 'off';
65
+ // Codex lists a hook's message as a warning, so it gets a three-line summary, not the art.
66
+ // The leading newline puts the logo on its own lines: Claude Code prefixes the first line with "SessionStart:startup says:".
67
+ out.systemMessage = harness === 'codex' ? buildSummary(inputs, { harness, version }) : '\n' + buildBanner(inputs, { harness, version, color });
68
+ }
69
+ } catch (e) {
70
+ text = `bravos briefing failed: ${e instanceof Error ? e.message : String(e)}`;
71
+ }
72
+ if (harness === 'agy') {
73
+ // Antigravity's SessionStart output: steps injected before the first model call.
74
+ process.stdout.write(JSON.stringify({ injectSteps: [{ systemMessage: text, metadata: { source: 'bravos' } }] }) + '\n');
75
+ process.exit(0);
76
+ }
77
+ out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: text };
78
+ process.stdout.write(JSON.stringify(out) + '\n');
79
+ process.exit(0);
80
+ }
81
+
82
+ main();
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@bugb-technologies/bravos",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Skills pack and installer that make Claude Code and Codex power users of guardlink (threat-model-as-code) and cert-x-gen (pentest-as-code).",
5
+ "keywords": [
6
+ "security",
7
+ "threat-modeling",
8
+ "guardlink",
9
+ "cert-x-gen",
10
+ "pentest",
11
+ "claude-code",
12
+ "codex",
13
+ "skills"
14
+ ],
15
+ "author": "BugB Technologies (https://www.bugb.io)",
16
+ "license": "SEE LICENSE IN LICENSE",
17
+ "type": "module",
18
+ "bin": {
19
+ "bravos": "dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "skills",
24
+ "hooks",
25
+ "templates",
26
+ "docs/GUIDE.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "pretest": "npm run build",
37
+ "test": "vitest run",
38
+ "capture-help": "bash scripts/capture-help.sh",
39
+ "prepublishOnly": "npm run build && npm test",
40
+ "build:plugin": "npm run build && node scripts/build-plugin.mjs"
41
+ },
42
+ "dependencies": {
43
+ "commander": "^15.0.0",
44
+ "smol-toml": "^1.9.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^26.6.6",
48
+ "typescript": "^5.9.3",
49
+ "vitest": "^5.0.5"
50
+ }
51
+ }
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: bravos
3
+ description: "Start here in any repo bravos is installed in: reads the security picture, explains the evidence loop in five lines, and routes to bravos-annotate, bravos-codegraph or bravos-ship. Use when asked where to start on security, what is open or confirmed, what to fix next, or whenever a task touches security-relevant code."
4
+ ---
5
+ # Bravos: where this repo stands and what to do next
6
+
7
+ ## When
8
+
9
+ - The user asks where to start on security, what is open, what is confirmed exploitable, or what to fix next.
10
+ - A task touches security-relevant code: endpoints, authn/authz, validation, queries, file I/O, external calls, crypto, process spawning, config parsing.
11
+ - You are about to pick one of the other bravos skills and are not sure which.
12
+ - The user is new or asks how to get started: "help me get started", "what can bravos do here?", "I'm new", or runs this skill with no task.
13
+
14
+ ## Do
15
+
16
+ 1. If the user is new or asks how to start, give the guided start and stop there: read the briefing's `Setup n/9` line (or run `bravos next --json`); say in three sentences what bravos does here; show what is on and the next step with its exact command and what it unlocks; offer three things to try now that fit the repo, in setup order — the code graph comes first because every later step reads it (graph off → build it with `bravos graph build . --scip no-exec`; intent none → learn what the app intends; low coverage → annotate the top files; then SAST, which reads the annotations; untested exposures → prove the top 3; a branch with changes → the ship check), in plain words with the skill named second; point to `bravos guide roles`, and mention once that `bravos watch` in a second terminal pane shows all of this live while they work. Run a setup command that changes config or hooks only after the person agrees. Otherwise read the session-start briefing. It opens with `GuardLink security picture (bravos) — <repo> @ <sha>, base <base>`. If none is in context (a harness that ran no hook), run `node .bravos/hooks/session-start.mjs --briefing` once: it prints the briefing as plain text. Pull out: the `Open exposures:` line (severity counts, coverage, worklist), `FIX NOW — confirmed exploitable`, `Next to test or fix (guardlink hypothesis next)`, `Went stale in files you changed since <base>`, `Open tickets`, `In flight by teammates`, the `Code graph:` state, and the `Intent:` line (none or draft means severities are not yet calibrated to the product; offer bravos-context). `Open exposures: unknown (guardlink ci unavailable)` means the picture is unknown, not empty. bravos keeps no state of its own (`.bravos/` holds only the hook and the skills lock); when the briefing says a tool is unavailable, `bravos doctor` reports which tools are present. When the briefing has a `Workspace:` line, this repo is one service of a larger app: changes belong in the service's own repo, questions across services go to the app folder it names, and `bravos workspace show` lists the services, their URLs and the calls between them.
17
+ 2. Explain the evidence loop in five lines when the user is new to it or asks what a finding means:
18
+ claim (an annotation says code is exposed or mitigated) → testable template → authorized run against a target the user owns → verified outcome → recheck when the code changes.
19
+ Three states are independent of each other: open / mitigated / accepted · untested / confirmed / refuted · verified / stale. A mitigated claim can still be untested; a confirmed one can go stale.
20
+ 3. Route the request:
21
+ no `.guardlink/intent.md` yet, business logic, "what matters here", "is this intended?", learning the app → bravos-context (run it before a first annotation pass).
22
+ coverage, annotating, "threat-model this", a worklist in the briefing → bravos-annotate.
23
+ reachability, blast radius, "is this live", tracing a handler to sinks → bravos-codegraph.
24
+ before a PR, push for review, or marking a draft ready → bravos-ship.
25
+ pentest templates, a retest kit, making a claim testable → bravos-templatize.
26
+ standing up a test target, seeding test users, capturing a session → bravos-env.
27
+ proving a finding, testing whether an exposure is exploitable → bravos-prove (after bravos-env).
28
+ a fix landed, stale claims, closing a security ticket → bravos-recheck.
29
+ write up the assessment, a finding, a retest or a brief for leadership → bravos-report.
30
+ static analysis, dependency, secret or IaC findings → bravos-sast; "what did SAST find" → `bravos sast candidates` (the briefing's `SAST:` line says whether a run exists, or that SAST is unavailable).
31
+ keep annotating while I work, nightly or periodic runs → bravos-schedule.
32
+ security CI, a PR check, code-scanning upload → bravos-cicd.
33
+ reach a goal over many rounds ("100% coverage", "test every exposure", "until done", autopilot, a campaign) → bravos-campaign.
34
+ The briefing's `Plan:` line says which tier this machine is on. On Free, the research loop (context, annotate, templatize, env, prove, recheck, report, watch) is all there; the pre-PR gate, CI and nightly, schedule, workspaces, campaign, SAST and the code graph are on Team. When the request needs one of those, still name the right skill, say in one sentence that it is on the Team plan (`bravos account activate` starts a 30-day trial, no card), and offer the nearest free step instead; never pretend the feature ran.
35
+ 4. bravos adds method and memory to what you can already do; it never caps it. Budgets in the skills are defaults: exceed them when the work calls for it and say why. Effort (low, medium, high, max) comes from the user's words, else a `bravos effort: <level>` line in `AGENTS.md` / `CLAUDE.md`, else medium. Only the safety rules under each skill's Never are hard.
36
+ 5. Name the next step in one line, taken from the briefing: a `FIX NOW` item first, then a stale claim in a file the user changed, then the top `guardlink hypothesis next` item, then coverage.
37
+ 6. When the user takes an item to work on and it already has a ticket in their tracker, record the pick-up beside its claim so the hive can see it: write `@ticket #<threat> on #<asset> <ticket-ref> sla "<label>" by <assignee> until <YYYY-MM-DD>` directly beside the claim's `@exposes`, clauses in that order, dropping `sla "<label>"` and `until <YYYY-MM-DD>` unless the user gives them, then run `guardlink validate .`. The briefing's `Open tickets` line and `guardlink lookup tickets` show it to teammates; removing the line is how the ticket leaves the model.
38
+
39
+ ## Never
40
+
41
+ - Run a setup command for the person (bravos sync --autopilot, --ci, --pre-pr-hook, bravos setup, bravos graph build with --scip auto) without their go-ahead.
42
+ - Run `guardlink annotate` or guardlink translate with an agent flag. Those launch a second agent outside this session; you are the agent, so do the work here.
43
+ - Write `@accepts`, `@entitles` or `@confirmed`. Acceptance and entitlement are a human's decision; confirmation needs a reproduced run, and reading code is not one.
44
+ - Call anything clean, safe or fixed unless a gate said so with exit 0.
45
+ - Invent a ticket id, SLA, assignee or due date; take each from the user or their tracker.
46
+
47
+ ## Done when
48
+
49
+ - The user knows the three states for what you reported: open vs mitigated or accepted, untested vs confirmed vs refuted, verified vs stale.
50
+ - Every finding you mention names the tool that judged it (guardlink ci, the code graph, a cxg run) and what was not tested.
51
+ - You named one next step and the skill that owns it.
52
+
53
+ ## What it leaves for the next skill
54
+
55
+ - The briefing lines you quoted and the one next step, so the routed skill starts from the same picture.
56
+
57
+ ## Go deeper
58
+
59
+ - Autopilot (`bravos init --autopilot`): an end-of-turn check sends you back once when changed code lacks annotations, sits under a confirmed or stale claim, or adds exposures; the briefing's `Autopilot:` and `Proving:` lines say whether it is on and how the person wants proving handled.
60
+ - `bravos guide` (in a shell; no model) explains bravos by role, by task and skill by skill, with the safety rules; `docs/GUIDE.md` in the bravos package is the same text. Point a person there when they ask how to use bravos.
61
+ - `docs/GUARDLINK_REFERENCE.md` — annotation syntax and verbs.
62
+ - `.guardlink/README.md` — where annotations go in this repo.
63
+ - `{{SKILLS_ROOT}}/guardlink-annotate-exploitable/SKILL.md` — the evidence bar a claim must meet (written by `guardlink init --agent claude` under `.claude/skills/` when present; otherwise `guardlink annotate --stdout --playbook exploitable "<scope>"` prints the same method).
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: bravos-annotate
3
+ description: "Keep the GuardLink threat model correct and growing, in this session: pick a budgeted slice of files (or go deep on request), follow guardlink's own playbook skill, write annotations that meet the evidence bar, run the lint gate until clean, and report the coverage delta. Use when asked to annotate, threat-model or cover code, after writing security-relevant code, when the briefing shows coverage or a worklist, and when asked to annotate deeply or run multiple rounds."
4
+ ---
5
+ # Bravos annotate: a budgeted slice of the threat model, gated
6
+
7
+ ## When
8
+
9
+ - The user asks to annotate, threat-model or cover code, or to go deep / run several rounds.
10
+ - You just wrote or changed security-relevant code and its claims are missing or stale.
11
+ - The briefing's `Open exposures:` line shows low coverage or a non-empty worklist.
12
+ - bravos-ship or bravos-codegraph handed you gaps to annotate.
13
+
14
+ ## Do
15
+
16
+ 1. Set up. You annotate in this session: guardlink supplies the method (its playbook skills), you supply the edits. `<base>` below is the base branch name, e.g. `main`; the briefing prints it as `base origin/<base>`, and without a briefing `git symbolic-ref -q --short refs/remotes/origin/HEAD` prints `origin/<base>` (if it prints nothing, use `main`). Run `git fetch origin <base>` before you use `origin/<base>`, so it is current; if the fetch fails (offline, no `origin`), say so and continue with the local ref. Record `git rev-parse HEAD` now as `<start>`: the lint gate judges only what this session writes. Definitions (`@asset`, `@threat`, `@control` with `#id`) live in `.guardlink/definitions.*`; reuse those ids and never redefine one. Read `.guardlink/intent.md`: its invariants (`INV-n`), intended-by-design items (`INT-n`), actors and crown jewels shape every claim below. If it is missing, run bravos-context first; if the user declines, carry on and tell them severities are not calibrated to the product.
17
+ 2. Run `guardlink ci -f json`. Note `summary.coverage.percent` as p0 and the worklist size from the briefing or `guardlink unannotated --json` (`summary.worklist`).
18
+ 3. Read the effort: the user's words ("at high"), else a `bravos effort: <level>` line in `AGENTS.md` / `CLAUDE.md`, else medium. Starting budgets — low: the changed files only; medium: changed plus 5 from the worklist; high: 25 per round, then the `chains` playbook; max: rounds until the worklist is empty (bravos-campaign runs those with subagents). These are defaults, not limits: take more or fewer when the code calls for it, and say why in one line ("12 files, not 5: the auth path spans them"). For a large pick, tell the user the files and rounds before you start.
19
+ 4. Run `node {{SKILL_DIR}}/scripts/slice.mjs --base origin/<base> --n <budget>` (`--deep` for no limit, `--batches <k>` to split the pick for parallel work, `--prefer <file>` for entry points `codegraph_entry_points` found). It prints one JSON object: `diff` (changed files), `coverage` (unannotated files to cover, entry points and trust code first, generated, test and CI files last), `coverage_detail` (`{path, tier, why}`), `batches`, `skipped` (`{path, reason}`), `coverage_before`, `notes`. The order is advice: if your reading of the code says a different file matters more, take it and say why. Tell the user which `skipped` files teammates have in flight. Report every `notes` entry to the user; if one says gh was unavailable, say in-flight PRs were not checked before you take the `coverage` files. Exit 1 means `guardlink unannotated --json` failed: report it and stop. When the briefing's `SAST:` line shows a run, `bravos sast candidates` lists reachable findings not in the threat model yet: add their files to the pick as leads, open each, and annotate only what meets the evidence bar, as `@exposes` + `@audit` citing the rule and route, or an `@audit` naming a false positive. A static finding is never `@confirmed`. With SAST unavailable or no report, pick as usual.
20
+ 5. Pick the playbook: `diff` files → `diff`; `coverage` files → `coverage`. Deep rounds run `exploitable`, then `chains`, then `coverage`, then `verify`. Add a logic pass when intent.md lists invariants (always at high and max, or when guardlink offers a `logic` playbook use it): walk each user journey that touches an invariant — signup, invite, role change, checkout, refund, export — and at each step ask what it assumes about the caller and the step before: ownership, tenant, state, amount, rate. The sink of a logic claim is the state change (the write, the transfer, the grant); the absent control is the missing ownership or state check. Claim it against the invariant's threat: `@mitigates <Asset> against #inv-… using #<control>` for a check that holds, `@exposes <Asset> to #inv-…` for one that is missing and meets the evidence bar, `@audit` for a doubt.
21
+ 6. Open `{{SKILLS_ROOT}}/guardlink-annotate-<id>/SKILL.md` (guardlink writes it under `.claude/skills/`, and `bravos init` mirrors it into `.agents/skills/` for Codex) and follow it. Otherwise call the MCP tool `guardlink_annotate` (server `guardlink`) with `prompt: "<scope>"` and `playbook: "<id>"`, or run `guardlink annotate --stdout --playbook <id> "<scope>"` (for the `diff` playbook, `guardlink annotate --stdout --playbook diff --since origin/<base> "<scope>"`, since `--since` defaults to HEAD and would list no changed files), and follow the prompt it returns.
22
+ 7. Write the annotations in each file's own comment syntax, directly above the code they describe, coupled: every `@exposes` with its `@mitigates` or `@audit`, the `@flows` the data takes, and a `@comment` on what the code does. Each claim meets the evidence bar: it names the code, the threat and the control by id, and you could point at the line that makes it true.
23
+ Rate each `@exposes` by impact × reach × ease, within its threat's band, under intent.md's `## Assumptions` when it has them (the configuration baseline, deployment and attackers in scope; an exposure only an out-of-scope attacker reaches gets an `@audit`, not a severity): impact from the asset's `@handles` class and intent.md's crown jewels; reach from the boundary in front of it (public, then signed-in, then admin-only, then internal; with the code graph (its MCP tools, or `bravos graph <question> --json` — same answers; see bravos-codegraph), `codegraph_auth_boundary` for the route and `codegraph_reachable_sinks` for what the handler reaches; code `codegraph_entry_reach` or `codegraph_dead_code` shows no entry point reaches gets an `@audit` "not reachable per code graph", not a severity; without the graph, judge reach by reading the code and say the graph was unavailable); ease from its preconditions. Write the severity inline and the reason in the description ("high: reachable unauthenticated, touches payment records").
24
+ Intended? When the code does what an intent.md `INT-n` item says the product does on purpose, write no `@exposes`: write an `@audit` citing it ("by design per intent.md INT-2; check only admins reach it") and annotate the guard that should hold. When it looks intended but intent.md is silent, write an `@audit` asking "intended?" with your reason, and list it for the user. They answer with a fix, an intent.md line or their own `@accepts`; you never accept it. In a bravos workspace (the briefing's `Workspace:` line, or `.bravos/workspace/workspace.json`), define only this service's own assets, with ids prefixed by the service (`#orders-create`), never another service's; declare each call to another service as `@flows #<this>-<x> -> #<other>-<y> via http|grpc|queue -- "..."` with the other service's own id. Do not write the dotted `#repo.tag` form that guardlink's Workspace Context block suggests: cxg cannot chain it, so the cross-service attack is never tested.
25
+ 8. Gate: run `guardlink lint . --since <start> --json`. For each entry in `violations[]`, apply its `fix`. Repeat until `summary.errors` is 0. A violation on a line you did not write this session is not yours: leave the line as it is and report it to the user.
26
+ 9. Run `guardlink validate .` and fix what it reports in the lines you wrote.
27
+ 10. Run `guardlink ci -f json` again and report `+<files>, <p0>% → <p1>%, <errors> lint errors`. Deep only: repeat steps 4–10 once per round, and stop when `coverage` comes back empty or a round adds no claims.
28
+ 11. Commit the slice on its own: `git add` only the annotated files, message `annotate: <n> files, <playbook>`. Ship it as a small PR or on the feature branch, whichever the team does.
29
+ 12. Hand the new claims to bravos-ship, and the new `@exposes` ids to bravos-templatize so they become testable.
30
+
31
+ ## Never
32
+
33
+ - Run `guardlink annotate` with an agent flag. Use `--stdout` and do the work here.
34
+ - Write `@accepts`, `@entitles` or `@confirmed`.
35
+ - Apply a lint `fix` to a line this session did not write. For `accepts-written`, `entitles-written` or `confirmed-without-evidence` on someone else's line, never remove or downgrade it: stop and report it to the user.
36
+ - Edit or delete an existing claim. If one looks wrong, write an `@audit` beside it saying why.
37
+ - Treat an intended-by-design match as an accepted risk, or drop an existing claim because intent.md covers it.
38
+ - Annotate a file `guardlink unannotated` lists under `excluded`.
39
+ - Pad coverage with claims that fail the evidence bar. An honest `@exposes` plus `@audit` beats a guessed `@mitigates`.
40
+
41
+ ## Done when
42
+
43
+ - `guardlink lint . --since <start> --json` reports `summary.errors` 0 and `guardlink validate .` exits 0.
44
+ - You reported the delta line from step 10, the files left in `skipped`, and any lint violation on a line you did not write.
45
+ - Each new `@exposes` carries a severity with its reason, and every "intended?" `@audit` is listed for the user.
46
+ - Each new claim's state is clear: open or mitigated (none accepted by you), untested (an annotation alone confirms or refutes nothing), and verified or stale per `guardlink ci`. Say what was not tested. Do not call the code clean; the lint gate judges annotations, not code.
47
+
48
+ ## What it leaves for the next skill
49
+
50
+ - One commit of annotated files and the list of new `@exposes` ids and files, for bravos-ship.
51
+
52
+ ## Go deeper
53
+
54
+ - `{{SKILLS_ROOT}}/guardlink-annotate-exploitable/SKILL.md` — the evidence bar (guardlink writes it under `.claude/skills/`, and `bravos init` mirrors it into `.agents/skills/` for Codex; otherwise `guardlink annotate --stdout --playbook exploitable "<scope>"` prints the same method).
55
+ - `docs/GUARDLINK_REFERENCE.md` — verbs and syntax.
56
+ - `.guardlink/README.md` — placement rules for this repo.
@@ -0,0 +1,127 @@
1
+ // @comment -- "Pure slice picker for bravos-annotate: ranks the worklist (entry points first, tests/generated/CI last), decides which files this session or its subagents annotate, skips files teammates have in open PRs, and splits the pick into disjoint directory-coherent batches; dependency-free, copied verbatim into target repos."
2
+
3
+ const arr = (v) => (Array.isArray(v) ? v : []);
4
+ const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
5
+
6
+ // Words in a path that mark code where attacker input arrives or trust is decided.
7
+ const ENTRY_WORDS = new Set([
8
+ 'handler', 'handlers', 'server', 'route', 'routes', 'router', 'controller', 'controllers', 'api', 'auth',
9
+ 'authn', 'authz', 'jwt', 'token', 'tokens', 'session', 'sessions', 'middleware', 'interceptor', 'interceptors',
10
+ 'gateway', 'ingress', 'rpc', 'grpc', 'webhook', 'webhooks', 'upload', 'admin', 'login', 'oauth', 'crypto',
11
+ 'secret', 'secrets', 'key', 'keys', 'jwks', 'cert', 'certs', 'tls', 'permission', 'permissions', 'rbac', 'acl',
12
+ 'endpoint', 'endpoints', 'frontend', 'proxy', 'graphql', 'resolver', 'resolvers', 'views',
13
+ ]);
14
+ const ENTRY_PREFIXES = ['authoriz', 'authentic', 'authn', 'claim', 'identit', 'credential', 'password', 'saml', 'oidc'];
15
+
16
+ const tokens = (p) =>
17
+ p
18
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
19
+ .toLowerCase()
20
+ .split(/[^a-z0-9]+/)
21
+ .filter(Boolean);
22
+
23
+ /**
24
+ * classify(path, generated) -> { tier, why }
25
+ * tier 1: entry points and trust code; tier 2: other source; tier 3: tests, generated, CI/tooling,
26
+ * vendored, docs/config. Tier 3 stays pickable; it only comes last.
27
+ */
28
+ export function classify(path, generated = new Set()) {
29
+ const p = `/${path}`;
30
+ const base = path.slice(path.lastIndexOf('/') + 1);
31
+ if (generated.has(path) || /\.pb\.go$|_pb2(_grpc)?\.py$|\.pb\.(ts|js)$|[._]gen\.|\/generated\/|\.g\.dart$/.test(p))
32
+ return { tier: 3, why: 'generated code' };
33
+ if (/\/(vendor|third_party|node_modules)\//.test(p)) return { tier: 3, why: 'vendored code' };
34
+ if (/(_test\.|\.test\.|\.spec\.|^test_|_spec\.)/.test(base) || /\/(tests?|__tests__|testdata|fixtures?)\//.test(p))
35
+ return { tier: 3, why: 'test code' };
36
+ if (/\/(\.github|\.circleci|ci|scripts|tools|build|hack)\//.test(p)) return { tier: 3, why: 'CI or tooling' };
37
+ if (/\.(md|txt|rst|json|ya?ml|toml|ini|lock|csv|svg|png)$/i.test(base)) return { tier: 3, why: 'docs or config' };
38
+ if (/\.config\.(c|m)?[jt]s$|^\.[a-z]+rc(\.[a-z]+)?$/i.test(base)) return { tier: 3, why: 'docs or config' };
39
+ const t = tokens(path);
40
+ const hit = t.find((w) => ENTRY_WORDS.has(w) || ENTRY_PREFIXES.some((x) => w.startsWith(x)));
41
+ if (hit) return { tier: 1, why: `entry point or trust code (${hit})` };
42
+ return { tier: 2, why: 'source' };
43
+ }
44
+
45
+ // The directory a batch groups by: the first two path segments of the file's directory.
46
+ const groupOf = (p) => dirOf(p).split('/').slice(0, 2).join('/');
47
+
48
+ /**
49
+ * pickSlice({ worklist, annotated, excluded, changed, inFlight, ownedDirs, generated, prefer, n, batches })
50
+ * -> { diff, coverage, coverage_detail, batches, skipped, tiers }
51
+ * diff: changed files minus excluded, in input order.
52
+ * coverage: up to n × batches worklist files not changed and not in flight. Walk order: tier-3 files last;
53
+ * otherwise files in the directory of a changed file or under an owned dir first, then by tier
54
+ * (`prefer` files — e.g. code-graph entry points — before tier 1), each group in worklist rank order.
55
+ * batches: coverage split into `batches` disjoint lists of up to n, consecutive in directory order so each
56
+ * batch is mostly one directory.
57
+ * skipped: files passed over (in-flight or changed) by the same walk, only those met before coverage filled.
58
+ * tiers: how many worklist files fell in each tier.
59
+ */
60
+ export function pickSlice(input) {
61
+ const n = input?.n;
62
+ const k = Number.isInteger(input?.batches) && input.batches > 0 ? input.batches : 1;
63
+ const budget = n === Infinity ? Infinity : n * k;
64
+ const worklist = arr(input?.worklist);
65
+ const excluded = new Set(arr(input?.excluded));
66
+ const changed = arr(input?.changed);
67
+ const inFlight = arr(input?.inFlight);
68
+ const ownedDirs = arr(input?.ownedDirs);
69
+ const generated = new Set(arr(input?.generated));
70
+ const prefer = new Set(arr(input?.prefer));
71
+
72
+ const diff = changed.filter((f) => !excluded.has(f));
73
+
74
+ const changedSet = new Set(changed);
75
+ const changedDirs = new Set(changed.map(dirOf));
76
+ const flightPr = new Map();
77
+ for (const pr of inFlight) {
78
+ for (const f of arr(pr?.files)) if (!flightPr.has(f)) flightPr.set(f, pr.pr);
79
+ }
80
+
81
+ const info = new Map();
82
+ const tiers = { 1: 0, 2: 0, 3: 0 };
83
+ worklist.forEach((path, rank) => {
84
+ const c = prefer.has(path) ? { tier: 1, why: 'entry point (code graph)' } : classify(path, generated);
85
+ tiers[c.tier] += 1;
86
+ const preferred = changedDirs.has(dirOf(path)) || ownedDirs.some((d) => path.startsWith(`${d}/`));
87
+ info.set(path, { ...c, rank, preferred, graph: prefer.has(path) });
88
+ });
89
+ const key = (p) => {
90
+ const i = info.get(p);
91
+ return [i.tier === 3 ? 1 : 0, i.preferred ? 0 : 1, i.graph ? 0 : 1, i.tier, i.rank];
92
+ };
93
+ const cmp = (a, b) => {
94
+ const ka = key(a);
95
+ const kb = key(b);
96
+ for (let j = 0; j < ka.length; j++) if (ka[j] !== kb[j]) return ka[j] - kb[j];
97
+ return 0;
98
+ };
99
+ const ordered = [...worklist].sort(cmp);
100
+
101
+ const coverage = [];
102
+ const skipped = [];
103
+ for (const path of ordered) {
104
+ if (coverage.length >= budget) break;
105
+ if (flightPr.has(path)) skipped.push({ path, reason: `in-flight PR #${flightPr.get(path)}` });
106
+ else if (changedSet.has(path)) skipped.push({ path, reason: 'in current change' });
107
+ else coverage.push(path);
108
+ }
109
+
110
+ // Batches: keep the walk's priority across groups (a group's place is set by its best file), keep files of
111
+ // one directory together, then cut consecutive runs.
112
+ const groupRank = new Map();
113
+ coverage.forEach((p, i) => {
114
+ const g = groupOf(p);
115
+ if (!groupRank.has(g)) groupRank.set(g, i);
116
+ });
117
+ const grouped = [...coverage].sort((a, b) => groupRank.get(groupOf(a)) - groupRank.get(groupOf(b)) || coverage.indexOf(a) - coverage.indexOf(b));
118
+ const size = n === Infinity ? Math.ceil(grouped.length / k) || 1 : n;
119
+ const batches = [];
120
+ for (let i = 0; i < grouped.length && batches.length < k; i += size) batches.push(grouped.slice(i, i + size));
121
+
122
+ const coverage_detail = coverage.map((path) => {
123
+ const i = info.get(path);
124
+ return { path, tier: i.tier, why: i.why };
125
+ });
126
+ return { diff, coverage, coverage_detail, batches, skipped, tiers };
127
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // @comment -- "Entry for bravos-annotate: gathers git diff, guardlink worklist/coverage and open PRs via read-only spawnSync calls, runs pickSlice and prints bravos.slice/v2 JSON (coverage, coverage_detail, batches, tiers); dependency-free, copied verbatim into target repos."
3
+ import { spawnSync } from 'node:child_process';
4
+ import { closeSync, openSync, readSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { pickSlice } from './lib/pick.mjs';
7
+
8
+ /** Read-only spawnSync wrapper (no shell, timeout, bounded buffer); mirrors hooks/lib/briefing.mjs runJson. */
9
+ function runJson(cmd, args, { cwd, timeoutMs = 10000, env = process.env } = {}) {
10
+ const r = spawnSync(cmd, args, { cwd, env, timeout: timeoutMs, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
11
+ if (r.error) {
12
+ if (r.error.code === 'ETIMEDOUT') return { ok: false, error: `${cmd}: timed out after ${timeoutMs / 1000}s` };
13
+ return { ok: false, error: `${cmd}: ${r.error.code ?? r.error.message}` };
14
+ }
15
+ if (r.status !== 0) return { ok: false, error: `${cmd} exited ${r.status}` };
16
+ try {
17
+ return { ok: true, json: JSON.parse(r.stdout) };
18
+ } catch {
19
+ return { ok: false, error: `${cmd}: output was not JSON` };
20
+ }
21
+ }
22
+
23
+ function usage(msg) {
24
+ process.stderr.write(`slice.mjs: ${msg} (usage: slice.mjs [--base <ref>] [--n <int>=5] [--batches <int>=1] [--prefer <file>]... [--deep] [--json])\n`);
25
+ process.exit(2);
26
+ }
27
+
28
+ function parseArgs(argv) {
29
+ const o = { base: null, n: 5, batches: 1, prefer: [], deep: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--deep') o.deep = true;
33
+ else if (a === '--json') continue;
34
+ else if (a === '--base') {
35
+ o.base = argv[++i];
36
+ if (!o.base) usage('--base needs a ref');
37
+ } else if (a === '--n') {
38
+ const v = argv[++i];
39
+ if (v === undefined || !/^\d+$/.test(v) || Number(v) < 1) usage('--n needs an integer >= 1');
40
+ o.n = Number(v);
41
+ } else if (a === '--batches') {
42
+ const v = argv[++i];
43
+ if (v === undefined || !/^\d+$/.test(v) || Number(v) < 1) usage('--batches needs an integer >= 1');
44
+ o.batches = Number(v);
45
+ } else if (a === '--prefer') {
46
+ const v = argv[++i];
47
+ if (!v || v.startsWith('--')) usage('--prefer needs a file path');
48
+ o.prefer.push(v);
49
+ } else usage(`unknown argument ${a}`);
50
+ }
51
+ return o;
52
+ }
53
+
54
+ const paths = (v) => (Array.isArray(v) ? v : []).map((e) => (typeof e === 'string' ? e : (e?.path ?? e?.file))).filter((p) => typeof p === 'string');
55
+
56
+ const opts = parseArgs(process.argv.slice(2));
57
+ const cwd = process.cwd();
58
+ const notes = [];
59
+
60
+ let base = opts.base;
61
+ if (!base) {
62
+ const r = spawnSync('git', ['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD'], { cwd, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] });
63
+ base = r.status === 0 && r.stdout.trim() ? r.stdout.trim() : 'origin/main';
64
+ }
65
+
66
+ let changed = [];
67
+ const d = spawnSync('git', ['diff', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`], { cwd, encoding: 'utf8', timeout: 10000, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
68
+ if (d.error || d.status !== 0) {
69
+ const why = d.error ? (d.error.code ?? d.error.message) : (d.stderr || '').trim().split('\n')[0] || `exited ${d.status}`;
70
+ notes.push(`git diff failed: ${why}`);
71
+ } else {
72
+ changed = d.stdout.split('\n').filter(Boolean);
73
+ }
74
+
75
+ const un = runJson('guardlink', ['unannotated', '--json'], { cwd });
76
+ if (!un.ok) {
77
+ process.stderr.write(`slice.mjs: guardlink unannotated failed: ${un.error}\n`);
78
+ process.exit(1);
79
+ }
80
+
81
+ let inFlight = [];
82
+ const gh = runJson('gh', ['pr', 'list', '--state', 'open', '--json', 'number,files', '--limit', '50'], { cwd });
83
+ if (gh.ok && Array.isArray(gh.json)) {
84
+ inFlight = gh.json.map((p) => ({ pr: p.number, files: paths(p.files) }));
85
+ } else {
86
+ notes.push('gh unavailable: in-flight PRs not checked');
87
+ }
88
+
89
+ let coverageBefore = null;
90
+ const ci = runJson('guardlink', ['ci', '-f', 'json'], { cwd });
91
+ const cov = ci.ok ? ci.json?.summary?.coverage : null;
92
+ if (cov) coverageBefore = { percent: cov.percent, annotated_files: cov.annotated_files, source_files: cov.source_files };
93
+ else notes.push('guardlink ci unavailable: coverage_before omitted');
94
+
95
+ // A file whose first 2 KB says it is generated ("Code generated ... DO NOT EDIT", "@generated") ranks last.
96
+ function isGenerated(rel) {
97
+ let fd;
98
+ try {
99
+ fd = openSync(join(cwd, rel), 'r');
100
+ const buf = Buffer.alloc(2048);
101
+ const len = readSync(fd, buf, 0, 2048, 0);
102
+ return /Code generated .*DO NOT EDIT|@generated\b|autogenerated|auto-generated/i.test(buf.toString('utf8', 0, len));
103
+ } catch {
104
+ return false;
105
+ } finally {
106
+ if (fd !== undefined) closeSync(fd);
107
+ }
108
+ }
109
+ const worklistPaths = paths(un.json?.worklist);
110
+ const generated = worklistPaths.slice(0, 5000).filter(isGenerated);
111
+
112
+ const n = opts.deep ? Infinity : opts.n;
113
+ const picked = pickSlice({
114
+ worklist: worklistPaths,
115
+ annotated: paths(un.json?.annotated),
116
+ excluded: paths(un.json?.excluded),
117
+ changed,
118
+ inFlight,
119
+ ownedDirs: [],
120
+ generated,
121
+ prefer: opts.prefer,
122
+ n,
123
+ batches: opts.batches,
124
+ });
125
+
126
+ process.stdout.write(
127
+ JSON.stringify({
128
+ schema: 'bravos.slice/v2',
129
+ base,
130
+ n: opts.deep ? 'unbounded' : n,
131
+ mode: opts.deep ? 'deep' : 'slice',
132
+ diff: picked.diff,
133
+ coverage: picked.coverage,
134
+ coverage_detail: picked.coverage_detail,
135
+ batches: picked.batches,
136
+ tiers: picked.tiers,
137
+ skipped: picked.skipped,
138
+ coverage_before: coverageBefore,
139
+ notes,
140
+ }) + '\n',
141
+ );