edward-guard 0.1.1__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.
- edward_guard-0.1.1/LICENSE +21 -0
- edward_guard-0.1.1/PKG-INFO +243 -0
- edward_guard-0.1.1/README.md +226 -0
- edward_guard-0.1.1/edward/__init__.py +8 -0
- edward_guard-0.1.1/edward/audit.py +112 -0
- edward_guard-0.1.1/edward/canonical_events.py +153 -0
- edward_guard-0.1.1/edward/cli.py +632 -0
- edward_guard-0.1.1/edward/config.py +220 -0
- edward_guard-0.1.1/edward/demo.py +57 -0
- edward_guard-0.1.1/edward/engine.py +150 -0
- edward_guard-0.1.1/edward/evalcmd.py +59 -0
- edward_guard-0.1.1/edward/kernel.py +65 -0
- edward_guard-0.1.1/edward/notify.py +34 -0
- edward_guard-0.1.1/edward/pi_client.py +144 -0
- edward_guard-0.1.1/edward/scenarios.py +221 -0
- edward_guard-0.1.1/edward/scorer.py +47 -0
- edward_guard-0.1.1/edward/scorer_client.py +96 -0
- edward_guard-0.1.1/edward/state_engine.py +214 -0
- edward_guard-0.1.1/edward/stepshield.py +409 -0
- edward_guard-0.1.1/edward/triggers.py +95 -0
- edward_guard-0.1.1/edward_guard.egg-info/PKG-INFO +243 -0
- edward_guard-0.1.1/edward_guard.egg-info/SOURCES.txt +25 -0
- edward_guard-0.1.1/edward_guard.egg-info/dependency_links.txt +1 -0
- edward_guard-0.1.1/edward_guard.egg-info/entry_points.txt +2 -0
- edward_guard-0.1.1/edward_guard.egg-info/top_level.txt +1 -0
- edward_guard-0.1.1/pyproject.toml +26 -0
- edward_guard-0.1.1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edward contributors
|
|
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,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edward-guard
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: External control plane for AI coding agents: deterministic rules + local semantic scorer, audit trail, human-resumable interventions.
|
|
5
|
+
Author: Edward contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,agent,guardrails,safety,observability,cost-control
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Edward
|
|
19
|
+
|
|
20
|
+
**Edward is an external control plane for AI coding agents.** Agents fail quietly: they retry the same broken test 40 times, burn $8 in tokens on a loop, run `rm -rf` on a database directory, and write to files they were never supposed to touch. The agent doesn't know it's failing — from its perspective, it's still trying.
|
|
21
|
+
|
|
22
|
+
Edward sits between the agent and its runtime. It watches the event stream, builds a picture of what the agent is actually doing across turns, and intervenes when the picture stops looking right.
|
|
23
|
+
|
|
24
|
+
[](../../actions)
|
|
25
|
+
[](https://pypi.org/project/edward-guard/)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
[](pyproject.toml)
|
|
28
|
+
|
|
29
|
+
**Measured on the StepShield benchmark (NeurIPS 2026):** deterministic rules alone detect 7.4% of content-semantic violations at 1.9% FPR; adding a local 4B scorer with evidence-grounded task-contract probes reaches **57.4% recall at 20.4% FPR with EIR_3 0.790** (paper's LLMJudge tier: 95.4% / 5.6% / 0.89, at GPT-4.1-mini cost). See [BENCHMARK.md](BENCHMARK.md).
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install edward-guard # zero dependencies, stdlib only
|
|
33
|
+
edward demo # self-running proof: 6 failure scenarios
|
|
34
|
+
edward wrap -- pi "fix the flaky test"
|
|
35
|
+
edward wrap --no-scorer -- python my_agent.py # any command, rule-only
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Agent (Pi / Codex / custom)
|
|
41
|
+
│ events
|
|
42
|
+
▼
|
|
43
|
+
Canonical Event Schema ← normalizes tool names to capabilities
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
State Engine ← materializes cross-turn agent state
|
|
47
|
+
│
|
|
48
|
+
▼
|
|
49
|
+
Trigger Rules [FROZEN] ← deterministic safety + convergence checks
|
|
50
|
+
│
|
|
51
|
+
├─ HARD_CONSTRAINT ──→ Kernel: BLOCK (Jev cannot override)
|
|
52
|
+
│
|
|
53
|
+
└─ SOFT_DECISION ──→ Jev (TypeSafe) ──→ Policy Resolver
|
|
54
|
+
│ │
|
|
55
|
+
└────────────────┘
|
|
56
|
+
│
|
|
57
|
+
▼
|
|
58
|
+
Control Kernel
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
PAUSE / CANCEL / RESUME
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Why not just if/else?
|
|
65
|
+
|
|
66
|
+
A simple watchdog (`error_count > 5 → stop`) looks at individual events. The failure modes that actually kill long-running agents don't show up in any single event. They emerge from the shape of the trajectory over time.
|
|
67
|
+
|
|
68
|
+
We tested this directly.
|
|
69
|
+
|
|
70
|
+
### Watchdog vs State Engine → MSS
|
|
71
|
+
|
|
72
|
+
Held-out evaluation, 20 trials × 6 scenarios, frozen config:
|
|
73
|
+
|
|
74
|
+
| Scenario | Watchdog detection | State Engine detection | Watchdog ctx | State ctx |
|
|
75
|
+
|---|---|---|---|---|
|
|
76
|
+
| budget bleed | 20% | **100%** | 2,322 | 409 |
|
|
77
|
+
| infinite loop | 0% | **100%** | 6,084 | 409 |
|
|
78
|
+
| convergence stall | 0% | **100%** | 1,442 | 409 |
|
|
79
|
+
| dangerous command | 75% | **100%** | 522 | 434 |
|
|
80
|
+
|
|
81
|
+
The watchdog misses infinite loops and stalls entirely because it has no memory across turns. The State Engine maintains a sliding window of recent tool calls, tracks file modifications, and computes elapsed time — none of which fit in a single-event check.
|
|
82
|
+
|
|
83
|
+
## What it detects
|
|
84
|
+
|
|
85
|
+
Six trigger rules, tuned on a development set (seed 42) and evaluated frozen on held-out data (seed 137):
|
|
86
|
+
|
|
87
|
+
| Signal | Threshold | Fires when |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| Error rate | > 40% over last 8+ calls | Agent is failing repeatedly, with no recovery signal |
|
|
90
|
+
| Retry count | ≥ 3 | Agent keeps retrying the same thing |
|
|
91
|
+
| Token budget | > 80% consumed | Agent is burning resources |
|
|
92
|
+
| Convergence stall | > 600s + > 5 turns | Agent is running but not completing |
|
|
93
|
+
| Passive stall | 12 consecutive reads, 0 writes | Agent is going in circles |
|
|
94
|
+
| Dangerous command | regex match | Agent executes `rm -rf`, `sudo`, `git push --force`, etc. |
|
|
95
|
+
|
|
96
|
+
Plus two post-hoc additions from robustness testing:
|
|
97
|
+
|
|
98
|
+
| Signal | Fires when |
|
|
99
|
+
|---|---|
|
|
100
|
+
| Scope violation | Agent writes to a path outside the allowed prefix list |
|
|
101
|
+
| Silent corruption risk | ≥ 10 consecutive file writes with zero shell verification |
|
|
102
|
+
|
|
103
|
+
## Held-out benchmark
|
|
104
|
+
|
|
105
|
+
50 trials × 6 scenarios, frozen configuration (no post-hoc tuning):
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
Detection FP Rate Latency (tool calls)
|
|
109
|
+
infinite_loop 100% — 8.0
|
|
110
|
+
budget_bleed 100% — 11.7
|
|
111
|
+
dangerous 100% — 5.0
|
|
112
|
+
stall 100% — 4.0
|
|
113
|
+
normal — 0% —
|
|
114
|
+
transient_failure — 0% —
|
|
115
|
+
|
|
116
|
+
Precision: 100% Recall: 100% FPR: 0/100
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Robustness: where it breaks
|
|
120
|
+
|
|
121
|
+
We attacked the architecture along four dimensions. Results after P0+P1 fixes:
|
|
122
|
+
|
|
123
|
+
**Agent heterogeneity.** Three event schemas (Pi, Codex-style, custom) normalized through the canonical schema layer. Custom agents use tool names like `execute` and `terminal` instead of `bash`. Before the fix, dangerous command detection on custom agents was 0%. After: 100% across all six combinations.
|
|
124
|
+
|
|
125
|
+
**Task distribution shift.** Three unseen normal task profiles (heavy-write, error-then-recover, mixed-signal). The error-then-recover profile initially produced 60% false positives because the error-rate trigger couldn't distinguish "temporarily failing but recovering" from "systematically diverging." Added a `recovery_signal` property: if the tail of the sliding window shows ≥3 consecutive successes after errors, the trigger holds. Post-fix FPR: 0% on all three profiles.
|
|
126
|
+
|
|
127
|
+
**Controller ablation.** Jev (TypeSafe's System One model) acts as a probabilistic second opinion on soft decisions. On dangerous commands, rules fire deterministically as HARD_CONSTRAINT — Jev cannot override. On soft decisions, Jev confirms ~100% of infinite-loop interventions but overrides ~97% of budget-bleed and ~100% of stall interventions back to CONTINUE. This tension is a design feature: the policy resolver lets deterministic safety guards override probabilistic judgments, but not vice versa.
|
|
128
|
+
|
|
129
|
+
**Unseen failure modes.** Three novel failure scenarios not in dev or test:
|
|
130
|
+
|
|
131
|
+
| Scenario | Detection | Mechanism |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| Permission escalation | 100% | Dangerous command regex |
|
|
134
|
+
| Cascading failure | 100% | Retry count trigger |
|
|
135
|
+
| Silent corruption | 76.7% | Unverified-write streak detector |
|
|
136
|
+
|
|
137
|
+
Silent corruption — the agent writes 15 files in a row without running any test — was invisible to every original trigger. The streak detector catches most cases but not all (some trials have only 8-9 writes, below the threshold). Full coverage would require World State tracking: comparing what the agent wrote against what the task expected.
|
|
138
|
+
|
|
139
|
+
## The four things we learned
|
|
140
|
+
|
|
141
|
+
1. **Execution telemetry ≠ world state.** The agent's own view ("I'm still trying") diverges from what an external observer sees (error rate rising, budget depleting, no progress). This gap is the entire reason a control plane exists.
|
|
142
|
+
|
|
143
|
+
2. **Event-level checks cannot detect temporal failure modes.** Every trigger that requires memory across turns (stall, convergence, recovery) needs the State Engine. Single-event or simple-counter approaches score 0% on these.
|
|
144
|
+
|
|
145
|
+
3. **Safety and intelligence need different authority levels.** Dangerous-command blocking is deterministic and non-overridable. Everything else benefits from a probabilistic second opinion. Conflating the two makes the safety guard probabilistic, which defeats the purpose.
|
|
146
|
+
|
|
147
|
+
4. **Agent-execution monitoring alone cannot detect world-state corruption.** If the agent writes destructive content without producing any error, no amount of execution telemetry will catch it. This requires comparing actual filesystem state against a task contract. It's the clearest motivation for v0.2.
|
|
148
|
+
|
|
149
|
+
## Running it
|
|
150
|
+
|
|
151
|
+
### Product quickstart (edward CLI)
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pip install -e . # zero dependencies, stdlib only (Python >= 3.11)
|
|
155
|
+
|
|
156
|
+
# optional: the pi coding agent (full monitoring + intervention on pi tasks)
|
|
157
|
+
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
|
158
|
+
|
|
159
|
+
edward doctor # environment checks: scorer, pi, audit dir
|
|
160
|
+
edward demo # self-running proof: 6 scenarios, PASS/FAIL gate
|
|
161
|
+
edward wrap -- pi "Fix the bug in utils.py so that the test passes"
|
|
162
|
+
edward wrap --scope ./src --no-scorer -- python agent_script.py # any command, rule-only
|
|
163
|
+
edward eval --policy conservative --trials 30 # tune a policy before live use
|
|
164
|
+
edward audit # intervention summary ($ saved evidence)
|
|
165
|
+
edward policy-template --preset balanced > edward.toml
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The semantic scorer is an internal HTTP endpoint (LAN only, no auth):
|
|
169
|
+
`GET /health` + `POST /v1/score` on Qwen/Qwen3.5-4B — see `edward/scorer_client.py`.
|
|
170
|
+
Without it, edward runs in rule-only mode and stays fully protective.
|
|
171
|
+
|
|
172
|
+
Exit codes: `0` completed, `75` PAUSED (resumable: `edward wrap --continue -- ...`),
|
|
173
|
+
`76` terminated by control plane, `130` interrupted. Audit JSONL lands in
|
|
174
|
+
`~/.edward/audit.jsonl`. Policy packs are TOML/JSON with three presets
|
|
175
|
+
(`conservative` / `balanced` = FROZEN defaults / `aggressive`).
|
|
176
|
+
|
|
177
|
+
### Research scripts (pre-packaging, still work)
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python main.py "Fix the bug in utils.py so that the test passes"
|
|
181
|
+
|
|
182
|
+
# Run the benchmark
|
|
183
|
+
python benchmark.py
|
|
184
|
+
|
|
185
|
+
# Run the ablation
|
|
186
|
+
python ablation.py
|
|
187
|
+
|
|
188
|
+
# Run held-out evaluation
|
|
189
|
+
python heldout_eval.py
|
|
190
|
+
|
|
191
|
+
# Run robustness evaluation
|
|
192
|
+
python robustness_eval.py
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### External benchmark: StepShield (NeurIPS 2026)
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
git clone --depth 1 https://github.com/glo26/stepshield /tmp/stepshield
|
|
199
|
+
edward eval --suite stepshield --data /tmp/stepshield/data --mode rules
|
|
200
|
+
edward eval --suite stepshield --data /tmp/stepshield/data --mode contract \
|
|
201
|
+
--scorer http://192.168.2.51:8000 # needs live scorer endpoint
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`rules` = the deterministic v0.1 layer (dangerous-command regex + scope
|
|
205
|
+
violations derived from task constraints). `contract` = the v0.2 Task
|
|
206
|
+
Contract preview: task intent + constraints go into the scorer question,
|
|
207
|
+
and the 4B endpoint judges each state-changing step (OK / VIOLATION /
|
|
208
|
+
UNSURE) with asymmetric confirmation: conf >= 0.9 fires alone, 0.6-0.9
|
|
209
|
+
needs one corroborating violation, a confident OK clears suspicion.
|
|
210
|
+
Metrics are paper-aligned (EIR_k, recall, FPR on clean, per-category).
|
|
211
|
+
|
|
212
|
+
Pi uses `--mode rpc` for headless operation. The control plane spawns it as a subprocess, reads JSONL events from stdout, and sends control commands (abort, steer) via stdin. No Pi source code is modified.
|
|
213
|
+
|
|
214
|
+
## Project structure
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
edward/ Product package (pip install -e .)
|
|
218
|
+
cli.py edward CLI: wrap / demo / eval / audit / doctor
|
|
219
|
+
engine.py ControlPlane: events -> triggers -> scorer -> decision -> audit
|
|
220
|
+
config.py Policy packs (TOML/JSON, 3 presets, strict validation)
|
|
221
|
+
audit.py Append-only JSONL audit log (never blocks monitoring)
|
|
222
|
+
scorer.py Semantic scorer client with circuit breaker
|
|
223
|
+
scenarios.py Failure scenario suite (single source for demo/eval)
|
|
224
|
+
evalcmd.py Policy evaluation gate (detection / FPR / timing)
|
|
225
|
+
pi_client.py Pi RPC client (cwd, provider/model, stderr capture)
|
|
226
|
+
scorer_client.py /v1/score client (internal endpoint, Qwen3.5-4B)
|
|
227
|
+
state_engine.py Materialize AgentState from event stream
|
|
228
|
+
triggers.py 8 trigger rules, policy-parameterized (defaults FROZEN)
|
|
229
|
+
kernel.py Decision authority hierarchy
|
|
230
|
+
canonical_events.py Normalize Pi/Codex/custom events to canonical schema
|
|
231
|
+
notify.py Slack webhook + stderr banners (fail-silent)
|
|
232
|
+
main.py Legacy entry -> edward wrap
|
|
233
|
+
benchmark.py 300-trial held-out benchmark (re-exports scenarios)
|
|
234
|
+
ablation.py Watchdog vs State Engine comparison
|
|
235
|
+
heldout_eval.py Dev/test split + frozen config evaluation
|
|
236
|
+
robustness_eval.py 4-dimension robustness attack
|
|
237
|
+
extreme_tests.py 4 extreme scenario demos
|
|
238
|
+
test_product.py Product test suite (unittest, offline)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## What's next
|
|
242
|
+
|
|
243
|
+
The silent-corruption gap points at the next layer: a Task Contract that defines which files the agent should touch and what the expected end-state looks like. When the agent's actual filesystem mutations diverge from the contract, that's a scope violation — regardless of whether any tool call returned an error. This moves the State Engine from "what is the agent doing" to "what is the agent doing to the world, and is that still allowed."
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Edward
|
|
2
|
+
|
|
3
|
+
**Edward is an external control plane for AI coding agents.** Agents fail quietly: they retry the same broken test 40 times, burn $8 in tokens on a loop, run `rm -rf` on a database directory, and write to files they were never supposed to touch. The agent doesn't know it's failing — from its perspective, it's still trying.
|
|
4
|
+
|
|
5
|
+
Edward sits between the agent and its runtime. It watches the event stream, builds a picture of what the agent is actually doing across turns, and intervenes when the picture stops looking right.
|
|
6
|
+
|
|
7
|
+
[](../../actions)
|
|
8
|
+
[](https://pypi.org/project/edward-guard/)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](pyproject.toml)
|
|
11
|
+
|
|
12
|
+
**Measured on the StepShield benchmark (NeurIPS 2026):** deterministic rules alone detect 7.4% of content-semantic violations at 1.9% FPR; adding a local 4B scorer with evidence-grounded task-contract probes reaches **57.4% recall at 20.4% FPR with EIR_3 0.790** (paper's LLMJudge tier: 95.4% / 5.6% / 0.89, at GPT-4.1-mini cost). See [BENCHMARK.md](BENCHMARK.md).
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pipx install edward-guard # zero dependencies, stdlib only
|
|
16
|
+
edward demo # self-running proof: 6 failure scenarios
|
|
17
|
+
edward wrap -- pi "fix the flaky test"
|
|
18
|
+
edward wrap --no-scorer -- python my_agent.py # any command, rule-only
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Agent (Pi / Codex / custom)
|
|
24
|
+
│ events
|
|
25
|
+
▼
|
|
26
|
+
Canonical Event Schema ← normalizes tool names to capabilities
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
State Engine ← materializes cross-turn agent state
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
Trigger Rules [FROZEN] ← deterministic safety + convergence checks
|
|
33
|
+
│
|
|
34
|
+
├─ HARD_CONSTRAINT ──→ Kernel: BLOCK (Jev cannot override)
|
|
35
|
+
│
|
|
36
|
+
└─ SOFT_DECISION ──→ Jev (TypeSafe) ──→ Policy Resolver
|
|
37
|
+
│ │
|
|
38
|
+
└────────────────┘
|
|
39
|
+
│
|
|
40
|
+
▼
|
|
41
|
+
Control Kernel
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
PAUSE / CANCEL / RESUME
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Why not just if/else?
|
|
48
|
+
|
|
49
|
+
A simple watchdog (`error_count > 5 → stop`) looks at individual events. The failure modes that actually kill long-running agents don't show up in any single event. They emerge from the shape of the trajectory over time.
|
|
50
|
+
|
|
51
|
+
We tested this directly.
|
|
52
|
+
|
|
53
|
+
### Watchdog vs State Engine → MSS
|
|
54
|
+
|
|
55
|
+
Held-out evaluation, 20 trials × 6 scenarios, frozen config:
|
|
56
|
+
|
|
57
|
+
| Scenario | Watchdog detection | State Engine detection | Watchdog ctx | State ctx |
|
|
58
|
+
|---|---|---|---|---|
|
|
59
|
+
| budget bleed | 20% | **100%** | 2,322 | 409 |
|
|
60
|
+
| infinite loop | 0% | **100%** | 6,084 | 409 |
|
|
61
|
+
| convergence stall | 0% | **100%** | 1,442 | 409 |
|
|
62
|
+
| dangerous command | 75% | **100%** | 522 | 434 |
|
|
63
|
+
|
|
64
|
+
The watchdog misses infinite loops and stalls entirely because it has no memory across turns. The State Engine maintains a sliding window of recent tool calls, tracks file modifications, and computes elapsed time — none of which fit in a single-event check.
|
|
65
|
+
|
|
66
|
+
## What it detects
|
|
67
|
+
|
|
68
|
+
Six trigger rules, tuned on a development set (seed 42) and evaluated frozen on held-out data (seed 137):
|
|
69
|
+
|
|
70
|
+
| Signal | Threshold | Fires when |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Error rate | > 40% over last 8+ calls | Agent is failing repeatedly, with no recovery signal |
|
|
73
|
+
| Retry count | ≥ 3 | Agent keeps retrying the same thing |
|
|
74
|
+
| Token budget | > 80% consumed | Agent is burning resources |
|
|
75
|
+
| Convergence stall | > 600s + > 5 turns | Agent is running but not completing |
|
|
76
|
+
| Passive stall | 12 consecutive reads, 0 writes | Agent is going in circles |
|
|
77
|
+
| Dangerous command | regex match | Agent executes `rm -rf`, `sudo`, `git push --force`, etc. |
|
|
78
|
+
|
|
79
|
+
Plus two post-hoc additions from robustness testing:
|
|
80
|
+
|
|
81
|
+
| Signal | Fires when |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Scope violation | Agent writes to a path outside the allowed prefix list |
|
|
84
|
+
| Silent corruption risk | ≥ 10 consecutive file writes with zero shell verification |
|
|
85
|
+
|
|
86
|
+
## Held-out benchmark
|
|
87
|
+
|
|
88
|
+
50 trials × 6 scenarios, frozen configuration (no post-hoc tuning):
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
Detection FP Rate Latency (tool calls)
|
|
92
|
+
infinite_loop 100% — 8.0
|
|
93
|
+
budget_bleed 100% — 11.7
|
|
94
|
+
dangerous 100% — 5.0
|
|
95
|
+
stall 100% — 4.0
|
|
96
|
+
normal — 0% —
|
|
97
|
+
transient_failure — 0% —
|
|
98
|
+
|
|
99
|
+
Precision: 100% Recall: 100% FPR: 0/100
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Robustness: where it breaks
|
|
103
|
+
|
|
104
|
+
We attacked the architecture along four dimensions. Results after P0+P1 fixes:
|
|
105
|
+
|
|
106
|
+
**Agent heterogeneity.** Three event schemas (Pi, Codex-style, custom) normalized through the canonical schema layer. Custom agents use tool names like `execute` and `terminal` instead of `bash`. Before the fix, dangerous command detection on custom agents was 0%. After: 100% across all six combinations.
|
|
107
|
+
|
|
108
|
+
**Task distribution shift.** Three unseen normal task profiles (heavy-write, error-then-recover, mixed-signal). The error-then-recover profile initially produced 60% false positives because the error-rate trigger couldn't distinguish "temporarily failing but recovering" from "systematically diverging." Added a `recovery_signal` property: if the tail of the sliding window shows ≥3 consecutive successes after errors, the trigger holds. Post-fix FPR: 0% on all three profiles.
|
|
109
|
+
|
|
110
|
+
**Controller ablation.** Jev (TypeSafe's System One model) acts as a probabilistic second opinion on soft decisions. On dangerous commands, rules fire deterministically as HARD_CONSTRAINT — Jev cannot override. On soft decisions, Jev confirms ~100% of infinite-loop interventions but overrides ~97% of budget-bleed and ~100% of stall interventions back to CONTINUE. This tension is a design feature: the policy resolver lets deterministic safety guards override probabilistic judgments, but not vice versa.
|
|
111
|
+
|
|
112
|
+
**Unseen failure modes.** Three novel failure scenarios not in dev or test:
|
|
113
|
+
|
|
114
|
+
| Scenario | Detection | Mechanism |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| Permission escalation | 100% | Dangerous command regex |
|
|
117
|
+
| Cascading failure | 100% | Retry count trigger |
|
|
118
|
+
| Silent corruption | 76.7% | Unverified-write streak detector |
|
|
119
|
+
|
|
120
|
+
Silent corruption — the agent writes 15 files in a row without running any test — was invisible to every original trigger. The streak detector catches most cases but not all (some trials have only 8-9 writes, below the threshold). Full coverage would require World State tracking: comparing what the agent wrote against what the task expected.
|
|
121
|
+
|
|
122
|
+
## The four things we learned
|
|
123
|
+
|
|
124
|
+
1. **Execution telemetry ≠ world state.** The agent's own view ("I'm still trying") diverges from what an external observer sees (error rate rising, budget depleting, no progress). This gap is the entire reason a control plane exists.
|
|
125
|
+
|
|
126
|
+
2. **Event-level checks cannot detect temporal failure modes.** Every trigger that requires memory across turns (stall, convergence, recovery) needs the State Engine. Single-event or simple-counter approaches score 0% on these.
|
|
127
|
+
|
|
128
|
+
3. **Safety and intelligence need different authority levels.** Dangerous-command blocking is deterministic and non-overridable. Everything else benefits from a probabilistic second opinion. Conflating the two makes the safety guard probabilistic, which defeats the purpose.
|
|
129
|
+
|
|
130
|
+
4. **Agent-execution monitoring alone cannot detect world-state corruption.** If the agent writes destructive content without producing any error, no amount of execution telemetry will catch it. This requires comparing actual filesystem state against a task contract. It's the clearest motivation for v0.2.
|
|
131
|
+
|
|
132
|
+
## Running it
|
|
133
|
+
|
|
134
|
+
### Product quickstart (edward CLI)
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install -e . # zero dependencies, stdlib only (Python >= 3.11)
|
|
138
|
+
|
|
139
|
+
# optional: the pi coding agent (full monitoring + intervention on pi tasks)
|
|
140
|
+
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
|
141
|
+
|
|
142
|
+
edward doctor # environment checks: scorer, pi, audit dir
|
|
143
|
+
edward demo # self-running proof: 6 scenarios, PASS/FAIL gate
|
|
144
|
+
edward wrap -- pi "Fix the bug in utils.py so that the test passes"
|
|
145
|
+
edward wrap --scope ./src --no-scorer -- python agent_script.py # any command, rule-only
|
|
146
|
+
edward eval --policy conservative --trials 30 # tune a policy before live use
|
|
147
|
+
edward audit # intervention summary ($ saved evidence)
|
|
148
|
+
edward policy-template --preset balanced > edward.toml
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The semantic scorer is an internal HTTP endpoint (LAN only, no auth):
|
|
152
|
+
`GET /health` + `POST /v1/score` on Qwen/Qwen3.5-4B — see `edward/scorer_client.py`.
|
|
153
|
+
Without it, edward runs in rule-only mode and stays fully protective.
|
|
154
|
+
|
|
155
|
+
Exit codes: `0` completed, `75` PAUSED (resumable: `edward wrap --continue -- ...`),
|
|
156
|
+
`76` terminated by control plane, `130` interrupted. Audit JSONL lands in
|
|
157
|
+
`~/.edward/audit.jsonl`. Policy packs are TOML/JSON with three presets
|
|
158
|
+
(`conservative` / `balanced` = FROZEN defaults / `aggressive`).
|
|
159
|
+
|
|
160
|
+
### Research scripts (pre-packaging, still work)
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
python main.py "Fix the bug in utils.py so that the test passes"
|
|
164
|
+
|
|
165
|
+
# Run the benchmark
|
|
166
|
+
python benchmark.py
|
|
167
|
+
|
|
168
|
+
# Run the ablation
|
|
169
|
+
python ablation.py
|
|
170
|
+
|
|
171
|
+
# Run held-out evaluation
|
|
172
|
+
python heldout_eval.py
|
|
173
|
+
|
|
174
|
+
# Run robustness evaluation
|
|
175
|
+
python robustness_eval.py
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### External benchmark: StepShield (NeurIPS 2026)
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
git clone --depth 1 https://github.com/glo26/stepshield /tmp/stepshield
|
|
182
|
+
edward eval --suite stepshield --data /tmp/stepshield/data --mode rules
|
|
183
|
+
edward eval --suite stepshield --data /tmp/stepshield/data --mode contract \
|
|
184
|
+
--scorer http://192.168.2.51:8000 # needs live scorer endpoint
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`rules` = the deterministic v0.1 layer (dangerous-command regex + scope
|
|
188
|
+
violations derived from task constraints). `contract` = the v0.2 Task
|
|
189
|
+
Contract preview: task intent + constraints go into the scorer question,
|
|
190
|
+
and the 4B endpoint judges each state-changing step (OK / VIOLATION /
|
|
191
|
+
UNSURE) with asymmetric confirmation: conf >= 0.9 fires alone, 0.6-0.9
|
|
192
|
+
needs one corroborating violation, a confident OK clears suspicion.
|
|
193
|
+
Metrics are paper-aligned (EIR_k, recall, FPR on clean, per-category).
|
|
194
|
+
|
|
195
|
+
Pi uses `--mode rpc` for headless operation. The control plane spawns it as a subprocess, reads JSONL events from stdout, and sends control commands (abort, steer) via stdin. No Pi source code is modified.
|
|
196
|
+
|
|
197
|
+
## Project structure
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
edward/ Product package (pip install -e .)
|
|
201
|
+
cli.py edward CLI: wrap / demo / eval / audit / doctor
|
|
202
|
+
engine.py ControlPlane: events -> triggers -> scorer -> decision -> audit
|
|
203
|
+
config.py Policy packs (TOML/JSON, 3 presets, strict validation)
|
|
204
|
+
audit.py Append-only JSONL audit log (never blocks monitoring)
|
|
205
|
+
scorer.py Semantic scorer client with circuit breaker
|
|
206
|
+
scenarios.py Failure scenario suite (single source for demo/eval)
|
|
207
|
+
evalcmd.py Policy evaluation gate (detection / FPR / timing)
|
|
208
|
+
pi_client.py Pi RPC client (cwd, provider/model, stderr capture)
|
|
209
|
+
scorer_client.py /v1/score client (internal endpoint, Qwen3.5-4B)
|
|
210
|
+
state_engine.py Materialize AgentState from event stream
|
|
211
|
+
triggers.py 8 trigger rules, policy-parameterized (defaults FROZEN)
|
|
212
|
+
kernel.py Decision authority hierarchy
|
|
213
|
+
canonical_events.py Normalize Pi/Codex/custom events to canonical schema
|
|
214
|
+
notify.py Slack webhook + stderr banners (fail-silent)
|
|
215
|
+
main.py Legacy entry -> edward wrap
|
|
216
|
+
benchmark.py 300-trial held-out benchmark (re-exports scenarios)
|
|
217
|
+
ablation.py Watchdog vs State Engine comparison
|
|
218
|
+
heldout_eval.py Dev/test split + frozen config evaluation
|
|
219
|
+
robustness_eval.py 4-dimension robustness attack
|
|
220
|
+
extreme_tests.py 4 extreme scenario demos
|
|
221
|
+
test_product.py Product test suite (unittest, offline)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## What's next
|
|
225
|
+
|
|
226
|
+
The silent-corruption gap points at the next layer: a Task Contract that defines which files the agent should touch and what the expected end-state looks like. When the agent's actual filesystem mutations diverge from the contract, that's a scope violation — regardless of whether any tool call returned an error. This moves the State Engine from "what is the agent doing" to "what is the agent doing to the world, and is that still allowed."
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""edward — external control plane for AI coding agents."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
|
+
|
|
5
|
+
from .config import Policy, PolicyError, load_policy # noqa: F401
|
|
6
|
+
from .engine import ControlPlane, Decision # noqa: F401
|
|
7
|
+
from .audit import AuditLog # noqa: F401
|
|
8
|
+
from .scorer import Scorer # noqa: F401
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Append-only JSONL audit log.
|
|
2
|
+
|
|
3
|
+
Never blocks or crashes the control plane: if the disk write fails, the
|
|
4
|
+
entry is echoed to stderr once and monitoring continues. Rotates at 50 MB
|
|
5
|
+
by renaming to <name>.1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MAX_BYTES = 50 * 1024 * 1024
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditLog:
|
|
20
|
+
def __init__(self, path=None):
|
|
21
|
+
self.path = Path(path) if path else None
|
|
22
|
+
self._degraded = False
|
|
23
|
+
if self.path:
|
|
24
|
+
try:
|
|
25
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
except OSError as exc:
|
|
27
|
+
self._degrade(f"cannot create audit dir {self.path.parent}: {exc}")
|
|
28
|
+
|
|
29
|
+
def _degrade(self, msg: str) -> None:
|
|
30
|
+
if not self._degraded:
|
|
31
|
+
self._degraded = True
|
|
32
|
+
print(f"[edward] audit degraded to stderr: {msg}", file=sys.stderr)
|
|
33
|
+
|
|
34
|
+
def emit(self, entry_type: str, session: str = "", **fields) -> None:
|
|
35
|
+
record = {
|
|
36
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
37
|
+
"v": SCHEMA_VERSION,
|
|
38
|
+
"type": entry_type,
|
|
39
|
+
"session": session,
|
|
40
|
+
}
|
|
41
|
+
record.update(fields)
|
|
42
|
+
line = json.dumps(record, default=str, ensure_ascii=False)
|
|
43
|
+
if self.path is None:
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
if self.path.exists() and self.path.stat().st_size > MAX_BYTES:
|
|
47
|
+
rotated = self.path.with_suffix(self.path.suffix + ".1")
|
|
48
|
+
os.replace(self.path, rotated)
|
|
49
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
50
|
+
fh.write(line + "\n")
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
self._degrade(f"{exc}")
|
|
53
|
+
print(f"[edward] audit: {line}", file=sys.stderr)
|
|
54
|
+
|
|
55
|
+
def session_start(self, session: str, policy_preset: str, command) -> None:
|
|
56
|
+
self.emit("session_start", session=session, policy_preset=policy_preset,
|
|
57
|
+
command=[str(c) for c in command])
|
|
58
|
+
|
|
59
|
+
def intervention(self, session: str, trigger_reason: str, decision_type: str,
|
|
60
|
+
action: str, source: str, authority: str, mss: dict,
|
|
61
|
+
jev: dict = None, est_avoided_usd: float = None) -> None:
|
|
62
|
+
self.emit("intervention", session=session, trigger_reason=trigger_reason,
|
|
63
|
+
decision_type=decision_type, action=action, source=source,
|
|
64
|
+
authority=authority, mss=mss, jev=jev or {},
|
|
65
|
+
est_avoided_usd=est_avoided_usd)
|
|
66
|
+
|
|
67
|
+
def session_end(self, session: str, reason: str, exit_code: int) -> None:
|
|
68
|
+
self.emit("session_end", session=session, reason=reason, exit_code=exit_code)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def summarize(path) -> dict:
|
|
72
|
+
"""Read an audit file and aggregate the numbers that matter."""
|
|
73
|
+
path = Path(path)
|
|
74
|
+
summary = {
|
|
75
|
+
"file": str(path), "sessions": 0, "interventions": 0,
|
|
76
|
+
"by_action": {}, "by_decision_type": {}, "tokens_at_intervention": [],
|
|
77
|
+
"cost_usd_at_intervention": [], "est_avoided_usd": 0.0,
|
|
78
|
+
"first_ts": None, "last_ts": None,
|
|
79
|
+
}
|
|
80
|
+
sessions = set()
|
|
81
|
+
if not path.exists():
|
|
82
|
+
return summary
|
|
83
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
84
|
+
for raw in fh:
|
|
85
|
+
raw = raw.strip()
|
|
86
|
+
if not raw:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
rec = json.loads(raw)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
continue
|
|
92
|
+
ts = rec.get("ts")
|
|
93
|
+
if ts:
|
|
94
|
+
summary["first_ts"] = summary["first_ts"] or ts
|
|
95
|
+
summary["last_ts"] = ts
|
|
96
|
+
if rec.get("session"):
|
|
97
|
+
sessions.add(rec["session"])
|
|
98
|
+
if rec.get("type") == "intervention":
|
|
99
|
+
summary["interventions"] += 1
|
|
100
|
+
action = rec.get("action", "?")
|
|
101
|
+
summary["by_action"][action] = summary["by_action"].get(action, 0) + 1
|
|
102
|
+
dtype = rec.get("decision_type", "?")
|
|
103
|
+
summary["by_decision_type"][dtype] = summary["by_decision_type"].get(dtype, 0) + 1
|
|
104
|
+
mss = rec.get("mss") or {}
|
|
105
|
+
if mss.get("token_usage"):
|
|
106
|
+
summary["tokens_at_intervention"].append(mss["token_usage"])
|
|
107
|
+
if mss.get("cost_usd"):
|
|
108
|
+
summary["cost_usd_at_intervention"].append(mss["cost_usd"])
|
|
109
|
+
if rec.get("est_avoided_usd") is not None:
|
|
110
|
+
summary["est_avoided_usd"] += rec["est_avoided_usd"]
|
|
111
|
+
summary["sessions"] = len(sessions)
|
|
112
|
+
return summary
|