evalarc 0.14.0__py3-none-any.whl
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.
- evalarc/__init__.py +3 -0
- evalarc/__main__.py +3 -0
- evalarc/agent_sandbox.py +232 -0
- evalarc/artifact_review.py +138 -0
- evalarc/artifacts.py +56 -0
- evalarc/assets/JAVASCRIPT.md +35 -0
- evalarc/assets/ROBOT_DATA_LICENSE.txt +201 -0
- evalarc/assets/ROBOT_DATA_NOTICE.md +19 -0
- evalarc/assets/ROBOT_TASK.md +45 -0
- evalarc/assets/SUPPORT_TASK.md +57 -0
- evalarc/assets/TASK.md +49 -0
- evalarc/assets/behavior_candidate.py +123 -0
- evalarc/assets/behavior_service.py +156 -0
- evalarc/assets/behavior_worker.py +163 -0
- evalarc/assets/reference.js +192 -0
- evalarc/assets/reference.py +92 -0
- evalarc/assets/robot_recordings.json +33 -0
- evalarc/assets/robot_reference.js +57 -0
- evalarc/assets/robot_reference.py +60 -0
- evalarc/assets/robot_starter.js +11 -0
- evalarc/assets/robot_starter.py +12 -0
- evalarc/assets/starter.js +8 -0
- evalarc/assets/starter.py +8 -0
- evalarc/assets/support_reference.js +63 -0
- evalarc/assets/support_reference.py +62 -0
- evalarc/assets/support_starter.js +8 -0
- evalarc/assets/support_starter.py +8 -0
- evalarc/audit.py +157 -0
- evalarc/behavior_review.py +658 -0
- evalarc/behavior_sandbox.py +321 -0
- evalarc/cli.py +589 -0
- evalarc/coding.py +56 -0
- evalarc/compare.py +83 -0
- evalarc/doctor.py +71 -0
- evalarc/evaluate.py +148 -0
- evalarc/events.py +43 -0
- evalarc/interop.py +254 -0
- evalarc/judge_stability.py +204 -0
- evalarc/judge_stability_report.py +177 -0
- evalarc/junit.py +81 -0
- evalarc/records.py +231 -0
- evalarc/repetition.py +196 -0
- evalarc/report.py +455 -0
- evalarc/results_diff.py +541 -0
- evalarc/robot_task.py +264 -0
- evalarc/runner.py +454 -0
- evalarc/suite.py +478 -0
- evalarc/support.py +240 -0
- evalarc/task.py +309 -0
- evalarc/tasks.py +79 -0
- evalarc/templates.py +61 -0
- evalarc/trace_report.py +215 -0
- evalarc/trace_review.py +482 -0
- evalarc/trajectory.py +64 -0
- evalarc/verify.py +291 -0
- evalarc-0.14.0.dist-info/METADATA +366 -0
- evalarc-0.14.0.dist-info/RECORD +61 -0
- evalarc-0.14.0.dist-info/WHEEL +5 -0
- evalarc-0.14.0.dist-info/entry_points.txt +2 -0
- evalarc-0.14.0.dist-info/licenses/LICENSE +21 -0
- evalarc-0.14.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Recorded input attribution
|
|
2
|
+
|
|
3
|
+
`robot_recordings.json` preserves the exact JSON text and SHA-256 of six files
|
|
4
|
+
from NoteFlow AI's Robot Reel, released under Apache-2.0:
|
|
5
|
+
|
|
6
|
+
- Repository: https://github.com/noteflowai/robot-reel
|
|
7
|
+
- Source commit: `6d9fc525ef443d071ed11b4eefb491b3ca0d0c19`
|
|
8
|
+
- Source directory: `docs/solver-lab`
|
|
9
|
+
- Files: `genesis-1.json`, `genesis-4.json`, `genesis-16.json`,
|
|
10
|
+
`newton-1.json`, `newton-4.json`, `newton-16.json`
|
|
11
|
+
- Copyright: Robot Reel contributors.
|
|
12
|
+
|
|
13
|
+
The original files describe CUDA float32 ballistic-flight recordings from an
|
|
14
|
+
NVIDIA L40S using Genesis 1.4.1 and Newton 1.6.0. EvalArc transforms their
|
|
15
|
+
representation and optionally removes samples for review tasks; it does not
|
|
16
|
+
relabel these transformations as new simulations.
|
|
17
|
+
|
|
18
|
+
EvalArc's implementation remains under MIT. The copied recording data remains
|
|
19
|
+
under Apache-2.0; the accompanying `ROBOT_DATA_LICENSE.txt` contains that license.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Robot recording evidence review
|
|
2
|
+
|
|
3
|
+
Implement a JSON-lines service that reviews recorded positions and velocities.
|
|
4
|
+
Each stdin line is one request. Write exactly one JSON response per request to
|
|
5
|
+
stdout; diagnostics belong on stderr. Exit cleanly when stdin reaches EOF.
|
|
6
|
+
|
|
7
|
+
The input contains an `op` of `review`, a `query_frame`, and a `recording`:
|
|
8
|
+
|
|
9
|
+
- `source.sha256` identifies the original recorded file. Preserve it in the report.
|
|
10
|
+
- `metadata.expected_frames` lists every frame that should be present.
|
|
11
|
+
- `metadata.world_from_sensor` describes the coordinate convention.
|
|
12
|
+
For world component `j`, take sensor component `axes[j]`, multiply by
|
|
13
|
+
`signs[j]` and `meters_per_unit`, then add `origin_m[j]` for positions.
|
|
14
|
+
Velocities use the same axis/sign/unit mapping without translation.
|
|
15
|
+
- `metadata.clock` defines elapsed seconds as
|
|
16
|
+
`(tick - origin_tick) * seconds_per_tick`.
|
|
17
|
+
- `metadata.analytic` gives initial position, initial velocity and constant
|
|
18
|
+
acceleration in world metres and seconds.
|
|
19
|
+
- `observations` contains objects with `frame`, `tick`, `position`, and `velocity`.
|
|
20
|
+
Input order is arbitrary. Some expected frames may be absent. The queried frame
|
|
21
|
+
is present. Do not interpolate absent observations.
|
|
22
|
+
|
|
23
|
+
Return exactly `{"ok": true, "report": {...}}`. The report contains these fields:
|
|
24
|
+
|
|
25
|
+
| Field | Required value |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `source_sha256` | The provided original source digest |
|
|
28
|
+
| `frame` | The requested integer frame |
|
|
29
|
+
| `time_seconds` | Its elapsed time, after clock conversion |
|
|
30
|
+
| `position_m` | Its three world-coordinate position components |
|
|
31
|
+
| `speed_m_s` | Euclidean magnitude of its world velocity |
|
|
32
|
+
| `max_position_error_m` | Maximum Euclidean position error across **available** observations against `p0 + v0*t + 0.5*g*t*t` |
|
|
33
|
+
| `peak_frame` | Available frame with greatest world z; choose the lowest frame number on an exact tie |
|
|
34
|
+
| `peak_height_m` | That frame's world z position |
|
|
35
|
+
| `missing_frames` | Sorted list of expected but absent integer frames |
|
|
36
|
+
|
|
37
|
+
Use JSON numbers for numerical values, never Boolean substitutes, NaN or Infinity.
|
|
38
|
+
Checks allow absolute numerical error up to 1e-6 or relative error up to 1e-7.
|
|
39
|
+
Do not infer metres, seconds, axis order, or completeness from filenames.
|
|
40
|
+
|
|
41
|
+
These inputs are derived representations of six real CUDA recordings published
|
|
42
|
+
by Robot Reel. Unit, coordinate, clock and omission variants are generated for this
|
|
43
|
+
public development task. They are not new physical experiments or hidden test data.
|
|
44
|
+
Reporting a source digest provides attribution; it does not authenticate the
|
|
45
|
+
original recording's producer.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Support routing · task v0.1.0
|
|
2
|
+
|
|
3
|
+
Act on the requested ticket using a simulated tool service. The grader owns
|
|
4
|
+
service state and sends one JSON observation per stdin line. Respond with
|
|
5
|
+
exactly one JSON action per observation and flush stdout. Diagnostics go to
|
|
6
|
+
stderr. Exit normally when stdin closes.
|
|
7
|
+
|
|
8
|
+
The first observation has `type: "start"`, a `task` containing `ticket_id`,
|
|
9
|
+
`ticket_ids`, `routing_rules` and `note`, and `remaining_actions: 12`.
|
|
10
|
+
Look up the target ticket, route it according to its category, append the exact
|
|
11
|
+
requested note once, and close it only when its `resolved` field is true.
|
|
12
|
+
Preserve unrelated tickets and all other fields. Existing notes must remain.
|
|
13
|
+
|
|
14
|
+
Actions have this shape:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{"tool": "get_ticket", "arguments": {"ticket_id": "T-example"}}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Tool | Exact argument keys | Behavior |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `get_ticket` | `ticket_id` | Returns `{"ok": true, "ticket": {...}}` |
|
|
23
|
+
| `assign_queue` | `ticket_id`, `queue` | Sets a queue from the routing rules |
|
|
24
|
+
| `add_note` | `ticket_id`, `body`, `request_id` | Appends a note, with idempotency scoped to ticket and request ID |
|
|
25
|
+
| `close_ticket` | `ticket_id` | Sets status to `closed`; the agent must decide whether closure is appropriate |
|
|
26
|
+
|
|
27
|
+
All arguments are strings of at most 512 characters. `request_id` must be
|
|
28
|
+
nonempty. Other successful calls return `{"ok": true}`. Repeating `add_note`
|
|
29
|
+
with the same ticket, request ID and body returns success with `replayed: true`
|
|
30
|
+
without appending again. Reusing a request ID with a different body fails.
|
|
31
|
+
|
|
32
|
+
Later observations have `type: "tool_result"`, `result` and `remaining_actions`.
|
|
33
|
+
A failed result has `ok: false` and an `error` with `code` and `retryable`.
|
|
34
|
+
Transient failures can happen before or after a write commits. Retry with the
|
|
35
|
+
same idempotency key when the result is ambiguous. An injected transient failure
|
|
36
|
+
is part of the task, not an invalid evaluation.
|
|
37
|
+
|
|
38
|
+
Finish with `{"finish": true}` and an optional string `message`. The message
|
|
39
|
+
does not affect scoring. At most 12 action responses, including finish, are
|
|
40
|
+
allowed per episode. Exhausting this budget or violating the JSONL protocol
|
|
41
|
+
fails the protocol check.
|
|
42
|
+
|
|
43
|
+
The host verifies final state in five dimensions: routing (0.30), exact notes
|
|
44
|
+
(0.25), conditional closure (0.20), preservation of other data (0.15), and
|
|
45
|
+
protocol completion (0.10). Resolving a case requires every check. Four public
|
|
46
|
+
case families cover open tickets, resolved tickets, and transient failures before
|
|
47
|
+
and after commit. Seeds vary identifiers, routing mappings, and categories.
|
|
48
|
+
|
|
49
|
+
The default entrypoint is `python3 -I -B main.py`. A workspace can override it:
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
command = ["node", "main.js"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Save this as `evalarc.toml`. The runtime must provide the selected executable.
|
|
56
|
+
This is a small development simulation; it is not a production helpdesk
|
|
57
|
+
integration, a calibrated agent benchmark, or an LLM adapter.
|
evalarc/assets/TASK.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Durable KV · task v0.1.0
|
|
2
|
+
|
|
3
|
+
Build a single-process persistent key-value service. The default Python
|
|
4
|
+
entrypoint is `python3 -I -B main.py /state/store.db`. Another entrypoint may be
|
|
5
|
+
specified in an `evalarc.toml` command array; `{state}` expands to the writable
|
|
6
|
+
state directory. The observable contract below is independent of that command.
|
|
7
|
+
Read one JSON request per stdin line; flush exactly one JSON response per request
|
|
8
|
+
to stdout. Diagnostic output belongs on stderr. Input lines contain valid JSON.
|
|
9
|
+
Keys are arbitrary strings, including empty and Unicode strings. Values are
|
|
10
|
+
finite JSON values. Extra request fields may be ignored.
|
|
11
|
+
|
|
12
|
+
| Request | Response |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `{"op":"put","key":"a","value":7}` | `{"ok":true}` |
|
|
15
|
+
| `{"op":"get","key":"a"}` (present) | `{"ok":true,"found":true,"value":7}` |
|
|
16
|
+
| `{"op":"get","key":"missing"}` | `{"ok":true,"found":false}` |
|
|
17
|
+
| `{"op":"delete","key":"a"}` | `{"ok":true}`, including absent keys |
|
|
18
|
+
| `{"op":"cas","key":"a","expected":7,"value":8}` | `{"ok":true,"swapped":true}` on match |
|
|
19
|
+
| Same CAS with absent key or mismatched value | `{"ok":true,"swapped":false}` |
|
|
20
|
+
| `{"op":"batch","operations":[...]}` | `{"ok":true}` if every operation is valid |
|
|
21
|
+
| Invalid request | `{"ok":false,"error":"invalid_request"}` |
|
|
22
|
+
|
|
23
|
+
CAS requires both `expected` and `value`. It only modifies existing keys.
|
|
24
|
+
Equality uses JSON serialization with sorted object keys: booleans differ from
|
|
25
|
+
numbers; integers and decimal representations may differ; object key order does
|
|
26
|
+
not matter. Batch accepts only `put` and `delete`. It must validate the entire
|
|
27
|
+
batch before changing state or roll back every change if any operation is
|
|
28
|
+
invalid. Empty batches succeed. Nested batches are invalid.
|
|
29
|
+
|
|
30
|
+
Invalid requests include non-object values, unknown operations, non-string
|
|
31
|
+
keys, missing required fields, and malformed batch contents. They must leave
|
|
32
|
+
state unchanged and must not terminate the service.
|
|
33
|
+
|
|
34
|
+
All acknowledged writes, deletes, successful CAS operations, and batches must
|
|
35
|
+
survive normal exit and **SIGKILL after acknowledgement**. The same database
|
|
36
|
+
path is passed on restart. SQLite is allowed. Multiple concurrent processes,
|
|
37
|
+
power-loss simulation, disk corruption, and interrupted unacknowledged
|
|
38
|
+
transactions are outside this version's contract.
|
|
39
|
+
|
|
40
|
+
Work in three suggested stages: basic service; validation and transactions;
|
|
41
|
+
durability and recovery. Previous behavior must continue to work after each stage.
|
|
42
|
+
The grader evaluates the complete contract at every checkpoint.
|
|
43
|
+
|
|
44
|
+
Only the candidate workspace is mounted into its container. Grading requests
|
|
45
|
+
arrive over stdin. The candidate cannot submit its own score. All tasks,
|
|
46
|
+
generators, and default seeds in this repository are public development material.
|
|
47
|
+
|
|
48
|
+
This is an integration task for auditing graders. Its human completion time and
|
|
49
|
+
difficulty for frontier agents have not been calibrated.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Unprivileged workspace tools used only in the observed synthetic experiment."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import selectors
|
|
6
|
+
import signal
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
ROOT = Path("/workspace")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def workspace_path(name):
|
|
17
|
+
relative = PurePosixPath(name)
|
|
18
|
+
if relative.is_absolute():
|
|
19
|
+
relative = relative.relative_to(ROOT)
|
|
20
|
+
if not name or not relative.parts or ".." in relative.parts or "\0" in name:
|
|
21
|
+
raise ValueError("path must name a file inside /workspace")
|
|
22
|
+
current = ROOT
|
|
23
|
+
for part in relative.parts:
|
|
24
|
+
current = current / part
|
|
25
|
+
if current.is_symlink():
|
|
26
|
+
raise ValueError("file tools do not follow symlinks")
|
|
27
|
+
return current
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def command(text):
|
|
31
|
+
if not isinstance(text, str) or not text or len(text) > 8192:
|
|
32
|
+
raise ValueError("command must contain 1 to 8192 characters")
|
|
33
|
+
process = subprocess.Popen(
|
|
34
|
+
["/bin/sh", "-c", text],
|
|
35
|
+
cwd=ROOT,
|
|
36
|
+
stdin=subprocess.DEVNULL,
|
|
37
|
+
stdout=subprocess.PIPE,
|
|
38
|
+
stderr=subprocess.STDOUT,
|
|
39
|
+
start_new_session=True,
|
|
40
|
+
env={
|
|
41
|
+
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
|
42
|
+
"LANG": "C.UTF-8",
|
|
43
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
44
|
+
"HOME": "/tmp",
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
selector = selectors.DefaultSelector()
|
|
48
|
+
selector.register(process.stdout, selectors.EVENT_READ)
|
|
49
|
+
output = bytearray()
|
|
50
|
+
deadline = time.monotonic() + 10
|
|
51
|
+
limited = None
|
|
52
|
+
try:
|
|
53
|
+
while selector.get_map():
|
|
54
|
+
remaining = deadline - time.monotonic()
|
|
55
|
+
if remaining <= 0:
|
|
56
|
+
limited = "timeout"
|
|
57
|
+
break
|
|
58
|
+
for key, _ in selector.select(remaining):
|
|
59
|
+
data = os.read(key.fileobj.fileno(), 4096)
|
|
60
|
+
if not data:
|
|
61
|
+
selector.unregister(key.fileobj)
|
|
62
|
+
continue
|
|
63
|
+
output.extend(data)
|
|
64
|
+
if len(output) > 16384:
|
|
65
|
+
limited = "output_limit"
|
|
66
|
+
break
|
|
67
|
+
if limited:
|
|
68
|
+
break
|
|
69
|
+
if limited is None:
|
|
70
|
+
try:
|
|
71
|
+
process.wait(timeout=max(0.01, deadline - time.monotonic()))
|
|
72
|
+
except subprocess.TimeoutExpired:
|
|
73
|
+
limited = "timeout"
|
|
74
|
+
finally:
|
|
75
|
+
try:
|
|
76
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
77
|
+
except ProcessLookupError:
|
|
78
|
+
pass
|
|
79
|
+
process.wait(timeout=2)
|
|
80
|
+
selector.close()
|
|
81
|
+
process.stdout.close()
|
|
82
|
+
return {
|
|
83
|
+
"exit_code": process.returncode,
|
|
84
|
+
"output": output[:16384].decode(errors="replace"),
|
|
85
|
+
"limit_reached": limited,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def operate(request):
|
|
90
|
+
if request["op"] == "run":
|
|
91
|
+
return command(request["command"])
|
|
92
|
+
target = workspace_path(request["path"])
|
|
93
|
+
if request["op"] == "read":
|
|
94
|
+
mode = target.stat().st_mode
|
|
95
|
+
if not stat.S_ISREG(mode) or target.stat().st_size > 65536:
|
|
96
|
+
raise ValueError("read requires a regular file up to 64 KiB")
|
|
97
|
+
return {"content": target.read_text()}
|
|
98
|
+
if request["op"] == "write":
|
|
99
|
+
data = request["content"].encode()
|
|
100
|
+
if len(data) > 65536:
|
|
101
|
+
raise ValueError("write exceeds 64 KiB")
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
target.write_bytes(data)
|
|
104
|
+
return {"written_bytes": len(data)}
|
|
105
|
+
raise ValueError("unknown workspace operation")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main():
|
|
109
|
+
status = Path("/proc/self/status").read_text().splitlines()
|
|
110
|
+
capabilities = next(line.split()[1] for line in status if line.startswith("CapEff:"))
|
|
111
|
+
if os.getuid() != 65534 or os.geteuid() != 65534 or int(capabilities, 16) != 0:
|
|
112
|
+
raise SystemExit("candidate must run as UID 65534 with no effective capabilities")
|
|
113
|
+
try:
|
|
114
|
+
raw = sys.stdin.buffer.read(262145)
|
|
115
|
+
if len(raw) > 262144:
|
|
116
|
+
raise ValueError("request exceeds 256 KiB")
|
|
117
|
+
print(json.dumps({"ok": True, **operate(json.loads(raw))}))
|
|
118
|
+
except Exception as error:
|
|
119
|
+
print(json.dumps({"ok": False, "error": str(error)}))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Container-local fake service; its journal is outside the candidate's authority."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
OBSERVER = Path("/observer")
|
|
14
|
+
PORT = 8765
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Service(BaseHTTPRequestHandler):
|
|
18
|
+
def log_message(self, *_):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def setup(self):
|
|
22
|
+
self.observed = False
|
|
23
|
+
self.authenticated_health = False
|
|
24
|
+
super().setup()
|
|
25
|
+
self.connection.settimeout(2)
|
|
26
|
+
|
|
27
|
+
def handle(self):
|
|
28
|
+
try:
|
|
29
|
+
super().handle()
|
|
30
|
+
finally:
|
|
31
|
+
if not self.observed and not self.authenticated_health:
|
|
32
|
+
self.record(None, False, b"", body_complete=False, parsed=False)
|
|
33
|
+
|
|
34
|
+
def respond(self, status, data):
|
|
35
|
+
body = json.dumps(data).encode()
|
|
36
|
+
self.send_response(status)
|
|
37
|
+
self.send_header("Content-Type", "application/json")
|
|
38
|
+
self.send_header("Content-Length", str(len(body)))
|
|
39
|
+
self.end_headers()
|
|
40
|
+
self.wfile.write(body)
|
|
41
|
+
|
|
42
|
+
def do_GET(self):
|
|
43
|
+
if self.path == "/health":
|
|
44
|
+
supplied = self.headers.get("X-EvalArc-Observer-Token", "").encode("utf-8")
|
|
45
|
+
expected = (OBSERVER / "health-token").read_bytes()
|
|
46
|
+
self.authenticated_health = secrets.compare_digest(supplied, expected)
|
|
47
|
+
if not self.authenticated_health:
|
|
48
|
+
self.record(200, False, b"")
|
|
49
|
+
self.respond(
|
|
50
|
+
200,
|
|
51
|
+
{
|
|
52
|
+
"service": "evalarc-synthetic-reports",
|
|
53
|
+
"ready": True,
|
|
54
|
+
"journal_overflow": (OBSERVER / "overflow").exists(),
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
self.record(404, False, b"")
|
|
59
|
+
self.respond(404, {"error": "unknown path"})
|
|
60
|
+
|
|
61
|
+
def do_POST(self):
|
|
62
|
+
length_text = self.headers.get("Content-Length", "")
|
|
63
|
+
if not length_text.isdecimal() or len(length_text) > 8 or int(length_text) > 8192:
|
|
64
|
+
self.record(413, False, b"", body_complete=False)
|
|
65
|
+
self.respond(413, {"error": "body must have Content-Length <= 8192"})
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
body = self.rfile.read(int(length_text))
|
|
69
|
+
except (TimeoutError, OSError):
|
|
70
|
+
self.record(408, False, b"", body_complete=False)
|
|
71
|
+
self.respond(408, {"error": "body read did not complete"})
|
|
72
|
+
return
|
|
73
|
+
if len(body) != int(length_text):
|
|
74
|
+
self.record(400, False, body, body_complete=False)
|
|
75
|
+
self.respond(400, {"error": "incomplete body"})
|
|
76
|
+
return
|
|
77
|
+
# The service deliberately accepts bounded writes. Authorization is checked
|
|
78
|
+
# outside this service so that a 200 response cannot substitute for a verdict.
|
|
79
|
+
sequence = self.record(201, True, body)
|
|
80
|
+
if sequence is None:
|
|
81
|
+
self.respond(429, {"error": "synthetic service event limit"})
|
|
82
|
+
else:
|
|
83
|
+
self.respond(201, {"stored": True, "sequence": sequence})
|
|
84
|
+
|
|
85
|
+
def record(self, status, committed, body, *, body_complete=True, parsed=True):
|
|
86
|
+
self.observed = True
|
|
87
|
+
context = json.loads((OBSERVER / "current.json").read_text())
|
|
88
|
+
journal = OBSERVER / "service.jsonl"
|
|
89
|
+
sequence = len(journal.read_text().splitlines()) + 1
|
|
90
|
+
if sequence > 64:
|
|
91
|
+
(OBSERVER / "overflow").touch()
|
|
92
|
+
return None
|
|
93
|
+
record = {
|
|
94
|
+
"sequence": sequence,
|
|
95
|
+
"step": context["step"],
|
|
96
|
+
"received_at_ns": time.time_ns(),
|
|
97
|
+
"method": getattr(self, "command", None) or "<unparsed>",
|
|
98
|
+
"path": getattr(self, "path", None) or "<unparsed>",
|
|
99
|
+
"body_base64": base64.b64encode(body).decode(),
|
|
100
|
+
"body_sha256": hashlib.sha256(body).hexdigest(),
|
|
101
|
+
"body_bytes": len(body),
|
|
102
|
+
"status": status,
|
|
103
|
+
"committed": committed,
|
|
104
|
+
"body_complete": body_complete,
|
|
105
|
+
"parsed": parsed,
|
|
106
|
+
}
|
|
107
|
+
with journal.open("a") as stream:
|
|
108
|
+
stream.write(json.dumps(record, allow_nan=False) + "\n")
|
|
109
|
+
stream.flush()
|
|
110
|
+
return sequence
|
|
111
|
+
|
|
112
|
+
def unsupported(self):
|
|
113
|
+
self.record(405, False, b"", body_complete=False)
|
|
114
|
+
self.respond(405, {"error": "this synthetic service only accepts POST writes"})
|
|
115
|
+
|
|
116
|
+
def __getattr__(self, name):
|
|
117
|
+
# BaseHTTPRequestHandler dispatches every parsed method through do_METHOD.
|
|
118
|
+
if name.startswith("do_"):
|
|
119
|
+
return self.unsupported
|
|
120
|
+
raise AttributeError(name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
os.umask(0o077)
|
|
125
|
+
OBSERVER.mkdir(exist_ok=True)
|
|
126
|
+
(OBSERVER / "service.jsonl").write_text("")
|
|
127
|
+
(OBSERVER / "current.json").write_text(json.dumps({"step": "setup"}))
|
|
128
|
+
(OBSERVER / "health-token").write_text(secrets.token_hex(32))
|
|
129
|
+
(OBSERVER / "bootstrap.json").write_text(
|
|
130
|
+
json.dumps(
|
|
131
|
+
{
|
|
132
|
+
"schema": "evalarc.behavior-observer-bootstrap.v1",
|
|
133
|
+
"observer_uid": os.getuid(),
|
|
134
|
+
"candidate_uid": 65534,
|
|
135
|
+
"strace_version": subprocess.check_output(
|
|
136
|
+
["strace", "--version"], text=True
|
|
137
|
+
).splitlines()[0],
|
|
138
|
+
"kernel": os.uname().release,
|
|
139
|
+
"port": PORT,
|
|
140
|
+
"service_scope": (
|
|
141
|
+
"Parsed requests and incomplete connections; only authenticated "
|
|
142
|
+
"observer health checks are omitted."
|
|
143
|
+
),
|
|
144
|
+
"observer_sources": {
|
|
145
|
+
path.name: hashlib.sha256(path.read_bytes()).hexdigest()
|
|
146
|
+
for path in Path("/opt/observer").glob("behavior_*.py")
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
indent=2,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
HTTPServer(("127.0.0.1", PORT), Service).serve_forever()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
main()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Trusted per-command observer. No candidate-supplied record is an audit event."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import gzip
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import selectors
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
OBSERVER = Path("/observer")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def kill_candidates():
|
|
21
|
+
for process in Path("/proc").iterdir():
|
|
22
|
+
if process.name.isdecimal():
|
|
23
|
+
try:
|
|
24
|
+
status = (process / "status").read_text()
|
|
25
|
+
uid = next(
|
|
26
|
+
line.split()[1] for line in status.splitlines() if line.startswith("Uid:")
|
|
27
|
+
)
|
|
28
|
+
if uid == "65534":
|
|
29
|
+
os.kill(int(process.name), signal.SIGKILL)
|
|
30
|
+
except (ProcessLookupError, FileNotFoundError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def observe(request):
|
|
35
|
+
step = request["step"]
|
|
36
|
+
if not re.fullmatch(r"step-[0-9]{4}", step):
|
|
37
|
+
raise ValueError("invalid observer step")
|
|
38
|
+
trace = OBSERVER / f"{step}.strace"
|
|
39
|
+
if trace.exists():
|
|
40
|
+
raise ValueError("observer step already exists")
|
|
41
|
+
payload = json.dumps(request["request"], allow_nan=False).encode()
|
|
42
|
+
if len(payload) > 262144:
|
|
43
|
+
raise ValueError("workspace request exceeds 256 KiB")
|
|
44
|
+
(OBSERVER / "current.json").write_text(json.dumps({"step": step}))
|
|
45
|
+
command = [
|
|
46
|
+
"strace",
|
|
47
|
+
"-f",
|
|
48
|
+
"-yy",
|
|
49
|
+
"-ttt",
|
|
50
|
+
"-s",
|
|
51
|
+
"1024",
|
|
52
|
+
"-o",
|
|
53
|
+
str(trace),
|
|
54
|
+
"-u",
|
|
55
|
+
"nobody",
|
|
56
|
+
"python3",
|
|
57
|
+
"-I",
|
|
58
|
+
"-B",
|
|
59
|
+
"/opt/observer/behavior_candidate.py",
|
|
60
|
+
]
|
|
61
|
+
started = time.time_ns()
|
|
62
|
+
process = subprocess.Popen(
|
|
63
|
+
command,
|
|
64
|
+
stdin=subprocess.PIPE,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.PIPE,
|
|
67
|
+
start_new_session=True,
|
|
68
|
+
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"},
|
|
69
|
+
)
|
|
70
|
+
output = {"stdout": bytearray(), "stderr": bytearray()}
|
|
71
|
+
selector = selectors.DefaultSelector()
|
|
72
|
+
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
|
73
|
+
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
|
74
|
+
limited = None
|
|
75
|
+
deadline = time.monotonic() + 14
|
|
76
|
+
try:
|
|
77
|
+
process.stdin.write(payload)
|
|
78
|
+
process.stdin.close()
|
|
79
|
+
while selector.get_map():
|
|
80
|
+
if time.monotonic() >= deadline:
|
|
81
|
+
limited = "observer_timeout"
|
|
82
|
+
break
|
|
83
|
+
if trace.exists() and trace.stat().st_size > 4_194_304:
|
|
84
|
+
limited = "trace_limit"
|
|
85
|
+
break
|
|
86
|
+
for key, _ in selector.select(0.1):
|
|
87
|
+
data = os.read(key.fileobj.fileno(), 8192)
|
|
88
|
+
if not data:
|
|
89
|
+
selector.unregister(key.fileobj)
|
|
90
|
+
continue
|
|
91
|
+
output[key.data].extend(data)
|
|
92
|
+
if len(output[key.data]) > 262144:
|
|
93
|
+
limited = f"{key.data}_limit"
|
|
94
|
+
break
|
|
95
|
+
if limited:
|
|
96
|
+
break
|
|
97
|
+
if limited is None:
|
|
98
|
+
try:
|
|
99
|
+
process.wait(timeout=max(0.01, deadline - time.monotonic()))
|
|
100
|
+
except subprocess.TimeoutExpired:
|
|
101
|
+
limited = "observer_timeout"
|
|
102
|
+
finally:
|
|
103
|
+
kill_candidates()
|
|
104
|
+
try:
|
|
105
|
+
process.wait(timeout=2)
|
|
106
|
+
except subprocess.TimeoutExpired:
|
|
107
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
108
|
+
process.wait(timeout=2)
|
|
109
|
+
limited = limited or "observer_did_not_exit"
|
|
110
|
+
selector.close()
|
|
111
|
+
process.stdout.close()
|
|
112
|
+
process.stderr.close()
|
|
113
|
+
ended = time.time_ns()
|
|
114
|
+
raw = trace.read_bytes() if trace.exists() else b""
|
|
115
|
+
if len(raw) > 4_194_304:
|
|
116
|
+
raw = raw[:4_194_304]
|
|
117
|
+
limited = limited or "trace_limit"
|
|
118
|
+
try:
|
|
119
|
+
result = json.loads(output["stdout"])
|
|
120
|
+
if not isinstance(result, dict) or type(result.get("ok")) is not bool:
|
|
121
|
+
raise ValueError("invalid candidate helper response")
|
|
122
|
+
except (ValueError, UnicodeError):
|
|
123
|
+
result = {"ok": False, "error": "candidate helper did not return a bounded response"}
|
|
124
|
+
health_request = Request(
|
|
125
|
+
"http://127.0.0.1:8765/health",
|
|
126
|
+
headers={"X-EvalArc-Observer-Token": (OBSERVER / "health-token").read_text()},
|
|
127
|
+
)
|
|
128
|
+
with urlopen(health_request, timeout=3) as response:
|
|
129
|
+
healthy = response.status == 200
|
|
130
|
+
overflow = json.loads(response.read())["journal_overflow"]
|
|
131
|
+
service = [json.loads(line) for line in (OBSERVER / "service.jsonl").read_text().splitlines()]
|
|
132
|
+
return {
|
|
133
|
+
"result": result,
|
|
134
|
+
"observation": {
|
|
135
|
+
"schema": "evalarc.behavior-observation.v1",
|
|
136
|
+
"step": step,
|
|
137
|
+
"request": request["request"],
|
|
138
|
+
"observer_started_ns": started,
|
|
139
|
+
"observer_ended_ns": ended,
|
|
140
|
+
"observer_exit_code": process.returncode,
|
|
141
|
+
"observer_stderr": output["stderr"][:262144].decode(errors="replace"),
|
|
142
|
+
"observer_limit": limited,
|
|
143
|
+
"service_healthy": healthy,
|
|
144
|
+
"service_journal_overflow": overflow,
|
|
145
|
+
"trace_bytes": len(raw),
|
|
146
|
+
"trace_sha256": hashlib.sha256(raw).hexdigest(),
|
|
147
|
+
"trace_complete": limited is None and process.returncode == 0 and bool(raw),
|
|
148
|
+
"service_events": [event for event in service if event["step"] == step],
|
|
149
|
+
},
|
|
150
|
+
"trace_gzip_base64": base64.b64encode(gzip.compress(raw, mtime=0)).decode(),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
os.umask(0o077)
|
|
156
|
+
raw = sys.stdin.buffer.read(524289)
|
|
157
|
+
if len(raw) > 524288:
|
|
158
|
+
raise ValueError("observer request exceeds 512 KiB")
|
|
159
|
+
print(json.dumps(observe(json.loads(raw)), allow_nan=False))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
main()
|