grison mirrors Ghostwriter (findings, reports, evidence) and BookStack (wiki) into a plain markdown workspace and syncs edits back. Its job is to validate what changed and sync it safely, whether a person or an agent made the edit.
Requires Python ≥3.11 on a POSIX system (grison uses os.chmod/fcntl.flock; it does
not run on Windows).
pipx install grison # or: uv tool install grisonmkdir engagement && cd engagement
grison syncThe first run in an empty directory scaffolds the whole workspace — findings/,
methodology/, .grison/ (with a commented credentials template at .grison/env),
CLAUDE.md, and .claude/settings.json — then stops and asks you to fill in
Ghostwriter (and, optionally, BookStack) credentials in .grison/env. Re-run
grison sync and it pulls every existing report, finding, and wiki page into the
tree.
.grison/env is created chmod 600 and is never committed (see
SECURITY.md). GRISON_* environment variables override the file, for
CI/headless use.
<workspace>/
.grison/
CLAUDE.md
.claude/
settings.json
.gitignore
findings/
library/
reports/
<report-dir>/
evidence/
narrative/
notes/
project.md
.report.yml
inbox/
methodology/
library/
<book>/
<chapter>/
images/
.book.yml
.shelves/
checklists/
A concrete example, annotated:
findings/
library/
weak-tls-config.md # reusable finding, not tied to any report
reports/
acme-corp-web-assessment/ # one directory per Ghostwriter report, named from its title
reflected-xss.md # a reported finding
evidence/
xss-alert.png # image referenced from a finding/narrative/note
narrative/
executive_summary.md # one file per report extraFields section
notes/
follow-up.md # a project note
project.md .report.yml # read-only mirrors, regenerated every sync
inbox/
sql-injection.md # `grison parse` output — triage, then `mv`/`cp`
methodology/
library/
web-application-testing/
.book.yml # read-only mirror of the BookStack book
recon.md # a page at the book root
images/
recon-diagram.png
reconnaissance/
.chapter.yml # read-only mirror of the BookStack chapter
subdomain-enum.md # a page inside that chapter
.shelves/
pentest-methodologies.yml # read-only mirror of a BookStack shelf
checklists/
acme-2026-08/ # a per-engagement working copy of a book
findings/inbox/ and methodology/checklists/<engagement>/ are local-only: fully
validated, but never synced and never recorded in .grison/index.json. Everything
else in findings//methodology/ is one of the shapes below — an unrecognized file
or directory anywhere in either tree is a hard validation failure.
The complete, exact spec (every rule, with its id) is
docs/workspace-format.md; a copy also ships inside every
workspace at .grison/SPEC.md. This section is the day-to-day summary.
No machine fields, no ids, ever. No document — finding, narrative section, note,
wiki page — carries a grison: block, an id, or a hash. Identity (which remote record
a file corresponds to) lives entirely in the tracked .grison/index.json, keyed by
the file's path.
File and directory names are stable handles. A name you create may be anything
matching [a-z0-9][a-z0-9._-]* (lowercase, starts with a letter/digit). Once grison
creates a file or directory on pull, it never renames it again, even after the remote
title changes. The one exception: a file directly inside an evidence/ or images/
folder keeps its name verbatim (non-ASCII, mixed case, whatever it arrived with) —
that name is still checked, just against a different, narrower rule (no path
separator, no leading dot, not shaped like a collision sidecar, valid UTF-8, ≤255
bytes).
A finding's frontmatter:
| field | required | notes |
|---|---|---|
severity |
yes | informational, low, medium, high, critical |
finding_type |
yes | network, physical, wireless, web, mobile, cloud, host |
cvss.vector |
no | a CVSS 3.0/3.1 base vector; the score is always derived |
cwe |
no | list of CWE ids, checked against an embedded MITRE index |
tags |
no | list of strings, no duplicates, no surrounding whitespace |
affected_entities |
no | free text — reported/inbox findings only, never findings/library/ |
No other frontmatter key is allowed. The body is exactly one # {title} line, then
## Description, ## Impact, ## Mitigation, ## Replication Steps,
## References, each exactly once, in that order. When cvss.vector is set,
severity must agree with its score band (inbox findings are exempt — a scanner's raw
severity rating routinely disagrees with a naive CVSS band before you've triaged it).
A wiki page's frontmatter: title (required, non-blank), priority (optional
integer, BookStack sort order), tags (optional, same shape rule as a finding's).
No other key. A page's book and chapter come from which directory it sits in — there
is no book/chapter field. The body is markdown BookStack stores and renders
verbatim: no real HTML tags (a placeholder like <domain> is fine), only
http:/https:/mailto: links, no heading-level skips, and the first heading must
not repeat the title.
The only evidence/image form is an image line, alone in its own block:
 in a finding, narrative
section, or note;  on a wiki page at its book's root, or
 one chapter down. A plain link to the same path —
[see Figure 1](evidence/file.png) — is a cross-reference, not an embed, and may
appear inline. Neither form is ever allowed in a library or inbox finding (there is
no report to hold evidence for). Removing an image line never deletes the file.
Write a template-injection payload literally. {7*7}, {{7*7}}, {% debug %},
${{ ... }} and similar Jinja/Handlebars/Go-template syntax are quoted exactly as the
target reflected them, in a code span when it's code. Don't escape or "defuse" it —
grison escapes it correctly on the way to Ghostwriter.
grison sync never asks push-or-pull: a per-record stored merge base decides.
- Only the local file changed since the last sync → push.
- Only the remote record changed → pull.
- Both changed → collision: the remote version is written to a
<name>.remote.<ext>sidecar next to the file, the local file is never overwritten. Resolve by hand, thengrison sync --force-local <path>or--force-remote <path>. A force flag always means "this side wins for this path", including on a deletion the change guard withheld:--force-remoterestores a locally deleted record instead of deleting it remotely,--force-localrecreates a remotely deleted one instead of deleting it locally.
Every record must pass grison validate before it's pushed, created, or deleted — the
validation gate blocks pushes, never pulls. A mass, sudden change (many records
looking edited/deleted at once, more than the change guard's threshold) withholds the
writes for that run instead of applying them, reported as withheld. When the bulk
change is deliberate (a first import, a whole report's worth of new findings), run
grison sync --allow-mass-change once: the guard steps aside for that run only, and
every write still lands in the undo snapshot.
Every remote write in one grison sync run — findings, report, and wiki phases alike
— shares one undo snapshot (see grison undo below).
grison validate (and, in the same spirit, status/sync) uses a three-way exit
code so a script can tell "your documents are wrong" apart from "the command itself
couldn't run":
| code | meaning |
|---|---|
0 |
clean — nothing to fix, nothing failed |
1 |
ran to completion and found real problems (an invalid document, a collision, a withheld mass-change, a failed sync phase) |
2 |
could not run at all — e.g. validate/status found no workspace or were given a bad path, or sync hit an incompatible Ghostwriter schema before its first fetch |
-
grison parse <path…> [--scanner NAME] [-o DIR] [--finding-type TYPE] [--min-severity SPEC] [--dry-run]— turn a scanner export into markdown findings underfindings/inbox/(default) or-o DIR. Fully offline; auto-detects the scanner from file content, or force it with--scanner. Supported: Acunetix, Burp Suite, Nessus, OpenVAS, Qualys (network scan and WAS export alike), sslyze, OWASP ZAP. Nmap files are recognised but refused — nmap is reconnaissance output, not findings; its own inventory support is pending. Pre-v5 sslyze JSON is also refused. A file that's refused, unreadable, or fails to parse is reported under its own heading and makesgrison parseexit1. -
grison status [--remote] [--json]— a whole-workspace overview: per-area counts (clean/edited/new/deleted/moved/invalid/unknown), plus any live collision sidecar, and when each phase last synced. Offline by default;--remotecontacts Ghostwriter and BookStack and dry-run classifies every phase ofgrison sync(report, evidence, narrative/notes, findings, wiki), printing per-kind pending push/pull/collision counts and problem paths without writing anything. -
grison validate [PATHS…] [--json] [--deleted-ok]— the format checker, offline, no credentials, no network. Runs from anywhere inside the workspace (walks up to.grison/, likegit). WithoutPATHS, checks everything. One line per failure:path:line: RULE-ID message — fix. APATHSentry that matches nothing exits2(could not run), never a silent0. -
grison sync [--dry-run] [--force-local PATH] [--force-remote PATH] [--allow-mass-change] [--json] [--verbose]— reconcile with Ghostwriter and (if configured) BookStack. Bootstraps on first run. -
grison undo [SNAPSHOT] [--list]— reverse a whole sync run's remote writes, newest write first, guarded by the same pre-write re-fetch check the forward sync uses (a record changed since the snapshot is reported, never silently overwritten). One snapshot pergrison syncrun, kept under.grison/snapshots/; the newest 10 are kept, older ones pruned automatically. Owner-only — the scaffolded.claude/settings.jsondenies an agent bothgrison syncandgrison undo. -
grison scaffold [--force]— (re)generate every scaffolded file on demand (e.g. after upgrading grison):.grison/SPEC.md,.grison/templates/,.grison/terms.txt,CLAUDE.md,.claude/settings.json, the root.gitignore's collision-sidecar entry, and (in a git repo) thepre-commithook. Runs automatically on everygrison sync/grison parsetoo.
Every workspace is scaffolded to survive an AI agent editing it unsupervised:
CLAUDE.md— generated agent instructions: layout, naming, frontmatter tables, the evidence/image line forms, and "rungrison validatebefore you finish; fix every failure; never work around one." Regenerated when its marker is stale; a stale, hand-edited copy failsgrison validate(WS-012) untilgrison scaffold --forceregenerates it..claude/settings.jsondeny-list — deniesReadon the genuinely private.grison/entries (env,state/,snapshots/,lock,terms.txt), deniesEdit/Writeon all of.grison/and on every read-only mirror (project.md,.report.yml,.book.yml,.chapter.yml,.shelves/**), and denies the Bash textgrison sync/grison undoin any spelling (advisory, not a sandbox — see the module's own caveat). Self-healing on every run.- A
PostToolUsehook (grison hook post-edit) validates only the file an agent just edited (or deleted) and prints the failures back to it. It can never block the edit — aPostToolUsehook runs after the tool call already happened — it only informs. - A git
pre-commithook runs a whole-workspacegrison validateand blocks the commit on failure. An existing, non-grisonpre-commithook is never overwritten; the text to add by hand is printed instead.
- Ghostwriter ≥ 7.2.0 (feature-gated, not version-string-checked: grison verifies the live GraphQL schema has what its own queries/mutations need, via a cached fingerprint probe with a full introspection fallback on drift). Tested against 7.2.6.
- BookStack, tested against the 26.05.x REST API. There is no equivalent hard schema gate for BookStack.
See SECURITY.md for how to report a vulnerability and what grison does (and doesn't do) with your credentials.
The full workspace-format spec, with a rule id for every check grison validate
performs, is docs/workspace-format.md.
MIT. Named after the mustelid.