@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.
@@ -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
@@ -493,3 +494,68 @@ 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
495
  until explicitly rebuilt. Source fixtures do not establish live desktop or
495
496
  three-environment shared-memory acceptance.
497
+
498
+ ### Organization consent and intake permission routing
499
+
500
+ Pack export never carries machine-local permission consent. An authenticated
501
+ user records consent separately, with an explicit environment, organization,
502
+ Agent workspace and complete mapping of role keys to owned intake keys:
503
+
504
+ ```sh
505
+ topolo agent-packs permissions topolo-clean --profile full-access \
506
+ --routes /absolute/intake-routes.json --environment production \
507
+ --context-key org:<organization-id> --agent-workspace <workspace-id> --confirm
508
+ topolo agent-packs policy topolo-clean --verify-organization \
509
+ --harness claude --output /absolute/review.json
510
+ ```
511
+
512
+ Profiles are `manual`, `role-default` and `full-access`. Per-role consent may
513
+ reduce the selected profile. Every role must route to an intake inside the same
514
+ owned roster and organization. Local consent grants no organization authority:
515
+ the public Agent organization execution-policy read supplies the current
516
+ profile ceiling. Missing, malformed, stale-context or unavailable policy
517
+ refuses before native grants. Legacy 1.0 policies use allowlists, including
518
+ empty-list denial; 1.1 adds explicit `scopePolicyMode`. Native application of
519
+ an allowlist is refused until the adapter can enforce its exact tool/action
520
+ scopes. An administrator may explicitly select 1.1 `unrestricted` with empty
521
+ tool/action lists; intake restrictions remain enforced separately.
522
+
523
+ For Codex, append `--apply` to the consent command to configure the existing
524
+ owned idle backend. For Claude, record consent with `--harness claude`, render
525
+ the reviewed policy, then use native `configure --apply`. Models, effort,
526
+ thread identities, histories and charters are preserved. Native settings
527
+ must acknowledge all selected roles before completion. An interrupted batch
528
+ retains an operation marker and refuses replay pending owner reconciliation.
529
+ Full access configures Codex `never`/`danger-full-access` and Claude
530
+ `bypassPermissions`; it does not bypass platform authorization or business
531
+ decision rules. Claude Manual is not filesystem confinement.
532
+
533
+ Real native permission requests are recorded with request fingerprints and
534
+ sent to the owning intake as generated notifications, never as human consent.
535
+ Idle agents consume no model turn merely because routing was installed.
536
+ Busy intakes wait for their next native event. Delivery or response uncertainty
537
+ is durable and never silently replayed. A human answer is relayed for one exact
538
+ pending request:
539
+
540
+ ```sh
541
+ topolo agent-packs approvals list
542
+ topolo agent-packs approvals details <key> --intake <thread-id> --fingerprint <hash>
543
+ topolo agent-packs approvals respond <key> --intake <thread-id> \
544
+ --fingerprint <hash> --response /absolute/answer.json --human-decision-recorded
545
+ # Add --harness claude for Claude requests; its answer is {"decision":"once"} or {"decision":"deny"}.
546
+ ```
547
+
548
+ The recorded-human flag is a workflow assertion, not cryptographic identity
549
+ proof. The broker preserves native authorization and never invents card origin,
550
+ standing grants or action completion. Claude card-only grants and unsupported
551
+ Codex elicitation/permission kinds remain visible in the intake but require
552
+ their original native interaction. Full-access consent cannot override managed
553
+ native restrictions.
554
+
555
+ Claude setup installs an event listener and private bounded Unix control socket
556
+ inside the same hash-qualified native process. The temporary setup inspector
557
+ may close; approvals use this socket, with private owner-only directory/token,
558
+ exact PID/roster identity and current module qualification. No independent
559
+ daemon, polling loop, arbitrary evaluation endpoint or UI pack fix is used.
560
+ Audit failures refuse further broker operation. Cached reports and source
561
+ 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)
@@ -347,7 +358,7 @@ def main(argv=None):
347
358
  parser().error('--harness requires codex or claude')
348
359
  harness = argv[at + 1]
349
360
  del argv[at:at + 2]
350
- policy_command = 'policy' in argv
361
+ policy_command = 'policy' in argv or 'permissions' in argv
351
362
  if harness == 'claude' and not policy_command:
352
363
  from . import claude
353
364
  return claude.main(argv)
@@ -363,11 +374,38 @@ def main(argv=None):
363
374
  emit(result)
364
375
  return 0
365
376
  with lib.lock():
366
- if args.command=='policy':
367
- from . import policy
377
+ if args.command=='approvals':
378
+ if args.operation!='list' and (not args.key or not args.intake or not args.fingerprint):
379
+ raise PackError('Exact request key, --intake and --fingerprint required')
380
+ params={'key':args.key,'intakeThreadId':args.intake,'fingerprint':args.fingerprint}
381
+ if args.operation=='respond':
382
+ if not args.response or not args.human_decision_recorded:
383
+ raise PackError('Response file and recorded human decision required')
384
+ params.update(result=read_json(args.response),humanDecisionRecorded=True)
385
+ result=live.request(lib,'approval-'+args.operation,params)
386
+ elif args.command=='permissions':
387
+ from . import permissions, topolo
388
+ if args.apply and harness!='codex':
389
+ raise PackError('Use native Claude configure with the reviewed policy')
390
+ result=permissions.configure(lib,args.source,args.profile,read_json(args.routes),args.environment,args.context_key,
391
+ topolo.Client(args.environment,args.context_key),args.agent_workspace)
392
+ if args.apply:
393
+ result=live.request(lib,'permissions-configure',{'pack':args.source})
394
+ elif args.command=='policy':
395
+ from . import policy, permissions
368
396
  manifest,revision=lib.load(args.source)
369
397
  result=policy.render(manifest,revision,select(manifest,args.sets,args.agents),
370
- (harness,) if explicit_harness else ('codex','claude'))
398
+ (harness,) if explicit_harness else ('codex','claude'),
399
+ permissions.load(lib,manifest['packId'],manifest['agents']))
400
+ if args.verify_organization:
401
+ from . import topolo
402
+ consent=result['installationConsent']
403
+ if not consent:
404
+ raise PackError('Organization-bound installation consent is unavailable')
405
+ remote,proof=topolo.organization_execution_policy(
406
+ topolo.Client(consent['environment'],consent['contextKey']),consent,
407
+ manifest['agents'],consent.get('agentWorkspaceId'))
408
+ result.update(organizationPolicy=remote,organizationPolicyProof=proof)
371
409
  if args.output:
372
410
  from .claude import write_new
373
411
  write_new(pathlib.Path(args.output).expanduser().absolute(), canonical(result)+b'\n')
@@ -13,7 +13,7 @@ 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
 
@@ -27,9 +27,13 @@ def compile_runtime(lib, components, runtime):
27
27
  matches = [a for a in manifest['agents'] if component_key(a) == component['key'] and a['threadId'] == component['threadId']]
28
28
  if len(matches) != 1 or component['threadId'] not in cwds:
29
29
  raise PackError('Execution policy has no unique owned role: ' + component['key'])
30
+ consent = permissions.load(lib, matches[0]['pack'], manifest['agents']) if hasattr(lib, 'root') else None
30
31
  rows.append({'key': component['key'], 'threadId': component['threadId'],
31
32
  'sourceRevision': revision, 'cwd': cwds[component['threadId']],
32
- 'settings': policy.settings(matches[0], 'codex')['nativeSettings']})
33
+ 'intakeKey': consent['intakeRoutes'][component['key']] if consent else None,
34
+ 'contextKey': consent['contextKey'] if consent else None,
35
+ 'installationConsent': consent,
36
+ 'settings': policy.settings(matches[0], 'codex', consent)['nativeSettings']})
33
37
  document = {'format': 'topolo-codex-runtime-execution', 'schemaVersion': 1,
34
38
  'roles': rows, 'applied': False, 'modelTurnsStarted': False}
35
39
  # Construct/validate fully before installing intent in a staged runtime.
@@ -51,11 +55,15 @@ class ExecutionGate:
51
55
  'sandbox_mode', 'sandbox_workspace_write', 'apps'}
52
56
  if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
53
57
  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
58
+ spec['approval_policy'] not in {'on-request', 'never'} or spec['approvals_reviewer'] != 'user' or
59
+ spec['sandbox_mode'] not in {'read-only', 'workspace-write', 'danger-full-access'} or
60
+ (spec['sandbox_mode'] == 'danger-full-access') != (spec['approval_policy'] == 'never') or
56
61
  spec['sandbox_workspace_write'] != {'network_access': False, 'writable_roots': [],
57
62
  'exclude_slash_tmp': True, 'exclude_tmpdir_env_var': True} or
58
- spec['apps'] != {'_default': {'default_tools_approval_mode': 'writes'}}):
63
+ spec['apps'] not in [{'_default': {'default_tools_approval_mode': v}} for v in ['writes', 'prompt', 'approve']] or
64
+ (spec['apps'] == {'_default': {'default_tools_approval_mode': 'approve'}} and spec['sandbox_mode'] != 'danger-full-access') or
65
+ (spec['sandbox_mode'] == 'danger-full-access' and
66
+ (spec['apps'] != {'_default': {'default_tools_approval_mode': 'approve'}} or not role.get('contextKey') or not role.get('intakeKey')))):
59
67
  raise PackError('Unqualified role execution settings')
60
68
  self.roles[tid] = copy.deepcopy(role)
61
69
 
@@ -64,6 +72,8 @@ class ExecutionGate:
64
72
  for row in result['data']}
65
73
 
66
74
  def sandbox(self, role):
75
+ if role['settings']['sandbox_mode'] == 'danger-full-access':
76
+ return {'type': 'dangerFullAccess'}
67
77
  if role['settings']['sandbox_mode'] == 'read-only':
68
78
  return {'type': 'readOnly', 'networkAccess': False}
69
79
  return {'type': 'workspaceWrite', 'networkAccess': False, 'writableRoots': [role['cwd']],