weir-run 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.
- weir_run-0.1.0/.gitignore +18 -0
- weir_run-0.1.0/PKG-INFO +285 -0
- weir_run-0.1.0/README.md +261 -0
- weir_run-0.1.0/demo/canary-leak.toml +33 -0
- weir_run-0.1.0/demo/canary.toml +33 -0
- weir_run-0.1.0/demo/flow.py +127 -0
- weir_run-0.1.0/pyproject.toml +47 -0
- weir_run-0.1.0/src/weir_run/__init__.py +6 -0
- weir_run-0.1.0/src/weir_run/capture.py +52 -0
- weir_run-0.1.0/src/weir_run/check.py +181 -0
- weir_run-0.1.0/src/weir_run/cli.py +95 -0
- weir_run-0.1.0/src/weir_run/config.py +169 -0
- weir_run-0.1.0/src/weir_run/flows.py +104 -0
- weir_run-0.1.0/src/weir_run/judge.py +92 -0
- weir_run-0.1.0/src/weir_run/ledger.py +190 -0
- weir_run-0.1.0/src/weir_run/mock/__init__.py +5 -0
- weir_run-0.1.0/src/weir_run/mock/model.py +303 -0
- weir_run-0.1.0/src/weir_run/mock/oauth.py +179 -0
- weir_run-0.1.0/src/weir_run/mock/otlp.py +122 -0
- weir_run-0.1.0/src/weir_run/mock/server.py +364 -0
- weir_run-0.1.0/src/weir_run/needles.py +120 -0
- weir_run-0.1.0/src/weir_run/py.typed +0 -0
- weir_run-0.1.0/src/weir_run/pytest_plugin.py +236 -0
- weir_run-0.1.0/src/weir_run/shapes.py +123 -0
- weir_run-0.1.0/src/weir_run/spec.py +219 -0
- weir_run-0.1.0/tests/_network.py +12 -0
- weir_run-0.1.0/tests/_probe_flow.py +50 -0
- weir_run-0.1.0/tests/conftest.py +83 -0
- weir_run-0.1.0/tests/test_runner_cli.py +335 -0
- weir_run-0.1.0/tests/test_runner_demo.py +104 -0
- weir_run-0.1.0/tests/test_runner_flows.py +157 -0
- weir_run-0.1.0/tests/test_runner_ledger.py +163 -0
- weir_run-0.1.0/tests/test_runner_mock.py +613 -0
- weir_run-0.1.0/tests/test_runner_needles.py +125 -0
- weir_run-0.1.0/tests/test_runner_plugin.py +137 -0
- weir_run-0.1.0/tests/test_runner_shapes.py +106 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
SECRETS_DONT_COMMIT.md.txt
|
|
2
|
+
# Engineering working docs stay local-only; removed from public history.
|
|
3
|
+
docs/superpowers/
|
|
4
|
+
# The private overlay. The tracked AGENTS.md is the curated public file; this
|
|
5
|
+
# one is never merged into it and never published.
|
|
6
|
+
CLAUDE.local.md
|
|
7
|
+
.venv/
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.pyc
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.uv_cache/
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
# Internal normative spec: requirement ids cited by docs/superpowers/. Local-only.
|
|
18
|
+
weir-engine-requirements.md
|
weir_run-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: weir-run
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Canary runner for weir canary judge: plants fake canaries, runs a program's flows against a loopback mock, judges the captured telemetry
|
|
5
|
+
Project-URL: Homepage, https://github.com/IdoGol24/weir
|
|
6
|
+
Project-URL: Repository, https://github.com/IdoGol24/weir
|
|
7
|
+
Project-URL: Issues, https://github.com/IdoGol24/weir/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/IdoGol24/weir/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Security, https://github.com/IdoGol24/weir/blob/main/SECURITY.md
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: click>=8.1
|
|
21
|
+
Requires-Dist: opentelemetry-proto>=1.44
|
|
22
|
+
Requires-Dist: weir-scan>=0.5.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# weir-run
|
|
26
|
+
|
|
27
|
+
The canary runner checks whether a program's telemetry carries the
|
|
28
|
+
credentials the program holds. It plants a unique fake credential (a
|
|
29
|
+
canary) wherever a real one would live, runs the program's own example
|
|
30
|
+
flows against a local mock with telemetry exported to a file, and asks
|
|
31
|
+
`weir canary judge` one question: did any planted value reach the capture?
|
|
32
|
+
Because the runner knows every value it planted, the answer is an exact
|
|
33
|
+
match in seven encoded forms, not a pattern guess.
|
|
34
|
+
|
|
35
|
+
Its contract, and the judge's, is weir's
|
|
36
|
+
[`docs/canary.md`](https://github.com/IdoGol24/weir/blob/main/docs/canary.md).
|
|
37
|
+
This page is for the maintainer who adds the check to a project's CI.
|
|
38
|
+
|
|
39
|
+
## The pieces
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
weir-run run --config canary.toml
|
|
43
|
+
1. plant one fresh fake value per placement -> ledger (mode 0600, private temp dir)
|
|
44
|
+
2. mock 127.0.0.1 only: OpenAI-compatible model API, OAuth endpoints,
|
|
45
|
+
OTLP/HTTP receiver, your project's own APIs; refuses every proxied
|
|
46
|
+
request to another host
|
|
47
|
+
3. flows each under an empty environment plus an allowlist, URLs pointed at the mock
|
|
48
|
+
4. capture OTLP/JSON lines + other telemetry bodies + each flow's stdout and stderr
|
|
49
|
+
5. judge weir canary judge (weir-scan: opens no socket, starts no process)
|
|
50
|
+
6. delete the ledger and the capture
|
|
51
|
+
exit 0 clean and complete | 1 a canary reached the capture | 2 usage, input or runner error | 3 incomplete
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **The judge is weir-scan's**, called only through its command line. The
|
|
55
|
+
runner never imports weir, and weir never imports the runner (both are
|
|
56
|
+
import-linter contracts in the repository's `pyproject.toml`). The runner
|
|
57
|
+
requires weir-scan 0.5.0 or later, the first release that ships
|
|
58
|
+
`weir canary judge`; without a judge it exits 2 before running anything.
|
|
59
|
+
- **Local and offline.** The mock binds `127.0.0.1` on a port the OS picks;
|
|
60
|
+
there is no option to bind anything else. Every flow gets the mock as its
|
|
61
|
+
HTTP and HTTPS proxy, and the mock refuses every proxied request: a
|
|
62
|
+
`CONNECT` or an absolute-URL request for any other host gets a 403, and
|
|
63
|
+
nothing is forwarded, opened or resolved. The runner does not sandbox the
|
|
64
|
+
network: a client that ignores proxy settings can connect directly (see
|
|
65
|
+
[Limits of this release](#limits-of-this-release)).
|
|
66
|
+
- **Test-time only.** The runner is a dependency of your CI job, never of
|
|
67
|
+
your program. The mock and the receiver exist only for one run.
|
|
68
|
+
|
|
69
|
+
## Fake data only
|
|
70
|
+
|
|
71
|
+
- Every value in a run is generated by the runner: the canaries it plants
|
|
72
|
+
and the tokens its OAuth endpoints issue. Never put a real credential in
|
|
73
|
+
a config, a flow's `env` or anywhere a flow reads it.
|
|
74
|
+
- Never point a flow at a real provider. Every API a flow calls must be the
|
|
75
|
+
mock: the model API through `OPENAI_BASE_URL`, your project's own APIs
|
|
76
|
+
through `[[mock.endpoint]]`, OAuth through the mock's endpoints.
|
|
77
|
+
- The ledger and the capture are deleted when the run ends. Do not copy
|
|
78
|
+
them anywhere, and never commit one: a provider-shaped canary in a public
|
|
79
|
+
repository is reported to the provider as a leaked key. Only the
|
|
80
|
+
value-free report may be kept, for example as a CI artifact.
|
|
81
|
+
- In a CI job, check out with `persist-credentials: false` so the job token
|
|
82
|
+
is not in the checkout a flow can read.
|
|
83
|
+
|
|
84
|
+
## Try the demo
|
|
85
|
+
|
|
86
|
+
The demo is one synthetic agent flow, standard library only: it gets an
|
|
87
|
+
OAuth token, asks the model (which calls its `web_search` tool), calls the
|
|
88
|
+
search API, sends the result back, and exports its spans over OTLP/HTTP.
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
weir-run run --config demo/canary.toml # exits 0
|
|
92
|
+
weir-run run --config demo/canary-leak.toml # exits 1
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`canary-leak.toml` differs only in `DEMO_LEAK = "1"`, which makes the flow
|
|
96
|
+
record its search key and its HTTP Basic client credentials as span
|
|
97
|
+
attributes, put its request headers (with the access token) in a JSON
|
|
98
|
+
string attribute, and print the search key in a debug line. The run
|
|
99
|
+
reports each as a `LEAK` line by placement, file, line and form, and the
|
|
100
|
+
relayed debug line reads `debug: search key <canary:env.SEARCH_API_KEY>`.
|
|
101
|
+
|
|
102
|
+
## The config
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[mock]
|
|
106
|
+
oauth = true # serve the OAuth endpoints (default false)
|
|
107
|
+
model_reply = "Done." # the model's final answer
|
|
108
|
+
tool_calls = "each-offered-tool-once" # or "none"
|
|
109
|
+
|
|
110
|
+
[mock.credential_fields] # extra credential fields of built-in endpoints
|
|
111
|
+
model = ["header:api-key"] # endpoints: model, oauth, otlp (otlp: header and query only)
|
|
112
|
+
|
|
113
|
+
[[mock.endpoint]] # one of your project's own APIs
|
|
114
|
+
id = "search" # served at {mock}/ep/search/...
|
|
115
|
+
credential_fields = ["header:x-api-key", "query:api_key", "body:api_key"]
|
|
116
|
+
status = 200
|
|
117
|
+
reply = '{"results": []}'
|
|
118
|
+
content_type = "application/json"
|
|
119
|
+
capture = false # true: keep request bodies as capture files
|
|
120
|
+
|
|
121
|
+
[[placement]]
|
|
122
|
+
id = "env.SEARCH_API_KEY" # the variable is the id after "env."
|
|
123
|
+
kind = "env" # env | code
|
|
124
|
+
shape = "generic" # see Shapes
|
|
125
|
+
required = true # incomplete unless exercised or leaked
|
|
126
|
+
provider_host = "api.search.example" # a refused attempt to reach it is evidence
|
|
127
|
+
|
|
128
|
+
[[placement]]
|
|
129
|
+
id = "code.client.api_key"
|
|
130
|
+
kind = "code"
|
|
131
|
+
variable = "MY_VARIABLE" # the flow reads this; omit it for CANARY_CODE_CLIENT_API_KEY
|
|
132
|
+
|
|
133
|
+
[[flow]]
|
|
134
|
+
id = "quickstart"
|
|
135
|
+
command = ["{python}", "examples/quickstart.py"] # {python}: the runner's interpreter
|
|
136
|
+
cwd = "." # relative to this file
|
|
137
|
+
timeout_seconds = 300
|
|
138
|
+
env = { SEARCH_BASE_URL = "{mock}/ep/search" } # non-secret settings; {mock} is the mock
|
|
139
|
+
skip = "needs a GPU" # optional: recorded as skipped, which is incomplete
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Unknown tables and keys are errors (exit 2), so a typo cannot silently
|
|
143
|
+
drop a placement. Ids follow the contract's id rule (1 to 128 characters
|
|
144
|
+
from `[A-Za-z0-9._:#/-]`) and are unique.
|
|
145
|
+
|
|
146
|
+
### Placements
|
|
147
|
+
|
|
148
|
+
This release plants two kinds and mints a third (docs/canary.md,
|
|
149
|
+
"Placements and kinds"):
|
|
150
|
+
|
|
151
|
+
| kind | where the canary lives |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `env` | the flow's environment, under the variable your software reads |
|
|
154
|
+
| `code` | a variable the runner names; the flow reads it and passes it explicitly to a constructor or call |
|
|
155
|
+
| `mock-oauth` | every authorization code, device code, access, refresh and ID token the mock's OAuth endpoints issue, as `runtime.oauth.<token>#<n>`; nothing to configure |
|
|
156
|
+
|
|
157
|
+
### Shapes
|
|
158
|
+
|
|
159
|
+
A canary is a provider's prefix followed by random `[A-Za-z0-9]`, so
|
|
160
|
+
software that checks a key's prefix or length accepts it. Each provider
|
|
161
|
+
shape matches the key pattern weir's catalog judges that provider's keys
|
|
162
|
+
by. No value ever contains `FAKE`, `TEST`, `EXAMPLE`, `DEMO`,
|
|
163
|
+
`PLACEHOLDER` or `REDACTED`, in any letter case.
|
|
164
|
+
|
|
165
|
+
| shape | prefix and body |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `generic` | 40 characters, no prefix |
|
|
168
|
+
| `openai` | `sk-proj-` + 48 |
|
|
169
|
+
| `anthropic` | `sk-ant-api03-` + 93 |
|
|
170
|
+
| `github` | `ghp_` + 36 |
|
|
171
|
+
| `google` | `AIza` + 35 |
|
|
172
|
+
| `aws-access-key-id` | `AKIA` + 16 of `[A-Z0-9]` |
|
|
173
|
+
|
|
174
|
+
Values come from the CSPRNG, fresh each run. `--seed N` swaps in a
|
|
175
|
+
deterministic stream for reproducing a run locally; never use it in CI.
|
|
176
|
+
|
|
177
|
+
## What a flow receives
|
|
178
|
+
|
|
179
|
+
A flow starts from an empty environment, not the job's, and gets exactly:
|
|
180
|
+
|
|
181
|
+
| variables | value |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| `PATH` | the runner's `PATH` |
|
|
184
|
+
| `HOME`, `TMPDIR`, `TEMP`, `TMP` | a fresh private directory per flow (`USERPROFILE` too on Windows) |
|
|
185
|
+
| `LANG`, `PYTHONUTF8` | `C.UTF-8`, `1` |
|
|
186
|
+
| `SYSTEMROOT`, `PATHEXT` | Windows only, from the runner |
|
|
187
|
+
| `CANARY_MOCK_URL` | the mock's base URL, `http://127.0.0.1:<port>` |
|
|
188
|
+
| `OPENAI_BASE_URL`, `OPENAI_API_BASE` | `<mock>/v1` |
|
|
189
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_{TRACES,LOGS,METRICS}_ENDPOINT` | the mock's receiver |
|
|
190
|
+
| `OTEL_EXPORTER_OTLP_PROTOCOL` and the per-signal ones | `http/protobuf` |
|
|
191
|
+
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (both cases) | the mock, which refuses |
|
|
192
|
+
| `NO_PROXY`, `no_proxy` | `127.0.0.1,localhost` |
|
|
193
|
+
| each placement's variable | its canary |
|
|
194
|
+
| the flow's `env` | as written, `{mock}` replaced |
|
|
195
|
+
|
|
196
|
+
A real provider key in your CI secrets never reaches a flow. A flow's
|
|
197
|
+
leftover child processes are stopped when the flow ends.
|
|
198
|
+
|
|
199
|
+
## The mock
|
|
200
|
+
|
|
201
|
+
| path | what it is |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
| `POST /v1/chat/completions` | Chat Completions, plain and streaming. With tools offered, turn one calls every tool once with a minimal schema-valid argument, so each tool's credentials are exercised; once tool results are in the conversation it answers `model_reply` |
|
|
204
|
+
| `POST /v1/responses` | Responses, plain and streaming, same script |
|
|
205
|
+
| `POST /v1/embeddings`, `GET /v1/models` | fixed answers |
|
|
206
|
+
| `POST /v1/traces`, `/v1/logs`, `/v1/metrics` | the OTLP/HTTP receiver: protobuf or JSON, gzip or deflate |
|
|
207
|
+
| `GET /oauth/authorize` | redirects with a fresh code |
|
|
208
|
+
| `POST /oauth/token` | authorization code, refresh token, password, device code, client credentials, token exchange and JWT bearer grants; a refresh token where the grant usually has one, an ID token when the scope holds `openid` |
|
|
209
|
+
| `POST /oauth/device_authorization` | a fresh device code; the user code is not a credential |
|
|
210
|
+
| `GET /.well-known/openid-configuration` | discovery metadata pointing at the mock |
|
|
211
|
+
| `/ep/<id>/...` | a configured endpoint: its fixed reply |
|
|
212
|
+
|
|
213
|
+
Every supported grant succeeds, whatever it presents. The OAuth paths exist only with `oauth = true`.
|
|
214
|
+
Anything else is answered 404 and listed after the run.
|
|
215
|
+
|
|
216
|
+
**Credential fields.** Before any of these handles a request, the mock
|
|
217
|
+
inspects its credential fields: the whole `Authorization` and
|
|
218
|
+
`Proxy-Authorization` headers on every mocked endpoint, each endpoint's
|
|
219
|
+
declared fields,
|
|
220
|
+
and on the OAuth endpoints the form fields `client_secret`,
|
|
221
|
+
`client_assertion`, `assertion`, `code`, `device_code`, `refresh_token`,
|
|
222
|
+
`subject_token`, `actor_token` and `password`. A field holding a canary in
|
|
223
|
+
any encoded form (a Basic credential included) is `request` evidence that
|
|
224
|
+
the placement was exercised. Any other non-empty value is recorded as
|
|
225
|
+
`unexpected.<endpoint>.<field>#<n>`, never with its value, and makes the
|
|
226
|
+
run incomplete: a flow that sends a credential no canary accounts for has
|
|
227
|
+
left the check's premise.
|
|
228
|
+
|
|
229
|
+
**The capture.** The receiver writes each export request decompressed, as
|
|
230
|
+
one OTLP/JSON line (trace and span ids in lowercase hex), under
|
|
231
|
+
`capture/otlp/`. Request headers never enter the capture. A body it cannot
|
|
232
|
+
decode is kept as its own capture file and a marker line makes the run
|
|
233
|
+
incomplete. Request bodies of an endpoint with `capture = true` and each
|
|
234
|
+
flow's stdout and stderr are swept capture files.
|
|
235
|
+
|
|
236
|
+
## Output
|
|
237
|
+
|
|
238
|
+
- **stderr:** each flow's status and its own stdout and stderr, relayed
|
|
239
|
+
after the run with every encoded form of every canary replaced by
|
|
240
|
+
`<canary:PLACEMENT-ID>`; refused egress and unmocked requests by count.
|
|
241
|
+
- **stdout:** the judge's text report: one `LEAK` line per hit, the
|
|
242
|
+
not-exercised placements, the incomplete reasons, the coverage line and
|
|
243
|
+
the result. It names placements and locations, never a value or a
|
|
244
|
+
fragment of one.
|
|
245
|
+
- `--report FILE` also writes the judge's `canary-report/1` JSON.
|
|
246
|
+
- The exit code is the judge's, or 2 for the runner's own usage and
|
|
247
|
+
configuration errors and any other failure of the run (an unwritable
|
|
248
|
+
`--report`, a judge that times out). A runner fault never exits 1.
|
|
249
|
+
|
|
250
|
+
A clean result is evidence about the flows that ran and the placements
|
|
251
|
+
planted, not a statement that the program is safe. Read the coverage line:
|
|
252
|
+
a placement no flow exercised is printed as `not exercised`.
|
|
253
|
+
|
|
254
|
+
## The pytest plugin
|
|
255
|
+
|
|
256
|
+
Installing the runner registers a `canary_check` fixture (subprocess mode):
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
def test_quickstart_exports_no_credentials(canary_check):
|
|
260
|
+
canary_check.mock(oauth=True)
|
|
261
|
+
canary_check.env("SEARCH_API_KEY", required=True)
|
|
262
|
+
variable = canary_check.code("code.client.api_key")
|
|
263
|
+
search = canary_check.endpoint("search", credential_fields=["header:x-api-key"])
|
|
264
|
+
canary_check.run(["{python}", "examples/quickstart.py"], env={"SEARCH_BASE_URL": search})
|
|
265
|
+
# teardown judges; the test fails on a leak or an incomplete run
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Declare the mock, the placements and the endpoints first; the first `run`
|
|
269
|
+
plants the canaries and starts the mock. At teardown the plugin judges,
|
|
270
|
+
prints the redacted flow output and the report, deletes the ledger and the
|
|
271
|
+
capture, and fails the test unless the result is clean. To assert on the
|
|
272
|
+
result yourself, call `result = canary_check.judge()` (`result.code`,
|
|
273
|
+
`result.report`). The judge is the `weir` beside the interpreter or on
|
|
274
|
+
`PATH`; override the `canary_judge_command` fixture to use another.
|
|
275
|
+
|
|
276
|
+
## Limits of this release
|
|
277
|
+
|
|
278
|
+
- Placements: `env`, `code` and `mock-oauth` only. The other kinds in the
|
|
279
|
+
contract (`tool-field`, `mcp`, `settings-file` and the rest) are reserved.
|
|
280
|
+
- Model APIs: OpenAI-compatible only.
|
|
281
|
+
- The runner does not sandbox the network. A client that ignores proxy
|
|
282
|
+
settings and has a hard-coded host is not stopped by the runner: on a
|
|
283
|
+
machine with network access it connects to that host directly. Run such
|
|
284
|
+
flows in a container or network namespace with no route out.
|
|
285
|
+
- No GitHub Action and no in-process pytest mode yet.
|
weir_run-0.1.0/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# weir-run
|
|
2
|
+
|
|
3
|
+
The canary runner checks whether a program's telemetry carries the
|
|
4
|
+
credentials the program holds. It plants a unique fake credential (a
|
|
5
|
+
canary) wherever a real one would live, runs the program's own example
|
|
6
|
+
flows against a local mock with telemetry exported to a file, and asks
|
|
7
|
+
`weir canary judge` one question: did any planted value reach the capture?
|
|
8
|
+
Because the runner knows every value it planted, the answer is an exact
|
|
9
|
+
match in seven encoded forms, not a pattern guess.
|
|
10
|
+
|
|
11
|
+
Its contract, and the judge's, is weir's
|
|
12
|
+
[`docs/canary.md`](https://github.com/IdoGol24/weir/blob/main/docs/canary.md).
|
|
13
|
+
This page is for the maintainer who adds the check to a project's CI.
|
|
14
|
+
|
|
15
|
+
## The pieces
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
weir-run run --config canary.toml
|
|
19
|
+
1. plant one fresh fake value per placement -> ledger (mode 0600, private temp dir)
|
|
20
|
+
2. mock 127.0.0.1 only: OpenAI-compatible model API, OAuth endpoints,
|
|
21
|
+
OTLP/HTTP receiver, your project's own APIs; refuses every proxied
|
|
22
|
+
request to another host
|
|
23
|
+
3. flows each under an empty environment plus an allowlist, URLs pointed at the mock
|
|
24
|
+
4. capture OTLP/JSON lines + other telemetry bodies + each flow's stdout and stderr
|
|
25
|
+
5. judge weir canary judge (weir-scan: opens no socket, starts no process)
|
|
26
|
+
6. delete the ledger and the capture
|
|
27
|
+
exit 0 clean and complete | 1 a canary reached the capture | 2 usage, input or runner error | 3 incomplete
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **The judge is weir-scan's**, called only through its command line. The
|
|
31
|
+
runner never imports weir, and weir never imports the runner (both are
|
|
32
|
+
import-linter contracts in the repository's `pyproject.toml`). The runner
|
|
33
|
+
requires weir-scan 0.5.0 or later, the first release that ships
|
|
34
|
+
`weir canary judge`; without a judge it exits 2 before running anything.
|
|
35
|
+
- **Local and offline.** The mock binds `127.0.0.1` on a port the OS picks;
|
|
36
|
+
there is no option to bind anything else. Every flow gets the mock as its
|
|
37
|
+
HTTP and HTTPS proxy, and the mock refuses every proxied request: a
|
|
38
|
+
`CONNECT` or an absolute-URL request for any other host gets a 403, and
|
|
39
|
+
nothing is forwarded, opened or resolved. The runner does not sandbox the
|
|
40
|
+
network: a client that ignores proxy settings can connect directly (see
|
|
41
|
+
[Limits of this release](#limits-of-this-release)).
|
|
42
|
+
- **Test-time only.** The runner is a dependency of your CI job, never of
|
|
43
|
+
your program. The mock and the receiver exist only for one run.
|
|
44
|
+
|
|
45
|
+
## Fake data only
|
|
46
|
+
|
|
47
|
+
- Every value in a run is generated by the runner: the canaries it plants
|
|
48
|
+
and the tokens its OAuth endpoints issue. Never put a real credential in
|
|
49
|
+
a config, a flow's `env` or anywhere a flow reads it.
|
|
50
|
+
- Never point a flow at a real provider. Every API a flow calls must be the
|
|
51
|
+
mock: the model API through `OPENAI_BASE_URL`, your project's own APIs
|
|
52
|
+
through `[[mock.endpoint]]`, OAuth through the mock's endpoints.
|
|
53
|
+
- The ledger and the capture are deleted when the run ends. Do not copy
|
|
54
|
+
them anywhere, and never commit one: a provider-shaped canary in a public
|
|
55
|
+
repository is reported to the provider as a leaked key. Only the
|
|
56
|
+
value-free report may be kept, for example as a CI artifact.
|
|
57
|
+
- In a CI job, check out with `persist-credentials: false` so the job token
|
|
58
|
+
is not in the checkout a flow can read.
|
|
59
|
+
|
|
60
|
+
## Try the demo
|
|
61
|
+
|
|
62
|
+
The demo is one synthetic agent flow, standard library only: it gets an
|
|
63
|
+
OAuth token, asks the model (which calls its `web_search` tool), calls the
|
|
64
|
+
search API, sends the result back, and exports its spans over OTLP/HTTP.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
weir-run run --config demo/canary.toml # exits 0
|
|
68
|
+
weir-run run --config demo/canary-leak.toml # exits 1
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`canary-leak.toml` differs only in `DEMO_LEAK = "1"`, which makes the flow
|
|
72
|
+
record its search key and its HTTP Basic client credentials as span
|
|
73
|
+
attributes, put its request headers (with the access token) in a JSON
|
|
74
|
+
string attribute, and print the search key in a debug line. The run
|
|
75
|
+
reports each as a `LEAK` line by placement, file, line and form, and the
|
|
76
|
+
relayed debug line reads `debug: search key <canary:env.SEARCH_API_KEY>`.
|
|
77
|
+
|
|
78
|
+
## The config
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[mock]
|
|
82
|
+
oauth = true # serve the OAuth endpoints (default false)
|
|
83
|
+
model_reply = "Done." # the model's final answer
|
|
84
|
+
tool_calls = "each-offered-tool-once" # or "none"
|
|
85
|
+
|
|
86
|
+
[mock.credential_fields] # extra credential fields of built-in endpoints
|
|
87
|
+
model = ["header:api-key"] # endpoints: model, oauth, otlp (otlp: header and query only)
|
|
88
|
+
|
|
89
|
+
[[mock.endpoint]] # one of your project's own APIs
|
|
90
|
+
id = "search" # served at {mock}/ep/search/...
|
|
91
|
+
credential_fields = ["header:x-api-key", "query:api_key", "body:api_key"]
|
|
92
|
+
status = 200
|
|
93
|
+
reply = '{"results": []}'
|
|
94
|
+
content_type = "application/json"
|
|
95
|
+
capture = false # true: keep request bodies as capture files
|
|
96
|
+
|
|
97
|
+
[[placement]]
|
|
98
|
+
id = "env.SEARCH_API_KEY" # the variable is the id after "env."
|
|
99
|
+
kind = "env" # env | code
|
|
100
|
+
shape = "generic" # see Shapes
|
|
101
|
+
required = true # incomplete unless exercised or leaked
|
|
102
|
+
provider_host = "api.search.example" # a refused attempt to reach it is evidence
|
|
103
|
+
|
|
104
|
+
[[placement]]
|
|
105
|
+
id = "code.client.api_key"
|
|
106
|
+
kind = "code"
|
|
107
|
+
variable = "MY_VARIABLE" # the flow reads this; omit it for CANARY_CODE_CLIENT_API_KEY
|
|
108
|
+
|
|
109
|
+
[[flow]]
|
|
110
|
+
id = "quickstart"
|
|
111
|
+
command = ["{python}", "examples/quickstart.py"] # {python}: the runner's interpreter
|
|
112
|
+
cwd = "." # relative to this file
|
|
113
|
+
timeout_seconds = 300
|
|
114
|
+
env = { SEARCH_BASE_URL = "{mock}/ep/search" } # non-secret settings; {mock} is the mock
|
|
115
|
+
skip = "needs a GPU" # optional: recorded as skipped, which is incomplete
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Unknown tables and keys are errors (exit 2), so a typo cannot silently
|
|
119
|
+
drop a placement. Ids follow the contract's id rule (1 to 128 characters
|
|
120
|
+
from `[A-Za-z0-9._:#/-]`) and are unique.
|
|
121
|
+
|
|
122
|
+
### Placements
|
|
123
|
+
|
|
124
|
+
This release plants two kinds and mints a third (docs/canary.md,
|
|
125
|
+
"Placements and kinds"):
|
|
126
|
+
|
|
127
|
+
| kind | where the canary lives |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `env` | the flow's environment, under the variable your software reads |
|
|
130
|
+
| `code` | a variable the runner names; the flow reads it and passes it explicitly to a constructor or call |
|
|
131
|
+
| `mock-oauth` | every authorization code, device code, access, refresh and ID token the mock's OAuth endpoints issue, as `runtime.oauth.<token>#<n>`; nothing to configure |
|
|
132
|
+
|
|
133
|
+
### Shapes
|
|
134
|
+
|
|
135
|
+
A canary is a provider's prefix followed by random `[A-Za-z0-9]`, so
|
|
136
|
+
software that checks a key's prefix or length accepts it. Each provider
|
|
137
|
+
shape matches the key pattern weir's catalog judges that provider's keys
|
|
138
|
+
by. No value ever contains `FAKE`, `TEST`, `EXAMPLE`, `DEMO`,
|
|
139
|
+
`PLACEHOLDER` or `REDACTED`, in any letter case.
|
|
140
|
+
|
|
141
|
+
| shape | prefix and body |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `generic` | 40 characters, no prefix |
|
|
144
|
+
| `openai` | `sk-proj-` + 48 |
|
|
145
|
+
| `anthropic` | `sk-ant-api03-` + 93 |
|
|
146
|
+
| `github` | `ghp_` + 36 |
|
|
147
|
+
| `google` | `AIza` + 35 |
|
|
148
|
+
| `aws-access-key-id` | `AKIA` + 16 of `[A-Z0-9]` |
|
|
149
|
+
|
|
150
|
+
Values come from the CSPRNG, fresh each run. `--seed N` swaps in a
|
|
151
|
+
deterministic stream for reproducing a run locally; never use it in CI.
|
|
152
|
+
|
|
153
|
+
## What a flow receives
|
|
154
|
+
|
|
155
|
+
A flow starts from an empty environment, not the job's, and gets exactly:
|
|
156
|
+
|
|
157
|
+
| variables | value |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `PATH` | the runner's `PATH` |
|
|
160
|
+
| `HOME`, `TMPDIR`, `TEMP`, `TMP` | a fresh private directory per flow (`USERPROFILE` too on Windows) |
|
|
161
|
+
| `LANG`, `PYTHONUTF8` | `C.UTF-8`, `1` |
|
|
162
|
+
| `SYSTEMROOT`, `PATHEXT` | Windows only, from the runner |
|
|
163
|
+
| `CANARY_MOCK_URL` | the mock's base URL, `http://127.0.0.1:<port>` |
|
|
164
|
+
| `OPENAI_BASE_URL`, `OPENAI_API_BASE` | `<mock>/v1` |
|
|
165
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_{TRACES,LOGS,METRICS}_ENDPOINT` | the mock's receiver |
|
|
166
|
+
| `OTEL_EXPORTER_OTLP_PROTOCOL` and the per-signal ones | `http/protobuf` |
|
|
167
|
+
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (both cases) | the mock, which refuses |
|
|
168
|
+
| `NO_PROXY`, `no_proxy` | `127.0.0.1,localhost` |
|
|
169
|
+
| each placement's variable | its canary |
|
|
170
|
+
| the flow's `env` | as written, `{mock}` replaced |
|
|
171
|
+
|
|
172
|
+
A real provider key in your CI secrets never reaches a flow. A flow's
|
|
173
|
+
leftover child processes are stopped when the flow ends.
|
|
174
|
+
|
|
175
|
+
## The mock
|
|
176
|
+
|
|
177
|
+
| path | what it is |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| `POST /v1/chat/completions` | Chat Completions, plain and streaming. With tools offered, turn one calls every tool once with a minimal schema-valid argument, so each tool's credentials are exercised; once tool results are in the conversation it answers `model_reply` |
|
|
180
|
+
| `POST /v1/responses` | Responses, plain and streaming, same script |
|
|
181
|
+
| `POST /v1/embeddings`, `GET /v1/models` | fixed answers |
|
|
182
|
+
| `POST /v1/traces`, `/v1/logs`, `/v1/metrics` | the OTLP/HTTP receiver: protobuf or JSON, gzip or deflate |
|
|
183
|
+
| `GET /oauth/authorize` | redirects with a fresh code |
|
|
184
|
+
| `POST /oauth/token` | authorization code, refresh token, password, device code, client credentials, token exchange and JWT bearer grants; a refresh token where the grant usually has one, an ID token when the scope holds `openid` |
|
|
185
|
+
| `POST /oauth/device_authorization` | a fresh device code; the user code is not a credential |
|
|
186
|
+
| `GET /.well-known/openid-configuration` | discovery metadata pointing at the mock |
|
|
187
|
+
| `/ep/<id>/...` | a configured endpoint: its fixed reply |
|
|
188
|
+
|
|
189
|
+
Every supported grant succeeds, whatever it presents. The OAuth paths exist only with `oauth = true`.
|
|
190
|
+
Anything else is answered 404 and listed after the run.
|
|
191
|
+
|
|
192
|
+
**Credential fields.** Before any of these handles a request, the mock
|
|
193
|
+
inspects its credential fields: the whole `Authorization` and
|
|
194
|
+
`Proxy-Authorization` headers on every mocked endpoint, each endpoint's
|
|
195
|
+
declared fields,
|
|
196
|
+
and on the OAuth endpoints the form fields `client_secret`,
|
|
197
|
+
`client_assertion`, `assertion`, `code`, `device_code`, `refresh_token`,
|
|
198
|
+
`subject_token`, `actor_token` and `password`. A field holding a canary in
|
|
199
|
+
any encoded form (a Basic credential included) is `request` evidence that
|
|
200
|
+
the placement was exercised. Any other non-empty value is recorded as
|
|
201
|
+
`unexpected.<endpoint>.<field>#<n>`, never with its value, and makes the
|
|
202
|
+
run incomplete: a flow that sends a credential no canary accounts for has
|
|
203
|
+
left the check's premise.
|
|
204
|
+
|
|
205
|
+
**The capture.** The receiver writes each export request decompressed, as
|
|
206
|
+
one OTLP/JSON line (trace and span ids in lowercase hex), under
|
|
207
|
+
`capture/otlp/`. Request headers never enter the capture. A body it cannot
|
|
208
|
+
decode is kept as its own capture file and a marker line makes the run
|
|
209
|
+
incomplete. Request bodies of an endpoint with `capture = true` and each
|
|
210
|
+
flow's stdout and stderr are swept capture files.
|
|
211
|
+
|
|
212
|
+
## Output
|
|
213
|
+
|
|
214
|
+
- **stderr:** each flow's status and its own stdout and stderr, relayed
|
|
215
|
+
after the run with every encoded form of every canary replaced by
|
|
216
|
+
`<canary:PLACEMENT-ID>`; refused egress and unmocked requests by count.
|
|
217
|
+
- **stdout:** the judge's text report: one `LEAK` line per hit, the
|
|
218
|
+
not-exercised placements, the incomplete reasons, the coverage line and
|
|
219
|
+
the result. It names placements and locations, never a value or a
|
|
220
|
+
fragment of one.
|
|
221
|
+
- `--report FILE` also writes the judge's `canary-report/1` JSON.
|
|
222
|
+
- The exit code is the judge's, or 2 for the runner's own usage and
|
|
223
|
+
configuration errors and any other failure of the run (an unwritable
|
|
224
|
+
`--report`, a judge that times out). A runner fault never exits 1.
|
|
225
|
+
|
|
226
|
+
A clean result is evidence about the flows that ran and the placements
|
|
227
|
+
planted, not a statement that the program is safe. Read the coverage line:
|
|
228
|
+
a placement no flow exercised is printed as `not exercised`.
|
|
229
|
+
|
|
230
|
+
## The pytest plugin
|
|
231
|
+
|
|
232
|
+
Installing the runner registers a `canary_check` fixture (subprocess mode):
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
def test_quickstart_exports_no_credentials(canary_check):
|
|
236
|
+
canary_check.mock(oauth=True)
|
|
237
|
+
canary_check.env("SEARCH_API_KEY", required=True)
|
|
238
|
+
variable = canary_check.code("code.client.api_key")
|
|
239
|
+
search = canary_check.endpoint("search", credential_fields=["header:x-api-key"])
|
|
240
|
+
canary_check.run(["{python}", "examples/quickstart.py"], env={"SEARCH_BASE_URL": search})
|
|
241
|
+
# teardown judges; the test fails on a leak or an incomplete run
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Declare the mock, the placements and the endpoints first; the first `run`
|
|
245
|
+
plants the canaries and starts the mock. At teardown the plugin judges,
|
|
246
|
+
prints the redacted flow output and the report, deletes the ledger and the
|
|
247
|
+
capture, and fails the test unless the result is clean. To assert on the
|
|
248
|
+
result yourself, call `result = canary_check.judge()` (`result.code`,
|
|
249
|
+
`result.report`). The judge is the `weir` beside the interpreter or on
|
|
250
|
+
`PATH`; override the `canary_judge_command` fixture to use another.
|
|
251
|
+
|
|
252
|
+
## Limits of this release
|
|
253
|
+
|
|
254
|
+
- Placements: `env`, `code` and `mock-oauth` only. The other kinds in the
|
|
255
|
+
contract (`tool-field`, `mcp`, `settings-file` and the rest) are reserved.
|
|
256
|
+
- Model APIs: OpenAI-compatible only.
|
|
257
|
+
- The runner does not sandbox the network. A client that ignores proxy
|
|
258
|
+
settings and has a hard-coded host is not stopped by the runner: on a
|
|
259
|
+
machine with network access it connects to that host directly. Run such
|
|
260
|
+
flows in a container or network namespace with no route out.
|
|
261
|
+
- No GitHub Action and no in-process pytest mode yet.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# The canary runner's demo, leaking: the flow of canary.toml with
|
|
2
|
+
# DEMO_LEAK=1, which writes three canaries into its spans and one into its
|
|
3
|
+
# stderr. `weir-run run --config canary-leak.toml` exits 1.
|
|
4
|
+
|
|
5
|
+
[mock]
|
|
6
|
+
oauth = true # the flow's OAuth provider is the mock
|
|
7
|
+
|
|
8
|
+
[[mock.endpoint]]
|
|
9
|
+
id = "search" # the search API, at {mock}/ep/search
|
|
10
|
+
credential_fields = ["header:x-api-key"]
|
|
11
|
+
reply = '{"results": [{"title": "weir", "snippet": "a post-hoc verification engine"}]}'
|
|
12
|
+
|
|
13
|
+
[[placement]]
|
|
14
|
+
id = "env.OPENAI_API_KEY"
|
|
15
|
+
kind = "env"
|
|
16
|
+
shape = "openai"
|
|
17
|
+
required = true
|
|
18
|
+
|
|
19
|
+
[[placement]]
|
|
20
|
+
id = "env.SEARCH_API_KEY"
|
|
21
|
+
kind = "env"
|
|
22
|
+
required = true
|
|
23
|
+
|
|
24
|
+
[[placement]]
|
|
25
|
+
id = "code.auth.client_secret" # read from CANARY_CODE_AUTH_CLIENT_SECRET
|
|
26
|
+
kind = "code"
|
|
27
|
+
required = true
|
|
28
|
+
|
|
29
|
+
[[flow]]
|
|
30
|
+
id = "demo"
|
|
31
|
+
command = ["{python}", "flow.py"]
|
|
32
|
+
timeout_seconds = 60
|
|
33
|
+
env = { SEARCH_BASE_URL = "{mock}/ep/search", DEMO_LEAK = "1" }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# The canary runner's demo: one synthetic agent flow that exports clean
|
|
2
|
+
# telemetry. `weir-run run --config canary.toml` exits 0.
|
|
3
|
+
# canary-leak.toml runs the same flow with DEMO_LEAK=1 and exits 1.
|
|
4
|
+
|
|
5
|
+
[mock]
|
|
6
|
+
oauth = true # the flow's OAuth provider is the mock
|
|
7
|
+
|
|
8
|
+
[[mock.endpoint]]
|
|
9
|
+
id = "search" # the search API, at {mock}/ep/search
|
|
10
|
+
credential_fields = ["header:x-api-key"]
|
|
11
|
+
reply = '{"results": [{"title": "weir", "snippet": "a post-hoc verification engine"}]}'
|
|
12
|
+
|
|
13
|
+
[[placement]]
|
|
14
|
+
id = "env.OPENAI_API_KEY"
|
|
15
|
+
kind = "env"
|
|
16
|
+
shape = "openai"
|
|
17
|
+
required = true
|
|
18
|
+
|
|
19
|
+
[[placement]]
|
|
20
|
+
id = "env.SEARCH_API_KEY"
|
|
21
|
+
kind = "env"
|
|
22
|
+
required = true
|
|
23
|
+
|
|
24
|
+
[[placement]]
|
|
25
|
+
id = "code.auth.client_secret" # read from CANARY_CODE_AUTH_CLIENT_SECRET
|
|
26
|
+
kind = "code"
|
|
27
|
+
required = true
|
|
28
|
+
|
|
29
|
+
[[flow]]
|
|
30
|
+
id = "demo"
|
|
31
|
+
command = ["{python}", "flow.py"]
|
|
32
|
+
timeout_seconds = 60
|
|
33
|
+
env = { SEARCH_BASE_URL = "{mock}/ep/search" }
|