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.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. 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()