@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
package/agent-packs/README.md
CHANGED
|
@@ -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.
|
|
434
|
-
|
|
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=='
|
|
367
|
-
|
|
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
|
-
'
|
|
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']
|
|
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']
|
|
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']],
|