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
msg/transports/mcp.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""MCP 2025-11-25 JSON-RPC over stateless Streamable HTTP or local stdio.
|
|
2
|
+
|
|
3
|
+
Remote calls carry the same signed operation envelope as other transports.
|
|
4
|
+
Local stdio exposes argument schemas directly and delegates signing to msg.
|
|
5
|
+
No root-administration operation is generated as an MCP tool.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from msg import __version__
|
|
10
|
+
from msg.core.codec import canonical,digest,loads
|
|
11
|
+
from msg.core.errors import Failure,require
|
|
12
|
+
from msg.core.executor import result_wire
|
|
13
|
+
from msg.transports.packet import decode_packet,REQUEST_SCHEMA,RESULT_SCHEMA
|
|
14
|
+
|
|
15
|
+
PROTOCOL_VERSION='2025-11-25'
|
|
16
|
+
SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCPServer:
|
|
20
|
+
def __init__(self,service, *, local_client=None):
|
|
21
|
+
self.service=service
|
|
22
|
+
self.local_client=local_client
|
|
23
|
+
self._requests={}
|
|
24
|
+
|
|
25
|
+
async def tools(self,cursor=None):
|
|
26
|
+
registry=self.service.registry
|
|
27
|
+
specs=registry.operations('network')
|
|
28
|
+
offset=0
|
|
29
|
+
if cursor:
|
|
30
|
+
offset=self.service.cursors.decode(cursor,'mcp-tools',registry.catalog()['digest'])
|
|
31
|
+
require(type(offset) is int and 0<=offset<=len(specs),'invalid_cursor')
|
|
32
|
+
if self.local_client is not None:
|
|
33
|
+
await registry.load_schemas(specs[offset:offset+8])
|
|
34
|
+
tools=[]
|
|
35
|
+
# Bounded discovery; an agent can request only the next useful page.
|
|
36
|
+
for spec in specs[offset:offset+8]:
|
|
37
|
+
tool_name=spec.name if spec.version==1 else f'{spec.name}@{spec.version}'
|
|
38
|
+
if self.local_client is not None:
|
|
39
|
+
input_schema=registry.schema(spec.input_schema)
|
|
40
|
+
else:
|
|
41
|
+
packet_schema={**REQUEST_SCHEMA,'properties':{**REQUEST_SCHEMA['properties'],
|
|
42
|
+
'operation':{'const':spec.name},'contract_version':{'const':spec.version},
|
|
43
|
+
'arguments':registry.schema(spec.input_schema)}}
|
|
44
|
+
input_schema={'type':'object','properties':{'packet':packet_schema},'required':['packet'],'additionalProperties':False}
|
|
45
|
+
tools.append({'name':tool_name,'description':f'{spec.name}@{spec.version}; {spec.effect}; contract /-/d/{spec.name.split(".",1)[0]}/{tool_name}',
|
|
46
|
+
'inputSchema':input_schema,'outputSchema':RESULT_SCHEMA,
|
|
47
|
+
'annotations':{'readOnlyHint':spec.effect=='read','idempotentHint':True,
|
|
48
|
+
'destructiveHint':spec.effect!='read','openWorldHint':spec.effect=='external'}})
|
|
49
|
+
result={'tools':tools}
|
|
50
|
+
if offset+len(tools)<len(specs):
|
|
51
|
+
result['nextCursor']=self.service.cursors.encode('mcp-tools',registry.catalog()['digest'],offset+len(tools))
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
async def handle(self,message):
|
|
55
|
+
id=message.get('id') if isinstance(message,dict) else None
|
|
56
|
+
try:
|
|
57
|
+
require(isinstance(message,dict) and message.get('jsonrpc')=='2.0' and
|
|
58
|
+
isinstance(message.get('method'),str),'invalid_jsonrpc')
|
|
59
|
+
require(id is None or type(id) in (str,int),'invalid_jsonrpc_id')
|
|
60
|
+
require(not isinstance(id,str) or len(id)<=128,'invalid_jsonrpc_id')
|
|
61
|
+
method=message['method']
|
|
62
|
+
params=message.get('params',{})
|
|
63
|
+
require(isinstance(params,dict),'invalid_jsonrpc_params')
|
|
64
|
+
if id is None:
|
|
65
|
+
# Notifications never execute operations, even if their method is
|
|
66
|
+
# tools/call. This prevents unacknowledged mutation messages.
|
|
67
|
+
return None
|
|
68
|
+
if method=='initialize':
|
|
69
|
+
requested=params.get('protocolVersion')
|
|
70
|
+
selected=requested if requested in SUPPORTED_VERSIONS else PROTOCOL_VERSION
|
|
71
|
+
result={'protocolVersion':selected,'capabilities':{'tools':{'listChanged':False}},
|
|
72
|
+
'serverInfo':{'name':'msg.lmm.best','version':__version__},
|
|
73
|
+
'instructions':'Read /AGENTS.md. Network operations use one signed envelope. Root administration is not available.'}
|
|
74
|
+
elif method=='ping':
|
|
75
|
+
result={}
|
|
76
|
+
elif method=='tools/list':
|
|
77
|
+
require(set(params)<={'cursor','_meta'},'invalid_jsonrpc_params')
|
|
78
|
+
result=await self.tools(params.get('cursor'))
|
|
79
|
+
elif method=='tools/call':
|
|
80
|
+
require(set(params)<={'name','arguments','_meta'} and isinstance(params.get('name'),str),'invalid_jsonrpc_params')
|
|
81
|
+
name=params['name']
|
|
82
|
+
operation,separator,version=name.rpartition('@')
|
|
83
|
+
if separator:
|
|
84
|
+
require(version.isdecimal() and int(version)>0,'invalid_operation_version')
|
|
85
|
+
spec=self.service.registry.operation(operation,int(version))
|
|
86
|
+
else:
|
|
87
|
+
spec=self.service.registry.operation(name)
|
|
88
|
+
require('network' in spec.entries,'entry_not_allowed')
|
|
89
|
+
arguments=params.get('arguments',{})
|
|
90
|
+
if self.local_client is None:
|
|
91
|
+
require(isinstance(arguments,dict) and set(arguments)=={'packet'},'signed_packet_required')
|
|
92
|
+
packet=decode_packet(arguments['packet'],self.service.settings.server.limits.max_request_bytes)
|
|
93
|
+
require(packet.operation==spec.name and packet.contract_version==spec.version,'operation_mismatch')
|
|
94
|
+
output=await self.service.executor.execute(packet,entry='network')
|
|
95
|
+
else:
|
|
96
|
+
require(isinstance(arguments,dict),'invalid_jsonrpc_params')
|
|
97
|
+
metadata=params.get('_meta',{})
|
|
98
|
+
expected=metadata.get('msg/expected_generations',())
|
|
99
|
+
request_id=metadata.get('msg/request_id')
|
|
100
|
+
cache_key=(type(id).__name__,id)
|
|
101
|
+
request_digest=digest((params['name'],arguments,expected))
|
|
102
|
+
existing=self._requests.get(cache_key)
|
|
103
|
+
if existing:
|
|
104
|
+
require(existing[0]==request_digest,'jsonrpc_id_conflict')
|
|
105
|
+
request_id=existing[1]
|
|
106
|
+
packet=self.local_client.prepare(spec.name,arguments,expected=expected,
|
|
107
|
+
request_id=request_id,contract_version=spec.version)
|
|
108
|
+
self._requests[cache_key]=(request_digest,packet.request_id)
|
|
109
|
+
# This is a process-local replay cache, not per-account server state.
|
|
110
|
+
if len(self._requests)>256:
|
|
111
|
+
self._requests.pop(next(iter(self._requests)))
|
|
112
|
+
output=await self.local_client.send(packet)
|
|
113
|
+
value=result_wire(output)
|
|
114
|
+
encoded=canonical(value).decode()
|
|
115
|
+
# Structured content is authoritative. Avoid doubling large
|
|
116
|
+
# transfer chunks in the text compatibility projection.
|
|
117
|
+
text=encoded if len(encoded)<=8192 else canonical({'request_id':output.request_id,
|
|
118
|
+
'status':output.status,'structured_content':True}).decode()
|
|
119
|
+
result={'content':[{'type':'text','text':text}],
|
|
120
|
+
'structuredContent':value,'isError':output.status=='error'}
|
|
121
|
+
else:
|
|
122
|
+
return {'jsonrpc':'2.0','id':id,'error':{'code':-32601,'message':'method_not_found'}}
|
|
123
|
+
return {'jsonrpc':'2.0','id':id,'result':result}
|
|
124
|
+
except Failure as exc:
|
|
125
|
+
if id is None:
|
|
126
|
+
return None
|
|
127
|
+
return {'jsonrpc':'2.0','id':id,'error':{'code':-32602,'message':exc.code}}
|
msg/transports/packet.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""URL and compression boundaries; common wire contracts live in core.packet."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import zlib
|
|
4
|
+
from msg.core.codec import unb64
|
|
5
|
+
from msg.core.errors import require, Failure
|
|
6
|
+
from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
|
|
7
|
+
from msg.core.models import OperationRequest, TokenProof
|
|
8
|
+
from msg.core.packet import (
|
|
9
|
+
REQUEST_SCHEMA, RESULT_SCHEMA, decode_packet, decode_result, safe_error_code,
|
|
10
|
+
)
|
|
11
|
+
from msg.transports.url_safety import contains_secret_fields
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def require_url_safe_packet(packet: OperationRequest) -> None:
|
|
15
|
+
"""Reject credentials and recovery claims before a GET URL is dispatched.
|
|
16
|
+
|
|
17
|
+
A signature is a bounded request proof; TokenProof and issuance claims are
|
|
18
|
+
reusable secrets. This guards both clients constructing URL packets and the
|
|
19
|
+
server receiving hand-built packets, including gzip envelopes.
|
|
20
|
+
"""
|
|
21
|
+
require(not isinstance(packet.proof, TokenProof), 'secure_channel_required')
|
|
22
|
+
require(packet.operation not in SECRET_DELIVERY_MIN_VERSION, 'secure_channel_required')
|
|
23
|
+
|
|
24
|
+
require(not contains_secret_fields(packet.arguments), 'secure_channel_required')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def gunzip(raw,limit):
|
|
28
|
+
inflater=zlib.decompressobj(wbits=31)
|
|
29
|
+
try:
|
|
30
|
+
result=inflater.decompress(raw,limit+1)
|
|
31
|
+
require(len(result)<=limit and not inflater.unconsumed_tail,'request_too_large')
|
|
32
|
+
result+=inflater.flush(limit+1-len(result))
|
|
33
|
+
require(len(result)<=limit and inflater.eof and not inflater.unused_data,'invalid_gzip')
|
|
34
|
+
return result
|
|
35
|
+
except zlib.error as exc:
|
|
36
|
+
raise Failure('invalid_gzip') from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def path_packet(encoded,encoding,limit):
|
|
40
|
+
raw=unb64(encoded,limit=limit)
|
|
41
|
+
require(encoding in {'j','gz'},'unknown_encoding')
|
|
42
|
+
return decode_packet(gunzip(raw,limit) if encoding=='gz' else raw,limit)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Readable v3 paths: x/{collection} enters a node; up/1 closes it."""
|
|
2
|
+
import re
|
|
3
|
+
from urllib.parse import quote,unquote_to_bytes
|
|
4
|
+
from msg.core.errors import Failure,require
|
|
5
|
+
from msg.core.read_query import MAX_READ_DEPTH,NESTED_FIELDS,ROOT_FIELDS
|
|
6
|
+
from msg.transports.dictionary import READ_QUERY_V1_SORT,READ_QUERY_V1_FIELDS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def decode_read_tree_path(raw_path):
|
|
10
|
+
parts=raw_path.split(b'/')
|
|
11
|
+
require(len(parts)>=6 and parts[:4] in
|
|
12
|
+
([b'',b'_r',b'q',b'3'],[b'',b'_read',b'q',b'3']),'invalid_path')
|
|
13
|
+
pieces=parts[4:]
|
|
14
|
+
proof=None
|
|
15
|
+
if len(pieces)>=2 and pieces[-2]==b'p':
|
|
16
|
+
proof=pieces[-1];pieces=pieces[:-2]
|
|
17
|
+
require(pieces and len(pieces)%2==0,'invalid_path')
|
|
18
|
+
root={'query_version':3}
|
|
19
|
+
stack=[root]
|
|
20
|
+
fields={value:key for key,value in READ_QUERY_V1_FIELDS.items()}
|
|
21
|
+
sorts={value:key for key,value in READ_QUERY_V1_SORT.items()}
|
|
22
|
+
for code,encoded in zip(pieces[::2],pieces[1::2],strict=True):
|
|
23
|
+
require(encoded and re.search(rb'%(?![0-9A-Fa-f]{2})',encoded) is None,'invalid_path')
|
|
24
|
+
try:value=unquote_to_bytes(encoded).decode('utf-8')
|
|
25
|
+
except UnicodeDecodeError as exc:raise Failure('invalid_path') from exc
|
|
26
|
+
require(quote(value,safe=',').encode()==encoded,'invalid_path')
|
|
27
|
+
node=stack[-1]
|
|
28
|
+
if code==b'x':
|
|
29
|
+
require(value in {'children','replies'},'invalid_nested_query')
|
|
30
|
+
require(len(stack)<=MAX_READ_DEPTH,'query_cost_exceeded')
|
|
31
|
+
tree=node.setdefault('expand',{})
|
|
32
|
+
require(value not in tree,'duplicate_query_parameter')
|
|
33
|
+
tree[value]={};stack.append(tree[value])
|
|
34
|
+
continue
|
|
35
|
+
if code==b'up':
|
|
36
|
+
require(value=='1' and len(stack)>1,'invalid_path')
|
|
37
|
+
stack.pop();continue
|
|
38
|
+
names={b'r':'parent',b't':'type',b's':'sort',b'f':'fields',b'n':'limit',b'a':'cursor',
|
|
39
|
+
b'co':'collection',b'pa':'parent'}
|
|
40
|
+
require(code in names,'unknown_query_parameter')
|
|
41
|
+
field=names[code]
|
|
42
|
+
require(len(stack)==1 or field in {'limit','fields'},'invalid_nested_query')
|
|
43
|
+
require(field not in node,'duplicate_query_parameter')
|
|
44
|
+
if field=='limit':
|
|
45
|
+
require(value.isdecimal() and 1<=int(value)<=(100 if len(stack)==1 else 10),
|
|
46
|
+
'query_cost_exceeded')
|
|
47
|
+
value=int(value)
|
|
48
|
+
elif field=='fields':
|
|
49
|
+
selected=value.split(',')
|
|
50
|
+
allowed=ROOT_FIELDS if len(stack)==1 else NESTED_FIELDS
|
|
51
|
+
require(selected and len(selected)==len(set(selected)) and
|
|
52
|
+
all(code in fields and fields[code] in allowed for code in selected),
|
|
53
|
+
'unknown_projection_field')
|
|
54
|
+
value=[fields[code] for code in selected]
|
|
55
|
+
elif field=='sort':
|
|
56
|
+
require(value in sorts,'invalid_sort');value=sorts[value]
|
|
57
|
+
elif field=='collection':
|
|
58
|
+
require(value in {'children','replies'},'invalid_nested_query')
|
|
59
|
+
node[field]=value
|
|
60
|
+
require(len(stack)==1,'invalid_path')
|
|
61
|
+
if 'cursor' in root:
|
|
62
|
+
require(set(root)=={'query_version','cursor'},'cursor_query_mismatch')
|
|
63
|
+
return {'cursor':root['cursor']},proof
|
|
64
|
+
require(root.get('parent'),'read_query_root_required')
|
|
65
|
+
return root,proof
|
msg/transports/stdio.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Local MCP is a signed network client, never a msgd administrative proxy."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
from msg.core.codec import canonical,loads
|
|
9
|
+
from msg.core.cursors import CursorCodec
|
|
10
|
+
from msg.core.errors import Failure
|
|
11
|
+
from msg.transports.mcp import MCPServer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def serve_stdio(client,input_stream=None,output_stream=None):
|
|
15
|
+
input_stream=input_stream or sys.stdin.buffer
|
|
16
|
+
output_stream=output_stream or sys.stdout.buffer
|
|
17
|
+
registry=await client.contract_registry()
|
|
18
|
+
service=SimpleNamespace(registry=registry,cursors=CursorCodec(os.urandom(32)))
|
|
19
|
+
server=MCPServer(service,local_client=client)
|
|
20
|
+
limit=(await client.transport.discover()).max_request_bytes
|
|
21
|
+
while True:
|
|
22
|
+
line=await asyncio.to_thread(input_stream.readline,limit+2)
|
|
23
|
+
if not line:
|
|
24
|
+
break
|
|
25
|
+
try:
|
|
26
|
+
if len(line)>limit or not line.endswith(b'\n'):
|
|
27
|
+
# Framing cannot be recovered safely from an unterminated oversized line.
|
|
28
|
+
error={'jsonrpc':'2.0','id':None,'error':{'code':-32700,'message':'message_too_large'}}
|
|
29
|
+
output_stream.write(canonical(error)+b'\n');output_stream.flush()
|
|
30
|
+
break
|
|
31
|
+
result=await server.handle(loads(line))
|
|
32
|
+
except Failure as exc:
|
|
33
|
+
result={'jsonrpc':'2.0','id':None,'error':{'code':-32700,'message':exc.code}}
|
|
34
|
+
if result is not None:
|
|
35
|
+
output_stream.write(canonical(result)+b'\n')
|
|
36
|
+
output_stream.flush()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Bounded URL inspection shared by HTTP, PathGET and clients.
|
|
2
|
+
|
|
3
|
+
Decoding here is for rejection only. It never produces a rewritten route or
|
|
4
|
+
business arguments. Ordinary data (including percent literals and quoted /)
|
|
5
|
+
still reaches its original parser. Only explicit credential field names and
|
|
6
|
+
legacy credential slots are classified; arbitrary text is not a secret scanner.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from urllib.parse import SplitResult, unquote_to_bytes, urlsplit
|
|
13
|
+
|
|
14
|
+
from msg.core.errors import Failure, require
|
|
15
|
+
|
|
16
|
+
MAX_DECODE_LAYERS = 8
|
|
17
|
+
_SECRET_NAMES = frozenset({
|
|
18
|
+
'token', 'accesstoken', 'refreshtoken', 'recoverysecret', 'newrecoverysecret',
|
|
19
|
+
'privatekey', 'secret', 'clientsecret', 'bootstrapclaim', 'password', 'apikey',
|
|
20
|
+
'authorization',
|
|
21
|
+
})
|
|
22
|
+
_BAD_ESCAPE = re.compile(rb'%(?![0-9a-fA-F]{2})')
|
|
23
|
+
_CONTROL = re.compile(rb'[\x00-\x20\x7f]')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _layers(raw: bytes):
|
|
27
|
+
"""At most eight decoding passes; never expose the rejected input."""
|
|
28
|
+
current = raw
|
|
29
|
+
for _ in range(MAX_DECODE_LAYERS + 1):
|
|
30
|
+
yield current
|
|
31
|
+
decoded = unquote_to_bytes(current)
|
|
32
|
+
if decoded == current:
|
|
33
|
+
return
|
|
34
|
+
current = decoded
|
|
35
|
+
require(False, 'invalid_path')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_secret_field(name: str | bytes) -> bool:
|
|
39
|
+
raw = name.encode('utf-8') if isinstance(name, str) else name
|
|
40
|
+
for layer in _layers(raw):
|
|
41
|
+
# Understand conventional nested field labels, not arbitrary substrings.
|
|
42
|
+
for part in re.split(rb'[.\[\]]', layer.lower()):
|
|
43
|
+
normalized = part.strip().replace(b'_', b'').replace(b'-', b'')
|
|
44
|
+
if normalized.decode('ascii', errors='replace') in _SECRET_NAMES:
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def contains_secret_fields(value) -> bool:
|
|
50
|
+
pending = [value]
|
|
51
|
+
visited = 0
|
|
52
|
+
while pending:
|
|
53
|
+
item = pending.pop()
|
|
54
|
+
visited += 1
|
|
55
|
+
require(visited <= 16384, 'request_too_large')
|
|
56
|
+
if isinstance(item, Mapping):
|
|
57
|
+
if any(is_secret_field(key) for key in item):
|
|
58
|
+
return True
|
|
59
|
+
pending.extend(item.values())
|
|
60
|
+
elif isinstance(item, (list, tuple)):
|
|
61
|
+
pending.extend(item)
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def require_safe_request_target(raw_path: bytes, query: bytes = b'', *, maximum: int) -> None:
|
|
66
|
+
"""Inspect raw ASGI/request-target bytes before routing or sending a request."""
|
|
67
|
+
require(len(raw_path) + len(query) + bool(query) <= maximum, 'path_too_large')
|
|
68
|
+
require(b'#' not in raw_path and b'#' not in query, 'secure_channel_required')
|
|
69
|
+
require(raw_path.startswith(b'/') and not raw_path.startswith(b'//'), 'invalid_path')
|
|
70
|
+
require(not _CONTROL.search(raw_path) and not _CONTROL.search(query), 'invalid_path')
|
|
71
|
+
require(not _BAD_ESCAPE.search(raw_path) and not _BAD_ESCAPE.search(query), 'invalid_path')
|
|
72
|
+
|
|
73
|
+
# Inspect delimiters at every level, not just the name after one decoding.
|
|
74
|
+
# This catches both %2574oken and ordinary=x%26token=... without interpreting
|
|
75
|
+
# either spelling as an additional business parameter.
|
|
76
|
+
for layer in _layers(query):
|
|
77
|
+
for pair in re.split(rb'[&;]', layer):
|
|
78
|
+
require(not is_secret_field(pair.partition(b'=')[0]), 'secure_channel_required')
|
|
79
|
+
|
|
80
|
+
ambiguous = False
|
|
81
|
+
for layer in _layers(raw_path):
|
|
82
|
+
require(b'\\' not in layer and b'\x00' not in layer, 'invalid_path')
|
|
83
|
+
segments = layer.split(b'/')
|
|
84
|
+
if len(segments) >= 5 and segments[:3] == [b'', b'-', b'g']:
|
|
85
|
+
require(segments[4].lower() not in {b'token', b'bootstrap'},
|
|
86
|
+
'secure_channel_required')
|
|
87
|
+
# A labelled assignment in a path is not a permitted secret channel.
|
|
88
|
+
for segment in segments:
|
|
89
|
+
if b'=' in segment:
|
|
90
|
+
require(not is_secret_field(segment.partition(b'=')[0]),
|
|
91
|
+
'secure_channel_required')
|
|
92
|
+
if layer == b'/-' or layer.startswith(b'/-/'):
|
|
93
|
+
original = raw_path.split(b'/')
|
|
94
|
+
ambiguous |= (not (raw_path == b'/-' or raw_path.startswith(b'/-/')) or
|
|
95
|
+
any(part in {b'.', b'..'} for part in segments) or
|
|
96
|
+
any(b'%' in part for part in original[2:4]) or
|
|
97
|
+
(len(original) > 4 and original[2] in {b'g', b'p'} and
|
|
98
|
+
b'.' in original[3] and b'%' in original[4]))
|
|
99
|
+
require(not ambiguous, 'not_found')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def require_safe_relative_url(path: str, *, maximum: int) -> None:
|
|
103
|
+
require(path.startswith('/') and not path.startswith('//'), 'invalid_relative_endpoint')
|
|
104
|
+
# Fragments are not transmitted; reject them before httpx/urlsplit can drop
|
|
105
|
+
# them. The server can only validate fragment-like bytes actually received.
|
|
106
|
+
raw_path, _, query = path.encode('utf-8').partition(b'?')
|
|
107
|
+
require_safe_request_target(raw_path, query, maximum=maximum)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def require_matching_host(values: list[str], expected: SplitResult) -> None:
|
|
111
|
+
"""Check Host syntax before a URL parser can discard delimiters or controls."""
|
|
112
|
+
require(len(values) == 1 and re.fullmatch(
|
|
113
|
+
r'(?:[A-Za-z0-9._-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?', values[0])
|
|
114
|
+
is not None, 'forbidden_host')
|
|
115
|
+
try:
|
|
116
|
+
supplied = urlsplit('//' + values[0])
|
|
117
|
+
port = supplied.port
|
|
118
|
+
default = 443 if expected.scheme == 'https' else 80
|
|
119
|
+
require((port is None or port > 0) and supplied.hostname == expected.hostname and
|
|
120
|
+
(port if port is not None else default) == (expected.port or default),
|
|
121
|
+
'forbidden_host')
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise Failure('forbidden_host') from None
|