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.
Files changed (36) hide show
  1. weir_run-0.1.0/.gitignore +18 -0
  2. weir_run-0.1.0/PKG-INFO +285 -0
  3. weir_run-0.1.0/README.md +261 -0
  4. weir_run-0.1.0/demo/canary-leak.toml +33 -0
  5. weir_run-0.1.0/demo/canary.toml +33 -0
  6. weir_run-0.1.0/demo/flow.py +127 -0
  7. weir_run-0.1.0/pyproject.toml +47 -0
  8. weir_run-0.1.0/src/weir_run/__init__.py +6 -0
  9. weir_run-0.1.0/src/weir_run/capture.py +52 -0
  10. weir_run-0.1.0/src/weir_run/check.py +181 -0
  11. weir_run-0.1.0/src/weir_run/cli.py +95 -0
  12. weir_run-0.1.0/src/weir_run/config.py +169 -0
  13. weir_run-0.1.0/src/weir_run/flows.py +104 -0
  14. weir_run-0.1.0/src/weir_run/judge.py +92 -0
  15. weir_run-0.1.0/src/weir_run/ledger.py +190 -0
  16. weir_run-0.1.0/src/weir_run/mock/__init__.py +5 -0
  17. weir_run-0.1.0/src/weir_run/mock/model.py +303 -0
  18. weir_run-0.1.0/src/weir_run/mock/oauth.py +179 -0
  19. weir_run-0.1.0/src/weir_run/mock/otlp.py +122 -0
  20. weir_run-0.1.0/src/weir_run/mock/server.py +364 -0
  21. weir_run-0.1.0/src/weir_run/needles.py +120 -0
  22. weir_run-0.1.0/src/weir_run/py.typed +0 -0
  23. weir_run-0.1.0/src/weir_run/pytest_plugin.py +236 -0
  24. weir_run-0.1.0/src/weir_run/shapes.py +123 -0
  25. weir_run-0.1.0/src/weir_run/spec.py +219 -0
  26. weir_run-0.1.0/tests/_network.py +12 -0
  27. weir_run-0.1.0/tests/_probe_flow.py +50 -0
  28. weir_run-0.1.0/tests/conftest.py +83 -0
  29. weir_run-0.1.0/tests/test_runner_cli.py +335 -0
  30. weir_run-0.1.0/tests/test_runner_demo.py +104 -0
  31. weir_run-0.1.0/tests/test_runner_flows.py +157 -0
  32. weir_run-0.1.0/tests/test_runner_ledger.py +163 -0
  33. weir_run-0.1.0/tests/test_runner_mock.py +613 -0
  34. weir_run-0.1.0/tests/test_runner_needles.py +125 -0
  35. weir_run-0.1.0/tests/test_runner_plugin.py +137 -0
  36. 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
@@ -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.
@@ -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" }