quality-loop 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.
- quality_loop-0.1.0/LICENSE +21 -0
- quality_loop-0.1.0/PKG-INFO +74 -0
- quality_loop-0.1.0/README.md +46 -0
- quality_loop-0.1.0/pyproject.toml +51 -0
- quality_loop-0.1.0/quality_loop/OPERATOR.md +103 -0
- quality_loop-0.1.0/quality_loop/__init__.py +15 -0
- quality_loop-0.1.0/quality_loop/__main__.py +48 -0
- quality_loop-0.1.0/quality_loop/adapter.py +88 -0
- quality_loop-0.1.0/quality_loop/audit.py +144 -0
- quality_loop-0.1.0/quality_loop/mcp.py +161 -0
- quality_loop-0.1.0/quality_loop/operator.py +186 -0
- quality_loop-0.1.0/quality_loop/pages.py +84 -0
- quality_loop-0.1.0/quality_loop/report.py +170 -0
- quality_loop-0.1.0/quality_loop/room.py +105 -0
- quality_loop-0.1.0/quality_loop/runner.py +160 -0
- quality_loop-0.1.0/quality_loop/scenario.py +160 -0
- quality_loop-0.1.0/quality_loop/screens/__init__.py +192 -0
- quality_loop-0.1.0/quality_loop/screens/_mac.py +152 -0
- quality_loop-0.1.0/quality_loop/screens/_windows.py +110 -0
- quality_loop-0.1.0/quality_loop/session.py +301 -0
- quality_loop-0.1.0/quality_loop/standin.py +193 -0
- quality_loop-0.1.0/quality_loop/transcript.py +130 -0
- quality_loop-0.1.0/quality_loop.egg-info/PKG-INFO +74 -0
- quality_loop-0.1.0/quality_loop.egg-info/SOURCES.txt +31 -0
- quality_loop-0.1.0/quality_loop.egg-info/dependency_links.txt +1 -0
- quality_loop-0.1.0/quality_loop.egg-info/requires.txt +4 -0
- quality_loop-0.1.0/quality_loop.egg-info/top_level.txt +1 -0
- quality_loop-0.1.0/setup.cfg +4 -0
- quality_loop-0.1.0/tests/test_core.py +371 -0
- quality_loop-0.1.0/tests/test_mcp.py +82 -0
- quality_loop-0.1.0/tests/test_operator.py +129 -0
- quality_loop-0.1.0/tests/test_scenario_report.py +144 -0
- quality_loop-0.1.0/tests/test_screens.py +167 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 craftware
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quality-loop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure what an agent-facing product teaches: isolated agent runs of real tasks, reported by an operator agent
|
|
5
|
+
Author: craftware
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/crftwr/quality-loop
|
|
8
|
+
Project-URL: Repository, https://github.com/crftwr/quality-loop
|
|
9
|
+
Project-URL: Issues, https://github.com/crftwr/quality-loop/issues
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-timeout>=2; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# quality-loop
|
|
30
|
+
|
|
31
|
+
Measure what an agent-facing product teaches.
|
|
32
|
+
|
|
33
|
+
A skill bundle, an MCP server or a CLI is only as good as what an agent with
|
|
34
|
+
nothing else can do with it. quality-loop runs that agent on a real task, in a
|
|
35
|
+
**room** that holds the product's agent-facing surface and nothing else. The
|
|
36
|
+
room is started so it cannot read the repository where the answers are.
|
|
37
|
+
Afterwards an **operator** agent reports the problems it finds in what the
|
|
38
|
+
room did, each with a severity. A human decides what to do with the report.
|
|
39
|
+
|
|
40
|
+
> Status: early development, run end to end against a toy product
|
|
41
|
+
> (`examples/toy/run.py`). In place: rooms and their isolation, the audit,
|
|
42
|
+
> the stand-in, fixture pages, screens by name on macOS and Windows,
|
|
43
|
+
> Markdown scenarios, the operator agent and its MCP tools, and reports with
|
|
44
|
+
> severities. The first real product, Keyhac, is next. The design is
|
|
45
|
+
> [crftwr/keyhac#205](https://github.com/crftwr/keyhac/issues/205).
|
|
46
|
+
|
|
47
|
+
## What stays code, and why
|
|
48
|
+
|
|
49
|
+
Anything a run depends on to mean something is code, not something an agent
|
|
50
|
+
decides:
|
|
51
|
+
|
|
52
|
+
- **The prompt the room is handed** is copied verbatim. The operator knows
|
|
53
|
+
what a good answer looks like, so any paraphrase of the prompt can leak it.
|
|
54
|
+
- **Isolation.** The room runs with `--restricted`, an explicit permission
|
|
55
|
+
list under `dontAsk`, one MCP configuration and nothing else, git stopped at
|
|
56
|
+
the room, and stdin closed. A transcript audit then checks what no flag can
|
|
57
|
+
enforce.
|
|
58
|
+
- **The operator's machine.** Their files are moved aside and replaced with
|
|
59
|
+
fixtures. On the way out they are put back in a `finally`, whether the run
|
|
60
|
+
ended normally, by an exception, by Ctrl-C or by SIGTERM.
|
|
61
|
+
- **Evidence.** A report cites what tools recorded, not what the room said.
|
|
62
|
+
|
|
63
|
+
## Products using it
|
|
64
|
+
|
|
65
|
+
- [Keyhac](https://github.com/crftwr/keyhac)
|
|
66
|
+
- [XeFM](https://github.com/crftwr/xefm) (planned)
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
make test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
MIT licensed.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# quality-loop
|
|
2
|
+
|
|
3
|
+
Measure what an agent-facing product teaches.
|
|
4
|
+
|
|
5
|
+
A skill bundle, an MCP server or a CLI is only as good as what an agent with
|
|
6
|
+
nothing else can do with it. quality-loop runs that agent on a real task, in a
|
|
7
|
+
**room** that holds the product's agent-facing surface and nothing else. The
|
|
8
|
+
room is started so it cannot read the repository where the answers are.
|
|
9
|
+
Afterwards an **operator** agent reports the problems it finds in what the
|
|
10
|
+
room did, each with a severity. A human decides what to do with the report.
|
|
11
|
+
|
|
12
|
+
> Status: early development, run end to end against a toy product
|
|
13
|
+
> (`examples/toy/run.py`). In place: rooms and their isolation, the audit,
|
|
14
|
+
> the stand-in, fixture pages, screens by name on macOS and Windows,
|
|
15
|
+
> Markdown scenarios, the operator agent and its MCP tools, and reports with
|
|
16
|
+
> severities. The first real product, Keyhac, is next. The design is
|
|
17
|
+
> [crftwr/keyhac#205](https://github.com/crftwr/keyhac/issues/205).
|
|
18
|
+
|
|
19
|
+
## What stays code, and why
|
|
20
|
+
|
|
21
|
+
Anything a run depends on to mean something is code, not something an agent
|
|
22
|
+
decides:
|
|
23
|
+
|
|
24
|
+
- **The prompt the room is handed** is copied verbatim. The operator knows
|
|
25
|
+
what a good answer looks like, so any paraphrase of the prompt can leak it.
|
|
26
|
+
- **Isolation.** The room runs with `--restricted`, an explicit permission
|
|
27
|
+
list under `dontAsk`, one MCP configuration and nothing else, git stopped at
|
|
28
|
+
the room, and stdin closed. A transcript audit then checks what no flag can
|
|
29
|
+
enforce.
|
|
30
|
+
- **The operator's machine.** Their files are moved aside and replaced with
|
|
31
|
+
fixtures. On the way out they are put back in a `finally`, whether the run
|
|
32
|
+
ended normally, by an exception, by Ctrl-C or by SIGTERM.
|
|
33
|
+
- **Evidence.** A report cites what tools recorded, not what the room said.
|
|
34
|
+
|
|
35
|
+
## Products using it
|
|
36
|
+
|
|
37
|
+
- [Keyhac](https://github.com/crftwr/keyhac)
|
|
38
|
+
- [XeFM](https://github.com/crftwr/xefm) (planned)
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
make test
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
MIT licensed.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quality-loop"
|
|
7
|
+
# version -> single-sourced from quality_loop/__init__.py's __version__.
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
description = "Measure what an agent-facing product teaches: isolated agent runs of real tasks, reported by an operator agent"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "craftware" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
25
|
+
"Topic :: Software Development :: Testing",
|
|
26
|
+
]
|
|
27
|
+
# None at runtime: the harness drives `claude` and the product's own tools as
|
|
28
|
+
# subprocesses, and everything it reads is JSON lines and Markdown.
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8", "pytest-timeout>=2"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/crftwr/quality-loop"
|
|
36
|
+
Repository = "https://github.com/crftwr/quality-loop"
|
|
37
|
+
Issues = "https://github.com/crftwr/quality-loop/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.dynamic]
|
|
40
|
+
version = { attr = "quality_loop.__version__" }
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
include = ["quality_loop*"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
# The operator agent's instructions, read from the installed package.
|
|
47
|
+
quality_loop = ["OPERATOR.md"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
timeout = 60
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# You are the operator of one room
|
|
2
|
+
|
|
3
|
+
A **room** is a separate Claude Code session that was given a task and nothing
|
|
4
|
+
but a product's agent-facing surface - a skill bundle and the product's own
|
|
5
|
+
tools. It was started so it cannot read the product's repository. What it
|
|
6
|
+
does with what it was given is the measurement. Your job is to make that
|
|
7
|
+
measurement possible and then report what it shows.
|
|
8
|
+
|
|
9
|
+
You work in two phases of one session. The message that starts each phase
|
|
10
|
+
says which one it is, and gives you the paths.
|
|
11
|
+
|
|
12
|
+
## Phase 1: put up the screen
|
|
13
|
+
|
|
14
|
+
1. Read the scenario file. Its **Screen** section says what has to be in
|
|
15
|
+
front before the room starts. It may say "none".
|
|
16
|
+
2. If a screen is needed, call `screen_list` to see what the product
|
|
17
|
+
declares, choose what the section asks for, and call `screen_open` for
|
|
18
|
+
each one. It returns once the window is up, or fails.
|
|
19
|
+
3. If the section asks for something no declared screen provides, do not
|
|
20
|
+
improvise. Write that into `setup.md` in your directory and stop: the
|
|
21
|
+
room cannot measure anything without its screen.
|
|
22
|
+
4. Write `setup.md` in your directory: what you opened, with the ids `open`
|
|
23
|
+
returned, and anything about the screen a reader of your report should
|
|
24
|
+
know.
|
|
25
|
+
|
|
26
|
+
You have no shell. Your tools are Read, Glob, Grep, writing in your own
|
|
27
|
+
directory and the room's REPORT.md, and the four tools above.
|
|
28
|
+
|
|
29
|
+
You may not change anything in the room. The runner checks every file in it
|
|
30
|
+
after this phase, and a changed byte cancels the room. In particular, the
|
|
31
|
+
room's TASK.md is copied from the scenario by the runner and must reach the
|
|
32
|
+
room exactly as written.
|
|
33
|
+
|
|
34
|
+
## Phase 2: report
|
|
35
|
+
|
|
36
|
+
The room has finished. In the room directory you have:
|
|
37
|
+
|
|
38
|
+
- `transcript.jsonl`: everything the room did, as stream-json. This is the
|
|
39
|
+
record. The room's own account of what it did is only a claim.
|
|
40
|
+
- `QUESTIONS.md`: what the room noticed it had to guess.
|
|
41
|
+
- `evidence/`: what the runner collected. That includes the product's files
|
|
42
|
+
before and after, anything the room created, the screen's event record, and
|
|
43
|
+
`audit.json`, the runner's isolation audit.
|
|
44
|
+
|
|
45
|
+
Then:
|
|
46
|
+
|
|
47
|
+
1. Read the scenario's **Must** and **Must not** sections. For each line,
|
|
48
|
+
establish from evidence whether the run met it. Use the product's evidence
|
|
49
|
+
tools where a line is about behaviour; the start-of-phase message lists
|
|
50
|
+
them. Quote what a tool answered. Do not infer it.
|
|
51
|
+
2. Read the transcript for **friction**, whether or not the room noticed it:
|
|
52
|
+
a tool call that failed or was refused, an answer that was an error or a
|
|
53
|
+
blank, a search of the bundle that came back empty, a file rewritten or a
|
|
54
|
+
run repeated, turns running out, sentences where the room says it is
|
|
55
|
+
unsure or was wrong. A problem the room never mentioned in QUESTIONS.md is
|
|
56
|
+
the most valuable kind.
|
|
57
|
+
3. Read **QUESTIONS.md**. Each question is a candidate problem.
|
|
58
|
+
4. Write **REPORT.md** in the room, in the format below. Then close every
|
|
59
|
+
screen you opened (`screen_close`).
|
|
60
|
+
|
|
61
|
+
**Friction is not a problem by itself.** A deploy's own "DEPLOY FAILED" on a
|
|
62
|
+
screen built to show a failed deploy is the task working. Report what a
|
|
63
|
+
product change would improve, and say why.
|
|
64
|
+
|
|
65
|
+
## REPORT.md
|
|
66
|
+
|
|
67
|
+
```markdown
|
|
68
|
+
# Report: <scenario title>
|
|
69
|
+
|
|
70
|
+
One paragraph: what the room was asked, what it did, and what it left.
|
|
71
|
+
|
|
72
|
+
### [high] <the problem, in one line>
|
|
73
|
+
- **Id:** `<kebab-case, stable>`
|
|
74
|
+
- **Kind:** skill gap
|
|
75
|
+
- **Must:** "<the Must line it concerns, or none>"
|
|
76
|
+
- **Evidence:** <tool output quoted, or transcript step and what it shows>
|
|
77
|
+
- **Where it would be fixed:** <file and section, in the product>
|
|
78
|
+
- **How a fix is confirmed:** <the evidence that would show it fixed>
|
|
79
|
+
|
|
80
|
+
<Free text: what happened, and why it matters.>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Severity** - pick the first that fits:
|
|
84
|
+
|
|
85
|
+
| | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| critical | The run did harm: data destroyed, the user's files rewritten or no longer loading, a destructive control pressed. |
|
|
88
|
+
| high | A Must not met, or a result that is silently wrong - one that looks right and is not. |
|
|
89
|
+
| medium | Met, but only by guessing something the product should have said, or after rework a user's agent would repeat. |
|
|
90
|
+
| low | Friction that cost little: an empty search, a retry, an unclear message. |
|
|
91
|
+
|
|
92
|
+
**Kind**: `skill gap` (the product's agent-facing text should have said it),
|
|
93
|
+
`product` (the product behaves wrongly), `harness` (the runner, or the room's
|
|
94
|
+
tools), `out of scope` (true of the task or the application, not of the
|
|
95
|
+
product).
|
|
96
|
+
|
|
97
|
+
**Id** names the problem, not the run: `fn-f1-not-bound`, not `room-3-issue`.
|
|
98
|
+
A later run reports the same problem under the same id, which is how a fix is
|
|
99
|
+
seen to have worked.
|
|
100
|
+
|
|
101
|
+
There is no verdict. Write problems, most severe first. If you found none,
|
|
102
|
+
say so in the opening paragraph and say what you checked. Zero problems is
|
|
103
|
+
rare, and the reader will want to know why you believe it.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""quality-loop: measure what an agent-facing product teaches.
|
|
2
|
+
|
|
3
|
+
A product that agents use - a skill bundle, an MCP server, a CLI - is only as
|
|
4
|
+
good as what an agent with nothing else can do with it. This package runs that
|
|
5
|
+
agent: a task, in a room holding the product's agent-facing surface and nothing
|
|
6
|
+
else, started so it cannot read the repository the answers are in. A second
|
|
7
|
+
agent, the operator, puts up whatever the task needs beforehand and afterwards
|
|
8
|
+
reports the problems it finds in what the room did, each with a severity.
|
|
9
|
+
|
|
10
|
+
What has to hold for a run to mean anything - the isolation, the prompt the
|
|
11
|
+
room is handed, putting the user's machine back - is code here, not an
|
|
12
|
+
agent's discretion.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The operator agent's command line: ``python -m quality_loop screen ...``.
|
|
2
|
+
|
|
3
|
+
python -m quality_loop screen list
|
|
4
|
+
python -m quality_loop screen open approval-queue
|
|
5
|
+
python -m quality_loop screen close approval-queue-1
|
|
6
|
+
|
|
7
|
+
Everything it takes is a name from the manifest the runner wrote. Answers are
|
|
8
|
+
JSON on stdout, errors one line on stderr with exit status 1.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from quality_loop import screens
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: list[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(prog="quality-loop")
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
screen = commands.add_parser("screen", help="put up or take down a declared screen")
|
|
24
|
+
actions = screen.add_subparsers(dest="action", required=True)
|
|
25
|
+
actions.add_parser("list", help="the screens the adapter declares")
|
|
26
|
+
opening = actions.add_parser("open", help="open one, and wait for its window")
|
|
27
|
+
opening.add_argument("name")
|
|
28
|
+
closing = actions.add_parser("close", help="close one that `open` recorded")
|
|
29
|
+
closing.add_argument("id")
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
if args.action == "list":
|
|
34
|
+
answer = {name: {"kind": e["kind"], "title": e["title"]}
|
|
35
|
+
for name, e in screens.names().items()}
|
|
36
|
+
elif args.action == "open":
|
|
37
|
+
answer = screens.open_screen(args.name)
|
|
38
|
+
else:
|
|
39
|
+
answer = screens.close_screen(args.id)
|
|
40
|
+
except screens.ScreenError as error:
|
|
41
|
+
print(f"error: {error}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
print(json.dumps(answer, indent=2))
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""What a product supplies: its surface, its rooms' limits, its evidence.
|
|
2
|
+
|
|
3
|
+
A product subclasses `Adapter` and fills in what is its own. Everything else -
|
|
4
|
+
building rooms, isolating the session, the stand-in, the screens, the
|
|
5
|
+
operator, the audit, the findings - is the runner's and is the same for
|
|
6
|
+
every product.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from quality_loop import audit, scenario, session, standin
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Adapter:
|
|
19
|
+
"""One product's half of a run.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: The product's name, used in the run directory and messages.
|
|
23
|
+
version: The product's version, which names the run directory.
|
|
24
|
+
checkout: The product's repository.
|
|
25
|
+
rooms: Where rooms are built. Inside the checkout is fine, because the
|
|
26
|
+
session is `--restricted`; anywhere else in the checkout is refused.
|
|
27
|
+
scenarios: The directory of scenario files.
|
|
28
|
+
skill_name: The bundle's directory name inside a room.
|
|
29
|
+
fixtures: The directory screens are served or copied from, or None.
|
|
30
|
+
screens: ``{name: Page | Terminal}`` - all the operator may put up.
|
|
31
|
+
evidence: ``{name: {"argv": [...], "help": "..."}}`` - commands that
|
|
32
|
+
read, which the operator runs through its MCP server with
|
|
33
|
+
arguments of its choosing and no shell.
|
|
34
|
+
evidence_help: One paragraph on what those tools answer.
|
|
35
|
+
python: The interpreter the operator's CLIs run under.
|
|
36
|
+
"""
|
|
37
|
+
name = "product"
|
|
38
|
+
version = "0"
|
|
39
|
+
checkout: pathlib.Path
|
|
40
|
+
rooms: pathlib.Path
|
|
41
|
+
scenarios: pathlib.Path
|
|
42
|
+
skill_name = "product"
|
|
43
|
+
fixtures: pathlib.Path | None = None
|
|
44
|
+
screens: dict = {}
|
|
45
|
+
evidence: dict = {}
|
|
46
|
+
evidence_help = ""
|
|
47
|
+
python = sys.executable
|
|
48
|
+
|
|
49
|
+
def bundle(self) -> pathlib.Path:
|
|
50
|
+
"""The surface under test - a zip or a directory - built for this run."""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def rules(self) -> str:
|
|
54
|
+
"""RULES.md: what the room may read and what it must write down."""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def session(self, room: pathlib.Path) -> session.Session:
|
|
58
|
+
"""How the room's session is started: its tools, permissions, servers."""
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
def policy(self, room: pathlib.Path) -> audit.Policy:
|
|
62
|
+
"""What the audit flags for this product."""
|
|
63
|
+
return audit.Policy(checkout=self.checkout)
|
|
64
|
+
|
|
65
|
+
def product(self):
|
|
66
|
+
"""A context manager for the length of a run: the product up, if it
|
|
67
|
+
has to be running for the room to use it."""
|
|
68
|
+
return contextlib.nullcontext()
|
|
69
|
+
|
|
70
|
+
def stand_in(self, case: scenario.Scenario, variant: str,
|
|
71
|
+
room: pathlib.Path) -> standin.StandIn | None:
|
|
72
|
+
"""The operator's files stood in for, for one room, or None."""
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def runs(self, cases: list[scenario.Scenario],
|
|
76
|
+
named: bool = False) -> list[tuple[scenario.Scenario, str]]:
|
|
77
|
+
"""``(scenario, variant)`` pairs: the same scenario on several fixture
|
|
78
|
+
configs is several rooms. The default is one room per scenario.
|
|
79
|
+
|
|
80
|
+
``named`` says the scenarios were picked by name rather than being
|
|
81
|
+
every one there is - a product can leave out of a run of everything
|
|
82
|
+
those that need a screen only the person running it can put up.
|
|
83
|
+
"""
|
|
84
|
+
return [(case, "") for case in cases]
|
|
85
|
+
|
|
86
|
+
def alive(self) -> bool:
|
|
87
|
+
"""Whether the product is still answering, asked on the heartbeat."""
|
|
88
|
+
return True
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Checking the transcript for the rules no flag can enforce.
|
|
2
|
+
|
|
3
|
+
RULES.md forbids the checkout, the installed package and other people's work,
|
|
4
|
+
and until there was a transcript the only evidence that a room obeyed was that
|
|
5
|
+
it said so. A rule you cannot check is a rule you are assuming.
|
|
6
|
+
|
|
7
|
+
What is scanned is where a tool was *pointed* - the path-bearing fields of its
|
|
8
|
+
input - not what it typed or what came back. A room that reads a path off the
|
|
9
|
+
screen and repeats it in its notes is doing its task, not reaching for the
|
|
10
|
+
checkout; and a window title is full of paths the room never asked for.
|
|
11
|
+
|
|
12
|
+
**A refusal is not a breach.** A call the confinement turned down read
|
|
13
|
+
nothing, so the measurement is intact. Refusals are reported - they are the
|
|
14
|
+
confinement working in public - and only a call that came back with something
|
|
15
|
+
disqualifies a run.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import re
|
|
24
|
+
from typing import Callable
|
|
25
|
+
|
|
26
|
+
from quality_loop import transcript as tr
|
|
27
|
+
|
|
28
|
+
#: Input fields that say where a tool was aimed.
|
|
29
|
+
AIMED_AT = ("file_path", "path", "notebook_path", "pattern", "command", "glob")
|
|
30
|
+
|
|
31
|
+
#: An absolute path in any spelling: /Users/..., C:\Users\... or C:/..., and
|
|
32
|
+
#: \Users\... - rooted on the current drive, which Windows accepts too.
|
|
33
|
+
ABSOLUTE = re.compile(r"(?:[A-Za-z]:[\\/]|[\\/])[^\s\"'`;|&)]+")
|
|
34
|
+
|
|
35
|
+
#: A shell command that runs git: at the start, or after a separator, by any
|
|
36
|
+
#: path, with or without ".exe".
|
|
37
|
+
RUNS_GIT = re.compile(r"(?:^|[;&|(`]|\$\()\s*(?:\S*/)?git(?:\.exe)?\b")
|
|
38
|
+
|
|
39
|
+
#: What git answers once GIT_CEILING_DIRECTORIES has stopped it at the room.
|
|
40
|
+
NOT_A_REPOSITORY = "not a git repository"
|
|
41
|
+
|
|
42
|
+
#: What room.CANARY asks a contaminated session to say.
|
|
43
|
+
CANARY_PHRASE = "without --restricted"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def norm(path: str) -> str:
|
|
47
|
+
"""A path in one spelling: forward slashes, and lowercase on Windows,
|
|
48
|
+
where C:\\Users and c:/users are the same place."""
|
|
49
|
+
path = path.replace("\\", "/")
|
|
50
|
+
return path.lower() if os.name == "nt" else path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def paths_in(tool_input: dict) -> list[str]:
|
|
54
|
+
"""The absolute paths a tool call was pointed at."""
|
|
55
|
+
aimed = []
|
|
56
|
+
for key in AIMED_AT:
|
|
57
|
+
value = tool_input.get(key)
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
value = value.replace("~/", str(pathlib.Path.home()) + "/")
|
|
60
|
+
aimed += ABSOLUTE.findall(value)
|
|
61
|
+
return aimed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass
|
|
65
|
+
class Policy:
|
|
66
|
+
"""What a product's rooms must not reach, and what their sessions must have.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
checkout: The product's repository. A path under it and outside the
|
|
70
|
+
room is a breach - not a mention of it, which every honest read of
|
|
71
|
+
the bundle inside the room also is.
|
|
72
|
+
forbidden: ``{substring: what it is}`` for paths that are a breach
|
|
73
|
+
anywhere: the installed package, the installed app, the operator's
|
|
74
|
+
files set aside.
|
|
75
|
+
foreign: Basenames of other authors' work in a directory the room may
|
|
76
|
+
write to - read one, and the room has learned from somebody who
|
|
77
|
+
had the repository.
|
|
78
|
+
bash: Whether the session has Bash on purpose. When it does not, Bash
|
|
79
|
+
in the init event means `--restricted` did not take effect.
|
|
80
|
+
servers: MCP servers that must be connected; one that is not means
|
|
81
|
+
the room reported on the outage, not the product.
|
|
82
|
+
leaked_skill: Says whether a slash command in the init event is one of
|
|
83
|
+
the checkout's own skills - the scoring key among them.
|
|
84
|
+
"""
|
|
85
|
+
checkout: pathlib.Path
|
|
86
|
+
forbidden: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
87
|
+
foreign: set[str] = dataclasses.field(default_factory=set)
|
|
88
|
+
bash: bool = False
|
|
89
|
+
servers: tuple[str, ...] = ()
|
|
90
|
+
leaked_skill: Callable[[str], bool] | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def isolation_held(transcript: pathlib.Path, policy: Policy) -> list[str]:
|
|
94
|
+
"""What the session's init event says went wrong with the flags."""
|
|
95
|
+
init = tr.init_event(transcript)
|
|
96
|
+
if init is None:
|
|
97
|
+
return ["the session produced no init event"]
|
|
98
|
+
found = []
|
|
99
|
+
if not policy.bash and "Bash" in init.get("tools", []):
|
|
100
|
+
found.append("the session had Bash: it was not started with --restricted, "
|
|
101
|
+
"so the checkout's CLAUDE.md was in context from the first token")
|
|
102
|
+
if policy.leaked_skill:
|
|
103
|
+
if leaked := [c for c in init.get("slash_commands", [])
|
|
104
|
+
if policy.leaked_skill(str(c))]:
|
|
105
|
+
found.append(f"the checkout's own skills were visible to the room: {leaked}")
|
|
106
|
+
servers = {s.get("name"): s.get("status") for s in init.get("mcp_servers", [])
|
|
107
|
+
if isinstance(s, dict)}
|
|
108
|
+
for name in policy.servers:
|
|
109
|
+
if servers.get(name) != "connected":
|
|
110
|
+
found.append(f"MCP server {name} was {servers.get(name, 'absent')}, so what "
|
|
111
|
+
f"the room reported is about the server, not the product")
|
|
112
|
+
if CANARY_PHRASE in tr.final_reply(transcript).replace("`", ""):
|
|
113
|
+
found.append("the room read its CLAUDE.md canary unasked: the session was "
|
|
114
|
+
"started without --restricted")
|
|
115
|
+
return found
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def audit(room: pathlib.Path, policy: Policy) -> tuple[list[str], list[str]]:
|
|
119
|
+
"""``(breaches, refused)`` from the room's transcript."""
|
|
120
|
+
room = pathlib.Path(room).resolve()
|
|
121
|
+
transcript = room / "transcript.jsonl"
|
|
122
|
+
if not transcript.exists():
|
|
123
|
+
return ["no transcript - the room was never driven"], []
|
|
124
|
+
breaches = isolation_held(transcript, policy)
|
|
125
|
+
refused: list[str] = []
|
|
126
|
+
checkout, inside = norm(str(policy.checkout.resolve())), norm(str(room))
|
|
127
|
+
forbidden = {norm(path): what for path, what in policy.forbidden.items()}
|
|
128
|
+
for call in tr.tool_calls(transcript):
|
|
129
|
+
landed = refused if call.is_error else breaches
|
|
130
|
+
command = call.input.get("command")
|
|
131
|
+
if isinstance(command, str) and RUNS_GIT.search(command):
|
|
132
|
+
# Refused when git found no repository - the ceiling held.
|
|
133
|
+
(refused if call.is_error or NOT_A_REPOSITORY in call.result
|
|
134
|
+
else breaches).append(f"{call.name} ran git: {command[:80]}")
|
|
135
|
+
for aimed in paths_in(call.input):
|
|
136
|
+
spelled = norm(aimed)
|
|
137
|
+
for path, what in forbidden.items():
|
|
138
|
+
if path in spelled:
|
|
139
|
+
landed.append(f"{call.name} reached into {what}: {aimed}")
|
|
140
|
+
if spelled.startswith(checkout) and not spelled.startswith(inside):
|
|
141
|
+
landed.append(f"{call.name} reached into the checkout: {aimed}")
|
|
142
|
+
if pathlib.PurePath(aimed.replace("\\", "/")).name in policy.foreign:
|
|
143
|
+
landed.append(f"{call.name} read another author's work: {aimed}")
|
|
144
|
+
return sorted(set(breaches)), sorted(set(refused))
|