diff --git a/docs/overview.md b/docs/overview.md
index c7c3243b..b84a2153 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -337,13 +337,13 @@ Safety and operations tools:
- Content Guard is embedded in Brigade and powers `brigade scrub`, publish checks, and the seeded pre-push hook. `CONTENT_GUARD_DIR` remains an explicit compatibility override for older standalone checkouts.
- [Agent Pantry](https://github.com/escoffier-labs/agentpantry): encrypted browser session, cookie, and secret sync for agent machines.
-- [agent-notify](https://github.com/escoffier-labs/agent-notify) (`stations/notify/` in this monorepo): optional notification hooks for long-running agent work. Released installs resolve the pinned managed `agent-notify` binary through `brigade setup` once a stable manifest publishes its assets. The standalone repository carries a migration notice and is not archived until a containing Brigade release ships and published acceptance passes.
+- [agent-notify](https://github.com/escoffier-labs/brigade/tree/main/stations/notify) (`stations/notify/` in this monorepo): optional notification hooks for long-running agent work. Released installs resolve the pinned managed `agent-notify` binary through `brigade setup` once a stable manifest publishes its assets. The standalone [agent-notify](https://github.com/escoffier-labs/agent-notify) repository carries a migration notice pointing here.
- [Token Glace](https://github.com/escoffier-labs/token-glace): output compaction for terminal-heavy agent workflows.
- Built-in Scout skills: Brigade wires `brigade-work` and `ultra-work-scout` during `brigade init`; use Skillet when you want the full optional skill roster.
Evidence ledger tools:
-- [MiseLedger](https://github.com/escoffier-labs/miseledger): local-first evidence ledger. One binary crawls sessions, files, git history, and chat sources (`miseledger crawl ...`), stores `miseledger.adapter.v1` JSONL in SQLite with FTS5, and emits Brigade-ready evidence bundles. No separate exporter install.
+- [MiseLedger](https://github.com/escoffier-labs/brigade/tree/main/engines/evidence-ledger) (`engines/evidence-ledger/` in this monorepo): local-first evidence ledger. One binary crawls sessions, files, git history, and chat sources (`miseledger crawl ...`), stores `miseledger.adapter.v1` JSONL in SQLite with FTS5, and emits Brigade-ready evidence bundles. The archived [miseledger](https://github.com/escoffier-labs/miseledger) repository is a frozen history mirror.
- Brigade station CLI (process boundary):
- `brigade setup` installs GraphTrail, `graphtrail-mcp`, MiseLedger, SessionFind, and `agent-notify` (when published on the release manifest) from the exact release manifest
- `brigade add evidence` is a one-release compatibility fallback for an independent MiseLedger install
@@ -509,7 +509,7 @@ brigade pantry expiry-alert --send # optional agent-notify (install notificati
The `notifications` station wires optional `agent-notify` into the same operator workflow: private Discord, Telegram, or Signal delivery for long-running agent work. Source lives in [`stations/notify/`](../stations/notify/) in this repository. `agent-notify` stays a **separate Go binary** (process boundary). Brigade installs it, plans setup, and health-checks it; it does not send messages from doctor, status, or brief flows.
-Released pipx installs resolve `agent-notify` from the pinned unified release manifest through `brigade setup` once stable publishes its assets. `go install github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest` is the explicit fallback when you are on a source checkout or the component is not yet published on the running manifest. The standalone [agent-notify](https://github.com/escoffier-labs/agent-notify) repository carries a migration notice pointing here; it is not archived until a containing Brigade release ships and published acceptance passes.
+Released pipx installs resolve `agent-notify` from the pinned unified release manifest through `brigade setup` once stable publishes its assets. `go install github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest` is the explicit fallback when you are on a source checkout or the component is not yet published on the running manifest. Source lives in [`stations/notify/`](../stations/notify/); the standalone [agent-notify](https://github.com/escoffier-labs/agent-notify) repository carries a migration notice pointing here.
```bash
brigade add notifications
diff --git a/docs/phase-4a-compatibility-and-archive.md b/docs/phase-4a-compatibility-and-archive.md
index 932cd2c4..65145005 100644
--- a/docs/phase-4a-compatibility-and-archive.md
+++ b/docs/phase-4a-compatibility-and-archive.md
@@ -133,7 +133,7 @@ guidance from documentation.
- [x] Migrate operator MCP client configuration to the Brigade-managed absolute path installed by `brigade setup` (operator machine migrated 2026-07-21: codex, Cursor, OpenClaw, and Claude configs plus the capped MCP wrapper now use the managed set).
- [x] Confirm migration notices as ordinary commits on both mirrors (graphtrail PR #44 and miseledger PR #44, merged 2026-07-21).
- [x] Verify that neither standalone `master` branch was rewritten or force-pushed (ancestry-checked against the pre-notice heads on 2026-07-21).
-- [ ] Update product and documentation links to the Brigade release path.
+- [x] Update product and documentation links to the Brigade release path.
- [x] Archive `escoffier-labs/graphtrail` (archived 2026-07-21).
- [x] Archive `escoffier-labs/miseledger` (archived 2026-07-21).
diff --git a/docs/security.md b/docs/security.md
index 05787bd7..b2916ec4 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -10,7 +10,7 @@ Content Guard is Brigade's publish and memory-safety scanner. Brigade shells out
Use it in three places:
-- `brigade handoff lint --content-guard --guard-policy personal` checks pending handoffs before memory ingest.
+- `brigade handoff lint --content-guard --guard-policy personal` checks pending handoffs before memory ingest. The flag runs content-guard for secret and identity leaks and Brigade injection heuristics for instruction-shaped payloads in handoff bodies (for example override phrases, fake system blocks, and base64-decode chains). Injection hits are reported as line-numbered warnings; benign discussion of prompt injection may appear as info-level notes.
- `brigade handoff draft --guard --guard-policy personal ...` writes a draft and returns failure if Content Guard blocks it.
- `brigade work import content-guard --policy public-repo` runs a scan and turns blocking findings into reviewable work imports.
diff --git a/docs/technical-guide.md b/docs/technical-guide.md
index a4b55654..4e34cf29 100644
--- a/docs/technical-guide.md
+++ b/docs/technical-guide.md
@@ -1378,7 +1378,7 @@ Use `brigade pantry status` and `brigade pantry doctor` for pantry-specific heal
Use `brigade pantry expiry-alert` to report near-expiry sessions and preview the `agent-notify` message Brigade would send. Add `--send` only after `brigade add notifications` if you want delivery.
These plan commands do not generate or copy PSKs, start services, or mutate browser, GitHub, OpenClaw, or other auth files. Product page: https://brigade.tools/agentpantry.
-`notifications` is the operator notification station. `agent-notify` remains a process-boundary Go binary; Brigade never imports it. Source lives in `stations/notify/` in this repository. Released pipx installs resolve `agent-notify` from the pinned unified release manifest through `brigade setup` once stable publishes its assets. `go install github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest` is the explicit fallback when you are on a source checkout or the component is not yet published on the running manifest. The standalone [agent-notify](https://github.com/escoffier-labs/agent-notify) repository carries a migration notice pointing at the monorepo; it is not archived until a containing Brigade release ships and published acceptance passes.
+`notifications` is the operator notification station. `agent-notify` remains a process-boundary Go binary; Brigade never imports it. Source lives in [`stations/notify/`](../stations/notify/) in this repository. Released pipx installs resolve `agent-notify` from the pinned unified release manifest through `brigade setup` once stable publishes its assets. `go install github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest` is the explicit fallback when you are on a source checkout or the component is not yet published on the running manifest. The standalone [agent-notify](https://github.com/escoffier-labs/agent-notify) repository carries a migration notice pointing at the monorepo.
`brigade add notifications` installs `agent-notify` when missing and prints manual wiring steps.
Use `brigade notifications status` and `brigade notifications setup plan` for advisory health and reviewed hook snippets without sending.
`brigade work brief`, `brigade center status`, and `brigade daily status/plan` may surface notification readiness or suggest installing the station; Brigade never sends unless the operator uses an explicit send action such as `brigade pantry expiry-alert --send`.
diff --git a/src/brigade/cli/handoff.py b/src/brigade/cli/handoff.py
index e7d95b41..dd2f652c 100644
--- a/src/brigade/cli/handoff.py
+++ b/src/brigade/cli/handoff.py
@@ -45,7 +45,9 @@ def register(sub: argparse._SubParsersAction) -> None:
)
p_handoff_lint.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
p_handoff_lint.add_argument(
- "--content-guard", action="store_true", help="Also scan handoff files with content-guard."
+ "--content-guard",
+ action="store_true",
+ help="Run content-guard leak scan plus handoff injection heuristics (secrets/PII and instruction-shaped payloads).",
)
p_handoff_lint.add_argument(
"--guard-policy", default="personal", help="Content Guard policy name or path for --content-guard."
diff --git a/src/brigade/handoff_cmd/linting.py b/src/brigade/handoff_cmd/linting.py
index 6926f4ba..eaa77322 100644
--- a/src/brigade/handoff_cmd/linting.py
+++ b/src/brigade/handoff_cmd/linting.py
@@ -24,6 +24,30 @@
globals().update({name: value for name, value in vars(_family_base).items() if not name.startswith("__")})
+def _injection_hit_dict(hit: Any) -> dict[str, Any]:
+ return {
+ "line": hit.line,
+ "severity": hit.severity,
+ "rule": hit.rule,
+ "excerpt": hit.excerpt,
+ }
+
+
+def _injection_messages(hits: tuple[Any, ...]) -> tuple[str, ...]:
+ messages: list[str] = []
+ for hit in hits:
+ prefix = "info" if hit.severity == "info" else "warning"
+ messages.append(f"line {hit.line}: {prefix}: [{hit.rule}] {hit.excerpt}")
+ return tuple(messages)
+
+
+def _read_handoff_text(path: Path) -> str | None:
+ try:
+ return path.read_text(errors="replace")
+ except OSError:
+ return None
+
+
def lint(
*,
target: Path,
@@ -36,29 +60,55 @@ def lint(
if not target.is_dir():
print(f"error: --target is not a directory: {target}", file=sys.stderr)
return 2
- from ..untrusted import scan_untrusted
+ from ..untrusted import scan_handoff_injection_heuristics, scan_untrusted
results = lint_targets(target, paths=paths)
- guard_results = (
- [_guard_handoff_path(path, target=target, policy=guard_policy) for path in [result.path for result in results]]
- if content_guard
- else []
- )
+ guard_results: list[dict[str, Any]] = []
+ if content_guard:
+ for result in results:
+ guard_item = _guard_handoff_path(result.path, target=target, policy=guard_policy)
+ text = _read_handoff_text(result.path)
+ hits = scan_handoff_injection_heuristics(text or "") if text is not None else ()
+ guard_item["injection_heuristics"] = [_injection_hit_dict(hit) for hit in hits]
+ guard_item["injection_warning_count"] = len([hit for hit in hits if hit.severity == "warning"])
+ guard_results.append(guard_item)
guard_ok = all(item.get("exit_code") == 0 for item in guard_results)
- # Content-guard checks egress (secrets/PII), not instructions. Surface the
- # injection signal here too so a poisoned note never reads as fully clean.
injection_counts: dict[str, int] = {}
+ injection_hits_by_path: dict[str, tuple[Any, ...]] = {}
+ enriched_results: list[HandoffLintResult] = []
for result in results:
- try:
- signal = scan_untrusted(result.path.read_text(errors="replace"))
- except OSError:
+ text = _read_handoff_text(result.path)
+ if text is None:
+ enriched_results.append(result)
continue
+ hits = scan_handoff_injection_heuristics(text)
+ injection_hits_by_path[str(result.path)] = hits
+ signal = scan_untrusted(text)
if signal.flagged:
injection_counts[str(result.path)] = signal.count
+ injection_messages = _injection_messages(hits) if content_guard or hits else ()
+ if not injection_messages and signal.flagged:
+ injection_messages = (
+ f"line ?: warning: [{signal.count} prompt-injection signal(s); see `brigade security scan`]",
+ )
+ enriched_results.append(
+ HandoffLintResult(
+ path=result.path,
+ action=result.action,
+ valid=result.valid,
+ errors=result.errors,
+ warnings=result.warnings + injection_messages,
+ hints=result.hints,
+ )
+ )
+ results = tuple(enriched_results)
result_dicts = []
for result in results:
row = result.as_dict()
- row["injection_signals"] = injection_counts.get(str(result.path), 0)
+ path_key = str(result.path)
+ row["injection_signals"] = injection_counts.get(path_key, 0)
+ hits = injection_hits_by_path.get(path_key, ())
+ row["injection_heuristics"] = [_injection_hit_dict(hit) for hit in hits]
result_dicts.append(row)
payload = {
"target": str(target),
@@ -82,17 +132,23 @@ def lint(
for hint in result.hints:
print(f" hint: {hint}")
for warning in result.warnings:
- print(f" warning: {warning}")
- signals = injection_counts.get(str(result.path), 0)
- if signals:
- print(
- f" warning: {signals} prompt-injection signal(s); content-guard does not check this, see `brigade security scan`"
- )
+ if warning.startswith("line "):
+ print(f" {warning}")
+ else:
+ print(f" warning: {warning}")
if content_guard:
- print(f"content_guard_policy: {guard_policy}")
+ print(f"content_guard_policy: {guard_policy} (leak scan + injection heuristics)")
for item in guard_results:
- status = OK if item.get("exit_code") == 0 else FAIL
- print(f"[{status}] content_guard: {item.get('path')} {item.get('detail')}")
+ leak_status = OK if item.get("exit_code") == 0 else FAIL
+ print(f"[{leak_status}] content_guard leaks: {item.get('path')} {item.get('detail')}")
+ warning_count = int(item.get("injection_warning_count") or 0)
+ if warning_count:
+ print(f" warning: {warning_count} injection heuristic hit(s)")
+ for hit in item.get("injection_heuristics") or []:
+ if hit.get("severity") == "info":
+ print(f" line {hit['line']}: info: [{hit['rule']}] {hit['excerpt']}")
+ elif hit.get("severity") == "warning":
+ print(f" line {hit['line']}: warning: [{hit['rule']}] {hit['excerpt']}")
return 0 if payload["valid"] else 1
diff --git a/src/brigade/templates/policies/personal.json b/src/brigade/templates/policies/personal.json
index 551b14a3..3f843376 100644
--- a/src/brigade/templates/policies/personal.json
+++ b/src/brigade/templates/policies/personal.json
@@ -2,7 +2,7 @@
"_comment": [
"Personal policy: for local working notes and memory handoffs before ingest.",
"It blocks secrets and attribution trailers, while warning on personal or infrastructure-like context.",
- "Use via: brigade handoff lint --content-guard --guard-policy personal"
+ "Use via: brigade handoff lint --content-guard --guard-policy personal (leak scan + injection heuristics)"
],
"_brigade_version": "0.25.1",
"categories": {
diff --git a/src/brigade/untrusted.py b/src/brigade/untrusted.py
index b84b573d..4cc0b613 100644
--- a/src/brigade/untrusted.py
+++ b/src/brigade/untrusted.py
@@ -14,7 +14,7 @@
import hashlib
import re
from dataclasses import dataclass
-from typing import List, Optional
+from typing import Iterable, List, Optional
_INJECTION_PATTERNS = (
"ig" + "nore (all )?(previous|prior) instructions",
@@ -82,6 +82,14 @@ def wrap_untrusted(
return "\n".join(parts)
+@dataclass(frozen=True)
+class InjectionHit:
+ line: int
+ severity: str
+ rule: str
+ excerpt: str
+
+
@dataclass
class InjectionSignal:
flagged: bool
@@ -89,20 +97,135 @@ class InjectionSignal:
markers: List[str]
+_LINE_RULES: tuple[tuple[str, re.Pattern[str]], ...] = (
+ ("classic-injection", PROMPT_INJECTION_RE),
+ ("disregard-system-prompt", re.compile(r"(?i)disregard (your |the )?system prompt")),
+ (
+ "ignore-instructions",
+ re.compile(r"(?i)ignore (all |any )?(previous|prior|above) (instructions|directives)"),
+ ),
+ ("fake-system-block", re.compile(r"(?i)(<\s*/?\s*system\b|\[INST\]|\[/INST\]|<<\s*SYS\s*>>)")),
+ ("role-override", re.compile(r"(?i)\byou are now\b")),
+ (
+ "assistant-directive",
+ re.compile(
+ r"(?i)(?:^(?:assistant|agent|ai)\s*[,:-]\s*(?:you )?(?:must|should|need to)\b"
+ r"|(?:dear|hey) (?:assistant|agent|ai)\b.*\b(?:ignore|disregard|override)\b)"
+ ),
+ ),
+)
+
+_BASE64_BLOB = re.compile(r"[A-Za-z0-9+/]{80,}={0,2}")
+_DECODE_INSTRUCTION = re.compile(r"(?i)\b(?:base64|atob|decode|decrypt)\b")
+
+_BENIGN_LINE_MARKERS = (
+ re.compile(r"(?i)\bprompt[- ]injection\b"),
+ re.compile(r"(?i)\binjection (?:heuristic|signal|scan|detection|mitigation|fixture)\b"),
+ re.compile(r"(?i)\b(?:example|documented|detected|benign|fixture|quoted|pattern|mitigation)\b"),
+ re.compile(r"(?i)\b(?:scans?|checks?) for\b"),
+)
+
+
+def _excerpt(line: str) -> str:
+ return line.strip()[:_MARKER_MAX]
+
+
+def _benign_injection_discussion(line: str, *, text: str) -> bool:
+ if any(marker.search(line) for marker in _BENIGN_LINE_MARKERS):
+ return True
+ if "`" in line and any(token in line.lower() for token in ("ignore", "disregard", "