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.
Files changed (54) hide show
  1. two_key/__init__.py +13 -0
  2. two_key/__main__.py +3 -0
  3. two_key/_platform.py +597 -0
  4. two_key/action.py +199 -0
  5. two_key/agent_access.py +96 -0
  6. two_key/agent_meta.py +139 -0
  7. two_key/agents.py +455 -0
  8. two_key/audit.py +90 -0
  9. two_key/backup.py +783 -0
  10. two_key/canonical.py +181 -0
  11. two_key/capability.py +222 -0
  12. two_key/cli.py +719 -0
  13. two_key/compiler.py +55 -0
  14. two_key/constitution.py +547 -0
  15. two_key/core.py +769 -0
  16. two_key/derive.py +593 -0
  17. two_key/fields.py +577 -0
  18. two_key/gateway.py +448 -0
  19. two_key/guard.py +205 -0
  20. two_key/identity.py +1106 -0
  21. two_key/judges/__init__.py +14 -0
  22. two_key/judges/anthropic.py +80 -0
  23. two_key/judges/base.py +103 -0
  24. two_key/judges/config.py +240 -0
  25. two_key/judges/credentials.py +154 -0
  26. two_key/judges/gemini.py +56 -0
  27. two_key/judges/llm.py +368 -0
  28. two_key/judges/ollama.py +56 -0
  29. two_key/judges/openai_compat.py +101 -0
  30. two_key/judges/transport.py +152 -0
  31. two_key/keys.py +140 -0
  32. two_key/ledger.py +1268 -0
  33. two_key/mcp_proxy/__init__.py +10 -0
  34. two_key/mcp_proxy/config.py +408 -0
  35. two_key/mcp_proxy/core.py +568 -0
  36. two_key/mcp_proxy/draft.py +301 -0
  37. two_key/mcp_proxy/sdk.py +226 -0
  38. two_key/netloc.py +96 -0
  39. two_key/policy_vm.py +342 -0
  40. two_key/quorum.py +515 -0
  41. two_key/scan.py +795 -0
  42. two_key/scanners.py +557 -0
  43. two_key/siem.py +1001 -0
  44. two_key/siem_export.py +266 -0
  45. two_key/siem_sinks.py +497 -0
  46. two_key/strict.py +141 -0
  47. two_key/testing.py +72 -0
  48. two_key/writable.py +200 -0
  49. two_key-0.7.0.dist-info/METADATA +783 -0
  50. two_key-0.7.0.dist-info/RECORD +54 -0
  51. two_key-0.7.0.dist-info/WHEEL +5 -0
  52. two_key-0.7.0.dist-info/entry_points.txt +2 -0
  53. two_key-0.7.0.dist-info/licenses/LICENSE +201 -0
  54. 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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