Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d825e97c0 |
No files matched your search
@@ -1,3 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
.pytest_cache/
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Path Proof Receipt v0
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The smallest useful receipt is a **runtime-signed commitment to an action and its observed execution path**. It must be emitted by a trusted recorder at the model/tool boundary, not composed by the agent whose claim is being checked.
|
||||||
|
|
||||||
|
A self-reported model name or an unsigned transcript is provenance metadata, not proof.
|
||||||
|
|
||||||
|
## Receipt
|
||||||
|
|
||||||
|
The wire form is RFC 8785 canonical JSON. Hashes are lowercase SHA-256 hex. The signature covers the canonical JSON object with `attestation.sig` omitted.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 0,
|
||||||
|
"action": {
|
||||||
|
"kind": "gitea.issue.labels.replace",
|
||||||
|
"target": "stackchain/stackchain-lab-loop#3",
|
||||||
|
"request_sha256": "<sha256-of-exact-action-request>",
|
||||||
|
"result_sha256": "<sha256-of-exact-observed-result>"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"model": {
|
||||||
|
"id": "<provider/model-version>",
|
||||||
|
"invocation": "<provider-or-gateway-invocation-id>"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"count": 1,
|
||||||
|
"trace_sha256": "<sha256-of-canonical-ordered-tool-events>"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"bundle_sha256": "<sha256-of-effective-policy-bundle>"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"repo": "<immutable-repository-identity>",
|
||||||
|
"commit": "<full-commit-id>",
|
||||||
|
"entrypoint": "<path-or-image-digest>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"uri": "sha256:<content-addressed-evidence-bundle-digest>"
|
||||||
|
},
|
||||||
|
"attestation": {
|
||||||
|
"issuer": "<runtime-recorder-identity>",
|
||||||
|
"key_id": "<verification-key-id>",
|
||||||
|
"alg": "Ed25519",
|
||||||
|
"sig": "<base64url-signature>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools.trace_sha256` commits to a canonical ordered array of `{seq, tool, request_sha256, response_sha256}` records. The evidence bundle contains the exact request/result bytes, ordered tool records, effective policy bundle, model gateway record, and code checkout/image metadata. Secrets may be replaced by typed redaction commitments before hashing; the redaction manifest is part of the bundle.
|
||||||
|
|
||||||
|
## Why each field survives minimization
|
||||||
|
|
||||||
|
- `action` binds the receipt to the consequential effect rather than merely to an agent run.
|
||||||
|
- `model.id` plus `model.invocation` lets the recorder correlate the claim with gateway/provider evidence. A model string alone proves nothing.
|
||||||
|
- `tools.trace_sha256` commits to the complete ordered tool path without inflating the receipt.
|
||||||
|
- `policy.bundle_sha256` identifies the policy actually loaded, including system/developer/project rules after assembly—not just a policy version label.
|
||||||
|
- `code.commit` and `entrypoint` identify both source and the invoked path. For non-Git execution, `entrypoint` must be an immutable image/module digest.
|
||||||
|
- `evidence.uri` makes all commitments independently retrievable and rejects a receipt whose evidence was discarded.
|
||||||
|
- `attestation` establishes who observed these facts and detects mutation.
|
||||||
|
|
||||||
|
Removing any one of these leaves one requested claim—actual model, tool, policy, code path, or action—unbound or unverifiable.
|
||||||
|
|
||||||
|
## Recorder requirements
|
||||||
|
|
||||||
|
1. The recorder is outside the agent process and intercepts model calls, tool dispatch/results, policy assembly, and the final action result.
|
||||||
|
2. It derives model identity from the authenticated gateway/provider response, never from assistant text.
|
||||||
|
3. It hashes exact bytes with media type and canonicalization rules recorded in the evidence bundle.
|
||||||
|
4. It emits the receipt only after observing the consequential result and stores the evidence bundle under its digest.
|
||||||
|
5. Its signing key is hardware- or service-isolated from agent-controlled tools. Key metadata states issuer, validity interval, and revocation status.
|
||||||
|
|
||||||
|
## Verification algorithm
|
||||||
|
|
||||||
|
A verifier MUST:
|
||||||
|
|
||||||
|
1. Resolve a trusted key for `attestation.issuer` and `key_id`; reject revoked or out-of-validity keys.
|
||||||
|
2. Recreate RFC 8785 canonical bytes without `attestation.sig` and verify Ed25519.
|
||||||
|
3. Fetch `evidence.uri`, hash it, and require equality with the URI digest.
|
||||||
|
4. Recompute the action request/result, ordered tool trace, and effective policy hashes from evidence.
|
||||||
|
5. Verify the model invocation against authenticated gateway/provider evidence.
|
||||||
|
6. Resolve the immutable code identity and require that `entrypoint` belongs to that checkout/image.
|
||||||
|
7. Apply action-specific checks (for example, read Gitea state back and compare it with `result_sha256`).
|
||||||
|
|
||||||
|
Any missing evidence or unsupported identity yields **unverified**, not partial success.
|
||||||
|
|
||||||
|
## Threat boundary
|
||||||
|
|
||||||
|
This receipt proves only what the trusted recorder observed. It does not prove the provider ran the advertised weights unless the provider supplies a separately trusted model attestation, and it cannot survive compromise of both recorder and signing key. Those limitations must be reported explicitly rather than papered over with transcript hashes.
|
||||||
|
|
||||||
|
## Adversarial-review questions
|
||||||
|
|
||||||
|
1. Can the `model.invocation` be replayed or bound to a different request?
|
||||||
|
2. Is one `policy.bundle_sha256` sufficient to represent dynamic policy/tool authorization changes during the run?
|
||||||
|
3. What canonical evidence-bundle format permits selective disclosure without making redactions ambiguous?
|
||||||
|
4. Should code provenance require a signed build/SLSA subject instead of a Git commit for production actions?
|
||||||
|
5. Which action-specific freshness field (trusted timestamp, nonce, or transparency-log index) is the minimum needed to prevent receipt replay?
|
||||||
+2
-8
@@ -68,16 +68,11 @@ class Gitea:
|
|||||||
if missing:
|
if missing:
|
||||||
raise LoopError(f"Missing repository labels: {', '.join(missing)}")
|
raise LoopError(f"Missing repository labels: {', '.join(missing)}")
|
||||||
self.request(
|
self.request(
|
||||||
"PUT",
|
"PATCH",
|
||||||
f"/repos/{self.repo}/issues/{number}/labels",
|
f"/repos/{self.repo}/issues/{number}",
|
||||||
{"labels": [label_map[name] for name in sorted(names)]},
|
{"labels": [label_map[name] for name in sorted(names)]},
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_assignee(self, number: int, agent: str) -> None:
|
|
||||||
if agent not in AGENTS:
|
|
||||||
raise LoopError(f"Unsupported assignee: {agent}")
|
|
||||||
self.request("PATCH", f"/repos/{self.repo}/issues/{number}", {"assignee": agent})
|
|
||||||
|
|
||||||
def comment(self, number: int, body: str) -> None:
|
def comment(self, number: int, body: str) -> None:
|
||||||
if not body.strip():
|
if not body.strip():
|
||||||
raise LoopError("Refusing to post an empty comment")
|
raise LoopError("Refusing to post an empty comment")
|
||||||
@@ -168,7 +163,6 @@ def cmd_handoff(api: Gitea, agent: str, args: argparse.Namespace) -> int:
|
|||||||
body = read_body(args, f"Continue the bounded task in issue #{args.number}.")
|
body = read_body(args, f"Continue the bounded task in issue #{args.number}.")
|
||||||
api.comment(args.number, f"[HANDOFF] from={agent} to={args.to}\n\n{body.strip()}")
|
api.comment(args.number, f"[HANDOFF] from={agent} to={args.to}\n\n{body.strip()}")
|
||||||
api.set_labels(args.number, transition(names, agent=args.to, state="ready"))
|
api.set_labels(args.number, transition(names, agent=args.to, state="ready"))
|
||||||
api.set_assignee(args.number, args.to)
|
|
||||||
verified = api.issue(args.number)
|
verified = api.issue(args.number)
|
||||||
validate_owner(verified, args.to, "ready")
|
validate_owner(verified, args.to, "ready")
|
||||||
print(json.dumps({"status": "handed_off", "number": args.number, "from": agent, "to": args.to}))
|
print(json.dumps({"status": "handed_off", "number": args.number, "from": agent, "to": args.to}))
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Open exactly one Eastern-time Daily Slop Drop cycle on the recurring issue."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import datetime as dt
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
BASE = "https://forge.alexanderwhitestone.com/git/api/v1"
|
|
||||||
REPO = "stackchain/stackchain-lab-loop"
|
|
||||||
TITLE = "[Recurring] Daily AI Slop Drop collaboration"
|
|
||||||
|
|
||||||
|
|
||||||
def request(method: str, path: str, token: str, payload=None):
|
|
||||||
data = None if payload is None else json.dumps(payload).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
BASE + path,
|
|
||||||
data=data,
|
|
||||||
method=method,
|
|
||||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=20) as response:
|
|
||||||
raw = response.read()
|
|
||||||
return json.loads(raw) if raw else None
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raise RuntimeError(f"Gitea {method} failed with HTTP {exc.code}: {exc.read().decode()}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def cycle_marker(day: dt.date) -> str:
|
|
||||||
return f"[CYCLE {day.isoformat()}]"
|
|
||||||
|
|
||||||
|
|
||||||
def cycle_body(day: dt.date) -> str:
|
|
||||||
marker = cycle_marker(day)
|
|
||||||
return f"""{marker}
|
|
||||||
|
|
||||||
**Timmy pass:** propose and build one concrete experience that improves on the prior drop.
|
|
||||||
|
|
||||||
**Vincent pass:** challenge the concept, remove slop, and contribute one material improvement before final review.
|
|
||||||
|
|
||||||
**Required audience package:**
|
|
||||||
- a polished MP4 walkthrough that plays directly in chat;
|
|
||||||
- the primary still or poster;
|
|
||||||
- any interactive/source artifact;
|
|
||||||
- verification evidence and one sharp discussion question.
|
|
||||||
|
|
||||||
Post only decisions, evidence, artifacts, blockers, or explicit handoffs. No progress updates or acknowledgments."""
|
|
||||||
|
|
||||||
|
|
||||||
def find_issue(token: str):
|
|
||||||
rows = request("GET", f"/repos/{REPO}/issues?state=all&type=issues&limit=100", token)
|
|
||||||
for row in rows:
|
|
||||||
if row.get("title") == TITLE:
|
|
||||||
return row
|
|
||||||
raise RuntimeError(f"Recurring issue not found: {TITLE}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--date", help="Eastern date override, YYYY-MM-DD")
|
|
||||||
args = parser.parse_args()
|
|
||||||
token = os.environ.get("GITEA_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise RuntimeError("GITEA_TOKEN is required")
|
|
||||||
day = dt.date.fromisoformat(args.date) if args.date else dt.datetime.now(ZoneInfo("America/New_York")).date()
|
|
||||||
issue = find_issue(token)
|
|
||||||
comments = request("GET", f"/repos/{REPO}/issues/{issue['number']}/comments?limit=100", token)
|
|
||||||
marker = cycle_marker(day)
|
|
||||||
if any(marker in (comment.get("body") or "") for comment in comments):
|
|
||||||
print(json.dumps({"status": "already_open", "date": day.isoformat(), "issue": issue["number"]}))
|
|
||||||
return 0
|
|
||||||
labels = request("GET", f"/repos/{REPO}/labels?limit=100", token)
|
|
||||||
label_ids = {label["name"]: label["id"] for label in labels}
|
|
||||||
preserve = {
|
|
||||||
label["name"] for label in issue.get("labels", [])
|
|
||||||
if not label["name"].startswith("agent:") and not label["name"].startswith("state:")
|
|
||||||
}
|
|
||||||
desired = preserve | {"agent:timmy", "state:ready", "series:daily-slop", "cadence:daily"}
|
|
||||||
missing = desired - label_ids.keys()
|
|
||||||
if missing:
|
|
||||||
raise RuntimeError(f"Missing labels: {sorted(missing)}")
|
|
||||||
request("POST", f"/repos/{REPO}/issues/{issue['number']}/comments", token, {"body": cycle_body(day)})
|
|
||||||
request("PATCH", f"/repos/{REPO}/issues/{issue['number']}", token, {
|
|
||||||
"labels": [label_ids[name] for name in sorted(desired)],
|
|
||||||
"assignee": "timmy",
|
|
||||||
})
|
|
||||||
verified = request("GET", f"/repos/{REPO}/issues/{issue['number']}", token)
|
|
||||||
names = {label["name"] for label in verified.get("labels", [])}
|
|
||||||
if not {"agent:timmy", "state:ready", "series:daily-slop", "cadence:daily"}.issubset(names):
|
|
||||||
raise RuntimeError("Cycle label verification failed")
|
|
||||||
print(json.dumps({"status": "opened", "date": day.isoformat(), "issue": issue["number"], "url": issue["html_url"]}))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import datetime as dt
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from scripts.open_daily_cycle import cycle_body, cycle_marker
|
|
||||||
|
|
||||||
|
|
||||||
class DailyCycleTests(unittest.TestCase):
|
|
||||||
def test_marker_is_stable(self):
|
|
||||||
self.assertEqual(cycle_marker(dt.date(2026, 8, 7)), "[CYCLE 2026-08-07]")
|
|
||||||
|
|
||||||
def test_cycle_requires_video_and_both_agents(self):
|
|
||||||
body = cycle_body(dt.date(2026, 8, 7))
|
|
||||||
self.assertIn("Timmy pass", body)
|
|
||||||
self.assertIn("Vincent pass", body)
|
|
||||||
self.assertIn("MP4 walkthrough", body)
|
|
||||||
self.assertIn("No progress updates", body)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
+1
-27
@@ -1,35 +1,9 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from scripts.lab_loop import Gitea, LoopError, issue_label_names, transition, validate_owner
|
from scripts.lab_loop import LoopError, issue_label_names, transition, validate_owner
|
||||||
|
|
||||||
|
|
||||||
class RecordingGitea(Gitea):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__("https://example.invalid/api/v1", "owner/repo", "secret")
|
|
||||||
self.calls = []
|
|
||||||
|
|
||||||
def labels(self):
|
|
||||||
return {"agent:timmy": 1, "state:claimed": 2, "priority:P0": 3}
|
|
||||||
|
|
||||||
def request(self, method, path, payload=None):
|
|
||||||
self.calls.append((method, path, payload))
|
|
||||||
|
|
||||||
|
|
||||||
class LoopProtocolTests(unittest.TestCase):
|
class LoopProtocolTests(unittest.TestCase):
|
||||||
def test_set_labels_uses_gitea_replace_labels_endpoint(self):
|
|
||||||
api = RecordingGitea()
|
|
||||||
api.set_labels(7, {"agent:timmy", "state:claimed", "priority:P0"})
|
|
||||||
self.assertEqual(
|
|
||||||
api.calls,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"PUT",
|
|
||||||
"/repos/owner/repo/issues/7/labels",
|
|
||||||
{"labels": [1, 3, 2]},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_transition_replaces_agent_and_state_but_preserves_kind_and_priority(self):
|
def test_transition_replaces_agent_and_state_but_preserves_kind_and_priority(self):
|
||||||
labels = {"agent:timmy", "state:claimed", "priority:P1", "kind:build"}
|
labels = {"agent:timmy", "state:claimed", "priority:P1", "kind:build"}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
Reference in new issue
Block a user