@topolo/cli 0.13.2 → 0.13.4

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.
@@ -430,8 +430,9 @@ Mode setup requires a one-time restart, followed by the Developer menu's
430
430
  do not patch fuses or assume a restart flag opens the endpoint.
431
431
 
432
432
  All selected sessions must be idle, have matching titles/workspaces, and use
433
- models from the native account catalog. Settings use Manual or Accept edits;
434
- no bypass permission is accepted. An fsynced journal records the baseline and
433
+ models from the native account catalog. Bypass permissions require explicit
434
+ machine-local full-access consent and a fresh authenticated organization
435
+ ceiling; role-default settings use Manual or Accept edits. An fsynced journal records the baseline and
435
436
  each attempted native effect before dispatch. On a disconnect or failed
436
437
  acknowledgement, use the explicit read-only native recovery command below before
437
438
  retrying; do not assume failure means no effects. Application independently
@@ -491,5 +492,82 @@ authorization remains separate from local command sandboxing.
491
492
  Managed bridge status reports requested policy and which resumes have current
492
493
  native readback. It does not claim an inference test or independent app-tool
493
494
  approval readback. Existing runtimes without this policy report not-configured
494
- until explicitly rebuilt. Source fixtures do not establish live desktop or
495
+ until explicit `permissions ... --apply` adoption on the existing idle connection.
496
+ Adoption validates the complete selected composition and current source revision,
497
+ qualifies the installed protocol and native model catalog, and verifies original
498
+ thread identities and workspaces before native permission changes. Legacy model
499
+ and effort values are reviewed source-role intent, qualified only by native
500
+ resume responses. Existing execution-policy selections are preserved. The
501
+ operation compares charter and transcript inventories and retains partial or
502
+ uncertain effects for read-only reconciliation; it never recreates threads.
503
+
504
+ The private token-authenticated bridge also supports `owned-roster-read` with
505
+ no parameters. It sends only fixed native thread metadata and model-catalog
506
+ reads for the full selected roster. Its result explicitly does not establish
507
+ effective permissions, models or app-tool approval behavior. Source fixtures do not establish live desktop or
495
508
  three-environment shared-memory acceptance.
509
+
510
+ ### Organization consent and intake permission routing
511
+
512
+ Pack export never carries machine-local permission consent. An authenticated
513
+ user records consent separately, with an explicit environment, organization,
514
+ Agent workspace and complete mapping of role keys to owned intake keys:
515
+
516
+ ```sh
517
+ topolo agent-packs permissions topolo-clean --profile full-access \
518
+ --routes /absolute/intake-routes.json --environment production \
519
+ --context-key org:<organization-id> --agent-workspace <workspace-id> --confirm
520
+ topolo agent-packs policy topolo-clean --verify-organization \
521
+ --harness claude --output /absolute/review.json
522
+ ```
523
+
524
+ Profiles are `manual`, `role-default` and `full-access`. Per-role consent may
525
+ reduce the selected profile. Every role must route to an intake inside the same
526
+ owned roster and organization. Local consent grants no organization authority:
527
+ the public Agent organization execution-policy read supplies the current
528
+ profile ceiling. Missing, malformed, stale-context or unavailable policy
529
+ refuses before native grants. Legacy 1.0 policies use allowlists, including
530
+ empty-list denial; 1.1 adds explicit `scopePolicyMode`. Native application of
531
+ an allowlist is refused until the adapter can enforce its exact tool/action
532
+ scopes. An administrator may explicitly select 1.1 `unrestricted` with empty
533
+ tool/action lists; intake restrictions remain enforced separately.
534
+
535
+ For Codex, append `--apply` to the consent command to configure the existing
536
+ owned idle backend. For Claude, record consent with `--harness claude`, render
537
+ the reviewed policy, then use native `configure --apply`. Models, effort,
538
+ thread identities, histories and charters are preserved. Native settings
539
+ must acknowledge all selected roles before completion. An interrupted batch
540
+ retains an operation marker and refuses replay pending owner reconciliation.
541
+ Full access configures Codex `never`/`danger-full-access` and Claude
542
+ `bypassPermissions`; it does not bypass platform authorization or business
543
+ decision rules. Claude Manual is not filesystem confinement.
544
+
545
+ Real native permission requests are recorded with request fingerprints and
546
+ sent to the owning intake as generated notifications, never as human consent.
547
+ Idle agents consume no model turn merely because routing was installed.
548
+ Busy intakes wait for their next native event. Delivery or response uncertainty
549
+ is durable and never silently replayed. A human answer is relayed for one exact
550
+ pending request:
551
+
552
+ ```sh
553
+ topolo agent-packs approvals list
554
+ topolo agent-packs approvals details <key> --intake <thread-id> --fingerprint <hash>
555
+ topolo agent-packs approvals respond <key> --intake <thread-id> \
556
+ --fingerprint <hash> --response /absolute/answer.json --human-decision-recorded
557
+ # Add --harness claude for Claude requests; its answer is {"decision":"once"} or {"decision":"deny"}.
558
+ ```
559
+
560
+ The recorded-human flag is a workflow assertion, not cryptographic identity
561
+ proof. The broker preserves native authorization and never invents card origin,
562
+ standing grants or action completion. Claude card-only grants and unsupported
563
+ Codex elicitation/permission kinds remain visible in the intake but require
564
+ their original native interaction. Full-access consent cannot override managed
565
+ native restrictions.
566
+
567
+ Claude setup installs an event listener and private bounded Unix control socket
568
+ inside the same hash-qualified native process. The temporary setup inspector
569
+ may close; approvals use this socket, with private owner-only directory/token,
570
+ exact PID/roster identity and current module qualification. No independent
571
+ daemon, polling loop, arbitrary evaluation endpoint or UI pack fix is used.
572
+ Audit failures refuse further broker operation. Cached reports and source
573
+ fixtures are not live native settings or shared Agent/Bytes acceptance proof.
@@ -0,0 +1,122 @@
1
+ """Fail closed on untested Python changes against the admitted released base.
2
+
3
+ Complements the whole-module floor. coverage.py parses current source again so
4
+ an omitted file, executable line or outgoing branch cannot disappear from the
5
+ denominator. New and untracked adapter modules are included before committing.
6
+ """
7
+ import argparse
8
+ import json
9
+ import pathlib
10
+ import re
11
+ import subprocess
12
+ import sys
13
+
14
+ from coverage.parser import PythonParser
15
+
16
+ RELEASE_BASE = '6a13ada56844712aae58738ba01dbbdf90718bef'
17
+ SOURCE = pathlib.Path('agent-packs/src/topolo_agent_pack')
18
+ MODULES = {'topolo', 'claude', 'policy', 'permissions', 'approvals', 'execution', 'live', 'cli'}
19
+
20
+
21
+ class CoverageRefused(RuntimeError):
22
+ pass
23
+
24
+
25
+ def git(root, *args, optional=False):
26
+ result = subprocess.run(['git', *args], cwd=root, capture_output=True, timeout=15)
27
+ if result.returncode and not optional:
28
+ raise CoverageRefused('Git qualification failed: ' + ' '.join(args[:2]))
29
+ return result
30
+
31
+
32
+ def regular(path):
33
+ if not path.is_file() or path.is_symlink():
34
+ raise CoverageRefused('Missing or unowned source/report: ' + str(path))
35
+
36
+
37
+ def qualify(root, report_path, baseline=RELEASE_BASE, expected_baseline=RELEASE_BASE):
38
+ root = pathlib.Path(root).resolve();report_path = pathlib.Path(report_path)
39
+ if not re.fullmatch(r'[a-f0-9]{40}', baseline) or baseline != expected_baseline:
40
+ raise CoverageRefused('Exact admitted full release baseline required')
41
+ git(root, 'cat-file', '-e', baseline + '^{commit}')
42
+ git(root, 'merge-base', '--is-ancestor', baseline, 'HEAD')
43
+ regular(report_path)
44
+ report = json.loads(report_path.read_text())
45
+ if report.get('meta', {}).get('branch_coverage') is not True or not isinstance(report.get('files'), dict):
46
+ raise CoverageRefused('Full coverage.py branch JSON report required')
47
+ files = {}
48
+ for name, value in report['files'].items():
49
+ path = pathlib.Path(name);path = path if path.is_absolute() else root / path
50
+ path = path.resolve()
51
+ if path in files:
52
+ raise CoverageRefused('Coverage source identity duplicated')
53
+ files[path] = value
54
+ changed_names = git(root, 'diff', '--name-only', '-z', baseline, '--', str(SOURCE)).stdout
55
+ new_names = git(root, 'ls-files', '--others', '-z', '--', str(SOURCE)).stdout
56
+ changed = {pathlib.Path(p.decode()) for p in (changed_names + new_names).split(b'\0') if p and p.endswith(b'.py')}
57
+ if not changed:
58
+ raise CoverageRefused('No Python source changes against admitted baseline')
59
+ required = changed | {SOURCE / (m + '.py') for m in MODULES}
60
+ missing_lines = [];missing_arcs = [];line_count = arc_count = 0
61
+ for relative in sorted(required):
62
+ path = root / relative;regular(path)
63
+ if not path.resolve().is_relative_to(root):
64
+ raise CoverageRefused('Source escaped the owned repository')
65
+ if path.resolve() not in files:
66
+ raise CoverageRefused('Source absent from coverage collection: ' + str(relative))
67
+ if path.stat().st_mtime_ns > report_path.stat().st_mtime_ns:
68
+ raise CoverageRefused('Coverage report predates current source: ' + str(relative))
69
+ value = files[path.resolve()]
70
+ if value.get('excluded_lines'):
71
+ raise CoverageRefused('Excluded executable source is not qualified')
72
+ executed = set(value['executed_lines']);missing = set(value['missing_lines'])
73
+ parser = PythonParser(filename=str(path));parser.parse_source()
74
+ if executed & missing or executed | missing != parser.statements:
75
+ raise CoverageRefused('Executable line inventory differs from current source: ' + str(relative))
76
+ counts = parser.exit_counts()
77
+ expected_arcs = {a for a in parser.arcs() if counts.get(a[0], 0) > 1}
78
+ executed_arcs = {tuple(a) for a in value['executed_branches']}
79
+ absent_arcs = {tuple(a) for a in value['missing_branches']}
80
+ if executed_arcs & absent_arcs or executed_arcs | absent_arcs != expected_arcs:
81
+ raise CoverageRefused('Outgoing branch inventory differs from current source: ' + str(relative))
82
+ if relative not in changed:
83
+ continue
84
+ if git(root, 'cat-file', '-e', baseline + ':' + str(relative), optional=True).returncode:
85
+ lines = set(range(1, len(path.read_text().splitlines()) + 1))
86
+ else:
87
+ diff = git(root, 'diff', '--no-ext-diff', '--no-textconv', '--unified=0', baseline, '--', str(relative)).stdout.decode()
88
+ lines = set()
89
+ for start, size in re.findall(r'^@@ .*?\+(\d+)(?:,(\d+))? @@', diff, re.M):
90
+ lines.update(range(int(start), int(start) + int(size or '1')))
91
+ relevant = parser.statements & lines;arcs = {a for a in expected_arcs if a[0] in lines}
92
+ line_count += len(relevant);arc_count += len(arcs)
93
+ missing_lines.extend((str(relative), n) for n in sorted(relevant - executed))
94
+ missing_arcs.extend((str(relative), a) for a in sorted(arcs - executed_arcs))
95
+ if not line_count:
96
+ raise CoverageRefused('No executable changed Python source qualified')
97
+ result = {'baseline': baseline, 'sourceModulesCollected': len(required),
98
+ 'changedExecutableLines': line_count, 'changedOutgoingArcs': arc_count,
99
+ 'missingLines': missing_lines, 'missingArcs': missing_arcs}
100
+ if missing_lines or missing_arcs:
101
+ raise CoverageRefused('Changed-code coverage must be 100%: ' + json.dumps(result))
102
+ return {**result, 'changedLineCoverage': 100, 'changedBranchCoverage': 100}
103
+
104
+
105
+ def main(argv=None):
106
+ parser = argparse.ArgumentParser(description=__doc__)
107
+ parser.add_argument('--root', default=str(pathlib.Path(__file__).resolve().parents[1]))
108
+ parser.add_argument('--report', default='node_modules/.cache/agent-pack-test/coverage.json')
109
+ parser.add_argument('--base', default=RELEASE_BASE)
110
+ args = parser.parse_args(argv)
111
+ try:
112
+ report = pathlib.Path(args.report)
113
+ report = report if report.is_absolute() else pathlib.Path(args.root) / report
114
+ print(json.dumps(qualify(args.root, report, args.base)))
115
+ return 0
116
+ except (CoverageRefused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as error:
117
+ print('Python changed coverage REFUSED: ' + str(error), file=sys.stderr)
118
+ return 2
119
+
120
+
121
+ if __name__ == '__main__':
122
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ """Durable intake visibility with original native request/response identities.
2
+
3
+ Request content stays in the live native connection; durable records contain
4
+ identities and fingerprints, not commands, question bodies or credentials.
5
+ No timer, automatic decision, fake native acknowledgement or approval replay.
6
+ """
7
+ import copy
8
+ import hashlib
9
+ import pathlib
10
+
11
+ from .storage import PackError, atomic_json, canonical, read_json
12
+
13
+ METHODS = {'item/commandExecution/requestApproval', 'item/fileChange/requestApproval',
14
+ 'item/permissions/requestApproval', 'item/tool/requestUserInput',
15
+ 'mcpServer/elicitation/request'}
16
+
17
+
18
+ class ApprovalRouter:
19
+ def __init__(self, root, roles, generation):
20
+ self.root = pathlib.Path(root); self.generation = generation
21
+ self.roles = {r['threadId']: r for r in roles}
22
+ self.by_key = {r['key']: r for r in roles}; self.pending = {}
23
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
24
+ for role in roles:
25
+ key = role.get('intakeKey')
26
+ if key is not None:
27
+ intake = self.by_key.get(key)
28
+ if (not intake or '/intakes/' not in key or not role.get('contextKey') or
29
+ intake.get('contextKey') != role['contextKey']):
30
+ raise PackError('Approval route has no same-context owned intake')
31
+
32
+ def capture(self, message):
33
+ if message.get('method') not in METHODS or 'id' not in message:
34
+ return None
35
+ params = message.get('params') or {}; role = self.roles.get(params.get('threadId'))
36
+ if not role or not role.get('intakeKey'):
37
+ return None
38
+ identity = str(message['id']); fingerprint = hashlib.sha256(canonical(message)).hexdigest()
39
+ if identity in self.pending:
40
+ if self.pending[identity]['record']['fingerprint'] != fingerprint:
41
+ raise PackError('Native approval request identity was reused with different content')
42
+ return copy.deepcopy(self.pending[identity]['record'])
43
+ key = hashlib.sha256(canonical([self.generation, message['id']])).hexdigest()
44
+ intake = self.by_key[role['intakeKey']]
45
+ record = {'format': 'topolo-native-approval', 'schemaVersion': 1, 'key': key,
46
+ 'generation': self.generation, 'requestId': message['id'], 'fingerprint': fingerprint,
47
+ 'method': message['method'], 'sourceThreadId': role['threadId'], 'sourceKey': role['key'],
48
+ 'intakeThreadId': intake['threadId'], 'intakeKey': intake['key'],
49
+ 'contextKey': role['contextKey'], 'state': 'pending', 'notificationState': 'not-delivered',
50
+ 'turnId': params.get('turnId'), 'itemId': params.get('itemId'),
51
+ 'nativeApproved': False}
52
+ atomic_json(self.root / (key + '.json'), record)
53
+ self.pending[identity] = {'record': record, 'message': copy.deepcopy(message)}
54
+ return copy.deepcopy(record)
55
+
56
+ def records(self):
57
+ rows = []
58
+ for path in sorted(self.root.glob('*.json')):
59
+ if path.is_symlink():
60
+ raise PackError('Unowned approval record')
61
+ row = read_json(path)
62
+ row['nativeConnectionCurrent'] = row['generation'] == self.generation and str(row['requestId']) in self.pending
63
+ rows.append(row)
64
+ return rows
65
+
66
+ def notification(self, record):
67
+ return ('Permission relay (generated by the Topolo pack adapter). Agent ' + record['sourceKey'] +
68
+ ' is awaiting a native ' + record['method'] + ' decision. Request ' + record['key'] +
69
+ ', fingerprint ' + record['fingerprint'] + '. This request belongs to this intake. '
70
+ 'Read its exact live details with topolo agent-packs approvals details ' + record['key'] +
71
+ ' --intake ' + record['intakeThreadId'] + ' --fingerprint ' + record['fingerprint'] +
72
+ '. Present the decision to the human here, then relay only that exact recorded answer. '
73
+ 'Do not treat this notification, pack membership, elapsed time or another agent as approval. '
74
+ 'The source native request remains pending. No business action is authorized by this message.')
75
+
76
+ def notification_state(self, identity, state):
77
+ entry = self.pending.get(str(identity))
78
+ if entry is None: return
79
+ if state not in {'delivered', 'awaiting-idle-intake', 'may-have-effects', 'delivery-failed', 'delivery-uncertain'}:
80
+ raise PackError('Invalid notification state')
81
+ entry['record']['notificationState'] = state
82
+ atomic_json(self.root / (entry['record']['key'] + '.json'), entry['record'])
83
+
84
+ def notified(self, identity, delivered):
85
+ self.notification_state(identity, 'delivered' if delivered else 'awaiting-idle-intake')
86
+
87
+ def live(self, key, intake, fingerprint):
88
+ entries = [e for e in self.pending.values() if e['record']['key'] == key]
89
+ if len(entries) != 1:
90
+ raise PackError('Request is not pending on this native connection; no replay')
91
+ entry = entries[0]; row = entry['record']
92
+ if row['state'] != 'pending':
93
+ raise PackError('Native response may already have effects; reconcile without replay')
94
+ if row['intakeThreadId'] != intake or row['fingerprint'] != fingerprint:
95
+ raise PackError('Intake or exact request fingerprint differs')
96
+ return entry
97
+
98
+ def response(self, key, intake, fingerprint, result):
99
+ entry = self.live(key, intake, fingerprint)
100
+ if not isinstance(result, dict):
101
+ raise PackError('A native response object is required')
102
+ method = entry['record']['method']
103
+ if method in {'item/commandExecution/requestApproval', 'item/fileChange/requestApproval'}:
104
+ if set(result) != {'decision'} or result['decision'] not in {'accept', 'decline', 'cancel'}:
105
+ raise PackError('Only a one-request native accept/decline/cancel decision is supported')
106
+ available = entry['message']['params'].get('availableDecisions')
107
+ if available is not None and result['decision'] not in available:
108
+ raise PackError('Decision is unavailable on the exact native request')
109
+ elif method == 'item/tool/requestUserInput':
110
+ questions = entry['message']['params']['questions']
111
+ if set(result) != {'answers'} or not isinstance(result['answers'], dict) or set(result['answers']) != {q['id'] for q in questions}:
112
+ raise PackError('Every exact native question requires an answer')
113
+ if any(not isinstance(a, dict) or set(a) != {'answers'} or not isinstance(a['answers'], list) or
114
+ not a['answers'] or any(not isinstance(v, str) for v in a['answers']) for a in result['answers'].values()):
115
+ raise PackError('Invalid native question answer')
116
+ else:
117
+ raise PackError('This permission kind requires its original native interaction; intake visibility is retained')
118
+ return {'id': entry['record']['requestId'], 'result': copy.deepcopy(result)}
119
+
120
+ def sending(self, message):
121
+ entry = self.pending.get(str(message.get('id')))
122
+ if entry is None: return
123
+ if entry['record']['state'] != 'pending':
124
+ raise PackError('Native response may already have effects; reconcile without replay')
125
+ entry['record']['state'] = 'response-may-have-effects'
126
+ atomic_json(self.root / (entry['record']['key'] + '.json'), entry['record'])
127
+
128
+ def settled(self, message):
129
+ entry = self.pending.pop(str(message.get('id')), None)
130
+ if entry is None: return
131
+ entry['record'].update(state='native-response-forwarded', nativeApproved=False,
132
+ responseFingerprint=hashlib.sha256(canonical(message)).hexdigest())
133
+ # A sent response is not proof that the protected operation happened.
134
+ atomic_json(self.root / (entry['record']['key'] + '.json'), entry['record'])
@@ -316,6 +316,17 @@ def parser():
316
316
  q=sub.add_parser('plan');q.add_argument('action',choices=['add','replace','switch']);q.add_argument('source');selection(q);q.add_argument('--target');q.add_argument('--fresh',action='store_true')
317
317
  q=sub.add_parser('policy',help='Render portable role settings for review; never changes live sessions')
318
318
  q.add_argument('source');selection(q);q.add_argument('--output')
319
+ q.add_argument('--verify-organization',action='store_true',help='Fresh authenticated profile ceiling; never uses cached policy as authority')
320
+ q=sub.add_parser('permissions',help='Record explicit organization-bound local installation consent; does not apply native settings')
321
+ q.add_argument('source');q.add_argument('--profile',choices=['manual','role-default','full-access'],required=True)
322
+ q.add_argument('--routes',required=True);q.add_argument('--environment',choices=['development','staging','production'],required=True)
323
+ q.add_argument('--context-key',required=True);q.add_argument('--confirm',action='store_true',required=True)
324
+ q.add_argument('--agent-workspace',required=True)
325
+ q.add_argument('--apply',action='store_true',help='Configure the existing idle owned Codex backend; no new app server or model turn')
326
+ q=sub.add_parser('approvals',help='Read and relay exact live native decisions through the owning intake')
327
+ q.add_argument('operation',choices=['list','details','respond']);q.add_argument('key',nargs='?')
328
+ q.add_argument('--intake');q.add_argument('--fingerprint');q.add_argument('--response')
329
+ q.add_argument('--human-decision-recorded',action='store_true')
319
330
  for command in ['connect','remote-context','remember','artifact','artifact-get','remote-recover']:
320
331
  q=sub.add_parser(command,help='Use public Topolo Agent continuity and Bytes artifact contracts')
321
332
  q.add_argument('name');q.add_argument('--environment',choices=['development','staging','production'],required=True)
@@ -324,6 +335,7 @@ def parser():
324
335
  if command=='connect':
325
336
  q.add_argument('--roles',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace',required=True)
326
337
  q.add_argument('--ownership',choices=['user','organization'],required=True);q.add_argument('--bytes-scope',choices=['private','org'],required=True);q.add_argument('--instance')
338
+ q.add_argument('--reconcile-local-context',action='store_true',help='Explicitly reconcile a recorded partial context attachment; never replays cloud writes')
327
339
  if command=='remember':
328
340
  q.add_argument('--content',required=True);q.add_argument('--kind',choices=['instruction','knowledge','durable'],required=True)
329
341
  q.add_argument('--provenance',required=True);q.add_argument('--visibility',choices=['private','shared'],default='private')
@@ -347,14 +359,19 @@ def main(argv=None):
347
359
  parser().error('--harness requires codex or claude')
348
360
  harness = argv[at + 1]
349
361
  del argv[at:at + 2]
350
- policy_command = 'policy' in argv
351
- if harness == 'claude' and not policy_command:
362
+ policy_command = 'policy' in argv or 'permissions' in argv
363
+ command_args=list(argv)
364
+ if command_args and command_args[0]=='--store':command_args=command_args[2:]
365
+ elif command_args and command_args[0].startswith('--store='):command_args=command_args[1:]
366
+ cloud_command=bool(command_args and command_args[0] in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'})
367
+ if harness == 'claude' and not policy_command and not cloud_command:
352
368
  from . import claude
353
369
  return claude.main(argv)
354
370
  if harness not in {'codex', 'claude'}:
355
371
  print('topolo-agent-pack: Unsupported harness: ' + harness, file=sys.stderr)
356
372
  return 2
357
373
  args=parser().parse_args(argv)
374
+ args.harness=harness
358
375
  try:
359
376
  lib=Library(args.store)
360
377
  if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
@@ -363,11 +380,38 @@ def main(argv=None):
363
380
  emit(result)
364
381
  return 0
365
382
  with lib.lock():
366
- if args.command=='policy':
367
- from . import policy
383
+ if args.command=='approvals':
384
+ if args.operation!='list' and (not args.key or not args.intake or not args.fingerprint):
385
+ raise PackError('Exact request key, --intake and --fingerprint required')
386
+ params={'key':args.key,'intakeThreadId':args.intake,'fingerprint':args.fingerprint}
387
+ if args.operation=='respond':
388
+ if not args.response or not args.human_decision_recorded:
389
+ raise PackError('Response file and recorded human decision required')
390
+ params.update(result=read_json(args.response),humanDecisionRecorded=True)
391
+ result=live.request(lib,'approval-'+args.operation,params)
392
+ elif args.command=='permissions':
393
+ from . import permissions, topolo
394
+ if args.apply and harness!='codex':
395
+ raise PackError('Use native Claude configure with the reviewed policy')
396
+ result=permissions.configure(lib,args.source,args.profile,read_json(args.routes),args.environment,args.context_key,
397
+ topolo.Client(args.environment,args.context_key),args.agent_workspace)
398
+ if args.apply:
399
+ result=live.request(lib,'permissions-configure',{'pack':args.source})
400
+ elif args.command=='policy':
401
+ from . import policy, permissions
368
402
  manifest,revision=lib.load(args.source)
369
403
  result=policy.render(manifest,revision,select(manifest,args.sets,args.agents),
370
- (harness,) if explicit_harness else ('codex','claude'))
404
+ (harness,) if explicit_harness else ('codex','claude'),
405
+ permissions.load(lib,manifest['packId'],manifest['agents']))
406
+ if args.verify_organization:
407
+ from . import topolo
408
+ consent=result['installationConsent']
409
+ if not consent:
410
+ raise PackError('Organization-bound installation consent is unavailable')
411
+ remote,proof=topolo.organization_execution_policy(
412
+ topolo.Client(consent['environment'],consent['contextKey']),consent,
413
+ manifest['agents'],consent.get('agentWorkspaceId'))
414
+ result.update(organizationPolicy=remote,organizationPolicyProof=proof)
371
415
  if args.output:
372
416
  from .claude import write_new
373
417
  write_new(pathlib.Path(args.output).expanduser().absolute(), canonical(result)+b'\n')
@@ -13,28 +13,40 @@ import subprocess
13
13
  import tempfile
14
14
  from contextlib import closing
15
15
 
16
- from . import policy
16
+ from . import policy, permissions
17
17
  from .storage import PackError, atomic_json, component_key, read_json
18
18
 
19
19
 
20
- def compile_runtime(lib, components, runtime):
20
+ def prepare_runtime(lib, components, runtime):
21
+ """Validate existing role intent without installing a policy or changing history."""
21
22
  runtime = pathlib.Path(runtime)
22
23
  with closing(sqlite3.connect('file:' + str(runtime / 'home/state_5.sqlite') + '?mode=ro', uri=True)) as db:
23
24
  cwds = dict(db.execute('SELECT id,cwd FROM threads'))
24
- rows = []
25
+ rows = []; keys = set(); identities = set()
25
26
  for component in components:
26
27
  manifest, revision = lib.load(component['sourcePack'], component['revision'])
27
28
  matches = [a for a in manifest['agents'] if component_key(a) == component['key'] and a['threadId'] == component['threadId']]
28
- if len(matches) != 1 or component['threadId'] not in cwds:
29
+ if (len(matches) != 1 or component['threadId'] not in cwds or revision != component['revision'] or
30
+ component['key'] in keys or component['threadId'] in identities):
29
31
  raise PackError('Execution policy has no unique owned role: ' + component['key'])
32
+ keys.add(component['key']); identities.add(component['threadId'])
33
+ consent = permissions.load(lib, matches[0]['pack'], manifest['agents']) if hasattr(lib, 'root') else None
30
34
  rows.append({'key': component['key'], 'threadId': component['threadId'],
31
35
  'sourceRevision': revision, 'cwd': cwds[component['threadId']],
32
- 'settings': policy.settings(matches[0], 'codex')['nativeSettings']})
36
+ 'intakeKey': consent['intakeRoutes'][component['key']] if consent else None,
37
+ 'contextKey': consent['contextKey'] if consent else None,
38
+ 'installationConsent': consent,
39
+ 'settings': policy.settings(matches[0], 'codex', consent)['nativeSettings']})
33
40
  document = {'format': 'topolo-codex-runtime-execution', 'schemaVersion': 1,
34
41
  'roles': rows, 'applied': False, 'modelTurnsStarted': False}
35
42
  # Construct/validate fully before installing intent in a staged runtime.
36
43
  ExecutionGate(document)
37
- atomic_json(runtime / 'execution-policy.json', document)
44
+ return document
45
+
46
+
47
+ def compile_runtime(lib, components, runtime):
48
+ document = prepare_runtime(lib, components, runtime)
49
+ atomic_json(pathlib.Path(runtime) / 'execution-policy.json', document)
38
50
  return document
39
51
 
40
52
 
@@ -51,11 +63,15 @@ class ExecutionGate:
51
63
  'sandbox_mode', 'sandbox_workspace_write', 'apps'}
52
64
  if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
53
65
  spec['model_reasoning_effort'] not in {'medium', 'high'} or
54
- spec['approval_policy'] != 'on-request' or spec['approvals_reviewer'] != 'user' or
55
- spec['sandbox_mode'] not in {'read-only', 'workspace-write'} or
66
+ spec['approval_policy'] not in {'on-request', 'never'} or spec['approvals_reviewer'] != 'user' or
67
+ spec['sandbox_mode'] not in {'read-only', 'workspace-write', 'danger-full-access'} or
68
+ (spec['sandbox_mode'] == 'danger-full-access') != (spec['approval_policy'] == 'never') or
56
69
  spec['sandbox_workspace_write'] != {'network_access': False, 'writable_roots': [],
57
70
  'exclude_slash_tmp': True, 'exclude_tmpdir_env_var': True} or
58
- spec['apps'] != {'_default': {'default_tools_approval_mode': 'writes'}}):
71
+ spec['apps'] not in [{'_default': {'default_tools_approval_mode': v}} for v in ['writes', 'prompt', 'approve']] or
72
+ (spec['apps'] == {'_default': {'default_tools_approval_mode': 'approve'}} and spec['sandbox_mode'] != 'danger-full-access') or
73
+ (spec['sandbox_mode'] == 'danger-full-access' and
74
+ (spec['apps'] != {'_default': {'default_tools_approval_mode': 'approve'}} or not role.get('contextKey') or not role.get('intakeKey')))):
59
75
  raise PackError('Unqualified role execution settings')
60
76
  self.roles[tid] = copy.deepcopy(role)
61
77
 
@@ -64,6 +80,8 @@ class ExecutionGate:
64
80
  for row in result['data']}
65
81
 
66
82
  def sandbox(self, role):
83
+ if role['settings']['sandbox_mode'] == 'danger-full-access':
84
+ return {'type': 'dangerFullAccess'}
67
85
  if role['settings']['sandbox_mode'] == 'read-only':
68
86
  return {'type': 'readOnly', 'networkAccess': False}
69
87
  return {'type': 'workspaceWrite', 'networkAccess': False, 'writableRoots': [role['cwd']],