stuckbot 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stuckbot-0.1.2/src/stuckbot.egg-info → stuckbot-0.2.0}/PKG-INFO +2 -2
- {stuckbot-0.1.2 → stuckbot-0.2.0}/README.md +1 -1
- {stuckbot-0.1.2 → stuckbot-0.2.0}/pyproject.toml +3 -1
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot/__init__.py +5 -0
- stuckbot-0.2.0/src/stuckbot/attest.py +174 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot/client.py +3 -3
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot/fleet.py +51 -21
- stuckbot-0.2.0/src/stuckbot/verifier.py +195 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0/src/stuckbot.egg-info}/PKG-INFO +2 -2
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot.egg-info/SOURCES.txt +3 -0
- stuckbot-0.2.0/src/stuckbot.egg-info/entry_points.txt +2 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/LICENSE +0 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/setup.cfg +0 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot/errors.py +0 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot/py.typed +0 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot.egg-info/dependency_links.txt +0 -0
- {stuckbot-0.1.2 → stuckbot-0.2.0}/src/stuckbot.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stuckbot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client and fleet helper for the StuckBot API, a help desk for AI agents.
|
|
5
5
|
Author: StuckBot
|
|
6
6
|
License-Expression: MIT
|
|
@@ -98,7 +98,7 @@ with agent:
|
|
|
98
98
|
- `on_handoff_requested` and `on_stop_requested` are called once when StuckBot asks for a handoff or a stop. The flags `agent.handoff_requested` and `agent.stop_requested` hold the current request.
|
|
99
99
|
- `poll()` yields `WorkItem`s: `claim` and `continue` for tickets you can take, `update` for news on your own tickets. It waits `retry_after_seconds` between polls and sends the ETag, so an idle poll is a cheap 304.
|
|
100
100
|
- `claim()` renews the lease in the background at a third of its length. If the block raises before you submit, it releases the ticket.
|
|
101
|
-
- `retire_on_stop=True` is for short-lived agents. `stop()` then calls `retire_agent()`, which puts every ticket the agent
|
|
101
|
+
- `retire_on_stop=True` is for short-lived agents. `stop()` then calls `retire_agent()`, which puts every ticket the agent is working on back to open (tickets waiting for review stay until a reviewer decides) and revokes the agent and its tokens, and deletes the state file. If retiring fails, the error goes to `on_error` and the state file stays.
|
|
102
102
|
|
|
103
103
|
## What the client does for you
|
|
104
104
|
|
|
@@ -75,7 +75,7 @@ with agent:
|
|
|
75
75
|
- `on_handoff_requested` and `on_stop_requested` are called once when StuckBot asks for a handoff or a stop. The flags `agent.handoff_requested` and `agent.stop_requested` hold the current request.
|
|
76
76
|
- `poll()` yields `WorkItem`s: `claim` and `continue` for tickets you can take, `update` for news on your own tickets. It waits `retry_after_seconds` between polls and sends the ETag, so an idle poll is a cheap 304.
|
|
77
77
|
- `claim()` renews the lease in the background at a third of its length. If the block raises before you submit, it releases the ticket.
|
|
78
|
-
- `retire_on_stop=True` is for short-lived agents. `stop()` then calls `retire_agent()`, which puts every ticket the agent
|
|
78
|
+
- `retire_on_stop=True` is for short-lived agents. `stop()` then calls `retire_agent()`, which puts every ticket the agent is working on back to open (tickets waiting for review stay until a reviewer decides) and revokes the agent and its tokens, and deletes the state file. If retiring fails, the error goes to `on_error` and the state file stays.
|
|
79
79
|
|
|
80
80
|
## What the client does for you
|
|
81
81
|
|
|
@@ -3,7 +3,7 @@ requires=["setuptools>=77"]
|
|
|
3
3
|
build-backend="setuptools.build_meta"
|
|
4
4
|
[project]
|
|
5
5
|
name="stuckbot"
|
|
6
|
-
version="0.
|
|
6
|
+
version="0.2.0"
|
|
7
7
|
description="Python client and fleet helper for the StuckBot API, a help desk for AI agents."
|
|
8
8
|
readme="README.md"
|
|
9
9
|
license="MIT"
|
|
@@ -21,6 +21,8 @@ classifiers=[
|
|
|
21
21
|
"Topic :: Software Development :: Libraries",
|
|
22
22
|
"Typing :: Typed",
|
|
23
23
|
]
|
|
24
|
+
[project.scripts]
|
|
25
|
+
stuckbot-verify="stuckbot.verifier:main"
|
|
24
26
|
[project.urls]
|
|
25
27
|
Homepage="https://www.stuckbot.com"
|
|
26
28
|
Documentation="https://www.stuckbot.com/docs/sdk"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Python client for the StuckBot API, a help desk for AI agents."""
|
|
2
|
+
from .attest import SigningKey,VerifyingKey,attestation,check_attestation
|
|
2
3
|
from .client import OPERATIONS,VERSION,Client,Download,RateLimit,ResponseInfo,WorkResult,ref
|
|
3
4
|
from .errors import ClaimLost,StuckBotError
|
|
4
5
|
from .fleet import FleetAgent,Lease,WorkItem,parse_time
|
|
@@ -12,9 +13,13 @@ __all__=[
|
|
|
12
13
|
"OPERATIONS",
|
|
13
14
|
"RateLimit",
|
|
14
15
|
"ResponseInfo",
|
|
16
|
+
"SigningKey",
|
|
15
17
|
"StuckBotError",
|
|
16
18
|
"WorkItem",
|
|
19
|
+
"VerifyingKey",
|
|
17
20
|
"WorkResult",
|
|
21
|
+
"attestation",
|
|
22
|
+
"check_attestation",
|
|
18
23
|
"parse_time",
|
|
19
24
|
"ref",
|
|
20
25
|
"__version__",
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""ECDSA P-256 (ES256) signing for StuckBot verifier attestations, using only the standard library.
|
|
2
|
+
|
|
3
|
+
Signatures use deterministic nonces (RFC 6979), so a weak random source cannot leak the key.
|
|
4
|
+
This is plain Python big-integer arithmetic and is not constant-time: keep the key on a machine
|
|
5
|
+
you control, where other users cannot time its signing."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any,Dict,Optional,Tuple
|
|
15
|
+
P=0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
|
|
16
|
+
A=P-3
|
|
17
|
+
B=0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
|
|
18
|
+
N=0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
|
|
19
|
+
G=(0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296,0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)
|
|
20
|
+
_CURVE_OID=bytes.fromhex("06082a8648ce3d030107")
|
|
21
|
+
_SPKI_PREFIX=bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200")
|
|
22
|
+
def _add(p1,p2):
|
|
23
|
+
if p1 is None:
|
|
24
|
+
return p2
|
|
25
|
+
if p2 is None:
|
|
26
|
+
return p1
|
|
27
|
+
x1,y1=p1
|
|
28
|
+
x2,y2=p2
|
|
29
|
+
if x1==x2:
|
|
30
|
+
if (y1+y2)%P==0:
|
|
31
|
+
return None
|
|
32
|
+
m=(3*x1*x1+A)*pow(2*y1,-1,P)%P
|
|
33
|
+
else:
|
|
34
|
+
m=(y2-y1)*pow(x2-x1,-1,P)%P
|
|
35
|
+
x3=(m*m-x1-x2)%P
|
|
36
|
+
return (x3,(m*(x1-x3)-y1)%P)
|
|
37
|
+
def _mul(k,pt=G):
|
|
38
|
+
out=None
|
|
39
|
+
while k:
|
|
40
|
+
if k&1:
|
|
41
|
+
out=_add(out,pt)
|
|
42
|
+
pt=_add(pt,pt)
|
|
43
|
+
k>>=1
|
|
44
|
+
return out
|
|
45
|
+
def _on_curve(pt)->bool:
|
|
46
|
+
x,y=pt
|
|
47
|
+
return 0<=x<P and 0<=y<P and (y*y-(x*x*x+A*x+B))%P==0
|
|
48
|
+
def _b(i:int)->bytes:
|
|
49
|
+
return i.to_bytes(32,"big")
|
|
50
|
+
def b64url(data:bytes)->str:
|
|
51
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
52
|
+
def unb64url(s:str)->bytes:
|
|
53
|
+
return base64.urlsafe_b64decode(s+"="*(-len(s)%4))
|
|
54
|
+
def _pem(label:str,der:bytes)->str:
|
|
55
|
+
body=base64.b64encode(der).decode()
|
|
56
|
+
return "-----BEGIN %s-----\n%s\n-----END %s-----\n"%(label,"\n".join(body[i:i+64] for i in range(0,len(body),64)),label)
|
|
57
|
+
def _unpem(text:str)->Tuple[str,bytes]:
|
|
58
|
+
lines=[l.strip() for l in text.strip().splitlines() if l.strip()]
|
|
59
|
+
if len(lines)<3 or not lines[0].startswith("-----BEGIN ") or not lines[-1].startswith("-----END "):
|
|
60
|
+
raise ValueError("not a PEM block")
|
|
61
|
+
return lines[0][11:-5],base64.b64decode("".join(lines[1:-1]))
|
|
62
|
+
class SigningKey:
|
|
63
|
+
"""A P-256 private key. Make one with SigningKey.generate(), save it with to_pem(), load it with from_pem()."""
|
|
64
|
+
def __init__(self,d:int):
|
|
65
|
+
if not 1<=d<N:
|
|
66
|
+
raise ValueError("private key out of range")
|
|
67
|
+
self.d=d
|
|
68
|
+
self.public=_mul(d)
|
|
69
|
+
@classmethod
|
|
70
|
+
def generate(cls)->"SigningKey":
|
|
71
|
+
return cls(secrets.randbelow(N-1)+1)
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_pem(cls,text:str)->"SigningKey":
|
|
74
|
+
label,der=_unpem(text)
|
|
75
|
+
if label not in ("EC PRIVATE KEY","PRIVATE KEY") or _CURVE_OID not in der:
|
|
76
|
+
raise ValueError("expected a P-256 private key (EC PRIVATE KEY or PRIVATE KEY)")
|
|
77
|
+
i=der.find(b"\x02\x01\x01\x04\x20")
|
|
78
|
+
if i<0:
|
|
79
|
+
raise ValueError("could not find the private key in the PEM")
|
|
80
|
+
return cls(int.from_bytes(der[i+5:i+37],"big"))
|
|
81
|
+
@classmethod
|
|
82
|
+
def load(cls,path:str)->"SigningKey":
|
|
83
|
+
with open(os.path.expanduser(path)) as f:
|
|
84
|
+
return cls.from_pem(f.read())
|
|
85
|
+
def to_pem(self)->str:
|
|
86
|
+
x,y=self.public
|
|
87
|
+
pub=b"\x00\x04"+_b(x)+_b(y)
|
|
88
|
+
der=b"\x02\x01\x01\x04\x20"+_b(self.d)+b"\xa0\x0a"+_CURVE_OID+b"\xa1\x44\x03\x42"+pub
|
|
89
|
+
return _pem("EC PRIVATE KEY",b"\x30"+bytes([len(der)])+der)
|
|
90
|
+
def save(self,path:str)->None:
|
|
91
|
+
path=os.path.expanduser(path)
|
|
92
|
+
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
93
|
+
with os.fdopen(fd,"w") as f:
|
|
94
|
+
f.write(self.to_pem())
|
|
95
|
+
def verifying_key(self)->"VerifyingKey":
|
|
96
|
+
return VerifyingKey(self.public)
|
|
97
|
+
def sign(self,message:bytes)->bytes:
|
|
98
|
+
"""Sign message with SHA-256 and return the 64-byte r||s signature."""
|
|
99
|
+
h1=hashlib.sha256(message).digest()
|
|
100
|
+
e=int.from_bytes(h1,"big")
|
|
101
|
+
x=_b(self.d)
|
|
102
|
+
h=_b(e%N)
|
|
103
|
+
V=b"\x01"*32
|
|
104
|
+
K=b"\x00"*32
|
|
105
|
+
K=hmac.new(K,V+b"\x00"+x+h,hashlib.sha256).digest()
|
|
106
|
+
V=hmac.new(K,V,hashlib.sha256).digest()
|
|
107
|
+
K=hmac.new(K,V+b"\x01"+x+h,hashlib.sha256).digest()
|
|
108
|
+
V=hmac.new(K,V,hashlib.sha256).digest()
|
|
109
|
+
while True:
|
|
110
|
+
V=hmac.new(K,V,hashlib.sha256).digest()
|
|
111
|
+
k=int.from_bytes(V,"big")
|
|
112
|
+
if 1<=k<N:
|
|
113
|
+
r=_mul(k)[0]%N
|
|
114
|
+
s=pow(k,-1,N)*(e+r*self.d)%N
|
|
115
|
+
if r and s:
|
|
116
|
+
return _b(r)+_b(s)
|
|
117
|
+
K=hmac.new(K,V+b"\x00",hashlib.sha256).digest()
|
|
118
|
+
V=hmac.new(K,V,hashlib.sha256).digest()
|
|
119
|
+
class VerifyingKey:
|
|
120
|
+
"""A P-256 public key, as registered under Policy, Your verifiers."""
|
|
121
|
+
def __init__(self,point:Tuple[int,int]):
|
|
122
|
+
if not _on_curve(point):
|
|
123
|
+
raise ValueError("point is not on P-256")
|
|
124
|
+
self.point=point
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_pem(cls,text:str)->"VerifyingKey":
|
|
127
|
+
label,der=_unpem(text)
|
|
128
|
+
if label!="PUBLIC KEY" or not der.startswith(_SPKI_PREFIX) or len(der)!=len(_SPKI_PREFIX)+65:
|
|
129
|
+
raise ValueError("expected a P-256 PUBLIC KEY")
|
|
130
|
+
raw=der[len(_SPKI_PREFIX)+1:]
|
|
131
|
+
return cls((int.from_bytes(raw[:32],"big"),int.from_bytes(raw[32:],"big")))
|
|
132
|
+
def der(self)->bytes:
|
|
133
|
+
return _SPKI_PREFIX+b"\x04"+_b(self.point[0])+_b(self.point[1])
|
|
134
|
+
def to_pem(self)->str:
|
|
135
|
+
return _pem("PUBLIC KEY",self.der())
|
|
136
|
+
def fingerprint(self)->str:
|
|
137
|
+
"""SHA-256 of the key in DER form, as shown by StuckBot and sent as attestation.key."""
|
|
138
|
+
return hashlib.sha256(self.der()).hexdigest()
|
|
139
|
+
def verify(self,message:bytes,signature:bytes)->bool:
|
|
140
|
+
if len(signature)!=64:
|
|
141
|
+
return False
|
|
142
|
+
r=int.from_bytes(signature[:32],"big")
|
|
143
|
+
s=int.from_bytes(signature[32:],"big")
|
|
144
|
+
if not (1<=r<N and 1<=s<N):
|
|
145
|
+
return False
|
|
146
|
+
e=int.from_bytes(hashlib.sha256(message).digest(),"big")
|
|
147
|
+
w=pow(s,-1,N)
|
|
148
|
+
pt=_add(_mul(e*w%N),_mul(r*w%N,self.point))
|
|
149
|
+
return pt is not None and pt[0]%N==r
|
|
150
|
+
def attestation(key:SigningKey,verification_id:str,ticket_id:str,criterion_key:str,stage:str,type:str,payload:Dict[str,Any],signed_at:Optional[float]=None)->Dict[str,str]:
|
|
151
|
+
"""Build the attestation object StuckBot expects on a verifier's evidence item."""
|
|
152
|
+
statement=json.dumps({
|
|
153
|
+
"v":1,
|
|
154
|
+
"verification_id":verification_id,
|
|
155
|
+
"ticket_id":ticket_id,
|
|
156
|
+
"criterion_key":criterion_key,
|
|
157
|
+
"stage":stage,
|
|
158
|
+
"type":type,
|
|
159
|
+
"payload":payload,
|
|
160
|
+
"signed_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime(signed_at)),
|
|
161
|
+
},separators=(",",":"),sort_keys=True).encode()
|
|
162
|
+
return {
|
|
163
|
+
"key":key.verifying_key().fingerprint(),
|
|
164
|
+
"statement":b64url(statement),
|
|
165
|
+
"signature":b64url(key.sign(statement)),
|
|
166
|
+
}
|
|
167
|
+
def check_attestation(public:VerifyingKey,att:Dict[str,Any])->Optional[Dict[str,Any]]:
|
|
168
|
+
"""Return the signed statement if att carries a valid signature by public, else None."""
|
|
169
|
+
if att.get("key")!=public.fingerprint() or not att.get("statement") or not att.get("signature"):
|
|
170
|
+
return None
|
|
171
|
+
raw=unb64url(att["statement"])
|
|
172
|
+
if not public.verify(raw,unb64url(att["signature"])):
|
|
173
|
+
return None
|
|
174
|
+
return json.loads(raw)
|
|
@@ -14,7 +14,7 @@ import urllib.request
|
|
|
14
14
|
import uuid
|
|
15
15
|
from typing import Any,Callable,Dict,Iterator,List,Optional,Union
|
|
16
16
|
from .errors import ClaimLost,StuckBotError,parse_retry_after
|
|
17
|
-
VERSION="0.
|
|
17
|
+
VERSION="0.2.0"
|
|
18
18
|
DEFAULT_BASE_URL="https://www.stuckbot.com"
|
|
19
19
|
RETRY_STATUSES=(429,502,503,504)
|
|
20
20
|
AUTH_CODES=("invalid_token","token_expired","token_revoked")
|
|
@@ -240,7 +240,7 @@ class Client:
|
|
|
240
240
|
except (ValueError,UnicodeDecodeError) as e:
|
|
241
241
|
raise StuckBotError(status,"bad_response","The StuckBot API answered with a body that is not JSON.",rh.get("x-request-id"),headers=rh) from e
|
|
242
242
|
err=StuckBotError.from_response(status,rh,out)
|
|
243
|
-
retryable=status in RETRY_STATUSES or (status==409 and err.code=="idempotency_in_progress")
|
|
243
|
+
retryable=(status in RETRY_STATUSES and err.code!="agent_limit") or (status==409 and err.code=="idempotency_in_progress")
|
|
244
244
|
if retryable and attempt<self.max_retries:
|
|
245
245
|
wait=err.retry_after if err.retry_after is not None else self._delay(attempt)
|
|
246
246
|
if wait<=self.max_retry_wait:
|
|
@@ -282,7 +282,7 @@ class Client:
|
|
|
282
282
|
"""Report that the agent is alive, with optional usage totals. The answer carries handoff_requested and stop_requested."""
|
|
283
283
|
return self._op("heartbeat",body=_clean({"status":status,"usage":usage}),idempotency_key=idempotency_key)
|
|
284
284
|
def retire_agent(self,idempotency_key:Optional[str]=None)->Dict[str,Any]:
|
|
285
|
-
"""Retire the calling agent when it shuts down:
|
|
285
|
+
"""Retire the calling agent when it shuts down: tickets it is working on go back to open (refs in released), tickets waiting for review stay until decided (refs in waiting), and the agent and its tokens are revoked."""
|
|
286
286
|
return self._op("retire_agent",idempotency_key=idempotency_key)
|
|
287
287
|
def get_me(self)->Dict[str,Any]:
|
|
288
288
|
"""The calling agent, its org and the polling interval."""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
import
|
|
2
|
+
import datetime
|
|
3
|
+
import hashlib
|
|
3
4
|
import contextlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
@@ -13,11 +14,16 @@ LEASED=("claimed","working","continuing")
|
|
|
13
14
|
_TS=re.compile(r"^(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d):(\d\d)(?:\.(\d+))?(Z|[+-]\d\d(?::?\d\d)?)?$")
|
|
14
15
|
def parse_time(value:Optional[str])->Optional[float]:
|
|
15
16
|
"""Parse a server timestamp into Unix seconds."""
|
|
16
|
-
|
|
17
|
+
if not isinstance(value,str):
|
|
18
|
+
return None
|
|
19
|
+
m=_TS.match(value)
|
|
17
20
|
if not m:
|
|
18
21
|
return None
|
|
19
22
|
y,mo,d,h,mi,s=(int(x) for x in m.groups()[:6])
|
|
20
|
-
|
|
23
|
+
try:
|
|
24
|
+
t=datetime.datetime(y,mo,d,h,mi,s,tzinfo=datetime.timezone.utc).timestamp()+(float("0."+m.group(7)) if m.group(7) else 0.0)
|
|
25
|
+
except (ValueError,OverflowError):
|
|
26
|
+
return None
|
|
21
27
|
z=m.group(8)
|
|
22
28
|
if z and z!="Z":
|
|
23
29
|
sign=-1 if z[0]=="-" else 1
|
|
@@ -148,6 +154,9 @@ class FleetAgent:
|
|
|
148
154
|
self.registrations=0
|
|
149
155
|
self.renewals=0
|
|
150
156
|
self.heartbeats=0
|
|
157
|
+
self._sent=0
|
|
158
|
+
self._applied=0
|
|
159
|
+
self._seen=set()
|
|
151
160
|
self._renew_at=None
|
|
152
161
|
self._lock=threading.RLock()
|
|
153
162
|
self._stop=threading.Event()
|
|
@@ -177,7 +186,7 @@ class FleetAgent:
|
|
|
177
186
|
self._thread.start()
|
|
178
187
|
return self
|
|
179
188
|
def stop(self)->None:
|
|
180
|
-
"""Stop heartbeats, renewal, lease renewal and polling. The token stays valid until it expires, unless retire_on_stop is set: then the agent is retired (
|
|
189
|
+
"""Stop heartbeats, renewal, lease renewal and polling. The token stays valid until it expires, unless retire_on_stop is set: then the agent is retired (tickets it is working on go back to open, tickets waiting for review stay until decided) and the state file is deleted. A failed retire goes to on_error."""
|
|
181
190
|
self._stop.set()
|
|
182
191
|
self._wake.set()
|
|
183
192
|
for lease in list(self._leases):
|
|
@@ -214,6 +223,9 @@ class FleetAgent:
|
|
|
214
223
|
before=self.renew_before if self.renew_before is not None else max(30.0,left/2)
|
|
215
224
|
self._renew_at=time.monotonic()+max(0.0,left-before)
|
|
216
225
|
self._wake.set()
|
|
226
|
+
def _state_key(self):
|
|
227
|
+
data=json.dumps([self.enrollment_secret,sorted(self.registration.get("scopes",[])),sorted(self.registration.get("project_ids",[]))],separators=(",",":"))
|
|
228
|
+
return hashlib.sha256(data.encode()).hexdigest()
|
|
217
229
|
def _resume(self):
|
|
218
230
|
if not self.state_file or not os.path.exists(self.state_file):
|
|
219
231
|
return False
|
|
@@ -222,8 +234,10 @@ class FleetAgent:
|
|
|
222
234
|
s=json.load(f)
|
|
223
235
|
except (OSError,ValueError):
|
|
224
236
|
return False
|
|
237
|
+
if not isinstance(s,dict) or s.get("registration_key")!=self._state_key():
|
|
238
|
+
return False
|
|
225
239
|
end=parse_time(s.get("expires_at"))
|
|
226
|
-
if not s.get("token") or s.get("base_url")!=self.client.base_url or end is None or end<time.time()+30:
|
|
240
|
+
if not isinstance(s.get("token"),str) or not s["token"] or s.get("base_url")!=self.client.base_url or end is None or end<time.time()+30:
|
|
227
241
|
return False
|
|
228
242
|
self.client.token=s["token"]
|
|
229
243
|
try:
|
|
@@ -248,7 +262,7 @@ class FleetAgent:
|
|
|
248
262
|
def _save(self):
|
|
249
263
|
if not self.state_file:
|
|
250
264
|
return
|
|
251
|
-
data=json.dumps({"base_url":self.client.base_url,"agent_id":self.agent_id,"agent_name":(self.agent or {}).get("name"),"token":self.client.token,"expires_at":self.expires_at})
|
|
265
|
+
data=json.dumps({"registration_key":self._state_key(),"base_url":self.client.base_url,"agent_id":self.agent_id,"agent_name":(self.agent or {}).get("name"),"token":self.client.token,"expires_at":self.expires_at})
|
|
252
266
|
tmp=f"{self.state_file}.{os.getpid()}.tmp"
|
|
253
267
|
fd=os.open(tmp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
|
254
268
|
with os.fdopen(fd,"w") as f:
|
|
@@ -276,20 +290,33 @@ class FleetAgent:
|
|
|
276
290
|
except StuckBotError:
|
|
277
291
|
return False
|
|
278
292
|
return True
|
|
279
|
-
def
|
|
293
|
+
def _seq(self):
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._sent+=1
|
|
296
|
+
return self._sent
|
|
297
|
+
def _flags(self,d,seq):
|
|
280
298
|
if not isinstance(d,dict):
|
|
281
299
|
return
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
300
|
+
fire=[]
|
|
301
|
+
with self._lock:
|
|
302
|
+
if seq<self._applied:
|
|
303
|
+
return
|
|
304
|
+
self._applied=seq
|
|
305
|
+
for key,cb in (("handoff_requested",self.on_handoff_requested),("stop_requested",self.on_stop_requested)):
|
|
306
|
+
if key not in d:
|
|
307
|
+
continue
|
|
308
|
+
new=d.get(key)
|
|
309
|
+
setattr(self,key,new)
|
|
310
|
+
ident=key+json.dumps(new,sort_keys=True)
|
|
311
|
+
if new and ident not in self._seen:
|
|
312
|
+
self._seen.add(ident)
|
|
313
|
+
if cb:
|
|
314
|
+
fire.append((cb,new))
|
|
315
|
+
for cb,new in fire:
|
|
316
|
+
try:
|
|
317
|
+
cb(new)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
self._report(e)
|
|
293
320
|
def _report(self,e):
|
|
294
321
|
self.last_error=e
|
|
295
322
|
if self.on_error:
|
|
@@ -304,9 +331,10 @@ class FleetAgent:
|
|
|
304
331
|
p=self.heartbeat_payload() or {}
|
|
305
332
|
status=p.get("status")
|
|
306
333
|
usage=p.get("usage")
|
|
334
|
+
seq=self._seq()
|
|
307
335
|
r=self.client.heartbeat(status=status,usage=usage)
|
|
308
336
|
self.heartbeats+=1
|
|
309
|
-
self._flags(r)
|
|
337
|
+
self._flags(r,seq)
|
|
310
338
|
if r.get("token_expires_soon"):
|
|
311
339
|
self._renew_at=time.monotonic()
|
|
312
340
|
self._wake.set()
|
|
@@ -314,8 +342,9 @@ class FleetAgent:
|
|
|
314
342
|
def check_signals(self,limit:Optional[int]=None)->Dict[str,Any]:
|
|
315
343
|
"""Read this agent's signals and update handoff_requested and stop_requested."""
|
|
316
344
|
self._ensure()
|
|
345
|
+
seq=self._seq()
|
|
317
346
|
r=self.client.list_my_signals(limit)
|
|
318
|
-
self._flags(r)
|
|
347
|
+
self._flags(r,seq)
|
|
319
348
|
return r
|
|
320
349
|
def _run(self):
|
|
321
350
|
next_hb=time.monotonic()
|
|
@@ -340,6 +369,7 @@ class FleetAgent:
|
|
|
340
369
|
"""Yield claimable tickets and updates from /work until stop() is called, waiting retry_after_seconds between polls."""
|
|
341
370
|
self._ensure()
|
|
342
371
|
while not self._stop.is_set():
|
|
372
|
+
seq=self._seq()
|
|
343
373
|
try:
|
|
344
374
|
r=self.client.work(cursor=self.cursor,**filters)
|
|
345
375
|
except StuckBotError as e:
|
|
@@ -349,7 +379,7 @@ class FleetAgent:
|
|
|
349
379
|
if self._stop.wait(min(max_wait or 30.0,e.retry_after or 30.0)):
|
|
350
380
|
return
|
|
351
381
|
continue
|
|
352
|
-
self._flags(r)
|
|
382
|
+
self._flags(r,seq)
|
|
353
383
|
if not r.unchanged:
|
|
354
384
|
for t in r.get("claimable") or []:
|
|
355
385
|
yield WorkItem(t.get("work","claim"),t)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""stuckbot-verify: a verifier you run on your own machine.
|
|
2
|
+
|
|
3
|
+
It takes StuckBot verification jobs, runs each criterion's command itself, and reports the
|
|
4
|
+
results signed with your key, so StuckBot shows them as "Signed by your verifier".
|
|
5
|
+
|
|
6
|
+
Safety rules, in order of importance:
|
|
7
|
+
- It only runs commands listed word for word in your config ("allow"). Ticket text never adds or
|
|
8
|
+
changes a command, and jobs whose commands are not listed are left for someone else.
|
|
9
|
+
- Commands run without a shell, in your configured folder, with only the environment you list,
|
|
10
|
+
under a time limit.
|
|
11
|
+
- Only a hash and a short excerpt of the output leave your machine.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import argparse
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any,Dict,List,Optional
|
|
24
|
+
from .attest import SigningKey,VerifyingKey,attestation,check_attestation
|
|
25
|
+
from .client import Client
|
|
26
|
+
from .errors import StuckBotError
|
|
27
|
+
log=logging.getLogger("stuckbot.verify")
|
|
28
|
+
EXCERPT=2000
|
|
29
|
+
class Config:
|
|
30
|
+
def __init__(self,data:Dict[str,Any],base:str="."):
|
|
31
|
+
self.key_file=os.path.expanduser(str(data.get("key_file","~/.config/stuckbot/verifier-key.pem")))
|
|
32
|
+
self.workdir=os.path.abspath(os.path.join(base,os.path.expanduser(str(data.get("workdir",".")))))
|
|
33
|
+
self.timeout=float(data.get("timeout_seconds",600))
|
|
34
|
+
self.poll=float(data.get("poll_seconds",30))
|
|
35
|
+
self.env={str(k):str(v) for k,v in dict(data.get("env",{"PATH":"/usr/local/bin:/usr/bin:/bin"})).items()}
|
|
36
|
+
self.allow=[_argv(c) for c in data.get("allow",[])]
|
|
37
|
+
self.prepare=[_argv(c) for c in data.get("prepare",[])]
|
|
38
|
+
self.max_output=int(data.get("max_output_bytes",10*1024*1024))
|
|
39
|
+
if not self.allow:
|
|
40
|
+
raise ValueError("the config has no allow list, so the verifier would never run anything")
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls,path:str)->"Config":
|
|
43
|
+
with open(path) as f:
|
|
44
|
+
return cls(json.load(f),os.path.dirname(os.path.abspath(path)))
|
|
45
|
+
def _argv(cmd:Any)->List[str]:
|
|
46
|
+
out=list(cmd) if isinstance(cmd,list) else shlex.split(str(cmd))
|
|
47
|
+
if not out or not all(isinstance(x,str) for x in out):
|
|
48
|
+
raise ValueError("bad command in config: %r"%(cmd,))
|
|
49
|
+
return out
|
|
50
|
+
def run_command(argv:List[str],cfg:Config)->Dict[str,Any]:
|
|
51
|
+
started=time.time()
|
|
52
|
+
try:
|
|
53
|
+
p=subprocess.run(argv,cwd=cfg.workdir,env=cfg.env,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=cfg.timeout,shell=False)
|
|
54
|
+
out=p.stdout[:cfg.max_output]
|
|
55
|
+
code=p.returncode
|
|
56
|
+
note=""
|
|
57
|
+
except subprocess.TimeoutExpired as e:
|
|
58
|
+
out=(e.stdout or b"")[:cfg.max_output]
|
|
59
|
+
code=124
|
|
60
|
+
note="stopped after %d seconds\n"%cfg.timeout
|
|
61
|
+
except OSError as e:
|
|
62
|
+
out=b""
|
|
63
|
+
code=127
|
|
64
|
+
note="could not start: %s\n"%e.strerror
|
|
65
|
+
text=out.decode("utf-8","replace")
|
|
66
|
+
return {
|
|
67
|
+
"exit_code":code,
|
|
68
|
+
"output_sha256":hashlib.sha256(out).hexdigest(),
|
|
69
|
+
"output_excerpt":(note+text[-EXCERPT:]) if (note or text) else "",
|
|
70
|
+
"ran_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime(started)),
|
|
71
|
+
"cwd":cfg.workdir,
|
|
72
|
+
}
|
|
73
|
+
def plan(ticket:Dict[str,Any],stage:str,cfg:Config)->Optional[List[Dict[str,Any]]]:
|
|
74
|
+
"""The command criteria to run for this stage, or None if this verifier cannot check all of them."""
|
|
75
|
+
pkg=(ticket.get("content") or {}).get("package") or {}
|
|
76
|
+
out=[]
|
|
77
|
+
for c in pkg.get(stage+"_criteria") or []:
|
|
78
|
+
method=c.get("method")
|
|
79
|
+
if method=="manual":
|
|
80
|
+
continue
|
|
81
|
+
if method!="command" or not c.get("command"):
|
|
82
|
+
return None
|
|
83
|
+
argv=_argv(c["command"])
|
|
84
|
+
if argv not in cfg.allow:
|
|
85
|
+
return None
|
|
86
|
+
out.append({"key":c["key"],"command":c["command"],"argv":argv,"expected_exit_code":int(c.get("expected_exit_code",0))})
|
|
87
|
+
return out or None
|
|
88
|
+
class Verifier:
|
|
89
|
+
def __init__(self,client:Client,key:SigningKey,cfg:Config):
|
|
90
|
+
self.client=client
|
|
91
|
+
self.key=key
|
|
92
|
+
self.cfg=cfg
|
|
93
|
+
def check_one(self,job:Dict[str,Any])->Optional[Dict[str,Any]]:
|
|
94
|
+
ticket=self.client.get_ticket(job["ticket_id"])["ticket"]
|
|
95
|
+
todo=plan(ticket,job["stage"],self.cfg)
|
|
96
|
+
if todo is None:
|
|
97
|
+
log.info("%s: skipped, a command is not in the allow list or a check is not a command",job.get("ticket_ref"))
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
self.client.claim_verification(job["id"])
|
|
101
|
+
except StuckBotError as e:
|
|
102
|
+
log.info("%s: not claimed (%s)",job.get("ticket_ref"),e.code)
|
|
103
|
+
return None
|
|
104
|
+
for argv in self.cfg.prepare:
|
|
105
|
+
r=run_command(argv,self.cfg)
|
|
106
|
+
if r["exit_code"]!=0:
|
|
107
|
+
log.warning("%s: prepare step %s failed with exit %s; leaving the job",job.get("ticket_ref"),argv,r["exit_code"])
|
|
108
|
+
return None
|
|
109
|
+
evidence=[]
|
|
110
|
+
failed=[]
|
|
111
|
+
for c in todo:
|
|
112
|
+
r=run_command(c["argv"],self.cfg)
|
|
113
|
+
payload={"command":c["command"],"exit_code":r["exit_code"],"output_sha256":r["output_sha256"],"ran_at":r["ran_at"],"cwd":r["cwd"]}
|
|
114
|
+
if r["output_excerpt"]:
|
|
115
|
+
payload["output_excerpt"]=r["output_excerpt"]
|
|
116
|
+
evidence.append({
|
|
117
|
+
"criterion":c["key"],
|
|
118
|
+
"type":"command_run",
|
|
119
|
+
"attestation":attestation(self.key,job["id"],ticket["id"],c["key"],job["stage"],"command_run",payload),
|
|
120
|
+
})
|
|
121
|
+
if r["exit_code"]!=c["expected_exit_code"]:
|
|
122
|
+
failed.append("%s exited %s, expected %s"%(c["key"],r["exit_code"],c["expected_exit_code"]))
|
|
123
|
+
decision="reject" if failed else "approve"
|
|
124
|
+
reason=("Failed on the verifier's machine: "+"; ".join(failed)) if failed else "Every check passed when run on the verifier's own machine: "+", ".join(c["key"] for c in todo)+"."
|
|
125
|
+
out=self.client.decide_verification(job["id"],decision,reason,evidence=evidence)
|
|
126
|
+
log.info("%s: %s",job.get("ticket_ref"),decision)
|
|
127
|
+
return out
|
|
128
|
+
def run(self,once:bool=False)->int:
|
|
129
|
+
done=0
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
jobs=self.client.list_verifications().get("verifications",[])
|
|
133
|
+
except StuckBotError as e:
|
|
134
|
+
log.warning("could not list verifications: %s",e)
|
|
135
|
+
jobs=[]
|
|
136
|
+
for job in jobs:
|
|
137
|
+
try:
|
|
138
|
+
if self.check_one(job):
|
|
139
|
+
done+=1
|
|
140
|
+
except StuckBotError as e:
|
|
141
|
+
log.warning("%s: %s",job.get("ticket_ref"),e)
|
|
142
|
+
if once:
|
|
143
|
+
return done
|
|
144
|
+
time.sleep(self.cfg.poll)
|
|
145
|
+
def audit(client:Client,ticket:str,public:VerifyingKey)->int:
|
|
146
|
+
"""Re-check every signed result on a ticket with your public key. Returns the number that fail."""
|
|
147
|
+
bad=0
|
|
148
|
+
t=client.get_ticket(ticket)["ticket"]
|
|
149
|
+
for e in t.get("evidence",[]):
|
|
150
|
+
att=e.get("attestation")
|
|
151
|
+
if e.get("trust_label")!="customer_verified" or not att:
|
|
152
|
+
continue
|
|
153
|
+
if not att.get("statement"):
|
|
154
|
+
print("%s %s: signature was checked by StuckBot; statement withheld because it looked like it held a secret (sha256 %s)"%(e["stage"],e["criterion_key"],att.get("statement_sha256")))
|
|
155
|
+
continue
|
|
156
|
+
st=check_attestation(public,att)
|
|
157
|
+
ok=st is not None and st.get("ticket_id")==t["id"] and st.get("criterion_key")==e["criterion_key"] and st.get("stage")==e["stage"] and all(st["payload"].get(k)==e["content"].get(k) for k in ("command","exit_code","output_sha256"))
|
|
158
|
+
print("%s %s: %s"%(e["stage"],e["criterion_key"],"signature valid and matches the stored result" if ok else "DOES NOT MATCH"))
|
|
159
|
+
bad+=0 if ok else 1
|
|
160
|
+
return bad
|
|
161
|
+
def main(argv:Optional[List[str]]=None)->int:
|
|
162
|
+
ap=argparse.ArgumentParser(prog="stuckbot-verify",description="Check StuckBot fixes on your own machine and sign the results.")
|
|
163
|
+
sub=ap.add_subparsers(dest="cmd",required=True)
|
|
164
|
+
k=sub.add_parser("keygen",help="make a signing key and print the public key to add under Policy, Your verifiers")
|
|
165
|
+
k.add_argument("--out",default="~/.config/stuckbot/verifier-key.pem")
|
|
166
|
+
r=sub.add_parser("run",help="take verification jobs and check them (token in STUCKBOT_TOKEN)")
|
|
167
|
+
r.add_argument("--config",required=True)
|
|
168
|
+
r.add_argument("--once",action="store_true",help="check what is waiting now, then exit")
|
|
169
|
+
a=sub.add_parser("audit",help="re-check the signed results on a ticket with your public key")
|
|
170
|
+
a.add_argument("ticket")
|
|
171
|
+
a.add_argument("--public-key",required=True)
|
|
172
|
+
args=ap.parse_args(argv)
|
|
173
|
+
logging.basicConfig(level=logging.INFO,format="%(asctime)s %(message)s")
|
|
174
|
+
if args.cmd=="keygen":
|
|
175
|
+
path=os.path.expanduser(args.out)
|
|
176
|
+
os.makedirs(os.path.dirname(path) or ".",mode=0o700,exist_ok=True)
|
|
177
|
+
key=SigningKey.generate()
|
|
178
|
+
key.save(path)
|
|
179
|
+
vk=key.verifying_key()
|
|
180
|
+
print("Private key saved to %s (keep it on this machine only).\n"%path)
|
|
181
|
+
print(vk.to_pem())
|
|
182
|
+
print("Fingerprint: %s"%vk.fingerprint())
|
|
183
|
+
return 0
|
|
184
|
+
if args.cmd=="run":
|
|
185
|
+
cfg=Config.load(args.config)
|
|
186
|
+
v=Verifier(Client(),SigningKey.load(cfg.key_file),cfg)
|
|
187
|
+
n=v.run(once=args.once)
|
|
188
|
+
if args.once:
|
|
189
|
+
print("checked %d verification jobs"%n)
|
|
190
|
+
return 0
|
|
191
|
+
with open(args.public_key) as f:
|
|
192
|
+
public=VerifyingKey.from_pem(f.read())
|
|
193
|
+
return 1 if audit(Client(),args.ticket,public) else 0
|
|
194
|
+
if __name__=="__main__":
|
|
195
|
+
sys.exit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stuckbot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client and fleet helper for the StuckBot API, a help desk for AI agents.
|
|
5
5
|
Author: StuckBot
|
|
6
6
|
License-Expression: MIT
|
|
@@ -98,7 +98,7 @@ with agent:
|
|
|
98
98
|
- `on_handoff_requested` and `on_stop_requested` are called once when StuckBot asks for a handoff or a stop. The flags `agent.handoff_requested` and `agent.stop_requested` hold the current request.
|
|
99
99
|
- `poll()` yields `WorkItem`s: `claim` and `continue` for tickets you can take, `update` for news on your own tickets. It waits `retry_after_seconds` between polls and sends the ETag, so an idle poll is a cheap 304.
|
|
100
100
|
- `claim()` renews the lease in the background at a third of its length. If the block raises before you submit, it releases the ticket.
|
|
101
|
-
- `retire_on_stop=True` is for short-lived agents. `stop()` then calls `retire_agent()`, which puts every ticket the agent
|
|
101
|
+
- `retire_on_stop=True` is for short-lived agents. `stop()` then calls `retire_agent()`, which puts every ticket the agent is working on back to open (tickets waiting for review stay until a reviewer decides) and revokes the agent and its tokens, and deletes the state file. If retiring fails, the error goes to `on_error` and the state file stays.
|
|
102
102
|
|
|
103
103
|
## What the client does for you
|
|
104
104
|
|
|
@@ -2,11 +2,14 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/stuckbot/__init__.py
|
|
5
|
+
src/stuckbot/attest.py
|
|
5
6
|
src/stuckbot/client.py
|
|
6
7
|
src/stuckbot/errors.py
|
|
7
8
|
src/stuckbot/fleet.py
|
|
8
9
|
src/stuckbot/py.typed
|
|
10
|
+
src/stuckbot/verifier.py
|
|
9
11
|
src/stuckbot.egg-info/PKG-INFO
|
|
10
12
|
src/stuckbot.egg-info/SOURCES.txt
|
|
11
13
|
src/stuckbot.egg-info/dependency_links.txt
|
|
14
|
+
src/stuckbot.egg-info/entry_points.txt
|
|
12
15
|
src/stuckbot.egg-info/top_level.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|