two-key 0.7.0__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.
- two_key/__init__.py +13 -0
- two_key/__main__.py +3 -0
- two_key/_platform.py +597 -0
- two_key/action.py +199 -0
- two_key/agent_access.py +96 -0
- two_key/agent_meta.py +139 -0
- two_key/agents.py +455 -0
- two_key/audit.py +90 -0
- two_key/backup.py +783 -0
- two_key/canonical.py +181 -0
- two_key/capability.py +222 -0
- two_key/cli.py +719 -0
- two_key/compiler.py +55 -0
- two_key/constitution.py +547 -0
- two_key/core.py +769 -0
- two_key/derive.py +593 -0
- two_key/fields.py +577 -0
- two_key/gateway.py +448 -0
- two_key/guard.py +205 -0
- two_key/identity.py +1106 -0
- two_key/judges/__init__.py +14 -0
- two_key/judges/anthropic.py +80 -0
- two_key/judges/base.py +103 -0
- two_key/judges/config.py +240 -0
- two_key/judges/credentials.py +154 -0
- two_key/judges/gemini.py +56 -0
- two_key/judges/llm.py +368 -0
- two_key/judges/ollama.py +56 -0
- two_key/judges/openai_compat.py +101 -0
- two_key/judges/transport.py +152 -0
- two_key/keys.py +140 -0
- two_key/ledger.py +1268 -0
- two_key/mcp_proxy/__init__.py +10 -0
- two_key/mcp_proxy/config.py +408 -0
- two_key/mcp_proxy/core.py +568 -0
- two_key/mcp_proxy/draft.py +301 -0
- two_key/mcp_proxy/sdk.py +226 -0
- two_key/netloc.py +96 -0
- two_key/policy_vm.py +342 -0
- two_key/quorum.py +515 -0
- two_key/scan.py +795 -0
- two_key/scanners.py +557 -0
- two_key/siem.py +1001 -0
- two_key/siem_export.py +266 -0
- two_key/siem_sinks.py +497 -0
- two_key/strict.py +141 -0
- two_key/testing.py +72 -0
- two_key/writable.py +200 -0
- two_key-0.7.0.dist-info/METADATA +783 -0
- two_key-0.7.0.dist-info/RECORD +54 -0
- two_key-0.7.0.dist-info/WHEEL +5 -0
- two_key-0.7.0.dist-info/entry_points.txt +2 -0
- two_key-0.7.0.dist-info/licenses/LICENSE +201 -0
- two_key-0.7.0.dist-info/top_level.txt +1 -0
two_key/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Two-Key: dual-path authorization for AI agents.
|
|
2
|
+
|
|
3
|
+
A tool call runs only if Path A (policy VM) and Path B (judge quorum) both
|
|
4
|
+
allow. It has no scanning engine of its own: from 0.6.0 ``two_key.scan`` calls
|
|
5
|
+
the operator's antivirus or DLP product. It is not a SIEM either: from 0.7.0
|
|
6
|
+
``two_key.siem`` copies allowlisted ledger fields to the operator's collector.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.7.0"
|
|
10
|
+
|
|
11
|
+
from .core import Decision, TwoKey
|
|
12
|
+
|
|
13
|
+
__all__ = ["Decision", "TwoKey", "__version__"]
|
two_key/__main__.py
ADDED
two_key/_platform.py
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Owner-only files, locks, and no-follow opens on Linux, macOS, and Windows (0.6.0).
|
|
2
|
+
|
|
3
|
+
Two-Key keeps its keys in files only their owner can read, opens them without following a symlink,
|
|
4
|
+
and serialises ledger writes and token redemption with a cross-process lock. POSIX does this with mode
|
|
5
|
+
bits, ``O_NOFOLLOW``, and ``flock``. Windows has none of them: Python reports every file there as mode
|
|
6
|
+
0666, ``O_NOFOLLOW`` does not exist, and there is no ``fcntl``. This module does each the native way on
|
|
7
|
+
each platform, with the standard library only (``ctypes`` on Windows; no pywin32).
|
|
8
|
+
|
|
9
|
+
On POSIX every function here does exactly what the callers did before 0.6.0.
|
|
10
|
+
|
|
11
|
+
On Windows:
|
|
12
|
+
|
|
13
|
+
- A file Two-Key creates gets a protected access list (nothing inherited) that gives full control to the
|
|
14
|
+
current user only, ``D:P(A;;FA;;;<user SID>)``, set in ``CreateFileW`` itself, so the file is never
|
|
15
|
+
readable by anyone else, not even for an instant. A directory gets the same, inheritable.
|
|
16
|
+
- A key is accepted when its owner and every "allow" entry in its access list is the current user,
|
|
17
|
+
SYSTEM, or the local Administrators group (maintainer's decision, 2026-10-15). SYSTEM and
|
|
18
|
+
Administrators can read any file on the machine anyway, as root can on POSIX.
|
|
19
|
+
- A file is opened with ``FILE_FLAG_OPEN_REPARSE_POINT``, so a symlink or junction is opened as itself
|
|
20
|
+
and refused, with no gap between the check and the open.
|
|
21
|
+
- The lock is ``LockFileEx`` on byte 0 of a separate lock file: exclusive and blocking, released when
|
|
22
|
+
the handle closes or the process ends, like ``flock``. It is a mandatory lock, but only the lock file
|
|
23
|
+
carries it, so it never blocks reading the ledger.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import stat
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
WINDOWS = os.name == "nt"
|
|
33
|
+
# Windows translates newlines (and stops at Ctrl-Z) on a descriptor opened in text mode. Every
|
|
34
|
+
# descriptor Two-Key opens with os.open carries this; on POSIX it is 0.
|
|
35
|
+
O_BINARY = getattr(os, "O_BINARY", 0)
|
|
36
|
+
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
37
|
+
|
|
38
|
+
SYSTEM_SID = "S-1-5-18"
|
|
39
|
+
# OWNER RIGHTS: an entry for whoever owns the file, which is checked to be one of the trusted accounts.
|
|
40
|
+
OWNER_RIGHTS_SID = "S-1-3-4"
|
|
41
|
+
ADMINISTRATORS_SID = "S-1-5-32-544"
|
|
42
|
+
# The account that owns Windows' own files (Program Files, System32): trusted for write_problem only.
|
|
43
|
+
TRUSTED_INSTALLER_SID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
|
44
|
+
# Names for the accounts a write_problem message most often names.
|
|
45
|
+
_SID_NAMES = {"S-1-1-0": "Everyone", "S-1-5-11": "Authenticated Users", "S-1-5-32-545": "Users",
|
|
46
|
+
"S-1-5-4": "INTERACTIVE", "S-1-5-19": "LOCAL SERVICE", "S-1-5-20": "NETWORK SERVICE"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sid_label(sid: str) -> str:
|
|
50
|
+
return f"{_SID_NAMES[sid]} ({sid})" if sid in _SID_NAMES else sid
|
|
51
|
+
|
|
52
|
+
if WINDOWS: # pragma: no cover - exercised on the Windows CI runner
|
|
53
|
+
import ctypes
|
|
54
|
+
import msvcrt
|
|
55
|
+
from ctypes import wintypes
|
|
56
|
+
|
|
57
|
+
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
58
|
+
_adv = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
59
|
+
|
|
60
|
+
_GENERIC_READ = 0x80000000
|
|
61
|
+
_GENERIC_WRITE = 0x40000000
|
|
62
|
+
_READ_CONTROL = 0x00020000
|
|
63
|
+
_FILE_SHARE_ALL = 0x1 | 0x2 | 0x4
|
|
64
|
+
_CREATE_NEW = 1
|
|
65
|
+
_OPEN_EXISTING = 3
|
|
66
|
+
_OPEN_ALWAYS = 4
|
|
67
|
+
_FILE_ATTRIBUTE_NORMAL = 0x80
|
|
68
|
+
_FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
69
|
+
_FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
|
70
|
+
_INVALID_HANDLE = wintypes.HANDLE(-1).value
|
|
71
|
+
_SE_FILE_OBJECT = 1
|
|
72
|
+
_OWNER_SECURITY_INFORMATION = 0x1
|
|
73
|
+
_DACL_SECURITY_INFORMATION = 0x4
|
|
74
|
+
_PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
|
|
75
|
+
_SDDL_REVISION_1 = 1
|
|
76
|
+
_TOKEN_QUERY = 0x8
|
|
77
|
+
_TOKEN_USER = 1
|
|
78
|
+
_ACL_SIZE_INFORMATION = 2
|
|
79
|
+
_ACCESS_ALLOWED_ACE_TYPE = 0
|
|
80
|
+
_ACCESS_DENIED_ACE_TYPE = 1
|
|
81
|
+
_INHERIT_ONLY_ACE = 0x08
|
|
82
|
+
_LOCKFILE_EXCLUSIVE_LOCK = 0x2
|
|
83
|
+
# Rights another account may hold without reaching the contents: wait on the handle, read the
|
|
84
|
+
# access list, read the timestamps and size.
|
|
85
|
+
_HARMLESS = 0x00100000 | _READ_CONTROL | 0x80
|
|
86
|
+
_SHARING_VIOLATION = (5, 32, 33) # access denied, sharing violation, lock violation
|
|
87
|
+
|
|
88
|
+
class _SECURITY_ATTRIBUTES(ctypes.Structure):
|
|
89
|
+
_fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p),
|
|
90
|
+
("bInheritHandle", wintypes.BOOL)]
|
|
91
|
+
|
|
92
|
+
class _ACE_HEADER(ctypes.Structure):
|
|
93
|
+
_fields_ = [("AceType", ctypes.c_ubyte), ("AceFlags", ctypes.c_ubyte), ("AceSize", wintypes.WORD)]
|
|
94
|
+
|
|
95
|
+
class _ACCESS_ALLOWED_ACE(ctypes.Structure):
|
|
96
|
+
_fields_ = [("Header", _ACE_HEADER), ("Mask", wintypes.DWORD), ("SidStart", wintypes.DWORD)]
|
|
97
|
+
|
|
98
|
+
class _ACL_SIZE_INFORMATION_T(ctypes.Structure):
|
|
99
|
+
_fields_ = [("AceCount", wintypes.DWORD), ("AclBytesInUse", wintypes.DWORD),
|
|
100
|
+
("AclBytesFree", wintypes.DWORD)]
|
|
101
|
+
|
|
102
|
+
class _OVERLAPPED(ctypes.Structure):
|
|
103
|
+
_fields_ = [("Internal", ctypes.c_void_p), ("InternalHigh", ctypes.c_void_p),
|
|
104
|
+
("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD), ("hEvent", wintypes.HANDLE)]
|
|
105
|
+
|
|
106
|
+
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
107
|
+
ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD, wintypes.DWORD,
|
|
108
|
+
wintypes.HANDLE]
|
|
109
|
+
_k32.CreateFileW.restype = wintypes.HANDLE
|
|
110
|
+
_k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
111
|
+
_k32.CloseHandle.restype = wintypes.BOOL
|
|
112
|
+
_k32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
113
|
+
_k32.LocalFree.restype = ctypes.c_void_p
|
|
114
|
+
_k32.GetCurrentProcess.argtypes = []
|
|
115
|
+
_k32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
116
|
+
_k32.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD,
|
|
117
|
+
wintypes.DWORD, ctypes.POINTER(_OVERLAPPED)]
|
|
118
|
+
_k32.LockFileEx.restype = wintypes.BOOL
|
|
119
|
+
_k32.UnlockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD,
|
|
120
|
+
ctypes.POINTER(_OVERLAPPED)]
|
|
121
|
+
_k32.UnlockFileEx.restype = wintypes.BOOL
|
|
122
|
+
_adv.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
|
123
|
+
_adv.OpenProcessToken.restype = wintypes.BOOL
|
|
124
|
+
_adv.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
|
|
125
|
+
ctypes.POINTER(wintypes.DWORD)]
|
|
126
|
+
_adv.GetTokenInformation.restype = wintypes.BOOL
|
|
127
|
+
_adv.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.LPWSTR)]
|
|
128
|
+
_adv.ConvertSidToStringSidW.restype = wintypes.BOOL
|
|
129
|
+
_adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
|
|
130
|
+
wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.ULONG)]
|
|
131
|
+
_adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL
|
|
132
|
+
_adv.GetSecurityDescriptorDacl.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.BOOL),
|
|
133
|
+
ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.BOOL)]
|
|
134
|
+
_adv.GetSecurityDescriptorDacl.restype = wintypes.BOOL
|
|
135
|
+
_adv.SetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD, ctypes.c_void_p,
|
|
136
|
+
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
|
|
137
|
+
_adv.SetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
138
|
+
_adv.GetSecurityInfo.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.DWORD,
|
|
139
|
+
ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p),
|
|
140
|
+
ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_void_p),
|
|
141
|
+
ctypes.POINTER(ctypes.c_void_p)]
|
|
142
|
+
_adv.GetSecurityInfo.restype = wintypes.DWORD
|
|
143
|
+
_adv.GetAclInformation.argtypes = [ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.c_int]
|
|
144
|
+
_adv.GetAclInformation.restype = wintypes.BOOL
|
|
145
|
+
_adv.GetAce.argtypes = [ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
|
146
|
+
_adv.GetAce.restype = wintypes.BOOL
|
|
147
|
+
|
|
148
|
+
def _error(code: int, path=None) -> OSError:
|
|
149
|
+
return OSError(None, ctypes.FormatError(code).strip(), None if path is None else os.fspath(path), code)
|
|
150
|
+
|
|
151
|
+
def _last_error(path=None) -> OSError:
|
|
152
|
+
return _error(ctypes.get_last_error(), path)
|
|
153
|
+
|
|
154
|
+
def _sid_string(psid) -> str:
|
|
155
|
+
out = wintypes.LPWSTR()
|
|
156
|
+
if not _adv.ConvertSidToStringSidW(psid, ctypes.byref(out)):
|
|
157
|
+
raise _last_error()
|
|
158
|
+
try:
|
|
159
|
+
return out.value
|
|
160
|
+
finally:
|
|
161
|
+
_k32.LocalFree(ctypes.cast(out, ctypes.c_void_p))
|
|
162
|
+
|
|
163
|
+
_USER_SID = None
|
|
164
|
+
|
|
165
|
+
def current_user_sid() -> str:
|
|
166
|
+
"""The string SID of the account this process runs as."""
|
|
167
|
+
global _USER_SID
|
|
168
|
+
if _USER_SID is None:
|
|
169
|
+
token = wintypes.HANDLE()
|
|
170
|
+
if not _adv.OpenProcessToken(_k32.GetCurrentProcess(), _TOKEN_QUERY, ctypes.byref(token)):
|
|
171
|
+
raise _last_error()
|
|
172
|
+
try:
|
|
173
|
+
size = wintypes.DWORD()
|
|
174
|
+
_adv.GetTokenInformation(token, _TOKEN_USER, None, 0, ctypes.byref(size))
|
|
175
|
+
buf = ctypes.create_string_buffer(size.value)
|
|
176
|
+
if not _adv.GetTokenInformation(token, _TOKEN_USER, buf, size, ctypes.byref(size)):
|
|
177
|
+
raise _last_error()
|
|
178
|
+
_USER_SID = _sid_string(ctypes.cast(buf, ctypes.POINTER(ctypes.c_void_p))[0])
|
|
179
|
+
finally:
|
|
180
|
+
_k32.CloseHandle(token)
|
|
181
|
+
return _USER_SID
|
|
182
|
+
|
|
183
|
+
class _Descriptor:
|
|
184
|
+
"""A self-relative security descriptor built from SDDL, freed on exit."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, sddl: str):
|
|
187
|
+
self.ptr = ctypes.c_void_p()
|
|
188
|
+
if not _adv.ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
|
189
|
+
sddl, _SDDL_REVISION_1, ctypes.byref(self.ptr), None):
|
|
190
|
+
raise _last_error()
|
|
191
|
+
|
|
192
|
+
def __enter__(self):
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def __exit__(self, *exc):
|
|
196
|
+
_k32.LocalFree(self.ptr)
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
def dacl(self):
|
|
200
|
+
present, defaulted, acl = wintypes.BOOL(), wintypes.BOOL(), ctypes.c_void_p()
|
|
201
|
+
if not _adv.GetSecurityDescriptorDacl(self.ptr, ctypes.byref(present), ctypes.byref(acl),
|
|
202
|
+
ctypes.byref(defaulted)):
|
|
203
|
+
raise _last_error()
|
|
204
|
+
return acl
|
|
205
|
+
|
|
206
|
+
def _to_fd(handle, flags: int, path) -> int:
|
|
207
|
+
try:
|
|
208
|
+
fd = msvcrt.open_osfhandle(handle, flags & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR | os.O_APPEND))
|
|
209
|
+
except BaseException:
|
|
210
|
+
_k32.CloseHandle(handle)
|
|
211
|
+
raise
|
|
212
|
+
msvcrt.setmode(fd, os.O_BINARY)
|
|
213
|
+
try:
|
|
214
|
+
st = os.fstat(fd)
|
|
215
|
+
except BaseException:
|
|
216
|
+
os.close(fd)
|
|
217
|
+
raise
|
|
218
|
+
if st.st_file_attributes & _FILE_ATTRIBUTE_REPARSE_POINT:
|
|
219
|
+
os.close(fd)
|
|
220
|
+
raise OSError(None, "is a symbolic link, junction, or other reparse point", os.fspath(path), 1921)
|
|
221
|
+
return fd
|
|
222
|
+
|
|
223
|
+
def _create_file(path, flags: int, sddl: str | None) -> int:
|
|
224
|
+
access = _READ_CONTROL
|
|
225
|
+
if flags & os.O_RDWR:
|
|
226
|
+
access |= _GENERIC_READ | _GENERIC_WRITE
|
|
227
|
+
elif flags & os.O_WRONLY:
|
|
228
|
+
access |= _GENERIC_WRITE
|
|
229
|
+
else:
|
|
230
|
+
access |= _GENERIC_READ
|
|
231
|
+
if flags & os.O_CREAT and flags & os.O_EXCL:
|
|
232
|
+
disposition = _CREATE_NEW
|
|
233
|
+
elif flags & os.O_CREAT:
|
|
234
|
+
disposition = _OPEN_ALWAYS
|
|
235
|
+
else:
|
|
236
|
+
disposition = _OPEN_EXISTING
|
|
237
|
+
attrs = None
|
|
238
|
+
descriptor = _Descriptor(sddl) if sddl else None
|
|
239
|
+
try:
|
|
240
|
+
if descriptor is not None:
|
|
241
|
+
attrs = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), descriptor.ptr, False)
|
|
242
|
+
handle = _k32.CreateFileW(os.fspath(path), access, _FILE_SHARE_ALL,
|
|
243
|
+
ctypes.byref(attrs) if attrs is not None else None, disposition,
|
|
244
|
+
_FILE_ATTRIBUTE_NORMAL | _FILE_FLAG_OPEN_REPARSE_POINT, None)
|
|
245
|
+
if handle == _INVALID_HANDLE or handle is None:
|
|
246
|
+
raise _last_error(path)
|
|
247
|
+
finally:
|
|
248
|
+
if descriptor is not None:
|
|
249
|
+
descriptor.__exit__()
|
|
250
|
+
return _to_fd(handle, flags, path)
|
|
251
|
+
|
|
252
|
+
def _owner_only_sddl(directory: bool) -> str:
|
|
253
|
+
inherit = "OICI" if directory else ""
|
|
254
|
+
return f"D:P(A;{inherit};FA;;;{current_user_sid()})"
|
|
255
|
+
|
|
256
|
+
def _access_problem_handle(handle, forbidden: int | None = None, *, owner: bool = False,
|
|
257
|
+
also_trusted: tuple = (), granted: str | None = None) -> str | None:
|
|
258
|
+
owner_psid, dacl, descriptor = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p()
|
|
259
|
+
rc = _adv.GetSecurityInfo(handle, _SE_FILE_OBJECT,
|
|
260
|
+
_OWNER_SECURITY_INFORMATION | _DACL_SECURITY_INFORMATION,
|
|
261
|
+
ctypes.byref(owner_psid), None, ctypes.byref(dacl), None,
|
|
262
|
+
ctypes.byref(descriptor))
|
|
263
|
+
if rc:
|
|
264
|
+
return f"its access list cannot be read ({ctypes.FormatError(rc).strip()})"
|
|
265
|
+
try:
|
|
266
|
+
trusted = {current_user_sid(), SYSTEM_SID, ADMINISTRATORS_SID, *also_trusted}
|
|
267
|
+
owner_sid = _sid_string(owner_psid)
|
|
268
|
+
if owner_sid not in trusted and (forbidden is None or owner):
|
|
269
|
+
return f"owned by {_sid_label(owner_sid)}" if granted else f"it is owned by {owner_sid}, not by you"
|
|
270
|
+
if owner_sid in trusted:
|
|
271
|
+
trusted.add(OWNER_RIGHTS_SID)
|
|
272
|
+
if not dacl.value:
|
|
273
|
+
return "it has no access list, so everyone has full access"
|
|
274
|
+
return _dacl_problem(dacl, trusted, ~_HARMLESS if forbidden is None else forbidden, granted)
|
|
275
|
+
finally:
|
|
276
|
+
_k32.LocalFree(descriptor)
|
|
277
|
+
|
|
278
|
+
def _dacl_problem(dacl, trusted: set, mask: int, granted: str | None) -> str | None:
|
|
279
|
+
"""Why an entry in the access list gives an account outside ``trusted`` any right in ``mask``, or None."""
|
|
280
|
+
info = _ACL_SIZE_INFORMATION_T()
|
|
281
|
+
if not _adv.GetAclInformation(dacl, ctypes.byref(info), ctypes.sizeof(info), _ACL_SIZE_INFORMATION):
|
|
282
|
+
return "its access list cannot be read"
|
|
283
|
+
for index in range(info.AceCount):
|
|
284
|
+
ace = ctypes.c_void_p()
|
|
285
|
+
if not _adv.GetAce(dacl, index, ctypes.byref(ace)):
|
|
286
|
+
return "its access list cannot be read"
|
|
287
|
+
problem = _ace_problem(ace.value, trusted, mask, granted)
|
|
288
|
+
if problem:
|
|
289
|
+
return problem
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def _ace_problem(address: int, trusted: set, mask: int, granted: str | None) -> str | None:
|
|
293
|
+
"""Why one entry gives an account outside ``trusted`` a right in ``mask``, or None. An inherit-only or
|
|
294
|
+
deny entry grants nothing here; any type but allow is not read, so it is a problem."""
|
|
295
|
+
header = _ACE_HEADER.from_address(address)
|
|
296
|
+
if header.AceFlags & _INHERIT_ONLY_ACE or header.AceType == _ACCESS_DENIED_ACE_TYPE:
|
|
297
|
+
return None
|
|
298
|
+
if header.AceType != _ACCESS_ALLOWED_ACE_TYPE:
|
|
299
|
+
return f"its access list has an entry of type {header.AceType} that Two-Key does not read"
|
|
300
|
+
allowed = _ACCESS_ALLOWED_ACE.from_address(address)
|
|
301
|
+
sid = _sid_string(address + _ACCESS_ALLOWED_ACE.SidStart.offset)
|
|
302
|
+
if sid in trusted or not allowed.Mask & mask:
|
|
303
|
+
return None
|
|
304
|
+
return f"{_sid_label(sid)} {granted}" if granted else f"{sid} has access to it"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def open_nofollow(path, flags: int = os.O_RDONLY) -> int:
|
|
308
|
+
"""``os.open`` that refuses a symlink (and on Windows a junction or other reparse point) as the last part."""
|
|
309
|
+
if not WINDOWS:
|
|
310
|
+
return os.open(path, flags | O_NOFOLLOW)
|
|
311
|
+
return _create_file(path, flags, None) # pragma: no cover
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def create_new(path, flags: int = os.O_WRONLY, *, owner_only: bool = True) -> int:
|
|
315
|
+
"""Create ``path``; an existing file or symlink is FileExistsError, never an overwrite.
|
|
316
|
+
|
|
317
|
+
``owner_only`` (the default) makes it readable by its owner only from the first byte: mode 0600 on
|
|
318
|
+
POSIX, the protected access list on Windows. Otherwise mode 0644 and, on Windows, the folder's
|
|
319
|
+
inherited access list.
|
|
320
|
+
"""
|
|
321
|
+
flags |= os.O_CREAT | os.O_EXCL
|
|
322
|
+
if not WINDOWS:
|
|
323
|
+
return os.open(path, flags | O_NOFOLLOW, 0o600 if owner_only else 0o644)
|
|
324
|
+
return _create_file(path, flags, _owner_only_sddl(False) if owner_only else None) # pragma: no cover
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def open_lock_file(path) -> int:
|
|
328
|
+
"""Open (creating if needed) a lock file for :func:`lock`."""
|
|
329
|
+
if not WINDOWS:
|
|
330
|
+
return os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
331
|
+
return _create_file(path, os.O_CREAT | os.O_RDWR, _owner_only_sddl(False)) # pragma: no cover
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def access_problem(fd: int, st: os.stat_result | None = None) -> str | None:
|
|
335
|
+
"""Why the open file ``fd`` is not owner-only, or None. Checked on the descriptor, so it cannot be swapped.
|
|
336
|
+
|
|
337
|
+
POSIX: any group or other mode bit. Windows: an owner, or an "allow" entry with more than metadata
|
|
338
|
+
access, other than the current user, SYSTEM, or Administrators.
|
|
339
|
+
"""
|
|
340
|
+
if not WINDOWS:
|
|
341
|
+
st = os.fstat(fd) if st is None else st
|
|
342
|
+
return "readable by group or others" if st.st_mode & 0o077 else None
|
|
343
|
+
return _access_problem_handle(msvcrt.get_osfhandle(fd)) # pragma: no cover
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def fix_hint(path) -> str:
|
|
347
|
+
"""The command that makes ``path`` owner-only, for an error message."""
|
|
348
|
+
if not WINDOWS:
|
|
349
|
+
return f"chmod 600 {path}"
|
|
350
|
+
return f'icacls "{path}" /inheritance:r /grant:r "%USERNAME%":F' # pragma: no cover
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def insecure(path, problem: str) -> str:
|
|
354
|
+
"""The text after a ``*_insecure:`` reason code. On POSIX, the same words as before 0.6.0."""
|
|
355
|
+
if not WINDOWS:
|
|
356
|
+
return f"{path} is {problem}; chmod 600 it"
|
|
357
|
+
return f"{path} is not private to you: {problem}; run {fix_hint(path)}" # pragma: no cover
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def owner_only_dir(path) -> None:
|
|
361
|
+
"""Restrict a directory Two-Key owns to its owner: mode 0700, or a protected inheritable access list."""
|
|
362
|
+
if not WINDOWS:
|
|
363
|
+
# Owner only (0o700): the rule's suggested 0o644 would let others read this key directory.
|
|
364
|
+
os.chmod(path, 0o700) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
|
|
365
|
+
return
|
|
366
|
+
with _Descriptor(_owner_only_sddl(True)) as descriptor: # pragma: no cover
|
|
367
|
+
rc = _adv.SetNamedSecurityInfoW(os.fspath(path), _SE_FILE_OBJECT,
|
|
368
|
+
_DACL_SECURITY_INFORMATION | _PROTECTED_DACL_SECURITY_INFORMATION,
|
|
369
|
+
None, None, descriptor.dacl(), None)
|
|
370
|
+
if rc:
|
|
371
|
+
raise _error(rc, path)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def is_owner_only_dir(path) -> bool:
|
|
375
|
+
"""Whether a directory is restricted to its owner (for tests and reports)."""
|
|
376
|
+
if not WINDOWS:
|
|
377
|
+
return stat.S_IMODE(os.stat(path).st_mode) == 0o700
|
|
378
|
+
fd = _create_file_dir(path) # pragma: no cover
|
|
379
|
+
try: # pragma: no cover
|
|
380
|
+
return _access_problem_handle(msvcrt.get_osfhandle(fd)) is None
|
|
381
|
+
finally: # pragma: no cover
|
|
382
|
+
os.close(fd)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if WINDOWS: # pragma: no cover
|
|
386
|
+
_FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
|
387
|
+
|
|
388
|
+
def _create_file_dir(path) -> int:
|
|
389
|
+
handle = _k32.CreateFileW(os.fspath(path), _READ_CONTROL, _FILE_SHARE_ALL, None, _OPEN_EXISTING,
|
|
390
|
+
_FILE_FLAG_BACKUP_SEMANTICS, None)
|
|
391
|
+
if handle == _INVALID_HANDLE or handle is None:
|
|
392
|
+
raise _last_error(path)
|
|
393
|
+
return msvcrt.open_osfhandle(handle, os.O_RDONLY)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def lock(fd: int) -> None:
|
|
397
|
+
"""Take an exclusive, blocking, cross-process lock on ``fd``."""
|
|
398
|
+
if not WINDOWS:
|
|
399
|
+
import fcntl
|
|
400
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
401
|
+
return
|
|
402
|
+
overlapped = _OVERLAPPED() # pragma: no cover
|
|
403
|
+
if not _k32.LockFileEx(msvcrt.get_osfhandle(fd), _LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, # pragma: no cover
|
|
404
|
+
ctypes.byref(overlapped)):
|
|
405
|
+
raise _last_error()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def unlock(fd: int) -> None:
|
|
409
|
+
if not WINDOWS:
|
|
410
|
+
import fcntl
|
|
411
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
412
|
+
return
|
|
413
|
+
overlapped = _OVERLAPPED() # pragma: no cover
|
|
414
|
+
if not _k32.UnlockFileEx(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped)): # pragma: no cover
|
|
415
|
+
raise _last_error()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def inside(path, directory) -> bool:
|
|
419
|
+
"""Whether ``path`` is ``directory`` or under it, after resolving links, case, and 8.3 short names."""
|
|
420
|
+
p = os.path.normcase(os.path.realpath(path))
|
|
421
|
+
d = os.path.normcase(os.path.realpath(directory))
|
|
422
|
+
return p == d or p.startswith(d.rstrip("\\/") + os.sep)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def replace(src, dst, *, attempts: int = 20, delay: float = 0.05) -> None:
|
|
426
|
+
"""``os.replace``; on Windows, retried briefly while another process holds ``dst`` open (bounded)."""
|
|
427
|
+
if not WINDOWS:
|
|
428
|
+
os.replace(src, dst)
|
|
429
|
+
return
|
|
430
|
+
for attempt in range(attempts): # pragma: no cover
|
|
431
|
+
try:
|
|
432
|
+
os.replace(src, dst)
|
|
433
|
+
return
|
|
434
|
+
except PermissionError as e:
|
|
435
|
+
if getattr(e, "winerror", None) not in _SHARING_VIOLATION or attempt == attempts - 1:
|
|
436
|
+
raise
|
|
437
|
+
time.sleep(delay)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def fsync_dir(path) -> None:
|
|
441
|
+
"""Flush a directory entry to disk. POSIX only: Windows cannot open a directory with ``os.open``, and
|
|
442
|
+
NTFS journals the rename itself."""
|
|
443
|
+
if WINDOWS: # pragma: no cover
|
|
444
|
+
return
|
|
445
|
+
fd = os.open(path, os.O_RDONLY)
|
|
446
|
+
try:
|
|
447
|
+
os.fsync(fd)
|
|
448
|
+
finally:
|
|
449
|
+
os.close(fd)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def is_link(path) -> bool:
|
|
453
|
+
"""A symlink, or on Windows any reparse point (a junction, too), as the last part of ``path``."""
|
|
454
|
+
if os.path.islink(path):
|
|
455
|
+
return True
|
|
456
|
+
if not WINDOWS:
|
|
457
|
+
return False
|
|
458
|
+
try: # pragma: no cover
|
|
459
|
+
return bool(os.lstat(path).st_file_attributes & 0x400)
|
|
460
|
+
except OSError: # pragma: no cover
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def dir_write_problem(path) -> str | None:
|
|
465
|
+
"""Why accounts other than the owner can add or remove entries in a directory, or None.
|
|
466
|
+
|
|
467
|
+
POSIX: the group or other write bit. Windows: an "allow" entry with a write right for an account other
|
|
468
|
+
than the current user, SYSTEM, or Administrators.
|
|
469
|
+
"""
|
|
470
|
+
if not WINDOWS:
|
|
471
|
+
return "writable by group or others" if os.lstat(path).st_mode & 0o022 else None
|
|
472
|
+
# GENERIC_ALL, GENERIC_WRITE, WRITE_OWNER, WRITE_DAC, DELETE, delete child, add subdirectory, add file
|
|
473
|
+
write = 0x10000000 | 0x40000000 | 0x80000 | 0x40000 | 0x10000 | 0x40 | 0x4 | 0x2 # pragma: no cover
|
|
474
|
+
fd = _create_file_dir(path) # pragma: no cover
|
|
475
|
+
try: # pragma: no cover
|
|
476
|
+
return _access_problem_handle(msvcrt.get_osfhandle(fd), write)
|
|
477
|
+
finally: # pragma: no cover
|
|
478
|
+
os.close(fd)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
if WINDOWS: # pragma: no cover
|
|
482
|
+
# GENERIC_ALL, GENERIC_WRITE, WRITE_OWNER, WRITE_DAC, DELETE, delete child, add subdirectory, add file
|
|
483
|
+
_WRITE_RIGHTS = 0x10000000 | 0x40000000 | 0x80000 | 0x40000 | 0x10000 | 0x40 | 0x4 | 0x2
|
|
484
|
+
# What it takes to rename or remove an entry: GENERIC_ALL, WRITE_OWNER, WRITE_DAC, DELETE, delete child.
|
|
485
|
+
_RENAME_RIGHTS = 0x10000000 | 0x80000 | 0x40000 | 0x10000 | 0x40
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _posix_account(uid: int) -> str:
|
|
489
|
+
try:
|
|
490
|
+
import pwd
|
|
491
|
+
return f"{pwd.getpwuid(uid).pw_name} (uid {uid})"
|
|
492
|
+
except (ImportError, KeyError):
|
|
493
|
+
return f"uid {uid}"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _posix_group(gid: int) -> str:
|
|
497
|
+
try:
|
|
498
|
+
import grp
|
|
499
|
+
return f"{grp.getgrgid(gid).gr_name} (gid {gid})"
|
|
500
|
+
except (ImportError, KeyError):
|
|
501
|
+
return f"gid {gid}"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def parent_account() -> tuple[int | None, str]:
|
|
505
|
+
"""The real account of the process that started this one, as ``(uid, label)``, or ``(None, why)``.
|
|
506
|
+
|
|
507
|
+
For the MCP proxy that process is the agent's host, or the ``sudo`` (or ``sshd`` session) it went
|
|
508
|
+
through, whose real user is still the caller. Linux reads ``/proc/<ppid>/status``; other POSIX systems
|
|
509
|
+
ask ``/bin/ps``. Windows is not supported yet, so it is ``(None, why)``, which the caller treats as
|
|
510
|
+
unknown, never as a different account.
|
|
511
|
+
"""
|
|
512
|
+
if WINDOWS: # pragma: no cover
|
|
513
|
+
return None, "telling the parent process's account is not supported on Windows yet"
|
|
514
|
+
ppid = os.getppid()
|
|
515
|
+
if ppid <= 1:
|
|
516
|
+
return None, f"the process that started Two-Key has exited (parent pid {ppid})"
|
|
517
|
+
status = f"/proc/{ppid}/status"
|
|
518
|
+
if os.path.isdir("/proc/self"):
|
|
519
|
+
try:
|
|
520
|
+
with open(status, encoding="ascii", errors="replace") as fh:
|
|
521
|
+
for line in fh:
|
|
522
|
+
if line.startswith("Uid:"):
|
|
523
|
+
uid = int(line.split()[1])
|
|
524
|
+
return uid, _posix_account(uid)
|
|
525
|
+
except (OSError, ValueError, IndexError) as e:
|
|
526
|
+
return None, f"cannot read {status} ({getattr(e, 'strerror', None) or type(e).__name__})"
|
|
527
|
+
return None, f"no Uid line in {status}"
|
|
528
|
+
import subprocess # nosec B404 - a fixed program and arguments, no shell # pragma: no cover
|
|
529
|
+
try: # pragma: no cover
|
|
530
|
+
done = subprocess.run(["/bin/ps", "-o", "ruid=", "-p", str(ppid)], # nosec B603 - fixed argv, no shell
|
|
531
|
+
capture_output=True, text=True, timeout=10, check=False)
|
|
532
|
+
uid = int(done.stdout.strip())
|
|
533
|
+
except (OSError, ValueError, subprocess.SubprocessError) as e: # pragma: no cover
|
|
534
|
+
return None, f"/bin/ps could not tell the parent's account ({type(e).__name__})"
|
|
535
|
+
return uid, _posix_account(uid) # pragma: no cover
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def account_can_write(path, uid: int) -> str | None:
|
|
539
|
+
"""How the POSIX account ``uid`` could change ``path`` (it owns it, everyone can write it, or a group it is
|
|
540
|
+
in can), or None. Follows links. An account whose groups cannot be listed is taken to be in every group,
|
|
541
|
+
so a group write bit counts. Root can change anything; the caller handles that."""
|
|
542
|
+
st = os.stat(path)
|
|
543
|
+
if st.st_uid == uid:
|
|
544
|
+
return "it owns it"
|
|
545
|
+
if st.st_mode & stat.S_IWOTH:
|
|
546
|
+
return "everyone can write it"
|
|
547
|
+
if st.st_mode & stat.S_IWGRP:
|
|
548
|
+
try:
|
|
549
|
+
import pwd
|
|
550
|
+
entry = pwd.getpwuid(uid)
|
|
551
|
+
groups = set(os.getgrouplist(entry.pw_name, entry.pw_gid))
|
|
552
|
+
except (ImportError, KeyError, OSError, AttributeError):
|
|
553
|
+
return f"its groups cannot be listed, and group {_posix_group(st.st_gid)} can write it"
|
|
554
|
+
if st.st_gid in groups:
|
|
555
|
+
return f"it is in group {_posix_group(st.st_gid)}, which can write it"
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def write_problem(path, rule: str = "file") -> str | None:
|
|
560
|
+
"""Why an account other than this process's user could change ``path``, or None. Follows links.
|
|
561
|
+
|
|
562
|
+
``rule`` is what matters about ``path``: ``file``, its contents; ``folder``, adding, removing, or renaming
|
|
563
|
+
its entries; ``parent``, renaming or removing an entry in it, which a POSIX sticky bit (or on Windows, the
|
|
564
|
+
right to add entries only) does not allow. Root, and on Windows SYSTEM, Administrators, and
|
|
565
|
+
TrustedInstaller, can change anything anyway, so they are not counted.
|
|
566
|
+
|
|
567
|
+
POSIX: an owner other than this user or root, the other write bit, or the group write bit for any group
|
|
568
|
+
but 0 (Two-Key cannot list a group's members reliably). Windows: an owner, or an "allow" entry with a
|
|
569
|
+
write right, other than this user or those accounts.
|
|
570
|
+
"""
|
|
571
|
+
if not WINDOWS:
|
|
572
|
+
st = os.stat(path)
|
|
573
|
+
if st.st_uid not in (0, os.geteuid()):
|
|
574
|
+
return f"owned by {_posix_account(st.st_uid)}"
|
|
575
|
+
if rule == "parent" and stat.S_ISDIR(st.st_mode) and st.st_mode & stat.S_ISVTX:
|
|
576
|
+
return None
|
|
577
|
+
if st.st_mode & stat.S_IWOTH:
|
|
578
|
+
return "writable by everyone"
|
|
579
|
+
if st.st_mode & stat.S_IWGRP and st.st_gid != 0:
|
|
580
|
+
return f"writable by group {_posix_group(st.st_gid)}"
|
|
581
|
+
return None
|
|
582
|
+
fd = _create_file_dir(path) # pragma: no cover
|
|
583
|
+
try: # pragma: no cover
|
|
584
|
+
return _access_problem_handle(msvcrt.get_osfhandle(fd),
|
|
585
|
+
_RENAME_RIGHTS if rule == "parent" else _WRITE_RIGHTS, owner=True,
|
|
586
|
+
also_trusted=(TRUSTED_INSTALLER_SID,), granted="can change it")
|
|
587
|
+
finally: # pragma: no cover
|
|
588
|
+
os.close(fd)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def resolve_program(name: str) -> str:
|
|
592
|
+
"""The program to start for a command's first word. On Windows, found through PATH and PATHEXT with
|
|
593
|
+
``shutil.which`` (so ``npx`` runs ``npx.cmd``); never through a shell. On POSIX, unchanged."""
|
|
594
|
+
if not WINDOWS:
|
|
595
|
+
return name
|
|
596
|
+
import shutil # pragma: no cover
|
|
597
|
+
return shutil.which(name) or name # pragma: no cover
|