@topolo/cli 0.13.2 → 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.
@@ -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
- 'harnesses': {h: settings(agent, h) for h in harnesses}})
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')