msgctl 0.1.0a1__py3-none-any.whl
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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Retention and rebuilding apply deployment-wide, never personal storage quotas."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from msg.constants import ROOT_SPACE, ONLINE_CA
|
|
12
|
+
from msg.core.codec import canonical, decode, digest, loads, wire, parse_time
|
|
13
|
+
from msg.core.errors import Failure, require
|
|
14
|
+
from msg.core.models import AuditEvent, BlobRef, Event, ResourceRef, Revision, Resource, TransferSession
|
|
15
|
+
from msg.plugins.common import new_id
|
|
16
|
+
from msg.storage.capacity import trim_purge_records
|
|
17
|
+
|
|
18
|
+
TODO_DELIVERY_BATCH_SIZE = 64
|
|
19
|
+
GARBAGE_COLLECTION_INTERVAL_SECONDS = 3600
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _release_pin(app, tx, blob, lease):
|
|
23
|
+
# Restore a pre-existing pin if a later step aborts the SQL transaction.
|
|
24
|
+
# Metadata writers (including the collector) share one serialization fence.
|
|
25
|
+
if await app.contents.pinned(blob, lease):
|
|
26
|
+
tx.on_rollback(lambda: app.contents.pin(blob, lease))
|
|
27
|
+
await app.contents.unpin(blob, lease)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def purge_revisions(app, tx, resource, *, actor, request_id, reason):
|
|
31
|
+
revisions = [decode(Revision,loads(raw)) for (raw,) in tx.execute(
|
|
32
|
+
'SELECT body FROM revisions WHERE resource_id=?',(resource.id,))]
|
|
33
|
+
tx.set_setting('purge_record:'+resource.id, {'revisions':wire(revisions),'reason':reason,'time':wire(app.clock())})
|
|
34
|
+
trim_purge_records(tx)
|
|
35
|
+
changed = replace(resource, state='purged', revision=None, generation=resource.generation+1,
|
|
36
|
+
modified_at=app.clock(), modified_by=actor)
|
|
37
|
+
await tx.replace(changed,resource.generation)
|
|
38
|
+
for revision in revisions:
|
|
39
|
+
await _release_pin(app,tx,revision.content,revision.id)
|
|
40
|
+
tx.execute('DELETE FROM revisions WHERE resource_id=?',(resource.id,),write=True)
|
|
41
|
+
tx.execute('DELETE FROM projections WHERE resource_id=?',(resource.id,),write=True)
|
|
42
|
+
# A surviving reply keeps its own revision. Relations into this tombstone
|
|
43
|
+
# remain stable references, not dangling filesystem paths.
|
|
44
|
+
event = Event(id=new_id('audit'), type='lifecycle.purge', time=app.clock(), request_id=request_id,
|
|
45
|
+
actor=actor, subject=actor, resources=(ResourceRef(id=resource.id),), data={'reason':reason,'system_policy':True})
|
|
46
|
+
await tx.append_audit(AuditEvent(event=event,authority=(),before_digest=digest(resource),after_digest=digest(changed),
|
|
47
|
+
previous_digest=None,entry_digest='',result='purged'))
|
|
48
|
+
return len(revisions)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _cleanup(app,tx, *, scheduled):
|
|
52
|
+
counts={'expired_resources':0,'expired_transfers':0,'expired_query_sources':0,
|
|
53
|
+
'expired_custodial_upgrades':0}
|
|
54
|
+
if not tx.setting('runtime_config',{}).get('cleanup_enabled',True):
|
|
55
|
+
return counts
|
|
56
|
+
now=app.clock()
|
|
57
|
+
policies=tx.rows("SELECT key,value FROM settings WHERE key LIKE 'policy:%'")
|
|
58
|
+
for key,raw in policies:
|
|
59
|
+
policy=loads(raw)
|
|
60
|
+
if 'retention_seconds' not in policy:
|
|
61
|
+
continue
|
|
62
|
+
rid=key.removeprefix('policy:')
|
|
63
|
+
last=tx.setting('cleanup_last:'+rid)
|
|
64
|
+
if scheduled and last and (now-parse_time(last)).total_seconds()<policy.get('cleanup_interval_seconds',3600):
|
|
65
|
+
continue
|
|
66
|
+
cutoff=now-timedelta(seconds=policy['retention_seconds'])
|
|
67
|
+
# Children are captured under this transaction. Descendants are evaluated
|
|
68
|
+
# through the unique safety parent, never reply/quote relations.
|
|
69
|
+
rows=tx.rows('WITH RECURSIVE descendants(id,depth) AS '
|
|
70
|
+
'(SELECT id,1 FROM resources WHERE parent=? UNION ALL '
|
|
71
|
+
'SELECT r.id,d.depth+1 FROM resources r JOIN descendants d ON r.parent=d.id) '
|
|
72
|
+
'SELECT r.body,d.depth FROM descendants d JOIN resources r ON r.id=d.id ORDER BY d.depth DESC', (rid,))
|
|
73
|
+
for raw,_depth in rows:
|
|
74
|
+
resource=decode(Resource,loads(raw))
|
|
75
|
+
if resource.state=='purged' or resource.created_at>cutoff:
|
|
76
|
+
continue
|
|
77
|
+
# Controlled identities/credentials/certificates never expire through
|
|
78
|
+
# a generic topic rule. A container with retained children stays.
|
|
79
|
+
if resource.type not in {'post','file','attachment','skill','template','topic','keystore'}:
|
|
80
|
+
continue
|
|
81
|
+
if tx.one("SELECT id FROM resources WHERE parent=? AND state<>'purged' LIMIT 1",(resource.id,)):
|
|
82
|
+
continue
|
|
83
|
+
await purge_revisions(app,tx,resource,actor=ONLINE_CA,request_id='retention:'+rid+':'+wire(now),reason='retention_expired')
|
|
84
|
+
counts['expired_resources']+=1
|
|
85
|
+
tx.set_setting('cleanup_last:'+rid,wire(now))
|
|
86
|
+
for (raw,) in tx.execute('SELECT body FROM transfers'):
|
|
87
|
+
transfer=decode(TransferSession,loads(raw))
|
|
88
|
+
if transfer.expires_at<=now and transfer.state!='expired':
|
|
89
|
+
await tx.save_transfer(replace(transfer,state='expired',generation=transfer.generation+1),transfer.generation)
|
|
90
|
+
for offset,chunk_raw in tx.execute(
|
|
91
|
+
'SELECT offset,body FROM chunks WHERE transfer_id=?',(transfer.id,)):
|
|
92
|
+
blob=decode(BlobRef,loads(chunk_raw)['content'])
|
|
93
|
+
await _release_pin(app,tx,blob,transfer.id+':'+str(offset))
|
|
94
|
+
if transfer.state=='sealed' and transfer.direction=='upload':
|
|
95
|
+
# The output revision may already have been purged. The sealed
|
|
96
|
+
# transfer itself retains the content identity needed to unpin.
|
|
97
|
+
blob=BlobRef(digest=transfer.expected_digest,size=transfer.expected_size,
|
|
98
|
+
media_type='application/octet-stream')
|
|
99
|
+
await _release_pin(app,tx,blob,transfer.id+':sealed')
|
|
100
|
+
tx.execute('DELETE FROM chunks WHERE transfer_id=?',(transfer.id,),write=True)
|
|
101
|
+
counts['expired_transfers']+=1
|
|
102
|
+
for key,raw in tx.rows("SELECT key,value FROM settings WHERE key LIKE 'query_ref_source:%'"):
|
|
103
|
+
marker=loads(raw)
|
|
104
|
+
if parse_time(marker['retain_until'])>now:
|
|
105
|
+
continue
|
|
106
|
+
rid=key.removeprefix('query_ref_source:')
|
|
107
|
+
try:
|
|
108
|
+
resource=await tx.resource(rid)
|
|
109
|
+
except Failure as exc:
|
|
110
|
+
if exc.code!='not_found':raise
|
|
111
|
+
tx.execute('DELETE FROM settings WHERE key=?',(key,),write=True)
|
|
112
|
+
continue
|
|
113
|
+
if resource.state=='purged':
|
|
114
|
+
tx.execute('DELETE FROM settings WHERE key=?',(key,),write=True)
|
|
115
|
+
continue
|
|
116
|
+
# A moved, shared or revised file has become ordinary user content.
|
|
117
|
+
if (resource.type!='file' or resource.owner!=marker['subject'] or
|
|
118
|
+
resource.parent!=marker['parent'] or resource.revision!=marker['revision'] or
|
|
119
|
+
resource.mode!=0o600):
|
|
120
|
+
tx.execute('DELETE FROM settings WHERE key=?',(key,),write=True)
|
|
121
|
+
continue
|
|
122
|
+
if tx.one('''SELECT 1 FROM relations rel JOIN resources source
|
|
123
|
+
ON source.id=rel.source_id AND source.revision=rel.revision_id
|
|
124
|
+
AND source.state<>'purged' WHERE rel.target_id=? LIMIT 1''',(rid,)):
|
|
125
|
+
continue
|
|
126
|
+
active_reference=False
|
|
127
|
+
for (transfer_raw,) in tx.execute('SELECT body FROM transfers'):
|
|
128
|
+
session=decode(TransferSession,loads(transfer_raw))
|
|
129
|
+
if session.expires_at>now and ((session.target and session.target.id==rid) or
|
|
130
|
+
(session.output and session.output.id==rid)):
|
|
131
|
+
active_reference=True
|
|
132
|
+
break
|
|
133
|
+
if active_reference:
|
|
134
|
+
continue
|
|
135
|
+
await purge_revisions(app,tx,resource,actor=ONLINE_CA,
|
|
136
|
+
request_id='query-ref-retention:'+rid+':'+wire(now),reason='query_ref_expired')
|
|
137
|
+
tx.execute('DELETE FROM settings WHERE key=?',(key,),write=True)
|
|
138
|
+
counts['expired_query_sources']+=1
|
|
139
|
+
tx.execute('DELETE FROM email_challenges WHERE expires<=?',(wire(now),),write=True)
|
|
140
|
+
counts['expired_custodial_upgrades']=tx.execute(
|
|
141
|
+
"DELETE FROM custodial_upgrades WHERE status IN ('pending','failed') AND expires_at<=?",
|
|
142
|
+
(wire(now),),write=True).rowcount
|
|
143
|
+
return counts
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _rebuild(app,tx):
|
|
147
|
+
tx.execute('DELETE FROM projections',write=True)
|
|
148
|
+
count=0
|
|
149
|
+
for (raw,) in tx.execute("SELECT body FROM resources WHERE revision IS NOT NULL AND state<>'purged'"):
|
|
150
|
+
resource=decode(Resource,loads(raw))
|
|
151
|
+
rev=await tx.revision(ResourceRef(id=resource.id,revision=resource.revision))
|
|
152
|
+
if rev.content.size<=1048576 and (rev.content.media_type.startswith('text/') or rev.content.media_type=='application/msg-template'):
|
|
153
|
+
text=(await app.contents.read_bytes(rev.content)).decode('utf-8',errors='replace')
|
|
154
|
+
tx.execute('INSERT INTO projections VALUES (?,?)',(resource.id,text),write=True)
|
|
155
|
+
count+=1
|
|
156
|
+
# Rebuilding a projection emits no duplicate email, certificate or message.
|
|
157
|
+
return {'indexed_resources':count}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _deliver_due_todos(app,tx):
|
|
161
|
+
"""Deliver current due todo revisions to their owner, once per due instant.
|
|
162
|
+
|
|
163
|
+
The maintenance transaction is the only producer. A revised, completed or
|
|
164
|
+
archived todo is evaluated from its current state rather than a stale job.
|
|
165
|
+
"""
|
|
166
|
+
now=app.clock()
|
|
167
|
+
delivered=0
|
|
168
|
+
cursor=tx.setting('todo_delivery_cursor','')
|
|
169
|
+
rows=tx.rows("SELECT id,body FROM resources WHERE type='todo' AND state='active' "
|
|
170
|
+
"AND id>? ORDER BY id LIMIT ?",(cursor,TODO_DELIVERY_BATCH_SIZE))
|
|
171
|
+
if len(rows)<TODO_DELIVERY_BATCH_SIZE:
|
|
172
|
+
rows+=tx.rows("SELECT id,body FROM resources WHERE type='todo' AND state='active' "
|
|
173
|
+
"AND id<=? ORDER BY id LIMIT ?",(cursor,TODO_DELIVERY_BATCH_SIZE-len(rows)))
|
|
174
|
+
if rows:
|
|
175
|
+
tx.set_setting('todo_delivery_cursor',rows[-1][0])
|
|
176
|
+
for _id,raw in rows:
|
|
177
|
+
resource=decode(Resource,loads(raw))
|
|
178
|
+
if resource.revision is None or resource.mode&0o077:
|
|
179
|
+
continue
|
|
180
|
+
parent=await tx.resource(resource.parent)
|
|
181
|
+
if (parent.name!='todos' or parent.parent!=resource.owner or
|
|
182
|
+
parent.owner!=resource.owner or
|
|
183
|
+
parent.mode&0o077 or parent.state!='active'):
|
|
184
|
+
continue
|
|
185
|
+
revision=await tx.revision(ResourceRef(id=resource.id,revision=resource.revision))
|
|
186
|
+
details=loads((await app.contents.read_bytes(revision.content)).decode('utf-8'))
|
|
187
|
+
due_at=details.get('due_at')
|
|
188
|
+
if (not due_at or details.get('status') not in {'pending','in_progress'} or
|
|
189
|
+
parse_time(due_at)>now):
|
|
190
|
+
continue
|
|
191
|
+
# An id stable across worker restarts and unrelated edits prevents
|
|
192
|
+
# duplicate reminders, while a changed due instant is a new reminder.
|
|
193
|
+
notice_id='m_'+digest(('todo_due',resource.id,due_at))[7:39]
|
|
194
|
+
record={'id':notice_id,'sender':None,'actor':ONLINE_CA,
|
|
195
|
+
'recipient':resource.owner,'resource':wire(ResourceRef(id=resource.id)),
|
|
196
|
+
'time':wire(now),'state':'delivered','source':'todo_due',
|
|
197
|
+
'due_at':due_at,'todo_revision_at_delivery':resource.revision,
|
|
198
|
+
'status_at_delivery':details['status']}
|
|
199
|
+
inserted=tx.execute('''INSERT INTO messages (id,sender,recipient,resource,event_id,body)
|
|
200
|
+
VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING''',
|
|
201
|
+
(notice_id,None,resource.owner,resource.id,notice_id,
|
|
202
|
+
canonical(record).decode()),write=True).rowcount
|
|
203
|
+
if not inserted:
|
|
204
|
+
continue
|
|
205
|
+
event=Event(id=new_id('audit'),type='identity.todo.due_notice',time=now,
|
|
206
|
+
request_id='todo-due:'+notice_id,actor=ONLINE_CA,subject=resource.owner,
|
|
207
|
+
resources=(ResourceRef(id=resource.id,revision=resource.revision),),
|
|
208
|
+
data={'message_id':notice_id,'due_at':due_at})
|
|
209
|
+
await tx.append_audit(AuditEvent(event=event,authority=(),
|
|
210
|
+
before_digest=digest(resource),after_digest=digest(record),
|
|
211
|
+
previous_digest=None,entry_digest='',result='delivered'))
|
|
212
|
+
delivered+=1
|
|
213
|
+
return {'delivered_todo_reminders':delivered}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _collect(app,tx, *, grace_seconds=3600):
|
|
217
|
+
"""Remove unreferenced content after a grace period and retained Git ancestry.
|
|
218
|
+
|
|
219
|
+
All remaining revisions are retained, including ordinary archived resources.
|
|
220
|
+
A topic commit can retain old parents: only unreferenced commits/objects are
|
|
221
|
+
pruned. Shared bytes referenced elsewhere are never promised to be erased.
|
|
222
|
+
"""
|
|
223
|
+
live=set()
|
|
224
|
+
revisions=set()
|
|
225
|
+
for (raw,) in tx.execute('SELECT body FROM revisions'):
|
|
226
|
+
rev=decode(Revision,loads(raw)); live.add(rev.content.digest[7:]); revisions.add(rev.id)
|
|
227
|
+
# In-flight chunks and sealed uploads are roots until the transfer expires.
|
|
228
|
+
for raw, in tx.execute('SELECT c.body FROM chunks c JOIN transfers t ON t.id=c.transfer_id'):
|
|
229
|
+
live.add(loads(raw)['content']['digest'][7:])
|
|
230
|
+
from msg.market.references import content_references
|
|
231
|
+
for content in content_references(tx.execute):
|
|
232
|
+
live.add(content['digest'][7:])
|
|
233
|
+
recent=time.time()-grace_seconds
|
|
234
|
+
removed=0
|
|
235
|
+
# Explicit pins are live roots even before a Revision/Transfer pointer is
|
|
236
|
+
# committed. In particular, tool jobs may hold a blob without either row.
|
|
237
|
+
git_pins=await asyncio.to_thread(app.contents._run,'for-each-ref','--format=%(refname)','refs/pins')
|
|
238
|
+
pinned_git={ref.rsplit('/',1)[-1] for ref in git_pins.splitlines()}
|
|
239
|
+
pin_dir=app.contents.path/'pins'
|
|
240
|
+
for entry in app.contents.index.iterdir():
|
|
241
|
+
if not entry.is_file() or entry.name in live or entry.stat().st_mtime>recent:
|
|
242
|
+
continue
|
|
243
|
+
# Ref removal is idempotent. Git's reachability remains the final guard
|
|
244
|
+
# for shared/parent objects before physical pruning.
|
|
245
|
+
key=entry.name
|
|
246
|
+
if len(key)!=64 or any(c not in '0123456789abcdef' for c in key):
|
|
247
|
+
continue
|
|
248
|
+
if key in pinned_git or (pin_dir.exists() and any(pin_dir.glob('*/'+key))):
|
|
249
|
+
continue
|
|
250
|
+
data=loads(entry.read_bytes())
|
|
251
|
+
if data['kind']=='git':
|
|
252
|
+
refs=await asyncio.to_thread(app.contents._run,'for-each-ref','--format=%(refname)','refs/staging')
|
|
253
|
+
for ref in refs.splitlines():
|
|
254
|
+
if ref.endswith('/'+key):
|
|
255
|
+
await asyncio.to_thread(app.contents._run,'update-ref','-d',ref)
|
|
256
|
+
else:
|
|
257
|
+
binary=app.contents.binary/key
|
|
258
|
+
# LFS repository links are durable roots of the shared binary CAS.
|
|
259
|
+
# Dropping the content index is safe, but keep the canonical inode
|
|
260
|
+
# while any repository still references it.
|
|
261
|
+
if binary.exists() and binary.stat().st_nlink==1:
|
|
262
|
+
binary.unlink()
|
|
263
|
+
entry.unlink(missing_ok=True)
|
|
264
|
+
removed+=1
|
|
265
|
+
# Only roots belonging to revisions actually removed from metadata are removed.
|
|
266
|
+
refs=await asyncio.to_thread(app.contents._run,'for-each-ref','--format=%(refname)','refs/topics')
|
|
267
|
+
keep_hashes={hashlib.sha256(r.encode()).hexdigest() for r in revisions}
|
|
268
|
+
for ref in refs.splitlines():
|
|
269
|
+
if ref.rsplit('/',1)[-1] not in keep_hashes:
|
|
270
|
+
await asyncio.to_thread(app.contents._run,'update-ref','-d',ref)
|
|
271
|
+
revdir=app.contents.path/'revisions'
|
|
272
|
+
if revdir.exists():
|
|
273
|
+
for entry in revdir.iterdir():
|
|
274
|
+
if entry.name not in revisions and entry.stat().st_mtime<=recent:
|
|
275
|
+
entry.unlink(missing_ok=True)
|
|
276
|
+
await asyncio.to_thread(app.contents._run,'gc','--prune=1.hour.ago')
|
|
277
|
+
from msg.storage.git import LFSObjectStore
|
|
278
|
+
lfs=LFSObjectStore(Path('/nonexistent'),app.settings.server.blob_dir)
|
|
279
|
+
collected,freed=await asyncio.to_thread(lfs.collect_unlinked,
|
|
280
|
+
older_than=recent,index=app.contents.index)
|
|
281
|
+
return {'unreferenced_contents_removed':removed,'shared_bytes':'retained_if_referenced',
|
|
282
|
+
'backups':'managed_separately','unlinked_lfs_objects_removed':collected,
|
|
283
|
+
'unlinked_lfs_bytes_removed':freed}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def run_maintenance(app,action, *, scheduled=False,principal=None):
|
|
287
|
+
require(action in {'cleanup_expired','deliver_due_todos','rebuild_search','collect_garbage'},'unknown_maintenance_action')
|
|
288
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
289
|
+
from msg.security.quarantine import active as quarantine_active
|
|
290
|
+
if quarantine_active(tx):
|
|
291
|
+
return {'skipped':'recovery_quarantine'}
|
|
292
|
+
if not scheduled:
|
|
293
|
+
require(principal is not None,'maintenance_authority_required')
|
|
294
|
+
from msg.workers.effects import current_principal
|
|
295
|
+
live=await current_principal(app,principal,tx)
|
|
296
|
+
require(await app.authorizer.has(live,'system.maintenance','system.maintenance@1',ROOT_SPACE,tx),
|
|
297
|
+
'capability_required')
|
|
298
|
+
if action=='cleanup_expired':return await _cleanup(app,tx,scheduled=scheduled)
|
|
299
|
+
if action=='deliver_due_todos':return await _deliver_due_todos(app,tx)
|
|
300
|
+
if action=='rebuild_search':return await _rebuild(app,tx)
|
|
301
|
+
if scheduled:
|
|
302
|
+
if not tx.setting('runtime_config',{}).get('cleanup_enabled',True):
|
|
303
|
+
return {'skipped':'cleanup_disabled'}
|
|
304
|
+
last=tx.setting('garbage_collection_last')
|
|
305
|
+
if last and (app.clock()-parse_time(last)).total_seconds()<GARBAGE_COLLECTION_INTERVAL_SECONDS:
|
|
306
|
+
return {'skipped':'not_due'}
|
|
307
|
+
result=await _collect(app,tx)
|
|
308
|
+
if scheduled:
|
|
309
|
+
tx.set_setting('garbage_collection_last',wire(app.clock()))
|
|
310
|
+
return result
|
msg/workers/sandbox.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Mandatory OS-isolated tool subprocess; no unsandboxed fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from msg.core.codec import canonical, loads, wire
|
|
8
|
+
from msg.core.errors import Failure, require
|
|
9
|
+
from msg.core.models import JsonMap, NetworkPolicy, ToolSpec
|
|
10
|
+
from msg.core.tool_execution import ToolResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BubblewrapRunner:
|
|
14
|
+
def __init__(self, app):
|
|
15
|
+
self.app = app
|
|
16
|
+
|
|
17
|
+
async def __call__(self, tool: ToolSpec, arguments: JsonMap,
|
|
18
|
+
policies: tuple[NetworkPolicy, ...], directory: Path) -> ToolResult:
|
|
19
|
+
bwrap = shutil.which('bwrap')
|
|
20
|
+
require(bwrap is not None, 'tool_isolation_unavailable')
|
|
21
|
+
source = Path(__file__).resolve().parents[2]
|
|
22
|
+
# Only trusted interpreter/library trees and this package are visible.
|
|
23
|
+
command = [bwrap, '--unshare-all', '--share-net', '--unshare-user', '--disable-userns',
|
|
24
|
+
'--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv',
|
|
25
|
+
'--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp']
|
|
26
|
+
for path in ('/usr', '/bin', '/lib', '/lib64'):
|
|
27
|
+
if Path(path).exists():
|
|
28
|
+
command += ['--ro-bind', path, path]
|
|
29
|
+
prefix = str(Path(sys.prefix).resolve())
|
|
30
|
+
if not prefix.startswith('/usr') and prefix not in {'/','/etc','/home','/mnt'}:
|
|
31
|
+
command += ['--ro-bind', prefix, prefix]
|
|
32
|
+
command += ['--dir','/etc']
|
|
33
|
+
for path in ('/etc/resolv.conf', '/etc/hosts', '/etc/nsswitch.conf', '/etc/ssl/certs'):
|
|
34
|
+
if Path(path).exists():
|
|
35
|
+
command += ['--ro-bind', path, path]
|
|
36
|
+
output = directory/'sandbox-output'
|
|
37
|
+
output.mkdir(mode=0o700)
|
|
38
|
+
command += ['--ro-bind',str(source),'/app', '--bind',str(output),'/output',
|
|
39
|
+
'--setenv','PYTHONPATH','/app','--setenv','PYTHONDONTWRITEBYTECODE','1',
|
|
40
|
+
'--setenv','HOME','/nonexistent','--chdir','/tmp']
|
|
41
|
+
args = dict(arguments)
|
|
42
|
+
body = args.pop('_body_path', None)
|
|
43
|
+
if body:
|
|
44
|
+
command += ['--ro-bind', body, '/request-body']
|
|
45
|
+
args['_body_path'] = '/request-body'
|
|
46
|
+
command += [sys.executable, '-m', 'msg.workers.sandbox_child']
|
|
47
|
+
packet = canonical({'executor':tool.executor_key, 'arguments':args, 'policies':wire(policies)})
|
|
48
|
+
require(len(packet) <= self.app.settings.server.limits.max_request_bytes+65536, 'tool_request_too_large')
|
|
49
|
+
process = await asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.PIPE,
|
|
50
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, limit=65536)
|
|
51
|
+
timeout = max(p.timeout_ms for p in policies)/1000+10
|
|
52
|
+
try:
|
|
53
|
+
stdout, _ = await asyncio.wait_for(process.communicate(packet), timeout)
|
|
54
|
+
except BaseException:
|
|
55
|
+
if process.returncode is None:
|
|
56
|
+
process.kill()
|
|
57
|
+
await process.wait()
|
|
58
|
+
raise Failure('external_uncertain')
|
|
59
|
+
if process.returncode != 0:
|
|
60
|
+
if stdout:
|
|
61
|
+
try:
|
|
62
|
+
detail = loads(stdout)
|
|
63
|
+
raise Failure(detail.get('code', 'external_uncertain'))
|
|
64
|
+
except (ValueError, TypeError) as exc:
|
|
65
|
+
if isinstance(exc, Failure):
|
|
66
|
+
raise
|
|
67
|
+
raise Failure('tool_isolation_failed')
|
|
68
|
+
require(len(stdout) <= 65536, 'invalid_tool_manifest')
|
|
69
|
+
data = loads(stdout)
|
|
70
|
+
source_file = output/'result.bin'
|
|
71
|
+
require(source_file.is_file() and not source_file.is_symlink(), 'invalid_tool_output_path')
|
|
72
|
+
destination = directory/'output.bin'
|
|
73
|
+
source_file.rename(destination)
|
|
74
|
+
return ToolResult(path=destination, media_type=data['media_type'], metadata=data['metadata'])
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Trusted sandbox entrypoint. Receives a bounded policy packet, never code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import resource
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
from urllib.parse import urljoin, urlsplit
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
from aiohttp.abc import AbstractResolver
|
|
14
|
+
import dns.asyncresolver
|
|
15
|
+
|
|
16
|
+
from msg.core.codec import canonical, decode, loads, unb64
|
|
17
|
+
from msg.core.errors import Failure, require
|
|
18
|
+
from msg.core.models import NetworkPolicy
|
|
19
|
+
from msg.security.network import normalized_host, validate_addresses, validate_url
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PinnedResolver(AbstractResolver):
|
|
23
|
+
def __init__(self, hostname, addresses):
|
|
24
|
+
self.hostname, self.addresses = normalized_host(hostname), addresses
|
|
25
|
+
|
|
26
|
+
async def resolve(self, host, port=0, family=socket.AF_UNSPEC):
|
|
27
|
+
require(normalized_host(host) == self.hostname, 'unexpected_dns_target')
|
|
28
|
+
return [{'hostname':host, 'host':address, 'port':port,
|
|
29
|
+
'family':socket.AF_INET6 if ':' in address else socket.AF_INET,
|
|
30
|
+
'proto':socket.IPPROTO_TCP, 'flags':socket.AI_NUMERICHOST} for address in self.addresses]
|
|
31
|
+
|
|
32
|
+
async def close(self):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def addresses_for(host):
|
|
37
|
+
host = normalized_host(host)
|
|
38
|
+
try:
|
|
39
|
+
return [str(ipaddress.ip_address(host))]
|
|
40
|
+
except ValueError:
|
|
41
|
+
records = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
|
42
|
+
return sorted({record[4][0] for record in records})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def select_target(url, method, addresses, policies):
|
|
46
|
+
accepted = []
|
|
47
|
+
for policy in policies:
|
|
48
|
+
try:
|
|
49
|
+
validate_url(url, method, policy)
|
|
50
|
+
validate_addresses(addresses, policy)
|
|
51
|
+
accepted.append(policy)
|
|
52
|
+
except Failure:
|
|
53
|
+
continue
|
|
54
|
+
require(accepted, 'network_policy_denied')
|
|
55
|
+
return accepted
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def http_request(args, policies):
|
|
59
|
+
method, url = args.get('method','GET'), args['url']
|
|
60
|
+
headers = dict(args.get('headers',{}))
|
|
61
|
+
forbidden = {'host','connection','proxy-authorization','proxy-connection','transfer-encoding',
|
|
62
|
+
'content-length','upgrade','te','trailer','forwarded','x-forwarded-for','x-forwarded-host'}
|
|
63
|
+
for name, value in headers.items():
|
|
64
|
+
require(name.lower() not in forbidden and '\r' not in value and '\n' not in value and
|
|
65
|
+
all(33 <= ord(c) <= 126 and c not in '()<>@,;:\\"/[]?={} ' for c in name), 'invalid_request_header')
|
|
66
|
+
body = unb64(args['body']) if 'body' in args else None
|
|
67
|
+
redirects = 0
|
|
68
|
+
# Alternative grants are not combined into a more powerful synthetic policy.
|
|
69
|
+
# Keep only policies which authorize every hop of this request.
|
|
70
|
+
surviving = list(policies)
|
|
71
|
+
started=asyncio.get_running_loop().time()
|
|
72
|
+
def unexpired(candidates):
|
|
73
|
+
elapsed=(asyncio.get_running_loop().time()-started)*1000
|
|
74
|
+
remaining=[p for p in candidates if elapsed<=p.timeout_ms]
|
|
75
|
+
require(remaining,'external_uncertain')
|
|
76
|
+
return remaining
|
|
77
|
+
while True:
|
|
78
|
+
surviving=unexpired(surviving)
|
|
79
|
+
provisional = []
|
|
80
|
+
for policy in surviving:
|
|
81
|
+
try:
|
|
82
|
+
provisional.append(validate_url(url, method, policy))
|
|
83
|
+
except Failure:
|
|
84
|
+
pass
|
|
85
|
+
require(provisional, 'network_policy_denied')
|
|
86
|
+
target = provisional[0]
|
|
87
|
+
addresses = await addresses_for(target.hostname)
|
|
88
|
+
surviving = select_target(url, method, addresses, unexpired(surviving))
|
|
89
|
+
resolver = PinnedResolver(target.hostname, addresses)
|
|
90
|
+
connector = aiohttp.TCPConnector(resolver=resolver, use_dns_cache=False, ttl_dns_cache=0)
|
|
91
|
+
timeout = aiohttp.ClientTimeout(total=max(0.001,max(p.timeout_ms for p in surviving)/1000-(asyncio.get_running_loop().time()-started)))
|
|
92
|
+
stream = open(args['_body_path'],'rb') if '_body_path' in args else None
|
|
93
|
+
try:
|
|
94
|
+
async with aiohttp.ClientSession(connector=connector, timeout=timeout, trust_env=False,
|
|
95
|
+
auto_decompress=False, cookie_jar=aiohttp.DummyCookieJar()) as session:
|
|
96
|
+
async with session.request(method, url, headers=headers, data=stream or body,
|
|
97
|
+
allow_redirects=False) as response:
|
|
98
|
+
surviving=unexpired(surviving)
|
|
99
|
+
if response.status in {301,302,303,307,308} and response.headers.get('Location'):
|
|
100
|
+
require(method in {'GET','HEAD'} or response.status in {307,308}, 'unsafe_redirect_method')
|
|
101
|
+
redirects += 1
|
|
102
|
+
surviving = [p for p in surviving if redirects <= p.max_redirects]
|
|
103
|
+
require(surviving, 'too_many_redirects')
|
|
104
|
+
next_url = urljoin(url, response.headers['Location'])
|
|
105
|
+
# Credentials never follow an origin change.
|
|
106
|
+
if (urlsplit(next_url).scheme,urlsplit(next_url).netloc) != (target.scheme,target.netloc):
|
|
107
|
+
headers = {k:v for k,v in headers.items() if k.lower() not in {'authorization','cookie'}}
|
|
108
|
+
url = next_url
|
|
109
|
+
continue
|
|
110
|
+
length = 0
|
|
111
|
+
with Path('/output/result.bin').open('xb') as output:
|
|
112
|
+
async for chunk in response.content.iter_chunked(65536):
|
|
113
|
+
length += len(chunk)
|
|
114
|
+
surviving = [p for p in unexpired(surviving) if length <= p.max_response_bytes]
|
|
115
|
+
require(surviving, 'tool_response_too_large')
|
|
116
|
+
output.write(chunk)
|
|
117
|
+
output.flush()
|
|
118
|
+
os.fsync(output.fileno())
|
|
119
|
+
surviving=unexpired(surviving)
|
|
120
|
+
selected = {k:v[:4096] for k,v in response.headers.items()
|
|
121
|
+
if k.lower() in {'content-type','content-encoding','etag','last-modified'}}
|
|
122
|
+
return {'media_type':response.headers.get('Content-Type','application/octet-stream')[:255],
|
|
123
|
+
'metadata':{'status':response.status,'headers':selected,'redirects':redirects}}
|
|
124
|
+
finally:
|
|
125
|
+
if stream:
|
|
126
|
+
stream.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def dns_query(args, policies):
|
|
130
|
+
name = normalized_host(args['name'])
|
|
131
|
+
permitted = [p for p in policies if not p.hosts or name in p.hosts]
|
|
132
|
+
require(permitted, 'network_policy_denied')
|
|
133
|
+
resolver = dns.asyncresolver.Resolver()
|
|
134
|
+
# Resolver addresses come only from the operator's read-only resolv.conf.
|
|
135
|
+
started=asyncio.get_running_loop().time()
|
|
136
|
+
answer = await resolver.resolve(name, args['type'], lifetime=max(p.timeout_ms for p in permitted)/1000)
|
|
137
|
+
elapsed=(asyncio.get_running_loop().time()-started)*1000
|
|
138
|
+
permitted=[p for p in permitted if elapsed<=p.timeout_ms]
|
|
139
|
+
require(permitted,'external_uncertain')
|
|
140
|
+
values = [record.to_text() for record in answer]
|
|
141
|
+
if args['type'] in {'A','AAAA'}:
|
|
142
|
+
accepted = []
|
|
143
|
+
for policy in permitted:
|
|
144
|
+
try:
|
|
145
|
+
validate_addresses(values, policy)
|
|
146
|
+
accepted.append(policy)
|
|
147
|
+
except Failure:
|
|
148
|
+
continue
|
|
149
|
+
require(accepted, 'network_policy_denied')
|
|
150
|
+
permitted = accepted
|
|
151
|
+
content = canonical({'name':name,'type':args['type'],'ttl':answer.rrset.ttl,'answers':values})
|
|
152
|
+
require(any(len(content) <= p.max_response_bytes for p in permitted), 'tool_response_too_large')
|
|
153
|
+
Path('/output/result.bin').write_bytes(content)
|
|
154
|
+
return {'media_type':'application/json','metadata':{'kind':'dns'}}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def run(packet):
|
|
158
|
+
policies = tuple(decode(NetworkPolicy, p) for p in packet['policies'])
|
|
159
|
+
require(policies, 'network_policy_denied')
|
|
160
|
+
require(packet['executor'] in {'curl','dns'}, 'untrusted_tool_executor')
|
|
161
|
+
handler = http_request if packet['executor'] == 'curl' else dns_query
|
|
162
|
+
try:
|
|
163
|
+
return await asyncio.wait_for(handler(packet['arguments'],policies),max(p.timeout_ms for p in policies)/1000)
|
|
164
|
+
except Failure:
|
|
165
|
+
raise
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
# For HTTP, a read timeout can occur after a non-idempotent request was
|
|
168
|
+
# accepted. It is not proof of non-execution.
|
|
169
|
+
raise Failure('external_uncertain') from exc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
resource.setrlimit(resource.RLIMIT_CORE,(0,0))
|
|
174
|
+
resource.setrlimit(resource.RLIMIT_NOFILE,(128,128))
|
|
175
|
+
resource.setrlimit(resource.RLIMIT_CPU,(40,40))
|
|
176
|
+
resource.setrlimit(resource.RLIMIT_FSIZE,(64*1024*1024,64*1024*1024))
|
|
177
|
+
resource.setrlimit(resource.RLIMIT_AS,(768*1024*1024,768*1024*1024))
|
|
178
|
+
try:
|
|
179
|
+
raw = sys.stdin.buffer.read(2*1024*1024+1)
|
|
180
|
+
require(len(raw) <= 2*1024*1024, 'tool_request_too_large')
|
|
181
|
+
result = asyncio.run(run(loads(raw)))
|
|
182
|
+
sys.stdout.buffer.write(canonical(result))
|
|
183
|
+
except Failure as exc:
|
|
184
|
+
sys.stdout.buffer.write(canonical({'code':exc.code}))
|
|
185
|
+
raise SystemExit(1)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == '__main__':
|
|
189
|
+
main()
|