diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd3b8295..7c9a2d50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `brigade security scan --import-findings` to route security findings into the local work import inbox for review.
- `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.
- 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.
@@ -74,7 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `brigade work run` now consumes the oldest pending ledger task before falling back to the latest extracted dogfood next step, and marks consumed tasks done after successful runs.
- `brigade work task add --from-next` now reuses an equivalent pending task instead of adding duplicates.
- `brigade work brief` now includes pending local work imports and import counts in both text and JSON output.
-- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, and `.brigade/runs/` as local state.
+- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, `.brigade/runs/`, and `.brigade/security/` as local state.
- Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`.
- Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`.
- Dogfood runs default to prompt-level read-only plus Codex's `danger-full-access` sandbox setting for trusted-workspace use so repo inspection works on hosts where native read-only sandboxing blocks shell inspection; `--native-read-only-sandbox` opts into stricter native enforcement.
diff --git a/README.md b/README.md
index fa61ad9a..65a1e672 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,7 @@ brigade runs show .brigade/runs/
brigade security init
brigade security scan --target .
brigade security scan --target . --policy public-repo
+brigade security scan --target . --output-dir .brigade/security/latest
brigade security scan --target . --import-findings
```
@@ -289,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, or add `--import-findings` to turn findings into local `brigade work import` review items. Secret evidence is redacted before reports 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. 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 1311902b..8214859e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -52,7 +52,7 @@ Baseline coverage targets:
- Analyze hooks and startup automation for command injection, remote execution, data exfiltration, silent failures, package installs, container escape, reverse shells, clipboard access, log tampering, and persistence behaviors.
- Audit MCP server configs for high-risk server types, remote transports, shell metacharacters, unpinned `npx` usage, hardcoded env secrets, sensitive file args, excessive server counts, missing timeouts, and auto-approve behavior.
- Review agent prompts, skills, subagents, slash commands, and workspace instructions for prompt-injection patterns, hidden instructions, URL execution, data harvesting, output suppression, time bombs, and unsafe auto-run language.
-- Emit graded reports with severity, category scores, evidence snippets, suggested fixes, JSON output, markdown output, HTML or bundle output, and CI-friendly exit codes.
+- Emit graded reports with severity, category scores, evidence snippets, suggested fixes, JSON output, markdown output, HTML or bundle output, and CI-friendly exit codes. Status: started with redacted JSON and Markdown evidence bundles.
- Support CLI use, GitHub Action use, and local evidence packs.
Brigade-specific additions:
@@ -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, then route selected findings into `brigade work import`. Status: started with `--import-findings`.
+- Output JSON plus readable text, redacted evidence bundles, then route selected findings into `brigade work import`. Status: started with `--output-dir` 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/cli.py b/src/brigade/cli.py
index e38549b3..22abc693 100644
--- a/src/brigade/cli.py
+++ b/src/brigade/cli.py
@@ -394,6 +394,12 @@ def _build_parser() -> argparse.ArgumentParser:
p_security_scan = security_sub.add_parser("scan", help="Run a read-only agent workspace security scan.")
p_security_scan.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to scan.")
p_security_scan.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
+ p_security_scan.add_argument(
+ "--output-dir",
+ type=Path,
+ default=None,
+ help="Write redacted security report artifacts to this directory.",
+ )
p_security_scan.add_argument(
"--policy",
choices=["personal", "public-repo", "strict"],
@@ -793,6 +799,7 @@ def main(argv=None) -> int:
fail_on=args.fail_on,
include_templates=args.include_templates,
import_findings=args.import_findings,
+ output_dir=args.output_dir,
)
parser.error(f"unknown security command: {args.security_command}")
return 2
diff --git a/src/brigade/install.py b/src/brigade/install.py
index 1bc2051f..cdd274d5 100644
--- a/src/brigade/install.py
+++ b/src/brigade/install.py
@@ -66,6 +66,7 @@ def build_gitignore_block(selection: Selection) -> str:
".brigade/logs/",
".brigade/runs/",
".brigade/scrub-cache/",
+ ".brigade/security/",
".brigade/work/",
GITIGNORE_END,
"",
diff --git a/src/brigade/security_cmd.py b/src/brigade/security_cmd.py
index 159074fc..183aa728 100644
--- a/src/brigade/security_cmd.py
+++ b/src/brigade/security_cmd.py
@@ -6,6 +6,7 @@
import json
import re
import sys
+from datetime import datetime, timezone
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -49,6 +50,7 @@
SKIP_PREFIXES = (
(".brigade", "runs"),
+ (".brigade", "security"),
(".brigade", "work"),
(".claude", "memory-handoffs"),
(".codex", "memory-handoffs"),
@@ -563,6 +565,63 @@ def _import_findings(target: Path, findings: list[dict[str, Any]]) -> tuple[list
return imported, skipped
+def _utc_iso() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _render_markdown_report(report: dict[str, Any]) -> str:
+ lines = [
+ "# Brigade Security Report",
+ "",
+ f"- target: `{report['target']}`",
+ f"- generated_at: `{report['generated_at']}`",
+ f"- policy: `{report['policy']}`",
+ f"- fail_on: `{report['fail_on']}`",
+ f"- include_templates: `{report['include_templates']}`",
+ f"- scanned_files: `{report['scanned_file_count']}`",
+ f"- findings: `{report['finding_count']}`",
+ f"- suppressed: `{report['suppressed_count']}`",
+ "",
+ "## Severity Counts",
+ "",
+ ]
+ if report["severity_counts"]:
+ for severity, count in report["severity_counts"].items():
+ lines.append(f"- {severity}: {count}")
+ else:
+ lines.append("- none: 0")
+ lines.extend(["", "## Findings", ""])
+ if not report["findings"]:
+ lines.append("No unsuppressed findings.")
+ for finding in report["findings"]:
+ lines.extend(
+ [
+ f"### {finding['id']} - {finding['title']}",
+ "",
+ f"- fingerprint: `{finding['fingerprint']}`",
+ f"- severity: `{finding['severity']}`",
+ f"- category: `{finding['category']}`",
+ f"- path: `{finding['path']}:{finding['line']}`",
+ f"- surface: `{finding['surface']}`",
+ f"- confidence: `{finding['confidence']}`",
+ f"- evidence: `{finding['evidence']}`",
+ f"- suggestion: {finding['suggestion']}",
+ "",
+ ]
+ )
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def write_evidence_bundle(report: dict[str, Any], output_dir: Path) -> Path:
+ output_dir = output_dir.expanduser().resolve()
+ output_dir.mkdir(parents=True, exist_ok=True)
+ report = dict(report)
+ report["artifacts"] = str(output_dir)
+ (output_dir / "security-report.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
+ (output_dir / "security-report.md").write_text(_render_markdown_report(report))
+ return output_dir
+
+
def scan(
*,
target: Path,
@@ -571,6 +630,7 @@ def scan(
fail_on: str | None = None,
include_templates: bool | None = None,
import_findings: bool = False,
+ output_dir: Path | None = None,
) -> int:
target = target.expanduser().resolve()
if not target.is_dir():
@@ -597,12 +657,16 @@ def scan(
report["include_templates"] = effective.include_templates
report["config"] = str(effective.config_path)
report["config_loaded"] = effective.config_loaded
+ report["generated_at"] = _utc_iso()
imported: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
if import_findings and report["findings"]:
imported, skipped = _import_findings(target, report["findings"])
report["imported_findings"] = len(imported)
report["skipped_duplicate_imports"] = len(skipped)
+ if output_dir is not None:
+ artifacts_dir = write_evidence_bundle(report, output_dir)
+ report["artifacts"] = str(artifacts_dir)
if json_output:
print(json.dumps(report, indent=2, sort_keys=True))
@@ -619,6 +683,8 @@ def scan(
if import_findings:
print(f"imported_findings: {len(imported)}")
print(f"skipped_duplicate_imports: {len(skipped)}")
+ if output_dir is not None:
+ print(f"artifacts: {report['artifacts']}")
for finding in report["findings"]:
print(
f"- [{finding['severity']}] {finding['category']} "
diff --git a/tests/test_gitignore.py b/tests/test_gitignore.py
index 415e78fe..82475d84 100644
--- a/tests/test_gitignore.py
+++ b/tests/test_gitignore.py
@@ -28,6 +28,7 @@ def test_init_creates_gitignore_when_missing(tmp_target: Path):
assert ".brigade/dogfood.toml" in gi
assert ".brigade/security.toml" in gi
assert ".brigade/runs/" in gi
+ assert ".brigade/security/" in gi
assert ".brigade/work/" in gi
@@ -183,6 +184,7 @@ def test_gitignore_block_includes_claude_section_when_selected():
assert "!.claude/memory-handoffs/TEMPLATE.md" in block
assert ".brigade/dogfood.toml" in block
assert ".brigade/runs/" in block
+ assert ".brigade/security/" in block
assert ".brigade/work/" in block
assert ".codex/memory-handoffs" not in block
diff --git a/tests/test_security_cmd.py b/tests/test_security_cmd.py
index 0a6eef0b..69ebf57f 100644
--- a/tests/test_security_cmd.py
+++ b/tests/test_security_cmd.py
@@ -119,6 +119,32 @@ def test_security_scan_can_import_findings(tmp_path, capsys):
assert "skipped_duplicate_imports: 1" in out
+def test_security_scan_writes_redacted_evidence_bundle(tmp_path, capsys):
+ (tmp_path / ".env").write_text("SERVICE_TOKEN=abcd1234abcd1234abcd1234\n")
+ output_dir = tmp_path / ".brigade" / "security" / "latest"
+
+ assert security_cmd.scan(target=tmp_path, fail_on="none", output_dir=output_dir) == 0
+ out = capsys.readouterr().out
+ assert f"artifacts: {output_dir.resolve()}" in out
+
+ json_path = output_dir / "security-report.json"
+ markdown_path = output_dir / "security-report.md"
+ assert json_path.is_file()
+ assert markdown_path.is_file()
+
+ payload = json.loads(json_path.read_text())
+ assert payload["artifacts"] == str(output_dir.resolve())
+ assert payload["generated_at"]
+ assert payload["finding_count"] == 1
+ assert "[REDACTED]" in json_path.read_text()
+ assert "abcd1234" not in json_path.read_text()
+ markdown = markdown_path.read_text()
+ assert "# Brigade Security Report" in markdown
+ assert "Possible sensitive secret material" in markdown
+ assert "[REDACTED]" in markdown
+ assert "abcd1234" not in markdown
+
+
def test_security_scan_cli(tmp_path, monkeypatch):
seen = {}
@@ -141,6 +167,8 @@ def fake_scan(**kwargs):
"medium",
"--include-templates",
"--import-findings",
+ "--output-dir",
+ str(tmp_path / "security-report"),
]
)
== 0
@@ -152,6 +180,7 @@ def fake_scan(**kwargs):
"fail_on": "medium",
"include_templates": True,
"import_findings": True,
+ "output_dir": tmp_path / "security-report",
}