@topolo/cli 0.13.1 → 0.13.3
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.
- package/agent-packs/README.md +68 -2
- package/agent-packs/coverage_changed.py +122 -0
- package/agent-packs/src/topolo_agent_pack/approvals.py +134 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +42 -4
- package/agent-packs/src/topolo_agent_pack/execution.py +15 -5
- package/agent-packs/src/topolo_agent_pack/live.py +186 -5
- package/agent-packs/src/topolo_agent_pack/permissions.py +167 -0
- package/agent-packs/src/topolo_agent_pack/policy.py +11 -6
- package/agent-packs/src/topolo_agent_pack/topolo.py +12 -0
- package/dist/{claude-pack-backend-IDQQX3DG.js → claude-pack-backend-5DWMIDRA.js} +442 -10
- package/dist/index.js +242 -68
- package/package.json +5 -5
|
@@ -24,7 +24,16 @@ import urllib.parse
|
|
|
24
24
|
import uuid
|
|
25
25
|
|
|
26
26
|
from .storage import Library,PackError,atomic_json,canonical,read_json
|
|
27
|
-
from .execution import load_gate, qualify_protocol
|
|
27
|
+
from .execution import ExecutionGate, load_gate, qualify_protocol
|
|
28
|
+
from .approvals import ApprovalRouter
|
|
29
|
+
|
|
30
|
+
def permission_settings(agent, current, consent):
|
|
31
|
+
from .policy import settings
|
|
32
|
+
wanted=settings(agent,'codex',consent)['nativeSettings']
|
|
33
|
+
# Permission transitions use canonical defaults, never old applied grants.
|
|
34
|
+
# Keep independently qualified models and effort on the owned session.
|
|
35
|
+
for field in ['model','model_reasoning_effort']:wanted[field]=current[field]
|
|
36
|
+
return wanted
|
|
28
37
|
|
|
29
38
|
def socket_path(lib):
|
|
30
39
|
directory=pathlib.Path('/tmp')/('topolo-agent-pack-'+hashlib.sha256(str(lib.root).encode()).hexdigest()[:16])
|
|
@@ -32,14 +41,14 @@ def socket_path(lib):
|
|
|
32
41
|
directory.mkdir(mode=0o700,exist_ok=True)
|
|
33
42
|
return directory/'control.sock'
|
|
34
43
|
|
|
35
|
-
def request(lib,operation):
|
|
44
|
+
def request(lib,operation,params=None):
|
|
36
45
|
record=read_json(lib.root/'live-bridge.json',{})
|
|
37
46
|
if not record: raise PackError('This desktop profile has no live bridge. Open it through topolo-agent-pack first.')
|
|
38
47
|
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as connection:
|
|
39
|
-
connection.settimeout(3 if operation=='status' else 20)
|
|
48
|
+
connection.settimeout(3 if operation=='status' else 60 if operation=='permissions-configure' else 20)
|
|
40
49
|
try: connection.connect(record['socket'])
|
|
41
50
|
except OSError as exc: raise PackError('Managed live bridge is unavailable: '+str(exc)) from exc
|
|
42
|
-
connection.sendall(canonical({'token':record['token'],'operation':operation})+b'\n')
|
|
51
|
+
connection.sendall(canonical({'token':record['token'],'operation':operation,**({'params':params} if params is not None else {})})+b'\n')
|
|
43
52
|
with connection.makefile('rb') as stream:
|
|
44
53
|
line=stream.readline(1024*1024)
|
|
45
54
|
if not line: raise PackError('Live bridge closed without a reply')
|
|
@@ -71,12 +80,17 @@ class BackendProxy:
|
|
|
71
80
|
self.token=secrets.token_hex(32);self.path=socket_path(lib)
|
|
72
81
|
self.execution=None;self.catalog_ready=asyncio.Event();self.catalog_id='topolo-catalog-'+uuid.uuid4().hex
|
|
73
82
|
self.catalog_rows=[];self.catalog_cursors=set()
|
|
83
|
+
self.approvals=None;self.native_calls={};self.notifications=set()
|
|
84
|
+
self.permission_configuration=False
|
|
85
|
+
self.permission_batch=None
|
|
74
86
|
|
|
75
87
|
async def start_child(self):
|
|
76
88
|
active=self.lib.active()
|
|
77
89
|
if not active.get('runtime'): raise PackError('No selected managed runtime')
|
|
78
90
|
self.runtime=active['runtime'];env=os.environ.copy()
|
|
79
91
|
self.execution=load_gate(self.runtime);self.catalog_ready.clear()
|
|
92
|
+
self.approvals=ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',
|
|
93
|
+
list(self.execution.roles.values()),uuid.uuid4().hex) if self.execution else None
|
|
80
94
|
self.catalog_rows=[];self.catalog_cursors=set()
|
|
81
95
|
self.protocol=qualify_protocol(self.real) if self.execution else None
|
|
82
96
|
env['CODEX_HOME']=str(pathlib.Path(self.runtime)/'home');env['CODEX_CLI_PATH']=self.real
|
|
@@ -86,6 +100,10 @@ class BackendProxy:
|
|
|
86
100
|
async def output(self):
|
|
87
101
|
while line:=await self.child.stdout.readline():
|
|
88
102
|
value=json.loads(line)
|
|
103
|
+
if str(value.get('id')) in self.native_calls and ('result' in value or 'error' in value):
|
|
104
|
+
future=self.native_calls.pop(str(value['id']));self.pending.discard(str(value['id']))
|
|
105
|
+
if not future.done():future.set_result(value)
|
|
106
|
+
continue
|
|
89
107
|
if value.get('id')==self.catalog_id:
|
|
90
108
|
self.pending.discard(self.catalog_id)
|
|
91
109
|
if self.execution and 'result' in value:
|
|
@@ -107,11 +125,19 @@ class BackendProxy:
|
|
|
107
125
|
self.pending.discard(str(value['id']))
|
|
108
126
|
if 'result' in value and isinstance(value['result'],dict) and 'userAgent' in value['result']: self.initialized=True
|
|
109
127
|
method=value.get('method');params=value.get('params') or {}
|
|
128
|
+
if self.approvals:
|
|
129
|
+
record=self.approvals.capture(value)
|
|
130
|
+
if record and record['sourceThreadId']!=record['intakeThreadId'] and record['notificationState']!='delivered':
|
|
131
|
+
self.schedule_notification(record)
|
|
110
132
|
if method=='turn/started': self.active_turns.add(params['threadId'])
|
|
111
133
|
if method=='turn/completed': self.active_turns.discard(params['threadId'])
|
|
112
134
|
if method=='thread/status/changed':
|
|
113
135
|
if params.get('status',{}).get('type')=='active':self.active_turns.add(params['threadId'])
|
|
114
136
|
elif params.get('status',{}).get('type') in {'idle','notLoaded','systemError'}:self.active_turns.discard(params['threadId'])
|
|
137
|
+
if self.approvals and params.get('status',{}).get('type')=='idle':
|
|
138
|
+
for row in self.approvals.records():
|
|
139
|
+
if row['nativeConnectionCurrent'] and row['intakeThreadId']==params['threadId'] and row['notificationState']!='delivered':
|
|
140
|
+
self.schedule_notification(row)
|
|
115
141
|
sys.stdout.buffer.write(line);sys.stdout.buffer.flush()
|
|
116
142
|
|
|
117
143
|
async def stop_child(self):
|
|
@@ -124,18 +150,24 @@ class BackendProxy:
|
|
|
124
150
|
if self.output_task: await self.output_task
|
|
125
151
|
|
|
126
152
|
async def forward(self,value):
|
|
153
|
+
if self.permission_configuration and value.get('method') in {'thread/resume','turn/start'}:
|
|
154
|
+
sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':'Owned permission configuration in progress'}})+b'\n')
|
|
155
|
+
sys.stdout.buffer.flush();return
|
|
127
156
|
if self.quiesced:
|
|
128
157
|
if 'id' in value:
|
|
129
158
|
sys.stdout.buffer.write(canonical({'id':value['id'],'error':{'code':-32000,'message':'Pack switch in progress; reconnecting backend'}})+b'\n');sys.stdout.buffer.flush()
|
|
130
159
|
return
|
|
131
160
|
if self.execution and value.get('method') in {'thread/resume','turn/start'} and (value.get('params') or {}).get('threadId') in self.execution.roles:
|
|
132
161
|
try:
|
|
162
|
+
await self.qualify_organization((value.get('params') or {})['threadId'])
|
|
133
163
|
await asyncio.wait_for(self.catalog_ready.wait(),10)
|
|
134
164
|
value=self.execution.prepare(value)
|
|
135
165
|
except (PackError,asyncio.TimeoutError) as exc:
|
|
136
166
|
sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':str(exc) or 'Native model catalog unavailable'}})+b'\n');sys.stdout.buffer.flush();return
|
|
137
167
|
if 'id' in value and 'method' in value:self.pending.add(str(value['id']))
|
|
168
|
+
if self.approvals and 'method' not in value and ('result' in value or 'error' in value):self.approvals.sending(value)
|
|
138
169
|
self.child.stdin.write(canonical(value)+b'\n');await self.child.stdin.drain()
|
|
170
|
+
if self.approvals and 'method' not in value and ('result' in value or 'error' in value):self.approvals.settled(value)
|
|
139
171
|
if self.execution and value.get('method')=='initialized':
|
|
140
172
|
await self.request_catalog()
|
|
141
173
|
|
|
@@ -145,6 +177,135 @@ class BackendProxy:
|
|
|
145
177
|
'params':{'includeHidden':True,**({'cursor':cursor} if cursor else {})}})+b'\n')
|
|
146
178
|
await self.child.stdin.drain()
|
|
147
179
|
|
|
180
|
+
async def call_native(self,method,params):
|
|
181
|
+
identity='topolo-relay-'+uuid.uuid4().hex
|
|
182
|
+
request={'id':identity,'method':method,'params':params}
|
|
183
|
+
if method in {'thread/resume','turn/start'}:
|
|
184
|
+
await self.qualify_organization(params['threadId'])
|
|
185
|
+
await asyncio.wait_for(self.catalog_ready.wait(),10)
|
|
186
|
+
request=self.execution.prepare(request)
|
|
187
|
+
future=asyncio.get_running_loop().create_future();self.native_calls[identity]=future;self.pending.add(identity)
|
|
188
|
+
try:
|
|
189
|
+
self.child.stdin.write(canonical(request)+b'\n');await self.child.stdin.drain()
|
|
190
|
+
reply=await asyncio.wait_for(future,15)
|
|
191
|
+
except BaseException:
|
|
192
|
+
self.native_calls.pop(identity,None);self.pending.discard(identity);raise
|
|
193
|
+
reply=self.execution.reply(reply) if self.execution else reply
|
|
194
|
+
if 'error' in reply:
|
|
195
|
+
raise PackError('Native intake relay refused; original request remains pending')
|
|
196
|
+
return reply['result']
|
|
197
|
+
|
|
198
|
+
async def qualify_organization(self,thread_id):
|
|
199
|
+
role=self.execution.roles.get(thread_id) if self.execution else None
|
|
200
|
+
if role and not self.permission_configuration and (pathlib.Path(self.runtime)/'permissions-operation.json').exists():
|
|
201
|
+
raise PackError('Unresolved native permission operation requires reconciliation before owned execution or grants')
|
|
202
|
+
if not role or not role.get('installationConsent'):
|
|
203
|
+
if role and role['settings']['sandbox_mode']=='danger-full-access':
|
|
204
|
+
raise PackError('Full access requires a fresh authenticated organization ceiling')
|
|
205
|
+
return
|
|
206
|
+
from . import topolo
|
|
207
|
+
consent=role['installationConsent']
|
|
208
|
+
agents=self.lib.load(consent['pack'])[0]['agents']
|
|
209
|
+
matches=[a for a in agents if '/'.join([a['pack'],a['set'],a['id']])==role['key'] and a['threadId']==role['threadId']]
|
|
210
|
+
if len(matches)!=1:
|
|
211
|
+
raise PackError('Current consent has no unique live owned role')
|
|
212
|
+
if role['settings']!=permission_settings(matches[0],role['settings'],consent):
|
|
213
|
+
raise PackError('Effective native permission intent differs from the current consent profile')
|
|
214
|
+
if self.permission_batch and self.permission_batch['consent']==consent:
|
|
215
|
+
if time.monotonic()>=self.permission_batch['deadline']:
|
|
216
|
+
raise PackError('Qualified native permission batch expired; reconcile without replay')
|
|
217
|
+
role['organizationPolicyProof']=self.permission_batch['proof'];return
|
|
218
|
+
_,proof=await asyncio.to_thread(topolo.organization_execution_policy,
|
|
219
|
+
topolo.Client(consent['environment'],consent['contextKey']),consent,agents,consent.get('agentWorkspaceId'))
|
|
220
|
+
from .permissions import authorize_native_scope
|
|
221
|
+
authorize_native_scope(proof)
|
|
222
|
+
role['organizationPolicyProof']=proof
|
|
223
|
+
|
|
224
|
+
async def configure_permissions(self,pack):
|
|
225
|
+
"""Configure the existing owned child, without launching another server."""
|
|
226
|
+
from . import permissions
|
|
227
|
+
if self.permission_configuration or self.quiesced or not self.execution or self.active_turns or self.pending:
|
|
228
|
+
raise PackError('Owned native connection must be initialized, idle and settled')
|
|
229
|
+
marker=pathlib.Path(self.runtime)/'permissions-operation.json'
|
|
230
|
+
if marker.exists():
|
|
231
|
+
raise PackError('Unresolved native permissions operation requires read-only reconciliation')
|
|
232
|
+
if self.approvals and self.approvals.pending:
|
|
233
|
+
raise PackError('Native approvals remain pending; preserve their owning connection')
|
|
234
|
+
manifest,_=self.lib.load(pack);consent=permissions.load(self.lib,pack,manifest['agents'])
|
|
235
|
+
if not consent:
|
|
236
|
+
raise PackError('Explicit organization-bound installation consent required')
|
|
237
|
+
roles=list(self.execution.roles.values());wanted=[]
|
|
238
|
+
by_key={r['key']:r for r in roles}
|
|
239
|
+
for agent in manifest['agents']:
|
|
240
|
+
key='/'.join([agent['pack'],agent['set'],agent['id']]);old=by_key.get(key)
|
|
241
|
+
if not old or old['threadId']!=agent['threadId']:
|
|
242
|
+
raise PackError('Complete pack must match the live owned roster')
|
|
243
|
+
row={**old,'settings':permission_settings(agent,old['settings'],consent),
|
|
244
|
+
'installationConsent':consent,'intakeKey':consent['intakeRoutes'][key],'contextKey':consent['contextKey']}
|
|
245
|
+
wanted.append(row)
|
|
246
|
+
changed={r['threadId']:r for r in wanted}
|
|
247
|
+
candidate=ExecutionGate({'format':'topolo-codex-runtime-execution','schemaVersion':1,
|
|
248
|
+
'roles':[changed.get(r['threadId'],r) for r in roles]})
|
|
249
|
+
candidate.catalog=self.execution.catalog.copy()
|
|
250
|
+
# Complete ownership/idle qualification precedes the first native effect.
|
|
251
|
+
self.permission_configuration=True
|
|
252
|
+
try:
|
|
253
|
+
for role in wanted:
|
|
254
|
+
state=await self.call_native('thread/read',{'threadId':role['threadId'],'includeTurns':False})
|
|
255
|
+
thread=state.get('thread',{})
|
|
256
|
+
if thread.get('id')!=role['threadId'] or thread.get('cwd')!=role['cwd'] or thread.get('status',{}).get('type') not in {'idle','notLoaded'}:
|
|
257
|
+
raise PackError('Native role identity or idle status differs')
|
|
258
|
+
if role['settings']['model_reasoning_effort'] not in candidate.catalog.get(role['settings']['model'],set()):
|
|
259
|
+
raise PackError('Native account lacks the preserved model/effort')
|
|
260
|
+
# Validate the new routing graph before committing any permission intent.
|
|
261
|
+
ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',list(candidate.roles.values()),uuid.uuid4().hex)
|
|
262
|
+
atomic_json(marker,{'state':'prepared','pack':pack,'before':roles,'requested':wanted})
|
|
263
|
+
self.execution=candidate
|
|
264
|
+
await self.qualify_organization(wanted[0]['threadId'])
|
|
265
|
+
self.permission_batch={'consent':consent,'proof':candidate.roles[wanted[0]['threadId']]['organizationPolicyProof'],
|
|
266
|
+
'deadline':time.monotonic()+45}
|
|
267
|
+
atomic_json(marker,{'state':'may-have-effects','pack':pack,'before':roles,'requested':wanted})
|
|
268
|
+
for role in wanted:
|
|
269
|
+
await self.call_native('thread/resume',{'threadId':role['threadId']})
|
|
270
|
+
atomic_json(marker,{'state':'may-have-effects','pack':pack,'before':roles,'requested':wanted,
|
|
271
|
+
'verifiedThreadIds':sorted(candidate.verified)})
|
|
272
|
+
document={'format':'topolo-codex-runtime-execution','schemaVersion':1,'roles':list(candidate.roles.values()),
|
|
273
|
+
'applied':False,'modelTurnsStarted':False}
|
|
274
|
+
atomic_json(pathlib.Path(self.runtime)/'execution-policy.json',document)
|
|
275
|
+
self.approvals=ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',list(candidate.roles.values()),uuid.uuid4().hex)
|
|
276
|
+
result={'state':'native-permission-resumes-verified','pack':pack,'verifiedAgents':len(wanted),
|
|
277
|
+
'modelTurnsStarted':False,'historyChanged':False,'appToolApprovalReadbackVerified':False,
|
|
278
|
+
'intakeRoutes':len(wanted),'nativeApprovalTransport': 'owned-live-connection'}
|
|
279
|
+
atomic_json(marker,{**result,'verifiedThreadIds':sorted(candidate.verified)})
|
|
280
|
+
atomic_json(pathlib.Path(self.runtime)/'permissions-proof.json',result);marker.unlink()
|
|
281
|
+
return result
|
|
282
|
+
finally:self.permission_configuration=False;self.permission_batch=None
|
|
283
|
+
|
|
284
|
+
def schedule_notification(self,record):
|
|
285
|
+
if (record['key'] in self.notifications or record['notificationState'] not in
|
|
286
|
+
{'not-delivered','awaiting-idle-intake'}):return
|
|
287
|
+
self.notifications.add(record['key'])
|
|
288
|
+
async def deliver():
|
|
289
|
+
try:
|
|
290
|
+
state=await self.call_native('thread/read',{'threadId':record['intakeThreadId'],'includeTurns':False})
|
|
291
|
+
if state.get('thread',{}).get('status',{}).get('type') not in {'idle','notLoaded'}:
|
|
292
|
+
self.approvals.notified(record['requestId'],False);return
|
|
293
|
+
if record['intakeThreadId'] not in self.execution.verified:
|
|
294
|
+
await self.call_native('thread/resume',{'threadId':record['intakeThreadId']})
|
|
295
|
+
self.approvals.live(record['key'],record['intakeThreadId'],record['fingerprint'])
|
|
296
|
+
# A lost turn/start reply cannot be retried on the next idle event.
|
|
297
|
+
# Persist possible delivery before dispatch, including process death.
|
|
298
|
+
self.approvals.notification_state(record['requestId'],'may-have-effects')
|
|
299
|
+
await self.call_native('turn/start',{'threadId':record['intakeThreadId'],
|
|
300
|
+
'input':[{'type':'text','text':self.approvals.notification(record),'text_elements':[]}]})
|
|
301
|
+
self.approvals.notified(record['requestId'],True)
|
|
302
|
+
except Exception:
|
|
303
|
+
entry=self.approvals.pending.get(str(record['requestId']))
|
|
304
|
+
uncertain=entry and entry['record']['notificationState']=='may-have-effects'
|
|
305
|
+
self.approvals.notification_state(record['requestId'],'delivery-uncertain' if uncertain else 'delivery-failed')
|
|
306
|
+
finally:self.notifications.discard(record['key'])
|
|
307
|
+
asyncio.create_task(deliver())
|
|
308
|
+
|
|
148
309
|
def status(self):
|
|
149
310
|
return {'pid':os.getpid(),'childPid':self.child.pid if self.child else None,'desktopPid':os.getppid(),'runtime':self.runtime,
|
|
150
311
|
'quiesced':self.quiesced,'initialized':self.initialized,
|
|
@@ -164,8 +325,28 @@ class BackendProxy:
|
|
|
164
325
|
await self.stop_child()
|
|
165
326
|
elif op=='resume':
|
|
166
327
|
if self.quiesced: raise PackError('Use the desktop reconnect action to resume a quiesced bridge')
|
|
328
|
+
elif op=='permissions-configure':
|
|
329
|
+
result=await self.configure_permissions((message.get('params') or {})['pack'])
|
|
330
|
+
elif op in {'approval-list','approval-details','approval-respond'}:
|
|
331
|
+
if not self.approvals:
|
|
332
|
+
raise PackError('This connection has no qualified intake routing')
|
|
333
|
+
params=message.get('params') or {}
|
|
334
|
+
if op=='approval-list':result={'requests':self.approvals.records()}
|
|
335
|
+
else:
|
|
336
|
+
entry=self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
|
|
337
|
+
if op=='approval-details':result={'request':entry['message'],'record':entry['record']}
|
|
338
|
+
else:
|
|
339
|
+
if params.get('humanDecisionRecorded') is not True:
|
|
340
|
+
raise PackError('Recorded human decision required; never auto-approve')
|
|
341
|
+
reply=self.approvals.response(params['key'],params['intakeThreadId'],params['fingerprint'],params['result'])
|
|
342
|
+
if reply['result'].get('decision')=='accept':
|
|
343
|
+
await self.qualify_organization(entry['record']['sourceThreadId'])
|
|
344
|
+
self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
|
|
345
|
+
self.approvals.sending(reply)
|
|
346
|
+
self.child.stdin.write(canonical(reply)+b'\n');await self.child.stdin.drain();self.approvals.settled(reply)
|
|
347
|
+
result={'state':'native-response-forwarded','operationEffectVerified':False}
|
|
167
348
|
elif op!='status': raise PackError('Unknown live bridge operation')
|
|
168
|
-
result=self.status()
|
|
349
|
+
if op not in {'approval-list','approval-details','approval-respond','permissions-configure'}:result=self.status()
|
|
169
350
|
except Exception as exc: result={'error':str(exc)}
|
|
170
351
|
writer.write(canonical(result)+b'\n');await writer.drain();writer.close();await writer.wait_closed()
|
|
171
352
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Organization-bound installation consent, distinct from managed enforcement.
|
|
2
|
+
|
|
3
|
+
Consent is machine local and never shipped in a portable pack. Native managed
|
|
4
|
+
requirements remain authoritative. This file is not an organization admin API.
|
|
5
|
+
"""
|
|
6
|
+
import copy
|
|
7
|
+
import pathlib
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from .storage import PackError, atomic_json, component_key, read_json, safe_id
|
|
13
|
+
|
|
14
|
+
PROFILES = {'manual': 0, 'role-default': 1, 'full-access': 2}
|
|
15
|
+
POLICY_FIELDS = ('allowedProfiles', 'maximumProfile', 'defaultProfile', 'fullAccessEnabled',
|
|
16
|
+
'allowedToolScopes', 'allowedActionScopes', 'allowedIntakes')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def organization_policy(envelope, context, organization):
|
|
20
|
+
"""Validate an authenticated action result; a local JSON never grants authority."""
|
|
21
|
+
required = set(POLICY_FIELDS) | {'contextKey', 'organizationId', 'revision', 'configured',
|
|
22
|
+
'policyDigest', 'updatedAt', 'updatedByUserId', 'decisionReference'}
|
|
23
|
+
if (not isinstance(envelope, dict) or set(envelope) != {'success', 'contractVersion', 'policy'} or
|
|
24
|
+
envelope['success'] is not True or envelope['contractVersion'] not in {'1.0.0', '1.1.0'}):
|
|
25
|
+
raise PackError('Organization policy contract unavailable or unsupported; no offline authority')
|
|
26
|
+
fields = list(POLICY_FIELDS)
|
|
27
|
+
if envelope['contractVersion'] == '1.1.0':
|
|
28
|
+
required.add('scopePolicyMode'); fields.insert(4, 'scopePolicyMode')
|
|
29
|
+
p = envelope['policy']
|
|
30
|
+
if (not isinstance(p, dict) or set(p) != required or p.get('contextKey') != context or
|
|
31
|
+
p.get('organizationId') != organization or context != 'org:' + organization or
|
|
32
|
+
not isinstance(p.get('revision'), str) or not re.fullmatch(r'0|[1-9][0-9]*', p['revision']) or
|
|
33
|
+
int(p['revision']) > 9007199254740991 or type(p.get('configured')) is not bool or
|
|
34
|
+
type(p.get('fullAccessEnabled')) is not bool):
|
|
35
|
+
raise PackError('Organization policy identity, revision or shape differs')
|
|
36
|
+
if 'scopePolicyMode' in required and p['scopePolicyMode'] not in {'allowlist', 'unrestricted'}:
|
|
37
|
+
raise PackError('Organization scope policy mode is invalid')
|
|
38
|
+
for field, maximum in [('allowedProfiles', 3), ('allowedToolScopes', 500),
|
|
39
|
+
('allowedActionScopes', 500), ('allowedIntakes', 200)]:
|
|
40
|
+
values = p[field]
|
|
41
|
+
pattern = (r'[a-z0-9][a-z0-9-]*/intakes/[a-z0-9][a-z0-9-]*' if field == 'allowedIntakes'
|
|
42
|
+
else r'[A-Za-z0-9][A-Za-z0-9_.:/-]*')
|
|
43
|
+
if (not isinstance(values, list) or len(values) > maximum or
|
|
44
|
+
any(not isinstance(v, str) or not 1 <= len(v) <= 255 or
|
|
45
|
+
not re.fullmatch(pattern, v) for v in values) or
|
|
46
|
+
len(set(values)) != len(values)):
|
|
47
|
+
raise PackError('Organization policy has invalid exact scope identifiers')
|
|
48
|
+
if p.get('scopePolicyMode') == 'unrestricted' and (p['allowedToolScopes'] or p['allowedActionScopes']):
|
|
49
|
+
raise PackError('Unrestricted mode must not contain ambiguous scope allowlists')
|
|
50
|
+
allowed = p['allowedProfiles']
|
|
51
|
+
if (not allowed or any(v not in PROFILES for v in allowed) or
|
|
52
|
+
p['maximumProfile'] not in PROFILES or p['defaultProfile'] not in allowed or
|
|
53
|
+
any(PROFILES[v] > PROFILES[p['maximumProfile']] for v in allowed) or
|
|
54
|
+
('full-access' in allowed or p['maximumProfile'] == 'full-access') and not p['fullAccessEnabled']):
|
|
55
|
+
raise PackError('Organization policy profile ceiling is inconsistent')
|
|
56
|
+
if (p['updatedAt'] is not None and (type(p['updatedAt']) is not int or p['updatedAt'] < 0) or
|
|
57
|
+
any(p[field] is not None and (not isinstance(p[field], str) or not 1 <= len(p[field]) <= 255)
|
|
58
|
+
for field in ['updatedByUserId', 'decisionReference'])):
|
|
59
|
+
raise PackError('Organization policy audit fields are invalid')
|
|
60
|
+
if not p['configured'] and (p['revision'] != '0' or allowed != ['manual'] or
|
|
61
|
+
p['maximumProfile'] != 'manual' or p['defaultProfile'] != 'manual' or p['fullAccessEnabled'] or
|
|
62
|
+
any(p[v] for v in ['allowedToolScopes', 'allowedActionScopes', 'allowedIntakes']) or
|
|
63
|
+
any(p[v] is not None for v in ['updatedAt', 'updatedByUserId', 'decisionReference'])):
|
|
64
|
+
raise PackError('Absent organization policy is not the restrictive default')
|
|
65
|
+
if not p['configured'] and p.get('scopePolicyMode', 'allowlist') != 'allowlist':
|
|
66
|
+
raise PackError('Absent organization scope policy must deny scoped use')
|
|
67
|
+
if p['configured'] and (p['revision'] == '0' or any(p[v] is None for v in
|
|
68
|
+
['updatedAt', 'updatedByUserId', 'decisionReference'])):
|
|
69
|
+
raise PackError('Configured organization policy lacks server audit metadata')
|
|
70
|
+
body = {field: sorted(p[field]) if isinstance(p[field], list) else p[field] for field in fields}
|
|
71
|
+
digest = 'sha256:' + hashlib.sha256(json.dumps(body, separators=(',', ':'), ensure_ascii=False).encode()).hexdigest()
|
|
72
|
+
if p['policyDigest'] != digest:
|
|
73
|
+
raise PackError('Organization policy digest differs')
|
|
74
|
+
return copy.deepcopy(p)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def authorize_profile(consent, agents, policy):
|
|
78
|
+
consent = validate(consent, consent['pack'], agents)
|
|
79
|
+
if policy['contextKey'] != consent['contextKey']:
|
|
80
|
+
raise PackError('Installation and organization policy contexts differ')
|
|
81
|
+
for agent in agents:
|
|
82
|
+
key = component_key(agent)
|
|
83
|
+
selected = consent.get('overrides', {}).get(key, consent['profile'])
|
|
84
|
+
if selected not in policy['allowedProfiles'] or PROFILES[selected] > PROFILES[policy['maximumProfile']]:
|
|
85
|
+
raise PackError('Organization policy prohibits the requested profile: ' + key)
|
|
86
|
+
if consent['intakeRoutes'][key] not in policy['allowedIntakes']:
|
|
87
|
+
raise PackError('Organization policy does not allow the owning intake: ' + key)
|
|
88
|
+
return {'contextKey': policy['contextKey'], 'revision': policy['revision'],
|
|
89
|
+
'policyDigest': policy['policyDigest'], 'profileCeilingVerified': True,
|
|
90
|
+
'toolActionScopeEnforcementVerified': False,
|
|
91
|
+
'scopePolicyMode': policy.get('scopePolicyMode', 'allowlist'),
|
|
92
|
+
'toolActionRestrictionRequested': policy.get('scopePolicyMode', 'allowlist') == 'allowlist'}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def authorize_native_scope(proof):
|
|
96
|
+
if proof.get('scopePolicyMode') != 'unrestricted' or proof.get('toolActionRestrictionRequested') is not False:
|
|
97
|
+
raise PackError('Native tool/action allowlist enforcement is unavailable; requested organization policy cannot be activated')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate(document, pack, agents):
|
|
101
|
+
keys = {component_key(a) for a in agents}
|
|
102
|
+
if (not isinstance(document, dict) or document.get('format') != 'topolo-pack-installation-consent'
|
|
103
|
+
or document.get('schemaVersion') != 1 or document.get('pack') != pack
|
|
104
|
+
or document.get('authority') != 'local-installation-consent'
|
|
105
|
+
or not isinstance(document.get('contextKey'), str)
|
|
106
|
+
or not document['contextKey'].startswith('org:') or len(document['contextKey']) <= 4
|
|
107
|
+
or not document.get('principalId') or document.get('profile') not in PROFILES
|
|
108
|
+
or document.get('approved') is not True):
|
|
109
|
+
raise PackError('Invalid organization-bound installation consent')
|
|
110
|
+
routes = document.get('intakeRoutes', {})
|
|
111
|
+
intake_keys = {component_key(a) for a in agents if a['set'] == 'intakes'}
|
|
112
|
+
if (not isinstance(routes, dict) or set(routes) != keys or
|
|
113
|
+
any(v not in intake_keys for v in routes.values())):
|
|
114
|
+
raise PackError('Every role requires an owned intake route')
|
|
115
|
+
overrides = document.get('overrides', {})
|
|
116
|
+
if not isinstance(overrides, dict) or set(overrides) - keys:
|
|
117
|
+
raise PackError('Permission override names an unowned role')
|
|
118
|
+
if any(v not in PROFILES or PROFILES[v] > PROFILES[document['profile']] for v in overrides.values()):
|
|
119
|
+
raise PackError('Role override widens installation consent')
|
|
120
|
+
return copy.deepcopy(document)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load(lib, pack, agents):
|
|
124
|
+
path = lib.root / 'permissions' / (safe_id(pack) + '.json')
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return None
|
|
127
|
+
if path.is_symlink() or not path.is_file():
|
|
128
|
+
raise PackError('Installation consent must be a regular owned file')
|
|
129
|
+
return validate(read_json(path), pack, agents)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def configure(lib, pack, profile, routes, environment, context, client, workspace=None):
|
|
133
|
+
from .topolo import identity
|
|
134
|
+
manifest, revision = lib.load(pack)
|
|
135
|
+
actor = identity(client)
|
|
136
|
+
if context != 'org:' + actor['organizationId']:
|
|
137
|
+
raise PackError('Authenticated organization differs from installation context')
|
|
138
|
+
document = validate({'format': 'topolo-pack-installation-consent', 'schemaVersion': 1,
|
|
139
|
+
'authority': 'local-installation-consent', 'pack': pack, 'sourceRevision': revision,
|
|
140
|
+
'contextKey': context, 'environment': environment, 'principalId': actor['userId'],
|
|
141
|
+
'profile': profile, 'approved': True, 'intakeRoutes': routes, 'overrides': {},
|
|
142
|
+
'agentWorkspaceId': workspace}, pack, manifest['agents'])
|
|
143
|
+
atomic_json(lib.root / 'permissions' / (safe_id(pack) + '.json'), document)
|
|
144
|
+
return {'state': 'installation-consent-recorded-not-native-applied', 'pack': pack,
|
|
145
|
+
'profile': profile, 'roles': len(manifest['agents']), 'contextKey': context,
|
|
146
|
+
'organizationManagedPolicyVerified': False, 'nativeApplied': False}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def apply_settings(agent, harness, native, document):
|
|
150
|
+
native = copy.deepcopy(native)
|
|
151
|
+
if document is None:
|
|
152
|
+
return native
|
|
153
|
+
key = component_key(agent)
|
|
154
|
+
if key not in document['intakeRoutes']:
|
|
155
|
+
raise PackError('Role has no approved intake route')
|
|
156
|
+
selected = document.get('overrides', {}).get(key, document['profile'])
|
|
157
|
+
if selected == 'role-default':
|
|
158
|
+
return native
|
|
159
|
+
if harness == 'codex':
|
|
160
|
+
native['approval_policy'] = 'never' if selected == 'full-access' else 'on-request'
|
|
161
|
+
native['sandbox_mode'] = 'danger-full-access' if selected == 'full-access' else 'read-only'
|
|
162
|
+
native['apps'] = {'_default': {'default_tools_approval_mode': 'approve' if selected == 'full-access' else 'prompt'}}
|
|
163
|
+
elif harness == 'claude':
|
|
164
|
+
native['permissions'] = {'defaultMode': 'bypassPermissions' if selected == 'full-access' else 'default'}
|
|
165
|
+
else:
|
|
166
|
+
raise PackError('Unqualified permission adapter')
|
|
167
|
+
return native
|
|
@@ -46,7 +46,7 @@ def role_profile(agent):
|
|
|
46
46
|
return 'operator'
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def settings(agent, harness):
|
|
49
|
+
def settings(agent, harness, consent=None):
|
|
50
50
|
if harness not in MODELS:
|
|
51
51
|
raise PackError('Execution settings adapter is not qualified: ' + harness)
|
|
52
52
|
profile = role_profile(agent)
|
|
@@ -62,22 +62,27 @@ def settings(agent, harness):
|
|
|
62
62
|
else:
|
|
63
63
|
native = {'model': model, 'effortLevel': spec['effort'],
|
|
64
64
|
'permissions': {'defaultMode': 'acceptEdits' if profile == 'build' else 'default'}}
|
|
65
|
+
from .permissions import apply_settings
|
|
66
|
+
native = apply_settings(agent, harness, native, consent)
|
|
67
|
+
full = native.get('sandbox_mode') == 'danger-full-access' or native.get('permissions', {}).get('defaultMode') == 'bypassPermissions'
|
|
65
68
|
return {'profile': profile, 'nativeSettings': native,
|
|
66
|
-
'localAccessIntent': spec['localAccess'], 'applied': False,
|
|
69
|
+
'localAccessIntent': 'full-access' if full else native.get('sandbox_mode', spec['localAccess']), 'applied': False,
|
|
67
70
|
# Claude Manual is an approval mode, not a read-only filesystem sandbox.
|
|
68
|
-
'requiresAdditionalLocalConfinement': harness == 'claude' and spec['localAccess'] == 'read-only'}
|
|
71
|
+
'requiresAdditionalLocalConfinement': harness == 'claude' and not full and spec['localAccess'] == 'read-only'}
|
|
69
72
|
|
|
70
73
|
|
|
71
|
-
def render(manifest, revision, agents, harnesses=('codex', 'claude')):
|
|
74
|
+
def render(manifest, revision, agents, harnesses=('codex', 'claude'), consent=None):
|
|
72
75
|
rows = []
|
|
73
76
|
for agent in agents:
|
|
74
77
|
rows.append({'key': component_key(agent), 'title': agent['title'],
|
|
75
78
|
'sidebar': {'group': GROUPS.get(agent['set'], agent['set']),
|
|
76
79
|
'pinned': agent.get('pinned', False)},
|
|
77
|
-
'
|
|
80
|
+
'intake': consent['intakeRoutes'][component_key(agent)] if consent else None,
|
|
81
|
+
'harnesses': {h: settings(agent, h, consent) for h in harnesses}})
|
|
78
82
|
return {'format': 'topolo-agent-execution-policy', 'schemaVersion': 1,
|
|
79
83
|
'pack': manifest['packId'], 'sourceRevision': revision, 'agents': rows,
|
|
80
|
-
'applied': False,
|
|
84
|
+
'applied': False, 'installationConsent': copy.deepcopy(consent),
|
|
85
|
+
'organizationManagedPolicyVerified': False,
|
|
81
86
|
'contextPolicy': {'load': ['role charter', 'current task', 'relevant retrieved memory'],
|
|
82
87
|
'sharedMemory': 'retrieve by task and user or organization scope',
|
|
83
88
|
'history': 'preserve, do not copy between roles',
|
|
@@ -49,6 +49,18 @@ def identity(client):
|
|
|
49
49
|
raise PackError('Verified user and organization context are required by the Agent contract')
|
|
50
50
|
return {'userId':user['id'],'organizationId':org['id']}
|
|
51
51
|
|
|
52
|
+
def organization_execution_policy(client, consent, agents, workspace):
|
|
53
|
+
"""Fresh authenticated organization ceiling, distinct from native acknowledgement."""
|
|
54
|
+
from .permissions import organization_policy, authorize_profile
|
|
55
|
+
actor=identity(client)
|
|
56
|
+
if actor['userId']!=consent['principalId'] or client.context!=consent['contextKey']:
|
|
57
|
+
raise PackError('Current installation principal/context differs')
|
|
58
|
+
if not workspace:
|
|
59
|
+
raise PackError('Explicit verified Agent workspace required for policy retrieval')
|
|
60
|
+
envelope=client.action('app_topolo_agent.organization_execution_policy.get',{},workspace)
|
|
61
|
+
policy=organization_policy(envelope,client.context,actor['organizationId'])
|
|
62
|
+
return policy,authorize_profile(consent,agents,policy)
|
|
63
|
+
|
|
52
64
|
def binding_path(lib,name,environment,context):
|
|
53
65
|
safe_id(name)
|
|
54
66
|
return lib.root/'connections'/name/environment/(hashlib.sha256(context.encode()).hexdigest()+'.json')
|