receipt-evidence 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritish Saini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: receipt-evidence
3
+ Version: 0.1.0
4
+ Summary: Run a command, and get a receipt for what it actually touched -- not just what it was asked to do.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/receipt
7
+ Project-URL: Source, https://github.com/MaXiMo000/receipt
8
+ Project-URL: Issues, https://github.com/MaXiMo000/receipt/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/receipt/releases
10
+ Keywords: verification,audit,agents,provenance,evidence
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # receipt
23
+
24
+ **Run a command. Get a receipt for what it actually touched — not just what
25
+ it was asked to do.**
26
+
27
+ An AI agent (or a script, or a CI job) says it's going to fix a bug in one
28
+ file. Nothing checks whether that's what it actually did until someone
29
+ reviews the diff by hand, if they do at all. `receipt` snapshots the working
30
+ directory before and after, and reports `pass`, `fail`, or `unverified` —
31
+ same three-status shape as
32
+ [invariant](https://github.com/MaXiMo000/invariant),
33
+ [firedrill](https://github.com/MaXiMo000/firedrill), and
34
+ [carabiner](https://github.com/MaXiMo000/carabiner).
35
+
36
+ ```
37
+ $ receipt run --task "fix the auth bug" --declare app/auth.py \
38
+ -- python fix_auth.py
39
+ [FAIL] touched 1 undeclared file(s): app/payments.py
40
+ receipt written to receipts/20260908T121251Z-4f2c9a1b.json
41
+ ```
42
+
43
+ ## Three statuses, one of them meaning something different here
44
+
45
+ `pass` — touched only what was declared. `fail` — touched something outside
46
+ the declared scope, named exactly. `unverified` — no scope was declared for
47
+ this run at all.
48
+
49
+ That third one is a deliberate difference from invariant/firedrill/
50
+ carabiner, where `unverified` means "a check that should have run, didn't."
51
+ Here it means "no promise was made this time" — plain audit logging is a
52
+ normal, legitimate use of this tool, not a degraded one. So `unverified`
53
+ does **not** fail the build; only a broken declared promise (`fail`) does.
54
+
55
+ ## What it actually does
56
+
57
+ 1. Hashes and permission-bits every file under the watched directory (sha256
58
+ + mode, skipping `.git`, `__pycache__`, etc.).
59
+ 2. Runs the given command, captures stdout/stderr/exit code/timing, and
60
+ redacts secret-shaped text (env-var-style `API_KEY=...` assignments,
61
+ credentialed URLs, well-known token prefixes, PEM key blocks) before any
62
+ of it is stored — see "What redaction doesn't mean" below. A command that
63
+ never launches at all (bad `--dir`, missing binary) still produces a
64
+ receipt — `fail`, with the launch error as the detail — instead of a
65
+ Python traceback and no evidence.
66
+ 3. Snapshots the directory again, diffs the two. A removed path and an added
67
+ path with identical content are reported as one `renamed` pair, not an
68
+ unrelated delete-plus-create; a path whose content is byte-identical but
69
+ whose permission bits changed is reported as `mode_changed` — see "What
70
+ `touched` means" below.
71
+ 4. If a scope was declared (exact paths, or glob patterns like `app/*.py`),
72
+ checks the diff against it.
73
+ 5. Writes the whole thing — command, task, diff, declared scope, verdict —
74
+ to `receipts/<timestamp>-<random>.json` alongside a sha256 of the receipt
75
+ itself, same evidence-bundle idiom as invariant's `--evidence`.
76
+
77
+ Zero dependencies — stdlib only (`hashlib`, `subprocess`, `argparse`,
78
+ `fnmatch`).
79
+
80
+ ## Install
81
+
82
+ ```bash
83
+ pip install receipt-evidence # the command it installs is `receipt`
84
+ ```
85
+
86
+ Or from a checkout, for development:
87
+
88
+ ```bash
89
+ pip install -e .
90
+ ```
91
+
92
+ ## Use
93
+
94
+ ```bash
95
+ receipt run --task "what this is supposed to do" \
96
+ --declare path/one.py,app/*.py \
97
+ --dir . --out receipts/ \
98
+ -- your-command --with --args
99
+ ```
100
+
101
+ Omit `--declare` to just log what happened without a scope to check it
102
+ against (`unverified`, still a written receipt, still exit 0).
103
+
104
+ ## Test
105
+
106
+ ```bash
107
+ python tests/test_receipt.py
108
+ ```
109
+
110
+ ## What `touched` means
111
+
112
+ `touched` is the union of every file that was added, removed, had its
113
+ content modified, was renamed (a removed path and an added path sharing a
114
+ content hash), or had its permission bits changed with content otherwise
115
+ identical. A rename or a chmod on a path outside the declared scope is a
116
+ real `fail`, named clearly — `sneaky.txt (renamed from output.txt)`, or
117
+ `secret.env (permissions changed, content unchanged)` — not silently
118
+ folded into "nothing happened" the way a plain content-hash diff would.
119
+
120
+ ## What `pass` doesn't mean
121
+
122
+ `pass` only means "touched nothing outside the declared scope **within
123
+ `--dir`**." A write anywhere outside that tree — `/tmp`, `~`, a sibling
124
+ directory, an absolute path elsewhere in a monorepo — is invisible to
125
+ `receipt` and won't affect the verdict. Point `--dir` at the smallest tree
126
+ that actually bounds what the task could legitimately touch; don't read
127
+ `pass` as "touched nothing on the filesystem."
128
+
129
+ ## What redaction doesn't mean
130
+
131
+ Captured stdout/stderr and the command's own argv are swept for
132
+ secret-shaped text (`receipt/redact.py`) before a receipt is written — this
133
+ closes a real gap found during review: a wrapped command that echoed
134
+ `API_KEY=sk-...` landed that value verbatim in the receipt JSON. The sweep
135
+ is a regex net for common shapes, not a guarantee. It will not catch a
136
+ secret with no recognizable shape (e.g. a bare 40-character hex string with
137
+ no key name attached, split across two log lines, or base64-wrapped). If a
138
+ command's output might contain something sensitive in an unusual shape,
139
+ don't assume the receipt is safe to share as-is — read it first.
140
+
141
+ ## What's deliberately not here yet
142
+
143
+ No network/API-call capture — only filesystem diffing. "What did this agent
144
+ touch" is answerable this way; "what did this agent call" isn't, without
145
+ hooking into a specific agent framework's own trace or intercepting
146
+ traffic, which is a real, separate, much bigger project.
147
+
148
+ No policy evaluation or rule composition beyond a flat declared-scope
149
+ check — that's deliberately a different tool's job. `receipt` stays the
150
+ evidence producer;
151
+ [invariant](https://github.com/MaXiMo000/invariant) is where richer policy
152
+ (is this evidence actually OK, across multiple runs, with other checks
153
+ composed in) belongs.
154
+
155
+ MIT licensed.
@@ -0,0 +1,134 @@
1
+ # receipt
2
+
3
+ **Run a command. Get a receipt for what it actually touched — not just what
4
+ it was asked to do.**
5
+
6
+ An AI agent (or a script, or a CI job) says it's going to fix a bug in one
7
+ file. Nothing checks whether that's what it actually did until someone
8
+ reviews the diff by hand, if they do at all. `receipt` snapshots the working
9
+ directory before and after, and reports `pass`, `fail`, or `unverified` —
10
+ same three-status shape as
11
+ [invariant](https://github.com/MaXiMo000/invariant),
12
+ [firedrill](https://github.com/MaXiMo000/firedrill), and
13
+ [carabiner](https://github.com/MaXiMo000/carabiner).
14
+
15
+ ```
16
+ $ receipt run --task "fix the auth bug" --declare app/auth.py \
17
+ -- python fix_auth.py
18
+ [FAIL] touched 1 undeclared file(s): app/payments.py
19
+ receipt written to receipts/20260908T121251Z-4f2c9a1b.json
20
+ ```
21
+
22
+ ## Three statuses, one of them meaning something different here
23
+
24
+ `pass` — touched only what was declared. `fail` — touched something outside
25
+ the declared scope, named exactly. `unverified` — no scope was declared for
26
+ this run at all.
27
+
28
+ That third one is a deliberate difference from invariant/firedrill/
29
+ carabiner, where `unverified` means "a check that should have run, didn't."
30
+ Here it means "no promise was made this time" — plain audit logging is a
31
+ normal, legitimate use of this tool, not a degraded one. So `unverified`
32
+ does **not** fail the build; only a broken declared promise (`fail`) does.
33
+
34
+ ## What it actually does
35
+
36
+ 1. Hashes and permission-bits every file under the watched directory (sha256
37
+ + mode, skipping `.git`, `__pycache__`, etc.).
38
+ 2. Runs the given command, captures stdout/stderr/exit code/timing, and
39
+ redacts secret-shaped text (env-var-style `API_KEY=...` assignments,
40
+ credentialed URLs, well-known token prefixes, PEM key blocks) before any
41
+ of it is stored — see "What redaction doesn't mean" below. A command that
42
+ never launches at all (bad `--dir`, missing binary) still produces a
43
+ receipt — `fail`, with the launch error as the detail — instead of a
44
+ Python traceback and no evidence.
45
+ 3. Snapshots the directory again, diffs the two. A removed path and an added
46
+ path with identical content are reported as one `renamed` pair, not an
47
+ unrelated delete-plus-create; a path whose content is byte-identical but
48
+ whose permission bits changed is reported as `mode_changed` — see "What
49
+ `touched` means" below.
50
+ 4. If a scope was declared (exact paths, or glob patterns like `app/*.py`),
51
+ checks the diff against it.
52
+ 5. Writes the whole thing — command, task, diff, declared scope, verdict —
53
+ to `receipts/<timestamp>-<random>.json` alongside a sha256 of the receipt
54
+ itself, same evidence-bundle idiom as invariant's `--evidence`.
55
+
56
+ Zero dependencies — stdlib only (`hashlib`, `subprocess`, `argparse`,
57
+ `fnmatch`).
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install receipt-evidence # the command it installs is `receipt`
63
+ ```
64
+
65
+ Or from a checkout, for development:
66
+
67
+ ```bash
68
+ pip install -e .
69
+ ```
70
+
71
+ ## Use
72
+
73
+ ```bash
74
+ receipt run --task "what this is supposed to do" \
75
+ --declare path/one.py,app/*.py \
76
+ --dir . --out receipts/ \
77
+ -- your-command --with --args
78
+ ```
79
+
80
+ Omit `--declare` to just log what happened without a scope to check it
81
+ against (`unverified`, still a written receipt, still exit 0).
82
+
83
+ ## Test
84
+
85
+ ```bash
86
+ python tests/test_receipt.py
87
+ ```
88
+
89
+ ## What `touched` means
90
+
91
+ `touched` is the union of every file that was added, removed, had its
92
+ content modified, was renamed (a removed path and an added path sharing a
93
+ content hash), or had its permission bits changed with content otherwise
94
+ identical. A rename or a chmod on a path outside the declared scope is a
95
+ real `fail`, named clearly — `sneaky.txt (renamed from output.txt)`, or
96
+ `secret.env (permissions changed, content unchanged)` — not silently
97
+ folded into "nothing happened" the way a plain content-hash diff would.
98
+
99
+ ## What `pass` doesn't mean
100
+
101
+ `pass` only means "touched nothing outside the declared scope **within
102
+ `--dir`**." A write anywhere outside that tree — `/tmp`, `~`, a sibling
103
+ directory, an absolute path elsewhere in a monorepo — is invisible to
104
+ `receipt` and won't affect the verdict. Point `--dir` at the smallest tree
105
+ that actually bounds what the task could legitimately touch; don't read
106
+ `pass` as "touched nothing on the filesystem."
107
+
108
+ ## What redaction doesn't mean
109
+
110
+ Captured stdout/stderr and the command's own argv are swept for
111
+ secret-shaped text (`receipt/redact.py`) before a receipt is written — this
112
+ closes a real gap found during review: a wrapped command that echoed
113
+ `API_KEY=sk-...` landed that value verbatim in the receipt JSON. The sweep
114
+ is a regex net for common shapes, not a guarantee. It will not catch a
115
+ secret with no recognizable shape (e.g. a bare 40-character hex string with
116
+ no key name attached, split across two log lines, or base64-wrapped). If a
117
+ command's output might contain something sensitive in an unusual shape,
118
+ don't assume the receipt is safe to share as-is — read it first.
119
+
120
+ ## What's deliberately not here yet
121
+
122
+ No network/API-call capture — only filesystem diffing. "What did this agent
123
+ touch" is answerable this way; "what did this agent call" isn't, without
124
+ hooking into a specific agent framework's own trace or intercepting
125
+ traffic, which is a real, separate, much bigger project.
126
+
127
+ No policy evaluation or rule composition beyond a flat declared-scope
128
+ check — that's deliberately a different tool's job. `receipt` stays the
129
+ evidence producer;
130
+ [invariant](https://github.com/MaXiMo000/invariant) is where richer policy
131
+ (is this evidence actually OK, across multiple runs, with other checks
132
+ composed in) belongs.
133
+
134
+ MIT licensed.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ # The command stays `receipt`; the distribution cannot -- both "receipt" and
3
+ # "receipt-verify" are already taken on PyPI (checked, not assumed). A
4
+ # distribution name differing from the command it installs is ordinary
5
+ # (python-dateutil installs `dateutil`).
6
+ name = "receipt-evidence"
7
+ version = "0.1.0"
8
+ description = "Run a command, and get a receipt for what it actually touched -- not just what it was asked to do."
9
+ requires-python = ">=3.10"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["verification", "audit", "agents", "provenance", "evidence"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Testing",
21
+ ]
22
+ dependencies = []
23
+
24
+ urls.Homepage = "https://github.com/MaXiMo000/receipt"
25
+ urls.Source = "https://github.com/MaXiMo000/receipt"
26
+ urls.Issues = "https://github.com/MaXiMo000/receipt/issues"
27
+ urls.Changelog = "https://github.com/MaXiMo000/receipt/releases"
28
+
29
+ [project.scripts]
30
+ receipt = "receipt.cli:main"
31
+
32
+ [build-system]
33
+ # 77 is the floor for PEP 639 (`license = "MIT"` as an SPDX expression) --
34
+ # a lesson already paid for in a sibling project's own release workflow,
35
+ # applied here before it bites this one too.
36
+ requires = ["setuptools>=77"]
37
+ build-backend = "setuptools.build_meta"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["receipt*"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,52 @@
1
+ """receipt run --task "..." [--declare a.py,app/*.py] [--out receipts/] -- <command...>"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ from .core import run as run_task
8
+ from .evidence import write as write_receipt
9
+ from .model import FAIL
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ argv = sys.argv[1:] if argv is None else argv
14
+ if "--" in argv:
15
+ split = argv.index("--")
16
+ own_args, cmd = argv[:split], argv[split + 1:]
17
+ else:
18
+ own_args, cmd = argv, []
19
+
20
+ parser = argparse.ArgumentParser(prog="receipt")
21
+ sub = parser.add_subparsers(dest="command", required=True)
22
+
23
+ run_p = sub.add_parser("run", help="run a command and receipt what it touched")
24
+ run_p.add_argument("--task", required=True, help="what the command was asked to do")
25
+ run_p.add_argument("--dir", default=".", dest="watch_dir", help="directory to watch (default: .)")
26
+ run_p.add_argument("--declare", default=None,
27
+ help="comma-separated relative paths (globs like app/*.py allowed) the "
28
+ "task is allowed to touch; omit to record without a declared scope "
29
+ "(status: unverified)")
30
+ run_p.add_argument("--out", default="receipts", help="directory to write the receipt into")
31
+
32
+ args = parser.parse_args(own_args)
33
+
34
+ if not cmd:
35
+ print("error: no command given -- pass it after `--`", file=sys.stderr)
36
+ return 2
37
+
38
+ declared = args.declare.split(",") if args.declare else None
39
+ result = run_task(args.task, cmd, watch_dir=args.watch_dir, declared_paths=declared)
40
+ path = write_receipt(result, args.out)
41
+
42
+ print(f"[{result['status'].upper()}] {result['detail']}")
43
+ print(f"receipt written to {path}")
44
+
45
+ # Unlike invariant, `unverified` here is not a failure to gain
46
+ # assurance -- it's "no scope was promised this run," a normal mode
47
+ # (plain audit logging). Only a broken promise (FAIL) fails the build.
48
+ return 1 if result["status"] == FAIL else 0
49
+
50
+
51
+ if __name__ == "__main__":
52
+ raise SystemExit(main())
@@ -0,0 +1,123 @@
1
+ """Run a command, snapshot its working directory before and after, and check
2
+ what it actually touched against what it was declared to touch.
3
+
4
+ This is the whole tool: everything else (CLI, evidence writer) is plumbing
5
+ around this one function.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import subprocess
11
+ import time
12
+
13
+ from . import model
14
+ from .redact import redact
15
+ from .snapshot import diff as diff_snapshots
16
+ from .snapshot import snapshot
17
+
18
+
19
+ def _is_declared(path: str, declared: set[str]) -> bool:
20
+ """A path is covered if it's an exact declared entry, or matches one as
21
+ a glob (`fnmatch`, case-sensitive on every platform -- consistent
22
+ matching regardless of OS matters more here than following whatever
23
+ case convention the local filesystem happens to use).
24
+
25
+ Exact match is checked first and separately so a literal declared path
26
+ containing an unintentional glob character (`[`, `]`, `?`, `*` in a
27
+ real filename) still matches itself even if it also happens to be a
28
+ strange glob pattern -- globs are additive, not a replacement for
29
+ exact matching.
30
+ """
31
+ if path in declared:
32
+ return True
33
+ return any(fnmatch.fnmatchcase(path, pattern) for pattern in declared)
34
+
35
+
36
+ def run(task: str, cmd: list[str], watch_dir: str = ".",
37
+ declared_paths: list[str] | None = None) -> dict:
38
+ """Execute `cmd` in `watch_dir`, and report what changed there.
39
+
40
+ declared_paths, if given, is the set of relative paths (or glob
41
+ patterns, e.g. `app/*.py`) the task claimed it would touch. Anything
42
+ touched that doesn't match one of them is a `fail`. If declared_paths
43
+ is None, no claim was made -- the receipt still records exactly what
44
+ happened, but the status is `unverified`: there's nothing to check the
45
+ touched files against.
46
+ """
47
+ before = snapshot(watch_dir)
48
+ started = time.monotonic()
49
+ try:
50
+ proc = subprocess.run(cmd, cwd=watch_dir, capture_output=True, text=True, errors="replace")
51
+ except OSError as exc:
52
+ # The command never ran at all -- `--dir` doesn't exist, the binary
53
+ # isn't found, no permission to execute it. The one promise this
54
+ # tool makes is "get a receipt for what actually happened," and
55
+ # that has to hold here too: report it as a receipt, don't crash
56
+ # before any evidence exists at all. Nothing ran, so nothing was
57
+ # touched -- but the declared promise clearly wasn't kept either,
58
+ # which is a fail, not "nothing to check" (that's what an absent
59
+ # --declare means, a different situation from this one).
60
+ return {
61
+ "task": task,
62
+ "command": [redact(part) for part in cmd],
63
+ "watch_dir": watch_dir,
64
+ "exit_code": None,
65
+ "seconds": round(time.monotonic() - started, 3),
66
+ "stdout": "",
67
+ "stderr": "",
68
+ "declared_paths": declared_paths,
69
+ "changes": {"added": [], "modified": [], "removed": [], "renamed": [], "mode_changed": []},
70
+ "unexpected": [],
71
+ "status": model.FAIL,
72
+ "detail": f"could not launch the command: {exc}",
73
+ }
74
+ seconds = time.monotonic() - started
75
+ after = snapshot(watch_dir)
76
+
77
+ changes = diff_snapshots(before, after)
78
+ renamed_endpoints = {r["from"] for r in changes["renamed"]} | {r["to"] for r in changes["renamed"]}
79
+ touched = sorted(set(changes["added"]) | set(changes["modified"]) | set(changes["removed"])
80
+ | renamed_endpoints | set(changes["mode_changed"]))
81
+
82
+ rename_from_by_to = {r["to"]: r["from"] for r in changes["renamed"]}
83
+ mode_only_changed = set(changes["mode_changed"])
84
+
85
+ def _annotate(p: str) -> str:
86
+ if p in rename_from_by_to:
87
+ # Name a renamed file's origin too -- "touched b.txt" alone
88
+ # hides that it's actually the declared a.txt under a new
89
+ # name, which is exactly the context someone needs to see
90
+ # this isn't an undeclared *new* file appearing from nowhere.
91
+ return f"{p} (renamed from {rename_from_by_to[p]})"
92
+ if p in mode_only_changed:
93
+ return f"{p} (permissions changed, content unchanged)"
94
+ return p
95
+
96
+ if declared_paths is None:
97
+ status = model.UNVERIFIED
98
+ unexpected: list[str] = []
99
+ detail = f"{len(touched)} file(s) touched; no declared scope to check against"
100
+ else:
101
+ declared = set(declared_paths)
102
+ unexpected = [p for p in touched if not _is_declared(p, declared)]
103
+ if unexpected:
104
+ status = model.FAIL
105
+ detail = f"touched {len(unexpected)} undeclared file(s): {', '.join(_annotate(p) for p in unexpected)}"
106
+ else:
107
+ status = model.PASS
108
+ detail = f"touched only what was declared ({len(touched)} file(s))"
109
+
110
+ return {
111
+ "task": task,
112
+ "command": [redact(part) for part in cmd],
113
+ "watch_dir": watch_dir,
114
+ "exit_code": proc.returncode,
115
+ "seconds": round(seconds, 3),
116
+ "stdout": redact(proc.stdout),
117
+ "stderr": redact(proc.stderr),
118
+ "declared_paths": declared_paths,
119
+ "changes": changes,
120
+ "unexpected": unexpected,
121
+ "status": status,
122
+ "detail": detail,
123
+ }
@@ -0,0 +1,43 @@
1
+ """Write a receipt to disk: the full record plus a sha256, so it can be
2
+ checked later without taking the run's word for it. Same idiom as
3
+ invariant's evidence.py, one file per receipt instead of one per check.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import pathlib
10
+ import secrets
11
+ import time
12
+
13
+ # Bump when the receipt dict's shape changes in a way a consumer parsing
14
+ # the JSON would need to know about (a field renamed or removed -- adding
15
+ # a new field is not a breaking change and doesn't need a bump).
16
+ SCHEMA_VERSION = 1
17
+
18
+
19
+ def write(result: dict, out_dir: str | pathlib.Path) -> pathlib.Path:
20
+ out_dir = pathlib.Path(out_dir)
21
+ out_dir.mkdir(parents=True, exist_ok=True)
22
+
23
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
24
+ # A second-resolution timestamp alone collides silently: two runs
25
+ # started within the same second (a script looping `receipt run`, two
26
+ # parallel CI jobs sharing --out) would overwrite each other with no
27
+ # error -- confirmed live, not hypothetical. The random suffix costs
28
+ # nothing and makes every receipt's filename unique regardless of
29
+ # timing.
30
+ suffix = secrets.token_hex(4)
31
+ path = out_dir / f"{stamp}-{suffix}.json"
32
+
33
+ blob = json.dumps(result, indent=2, sort_keys=True, default=str)
34
+ digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()
35
+
36
+ record = {
37
+ "schema_version": SCHEMA_VERSION,
38
+ "receipt": result,
39
+ "sha256": digest,
40
+ "written_at": time.time(),
41
+ }
42
+ path.write_text(json.dumps(record, indent=2, sort_keys=True, default=str), encoding="utf-8")
43
+ return path
@@ -0,0 +1,9 @@
1
+ """Three statuses, same shape as invariant/firedrill/carabiner.
2
+
3
+ `unverified` here means "no scope was declared" -- the task ran and the
4
+ receipt records exactly what it touched, but there was nothing to check that
5
+ against, so calling it a pass would claim more than was actually verified.
6
+ """
7
+ PASS = "pass"
8
+ FAIL = "fail"
9
+ UNVERIFIED = "unverified"
@@ -0,0 +1,58 @@
1
+ """Best-effort redaction of secret-shaped text before it's written into a
2
+ receipt.
3
+
4
+ Not a guarantee -- a regex sweep can't catch every shape a secret takes --
5
+ but it closes the exact failure mode found live during the portfolio audit:
6
+ a wrapped command's own stdout/stderr echoing a real credential
7
+ (`API_KEY=sk-supersecret12345`) landed verbatim in the receipt JSON on disk.
8
+ Receipts are meant to be kept and handed to someone else as evidence; the
9
+ evidence artifact itself must not be a credential-leak vector.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ MASK = "[REDACTED]"
16
+
17
+ # key=value / key: value pairs where the key name says "this is a secret" --
18
+ # the most common real leak shape (the exact one confirmed live in the
19
+ # audit: an env var echoed by the wrapped command).
20
+ _KEYED = re.compile(
21
+ r"(?i)\b([A-Za-z0-9_]*(?:SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|"
22
+ r"PASSWORD|PASSWD|PWD|CREDENTIAL)[A-Za-z0-9_]*)(\s*[:=]\s*)(['\"]?)(\S+)\3"
23
+ )
24
+
25
+ # scheme://user:pass@host -- a DSN or an authenticated URL carrying a
26
+ # credential in its userinfo component (the same shape as invariant's DSN
27
+ # leak, in case a wrapped command prints a connection string).
28
+ _URL_CRED = re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]*://)([^:/\s@]+):([^@/\s]+)@")
29
+
30
+ # Recognizable provider token prefixes -- these are secrets on sight,
31
+ # regardless of what surrounds them.
32
+ _PREFIXED = re.compile(
33
+ r"\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|"
34
+ r"github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|"
35
+ r"AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z\-_]{30,})\b"
36
+ )
37
+
38
+ _PEM_BLOCK = re.compile(
39
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
40
+ re.DOTALL,
41
+ )
42
+
43
+
44
+ def redact(text: str) -> str:
45
+ """Returns `text` with secret-shaped substrings replaced by a mask.
46
+
47
+ Applied to captured stdout/stderr before a receipt is written. Best
48
+ effort, not exhaustive -- it catches the common shapes (env-var-style
49
+ key=value pairs, credentialed URLs, well-known token prefixes, PEM
50
+ private key blocks), not every possible one.
51
+ """
52
+ if not text:
53
+ return text
54
+ text = _PEM_BLOCK.sub(MASK, text)
55
+ text = _URL_CRED.sub(lambda m: f"{m.group(1)}{m.group(2)}:{MASK}@", text)
56
+ text = _KEYED.sub(lambda m: f"{m.group(1)}{m.group(2)}{MASK}", text)
57
+ text = _PREFIXED.sub(MASK, text)
58
+ return text