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.
- receipt_evidence-0.1.0/LICENSE +21 -0
- receipt_evidence-0.1.0/PKG-INFO +155 -0
- receipt_evidence-0.1.0/README.md +134 -0
- receipt_evidence-0.1.0/pyproject.toml +40 -0
- receipt_evidence-0.1.0/receipt/__init__.py +1 -0
- receipt_evidence-0.1.0/receipt/cli.py +52 -0
- receipt_evidence-0.1.0/receipt/core.py +123 -0
- receipt_evidence-0.1.0/receipt/evidence.py +43 -0
- receipt_evidence-0.1.0/receipt/model.py +9 -0
- receipt_evidence-0.1.0/receipt/redact.py +58 -0
- receipt_evidence-0.1.0/receipt/snapshot.py +104 -0
- receipt_evidence-0.1.0/receipt_evidence.egg-info/PKG-INFO +155 -0
- receipt_evidence-0.1.0/receipt_evidence.egg-info/SOURCES.txt +16 -0
- receipt_evidence-0.1.0/receipt_evidence.egg-info/dependency_links.txt +1 -0
- receipt_evidence-0.1.0/receipt_evidence.egg-info/entry_points.txt +2 -0
- receipt_evidence-0.1.0/receipt_evidence.egg-info/top_level.txt +1 -0
- receipt_evidence-0.1.0/setup.cfg +4 -0
- receipt_evidence-0.1.0/tests/test_receipt.py +466 -0
|
@@ -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
|