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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stuckbot
3
- Version: 0.1.2
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 holds back to open 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.
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 holds back to open 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.
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.1.2"
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.1.2"
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: its tickets go back to open (refs in released) and the agent and its tokens are revoked."""
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 calendar
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
- m=_TS.match(value or "")
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
- t=calendar.timegm((y,mo,d,h,mi,s,0,0,0))+(float("0."+m.group(7)) if m.group(7) else 0.0)
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 (its tickets go back to open) and the state file is deleted. A failed retire goes to on_error."""
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 _flags(self,d):
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
- for key,cb in (("handoff_requested",self.on_handoff_requested),("stop_requested",self.on_stop_requested)):
283
- if key not in d:
284
- continue
285
- new=d.get(key)
286
- old=getattr(self,key)
287
- setattr(self,key,new)
288
- if new and not old and cb:
289
- try:
290
- cb(new)
291
- except Exception as e:
292
- self._report(e)
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.1.2
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 holds back to open 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.
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
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stuckbot-verify = stuckbot.verifier:main
File without changes
File without changes
File without changes