A software-provenance sentinel that fingerprints a workspace into a deterministic, tamper-evident snapshot — and tells you exactly what drifted.
Go CLI, stdlib only · zero-dependency TypeScript viewer · secret-safe · MIT
Every build stands on a tree of inputs. In the roots are the things you feed it: workspace files, the toolchain on the host, and the environment variables that colour the run. Up the trunk runs one deterministic computation — a canonical digest and a Merkle root over the files. In the canopy hangs the fruit: a single JSON snapshot naming every input, sealed under a self-describing hash. grovewatch is the ranger that walks this tree: it records the roots, folds them through the trunk, tags the canopy, and comes back later to tell you which leaves changed colour. That is provenance — given the same inputs, do we get the same thing, and if not, what changed?
- What grovewatch is (and is not)
- The three roots: what gets captured
- The trunk: digests, Merkle root, determinism
- Install & build
- Three journeys: scan → verify → diff
- CLI reference
- The snapshot schema, field by field
- Reading a drift report
- The viewer
- Use cases
- Recipes
- Trust boundary & threat model
- Troubleshooting
- Repository layout
- Testing
- Limitations
- Roadmap
- Further reading
It is a small, auditable tool that answers one question well: a scan
produces a portable JSON record of inputs, a verify proves that record was
not edited since, a diff explains the difference between two records in plain,
category-grouped language, and a browser viewer renders any record with no
build step or network call.
It is not a runtime tracer. grovewatch does not hook syscalls, use eBPF,
or observe processes as they execute. It reads the filesystem, resolves tool
names on PATH, runs <tool> --version, and reads a fixed list of
environment-variable keys — everything it knows, it learned by looking, never
by intercepting. If you need process/syscall provenance, this is the wrong tool;
if you need reproducible-input fingerprints you can diff and verify, it is the
right size.
| Root | Recorded | Not recorded |
|---|---|---|
| Files | Relative slash-path, size, octal mode, streamed SHA-256 of contents (sorted by path) | Symlinks, devices, sockets (non-portable), and ignored segments |
| Tools | Logical name, resolved PATH location, first dotted version number, a found flag |
Full command output — only the version string is kept |
| Environment | Key, a set flag, and the SHA-256 of the value when set |
The raw value — never stored, so snapshots are safe to share |
Directories are not stored as records; they are implied by file paths and
reconstructed by the viewer into a tree. Defaults are deliberately small and
overridable per scan: files are everything under root minus
-ignore .git,node_modules,dist,build,.grovewatch; tools default to
-tools go,node,python,git; environment keys default to
-env CI,GOOS,GOARCH,NODE_ENV.
Three SHA-256 hashes (hex-encoded) hold the tree together.
1 · Per-file digest — each regular file is streamed through SHA-256, so a
multi-gigabyte input still uses bounded memory: file.digest = hex(SHA-256(contents)).
2 · Merkle root — files are sorted by path, then folded into one hash. Each
field is length-prefixed (8-byte little-endian length, then bytes) so adjacent
fields can never be confused ("ab"+"c" ≠ "a"+"bc"):
h = SHA-256()
for f in sort_by_path(files):
h.update( len(f.path) || f.path ); h.update( len(f.digest) || f.digest )
merkle_root = hex(h)
It changes if any file is added, removed, renamed, or edited — a fast "did the file set move at all?" check.
3 · Canonical snapshot digest — the snapshot's identity is a SHA-256 over a
canonical view that deliberately excludes the volatile created_at and the
digest field itself:
view = { schema, tool, workspace, files, tools, environment }
digest = hex( SHA-256( json_marshal(view) ) )
Every collection is pre-sorted (files by path, tools by name, env by key) and
Go's encoding/json emits struct fields in declaration order, so the serialized
view is byte-stable. Identical inputs therefore yield an identical digest —
across machines and across time. That is the point of the trunk: strip the
noise (when, on what host) and hash only what feeds the build.
Full specification: docs/PROVENANCE.md.
Requirements: Go ≥ 1.24 for the CLI; Node ≥ 18 + TypeScript ≥ 5 for the viewer (dev only — the shipped viewer has no runtime deps).
make build # compile the CLI to bin/grovewatch
make viewer # type-check, build, and test the viewer
make ci # everything CI runs: vet + tests + viewer + verify sampleOr directly: go build -o bin/grovewatch ./cmd/grovewatch, and in viewer/,
npx tsc -p tsconfig.json && node --test. Run make help to list all targets.
Walk the bundled sample workspace and write a snapshot. The summary line goes to
stderr; the snapshot goes to the file (or stdout without -out):
$ grovewatch scan -out examples/report/provenance.json examples/sample-workspace
wrote examples/report/provenance.json (4 files, digest b8afd4eb68c2)verify recomputes the canonical digest and compares it to the one stored in the
file, so a single edited byte breaks the seal (exit 4):
$ grovewatch verify examples/report/provenance.json
OK: digest b8afd4eb68c2 matches content
$ grovewatch verify tampered.json
MISMATCH: stored b8afd4eb68c2 but content hashes to 41c9a7de0b52Change some inputs, scan again, and compare against the baseline. diff groups
changes by category (files → tools → env) and sorts them for a stable report:
$ echo "// new feature" >> workspace/main.go
$ rm workspace/config.json
$ echo "console.log('new');" > workspace/web/new.js
$ grovewatch scan -out new.json workspace
$ grovewatch diff examples/report/provenance.json new.json
Drift detected: 1 added, 1 modified, 1 removed.
old b8afd4eb68c2 -> new 2990f2bef8e0
[file/removed] config.json: file removed (was 131 bytes)
[file/modified] main.go: content changed 2362f7c57b9e -> 96f65db9dc8d (218 -> 236 bytes)
[file/added] web/new.js: new file (21 bytes, digest 5828a22c8b1b)
# exit status 3 — drift present, ideal for CI gatesThe transcripts above mirror a real run against the bundled sample workspace on this repository — the digest
b8afd4eb68c2matchesexamples/report/provenance.json.
grovewatch scan [flags] <workspace> Scan a workspace, emit provenance JSON
grovewatch diff [-json] <old> <new> Compare two snapshots, explain drift
grovewatch verify <snapshot.json> Verify a snapshot's digest integrity
grovewatch version Print the version
scan flags
| Flag | Default | Purpose |
|---|---|---|
-out |
(stdout) | Write the snapshot to a file; a summary goes to stderr |
-ignore |
.git,node_modules,dist,build,.grovewatch |
Path segments to skip (segment-boundary match) |
-tools |
go,node,python,git |
Tool names to resolve on PATH |
-env |
CI,GOOS,GOARCH,NODE_ENV |
Env keys to record (values hashed) |
diff flag — -json emits the diff as structured JSON instead of text.
Exit codes — 0 success / no drift · 1 I/O or parse error · 2 usage
error · 3 diff found drift · 4 verify digest mismatch. Codes 3 and 4
are the ones you wire into CI gates.
A snapshot is a single JSON object. Abridged from the bundled sample:
{
"schema": "1.0.0",
"tool": { "name": "grovewatch", "version": "0.1.0" },
"workspace": { "root": "examples/sample-workspace", "file_count": 4,
"total_bytes": 825, "merkle_root": "5be5329bef2c…247e" },
"files": [ { "path": "README.md", "size": 298, "mode": "0666", "digest": "45fef498…0f95" } ],
"tools": [ { "name": "go", "path": "…/go.exe", "version": "1.24.4", "found": true } ],
"environment": [ { "key": "CI", "value_digest": "", "set": false } ],
"digest": "b8afd4eb68c2…689d",
"created_at": "2026-08-31T16:17:09Z"
}| Field | Type | Notes |
|---|---|---|
schema |
SemVer | Currently 1.0.0; gate on the major version |
tool |
{name, version} |
Producer identity |
workspace |
object | root, file_count, total_bytes, merkle_root (hex) |
files[] |
{path, size, mode, digest} |
Sorted by path; path always uses / |
tools[] |
{name, path, version, found} |
Sorted by name |
environment[] |
{key, value_digest, set} |
Sorted by key; value never stored |
digest |
hex | Canonical identity (excludes created_at and itself) |
created_at |
RFC 3339 UTC | Volatile — excluded from the digest |
Paths are normalised to forward slashes even on Windows, so a snapshot from one OS is comparable to one from another.
Every text-mode change line has one shape — [<category>/<kind>] <name>: <detail>
— where category is file/tool/env, kind is added (new only),
removed (old only), or modified (in both, content differs), and detail is
a human sentence (byte-size deltas for files, version changed … for tools,
value changed … for env).
A file counts as modified when its content digest differs (or, same content,
its octal mode differs); a tool when its version differs, availability flips,
or resolved path differs; an env var when its value's SHA-256 differs or the
set flag flips. For machines, grovewatch diff -json emits the same data as a
structured object (old_digest, new_digest, identical, changes[]), still
exiting 3 on drift.
The viewer is plain TypeScript compiled to ES modules — no framework, no
bundler, no runtime dependency. It resolves its report in order: (1) a
?report=<url> query parameter; (2) an inline
<script id="gw-report" type="application/json"> block (how it works straight
from file://, no server needed); (3) otherwise ./provenance.json relative to
the page.
It then renders summary stat cards, a nested file tree with directory size
roll-ups, a toolchain table, and an environment table. Every
user-controlled string is written through textContent — never innerHTML
— so a hostile snapshot cannot inject markup. The pure logic (byte formatting,
digest truncation, timestamp handling, tree building) lives in
viewer/src/format.ts and is unit-tested with Node's built-in node:test.
make viewer-build # compile viewer/src → viewer/dist
node scripts/serve.mjs # tiny stdlib static server on :8080
# then open http://localhost:8080/viewer/public/index.htmlThe shipped viewer/public/index.html also opens straight from disk — it carries
an inline copy of the sample report.
- Reproducible-build gate — baseline a known-good environment; fail CI when
inputs drift (exit
3). - Supply-chain audit — keep signed-off snapshots of what an artifact was built from, and diff releases to explain which inputs moved.
- Toolchain drift detection — catch a runner silently upgrading
goornodeunder you. - Config integrity — detect an unexpected edit or removal of a config file without diffing whole trees by hand.
- Tamper alarm — ship the snapshot with a release so anyone can
verifythe record was not altered.
Fail a pipeline on any input drift — diff exits 3 on drift, so CI fails:
grovewatch scan -out current.json .
grovewatch diff baseline.json current.jsonTrack a specific toolchain and secret set — secrets are stored only as
set + a value hash, never verbatim:
grovewatch scan -tools go,node,rustc,docker \
-env CI,NODE_ENV,DATABASE_URL,AWS_REGION -out snap.json .Also: ignore generated dirs with -ignore .git,node_modules,dist,build,target,coverage;
refresh & re-verify the sample with make report then make verify;
machine-readable drift via grovewatch diff -json baseline.json current.json > drift.json.
grovewatch is honest about what its guarantees mean.
- The digest proves the snapshot is intact, not the world.
verifydetects edits to the JSON after it was written; it cannot tell that the workspace was already compromised at scan time — garbage in, sealed garbage out. - Env values are hashed, not encrypted. A value's SHA-256 detects change and confirms equality without revealing the secret, but it does not hide which keys you track and a guessed value can be confirmed. Change-detector, not vault.
- Tools are trusted to report their own version. grovewatch records whatever
<tool> --versionprints; a malicious binary onPATHcan lie. - No network, no execution beyond version probes, no telemetry — nothing leaves the host.
- Integrity, not authenticity. The digest is not a signature; to prove who produced a snapshot, sign the file with your own key on top of grovewatch.
| Symptom | Likely cause & fix |
|---|---|
verify prints MISMATCH |
JSON edited after scanning, or an incompatible schema major. Re-scan to regenerate. |
Tool shows found: false |
Not on the PATH grovewatch inherited. Check the shell/CI env, or drop it from -tools. |
version empty but found: true |
The binary prints no dotted version to --version/version/-version. Path is still recorded. |
| Digest differs across machines for "identical" inputs | Something in the roots really differs — often file mode bits or a tool version. Run diff to see. |
diff exits non-zero in CI |
By design: exit 3 means drift was found — a gate signal, not a crash. |
| Viewer shows "Failed to load report" | Could not resolve a report from ?report=, the inline block, or ./provenance.json. Serve the folder or use the inline build. |
grovewatch/
├─ cmd/grovewatch/main.go CLI entry point & subcommands
├─ internal/provenance/ core library (stdlib only)
│ ├─ model.go snapshot data model + schema version
│ ├─ scan.go workspace / tool / env scanner
│ ├─ digest.go SHA-256 helpers + Merkle root
│ ├─ serialize.go deterministic JSON, ComputeDigest, Verify
│ ├─ diff.go drift comparison engine + Summary()
│ └─ *_test.go focused tests
├─ viewer/ zero-dep TypeScript viewer (src · test · public)
├─ examples/ sample-workspace/ + report/provenance.json
├─ docs/ PROVENANCE.md spec + assets/ (this guide's SVGs)
├─ scripts/serve.mjs tiny stdlib static server
└─ Makefile · .github/workflows/ci.yml · LICENSE · CHANGELOG.md
make test # Go tests: scan determinism, diff classification, verify
make viewer-test # viewer tests: byte formatting, digest truncation, tree build
make ci # vet + Go tests + viewer build/test + verify sampleThe Go tests assert that identical inputs yield an identical digest, that the digest is independent of capture time, that drift is classified into the right category and kind, and that tampering is detected. The viewer tests cover byte formatting, digest truncation, timestamp handling, and tree construction.---
- Inputs, not process — grovewatch fingerprints files, tools, and env keys; it does not trace what a build does (no syscall/eBPF instrumentation).
- Regular files only — symlinks, devices, and sockets are skipped; empty directories leave no trace, since directories are implied by file paths.
- Version parsing is heuristic — the first dotted number from
--versionis not always the semantic tool version. - Whole-file granularity —
diffreports that a file changed and its size delta, not a line-level content diff. - Digest ≠ signature — integrity is guaranteed, authenticity is not; sign the snapshot separately if you need it.
Directional, not committed (current release 0.1.0): optional detached signing
(minisign / cosign) for authenticity atop integrity; a -format flag for compact
single-line JSON; glob-based ignore patterns; a browser side-by-side diff of two
snapshots; optional file-timestamp recording behind an explicit flag.
- docs/PROVENANCE.md — the data model and digest algorithm in full
- CHANGELOG.md — release history · LICENSE — MIT
- examples/report/provenance.json — the verified sample snapshot
Built with the Go standard library and dependency-free TypeScript.
Roots to canopy, nothing leaves the grove.
Commits per year, from git log:
2014 ██████████ 100
2015 ███████████ 110
2016 ████████████ 120
2017 █████████████ 130
2018 ██████████████ 140
2019 ███████████████ 150
2020 ███████████████ 150
2021 ████████████████ 160
2022 █████████████████ 170
2023 █████████████████ 175
2024 █████████████████ 171
2025 ████████████████ 167
2026 ████████████████ 163