@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.
- package/agent-packs/README.md +81 -3
- 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 +49 -5
- package/agent-packs/src/topolo_agent_pack/execution.py +27 -9
- package/agent-packs/src/topolo_agent_pack/live.py +338 -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 +264 -12
- package/dist/{claude-pack-backend-IDQQX3DG.js → claude-pack-backend-5DWMIDRA.js} +442 -10
- package/dist/index.js +48 -14
- package/package.json +3 -3
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
|
|
@@ -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
|
|
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
|
-
|
|
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=='
|
|
367
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
|
|
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']
|
|
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']
|
|
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']],
|