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 +3 -0
- toolgraph/artifact_safety.py +97 -0
- toolgraph/artifacts.py +122 -0
- toolgraph/cli.py +951 -0
- toolgraph/config.py +106 -0
- toolgraph/control_plan.py +494 -0
- toolgraph/crawler/__init__.py +0 -0
- toolgraph/crawler/client.py +120 -0
- toolgraph/crawler/crawl.py +103 -0
- toolgraph/crawler/transports.py +60 -0
- toolgraph/graph/__init__.py +0 -0
- toolgraph/graph/driver.py +275 -0
- toolgraph/graph/loader.py +280 -0
- toolgraph/graph/queries.py +984 -0
- toolgraph/graph/schema.py +166 -0
- toolgraph/graph/selector.py +423 -0
- toolgraph/graph/store.py +57 -0
- toolgraph/manifest/__init__.py +0 -0
- toolgraph/manifest/ingest.py +542 -0
- toolgraph/manifest/parser.py +26 -0
- toolgraph/models.py +192 -0
- toolgraph/policy_bundle.py +212 -0
- toolgraph/policy_review.py +97 -0
- toolgraph/preflight.py +77 -0
- toolgraph/quickstart/governance.yaml +28 -0
- toolgraph/quickstart/policy_gateway_server.py +31 -0
- toolgraph/quickstart/servers.yaml +5 -0
- toolgraph/redaction.py +82 -0
- toolgraph/review_candidates.py +579 -0
- toolgraph/server/__init__.py +0 -0
- toolgraph/server/app.py +306 -0
- toolgraph/uris.py +64 -0
- toolgraph/yaml_loader.py +68 -0
- toolgraph-0.0.1.dist-info/METADATA +674 -0
- toolgraph-0.0.1.dist-info/RECORD +38 -0
- toolgraph-0.0.1.dist-info/WHEEL +4 -0
- toolgraph-0.0.1.dist-info/entry_points.txt +2 -0
- toolgraph-0.0.1.dist-info/licenses/LICENSE +191 -0
toolgraph/__init__.py
ADDED
|
@@ -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)
|