test(827): capture Gate0 runtime evidence
This commit is contained in:
Binary file not shown.
51
docs/compaction-refresh/probes/p1_anchor_exec.py
Normal file
51
docs/compaction-refresh/probes/p1_anchor_exec.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Register this PID as anchor, then exec the real `mosaic yolo` launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def request(socket_path: str, payload: dict[str, object]) -> dict[str, object]:
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.connect(socket_path)
|
||||
conn.sendall((json.dumps(payload) + "\n").encode())
|
||||
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
conn.close()
|
||||
return response
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("runtime", choices=["pi", "claude"])
|
||||
parser.add_argument("args", nargs=argparse.REMAINDER)
|
||||
ns = parser.parse_args()
|
||||
|
||||
response = request(ns.socket, {"action": "register-anchor", "runtime": ns.runtime})
|
||||
if response.get("decision") != "ACCEPT":
|
||||
raise SystemExit("anchor registration refused")
|
||||
os.environ["GATE0_SESSION_ID"] = str(response["session_id"])
|
||||
argv = ["mosaic", "yolo", ns.runtime, *ns.args]
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "anchor-exec",
|
||||
"pid": os.getpid(),
|
||||
"argv": ["mosaic", "yolo", ns.runtime, f"<{len(ns.args)} runtime args>"],
|
||||
"note": "os.execvpe retains pid and /proc starttime",
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
os.execvpe("mosaic", argv, os.environ)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
180
docs/compaction-refresh/probes/p1_broker.py
Normal file
180
docs/compaction-refresh/probes/p1_broker.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gate0 P1 broker prototype: peercred anchor minting and /proc ancestry checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def proc_node(pid: int) -> dict[str, Any]:
|
||||
text = Path(f"/proc/{pid}/stat").read_text()
|
||||
close = text.rfind(")")
|
||||
comm = text[text.find("(") + 1 : close]
|
||||
fields = text[close + 2 :].split()
|
||||
cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
||||
return {
|
||||
"pid": pid,
|
||||
"ppid": int(fields[1]),
|
||||
"starttime_ticks": int(fields[19]),
|
||||
"comm": comm,
|
||||
"exe": os.readlink(f"/proc/{pid}/exe"),
|
||||
"argv0": cmdline[0].decode(errors="replace") if cmdline and cmdline[0] else "",
|
||||
"argc": len([part for part in cmdline if part]),
|
||||
}
|
||||
|
||||
|
||||
def ancestry(peer_pid: int, anchor: dict[str, Any] | None) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
chain: list[dict[str, Any]] = []
|
||||
pid = peer_pid
|
||||
seen: set[int] = set()
|
||||
try:
|
||||
while pid > 0 and pid not in seen:
|
||||
seen.add(pid)
|
||||
node = proc_node(pid)
|
||||
chain.append(node)
|
||||
if anchor and pid == anchor["pid"]:
|
||||
if node["starttime_ticks"] != anchor["starttime_ticks"]:
|
||||
return chain, False, "anchor-starttime-mismatch"
|
||||
break
|
||||
pid = node["ppid"]
|
||||
else:
|
||||
return chain, False, "anchor-not-reached"
|
||||
|
||||
if not anchor or chain[-1]["pid"] != anchor["pid"]:
|
||||
return chain, False, "anchor-not-reached"
|
||||
|
||||
# Re-read every node after the walk. A disappearing PID or changed
|
||||
# starttime invalidates the complete chain (PID-reuse/race closure).
|
||||
for original in chain:
|
||||
again = proc_node(original["pid"])
|
||||
if again["starttime_ticks"] != original["starttime_ticks"]:
|
||||
return chain, False, f"starttime-race:{original['pid']}"
|
||||
return chain, True, "ancestry-reaches-registered-anchor"
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError) as exc:
|
||||
return chain, False, f"proc-walk-failed:{type(exc).__name__}"
|
||||
|
||||
|
||||
def emit(log_file: Path, record: dict[str, Any]) -> None:
|
||||
line = json.dumps(record, sort_keys=True)
|
||||
with log_file.open("a", encoding="utf-8") as out:
|
||||
out.write(line + "\n")
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("--log", required=True)
|
||||
parser.add_argument("--state", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
socket_path = Path(args.socket)
|
||||
log_file = Path(args.log)
|
||||
state_file = Path(args.state)
|
||||
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(socket_path.parent, 0o700)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
log_file.unlink(missing_ok=True)
|
||||
state_file.unlink(missing_ok=True)
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(socket_path))
|
||||
os.chmod(socket_path, 0o600)
|
||||
server.listen(8)
|
||||
anchor: dict[str, Any] | None = None
|
||||
session_id: str | None = None
|
||||
emit(
|
||||
log_file,
|
||||
{
|
||||
"event": "broker-listen",
|
||||
"pid": os.getpid(),
|
||||
"socket": str(socket_path),
|
||||
"directory_mode": f"{stat.S_IMODE(socket_path.parent.stat().st_mode):04o}",
|
||||
"socket_mode": f"{stat.S_IMODE(socket_path.stat().st_mode):04o}",
|
||||
},
|
||||
)
|
||||
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
peer_pid, peer_uid, peer_gid = struct.unpack("3i", raw)
|
||||
request = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
action = request.get("action")
|
||||
|
||||
if action == "register-anchor" and anchor is None:
|
||||
anchor = proc_node(peer_pid)
|
||||
session_id = secrets.token_hex(16)
|
||||
state = {"session_id": session_id, "anchor": anchor}
|
||||
state_file.write_text(json.dumps(state, sort_keys=True) + "\n")
|
||||
record = {
|
||||
"event": "anchor-minted",
|
||||
"decision": "ACCEPT",
|
||||
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
|
||||
"anchor": anchor,
|
||||
"session_id": session_id,
|
||||
}
|
||||
emit(log_file, record)
|
||||
conn.sendall((json.dumps(record) + "\n").encode())
|
||||
continue
|
||||
|
||||
if action in {"resolve-hook", "claim-session"}:
|
||||
chain, reaches, reason = ancestry(peer_pid, anchor)
|
||||
claimed = request.get("session_id")
|
||||
claim_ok = action == "resolve-hook" or claimed == session_id
|
||||
accepted = bool(anchor and session_id and reaches and claim_ok)
|
||||
if action == "claim-session" and claimed != session_id:
|
||||
reason = "unknown-session-id"
|
||||
elif action == "claim-session" and claimed == session_id and not reaches:
|
||||
reason = "victim-id-known-but-ancestry-mismatch"
|
||||
record = {
|
||||
"event": action,
|
||||
"decision": "ACCEPT" if accepted else "REJECT",
|
||||
"reason": reason,
|
||||
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
|
||||
"claimed_session_id": claimed,
|
||||
"resolved_session_id": session_id if accepted else None,
|
||||
"anchor": anchor,
|
||||
"ancestry": chain,
|
||||
"starttimes_rechecked": reaches,
|
||||
}
|
||||
emit(log_file, record)
|
||||
conn.sendall((json.dumps(record) + "\n").encode())
|
||||
continue
|
||||
|
||||
if action == "shutdown":
|
||||
record = {
|
||||
"event": "broker-shutdown",
|
||||
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
|
||||
}
|
||||
emit(log_file, record)
|
||||
conn.sendall((json.dumps(record) + "\n").encode())
|
||||
break
|
||||
|
||||
record = {
|
||||
"event": "invalid-request",
|
||||
"decision": "REJECT",
|
||||
"peercred": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid},
|
||||
}
|
||||
emit(log_file, record)
|
||||
conn.sendall((json.dumps(record) + "\n").encode())
|
||||
|
||||
server.close()
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(f"P1 broker fatal: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
raise
|
||||
38
docs/compaction-refresh/probes/p1_hook_client.py
Normal file
38
docs/compaction-refresh/probes/p1_hook_client.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude SessionStart hook client for P1 ancestry evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Consume the real Claude hook payload without recording transcript paths or
|
||||
# prompt content in the evidence artifact.
|
||||
hook_input = json.load(sys.stdin)
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.connect(os.environ["GATE0_BROKER_SOCKET"])
|
||||
conn.sendall((json.dumps({"action": "resolve-hook"}) + "\n").encode())
|
||||
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
conn.close()
|
||||
event_name = hook_input.get("hook_event_name")
|
||||
if response.get("decision") != "ACCEPT":
|
||||
print(f"Gate0 broker rejected {event_name} hook ancestry", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": event_name,
|
||||
"additionalContext": "GATE0_P1_SUPPORTED_HOOK_ANCESTRY_ACCEPTED",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
docs/compaction-refresh/probes/p1_pi_extension.ts
Normal file
35
docs/compaction-refresh/probes/p1_pi_extension.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||||
import net from 'node:net';
|
||||
|
||||
async function brokerRequest(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const socketPath = process.env['GATE0_BROKER_SOCKET'];
|
||||
if (!socketPath) throw new Error('GATE0_BROKER_SOCKET missing');
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let buffer = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
const newline = buffer.indexOf('\n');
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
resolve(JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export default function register(pi: ExtensionAPI) {
|
||||
pi.on('session_start', async () => {
|
||||
const response = await brokerRequest({ action: 'resolve-hook', runtime: 'pi-extension' });
|
||||
if (response['decision'] !== 'ACCEPT') {
|
||||
throw new Error(`P1 broker rejected Pi extension ancestry: ${response['reason']}`);
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand('gate0-p1-ready', {
|
||||
description: 'Return only after the P1 session_start ancestry hook completed',
|
||||
handler: async () => undefined,
|
||||
});
|
||||
}
|
||||
274
docs/compaction-refresh/probes/p1_run.py
Normal file
274
docs/compaction-refresh/probes/p1_run.py
Normal file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run P1 against the real installed Mosaic→Pi and Mosaic→Claude chains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def wait_for(predicate, description: str, timeout: float = 30.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(f"timed out waiting for {description}")
|
||||
|
||||
|
||||
def read_records(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||
|
||||
|
||||
def socket_request(path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.connect(str(path))
|
||||
conn.sendall((json.dumps(payload) + "\n").encode())
|
||||
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
conn.close()
|
||||
return response
|
||||
|
||||
|
||||
def start_broker(root: Path) -> tuple[subprocess.Popen[str], Path, Path, Path]:
|
||||
socket_path = root / "broker.sock"
|
||||
log_path = root / "broker.jsonl"
|
||||
state_path = root / "state.json"
|
||||
broker = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(HERE / "p1_broker.py"),
|
||||
"--socket",
|
||||
str(socket_path),
|
||||
"--log",
|
||||
str(log_path),
|
||||
"--state",
|
||||
str(state_path),
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
wait_for(socket_path.exists, "broker socket")
|
||||
return broker, socket_path, log_path, state_path
|
||||
|
||||
|
||||
def print_ps(record: dict[str, Any]) -> None:
|
||||
chain = record.get("ancestry", [])
|
||||
pids = [str(node["pid"]) for node in chain if Path(f"/proc/{node['pid']}").exists()]
|
||||
if not pids:
|
||||
print("ps_snapshot=<hook chain exited; broker /proc snapshot above is authoritative>")
|
||||
return
|
||||
command = [
|
||||
"ps",
|
||||
"-o",
|
||||
"pid=,ppid=,lstart=,uid=,gid=,comm=",
|
||||
"-p",
|
||||
",".join(pids),
|
||||
]
|
||||
print("$ " + " ".join(command))
|
||||
print(subprocess.check_output(command, text=True).rstrip())
|
||||
|
||||
|
||||
def run_runtime(runtime: str) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix=f"gate0-p1-{runtime}-") as temp:
|
||||
root = Path(temp)
|
||||
workspace = root / "workspace"
|
||||
workspace.mkdir()
|
||||
broker, socket_path, log_path, state_path = start_broker(root)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"GATE0_BROKER_SOCKET": str(socket_path),
|
||||
"MOSAIC_PI_FORCE_SKILLS": "",
|
||||
"PI_SKIP_VERSION_CHECK": "1",
|
||||
}
|
||||
)
|
||||
stdout_path = root / f"{runtime}.stdout"
|
||||
stderr_path = root / f"{runtime}.stderr"
|
||||
|
||||
if runtime == "pi":
|
||||
runtime_args = [
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--no-context-files",
|
||||
"--no-prompt-templates",
|
||||
"--extension",
|
||||
str(HERE / "p1_pi_extension.ts"),
|
||||
]
|
||||
else:
|
||||
settings = root / "claude-settings.json"
|
||||
settings.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": f'python3 "{HERE / "p1_hook_client.py"}"',
|
||||
"timeout": 20,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
runtime_args = [
|
||||
"--settings",
|
||||
str(settings),
|
||||
"--model",
|
||||
"haiku",
|
||||
"--print",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-hook-events",
|
||||
"--max-budget-usd",
|
||||
"0.03",
|
||||
"Reply exactly: OK",
|
||||
]
|
||||
|
||||
out = stdout_path.open("w", encoding="utf-8")
|
||||
err = stderr_path.open("w", encoding="utf-8")
|
||||
anchor = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(HERE / "p1_anchor_exec.py"),
|
||||
"--socket",
|
||||
str(socket_path),
|
||||
runtime,
|
||||
*runtime_args,
|
||||
],
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
stdin=subprocess.PIPE if runtime == "pi" else subprocess.DEVNULL,
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
wait_for(state_path.exists, "anchor registration")
|
||||
attacker = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(HERE / "p1_sibling_attacker.py"),
|
||||
"--socket",
|
||||
str(socket_path),
|
||||
"--state",
|
||||
str(state_path),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if runtime == "pi":
|
||||
assert anchor.stdin is not None
|
||||
anchor.stdin.write('{"id":"state","type":"get_state"}\n')
|
||||
anchor.stdin.flush()
|
||||
|
||||
wait_for(
|
||||
lambda: any(r.get("event") == "resolve-hook" for r in read_records(log_path)),
|
||||
f"{runtime} supported hook/extension broker contact",
|
||||
timeout=60,
|
||||
)
|
||||
if runtime == "claude":
|
||||
try:
|
||||
anchor.wait(timeout=90)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
records = read_records(log_path)
|
||||
state = json.loads(state_path.read_text())
|
||||
resolve = next(r for r in records if r.get("event") == "resolve-hook")
|
||||
reject = next(r for r in records if r.get("event") == "claim-session")
|
||||
if resolve.get("decision") != "ACCEPT":
|
||||
raise AssertionError(f"{runtime} hook ancestry was not accepted: {resolve}")
|
||||
if reject.get("decision") != "REJECT":
|
||||
raise AssertionError(f"{runtime} sibling substitution was not rejected: {reject}")
|
||||
if attacker.returncode != 0:
|
||||
raise AssertionError(f"{runtime} sibling probe did not observe rejection: {attacker.stderr}")
|
||||
|
||||
print(f"=== P1 {runtime.upper()} REAL LAUNCH ===")
|
||||
print("machine_assertions=PASS")
|
||||
print(
|
||||
"$ python3 docs/compaction-refresh/probes/p1_anchor_exec.py "
|
||||
f"--socket <protected-socket> {runtime} <runtime args>"
|
||||
)
|
||||
print("registered_anchor=" + json.dumps(state["anchor"], sort_keys=True))
|
||||
print("broker_minted_session_id=" + state["session_id"])
|
||||
print("hook_or_extension_record=" + json.dumps(resolve, sort_keys=True))
|
||||
print("sibling_attack_record=" + json.dumps(reject, sort_keys=True))
|
||||
print("sibling_process_stdout=" + attacker.stdout.strip())
|
||||
print(f"sibling_process_exit={attacker.returncode}")
|
||||
print_ps(resolve)
|
||||
print("launcher_stderr_excerpt:")
|
||||
for line in stderr_path.read_text(errors="replace").splitlines()[:12]:
|
||||
print(" " + line[:500])
|
||||
runtime_lines = stdout_path.read_text(errors="replace").splitlines()
|
||||
print("runtime_stdout_excerpt:")
|
||||
for line in runtime_lines[:8]:
|
||||
print(" " + line[:500])
|
||||
hook_lines = [
|
||||
line
|
||||
for line in runtime_lines
|
||||
if "hook" in line.lower() or "GATE0_P1_SUPPORTED_HOOK" in line
|
||||
]
|
||||
print("runtime_hook_event_excerpt:")
|
||||
for line in hook_lines[:8]:
|
||||
print(" " + line[:1000])
|
||||
print()
|
||||
finally:
|
||||
if anchor.poll() is None:
|
||||
try:
|
||||
os.killpg(anchor.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
anchor.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(anchor.pid, signal.SIGKILL)
|
||||
anchor.wait(timeout=5)
|
||||
out.close()
|
||||
err.close()
|
||||
try:
|
||||
socket_request(socket_path, {"action": "shutdown"})
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
broker.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
broker.kill()
|
||||
broker.wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runtime", choices=["pi", "claude", "both"], default="both")
|
||||
ns = parser.parse_args()
|
||||
if ns.runtime in {"pi", "both"}:
|
||||
run_runtime("pi")
|
||||
if ns.runtime in {"claude", "both"}:
|
||||
run_runtime("claude")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
docs/compaction-refresh/probes/p1_sibling_attacker.py
Normal file
54
docs/compaction-refresh/probes/p1_sibling_attacker.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Same-UID sibling that attempts to claim the anchor's broker-minted id."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("--state", required=True)
|
||||
ns = parser.parse_args()
|
||||
state_path = Path(ns.state)
|
||||
for _ in range(200):
|
||||
if state_path.exists():
|
||||
break
|
||||
time.sleep(0.025)
|
||||
state = json.loads(state_path.read_text())
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.connect(ns.socket)
|
||||
conn.sendall(
|
||||
(
|
||||
json.dumps(
|
||||
{"action": "claim-session", "session_id": state["session_id"]},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
).encode()
|
||||
)
|
||||
response = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
conn.close()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"attacker_pid": os.getpid(),
|
||||
"attacker_uid": os.getuid(),
|
||||
"victim_session_id_known": True,
|
||||
"broker_decision": response.get("decision"),
|
||||
"broker_reason": response.get("reason"),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
raise SystemExit(0 if response.get("decision") == "REJECT" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
135
docs/compaction-refresh/probes/p2_provider_timing_run.py
Normal file
135
docs/compaction-refresh/probes/p2_provider_timing_run.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Force a real Pi HTTP provider response to prove response-hook timing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from pi_gate0_run import PiRpc, jsonl
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
self.rfile.read(length)
|
||||
chunks = [
|
||||
{
|
||||
"id": "gate0-response",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gate0-model",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
||||
},
|
||||
{
|
||||
"id": "gate0-response",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gate0-model",
|
||||
"choices": [
|
||||
{"index": 0, "delta": {"content": "TIMING_OK"}, "finish_reason": None}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "gate0-response",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gate0-model",
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
|
||||
},
|
||||
]
|
||||
body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n"
|
||||
encoded = body.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.send_header("X-Gate0-Response", "headers-before-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
port = server.server_address[1]
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="gate0-p2-timing-") as temp:
|
||||
root = Path(temp)
|
||||
workspace = root / "workspace"
|
||||
workspace.mkdir()
|
||||
log = root / "hooks.jsonl"
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"GATE0_PI_LOG": str(log),
|
||||
"GATE0_LOCAL_PROVIDER_URL": f"http://127.0.0.1:{port}/v1",
|
||||
"MOSAIC_PI_FORCE_SKILLS": "",
|
||||
"PI_SKIP_VERSION_CHECK": "1",
|
||||
}
|
||||
)
|
||||
command = [
|
||||
"mosaic",
|
||||
"yolo",
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--no-context-files",
|
||||
"--no-prompt-templates",
|
||||
"--provider",
|
||||
"gate0-local",
|
||||
"--model",
|
||||
"gate0-model",
|
||||
"--extension",
|
||||
str(HERE / "pi_gate0_extension.ts"),
|
||||
]
|
||||
pi = PiRpc(command, workspace, env)
|
||||
try:
|
||||
pi.prompt_and_settle("timing", "Reply with TIMING_OK")
|
||||
records = jsonl(log)
|
||||
selected = [
|
||||
record
|
||||
for record in records
|
||||
if record["event"] in {"before_provider_request", "after_provider_response", "message_end"}
|
||||
and (record["event"] != "message_end" or record.get("role") == "assistant")
|
||||
]
|
||||
print("$ python3 docs/compaction-refresh/probes/p2_provider_timing_run.py")
|
||||
print(f"local_http_endpoint=http://127.0.0.1:{port}/v1/chat/completions")
|
||||
for record in selected:
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
after = next(record for record in selected if record["event"] == "after_provider_response")
|
||||
message = next(record for record in selected if record["event"] == "message_end")
|
||||
if not (
|
||||
after["seq"] < message["seq"]
|
||||
and after["assistantContentAvailableAtThisHook"] is False
|
||||
and message["assistantContentObserved"] is True
|
||||
):
|
||||
raise AssertionError("provider response/content observation ordering failed")
|
||||
print("machine_assertions=PASS")
|
||||
print(f"after_provider_response_seq={after['seq']}")
|
||||
print(f"message_end_seq={message['seq']}")
|
||||
print(f"headers_hook_precedes_completed_message={after['seq'] < message['seq']}")
|
||||
finally:
|
||||
pi.close()
|
||||
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
docs/compaction-refresh/probes/p3_generation_broker.py
Normal file
117
docs/compaction-refresh/probes/p3_generation_broker.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P3 broker prototype: peercred-keyed runtime_generation and lease revocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def proc_starttime(pid: int) -> int:
|
||||
text = Path(f"/proc/{pid}/stat").read_text()
|
||||
close = text.rfind(")")
|
||||
return int(text[close + 2 :].split()[19])
|
||||
|
||||
|
||||
def emit(log: Path, value: dict[str, Any]) -> None:
|
||||
with log.open("a", encoding="utf-8") as out:
|
||||
out.write(json.dumps(value, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("--log", required=True)
|
||||
ns = parser.parse_args()
|
||||
socket_path = Path(ns.socket)
|
||||
log_path = Path(ns.log)
|
||||
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(socket_path.parent, 0o700)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
log_path.unlink(missing_ok=True)
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(socket_path))
|
||||
os.chmod(socket_path, 0o600)
|
||||
server.listen(8)
|
||||
generations: dict[tuple[int, int], int] = {}
|
||||
lease_state: dict[tuple[int, int], str] = {}
|
||||
emit(log_path, {"event": "listen", "pid": os.getpid(), "socket": str(socket_path)})
|
||||
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
pid, uid, gid = struct.unpack("3i", raw)
|
||||
starttime = proc_starttime(pid)
|
||||
request = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
if request.get("action") == "shutdown-broker":
|
||||
conn.sendall(b'{"ok":true}\n')
|
||||
break
|
||||
identity = (pid, starttime)
|
||||
if request.get("action") == "promote-probe":
|
||||
generation = generations.get(identity, 0)
|
||||
lease_state[identity] = "VERIFIED"
|
||||
record = {
|
||||
"event": "probe_lease_promoted",
|
||||
"peercred": {"pid": pid, "uid": uid, "gid": gid},
|
||||
"starttime_ticks": starttime,
|
||||
"generation": generation,
|
||||
"new_lease_state": "VERIFIED",
|
||||
}
|
||||
emit(log_path, record)
|
||||
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
|
||||
continue
|
||||
if request.get("action") == "source-invalid":
|
||||
old_lease = lease_state.get(identity, "NONE")
|
||||
lease_state[identity] = "REVOKED"
|
||||
record = {
|
||||
"event": "source_invalidation_revoke",
|
||||
"peercred": {"pid": pid, "uid": uid, "gid": gid},
|
||||
"starttime_ticks": starttime,
|
||||
"generation": generations.get(identity, 0),
|
||||
"source_reason": request.get("reason"),
|
||||
"prior_lease": old_lease,
|
||||
"new_lease_state": "REVOKED",
|
||||
"promotion": False,
|
||||
}
|
||||
emit(log_path, record)
|
||||
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
|
||||
continue
|
||||
if request.get("action") != "lifecycle":
|
||||
conn.sendall(b'{"ok":false,"reason":"invalid-action"}\n')
|
||||
continue
|
||||
|
||||
old_generation = generations.get(identity, 0)
|
||||
old_lease = lease_state.get(identity, "NONE")
|
||||
new_generation = old_generation + 1
|
||||
generations[identity] = new_generation
|
||||
# Every lifecycle boundary revokes first. A start establishes a new
|
||||
# UNVERIFIED incarnation; it never inherits prior VERIFIED state.
|
||||
lease_state[identity] = "UNVERIFIED" if request.get("phase") == "start" else "REVOKED"
|
||||
record = {
|
||||
"event": "runtime_generation_bump",
|
||||
"peercred": {"pid": pid, "uid": uid, "gid": gid},
|
||||
"starttime_ticks": starttime,
|
||||
"phase": request.get("phase"),
|
||||
"reason": request.get("reason"),
|
||||
"old_generation": old_generation,
|
||||
"new_generation": new_generation,
|
||||
"prior_lease": old_lease,
|
||||
"prior_lease_revoked": True,
|
||||
"new_lease_state": lease_state[identity],
|
||||
}
|
||||
emit(log_path, record)
|
||||
conn.sendall((json.dumps(record, sort_keys=True) + "\n").encode())
|
||||
|
||||
server.close()
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
docs/compaction-refresh/probes/p4_peercred_probe.py
Normal file
97
docs/compaction-refresh/probes/p4_peercred_probe.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gate0 P4: exercise Linux SO_PEERCRED and correlate it to /proc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def proc_identity(pid: int) -> dict[str, int | str]:
|
||||
stat_text = Path(f"/proc/{pid}/stat").read_text()
|
||||
close = stat_text.rfind(")")
|
||||
fields = stat_text[close + 2 :].split()
|
||||
# fields[0] is field 3 (state); ppid is field 4 and starttime is field 22.
|
||||
return {
|
||||
"pid": pid,
|
||||
"ppid": int(fields[1]),
|
||||
"starttime_ticks": int(fields[19]),
|
||||
"uid": int(Path(f"/proc/{pid}/status").read_text().split("Uid:", 1)[1].split()[0]),
|
||||
"exe": os.readlink(f"/proc/{pid}/exe"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="gate0-p4-") as tmp:
|
||||
root = Path(tmp)
|
||||
os.chmod(root, 0o700)
|
||||
socket_path = root / "broker.sock"
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(socket_path))
|
||||
os.chmod(socket_path, 0o600)
|
||||
server.listen(1)
|
||||
|
||||
child = os.fork()
|
||||
if child == 0:
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(socket_path))
|
||||
identity = proc_identity(os.getpid())
|
||||
client.sendall((json.dumps(identity, sort_keys=True) + "\n").encode())
|
||||
# Keep /proc/<pid> alive until the server has correlated peercred.
|
||||
if client.recv(2) != b"OK":
|
||||
os._exit(2)
|
||||
client.close()
|
||||
os._exit(0)
|
||||
|
||||
conn, _ = server.accept()
|
||||
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
peer_pid = int.from_bytes(raw[0:4], byteorder="little", signed=True)
|
||||
peer_uid = int.from_bytes(raw[4:8], byteorder="little", signed=True)
|
||||
peer_gid = int.from_bytes(raw[8:12], byteorder="little", signed=True)
|
||||
claimed = json.loads(conn.makefile("r", encoding="utf-8").readline())
|
||||
observed = proc_identity(peer_pid)
|
||||
conn.sendall(b"OK")
|
||||
_, status = os.waitpid(child, 0)
|
||||
|
||||
root_mode = stat.S_IMODE(root.stat().st_mode)
|
||||
socket_mode = stat.S_IMODE(socket_path.stat().st_mode)
|
||||
if not (
|
||||
peer_pid == claimed["pid"] == observed["pid"]
|
||||
and peer_uid == claimed["uid"] == observed["uid"]
|
||||
and claimed["starttime_ticks"] == observed["starttime_ticks"]
|
||||
and root_mode == 0o700
|
||||
and socket_mode == 0o600
|
||||
and os.waitstatus_to_exitcode(status) == 0
|
||||
):
|
||||
raise AssertionError("SO_PEERCRED, /proc identity, or socket-mode correlation failed")
|
||||
print("machine_assertions=PASS")
|
||||
print(f"server_pid={os.getpid()} server_uid={os.getuid()} server_gid={os.getgid()}")
|
||||
print(f"socket_path={socket_path}")
|
||||
print(f"directory_mode={root_mode:04o} socket_mode={socket_mode:04o}")
|
||||
print(f"SO_PEERCRED pid={peer_pid} uid={peer_uid} gid={peer_gid}")
|
||||
print("client_claim=" + json.dumps(claimed, sort_keys=True))
|
||||
print("proc_observed=" + json.dumps(observed, sort_keys=True))
|
||||
print(f"pid_match={peer_pid == claimed['pid'] == observed['pid']}")
|
||||
print(f"uid_match={peer_uid == claimed['uid'] == observed['uid']}")
|
||||
print(
|
||||
"starttime_match="
|
||||
+ str(claimed["starttime_ticks"] == observed["starttime_ticks"])
|
||||
)
|
||||
print(f"client_exit_status={os.waitstatus_to_exitcode(status)}")
|
||||
print("same_principal_socket=true")
|
||||
print(
|
||||
"posture=0700 parent + 0600 socket excludes other UIDs, but does not prevent "
|
||||
"the same UID from unlinking/rebinding; distinct-principal system service remains "
|
||||
"required for a claim stronger than T-C against same-UID counterfeit replacement"
|
||||
)
|
||||
|
||||
conn.close()
|
||||
server.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
docs/compaction-refresh/probes/p6_claude_hook.py
Normal file
54
docs/compaction-refresh/probes/p6_claude_hook.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude SessionStart additionalContext producer for P6 observation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BLOCK = "\n".join(
|
||||
[
|
||||
"GATE0_CLAUDE_ATOMIC_BEGIN",
|
||||
"segment-01=alpha-2d11",
|
||||
"segment-02=middle-8e22",
|
||||
"segment-03=omega-4f33",
|
||||
"GATE0_CLAUDE_ATOMIC_END",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def starttime(pid: int) -> int:
|
||||
text = Path(f"/proc/{pid}/stat").read_text()
|
||||
return int(text[text.rfind(")") + 2 :].split()[19])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
hook_input = json.load(sys.stdin)
|
||||
log = Path(os.environ["GATE0_CLAUDE_HOOK_LOG"])
|
||||
record = {
|
||||
"hook_event_name": hook_input.get("hook_event_name"),
|
||||
"pid": os.getpid(),
|
||||
"ppid": os.getppid(),
|
||||
"starttime_ticks": starttime(os.getpid()),
|
||||
"block_length": len(BLOCK.encode()),
|
||||
"block_sha256": hashlib.sha256(BLOCK.encode()).hexdigest(),
|
||||
"emission": "one hookSpecificOutput.additionalContext string field",
|
||||
}
|
||||
log.write_text(json.dumps(record, sort_keys=True) + "\n")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": BLOCK,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
145
docs/compaction-refresh/probes/p6_claude_run.py
Normal file
145
docs/compaction-refresh/probes/p6_claude_run.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run real Claude 2.1.x through `mosaic yolo` for P6 observation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
BLOCK = "\n".join(
|
||||
[
|
||||
"GATE0_CLAUDE_ATOMIC_BEGIN",
|
||||
"segment-01=alpha-2d11",
|
||||
"segment-02=middle-8e22",
|
||||
"segment-03=omega-4f33",
|
||||
"GATE0_CLAUDE_ATOMIC_END",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def strings(value: Any):
|
||||
if isinstance(value, str):
|
||||
yield value
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from strings(item)
|
||||
elif isinstance(value, dict):
|
||||
for item in value.values():
|
||||
yield from strings(item)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="gate0-p6-claude-") as temp:
|
||||
root = Path(temp)
|
||||
workspace = root / "workspace"
|
||||
workspace.mkdir()
|
||||
settings = root / "settings.json"
|
||||
hook_log = root / "hook.jsonl"
|
||||
settings.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": f'python3 "{HERE / "p6_claude_hook.py"}"',
|
||||
"timeout": 20,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["GATE0_CLAUDE_HOOK_LOG"] = str(hook_log)
|
||||
command = [
|
||||
"mosaic",
|
||||
"yolo",
|
||||
"claude",
|
||||
"--settings",
|
||||
str(settings),
|
||||
"--model",
|
||||
"haiku",
|
||||
"--print",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-hook-events",
|
||||
"--max-budget-usd",
|
||||
"0.10",
|
||||
"Return only the exact full GATE0_CLAUDE_ATOMIC_BEGIN through GATE0_CLAUDE_ATOMIC_END block injected by SessionStart, with no code fence or commentary.",
|
||||
]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=150,
|
||||
check=False,
|
||||
)
|
||||
events: list[dict[str, Any]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
hook_events = [
|
||||
event
|
||||
for event in events
|
||||
if event.get("type") == "system"
|
||||
and event.get("subtype") in {"hook_started", "hook_response"}
|
||||
]
|
||||
full_matches = [text for event in events for text in strings(event) if BLOCK in text]
|
||||
exact_matches = [text for event in events for text in strings(event) if text == BLOCK]
|
||||
assistant_texts: list[str] = []
|
||||
for event in events:
|
||||
if event.get("type") != "assistant":
|
||||
continue
|
||||
for text in strings(event.get("message", {})):
|
||||
if "GATE0_CLAUDE_ATOMIC_BEGIN" in text:
|
||||
assistant_texts.append(text)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(f"Claude probe exited {result.returncode}")
|
||||
if not any(event.get("subtype") == "hook_response" and event.get("outcome") == "success" for event in hook_events):
|
||||
raise AssertionError("Claude SessionStart hook did not complete successfully")
|
||||
if BLOCK not in exact_matches:
|
||||
raise AssertionError("Claude did not return an exact full-block field")
|
||||
|
||||
print("$ python3 docs/compaction-refresh/probes/p6_claude_run.py")
|
||||
print("machine_assertions=PASS")
|
||||
print("command=mosaic yolo claude --settings <isolated> --model haiku --print --output-format stream-json --verbose --include-hook-events <prompt>")
|
||||
print("claude_version=" + subprocess.check_output(["claude", "--version"], text=True).strip())
|
||||
print("mosaic_version=" + subprocess.check_output(["mosaic", "--version"], text=True).strip())
|
||||
print(f"exit_code={result.returncode}")
|
||||
print("hook_process_log=" + hook_log.read_text().strip())
|
||||
for event in hook_events:
|
||||
print("hook_stream_event=" + json.dumps(event, sort_keys=True))
|
||||
print(f"block_length={len(BLOCK.encode())}")
|
||||
print(f"block_sha256={hashlib.sha256(BLOCK.encode()).hexdigest()}")
|
||||
print(f"stream_fields_containing_full_block={len(full_matches)}")
|
||||
print(f"stream_fields_exactly_equal_block={len(exact_matches)}")
|
||||
for text in assistant_texts:
|
||||
print(f"assistant_copy_length={len(text.encode())}")
|
||||
print(f"assistant_copy_sha256={hashlib.sha256(text.encode()).hexdigest()}")
|
||||
print(f"assistant_copy_exact={text == BLOCK}")
|
||||
print("assistant_copy=" + json.dumps(text))
|
||||
if result.stderr.strip():
|
||||
print("stderr_excerpt=" + json.dumps(result.stderr.splitlines()[:10]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
350
docs/compaction-refresh/probes/pi_gate0_extension.ts
Normal file
350
docs/compaction-refresh/probes/pi_gate0_extension.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFileSync, appendFileSync, statSync } from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const SELF = resolve(fileURLToPath(import.meta.url).split('?')[0]!);
|
||||
const LOG = process.env['GATE0_PI_LOG'];
|
||||
const CONTEXT_BLOCK =
|
||||
process.env['GATE0_PI_CONTEXT_BLOCK'] ??
|
||||
[
|
||||
'GATE0_PI_ATOMIC_BEGIN',
|
||||
'segment-01=alpha-7e31',
|
||||
'segment-02=middle-9c42',
|
||||
'segment-03=omega-5b83',
|
||||
'GATE0_PI_ATOMIC_END',
|
||||
].join('\n');
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
function sha(value: string | Buffer): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function procStarttime(): number {
|
||||
const text = readFileSync(`/proc/${process.pid}/stat`, 'utf8');
|
||||
const close = text.lastIndexOf(')');
|
||||
const fields = text.slice(close + 2).trim().split(/\s+/);
|
||||
return Number(fields[19]);
|
||||
}
|
||||
|
||||
function log(event: string, details: Record<string, unknown> = {}): void {
|
||||
if (!LOG) return;
|
||||
sequence += 1;
|
||||
appendFileSync(
|
||||
LOG,
|
||||
`${JSON.stringify({ seq: sequence, event, pid: process.pid, starttime_ticks: procStarttime(), ...details })}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function argvExtensions(): string[] {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
if (process.argv[i] === '--extension' || process.argv[i] === '-e') {
|
||||
const candidate = process.argv[i + 1];
|
||||
if (candidate) result.push(resolve(candidate));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface SourceValidation {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
fragment?: string;
|
||||
}
|
||||
|
||||
function validateSources(): SourceValidation {
|
||||
const manifestPath = process.env['GATE0_SOURCE_MANIFEST'];
|
||||
if (!manifestPath) return { ok: true, reason: 'no-manifest-probe-disabled' };
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
||||
maxBytes: number;
|
||||
fragments: Array<{ path: string; sha256: string }>;
|
||||
};
|
||||
for (const fragment of manifest.fragments) {
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = statSync(fragment.path);
|
||||
} catch {
|
||||
return { ok: false, reason: 'missing', fragment: fragment.path };
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
return { ok: false, reason: 'not-regular-file', fragment: fragment.path };
|
||||
}
|
||||
if (fileStat.size > manifest.maxBytes) {
|
||||
return { ok: false, reason: 'oversize', fragment: fragment.path };
|
||||
}
|
||||
const bytes = readFileSync(fragment.path);
|
||||
if (sha(bytes) !== fragment.sha256) {
|
||||
return { ok: false, reason: 'hash-mismatch', fragment: fragment.path };
|
||||
}
|
||||
}
|
||||
return { ok: true, reason: 'all-fragments-valid' };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: `manifest-error:${error instanceof Error ? error.name : 'unknown'}` };
|
||||
}
|
||||
}
|
||||
|
||||
function brokerRequest(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const socketPath = process.env['GATE0_GENERATION_SOCKET'];
|
||||
if (!socketPath) return Promise.resolve({ skipped: true });
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let buffer = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
const newline = buffer.indexOf('\n');
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
resolvePromise(JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function markerPaths(value: unknown, path = '$'): string[] {
|
||||
const matches: string[] = [];
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes(CONTEXT_BLOCK)) matches.push(path);
|
||||
return matches;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => matches.push(...markerPaths(item, `${path}[${index}]`)));
|
||||
return matches;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
matches.push(...markerPaths(item, `${path}.${key}`));
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function assistantToolIds(message: unknown): string[] {
|
||||
if (!message || typeof message !== 'object') return [];
|
||||
const candidate = message as { role?: string; content?: unknown };
|
||||
if (candidate.role !== 'assistant' || !Array.isArray(candidate.content)) return [];
|
||||
return candidate.content
|
||||
.filter(
|
||||
(block): block is { type: 'toolCall'; id: string } =>
|
||||
Boolean(
|
||||
block &&
|
||||
typeof block === 'object' &&
|
||||
(block as { type?: string }).type === 'toolCall' &&
|
||||
typeof (block as { id?: unknown }).id === 'string',
|
||||
),
|
||||
)
|
||||
.map((block) => block.id);
|
||||
}
|
||||
|
||||
function assistantText(message: unknown): string {
|
||||
if (!message || typeof message !== 'object') return '';
|
||||
const candidate = message as { role?: string; content?: unknown };
|
||||
if (candidate.role !== 'assistant' || !Array.isArray(candidate.content)) return '';
|
||||
return candidate.content
|
||||
.filter(
|
||||
(block): block is { type: 'text'; text: string } =>
|
||||
Boolean(
|
||||
block &&
|
||||
typeof block === 'object' &&
|
||||
(block as { type?: string }).type === 'text' &&
|
||||
typeof (block as { text?: unknown }).text === 'string',
|
||||
),
|
||||
)
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export default function register(pi: ExtensionAPI) {
|
||||
const localProviderUrl = process.env['GATE0_LOCAL_PROVIDER_URL'];
|
||||
if (localProviderUrl) {
|
||||
pi.registerProvider('gate0-local', {
|
||||
baseUrl: localProviderUrl,
|
||||
apiKey: 'gate0-probe-not-a-secret',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{
|
||||
id: 'gate0-model',
|
||||
name: 'Gate0 deterministic local model',
|
||||
reasoning: false,
|
||||
input: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32_000,
|
||||
maxTokens: 1_024,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const extensions = argvExtensions();
|
||||
const lastPosition = extensions.length > 0 && extensions.at(-1) === SELF;
|
||||
interface RequestCycle {
|
||||
nonce: string;
|
||||
verified: boolean;
|
||||
sourceReason: string;
|
||||
}
|
||||
let buildingCycle: RequestCycle | undefined;
|
||||
const inFlightCycles: RequestCycle[] = [];
|
||||
const toolNonce = new Map<string, { nonce: string; verified: boolean; sourceReason: string }>();
|
||||
|
||||
pi.on('session_start', async (event) => {
|
||||
const broker = await brokerRequest({ action: 'lifecycle', phase: 'start', reason: event.reason });
|
||||
log('session_start', {
|
||||
reason: event.reason,
|
||||
extensions,
|
||||
self: SELF,
|
||||
lastPosition,
|
||||
gateState: lastPosition ? 'UNVERIFIED_READY' : 'CLOSED_NOT_LAST',
|
||||
broker,
|
||||
});
|
||||
});
|
||||
|
||||
pi.on('session_shutdown', async (event) => {
|
||||
const broker = await brokerRequest({ action: 'lifecycle', phase: 'shutdown', reason: event.reason });
|
||||
log('session_shutdown', { reason: event.reason, broker });
|
||||
});
|
||||
|
||||
pi.on('context', async (event) => {
|
||||
const validation = validateSources();
|
||||
buildingCycle = {
|
||||
nonce: randomUUID(),
|
||||
sourceReason: validation.reason,
|
||||
verified: lastPosition && validation.ok,
|
||||
};
|
||||
const inputJson = JSON.stringify(event.messages);
|
||||
const injected = {
|
||||
role: 'custom' as const,
|
||||
customType: 'gate0-context',
|
||||
content: CONTEXT_BLOCK,
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const outputMessages = buildingCycle.verified
|
||||
? [...event.messages, injected]
|
||||
: [...event.messages];
|
||||
const outputPrefix = outputMessages.slice(0, event.messages.length);
|
||||
const sourceBroker = validation.ok
|
||||
? { action: 'none', reason: 'source-valid' }
|
||||
: await brokerRequest({ action: 'source-invalid', reason: validation.reason });
|
||||
log('context_return', {
|
||||
requestNonce: buildingCycle.nonce,
|
||||
sourceValidation: validation,
|
||||
sourceBroker,
|
||||
lastPosition,
|
||||
promotion: false,
|
||||
injectionDecision: buildingCycle.verified ? 'ONE_ATOMIC_AGENT_MESSAGE' : 'REFUSED',
|
||||
inputCount: event.messages.length,
|
||||
outputCount: outputMessages.length,
|
||||
prefixHashBefore: sha(inputJson),
|
||||
prefixHashAfter: sha(JSON.stringify(outputPrefix)),
|
||||
prefixPreservedByReturn: sha(inputJson) === sha(JSON.stringify(outputPrefix)),
|
||||
blockLength: CONTEXT_BLOCK.length,
|
||||
blockSha256: sha(CONTEXT_BLOCK),
|
||||
});
|
||||
return { messages: outputMessages };
|
||||
});
|
||||
|
||||
pi.on('before_provider_request', async (event) => {
|
||||
const paths = markerPaths(event.payload);
|
||||
const cycle = buildingCycle;
|
||||
buildingCycle = undefined;
|
||||
if (cycle) inFlightCycles.push(cycle);
|
||||
log('before_provider_request', {
|
||||
requestNonce: cycle?.nonce,
|
||||
inFlightDepth: inFlightCycles.length,
|
||||
markerOccurrences: paths.length,
|
||||
markerPaths: paths,
|
||||
finalPayloadValid: Boolean(cycle?.verified && paths.length === 1),
|
||||
});
|
||||
});
|
||||
|
||||
pi.on('after_provider_response', async (event) => {
|
||||
const cycle = inFlightCycles[0];
|
||||
log('after_provider_response', {
|
||||
requestNonce: cycle?.nonce,
|
||||
status: event.status,
|
||||
assistantContentAvailableAtThisHook: false,
|
||||
timing: 'headers/status before stream consumption',
|
||||
});
|
||||
});
|
||||
|
||||
pi.on('message_end', async (event) => {
|
||||
const role = (event.message as { role?: string }).role;
|
||||
const ids = assistantToolIds(event.message);
|
||||
const text = assistantText(event.message);
|
||||
const cycle = role === 'assistant' ? inFlightCycles.shift() : undefined;
|
||||
if (ids.length > 0 && cycle) {
|
||||
for (const id of ids) {
|
||||
toolNonce.set(id, {
|
||||
nonce: cycle.nonce,
|
||||
verified: cycle.verified,
|
||||
sourceReason: cycle.sourceReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
log('message_end', {
|
||||
role,
|
||||
assistantContentObserved: role === 'assistant',
|
||||
requestNonce: cycle?.nonce,
|
||||
inFlightDepthAfter: inFlightCycles.length,
|
||||
toolCallIds: ids,
|
||||
nonceMappings: ids.map((id) => ({ toolCallId: id, requestNonce: cycle?.nonce })),
|
||||
exactContextBlockCopied: text.includes(CONTEXT_BLOCK),
|
||||
assistantTextSha256: text ? sha(text) : null,
|
||||
});
|
||||
});
|
||||
|
||||
pi.on('tool_call', async (event) => {
|
||||
const mapping = toolNonce.get(event.toolCallId);
|
||||
const allowed = Boolean(lastPosition && mapping?.verified);
|
||||
log('tool_call', {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
mapping: mapping ?? null,
|
||||
allowed,
|
||||
reason: !lastPosition
|
||||
? 'closed-not-last'
|
||||
: !mapping
|
||||
? 'unknown-tool-call-id'
|
||||
: !mapping.verified
|
||||
? `unverified-source:${mapping.sourceReason}`
|
||||
: 'exact-tool-call-id-mapped-to-verified-request-nonce',
|
||||
});
|
||||
if (!allowed) return { block: true, reason: 'Gate0 probe refused unverified tool batch' };
|
||||
});
|
||||
|
||||
pi.on('agent_settled', async () => {
|
||||
log('agent_settled', { retainedNonceMappingsBeforeClear: toolNonce.size });
|
||||
toolNonce.clear();
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: 'gate0_nonce_probe',
|
||||
label: 'Gate0 Nonce Probe',
|
||||
description: 'Gate0-only harmless tool used to prove toolCallId to request-nonce correlation.',
|
||||
parameters: Type.Object({ label: Type.String() }),
|
||||
async execute(toolCallId, params) {
|
||||
const broker = await brokerRequest({ action: 'promote-probe' });
|
||||
log('tool_execute', { toolCallId, label: params.label, broker });
|
||||
return {
|
||||
content: [{ type: 'text', text: `gate0_nonce_probe executed for ${params.label}` }],
|
||||
details: { harmless: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand('gate0-reload', {
|
||||
description: 'Trigger a real same-PID Pi extension/runtime reload.',
|
||||
handler: async (_args, ctx) => {
|
||||
log('reload_command_before');
|
||||
await ctx.reload();
|
||||
return;
|
||||
},
|
||||
});
|
||||
}
|
||||
450
docs/compaction-refresh/probes/pi_gate0_run.py
Normal file
450
docs/compaction-refresh/probes/pi_gate0_run.py
Normal file
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive real Pi 0.80.x RPC for P2/P3/P5/P6 runtime evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
BLOCK = "\n".join(
|
||||
[
|
||||
"GATE0_PI_ATOMIC_BEGIN",
|
||||
"segment-01=alpha-7e31",
|
||||
"segment-02=middle-9c42",
|
||||
"segment-03=omega-5b83",
|
||||
"GATE0_PI_ATOMIC_END",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def wait_path(path: Path, timeout: float = 20) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if path.exists():
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(f"timed out waiting for {path}")
|
||||
|
||||
|
||||
def socket_request(path: Path, payload: dict[str, object]) -> None:
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.connect(str(path))
|
||||
conn.sendall((json.dumps(payload) + "\n").encode())
|
||||
conn.makefile("r", encoding="utf-8").readline()
|
||||
conn.close()
|
||||
|
||||
|
||||
def jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||
|
||||
|
||||
class PiRpc:
|
||||
def __init__(self, command: list[str], cwd: Path, env: dict[str, str]):
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.events: queue.Queue[dict[str, Any]] = queue.Queue()
|
||||
self.raw_lines: list[str] = []
|
||||
self.stderr_lines: list[str] = []
|
||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||
threading.Thread(target=self._read_stderr, daemon=True).start()
|
||||
|
||||
def _read_stdout(self) -> None:
|
||||
assert self.process.stdout is not None
|
||||
for line in self.process.stdout:
|
||||
stripped = line.rstrip("\n")
|
||||
self.raw_lines.append(stripped)
|
||||
try:
|
||||
event = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self.events.put(event)
|
||||
|
||||
def _read_stderr(self) -> None:
|
||||
assert self.process.stderr is not None
|
||||
for line in self.process.stderr:
|
||||
self.stderr_lines.append(line.rstrip("\n"))
|
||||
|
||||
def send(self, payload: dict[str, object]) -> None:
|
||||
assert self.process.stdin is not None
|
||||
self.process.stdin.write(json.dumps(payload) + "\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
def wait(self, predicate: Callable[[dict[str, Any]], bool], description: str, timeout: float = 180) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if self.process.poll() is not None and self.events.empty():
|
||||
raise RuntimeError(
|
||||
f"Pi exited {self.process.returncode} while waiting for {description}: "
|
||||
+ " | ".join(self.stderr_lines[-5:])
|
||||
)
|
||||
try:
|
||||
event = self.events.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if predicate(event):
|
||||
return event
|
||||
raise TimeoutError(f"timed out waiting for {description}")
|
||||
|
||||
def response(self, request_id: str, timeout: float = 180) -> dict[str, Any]:
|
||||
return self.wait(
|
||||
lambda event: event.get("type") == "response" and event.get("id") == request_id,
|
||||
f"response {request_id}",
|
||||
timeout,
|
||||
)
|
||||
|
||||
def prompt_and_settle(self, request_id: str, message: str) -> None:
|
||||
self.send({"id": request_id, "type": "prompt", "message": message})
|
||||
response = self.response(request_id)
|
||||
if not response.get("success"):
|
||||
raise RuntimeError(f"prompt rejected: {response}")
|
||||
self.wait(lambda event: event.get("type") == "agent_settled", f"agent_settled {request_id}")
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
self.process.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
|
||||
def manifest(path: Path, fragment: Path, expected_hash: str, max_bytes: int = 64) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"maxBytes": max_bytes,
|
||||
"fragments": [{"path": str(fragment), "sha256": expected_hash}],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run_open(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str], list[str]]:
|
||||
workspace = root / "workspace"
|
||||
workspace.mkdir()
|
||||
session_dir = root / "sessions"
|
||||
session_dir.mkdir()
|
||||
pi_log = root / "pi-hooks.jsonl"
|
||||
generation_log = root / "generation.jsonl"
|
||||
generation_socket = root / "generation.sock"
|
||||
source_manifest = root / "manifest.json"
|
||||
valid_fragment = root / "fragment.md"
|
||||
valid_fragment.write_text("NORMATIVE-FRAGMENT-v1\n")
|
||||
expected = hashlib.sha256(valid_fragment.read_bytes()).hexdigest()
|
||||
manifest(source_manifest, valid_fragment, expected)
|
||||
|
||||
broker = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(HERE / "p3_generation_broker.py"),
|
||||
"--socket",
|
||||
str(generation_socket),
|
||||
"--log",
|
||||
str(generation_log),
|
||||
],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
wait_path(generation_socket)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"GATE0_PI_LOG": str(pi_log),
|
||||
"GATE0_GENERATION_SOCKET": str(generation_socket),
|
||||
"GATE0_SOURCE_MANIFEST": str(source_manifest),
|
||||
"GATE0_PI_CONTEXT_BLOCK": BLOCK,
|
||||
"MOSAIC_PI_FORCE_SKILLS": "",
|
||||
"PI_SKIP_VERSION_CHECK": "1",
|
||||
}
|
||||
)
|
||||
command = [
|
||||
"mosaic",
|
||||
"yolo",
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--session-dir",
|
||||
str(session_dir),
|
||||
"--no-extensions",
|
||||
"--no-context-files",
|
||||
"--no-prompt-templates",
|
||||
"--model",
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--extension",
|
||||
str(HERE / "pi_gate0_extension.ts"),
|
||||
]
|
||||
pi = PiRpc(command, workspace, env)
|
||||
try:
|
||||
pi.send({"id": "state-0", "type": "get_state"})
|
||||
state0 = pi.response("state-0")
|
||||
original_session = state0["data"]["sessionFile"]
|
||||
|
||||
pi.prompt_and_settle(
|
||||
"p2",
|
||||
"Call gate0_nonce_probe exactly once with label p2. After the tool finishes, copy the exact full GATE0_PI_ATOMIC_BEGIN through GATE0_PI_ATOMIC_END block from context, with no commentary.",
|
||||
)
|
||||
|
||||
# P3 immediately follows the valid P2 promotion so reload must revoke a
|
||||
# genuinely VERIFIED prior generation, not an already-invalid source run.
|
||||
pi.send({"id": "reload", "type": "prompt", "message": "/gate0-reload"})
|
||||
reload_response = pi.response("reload")
|
||||
if not reload_response.get("success"):
|
||||
raise RuntimeError(f"reload command failed: {reload_response}")
|
||||
|
||||
pi.send({"id": "clone", "type": "clone"})
|
||||
clone_response = pi.response("clone")
|
||||
if not clone_response.get("success") or clone_response.get("data", {}).get("cancelled"):
|
||||
raise RuntimeError(f"clone failed: {clone_response}")
|
||||
|
||||
pi.send({"id": "new", "type": "new_session"})
|
||||
new_response = pi.response("new")
|
||||
if not new_response.get("success") or new_response.get("data", {}).get("cancelled"):
|
||||
raise RuntimeError(f"new session failed: {new_response}")
|
||||
|
||||
pi.send(
|
||||
{
|
||||
"id": "resume",
|
||||
"type": "switch_session",
|
||||
"sessionPath": original_session,
|
||||
}
|
||||
)
|
||||
resume_response = pi.response("resume")
|
||||
if not resume_response.get("success") or resume_response.get("data", {}).get("cancelled"):
|
||||
raise RuntimeError(f"resume failed: {resume_response}")
|
||||
|
||||
# P5 missing fragment: action-time source validation must revoke/refuse.
|
||||
manifest(source_manifest, root / "absent-fragment.md", expected)
|
||||
pi.prompt_and_settle(
|
||||
"p5-missing",
|
||||
"Call gate0_nonce_probe exactly once with label p5-missing, then stop.",
|
||||
)
|
||||
|
||||
# P5 oversize fragment: expected hash is correct, size limit is not.
|
||||
oversize = root / "oversize.md"
|
||||
oversize.write_text("X" * 65)
|
||||
manifest(source_manifest, oversize, hashlib.sha256(oversize.read_bytes()).hexdigest(), 64)
|
||||
pi.prompt_and_settle(
|
||||
"p5-oversize",
|
||||
"Call gate0_nonce_probe exactly once with label p5-oversize, then stop.",
|
||||
)
|
||||
|
||||
# P5 hash mismatch: size is valid but bytes differ from expected.
|
||||
mismatch = root / "mismatch.md"
|
||||
mismatch.write_text("tampered\n")
|
||||
manifest(source_manifest, mismatch, expected, 64)
|
||||
pi.prompt_and_settle(
|
||||
"p5-hash",
|
||||
"Call gate0_nonce_probe exactly once with label p5-hash-mismatch, then stop.",
|
||||
)
|
||||
|
||||
time.sleep(1)
|
||||
return jsonl(pi_log), jsonl(generation_log), list(pi.raw_lines), list(pi.stderr_lines)
|
||||
finally:
|
||||
pi.close()
|
||||
try:
|
||||
socket_request(generation_socket, {"action": "shutdown-broker"})
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
broker.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
broker.kill()
|
||||
broker.wait()
|
||||
|
||||
|
||||
def run_closed(root: Path) -> list[dict[str, Any]]:
|
||||
workspace = root / "closed-workspace"
|
||||
workspace.mkdir()
|
||||
pi_log = root / "closed-hooks.jsonl"
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"GATE0_PI_LOG": str(pi_log),
|
||||
"MOSAIC_PI_FORCE_SKILLS": "",
|
||||
"PI_SKIP_VERSION_CHECK": "1",
|
||||
}
|
||||
)
|
||||
command = [
|
||||
"mosaic",
|
||||
"yolo",
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--no-context-files",
|
||||
"--no-prompt-templates",
|
||||
"--extension",
|
||||
str(HERE / "pi_gate0_extension.ts"),
|
||||
"--extension",
|
||||
str(HERE / "pi_later_extension.ts"),
|
||||
]
|
||||
pi = PiRpc(command, workspace, env)
|
||||
try:
|
||||
pi.send({"id": "closed-state", "type": "get_state"})
|
||||
pi.response("closed-state")
|
||||
time.sleep(0.5)
|
||||
return jsonl(pi_log)
|
||||
finally:
|
||||
pi.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="gate0-pi-") as temp:
|
||||
root = Path(temp)
|
||||
records, generations, rpc_lines, stderr_lines = run_open(root)
|
||||
closed = run_closed(root)
|
||||
|
||||
p2_message = next(
|
||||
r for r in records if r["event"] == "message_end" and r.get("nonceMappings")
|
||||
)
|
||||
p2_tool = next(r for r in records if r["event"] == "tool_call" and r.get("allowed"))
|
||||
mapped = p2_message["nonceMappings"][0]
|
||||
assert mapped["toolCallId"] == p2_tool["toolCallId"]
|
||||
assert mapped["requestNonce"] == p2_tool["mapping"]["nonce"]
|
||||
assert next(r for r in records if r["event"] == "session_start")["lastPosition"] is True
|
||||
assert next(r for r in closed if r["event"] == "session_start")["gateState"] == "CLOSED_NOT_LAST"
|
||||
reload_revoke = next(
|
||||
r
|
||||
for r in generations
|
||||
if r["event"] == "runtime_generation_bump"
|
||||
and r.get("reason") == "reload"
|
||||
and r.get("phase") == "shutdown"
|
||||
)
|
||||
assert reload_revoke["prior_lease"] == "VERIFIED"
|
||||
assert reload_revoke["prior_lease_revoked"] is True
|
||||
for reason in {"missing", "oversize", "hash-mismatch"}:
|
||||
assert any(
|
||||
r["event"] == "context_return"
|
||||
and r.get("sourceValidation", {}).get("reason") == reason
|
||||
and r.get("injectionDecision") == "REFUSED"
|
||||
and r.get("promotion") is False
|
||||
for r in records
|
||||
)
|
||||
assert any(
|
||||
r["event"] == "tool_call"
|
||||
and r.get("mapping", {}).get("sourceReason") == reason
|
||||
and r.get("allowed") is False
|
||||
for r in records
|
||||
)
|
||||
assert any(
|
||||
r["event"] == "message_end" and r.get("exactContextBlockCopied") is True
|
||||
for r in records
|
||||
)
|
||||
|
||||
print("$ python3 docs/compaction-refresh/probes/pi_gate0_run.py")
|
||||
print("machine_assertions=PASS")
|
||||
print("runtime_versions:")
|
||||
print(" " + subprocess.check_output(["pi", "--version"], text=True).strip())
|
||||
print(" " + subprocess.check_output(["mosaic", "--version"], text=True).strip())
|
||||
|
||||
print("\nP2_EVENT_ORDER_AND_NONCE_MAP:")
|
||||
for record in records:
|
||||
if record["seq"] <= 12 and record["event"] in {
|
||||
"after_provider_response",
|
||||
"message_end",
|
||||
"tool_call",
|
||||
"tool_execute",
|
||||
} and (
|
||||
record["event"] != "message_end"
|
||||
or record.get("role") == "assistant"
|
||||
):
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
|
||||
print("\nP2_LAST_OR_CLOSED:")
|
||||
print(json.dumps(next(r for r in records if r["event"] == "session_start"), sort_keys=True))
|
||||
print(json.dumps(next(r for r in closed if r["event"] == "session_start"), sort_keys=True))
|
||||
|
||||
print("\nP3_GENERATION_BROKER:")
|
||||
for record in generations:
|
||||
if record["event"] in {"probe_lease_promoted", "runtime_generation_bump"}:
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
|
||||
print("\nP5_SOURCE_INVALIDATION:")
|
||||
fault_reasons = {"missing", "oversize", "hash-mismatch"}
|
||||
emitted_context: set[str] = set()
|
||||
emitted_tool: set[str] = set()
|
||||
for record in records:
|
||||
source_reason = record.get("sourceValidation", {}).get("reason")
|
||||
if (
|
||||
record["event"] == "context_return"
|
||||
and source_reason in fault_reasons
|
||||
and source_reason not in emitted_context
|
||||
):
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
emitted_context.add(source_reason)
|
||||
mapping_reason = record.get("mapping", {}).get("sourceReason")
|
||||
if (
|
||||
record["event"] == "tool_call"
|
||||
and not record.get("allowed")
|
||||
and mapping_reason in fault_reasons
|
||||
and mapping_reason not in emitted_tool
|
||||
):
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
emitted_tool.add(mapping_reason)
|
||||
emitted_broker: set[str] = set()
|
||||
for record in generations:
|
||||
reason = record.get("source_reason")
|
||||
if record["event"] == "source_invalidation_revoke" and reason not in emitted_broker:
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
emitted_broker.add(str(reason))
|
||||
|
||||
print("\nP6_PI_CONTEXT_ATOMIC_OBSERVATION:")
|
||||
for record in records:
|
||||
include = (
|
||||
(record["event"] == "context_return" and record.get("injectionDecision") == "ONE_ATOMIC_AGENT_MESSAGE")
|
||||
or (record["event"] == "before_provider_request" and record.get("finalPayloadValid"))
|
||||
or (record["event"] == "message_end" and record.get("exactContextBlockCopied"))
|
||||
)
|
||||
if include and record["seq"] <= 12:
|
||||
print(json.dumps(record, sort_keys=True))
|
||||
|
||||
print("\nRPC_EVENT_COUNTS:")
|
||||
counts: dict[str, int] = {}
|
||||
for line in rpc_lines:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
key = str(event.get("type"))
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
print(json.dumps(counts, sort_keys=True))
|
||||
print("stderr_nonempty=" + str(bool(stderr_lines)))
|
||||
for line in stderr_lines[:10]:
|
||||
print("stderr: " + line[:500])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
docs/compaction-refresh/probes/pi_later_extension.ts
Normal file
8
docs/compaction-refresh/probes/pi_later_extension.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
// Deliberately loaded after pi_gate0_extension.ts. The Gate0 extension must
|
||||
// observe its argv position and remain CLOSED rather than claiming finality.
|
||||
export default function register(pi: ExtensionAPI) {
|
||||
pi.on('context', async (event) => ({ messages: [...event.messages] }));
|
||||
pi.on('before_provider_request', async () => undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user