toolgraph 0.0.1__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.
toolgraph/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """toolgraph — graph-native MCP/tool registry for advisory governance analysis."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,97 @@
1
+ """Evidence-only scrubbing and immutable artifact identity validation."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit, urlunsplit
5
+
6
+ _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s)]+")
7
+
8
+
9
+ def _redact_uri(value: str) -> str:
10
+ """Remove URI userinfo and query strings without changing ordinary text."""
11
+ if "://" not in value:
12
+ return value
13
+ parts = urlsplit(value)
14
+ # Use the original netloc rather than ``parts.hostname``: urlsplit
15
+ # lowercases hostname accessors, while graph resource identity preserves
16
+ # the authored case. Only userinfo and query data are sensitive here.
17
+ host = parts.netloc.rsplit("@", 1)[-1]
18
+ return urlunsplit((parts.scheme, host, parts.path, "", ""))
19
+
20
+
21
+ _MAX_REDACT_DEPTH = 100
22
+
23
+
24
+ def redact(value, *, _depth: int = 0):
25
+ """Recursively scrub strings that may contain resource URI credentials."""
26
+ if _depth > _MAX_REDACT_DEPTH:
27
+ raise ValueError(
28
+ f"artifact nesting exceeds the {_MAX_REDACT_DEPTH}-level redaction limit"
29
+ )
30
+ if isinstance(value, dict):
31
+ return {key: redact(item, _depth=_depth + 1) for key, item in value.items()}
32
+ if isinstance(value, list):
33
+ return [redact(item, _depth=_depth + 1) for item in value]
34
+ if isinstance(value, str):
35
+ # Evidence paths contain a URI inside surrounding graph notation.
36
+ return _URI.sub(lambda match: _redact_uri(match.group(0)), value)
37
+ return value
38
+
39
+
40
+ _CREDENTIAL_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@")
41
+ _CREDENTIAL_QUERY = re.compile(
42
+ r"[?&](?:api[_-]?key|password|secret|token)=[^&#\s]+", re.IGNORECASE
43
+ )
44
+
45
+
46
+ def validate_identity(value: str) -> None:
47
+ if _CREDENTIAL_URI.search(value) or _CREDENTIAL_QUERY.search(value):
48
+ raise ValueError("artifact identity contains credential-shaped data")
49
+
50
+
51
+ _EVIDENCE_FIELDS = frozenset(
52
+ {
53
+ "paths",
54
+ "deny_paths",
55
+ "current_paths",
56
+ "evidence",
57
+ "resource",
58
+ "resource_uri",
59
+ "exception_reason",
60
+ }
61
+ )
62
+ _IDENTITY_FIELDS = frozenset(
63
+ {
64
+ "agent",
65
+ "principal",
66
+ "principal_id",
67
+ "run_id",
68
+ "candidate",
69
+ "tool_key",
70
+ "eligible",
71
+ "candidates",
72
+ }
73
+ )
74
+
75
+
76
+ def safe_artifact(value, *, _depth=0):
77
+ """Preserve opaque identities; scrub only explicitly designated evidence."""
78
+ if _depth > _MAX_REDACT_DEPTH:
79
+ raise ValueError("artifact nesting exceeds safety limit")
80
+ if isinstance(value, dict):
81
+ result = {}
82
+ for key, item in value.items():
83
+ if key in _EVIDENCE_FIELDS:
84
+ result[key] = redact(item)
85
+ continue
86
+ if key in _IDENTITY_FIELDS:
87
+ if isinstance(item, str):
88
+ validate_identity(item)
89
+ elif isinstance(item, list):
90
+ for identity in item:
91
+ if isinstance(identity, str):
92
+ validate_identity(identity)
93
+ result[key] = safe_artifact(item, _depth=_depth + 1)
94
+ return result
95
+ if isinstance(value, list):
96
+ return [safe_artifact(item, _depth=_depth + 1) for item in value]
97
+ return value
toolgraph/artifacts.py ADDED
@@ -0,0 +1,122 @@
1
+ """Canonical, private, atomic artifact helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ import errno
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import tempfile
12
+ import warnings
13
+
14
+
15
+ class ArtifactDurabilityWarning(RuntimeWarning):
16
+ """The artifact was replaced, but crash durability could not be confirmed."""
17
+
18
+
19
+ def rfc3339_offset_error(created_at: datetime) -> str | None:
20
+ """Why *created_at* cannot be stamped on an artifact, or None if it can.
21
+
22
+ Returned rather than raised so each producer keeps its own error type.
23
+ Aware means ``utcoffset()`` returns a value, not merely that tzinfo is
24
+ set: a tzinfo whose ``utcoffset()`` is None still isoformat()s without an
25
+ offset, which the schemas' RFC 3339 prose forbids.
26
+ """
27
+ offset = created_at.utcoffset()
28
+ if offset is None:
29
+ return (
30
+ "created_at must be timezone-aware — the contract requires an"
31
+ " RFC 3339 timestamp with a UTC offset"
32
+ )
33
+ if offset % timedelta(minutes=1):
34
+ return (
35
+ f"created_at offset {offset} has sub-minute resolution — RFC 3339"
36
+ " offsets are ±HH:MM"
37
+ )
38
+ return None
39
+
40
+
41
+ def canonical_json_bytes(value: object) -> bytes:
42
+ """Encode one artifact deterministically for exact-byte digests."""
43
+ return (
44
+ json.dumps(
45
+ value,
46
+ ensure_ascii=False,
47
+ separators=(",", ":"),
48
+ sort_keys=True,
49
+ )
50
+ + "\n"
51
+ ).encode("utf-8")
52
+
53
+
54
+ def sha256_bytes(payload: bytes) -> str:
55
+ return hashlib.sha256(payload).hexdigest()
56
+
57
+
58
+ def fsync_parent_dir(path: Path) -> None:
59
+ """Make an atomic rename durable on filesystems that support dir fsync.
60
+
61
+ Filesystems that cannot fsync a directory (some network/container mounts)
62
+ report EINVAL/ENOTSUP/EROFS — those are tolerated silently because there
63
+ is nothing more the writer can do. Any other failure propagates so the
64
+ caller can decide whether it is fatal (the rename itself already
65
+ published the file).
66
+ """
67
+ if os.name == "nt": # pragma: no cover - Windows has no directory fd
68
+ return
69
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
70
+ try:
71
+ descriptor = os.open(path.parent, flags)
72
+ try:
73
+ os.fsync(descriptor)
74
+ finally:
75
+ os.close(descriptor)
76
+ except OSError as exc:
77
+ unsupported = {
78
+ errno.EINVAL,
79
+ errno.ENOTSUP,
80
+ getattr(errno, "EOPNOTSUPP", errno.ENOTSUP),
81
+ errno.EROFS,
82
+ }
83
+ if exc.errno not in unsupported:
84
+ raise
85
+
86
+
87
+ def atomic_write_private(path: Path, payload: bytes) -> str:
88
+ """Write 0600 bytes via fsync + atomic replace; return exact digest."""
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ temporary: str | None = None
91
+ try:
92
+ with tempfile.NamedTemporaryFile(
93
+ mode="wb",
94
+ dir=path.parent,
95
+ prefix=f".{path.name}.",
96
+ delete=False,
97
+ ) as handle:
98
+ temporary = handle.name
99
+ os.chmod(temporary, 0o600)
100
+ handle.write(payload)
101
+ handle.flush()
102
+ os.fsync(handle.fileno())
103
+ os.replace(temporary, path)
104
+ temporary = None
105
+ os.chmod(path, 0o600)
106
+ try:
107
+ fsync_parent_dir(path)
108
+ except OSError:
109
+ # The artifact is already atomically visible with the digest the
110
+ # caller reports. Failing the whole write here would report ERROR
111
+ # for a correct published file; surface only the narrower
112
+ # crash-durability uncertainty.
113
+ warnings.warn(
114
+ "artifact was replaced, but parent-directory fsync failed; the"
115
+ " file is published but crash durability is uncertain",
116
+ ArtifactDurabilityWarning,
117
+ stacklevel=2,
118
+ )
119
+ finally:
120
+ if temporary is not None:
121
+ Path(temporary).unlink(missing_ok=True)
122
+ return sha256_bytes(payload)