diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9a2d50..717f59f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade security init` to write gitignored local defaults to `.brigade/security.toml`. - Security policy presets (`personal`, `public-repo`, `strict`), template scanning controls, stable finding fingerprints, and fingerprint suppressions. - `brigade security scan --output-dir ` to write redacted `security-report.json` and `security-report.md` evidence bundles. +- `brigade doctor` and `brigade work doctor` now report security config health, latest security evidence bundle status, and local security artifact ignore coverage. - Security scan secret evidence is redacted before reports or work imports are written. - `ROADMAP.md` covering the daily-driver path, scanner-ready inbox, chat-surface scanners, memory-card decay refresh, and portable operator setup. - `brigade work note` to append timestamped checkpoints to the active work session without ending it. diff --git a/README.md b/README.md index 65a1e672..4b910d2d 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ CLI runs write artifacts by default under `.brigade/runs/` below `--cwd`; do Use `--output-dir ` to pick the artifact directory, or `--no-artifacts` for a throwaway run. -Use `brigade work bootstrap` once in a repo to initialize the daily loop: it writes or verifies `.brigade/dogfood.toml`, creates local run and work artifact directories, creates the configured handoff inbox, updates the managed `.gitignore` block, and reports whether the repo is ready to run. Use `brigade work brief` as the normal start-of-day command. It shows branch and dirty-file state, active and latest work sessions, pending local tasks, pending local import counts by source and kind, latest dogfood run, extracted next task, recent sessions, and the command to continue; add `--json` for wrappers. Use `brigade work tasks` to list the local task ledger stored in `.brigade/work/tasks.json`, `brigade work task add "..."` to queue work manually, `brigade work task add --from-next` to promote the latest extracted dogfood next step, and `brigade work task done ` to close queued work. Use `brigade work import add "..."` as a scanner-ready local inbox for candidate tasks, findings, decisions, preferences, incidents, links, and commands from systems such as chat crawlers or memory-care decay scans; imports are stored under `.brigade/work/imports/inbox.jsonl`, stay gitignored, and do not write memory directly. Scanner authors can write JSONL records documented in [`docs/import-schema.md`](docs/import-schema.md), then run `brigade work import validate imports.jsonl` and `brigade work import ingest imports.jsonl`. Use `brigade work import memory-care` to convert `memory/cards/decay/refresh-queue.json` into local imports. Use `brigade work import triage` to group pending imports by source and kind, `brigade work import show ` to inspect one item, `brigade work import dismiss ` to remove noise from the pending queue, `brigade work import promote ` to promote one reviewed import into the task ledger, or `brigade work import promote --all --source memory-care --kind task` to batch-promote filtered pending imports with source metadata preserved. Use `brigade work status` as the quick daily dashboard for a repo. It reports the current branch, dirty files, dogfood readiness, configured dogfood paths, latest dogfood run, and extracted next step without starting a new orchestration. Use `brigade work doctor` to check whether the repo is ready for the daily loop: dogfood config, Codex CLI, local artifact paths, handoff inbox, ignore coverage, and latest run context. Use `brigade work resume` when returning to a repo; it shows the active or latest work session, latest dogfood run, extracted next step, and the suggested command to continue. Use `brigade work next` when you only need the task Brigade would run next, or `brigade work next --json` for wrappers that want the active session, dogfood snapshot, resolved task, and suggested command as structured output. `brigade work run` is the one-command daily loop: it starts a work session, consumes the oldest pending ledger task when no task is passed, otherwise uses the latest extracted dogfood next step, runs `brigade dogfood`, marks consumed ledger tasks done after successful runs, ends the session, writes a work-session Memory Handoff by default, and prints a compact recap. Add `--queue-next` to queue the successful run's extracted next step for the next session; duplicate pending task text is reused instead of added again. Pass a task to override the resolved next step, `--title` to name the session, `--no-handoff` to skip the work handoff, or `--dogfood-handoff` to also let the underlying dogfood run write its own handoff. `brigade work start "title"` opens a local work session under `.brigade/work//`, records the starting git and dogfood context, and writes `start.md`. `brigade work note "checkpoint"` appends a timestamped note to the active session without ending it. `brigade work end --note "what happened"` closes the active session, records ending context, and writes `end.md`. Add `--handoff` to also write a Memory Handoff for the closed session; it defaults to the configured dogfood handoff inbox or `.codex/memory-handoffs`. +Use `brigade work bootstrap` once in a repo to initialize the daily loop: it writes or verifies `.brigade/dogfood.toml`, creates local run and work artifact directories, creates the configured handoff inbox, updates the managed `.gitignore` block, and reports whether the repo is ready to run. Use `brigade work brief` as the normal start-of-day command. It shows branch and dirty-file state, active and latest work sessions, pending local tasks, pending local import counts by source and kind, latest dogfood run, extracted next task, recent sessions, and the command to continue; add `--json` for wrappers. Use `brigade work tasks` to list the local task ledger stored in `.brigade/work/tasks.json`, `brigade work task add "..."` to queue work manually, `brigade work task add --from-next` to promote the latest extracted dogfood next step, and `brigade work task done ` to close queued work. Use `brigade work import add "..."` as a scanner-ready local inbox for candidate tasks, findings, decisions, preferences, incidents, links, and commands from systems such as chat crawlers or memory-care decay scans; imports are stored under `.brigade/work/imports/inbox.jsonl`, stay gitignored, and do not write memory directly. Scanner authors can write JSONL records documented in [`docs/import-schema.md`](docs/import-schema.md), then run `brigade work import validate imports.jsonl` and `brigade work import ingest imports.jsonl`. Use `brigade work import memory-care` to convert `memory/cards/decay/refresh-queue.json` into local imports. Use `brigade work import triage` to group pending imports by source and kind, `brigade work import show ` to inspect one item, `brigade work import dismiss ` to remove noise from the pending queue, `brigade work import promote ` to promote one reviewed import into the task ledger, or `brigade work import promote --all --source memory-care --kind task` to batch-promote filtered pending imports with source metadata preserved. Use `brigade work status` as the quick daily dashboard for a repo. It reports the current branch, dirty files, dogfood readiness, configured dogfood paths, latest dogfood run, and extracted next step without starting a new orchestration. Use `brigade work doctor` to check whether the repo is ready for the daily loop: dogfood config, security config, latest security evidence bundle, Codex CLI, local artifact paths, handoff inbox, ignore coverage, and latest run context. Use `brigade work resume` when returning to a repo; it shows the active or latest work session, latest dogfood run, extracted next step, and the suggested command to continue. Use `brigade work next` when you only need the task Brigade would run next, or `brigade work next --json` for wrappers that want the active session, dogfood snapshot, resolved task, and suggested command as structured output. `brigade work run` is the one-command daily loop: it starts a work session, consumes the oldest pending ledger task when no task is passed, otherwise uses the latest extracted dogfood next step, runs `brigade dogfood`, marks consumed ledger tasks done after successful runs, ends the session, writes a work-session Memory Handoff by default, and prints a compact recap. Add `--queue-next` to queue the successful run's extracted next step for the next session; duplicate pending task text is reused instead of added again. Pass a task to override the resolved next step, `--title` to name the session, `--no-handoff` to skip the work handoff, or `--dogfood-handoff` to also let the underlying dogfood run write its own handoff. `brigade work start "title"` opens a local work session under `.brigade/work//`, records the starting git and dogfood context, and writes `start.md`. `brigade work note "checkpoint"` appends a timestamped note to the active session without ending it. `brigade work end --note "what happened"` closes the active session, records ending context, and writes `end.md`. Add `--handoff` to also write a Memory Handoff for the closed session; it defaults to the configured dogfood handoff inbox or `.codex/memory-handoffs`. Memory and bootstrap readiness are part of the same operating-system health story. `brigade doctor` checks installed bootstrap files against built-in hard byte budgets so overgrown files fail before agents load truncated context. It also checks `memory/cards/*.md` budgets, verifies that `MEMORY.md` card links resolve under `memory/cards/`, and reports memory-care freshness from `memory/cards/decay/scan-latest.json`. Workspace installs include `.brigade/memory-care.example.json` as a scanner wiring contract for whatever scheduler or memory owner produces the decay files. Missing memory-care decay state is advisory for fresh installs, but corrupt scan or refresh-queue JSON fails once the loop is wired. Bootstrap truncation is treated as a hard failure to prevent, not a cosmetic warning; keep cards atomic and bootstrap files as slim indexes. @@ -290,7 +290,7 @@ brigade add guard # content-guard brigade add tokens # tokenjuice ``` -`security` is a built-in station with no external managed tool yet. Run `brigade security scan --target .` for a read-only agent workspace security report, add `--output-dir .brigade/security/latest` to write redacted `security-report.json` and `security-report.md` artifacts, or add `--import-findings` to turn findings into local `brigade work import` review items. Secret evidence is redacted before reports, artifacts, or imports are written. Use `brigade security init` to write gitignored local defaults to `.brigade/security.toml`; it supports policy presets (`personal`, `public-repo`, `strict`), `fail_on`, template scanning, and fingerprint suppressions for reviewed findings. +`security` is a built-in station with no external managed tool yet. Run `brigade security scan --target .` for a read-only agent workspace security report, add `--output-dir .brigade/security/latest` to write redacted `security-report.json` and `security-report.md` artifacts, or add `--import-findings` to turn findings into local `brigade work import` review items. `brigade doctor` and `brigade work doctor` report security config health, latest evidence bundle status, and whether local security artifacts are ignored. Secret evidence is redacted before reports, artifacts, or imports are written. Use `brigade security init` to write gitignored local defaults to `.brigade/security.toml`; it supports policy presets (`personal`, `public-repo`, `strict`), `fail_on`, template scanning, and fingerprint suppressions for reviewed findings. The current managed tools: diff --git a/ROADMAP.md b/ROADMAP.md index 8214859e..c0b97bb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,7 @@ Brigade-specific additions: - Scan Claude Code, Codex, OpenCode, Gemini, Hermes, OpenClaw, VS Code, Zed, dmux, and generic repo-local agent harness surfaces with explicit runtime-confidence labels. - Understand Brigade installs: `.brigade/`, `.codex/`, `.claude/`, memory handoff inboxes, roster files, dogfood configs, run artifacts, work imports, memory-care decay files, and public template folders. - Treat public-template findings differently from active runtime findings so docs and starter templates do not score like live credentials or enabled tools. -- Integrate with `brigade doctor` as a security station and with `brigade work import` so findings can become reviewable local tasks instead of only console output. +- Integrate with `brigade doctor` as a security station and with `brigade work import` so findings can become reviewable local tasks instead of only console output. Status: started with doctor checks, work doctor checks, and `--import-findings`. - Provide safe auto-fix only for narrow cases such as replacing obvious hardcoded sample secrets, tightening generated allow-list examples, or adding missing ignore rules. - Produce Memory Handoffs for durable security findings while keeping raw secret evidence redacted. - Add policy packs for personal dogfooding, public-repo release checks, CI gates, and strict enterprise workspaces. Status: started with `personal`, `public-repo`, and `strict`. @@ -72,7 +72,7 @@ First build slice: - Create a plugin scaffold and security scan contract. Status: started with built-in `security` station, `brigade security init`, and `brigade security scan`. - Start with config discovery and read-only reporting for Brigade, Claude Code, Codex, and MCP config files. Status: started. - Add core rule categories for secrets, permissions, hooks, MCP servers, supply-chain patterns, and agent instructions. Status: started. -- Output JSON plus readable text, redacted evidence bundles, then route selected findings into `brigade work import`. Status: started with `--output-dir` and `--import-findings`. +- Output JSON plus readable text, redacted evidence bundles, then route selected findings into `brigade work import`. Status: started with `--output-dir`, doctor evidence status, and `--import-findings`. - Keep all raw findings local and gitignored unless the operator explicitly exports an evidence pack. Status: current default. - Add local policy defaults, stable finding fingerprints, and suppressions. Status: started with `.brigade/security.toml`. diff --git a/src/brigade/doctor.py b/src/brigade/doctor.py index 67aedf3b..e46f4145 100644 --- a/src/brigade/doctor.py +++ b/src/brigade/doctor.py @@ -83,7 +83,41 @@ def tokens_station_checks(ctx: DoctorContext) -> List[CheckResult]: def security_station_checks(ctx: DoctorContext) -> List[CheckResult]: - return [(OK, "security: built-in scanner", "run `brigade security scan --target .`")] + from . import dogfood_cmd, security_cmd + + results: List[CheckResult] = [(OK, "security: built-in scanner", "available")] + config = security_cmd.config_path(ctx.target) + if config.is_file(): + try: + loaded = security_cmd.load_config(ctx.target) + except ValueError as exc: + results.append((FAIL, "security: config", f"invalid {config}: {exc}")) + else: + results.append((OK, "security: config", f"{config} (policy={loaded.policy if loaded else 'personal'})")) + else: + results.append((WARN, "security: config", f"missing at {config}; run `brigade security init --target .`")) + + artifacts_dir = security_cmd.default_artifacts_dir(ctx.target) + bundle = security_cmd.inspect_evidence_bundle(artifacts_dir) + if bundle.get("ready"): + detail = ( + f"{artifacts_dir} " + f"(generated_at={bundle.get('generated_at')}, findings={bundle.get('finding_count')})" + ) + results.append((OK, "security: evidence bundle", detail)) + else: + results.append( + ( + WARN, + "security: evidence bundle", + f"{bundle.get('reason')} at {artifacts_dir}; run `brigade security scan --output-dir {artifacts_dir}`", + ) + ) + + ignored = dogfood_cmd._check_git_ignored(ctx.target, artifacts_dir) + level = OK if ignored in {"yes", "outside-target"} else WARN + results.append((level, "security: evidence ignored", ignored)) + return results def run(target: Path, harness: str = "generic") -> int: diff --git a/src/brigade/security_cmd.py b/src/brigade/security_cmd.py index 183aa728..43d5c756 100644 --- a/src/brigade/security_cmd.py +++ b/src/brigade/security_cmd.py @@ -21,6 +21,7 @@ "critical": 4, } CONFIG_REL_PATH = ".brigade/security.toml" +ARTIFACTS_REL_PATH = ".brigade/security/latest" POLICIES = { "personal": { "fail_on": "critical", @@ -113,6 +114,34 @@ def config_path(target: Path) -> Path: return target / CONFIG_REL_PATH +def default_artifacts_dir(target: Path) -> Path: + return target / ARTIFACTS_REL_PATH + + +def inspect_evidence_bundle(path: Path) -> dict[str, Any]: + path = path.expanduser().resolve() + json_path = path / "security-report.json" + markdown_path = path / "security-report.md" + if not path.is_dir(): + return {"ready": False, "path": str(path), "reason": "missing"} + missing = [item.name for item in (json_path, markdown_path) if not item.is_file()] + if missing: + return {"ready": False, "path": str(path), "reason": f"missing {', '.join(missing)}"} + try: + payload = json.loads(json_path.read_text()) + except json.JSONDecodeError as exc: + return {"ready": False, "path": str(path), "reason": f"invalid JSON: {exc}"} + if not isinstance(payload, dict): + return {"ready": False, "path": str(path), "reason": "security-report.json must contain an object"} + return { + "ready": True, + "path": str(path), + "generated_at": payload.get("generated_at"), + "finding_count": payload.get("finding_count"), + "policy": payload.get("policy"), + } + + def _parse_toml_value(raw: str) -> object: value = raw.strip() if value == "true": diff --git a/src/brigade/work_cmd.py b/src/brigade/work_cmd.py index 87254f60..426b50bf 100644 --- a/src/brigade/work_cmd.py +++ b/src/brigade/work_cmd.py @@ -2226,6 +2226,8 @@ def status(*, target: Path, limit: int = 12) -> int: def doctor(*, target: Path) -> int: + from . import security_cmd + target = target.expanduser().resolve() failures = 0 @@ -2260,6 +2262,35 @@ def doctor(*, target: Path) -> int: _doctor_line(OK, "dogfood_target", effective_target) _doctor_line(OK, "dogfood_artifacts", artifacts_dir) + security_config = security_cmd.config_path(effective_target) + if security_config.is_file(): + try: + loaded_security = security_cmd.load_config(effective_target) + except ValueError as exc: + failures += 1 + _doctor_line(FAIL, "security_config", f"invalid {security_config}: {exc}") + else: + policy = loaded_security.policy if loaded_security is not None else "personal" + _doctor_line(OK, "security_config", f"{security_config} (policy={policy})") + else: + _doctor_line(WARN, "security_config", f"missing, run `brigade security init --target {effective_target}`") + + security_artifacts = security_cmd.default_artifacts_dir(effective_target) + security_bundle = security_cmd.inspect_evidence_bundle(security_artifacts) + if security_bundle.get("ready"): + _doctor_line( + OK, + "security_evidence", + f"{security_artifacts} " + f"(generated_at={security_bundle.get('generated_at')}, findings={security_bundle.get('finding_count')})", + ) + else: + _doctor_line( + WARN, + "security_evidence", + f"{security_bundle.get('reason')}; run `brigade security scan --target {effective_target} --output-dir {security_artifacts}`", + ) + codex_path = shutil.which("codex") if codex_path is None: failures += 1 @@ -2291,6 +2322,8 @@ def doctor(*, target: Path) -> int: _doctor_line(_doctor_ignore_level(config_ignored), "config_ignored", config_ignored) artifacts_ignored = dogfood_cmd._check_git_ignored(effective_target, artifacts_dir) _doctor_line(_doctor_ignore_level(artifacts_ignored), "artifacts_ignored", artifacts_ignored) + security_ignored = dogfood_cmd._check_git_ignored(effective_target, security_artifacts) + _doctor_line(_doctor_ignore_level(security_ignored), "security_ignored", security_ignored) work_ignored = dogfood_cmd._check_git_ignored(effective_target, work_root) _doctor_line(_doctor_ignore_level(work_ignored), "work_ignored", work_ignored) handoff_ignored = dogfood_cmd._check_git_ignored(effective_target, handoff_inbox) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 768a33e7..33ab8f7c 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -32,6 +32,52 @@ def test_doctor_reports_failures_on_empty_dir(tmp_target: Path, capsys): assert "[fail]" in out +def test_doctor_reports_security_config_and_evidence_bundle(tmp_target: Path, capsys): + install_selection( + tmp_target, + Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), + ) + from brigade import security_cmd + + security_cmd.init(target=tmp_target) + security_dir = tmp_target / ".brigade" / "security" / "latest" + security_dir.mkdir(parents=True) + (security_dir / "security-report.json").write_text( + json.dumps( + { + "generated_at": "2026-05-26T12:00:00Z", + "finding_count": 0, + "policy": "personal", + } + ) + ) + (security_dir / "security-report.md").write_text("# Brigade Security Report\n") + + rc = doctor_mod.run(target=tmp_target, harness="generic") + out = capsys.readouterr().out + assert rc == 0 + assert "security: config" in out + assert "policy=personal" in out + assert "security: evidence bundle" in out + assert "findings=0" in out + + +def test_doctor_fails_invalid_security_config(tmp_target: Path, capsys): + install_selection( + tmp_target, + Selection(depth="workspace", harnesses=["claude"], owner="claude", includes=[]), + ) + security_config = tmp_target / ".brigade" / "security.toml" + security_config.parent.mkdir(exist_ok=True) + security_config.write_text('policy = "not-real"\n') + + rc = doctor_mod.run(target=tmp_target, harness="generic") + out = capsys.readouterr().out + assert rc == 1 + assert "security: config" in out + assert "invalid" in out + + def test_doctor_fails_when_bootstrap_file_exceeds_budget(tmp_target: Path, capsys): install_selection( tmp_target, diff --git a/tests/test_work_cmd.py b/tests/test_work_cmd.py index 538a1f4c..1df770e3 100644 --- a/tests/test_work_cmd.py +++ b/tests/test_work_cmd.py @@ -4,6 +4,7 @@ from brigade import cli from brigade import dogfood_cmd +from brigade import security_cmd from brigade import work_cmd @@ -72,6 +73,14 @@ def test_work_status_rejects_bad_limit(tmp_path, capsys): def test_work_doctor_reports_ready_repo(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) dogfood_cmd.init(target=tmp_path) + security_cmd.init(target=tmp_path) + security_dir = tmp_path / ".brigade" / "security" / "latest" + security_dir.mkdir(parents=True) + _write_json( + security_dir / "security-report.json", + {"generated_at": "2026-05-26T12:00:00Z", "finding_count": 0, "policy": "personal"}, + ) + (security_dir / "security-report.md").write_text("# Brigade Security Report\n") run_dir = tmp_path / ".brigade" / "runs" / "latest" run_dir.mkdir(parents=True) _write_json(run_dir / "run.json", {"started_at": "2026-05-26T12:00:00Z", "status": "ok", "task": "review"}) @@ -85,11 +94,28 @@ def test_work_doctor_reports_ready_repo(tmp_path, monkeypatch, capsys): assert "[ok] target:" in out assert "[ok] git:" in out assert "[ok] dogfood_config:" in out + assert "[ok] security_config:" in out + assert "[ok] security_evidence:" in out assert "[ok] codex: /usr/bin/codex" in out assert "[ok] latest_next: Build doctor." in out assert "[ok] ready: daily work loop is usable" in out +def test_work_doctor_fails_invalid_security_config(tmp_path, monkeypatch, capsys): + _init_git_repo(tmp_path) + dogfood_cmd.init(target=tmp_path) + security_config = tmp_path / ".brigade" / "security.toml" + security_config.write_text('policy = "not-real"\n') + monkeypatch.setattr(work_cmd.shutil, "which", lambda name: f"/usr/bin/{name}" if name == "codex" else None) + monkeypatch.setattr(dogfood_cmd, "_check_git_ignored", lambda repo, path: "yes") + + assert work_cmd.doctor(target=tmp_path) == 1 + out = capsys.readouterr().out + assert "[fail] security_config:" in out + assert "invalid" in out + assert "[fail] ready: 1 blocker" in out + + def test_work_doctor_reports_blockers(tmp_path, monkeypatch, capsys): _init_git_repo(tmp_path) monkeypatch.setattr(work_cmd.shutil, "which", lambda name: None)