gauntlet-guard 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.
- gauntlet_guard-0.1.0/PKG-INFO +162 -0
- gauntlet_guard-0.1.0/README.md +139 -0
- gauntlet_guard-0.1.0/pyproject.toml +88 -0
- gauntlet_guard-0.1.0/pyproject.toml.orig +62 -0
- gauntlet_guard-0.1.0/src/gauntlet/__init__.py +3 -0
- gauntlet_guard-0.1.0/src/gauntlet/cli.py +159 -0
- gauntlet_guard-0.1.0/src/gauntlet/fingerprint.py +39 -0
- gauntlet_guard-0.1.0/src/gauntlet/guard.py +156 -0
- gauntlet_guard-0.1.0/src/gauntlet/manifest.py +109 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: gauntlet-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The anti-cheating layer for evaluating self-improving agents: sealed holdouts, persistence-leak guards, and keep/revert verdicts.
|
|
5
|
+
Keywords: agents,evaluation,holdout,contamination,regression,llm
|
|
6
|
+
Author: hamdani
|
|
7
|
+
Author-email: hamdani <mhamdani35@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Project-URL: Homepage, https://github.com/dhanizael/gauntlet
|
|
18
|
+
Project-URL: Repository, https://github.com/dhanizael/gauntlet
|
|
19
|
+
Project-URL: Issues, https://github.com/dhanizael/gauntlet/issues
|
|
20
|
+
Project-URL: Releases, https://github.com/dhanizael/gauntlet/releases
|
|
21
|
+
Project-URL: Changelog, https://github.com/dhanizael/gauntlet/blob/main/CHANGELOG.md
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# gauntlet
|
|
25
|
+
|
|
26
|
+
**The anti-cheating layer for evaluating self-improving agents.**
|
|
27
|
+
|
|
28
|
+
> Your agent has memory now. That makes your agent evaluations quietly lie.
|
|
29
|
+
|
|
30
|
+
Everyone evaluates their agents, skills, prompts, and AGENTS.md files. Almost nobody
|
|
31
|
+
audits **the evaluator itself**. `gauntlet` closes that gap: sealed holdouts generated
|
|
32
|
+
from private seeds, a persistence-leak guard that treats your agent's own memory files
|
|
33
|
+
as the cheating channel they are, and blind paired-arm verdicts (`keep / revert /
|
|
34
|
+
provisional`) with repeated-run statistics — because agents are stochastic and a
|
|
35
|
+
single run proves nothing.
|
|
36
|
+
|
|
37
|
+
## The problem, concretely
|
|
38
|
+
|
|
39
|
+
You improved a skill. You A/B tested it on your eval tasks. It won. You shipped it.
|
|
40
|
+
Two weeks later you find out:
|
|
41
|
+
|
|
42
|
+
- the eval task text was paraphrased into `LESSONS.md` / `now.md` / a vector store by
|
|
43
|
+
the agent **during** an earlier run — the next runs studied for the test;
|
|
44
|
+
- the winning arm won by noise (n=1, no spread reported);
|
|
45
|
+
- your `provisional → verified` promotion was decided by vibes, not a gate.
|
|
46
|
+
|
|
47
|
+
Benchmark contamination literature worries about *pretraining corpora* (n-grams,
|
|
48
|
+
MinHash, memorized MMLU). None of it addresses the agent-native leak path:
|
|
49
|
+
**persistent memory inside the evaluated system itself.** gauntlet's threat model
|
|
50
|
+
starts there.
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install gauntlet-guard # or: uv tool install gauntlet-guard
|
|
56
|
+
# (PyPI publish is the next step after launch; day-one install from source:)
|
|
57
|
+
git clone https://github.com/dhanizael/gauntlet && cd gauntlet \
|
|
58
|
+
&& uv run gauntlet guard selftest # prove the tool before you trust it
|
|
59
|
+
|
|
60
|
+
# 1. Register a holdout instance (run this from the PRIVATE side, never in agent context)
|
|
61
|
+
gauntlet manifest add ~/.private/eval/manifest.jsonl \
|
|
62
|
+
--instance sched-frostgate-0001 --seed s-77 \
|
|
63
|
+
task-input.txt answer-key.md
|
|
64
|
+
|
|
65
|
+
# 2. After every eval session: scan the agent's persistent stores for leakage
|
|
66
|
+
gauntlet guard scan ~/.private/eval/manifest.jsonl \
|
|
67
|
+
--store ~/agent-workspace/.agent-state/ \
|
|
68
|
+
--store ~/agent-workspace/LESSONS.md \
|
|
69
|
+
--store ~/agent-transcripts/
|
|
70
|
+
# findings are reported by hash reference, never by content (see Design principles)
|
|
71
|
+
# exit code 1 = leak found → retire the instance, regenerate from an unused seed
|
|
72
|
+
|
|
73
|
+
# 3. Prove the tool works (synthetic planted leak + skip accounting):
|
|
74
|
+
gauntlet guard selftest
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## We ran our own gauntlet first
|
|
78
|
+
|
|
79
|
+
The first real scan target was the author's actual agent infrastructure: 12 sealed
|
|
80
|
+
task instances (the development pack of a private eval suite), scanned against the
|
|
81
|
+
agent's persistent stores — long-term memory file, journal log, working-state
|
|
82
|
+
directories, and evaluation run results. 271 files, **3.1 seconds**, zero runtime
|
|
83
|
+
dependencies.
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
$ gauntlet guard scan manifest.jsonl --store LESSONS.md --store journal.log ...
|
|
87
|
+
[NEAR ] results/…/L5-lens-r1/config.py :: instance L5 (20/217 shingles, 9.22%)
|
|
88
|
+
[NEAR ] results/…/L5-lens-r2/config.py :: instance L5 (14/217 shingles, 6.45%)
|
|
89
|
+
[TRACE] results/…/L5-raw-r1/config.py :: instance L5 (9/217 shingles, 4.15%)
|
|
90
|
+
[TRACE] results/…/L5-*/test_config_loader.py :: instance L5 (6/217 shingles) ×3
|
|
91
|
+
[TRACE] results/…/L1-raw-r1/count_patterns.py :: instance L1 (3/133 shingles, 2.26%)
|
|
92
|
+
|
|
93
|
+
scanned 239 file(s), skipped 32 (17 binary, 15 too-large)
|
|
94
|
+
7 finding(s).
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The result is a perfect demonstration of why this tool exists: the agent's own memory
|
|
98
|
+
files were **clean**, but task fixtures persisted verbatim into run-result directories —
|
|
99
|
+
real, previously uncounted contamination vectors, exactly the failure mode a sealed
|
|
100
|
+
holdout is designed to survive. Severity tracking is calibrated: shared boilerplate
|
|
101
|
+
surfaced as `TRACE` (human look), fixture content surfaced as `NEAR` (retire it). A
|
|
102
|
+
control scan of unrelated notes returned clean with no false positives, and the
|
|
103
|
+
machine-readable report contains **zero task text** — verified by assertion in CI.
|
|
104
|
+
|
|
105
|
+
## Design principles
|
|
106
|
+
|
|
107
|
+
1. **The scanner must not leak.** Guard reports file, word-position, match counts and
|
|
108
|
+
*hash references only* — never the matched text. The report is safe to paste into
|
|
109
|
+
an issue even when the task is secret.
|
|
110
|
+
2. **Manifests are private keys.** Task content lives only in the manifest file;
|
|
111
|
+
guard consumes it without echoing it. Keep manifests out of any agent-readable
|
|
112
|
+
surface (`chmod 700` the directory).
|
|
113
|
+
3. **Deterministic first, judge second.** Hash/shingle evidence outranks any LLM
|
|
114
|
+
opinion; `guard` is fully executable in CI.
|
|
115
|
+
4. **Leaks are lifecycle events, not warnings.** Detection → `retire` → regenerate
|
|
116
|
+
from an unused seed. A benchmark instance that leaked once is dead; say so in
|
|
117
|
+
the record.
|
|
118
|
+
5. **Verdicts over scores.** `keep / revert / provisional` with ≥N repeated runs and
|
|
119
|
+
reported spread — an improvement that cannot survive the gauntlet is not an
|
|
120
|
+
improvement.
|
|
121
|
+
|
|
122
|
+
## How detection works
|
|
123
|
+
|
|
124
|
+
Task content is fingerprinted into 8-word normalized shingles (unicode-folded,
|
|
125
|
+
punctuation-free, case-folded → SHA-256 prefixes). A store is scanned in a single
|
|
126
|
+
pass; findings are classified:
|
|
127
|
+
|
|
128
|
+
| severity | trigger | action |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `exact` | normalized task text appears verbatim | retire + regenerate |
|
|
131
|
+
| `near` | ≥5% of task shingles matched (paraphrase, copy-edit) | retire + regenerate |
|
|
132
|
+
| `trace` | ≥2 matched shingles, below the near threshold | human review |
|
|
133
|
+
|
|
134
|
+
Oversize (>8 MiB default, configurable) and binary files are skipped **and reported** —
|
|
135
|
+
a guard that silently hides what it didn't scan is the bug we found on day one of
|
|
136
|
+
real-world use and refused to keep.
|
|
137
|
+
|
|
138
|
+
## Status
|
|
139
|
+
|
|
140
|
+
- `guard` + `manifest`: **shipped (v0.1)** — 12 tests, CI on pytest/ruff/ty, selftest
|
|
141
|
+
in the build pipeline
|
|
142
|
+
- `run` (blind paired arms, isolated workspaces, environment drift fingerprinting): next
|
|
143
|
+
- `grade` (verdict engine, ≥N repeats + spread, provisional→verified lifecycle): next
|
|
144
|
+
- `holdout` (seed generators + retirement ledger, contract spec): next
|
|
145
|
+
|
|
146
|
+
Built from a battle-tested private protocol: doctrine changes tagged `PROVISIONAL`
|
|
147
|
+
until they beat the previous version on repeated, holdout-guarded evals — gauntlet
|
|
148
|
+
is that gauntlet, made a tool.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uv sync
|
|
154
|
+
uv run pytest
|
|
155
|
+
uv run ruff check && uv run ruff format --check
|
|
156
|
+
uv run ty check src/
|
|
157
|
+
uv run gauntlet guard selftest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# gauntlet
|
|
2
|
+
|
|
3
|
+
**The anti-cheating layer for evaluating self-improving agents.**
|
|
4
|
+
|
|
5
|
+
> Your agent has memory now. That makes your agent evaluations quietly lie.
|
|
6
|
+
|
|
7
|
+
Everyone evaluates their agents, skills, prompts, and AGENTS.md files. Almost nobody
|
|
8
|
+
audits **the evaluator itself**. `gauntlet` closes that gap: sealed holdouts generated
|
|
9
|
+
from private seeds, a persistence-leak guard that treats your agent's own memory files
|
|
10
|
+
as the cheating channel they are, and blind paired-arm verdicts (`keep / revert /
|
|
11
|
+
provisional`) with repeated-run statistics — because agents are stochastic and a
|
|
12
|
+
single run proves nothing.
|
|
13
|
+
|
|
14
|
+
## The problem, concretely
|
|
15
|
+
|
|
16
|
+
You improved a skill. You A/B tested it on your eval tasks. It won. You shipped it.
|
|
17
|
+
Two weeks later you find out:
|
|
18
|
+
|
|
19
|
+
- the eval task text was paraphrased into `LESSONS.md` / `now.md` / a vector store by
|
|
20
|
+
the agent **during** an earlier run — the next runs studied for the test;
|
|
21
|
+
- the winning arm won by noise (n=1, no spread reported);
|
|
22
|
+
- your `provisional → verified` promotion was decided by vibes, not a gate.
|
|
23
|
+
|
|
24
|
+
Benchmark contamination literature worries about *pretraining corpora* (n-grams,
|
|
25
|
+
MinHash, memorized MMLU). None of it addresses the agent-native leak path:
|
|
26
|
+
**persistent memory inside the evaluated system itself.** gauntlet's threat model
|
|
27
|
+
starts there.
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install gauntlet-guard # or: uv tool install gauntlet-guard
|
|
33
|
+
# (PyPI publish is the next step after launch; day-one install from source:)
|
|
34
|
+
git clone https://github.com/dhanizael/gauntlet && cd gauntlet \
|
|
35
|
+
&& uv run gauntlet guard selftest # prove the tool before you trust it
|
|
36
|
+
|
|
37
|
+
# 1. Register a holdout instance (run this from the PRIVATE side, never in agent context)
|
|
38
|
+
gauntlet manifest add ~/.private/eval/manifest.jsonl \
|
|
39
|
+
--instance sched-frostgate-0001 --seed s-77 \
|
|
40
|
+
task-input.txt answer-key.md
|
|
41
|
+
|
|
42
|
+
# 2. After every eval session: scan the agent's persistent stores for leakage
|
|
43
|
+
gauntlet guard scan ~/.private/eval/manifest.jsonl \
|
|
44
|
+
--store ~/agent-workspace/.agent-state/ \
|
|
45
|
+
--store ~/agent-workspace/LESSONS.md \
|
|
46
|
+
--store ~/agent-transcripts/
|
|
47
|
+
# findings are reported by hash reference, never by content (see Design principles)
|
|
48
|
+
# exit code 1 = leak found → retire the instance, regenerate from an unused seed
|
|
49
|
+
|
|
50
|
+
# 3. Prove the tool works (synthetic planted leak + skip accounting):
|
|
51
|
+
gauntlet guard selftest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## We ran our own gauntlet first
|
|
55
|
+
|
|
56
|
+
The first real scan target was the author's actual agent infrastructure: 12 sealed
|
|
57
|
+
task instances (the development pack of a private eval suite), scanned against the
|
|
58
|
+
agent's persistent stores — long-term memory file, journal log, working-state
|
|
59
|
+
directories, and evaluation run results. 271 files, **3.1 seconds**, zero runtime
|
|
60
|
+
dependencies.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
$ gauntlet guard scan manifest.jsonl --store LESSONS.md --store journal.log ...
|
|
64
|
+
[NEAR ] results/…/L5-lens-r1/config.py :: instance L5 (20/217 shingles, 9.22%)
|
|
65
|
+
[NEAR ] results/…/L5-lens-r2/config.py :: instance L5 (14/217 shingles, 6.45%)
|
|
66
|
+
[TRACE] results/…/L5-raw-r1/config.py :: instance L5 (9/217 shingles, 4.15%)
|
|
67
|
+
[TRACE] results/…/L5-*/test_config_loader.py :: instance L5 (6/217 shingles) ×3
|
|
68
|
+
[TRACE] results/…/L1-raw-r1/count_patterns.py :: instance L1 (3/133 shingles, 2.26%)
|
|
69
|
+
|
|
70
|
+
scanned 239 file(s), skipped 32 (17 binary, 15 too-large)
|
|
71
|
+
7 finding(s).
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The result is a perfect demonstration of why this tool exists: the agent's own memory
|
|
75
|
+
files were **clean**, but task fixtures persisted verbatim into run-result directories —
|
|
76
|
+
real, previously uncounted contamination vectors, exactly the failure mode a sealed
|
|
77
|
+
holdout is designed to survive. Severity tracking is calibrated: shared boilerplate
|
|
78
|
+
surfaced as `TRACE` (human look), fixture content surfaced as `NEAR` (retire it). A
|
|
79
|
+
control scan of unrelated notes returned clean with no false positives, and the
|
|
80
|
+
machine-readable report contains **zero task text** — verified by assertion in CI.
|
|
81
|
+
|
|
82
|
+
## Design principles
|
|
83
|
+
|
|
84
|
+
1. **The scanner must not leak.** Guard reports file, word-position, match counts and
|
|
85
|
+
*hash references only* — never the matched text. The report is safe to paste into
|
|
86
|
+
an issue even when the task is secret.
|
|
87
|
+
2. **Manifests are private keys.** Task content lives only in the manifest file;
|
|
88
|
+
guard consumes it without echoing it. Keep manifests out of any agent-readable
|
|
89
|
+
surface (`chmod 700` the directory).
|
|
90
|
+
3. **Deterministic first, judge second.** Hash/shingle evidence outranks any LLM
|
|
91
|
+
opinion; `guard` is fully executable in CI.
|
|
92
|
+
4. **Leaks are lifecycle events, not warnings.** Detection → `retire` → regenerate
|
|
93
|
+
from an unused seed. A benchmark instance that leaked once is dead; say so in
|
|
94
|
+
the record.
|
|
95
|
+
5. **Verdicts over scores.** `keep / revert / provisional` with ≥N repeated runs and
|
|
96
|
+
reported spread — an improvement that cannot survive the gauntlet is not an
|
|
97
|
+
improvement.
|
|
98
|
+
|
|
99
|
+
## How detection works
|
|
100
|
+
|
|
101
|
+
Task content is fingerprinted into 8-word normalized shingles (unicode-folded,
|
|
102
|
+
punctuation-free, case-folded → SHA-256 prefixes). A store is scanned in a single
|
|
103
|
+
pass; findings are classified:
|
|
104
|
+
|
|
105
|
+
| severity | trigger | action |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `exact` | normalized task text appears verbatim | retire + regenerate |
|
|
108
|
+
| `near` | ≥5% of task shingles matched (paraphrase, copy-edit) | retire + regenerate |
|
|
109
|
+
| `trace` | ≥2 matched shingles, below the near threshold | human review |
|
|
110
|
+
|
|
111
|
+
Oversize (>8 MiB default, configurable) and binary files are skipped **and reported** —
|
|
112
|
+
a guard that silently hides what it didn't scan is the bug we found on day one of
|
|
113
|
+
real-world use and refused to keep.
|
|
114
|
+
|
|
115
|
+
## Status
|
|
116
|
+
|
|
117
|
+
- `guard` + `manifest`: **shipped (v0.1)** — 12 tests, CI on pytest/ruff/ty, selftest
|
|
118
|
+
in the build pipeline
|
|
119
|
+
- `run` (blind paired arms, isolated workspaces, environment drift fingerprinting): next
|
|
120
|
+
- `grade` (verdict engine, ≥N repeats + spread, provisional→verified lifecycle): next
|
|
121
|
+
- `holdout` (seed generators + retirement ledger, contract spec): next
|
|
122
|
+
|
|
123
|
+
Built from a battle-tested private protocol: doctrine changes tagged `PROVISIONAL`
|
|
124
|
+
until they beat the previous version on repeated, holdout-guarded evals — gauntlet
|
|
125
|
+
is that gauntlet, made a tool.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv sync
|
|
131
|
+
uv run pytest
|
|
132
|
+
uv run ruff check && uv run ruff format --check
|
|
133
|
+
uv run ty check src/
|
|
134
|
+
uv run gauntlet guard selftest
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gauntlet-guard"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The anti-cheating layer for evaluating self-improving agents: sealed holdouts, persistence-leak guards, and keep/revert verdicts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
keywords = [
|
|
8
|
+
"agents",
|
|
9
|
+
"evaluation",
|
|
10
|
+
"holdout",
|
|
11
|
+
"contamination",
|
|
12
|
+
"regression",
|
|
13
|
+
"llm",
|
|
14
|
+
]
|
|
15
|
+
dependencies = []
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: Software Development :: Testing",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[project.authors]]
|
|
27
|
+
name = "hamdani"
|
|
28
|
+
email = "mhamdani35@gmail.com"
|
|
29
|
+
|
|
30
|
+
[project.license]
|
|
31
|
+
text = "MIT"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/dhanizael/gauntlet"
|
|
35
|
+
Repository = "https://github.com/dhanizael/gauntlet"
|
|
36
|
+
Issues = "https://github.com/dhanizael/gauntlet/issues"
|
|
37
|
+
Releases = "https://github.com/dhanizael/gauntlet/releases"
|
|
38
|
+
Changelog = "https://github.com/dhanizael/gauntlet/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
gauntlet = "gauntlet.cli:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-root = "src"
|
|
49
|
+
module-name = "gauntlet"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
target-version = "py311"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["ALL"]
|
|
57
|
+
ignore = [
|
|
58
|
+
"D",
|
|
59
|
+
"COM812",
|
|
60
|
+
"ISC001",
|
|
61
|
+
"TRY003",
|
|
62
|
+
"EM101",
|
|
63
|
+
"EM102",
|
|
64
|
+
"S",
|
|
65
|
+
"FBT001",
|
|
66
|
+
"FBT002",
|
|
67
|
+
"PLR2004",
|
|
68
|
+
"C901",
|
|
69
|
+
"T201",
|
|
70
|
+
"CPY001",
|
|
71
|
+
"TC001",
|
|
72
|
+
"TC003",
|
|
73
|
+
"PLC0415",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
"tests/*" = ["ALL"]
|
|
78
|
+
|
|
79
|
+
[tool.pytest.ini_options]
|
|
80
|
+
addopts = "-q"
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
|
|
83
|
+
[dependency-groups]
|
|
84
|
+
dev = [
|
|
85
|
+
"pytest>=9.1.1",
|
|
86
|
+
"ruff>=0.16.8",
|
|
87
|
+
"ty>=0.0.81",
|
|
88
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gauntlet-guard"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The anti-cheating layer for evaluating self-improving agents: sealed holdouts, persistence-leak guards, and keep/revert verdicts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "hamdani", email = "mhamdani35@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
keywords = ["agents", "evaluation", "holdout", "contamination", "regression", "llm"]
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/dhanizael/gauntlet"
|
|
25
|
+
Repository = "https://github.com/dhanizael/gauntlet"
|
|
26
|
+
Issues = "https://github.com/dhanizael/gauntlet/issues"
|
|
27
|
+
Releases = "https://github.com/dhanizael/gauntlet/releases"
|
|
28
|
+
Changelog = "https://github.com/dhanizael/gauntlet/blob/main/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
gauntlet = "gauntlet.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[tool.uv.build-backend]
|
|
38
|
+
module-root = "src"
|
|
39
|
+
module-name = "gauntlet"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=9.1.1",
|
|
44
|
+
"ruff>=0.16.8",
|
|
45
|
+
"ty>=0.0.81",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py311"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["ALL"]
|
|
54
|
+
ignore = ["D", "COM812", "ISC001", "TRY003", "EM101", "EM102", "S", "FBT001", "FBT002",
|
|
55
|
+
"PLR2004", "C901", "T201", "CPY001", "TC001", "TC003", "PLC0415"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint.per-file-ignores]
|
|
58
|
+
"tests/*" = ["ALL"]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
addopts = "-q"
|
|
62
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""gauntlet CLI — sealed holdouts, persistence-leak guards, keep/revert verdicts.
|
|
2
|
+
|
|
3
|
+
v0 commands:
|
|
4
|
+
gauntlet manifest add MANIFEST --instance ID --seed S FILES...
|
|
5
|
+
gauntlet manifest list MANIFEST
|
|
6
|
+
gauntlet manifest retire MANIFEST --instance ID
|
|
7
|
+
gauntlet guard scan MANIFEST --store PATH [--store PATH] [--json OUT]
|
|
8
|
+
gauntlet guard selftest
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .guard import finding_to_dict, scan_store
|
|
20
|
+
from .manifest import add_instance, load_manifest, retire_instance
|
|
21
|
+
|
|
22
|
+
BANNER = "gauntlet 0.1.0 — the anti-cheating layer for evaluating self-improving agents"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
ap = argparse.ArgumentParser(prog="gauntlet", description=BANNER)
|
|
27
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
28
|
+
|
|
29
|
+
m = sub.add_parser("manifest", help="manage instance manifests (SECRET — private side only)")
|
|
30
|
+
msub = m.add_subparsers(dest="mcmd", required=True)
|
|
31
|
+
ma = msub.add_parser("add", help="register a task instance from its files")
|
|
32
|
+
ma.add_argument("manifest", type=Path)
|
|
33
|
+
ma.add_argument("--instance", required=True)
|
|
34
|
+
ma.add_argument("--seed", default="")
|
|
35
|
+
ma.add_argument("files", type=Path, nargs="+")
|
|
36
|
+
ml = msub.add_parser("list", help="list instances (metadata only, no content)")
|
|
37
|
+
ml.add_argument("manifest", type=Path)
|
|
38
|
+
mr = msub.add_parser("retire", help="mark an instance retired (its content leaked)")
|
|
39
|
+
mr.add_argument("manifest", type=Path)
|
|
40
|
+
mr.add_argument("--instance", required=True)
|
|
41
|
+
|
|
42
|
+
g = sub.add_parser("guard", help="scan agent persistent stores for task leakage")
|
|
43
|
+
gsub = g.add_subparsers(dest="gcmd", required=True)
|
|
44
|
+
gs = gsub.add_parser("scan", help="scan stores against a manifest")
|
|
45
|
+
gs.add_argument("manifest", type=Path)
|
|
46
|
+
gs.add_argument("--store", type=Path, action="append", required=True)
|
|
47
|
+
gs.add_argument("--min-overlap", type=int, default=2)
|
|
48
|
+
gs.add_argument("--max-bytes", type=int, default=8 * 1024 * 1024)
|
|
49
|
+
gs.add_argument("--json", type=Path, default=None)
|
|
50
|
+
gsub.add_parser("selftest", help="plant a synthetic leak and prove it is caught")
|
|
51
|
+
|
|
52
|
+
args = ap.parse_args(argv)
|
|
53
|
+
|
|
54
|
+
if args.cmd == "manifest" and args.mcmd == "add":
|
|
55
|
+
rec = add_instance(args.manifest, args.instance, args.seed, args.files)
|
|
56
|
+
print(f"registered {rec.instance} ({len(rec.shingles)} shingles, seed={rec.seed!r})")
|
|
57
|
+
return 0
|
|
58
|
+
if args.cmd == "manifest" and args.mcmd == "list":
|
|
59
|
+
for r in load_manifest(args.manifest):
|
|
60
|
+
print(
|
|
61
|
+
f"{r.instance:24s} seed={r.seed:12s} retired={r.retired} shingles={len(r.shingles)}"
|
|
62
|
+
)
|
|
63
|
+
return 0
|
|
64
|
+
if args.cmd == "manifest" and args.mcmd == "retire":
|
|
65
|
+
ok = retire_instance(args.manifest, args.instance)
|
|
66
|
+
print("retired" if ok else "instance not found (or already retired)")
|
|
67
|
+
return 0 if ok else 1
|
|
68
|
+
if args.cmd == "guard" and args.gcmd == "scan":
|
|
69
|
+
return _guard_scan(args)
|
|
70
|
+
if args.cmd == "guard" and args.gcmd == "selftest":
|
|
71
|
+
return _guard_selftest()
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _guard_scan(args: argparse.Namespace) -> int:
|
|
76
|
+
records = load_manifest(args.manifest)
|
|
77
|
+
result = scan_store(records, args.store, args.min_overlap, args.max_bytes)
|
|
78
|
+
for f in result.findings:
|
|
79
|
+
print(
|
|
80
|
+
f"[{f.severity.upper():5s}] {f.store_file} :: instance {f.instance} "
|
|
81
|
+
f"({f.matched_shingles}/{f.total_shingles} shingles, {f.overlap_pct}% overlap, "
|
|
82
|
+
f"word@{f.first_word_pos}, ref {f.content_ref})"
|
|
83
|
+
)
|
|
84
|
+
if args.json:
|
|
85
|
+
args.json.write_text(json.dumps([finding_to_dict(f) for f in result.findings], indent=1))
|
|
86
|
+
reasons: dict[str, int] = {}
|
|
87
|
+
for _, why in result.skipped:
|
|
88
|
+
reasons[why] = reasons.get(why, 0) + 1
|
|
89
|
+
skip_note = ""
|
|
90
|
+
if result.skipped:
|
|
91
|
+
breakdown = ", ".join(f"{n} {k}" for k, n in sorted(reasons.items()))
|
|
92
|
+
skip_note = f", skipped {len(result.skipped)} ({breakdown})"
|
|
93
|
+
print(f"\nscanned {result.scanned} file(s){skip_note}")
|
|
94
|
+
if not result.findings:
|
|
95
|
+
print("clean: no task content detected in stores")
|
|
96
|
+
return 0
|
|
97
|
+
print(
|
|
98
|
+
f"{len(result.findings)} finding(s). "
|
|
99
|
+
"Retire leaked instances and regenerate from unused seeds."
|
|
100
|
+
)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _guard_selftest() -> int:
|
|
105
|
+
"""Synthetic end-to-end proof: plant a paraphrased leak next to one clean note,
|
|
106
|
+
plus one oversized and one binary file that also contain the leak, and assert
|
|
107
|
+
detection, redaction, no false positives, and honest skip accounting."""
|
|
108
|
+
with tempfile.TemporaryDirectory() as td:
|
|
109
|
+
tdp = Path(td)
|
|
110
|
+
task = tdp / "task.txt"
|
|
111
|
+
task.write_text(
|
|
112
|
+
"A logistics firm must schedule exactly nine trucks across four docks under the "
|
|
113
|
+
"frostgate constraint: no dock may receive two consecutive refrigerated trucks, "
|
|
114
|
+
"and the vermilion manifest must depart before the azure convoy clears weighbridge "
|
|
115
|
+
"seven. Produce the minimal feasible ordering and prove minimality."
|
|
116
|
+
)
|
|
117
|
+
store = tdp / "agent_memory"
|
|
118
|
+
store.mkdir()
|
|
119
|
+
leak_text = (
|
|
120
|
+
"# Lessons\n- the frostgate constraint (no dock may receive two consecutive "
|
|
121
|
+
"refrigerated trucks, and the vermilion manifest must depart before the azure "
|
|
122
|
+
"convoy clears weighbridge seven) keeps biting us on scheduling tasks\n"
|
|
123
|
+
"- unrelated: prefer uv over pip\n"
|
|
124
|
+
)
|
|
125
|
+
(store / "LESSONS.md").write_text(leak_text)
|
|
126
|
+
(store / "clean_note.md").write_text("# Notes\n- always pin tool versions in CI\n")
|
|
127
|
+
(store / "huge_memory.md").write_text(leak_text + "x" * (4 * 1024 * 1024))
|
|
128
|
+
(store / "blob.bin").write_bytes(b"\x00\x01\x02" + leak_text.encode() + b"\x00")
|
|
129
|
+
|
|
130
|
+
mf = tdp / "manifest.jsonl"
|
|
131
|
+
add_instance(mf, "synthetic-1", "seed-42", [task])
|
|
132
|
+
result = scan_store(load_manifest(mf), [store], max_bytes=2 * 1024 * 1024)
|
|
133
|
+
findings = result.findings
|
|
134
|
+
by_file = {Path(f.store_file).name: f for f in findings}
|
|
135
|
+
assert "LESSONS.md" in by_file, "selftest FAILED: planted leak not detected"
|
|
136
|
+
assert "clean_note.md" not in by_file, "selftest FAILED: false positive"
|
|
137
|
+
assert "huge_memory.md" not in by_file, "selftest FAILED: oversize file was scanned"
|
|
138
|
+
assert "blob.bin" not in by_file, "selftest FAILED: binary file was scanned"
|
|
139
|
+
skip_reasons = {Path(p).name: why for p, why in result.skipped}
|
|
140
|
+
assert skip_reasons.get("huge_memory.md") == "too-large", (
|
|
141
|
+
"selftest FAILED: size skip unreported"
|
|
142
|
+
)
|
|
143
|
+
assert skip_reasons.get("blob.bin") == "binary", "selftest FAILED: binary skip unreported"
|
|
144
|
+
leak = by_file["LESSONS.md"]
|
|
145
|
+
print(
|
|
146
|
+
f"selftest PASS: caught planted leak in LESSONS.md [{leak.severity}] "
|
|
147
|
+
f"{leak.matched_shingles} shingles matched"
|
|
148
|
+
)
|
|
149
|
+
print(f" skipped reported: {sorted(skip_reasons.items())}")
|
|
150
|
+
# verify redaction: no printable content in the serialized findings
|
|
151
|
+
blob = json.dumps([finding_to_dict(f) for f in findings]).lower()
|
|
152
|
+
for probe in ("frostgate", "vermilion manifest must depart", "logistics firm"):
|
|
153
|
+
assert probe not in blob, f"selftest FAILED: report contained task text {probe!r}"
|
|
154
|
+
print("selftest PASS: report is content-free (redaction holds)")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Text fingerprinting: normalize, shingle, hash.
|
|
2
|
+
|
|
3
|
+
Everything the scanner needs to compare stores against instances without
|
|
4
|
+
ever treating task content as printable output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
SHINGLE_WORDS = 8
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize(text: str) -> str:
|
|
17
|
+
"""Aggressive but deterministic: unicode-fold, lowercase, strip punctuation."""
|
|
18
|
+
text = unicodedata.normalize("NFKC", text)
|
|
19
|
+
text = text.lower()
|
|
20
|
+
text = re.sub(r"[^a-z0-9\s]", " ", text)
|
|
21
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def word_shingles(norm_text: str, k: int = SHINGLE_WORDS) -> list[tuple[int, str]]:
|
|
25
|
+
"""Return (word_position, shingle_hash) for every k-word window of normalized text."""
|
|
26
|
+
words = norm_text.split()
|
|
27
|
+
out: list[tuple[int, str]] = []
|
|
28
|
+
for i in range(max(0, len(words) - k + 1)):
|
|
29
|
+
window = " ".join(words[i : i + k])
|
|
30
|
+
out.append((i, hashlib.sha256(window.encode()).hexdigest()[:16]))
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def shingle_set(norm_text: str, k: int = SHINGLE_WORDS) -> set[str]:
|
|
35
|
+
return {h for _, h in word_shingles(norm_text, k)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sha256_hex(data: bytes) -> str:
|
|
39
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Leak guard: detect holdout task content inside agent persistent stores.
|
|
2
|
+
|
|
3
|
+
The threat model is agent-native: agents now carry memory across sessions
|
|
4
|
+
(LESSONS.md, now.md, journal logs, vector stores, transcripts). Holdout
|
|
5
|
+
secrecy fails silently through these channels: a paraphrase saved today
|
|
6
|
+
becomes an unfair advantage next week.
|
|
7
|
+
|
|
8
|
+
This module scans persistent stores against an instance manifest and reports
|
|
9
|
+
LEAKAGE WITHOUT EMITTING CONTENT: every finding is file, word-position,
|
|
10
|
+
match counts, and shingle hash references — never the matched text. The
|
|
11
|
+
scanner itself must not become a leak channel.
|
|
12
|
+
|
|
13
|
+
Operational hardening (learned dogfooding on real agent stores): size caps,
|
|
14
|
+
binary sniffing, and per-file shingle computation so scanning a workspace of
|
|
15
|
+
multi-gigabyte transcripts terminates in bounded time.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from dataclasses import asdict, dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .fingerprint import normalize, word_shingles
|
|
27
|
+
from .manifest import InstanceRecord
|
|
28
|
+
|
|
29
|
+
SEVERITY_ORDER = {"trace": 0, "near": 1, "exact": 2}
|
|
30
|
+
|
|
31
|
+
DEFAULT_SKIP_NAMES = {".git", ".venv", "node_modules", "__pycache__", ".uv-cache"}
|
|
32
|
+
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
33
|
+
NEAR_OVERLAP_PCT = 5.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Finding:
|
|
38
|
+
store_file: str
|
|
39
|
+
instance: str
|
|
40
|
+
seed: str
|
|
41
|
+
severity: str # exact | near | trace
|
|
42
|
+
matched_shingles: int
|
|
43
|
+
total_shingles: int
|
|
44
|
+
overlap_pct: float
|
|
45
|
+
first_word_pos: int
|
|
46
|
+
evidence_shingle: str # hash reference only — never printable content
|
|
47
|
+
content_ref: str # double indirection: sha256 of the instance's full-content hash
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ScanResult:
|
|
52
|
+
findings: list[Finding]
|
|
53
|
+
scanned: int = 0
|
|
54
|
+
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iter_store_files(
|
|
58
|
+
store_paths: list[Path], skip: set[str] = DEFAULT_SKIP_NAMES
|
|
59
|
+
) -> Iterator[Path]:
|
|
60
|
+
for p in store_paths:
|
|
61
|
+
if p.is_file():
|
|
62
|
+
yield p
|
|
63
|
+
elif p.is_dir():
|
|
64
|
+
for f in sorted(p.rglob("*")):
|
|
65
|
+
if f.is_file() and not any(part in skip for part in f.parts):
|
|
66
|
+
yield f
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def scan_store(
|
|
70
|
+
records: list[InstanceRecord],
|
|
71
|
+
store_paths: list[Path],
|
|
72
|
+
min_overlap_shingles: int = 2,
|
|
73
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
74
|
+
) -> ScanResult:
|
|
75
|
+
result = ScanResult(findings=[])
|
|
76
|
+
active = [r for r in records if not r.retired]
|
|
77
|
+
by_k: dict[int, list[InstanceRecord]] = defaultdict(list)
|
|
78
|
+
for r in active:
|
|
79
|
+
by_k[r.shingle_words].append(r)
|
|
80
|
+
|
|
81
|
+
for path in iter_store_files(store_paths):
|
|
82
|
+
try:
|
|
83
|
+
if path.stat().st_size > max_bytes:
|
|
84
|
+
result.skipped.append((str(path), "too-large"))
|
|
85
|
+
continue
|
|
86
|
+
with path.open("rb") as fh:
|
|
87
|
+
if b"\x00" in fh.read(1024):
|
|
88
|
+
result.skipped.append((str(path), "binary"))
|
|
89
|
+
continue
|
|
90
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
except OSError:
|
|
92
|
+
continue
|
|
93
|
+
result.scanned += 1
|
|
94
|
+
norm = normalize(text)
|
|
95
|
+
shingles_by_k = {k: word_shingles(norm, k) for k in by_k}
|
|
96
|
+
for rec in active:
|
|
97
|
+
f = _match_one(path, norm, shingles_by_k[rec.shingle_words], rec, min_overlap_shingles)
|
|
98
|
+
if f is not None:
|
|
99
|
+
result.findings.append(f)
|
|
100
|
+
|
|
101
|
+
result.findings.sort(key=lambda x: (-SEVERITY_ORDER[x.severity], -x.overlap_pct))
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _match_one(
|
|
106
|
+
path: Path,
|
|
107
|
+
norm: str,
|
|
108
|
+
shingles: list[tuple[int, str]],
|
|
109
|
+
rec: InstanceRecord,
|
|
110
|
+
min_hits: int,
|
|
111
|
+
) -> Finding | None:
|
|
112
|
+
severity = "near"
|
|
113
|
+
first_pos = -1
|
|
114
|
+
matched: set[str] = set()
|
|
115
|
+
|
|
116
|
+
exact = (
|
|
117
|
+
rec.normalized is not None
|
|
118
|
+
and len(rec.normalized.split()) >= rec.shingle_words
|
|
119
|
+
and rec.normalized in norm
|
|
120
|
+
)
|
|
121
|
+
if exact:
|
|
122
|
+
severity = "exact"
|
|
123
|
+
first_pos = norm.find(rec.normalized)
|
|
124
|
+
matched.update(h for _, h in word_shingles(rec.normalized, rec.shingle_words))
|
|
125
|
+
else:
|
|
126
|
+
for pos, h in shingles:
|
|
127
|
+
if h in rec.shingles:
|
|
128
|
+
matched.add(h)
|
|
129
|
+
if first_pos < 0:
|
|
130
|
+
first_pos = pos
|
|
131
|
+
if len(matched) < min_hits:
|
|
132
|
+
return None
|
|
133
|
+
severity = (
|
|
134
|
+
"near"
|
|
135
|
+
if len(matched) / max(1, len(rec.shingles)) * 100 >= NEAR_OVERLAP_PCT
|
|
136
|
+
else "trace"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
total = max(1, len(rec.shingles))
|
|
140
|
+
overlap = len(matched) / total * 100.0
|
|
141
|
+
return Finding(
|
|
142
|
+
store_file=str(path),
|
|
143
|
+
instance=rec.instance,
|
|
144
|
+
seed=rec.seed,
|
|
145
|
+
severity=severity,
|
|
146
|
+
matched_shingles=len(matched),
|
|
147
|
+
total_shingles=total,
|
|
148
|
+
overlap_pct=round(overlap, 2),
|
|
149
|
+
first_word_pos=first_pos,
|
|
150
|
+
evidence_shingle=min(matched),
|
|
151
|
+
content_ref=hashlib.sha256(rec.full_sha256.encode()).hexdigest()[:16],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def finding_to_dict(f: Finding) -> dict:
|
|
156
|
+
return asdict(f)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Instance manifests: the secret side of a gauntlet run.
|
|
2
|
+
|
|
3
|
+
A manifest is JSONL, one record per holdout task instance. It is generated by
|
|
4
|
+
the private side (never the evaluated agent) from a seed, and it holds enough
|
|
5
|
+
fingerprint detail for `guard` to detect leaks WITHOUT being given the raw
|
|
6
|
+
task files at scan time.
|
|
7
|
+
|
|
8
|
+
Treat manifest files as private keys: readable by the grader side only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .fingerprint import SHINGLE_WORDS, normalize, sha256_hex, shingle_set
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class InstanceRecord:
|
|
23
|
+
instance: str
|
|
24
|
+
seed: str
|
|
25
|
+
created: str
|
|
26
|
+
retired: bool
|
|
27
|
+
full_sha256: str
|
|
28
|
+
normalized: str # kept so guard can find substrings; NEVER print this
|
|
29
|
+
shingles: frozenset[str]
|
|
30
|
+
shingle_words: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_instance(
|
|
34
|
+
manifest_path: Path,
|
|
35
|
+
instance_id: str,
|
|
36
|
+
seed: str,
|
|
37
|
+
files: list[Path],
|
|
38
|
+
) -> InstanceRecord:
|
|
39
|
+
blob = "\n".join(f.read_text(encoding="utf-8", errors="replace") for f in files)
|
|
40
|
+
norm = normalize(blob)
|
|
41
|
+
rec = InstanceRecord(
|
|
42
|
+
instance=instance_id,
|
|
43
|
+
seed=seed,
|
|
44
|
+
created=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
45
|
+
retired=False,
|
|
46
|
+
full_sha256=sha256_hex(blob.encode()),
|
|
47
|
+
normalized=norm,
|
|
48
|
+
shingles=frozenset(shingle_set(norm)),
|
|
49
|
+
shingle_words=SHINGLE_WORDS,
|
|
50
|
+
)
|
|
51
|
+
_append(manifest_path, rec)
|
|
52
|
+
return rec
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_manifest(manifest_path: Path) -> list[InstanceRecord]:
|
|
56
|
+
records = []
|
|
57
|
+
for line in manifest_path.read_text(encoding="utf-8").splitlines():
|
|
58
|
+
if line.strip():
|
|
59
|
+
d = json.loads(line)
|
|
60
|
+
records.append(
|
|
61
|
+
InstanceRecord(
|
|
62
|
+
instance=d["instance"],
|
|
63
|
+
seed=d["seed"],
|
|
64
|
+
created=d["created"],
|
|
65
|
+
retired=bool(d.get("retired", False)),
|
|
66
|
+
full_sha256=d["full_sha256"],
|
|
67
|
+
normalized=d["normalized"],
|
|
68
|
+
shingles=frozenset(d["shingles"]),
|
|
69
|
+
shingle_words=d.get("shingle_words", SHINGLE_WORDS),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return records
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def retire_instance(manifest_path: Path, instance_id: str) -> bool:
|
|
76
|
+
"""Mark an instance retired (its content leaked); rewrite manifest atomically."""
|
|
77
|
+
records = load_manifest(manifest_path)
|
|
78
|
+
found = False
|
|
79
|
+
for i, r in enumerate(records):
|
|
80
|
+
if r.instance == instance_id and not r.retired:
|
|
81
|
+
records[i] = replace(r, retired=True)
|
|
82
|
+
found = True
|
|
83
|
+
if found:
|
|
84
|
+
tmp = manifest_path.with_suffix(".tmp")
|
|
85
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
86
|
+
for r in records:
|
|
87
|
+
fh.write(_line(r))
|
|
88
|
+
tmp.replace(manifest_path)
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _append(manifest_path: Path, rec: InstanceRecord) -> None:
|
|
93
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
with manifest_path.open("a", encoding="utf-8") as fh:
|
|
95
|
+
fh.write(_line(rec))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _line(rec: InstanceRecord) -> str:
|
|
99
|
+
d = {
|
|
100
|
+
"instance": rec.instance,
|
|
101
|
+
"seed": rec.seed,
|
|
102
|
+
"created": rec.created,
|
|
103
|
+
"retired": rec.retired,
|
|
104
|
+
"full_sha256": rec.full_sha256,
|
|
105
|
+
"normalized": rec.normalized,
|
|
106
|
+
"shingles": sorted(rec.shingles),
|
|
107
|
+
"shingle_words": rec.shingle_words,
|
|
108
|
+
}
|
|
109
|
+
return json.dumps(d, sort_keys=True) + "\n"
|