toolgraph 0.4.0__tar.gz → 0.4.1__tar.gz

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 (59) hide show
  1. {toolgraph-0.4.0 → toolgraph-0.4.1}/CHANGELOG.md +86 -0
  2. {toolgraph-0.4.0 → toolgraph-0.4.1}/PKG-INFO +1 -1
  3. {toolgraph-0.4.0 → toolgraph-0.4.1}/pyproject.toml +1 -1
  4. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/__init__.py +1 -1
  5. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/artifact_safety.py +12 -6
  6. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/cli.py +2 -2
  7. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/config.py +4 -3
  8. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/driver.py +8 -5
  9. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_propose.py +13 -6
  10. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/redaction.py +133 -48
  11. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/yaml_loader.py +6 -2
  12. {toolgraph-0.4.0 → toolgraph-0.4.1}/.gitignore +0 -0
  13. {toolgraph-0.4.0 → toolgraph-0.4.1}/LICENSE +0 -0
  14. {toolgraph-0.4.0 → toolgraph-0.4.1}/README.md +0 -0
  15. {toolgraph-0.4.0 → toolgraph-0.4.1}/SECURITY.md +0 -0
  16. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/control-plan.schema.json +0 -0
  17. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/control-preflight.schema.json +0 -0
  18. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/observation-window.schema.json +0 -0
  19. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/policy-bundle.schema.json +0 -0
  20. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/policy-impact.schema.json +0 -0
  21. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/policy-review-plan.schema.json +0 -0
  22. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/preflight.schema.json +0 -0
  23. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/review-annotations.schema.json +0 -0
  24. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/tracegraph/README.md +0 -0
  25. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/tracegraph/review-candidates.schema.json +0 -0
  26. {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/tracegraph/v1.json +0 -0
  27. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/artifacts.py +0 -0
  28. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/control_plan.py +0 -0
  29. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/__init__.py +0 -0
  30. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/client.py +0 -0
  31. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/crawl.py +0 -0
  32. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/transports.py +0 -0
  33. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/error_text.py +0 -0
  34. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/errors.py +0 -0
  35. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/__init__.py +0 -0
  36. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/loader.py +0 -0
  37. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/queries.py +0 -0
  38. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/schema.py +0 -0
  39. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/selector.py +0 -0
  40. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/store.py +0 -0
  41. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/health.py +0 -0
  42. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/log_safety.py +0 -0
  43. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/manifest/__init__.py +0 -0
  44. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/manifest/ingest.py +0 -0
  45. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/manifest/parser.py +0 -0
  46. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/models.py +0 -0
  47. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_bundle.py +0 -0
  48. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_impact.py +0 -0
  49. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_impact_models.py +0 -0
  50. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_review.py +0 -0
  51. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/preflight.py +0 -0
  52. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/README.md +0 -0
  53. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/governance.yaml +0 -0
  54. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/policy_gateway_server.py +0 -0
  55. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/servers.yaml +0 -0
  56. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/review_candidates.py +0 -0
  57. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/server/__init__.py +0 -0
  58. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/server/app.py +0 -0
  59. {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/uris.py +0 -0
@@ -2,6 +2,92 @@
2
2
 
3
3
  All notable changes to Toolgraph are documented here.
4
4
 
5
+ ## 0.4.1 - 2026-11-09
6
+
7
+ Sixth alpha, a patch release: diagnostics only. Nothing that 0.4.0 accepted
8
+ is refused, and no command, flag, default, function signature or JSON key
9
+ changes. Read **Upgrading from 0.4.0** below if a script or tool matches
10
+ Toolgraph's error text, or reads the configuration path it reports: more of
11
+ it is hidden or withheld whole.
12
+
13
+ ### Upgrading from 0.4.0
14
+
15
+ - Diagnostics hide more, and more of them are withheld whole (#241, #245,
16
+ #262). An `Authorization` value is hidden to the end of the message
17
+ (`Authorization: Bearer abc; retrying in 5s` now reads
18
+ `Authorization=<redacted>`). A message whose URL ends in a credential name
19
+ that assigns a value after it is withheld, in each form the scrubber reads
20
+ as an assignment (`GET https://h/authorization=Basic returned 404`). A
21
+ credential-shaped path in a YAML, `policy propose` export, `doctor` or
22
+ Ladybug backend error reads `<path withheld: credential-shaped>`, and so
23
+ does the configuration `path` in `doctor --json`, where 0.4.0 showed a
24
+ partly scrubbed path or withheld the line.
25
+ `toolgraph.config.check_config_health()` returns that display value in
26
+ `path` and `detail` too, where 0.4.0 returned the path as given. Tooling
27
+ that matches stderr, MCP error text or `doctor` output should expect these
28
+ forms; the entries under Fixed give each one.
29
+
30
+ ### Changed
31
+
32
+ - Scrubbing a diagnostic is faster (#263), with the same output as before
33
+ that change: a check that needs a `%`, a backslash, a quote or `://` is
34
+ skipped when the message holds none, and each repr literal is parsed once
35
+ instead of twice. Against 0.4.0, measured locally (CPU time, best of three),
36
+ 19 of the 21 adversarial inputs in `tests/test_redaction.py` run at least
37
+ 1.4 times faster and a short plain message about 4.5 times; the two
38
+ percent-encoded URL inputs run about 12% slower, the cost of the decoded
39
+ check #241 added.
40
+ - Distributions are built with `hatchling` 1.33.0 (was 1.32.4); the sdist
41
+ pins it in `[build-system] requires`.
42
+
43
+ ### Fixed
44
+
45
+ - A credential name at the end of a URL is caught in the forms the scrubber
46
+ reads as an assignment, not only those the refusal rule does (#241). 0.4.0
47
+ withheld `fetch http://h/password =secret` but printed
48
+ `http://h/authorization=Basic secret` as `http://h secret`, and likewise
49
+ `Authorization=Token`, `proxy_authorization=Basic`, `password :secret`,
50
+ `password:[a, secret]` and `credential:{a: secret}`. Such a message is now
51
+ withheld whole, and so is one where the name or its delimiter is
52
+ percent-encoded (`http://h/pass%77ord :secret`,
53
+ `http://h/password%3A[a, secret]`), judged against each URL where the
54
+ rewrite finds it. More diagnostics are withheld for the same reason: a URL
55
+ path ending in a credential name and a scheme word with text after it
56
+ (`GET https://h/authorization=Basic returned 404`), or a bracket opening
57
+ inside the URL after a `:` (`GET https://h/password:[id] returned 404`),
58
+ as one after `=` already was. Not caught: an encoded delimiter that joins
59
+ the text after a URL to the start of another one
60
+ (`http://h/password %3Ahunter2://host`); an encoded name before a URL's
61
+ scheme (`%70assword://hunter2`); and a repr-escaped URL followed by an
62
+ assignment (`'http://h/password\t':hunter2`) (#246).
63
+ - Diagnostics hide everything after an `Authorization` name, not only the
64
+ first word of its value (#245). 0.4.0 knew only the `Bearer`, `Basic` and
65
+ `Token` schemes and took any other scheme word as the whole value, so
66
+ `Authorization: Digest username=alice, response=hunter2` printed
67
+ `Authorization=<redacted> username=alice, response=hunter2`; Negotiate,
68
+ AWS4-HMAC-SHA256 `Signature=`, OAuth, SCRAM and Hawk parameters showed the
69
+ same way, and so did a URL ending in the name
70
+ (`http://h/authorization=Digest username=alice,response=hunter2` printed
71
+ `http://h username=alice,response=hunter2`; it is now withheld). A name with
72
+ a prefix is scrubbed too: `proxy_authorization:hunter2` and
73
+ `HTTP_AUTHORIZATION:hunter2` were printed as given. Where such a value ends
74
+ cannot be told from the text, so the rest of the message goes with it:
75
+ `Authorization: Bearer abc; retrying in 5s` now prints
76
+ `Authorization=<redacted>`, and a URL holding an Authorization name with
77
+ text after it is withheld (`GET https://h/authorization=abc returned 404`).
78
+ - More error lines show a credential-shaped path as
79
+ `<path withheld: credential-shaped>` and keep the rest of the line (#262):
80
+ a YAML file over the size limit or without a mapping at its root, a
81
+ `policy propose` export over the size limit or with a bad envelope or row,
82
+ the manifest digest hint for an export without an envelope, `doctor`'s
83
+ configuration check, and the `BACKEND_CORRUPT`, `BACKEND_UNAVAILABLE` and
84
+ `BACKEND_LOCKED` errors for a Ladybug database. 0.4.0 scrubbed these paths
85
+ along with the line, so a directory named `password=x` printed a path that
86
+ does not exist (`expected a YAML mapping at document root:
87
+ /tmp/password=<redacted>`; an export error lost its `:1:` line number with
88
+ it), and a percent-encoded name (`/tmp/?%74oken=x`) withheld the whole
89
+ line, error code included.
90
+
5
91
  ## 0.4.0 - 2026-10-21
6
92
 
7
93
  Fifth alpha. Read **Upgrading from 0.3.0** below before installing: it lists
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: toolgraph
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Graph-native MCP/tool registry, policy compiler, and impact analyzer
5
5
  Project-URL: Homepage, https://github.com/memtomem/toolgraph
6
6
  Project-URL: Documentation, https://github.com/memtomem/toolgraph/blob/main/docs/beginner-guide.md
@@ -77,7 +77,7 @@ notebook = ["nbclient>=0.10", "ipykernel>=6"]
77
77
  # different bytes behind the same reviewed commit. `scripts/verify-artifacts.sh`
78
78
  # asserts the sdist rebuilds the wheel byte for byte, which only holds while
79
79
  # the backend is fixed. Bump deliberately, then rehearse.
80
- requires = ["hatchling==1.32.4"]
80
+ requires = ["hatchling==1.33.0"]
81
81
  build-backend = "hatchling.build"
82
82
 
83
83
  [tool.hatch.version]
@@ -1,3 +1,3 @@
1
1
  """toolgraph — graph-native MCP/tool registry for advisory governance analysis."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
@@ -73,9 +73,12 @@ def display_resource(value: str) -> str:
73
73
  one percent-decoded form; the output is never decoded.
74
74
  """
75
75
  end = value.find("://")
76
+ if end == -1 and "%" not in value:
77
+ return value
76
78
  # An encoded scheme separator before the first raw one (`neo4j%3A%2F%2F`)
77
79
  # marks a URI the checks below would not see.
78
- if "://" in unquote(value if end == -1 else value[:end]):
80
+ prefix = value if end == -1 else value[:end]
81
+ if "%" in prefix and "://" in unquote(prefix):
79
82
  return _HIDDEN
80
83
  if end == -1:
81
84
  return value
@@ -93,12 +96,13 @@ def display_resource(value: str) -> str:
93
96
  quote = _QUOTE.search(uri, end + 3 - first)
94
97
  if quote is not None and uri.rfind("@") > quote.start():
95
98
  return hidden
96
- decoded = unquote(uri)
97
- if decoded.count("@") > uri.count("@"):
99
+ decoded = unquote(uri) if "%" in uri else uri
100
+ if decoded != uri and decoded.count("@") > uri.count("@"):
98
101
  return hidden
99
102
  try:
100
103
  parts = urlsplit(uri)
101
- if any("@" in part for form in (parts, urlsplit(decoded)) for part in form[2:]):
104
+ forms = (parts,) if decoded == uri else (parts, urlsplit(decoded))
105
+ if any("@" in part for form in forms for part in form[2:]):
102
106
  return hidden
103
107
  except ValueError:
104
108
  return hidden
@@ -129,7 +133,7 @@ def _redact_text(text: str) -> str:
129
133
  while True:
130
134
  scheme = _SCHEME.search(word, position)
131
135
  head = word[position:] if scheme is None else word[position : scheme.start()]
132
- if unquote(head).count("://") > head.count("://"):
136
+ if "%" in head and unquote(head).count("://") > head.count("://"):
133
137
  return "".join(shown) + text[done : base + position] + _HIDDEN
134
138
  if scheme is None:
135
139
  # A scheme with no letter (`+++://secret=x`) is left alone, so
@@ -166,10 +170,12 @@ _RESOURCE_LIST_KEYS = frozenset(
166
170
  def _redact_string(value: str, *, whole: bool) -> str:
167
171
  # A string that is one URI from its start (raw or decoded, whitespace
168
172
  # around it aside) is one value too, wherever it sits.
173
+ if "://" not in value and "%" not in value:
174
+ return value
169
175
  stripped = value.strip()
170
176
  if whole or (
171
177
  not any(char.isspace() for char in stripped)
172
- and (_SCHEME.match(stripped) or _SCHEME.match(unquote(stripped)))
178
+ and (_SCHEME.match(stripped) or ("%" in stripped and _SCHEME.match(unquote(stripped))))
173
179
  ):
174
180
  return display_resource(value)
175
181
  return _redact_text(value)
@@ -97,8 +97,8 @@ _DURABILITY_WARNINGS = (ArtifactDurabilityWarning, ReviewCandidateDurabilityWarn
97
97
  def _credential_shaped_argument(args: list[str]) -> bool:
98
98
  """Whether any command-line token is credential-shaped (the refusal rule).
99
99
 
100
- Checked on the tokens as typed: Click and Typer print a value escaped
101
- (`\\x09`, `\\xa0`) or quoted, a form the scrubber does not read. The
100
+ Checked on the tokens as typed, before Click and Typer quote or escape
101
+ them (`\\x09`, `\\xa0`) in the message. The
102
102
  tokens joined by spaces are checked too: Click prints unexpected extra
103
103
  arguments that way, so `password` and `<TAB>=value` make one assignment.
104
104
  """
@@ -33,16 +33,17 @@ def runtime_config_path() -> Path:
33
33
  def check_config_health() -> dict[str, Any]:
34
34
  """Inspect active configuration file existence, schema validity, and permissions."""
35
35
  path = runtime_config_path()
36
+ shown_path = display_path(path)
36
37
  exists = path.is_file()
37
38
  if not exists:
38
39
  return {
39
- "path": str(path),
40
+ "path": shown_path,
40
41
  "exists": False,
41
42
  "valid_json": False,
42
43
  "permissions_ok": None,
43
44
  "permissions": None,
44
45
  "healthy": False,
45
- "detail": f"configuration file not found at {path}",
46
+ "detail": f"configuration file not found at {shown_path}",
46
47
  "backend_source": backend_source(),
47
48
  }
48
49
 
@@ -64,7 +65,7 @@ def check_config_health() -> dict[str, Any]:
64
65
 
65
66
  healthy = exists and valid_json and (permissions_ok is not False)
66
67
  return {
67
- "path": str(path),
68
+ "path": shown_path,
68
69
  "exists": exists,
69
70
  "valid_json": valid_json,
70
71
  "permissions_ok": permissions_ok,
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any
18
18
 
19
19
  from toolgraph import config
20
20
  from toolgraph.models import TOOL_HINT_FIELDS
21
+ from toolgraph.redaction import display_path
21
22
 
22
23
  if TYPE_CHECKING:
23
24
  from neo4j import Driver, Session
@@ -349,7 +350,7 @@ def _not_a_database(path: Path) -> BackendConfigurationError:
349
350
  # Not retryable: waiting does not turn a foreign or corrupt file into a
350
351
  # database, so this must not read as an outage.
351
352
  return BackendConfigurationError(
352
- f"BACKEND_CORRUPT: {path} is not a valid Ladybug database file; "
353
+ f"BACKEND_CORRUPT: {display_path(path)} is not a valid Ladybug database file; "
353
354
  "check file integrity and format",
354
355
  cause=ConfigurationCause.BACKEND_CORRUPT,
355
356
  )
@@ -357,7 +358,8 @@ def _not_a_database(path: Path) -> BackendConfigurationError:
357
358
 
358
359
  def _missing_database(path: Path) -> BackendUnavailableError:
359
360
  return BackendUnavailableError(
360
- f"BACKEND_UNAVAILABLE: Ladybug database {path} does not exist or is not a file"
361
+ f"BACKEND_UNAVAILABLE: Ladybug database {display_path(path)} "
362
+ "does not exist or is not a file"
361
363
  )
362
364
 
363
365
 
@@ -381,7 +383,7 @@ def _open_ladybug_database(path: Path, *, create: bool) -> Any:
381
383
  message = str(exc)
382
384
  if "lock" in message.lower() or "another process" in message.lower():
383
385
  raise BackendLockedError(
384
- f"BACKEND_LOCKED: Ladybug database {path} already has an owner"
386
+ f"BACKEND_LOCKED: Ladybug database {display_path(path)} already has an owner"
385
387
  ) from exc
386
388
  if _NOT_A_DATABASE in message:
387
389
  raise _not_a_database(path) from exc
@@ -535,11 +537,12 @@ def acquire_readonly_session() -> Iterator[Session | LadybugSession]:
535
537
  except RuntimeError as exc:
536
538
  if "lock" in str(exc).lower() or "another process" in str(exc).lower():
537
539
  raise BackendLockedError(
538
- f"BACKEND_LOCKED: Ladybug database {path} already has an owner"
540
+ f"BACKEND_LOCKED: Ladybug database {display_path(path)} "
541
+ "already has an owner"
539
542
  ) from exc
540
543
  if not path.is_file():
541
544
  raise BackendUnavailableError(
542
- f"BACKEND_UNAVAILABLE: Ladybug database {path} does not exist"
545
+ f"BACKEND_UNAVAILABLE: Ladybug database {display_path(path)} does not exist"
543
546
  ) from exc
544
547
  if _NOT_A_DATABASE in str(exc):
545
548
  raise _not_a_database(path) from exc
@@ -16,7 +16,12 @@ from toolgraph.artifact_safety import validate_identity
16
16
  from toolgraph.graph import driver, queries, selector
17
17
  from toolgraph.graph.selector import PROFILES
18
18
  from toolgraph.manifest.parser import Governance, governance_digest, load_governance
19
- from toolgraph.redaction import is_credential_shaped, is_credential_shaped_identity, redact_text
19
+ from toolgraph.redaction import (
20
+ display_path,
21
+ is_credential_shaped,
22
+ is_credential_shaped_identity,
23
+ redact_text,
24
+ )
20
25
 
21
26
 
22
27
  class PolicyProposeError(Exception):
@@ -273,11 +278,12 @@ MAX_EXPORT_BYTES = 50_000_000
273
278
 
274
279
  def load_export(path: Path) -> tuple[dict | None, list[dict]]:
275
280
  # One open descriptor: a stat-then-read pair is a TOCTOU.
281
+ shown_path = display_path(path)
276
282
  with path.open("rb") as handle:
277
283
  raw = handle.read(MAX_EXPORT_BYTES + 1)
278
284
  if len(raw) > MAX_EXPORT_BYTES:
279
285
  raise PolicyProposeError(
280
- f"refusing to load {path}: exceeds the {MAX_EXPORT_BYTES}-byte limit"
286
+ f"refusing to load {shown_path}: exceeds the {MAX_EXPORT_BYTES}-byte limit"
281
287
  )
282
288
  lines = raw.decode("utf-8").splitlines()
283
289
  envelope: dict | None = None
@@ -292,10 +298,10 @@ def load_export(path: Path) -> tuple[dict | None, list[dict]]:
292
298
  first = None
293
299
  if isinstance(first, dict) and ENVELOPE_KEY in first:
294
300
  if not isinstance(first[ENVELOPE_KEY], dict):
295
- raise ProvenanceError(f"{path}:{lineno}: envelope must be a JSON object")
301
+ raise ProvenanceError(f"{shown_path}:{lineno}: envelope must be a JSON object")
296
302
  if set(first) != {ENVELOPE_KEY}:
297
303
  raise ProvenanceError(
298
- f"{path}:{lineno}: the envelope line carries only "
304
+ f"{shown_path}:{lineno}: the envelope line carries only "
299
305
  f"{ENVELOPE_KEY!r}; observation fields belong on their own rows"
300
306
  )
301
307
  envelope = first[ENVELOPE_KEY]
@@ -307,9 +313,10 @@ def load_export(path: Path) -> tuple[dict | None, list[dict]]:
307
313
  def _load_rows(path: Path, lines: list[tuple[int, str]]) -> list[dict]:
308
314
  rows: list[dict] = []
309
315
  seen: set[tuple[str, str]] = set()
316
+ shown_path = display_path(path)
310
317
  for lineno, raw in lines:
311
318
  line = raw.strip()
312
- where = f"{path}:{lineno}"
319
+ where = f"{shown_path}:{lineno}"
313
320
  try:
314
321
  row = json.loads(line)
315
322
  except json.JSONDecodeError as exc:
@@ -698,7 +705,7 @@ def run_propose(
698
705
  "Re-export with an envelope line:\n"
699
706
  ' {"envelope": {"governance_digest": "<64 hex>", "graph_generation": 0, '
700
707
  '"profile": "strict", "window_start": "...", "window_end": "..."}}\n'
701
- f"The digest for {governance_path} right now is {expected_digest}.\n"
708
+ f"The digest for {display_path(governance_path)} right now is {expected_digest}.\n"
702
709
  "Or pass --unverified-provenance to proceed without envelope verification."
703
710
  )
704
711
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import ast
6
6
  import re
7
7
  import shlex
8
- from collections.abc import Iterator
8
+ from collections.abc import Iterable, Iterator
9
9
  from itertools import islice
10
10
  from pathlib import Path, PureWindowsPath
11
11
  from typing import TYPE_CHECKING
@@ -37,12 +37,13 @@ _AUTHORIZATION = re.compile(
37
37
  r"(?i)\bauthorization['\"]?\s*[:=]\s*['\"]?"
38
38
  r"(?:(?:bearer|basic|token)\s+)?[^\s,'\"}\]]+"
39
39
  )
40
- # The scrubbing form: a quoted or bracketed value is hidden through the end of
41
- # the text, as for _SCRUB_VALUE below.
42
- _AUTHORIZATION_SCRUB = re.compile(
43
- r"(?i)\bauthorization['\"]?\s*[:=]\s*"
44
- r"(?:['\"\[{][\s\S]*|(?:(?:bearer|basic|token)\s+)?[^\s,'\"}\]]+)"
45
- )
40
+ # The scrubbing form hides everything after the name through the end of the
41
+ # text. Where an Authorization value ends cannot be told from prose: a scheme
42
+ # may take a parameter list (`Digest username=a, response=x failed`), a value
43
+ # may be malformed (`Basic user x`), and a header dump may hold another
44
+ # Authorization header after it. No `\b`, so `proxy_authorization` and
45
+ # `HTTP_AUTHORIZATION` count, as the credential names below do.
46
+ _AUTHORIZATION_SCRUB = re.compile(r"(?i)authorization['\"]?\s*[:=]\s*\S[\s\S]*")
46
47
  # The names every credential detector treats as a secret when assigned. One
47
48
  # list, so a name refused in one place is refused in artifact identities,
48
49
  # review notes and control plans alike. Scrubbing (_CREDENTIAL below) keeps the
@@ -171,10 +172,31 @@ def _inspected(text: str) -> tuple[str, ...]:
171
172
  # One percent-decoding pass, for inspection only: the value itself is
172
173
  # never rewritten. `+` stays literal, and a doubly encoded key such as
173
174
  # `%2574oken` is not decoded a second time.
175
+ if "%" not in text:
176
+ return (text,)
174
177
  decoded = unquote(text)
175
178
  return (text,) if decoded == text else (text, decoded)
176
179
 
177
180
 
181
+ def _form_is_credential_shaped(form: str) -> bool:
182
+ has_colon = ":" in form
183
+ has_equals = "=" in form
184
+ if has_colon and "://" in form and (at := form.rfind("@")) != -1:
185
+ if CREDENTIAL_URI.search(form):
186
+ return True
187
+ if ("'" in form or '"' in form) and any(
188
+ pattern.search(form, 0, at) for pattern in _QUOTED_USERINFO
189
+ ):
190
+ return True
191
+ if has_equals and CREDENTIAL_QUERY.search(form):
192
+ return True
193
+ if (has_colon or has_equals) and _REFUSED_ASSIGNMENT.search(form):
194
+ return True
195
+ if has_colon and _REFUSED_AUTHORIZATION.search(form):
196
+ return True
197
+ return bool(_BEARER.search(form))
198
+
199
+
178
200
  def is_credential_shaped(text: str) -> bool:
179
201
  """True when a value that is stored or emitted as given holds a credential.
180
202
 
@@ -192,15 +214,7 @@ def is_credential_shaped(text: str) -> bool:
192
214
  userinfo whose unquoted part holds whitespace (``neo4j://u v:"p w"@h``
193
215
  reads as a URL followed by text), so only identities refuse that.
194
216
  """
195
- return any(
196
- CREDENTIAL_URI.search(form)
197
- or any(pattern.search(form, 0, form.rfind("@")) for pattern in _QUOTED_USERINFO)
198
- or CREDENTIAL_QUERY.search(form)
199
- or _REFUSED_ASSIGNMENT.search(form)
200
- or _REFUSED_AUTHORIZATION.search(form)
201
- or _BEARER.search(form)
202
- for form in _inspected(text)
203
- )
217
+ return any(_form_is_credential_shaped(form) for form in _inspected(text))
204
218
 
205
219
 
206
220
  def is_credential_shaped_identity(text: str) -> bool:
@@ -223,6 +237,8 @@ def _uri_origin_keeping_prefix(value: str) -> str:
223
237
  # stay as they are, so a credential name the URI's host position holds
224
238
  # (`1https://password : x`) is still there for credential scrubbing. A
225
239
  # scheme without a letter (`123://`) is not a URI and is left alone.
240
+ if value[0].isalpha():
241
+ return _diagnostic_uri_origin(value)
226
242
  scheme = value[: value.index("://")]
227
243
  first = next((i for i, char in enumerate(scheme) if char.isalpha()), None)
228
244
  if first is None:
@@ -236,7 +252,11 @@ def _hide_quoted_userinfo(text: str) -> str:
236
252
  # wherever the refusal rule refuses one. The hidden span starts at the
237
253
  # beginning of the whitespace-delimited token: cutting at a nested scheme
238
254
  # (`https://user:x://"pw"@host`) would leave a prefix that reads as a URI.
255
+ if "'" not in text and '"' not in text:
256
+ return text
239
257
  end = text.rfind("@")
258
+ if end == -1:
259
+ return text
240
260
  matches = [match for pattern in _QUOTED_USERINFO if (match := pattern.search(text, 0, end))]
241
261
  if not matches:
242
262
  return text
@@ -251,7 +271,16 @@ def _hide_quoted_userinfo(text: str) -> str:
251
271
  # would go with the URI rewrite and leave its value readable. The search is
252
272
  # zero-width, so names that share letters (`secretoken`) are each tried.
253
273
  _NAME_START = re.compile(rf"(?i)(?=(?:{CREDENTIAL_KEYWORDS}|authorization|bearer))")
254
- _NAME_RULES = (_REFUSED_ASSIGNMENT, _REFUSED_AUTHORIZATION, _BEARER)
274
+ # What the scrubbers take as an assignment, up to where the value starts: the
275
+ # first group of _SCRUB_VALUE after any name. Matching no value keeps each try
276
+ # bounded. The refusal patterns accept fewer forms (`password :x`,
277
+ # `authorization=Basic x`), and every form the scrubbers accept beyond them
278
+ # leaked here.
279
+ _SCRUB_ASSIGNMENT = re.compile(rf"(?i)(?:{CREDENTIAL_KEYWORDS}|authorization)['\"]?\s*[:=]\s*")
280
+ # _AUTHORIZATION_SCRUB's value runs to the end of the text, so an Authorization
281
+ # name inside a URI assigns past it whenever any text follows the URI.
282
+ _AUTHORIZATION_NAME = re.compile(r"(?i)authorization['\"]?\s*[:=]")
283
+ _NAME_RULES = (_SCRUB_ASSIGNMENT, _BEARER)
255
284
  _NON_SPACE = re.compile(r"\S")
256
285
 
257
286
 
@@ -259,8 +288,8 @@ def _assigns_past(text: str, uri_end: int, match: re.Match[str]) -> bool:
259
288
  # Whether rewriting a URI that ends at `uri_end` would hide the name
260
289
  # `match` starts with but not what it assigns.
261
290
  end = match.end()
262
- if match.re is not _REFUSED_ASSIGNMENT:
263
- # Authorization and Bearer matches hold the credential itself.
291
+ if match.re is _BEARER:
292
+ # A Bearer match holds the credential itself.
264
293
  return end > uri_end
265
294
  if end < uri_end:
266
295
  # A bracketed value runs to the end of the text.
@@ -269,26 +298,73 @@ def _assigns_past(text: str, uri_end: int, match: re.Match[str]) -> bool:
269
298
  return _NON_SPACE.search(text, end) is not None
270
299
 
271
300
 
272
- def _assignment_crosses_uri(text: str) -> bool:
273
- """True when a credential name inside a URI assigns a value past its end."""
274
- # Names are found in text order, so one pointer walks the URIs once.
275
- uris = _URI.finditer(text)
301
+ def _crosses(text: str, spans: Iterable[tuple[int, int]]) -> bool:
302
+ # Names are found in text order, so one pointer walks the spans once. The
303
+ # end of the text without trailing whitespace is found once too: searching
304
+ # past each URI for every name would be quadratic.
305
+ uris = iter(spans)
276
306
  uri = next(uris, None)
277
- for name in _NAME_START.finditer(text):
307
+ if uri is None:
308
+ return False
309
+ last = len(text.rstrip())
310
+ for name in _NAME_START.finditer(text, uri[0]):
278
311
  at = name.start()
279
- while uri is not None and uri.end() <= at:
312
+ while uri is not None and uri[1] <= at:
280
313
  uri = next(uris, None)
281
314
  if uri is None:
282
315
  return False
283
- if at < uri.start():
316
+ if at < uri[0]:
284
317
  continue
285
- for rule in _NAME_RULES:
286
- match = rule.match(text, at)
287
- if match and _assigns_past(text, uri.end(), match):
318
+ if text[at] in "bB":
319
+ match = _BEARER.match(text, at)
320
+ if match and _assigns_past(text, uri[1], match):
321
+ return True
322
+ else:
323
+ match = _SCRUB_ASSIGNMENT.match(text, at)
324
+ if match and (
325
+ (last > uri[1] and text[at] in "aA" and _AUTHORIZATION_NAME.match(text, at))
326
+ or _assigns_past(text, uri[1], match)
327
+ ):
288
328
  return True
289
329
  return False
290
330
 
291
331
 
332
+ def _decoded_keeping_uris(text: str) -> tuple[str, list[tuple[int, int]]]:
333
+ # Each URI and each gap between URIs is decoded on its own, so a URI keeps
334
+ # the bounds the rewrite will use: decoding the whole text would let a
335
+ # `%20` end a URI early and move a name out of it.
336
+ parts: list[str] = []
337
+ spans: list[tuple[int, int]] = []
338
+ length = consumed = 0
339
+ for uri in _URI.finditer(text):
340
+ gap = unquote(text[consumed : uri.start()])
341
+ value = unquote(uri.group(0))
342
+ start = length + len(gap)
343
+ parts += [gap, value]
344
+ spans.append((start, start + len(value)))
345
+ length = start + len(value)
346
+ consumed = uri.end()
347
+ parts.append(unquote(text[consumed:]))
348
+ return "".join(parts), spans
349
+
350
+
351
+ def _assignment_crosses_uri(text: str) -> bool:
352
+ """True when a credential name inside a URI assigns a value past its end.
353
+
354
+ Checked on the text as given and on its percent-decoded form, with each
355
+ URI where the rewrite will find it.
356
+ """
357
+ if "://" not in text:
358
+ return False
359
+ if _crosses(text, (uri.span() for uri in _URI.finditer(text))):
360
+ return True
361
+ if "%" not in text:
362
+ # Nothing to decode, and building the copy slows a URI-heavy text.
363
+ return False
364
+ decoded, spans = _decoded_keeping_uris(text)
365
+ return decoded != text and _crosses(decoded, spans)
366
+
367
+
292
368
  # `repr`, `ascii`, `%r`, a container's `str()` and many library errors
293
369
  # (`int('…')`) print a tab or a no-break space as an escape (`\t`, `\xa0`), so
294
370
  # `password<TAB>=value` no longer reads as an assignment. Such output is a
@@ -299,6 +375,7 @@ def _assignment_crosses_uri(text: str) -> bool:
299
375
  # quote. The repeat is possessive: the two branches never overlap, so giving
300
376
  # characters back could not find another match, and a backtracking repeat
301
377
  # keeps state for every character (about 180 MiB for a 1 MB literal).
378
+ _QUOTE_CHAR = re.compile(r"""['"]""")
302
379
  _LITERAL = re.compile(r"""(['"])((?:\\.|(?!\1)[^\\\n])*+)\1""")
303
380
  # The escapes repr and ascii print. A candidate holding any other backslash
304
381
  # sequence is not their output and never reaches the parser, which would
@@ -350,8 +427,9 @@ def _repr_literals(text: str) -> Iterator[list[tuple[int, int, str]]]:
350
427
  an odd run of backslashes (`C:\\` followed by repr output) is therefore
351
428
  not read.
352
429
  """
353
- for index, char in enumerate(text):
354
- if char not in "'\"" or _escaped(text, index):
430
+ for quote in _QUOTE_CHAR.finditer(text):
431
+ index = quote.start()
432
+ if _escaped(text, index):
355
433
  continue
356
434
  match = _LITERAL.match(text, index)
357
435
  if not match or "\\" not in match[2]:
@@ -366,11 +444,14 @@ def _repr_reads_as_credential(text: str) -> bool:
366
444
  # Counted first, so a text over the limit costs one scan and no checks.
367
445
  # Each form is then built and checked on its own: holding them all would
368
446
  # copy the whole text twice per reading.
369
- if sum(1 for _ in islice(_repr_literals(text), _MAX_LITERALS + 1)) > _MAX_LITERALS:
447
+ if "\\" not in text:
448
+ return False
449
+ literals = list(islice(_repr_literals(text), _MAX_LITERALS + 1))
450
+ if len(literals) > _MAX_LITERALS:
370
451
  return True
371
452
  return any(
372
453
  is_credential_shaped(value) or is_credential_shaped(text[:start] + value + text[end:])
373
- for readings in _repr_literals(text)
454
+ for readings in literals
374
455
  for start, end, value in readings
375
456
  )
376
457
 
@@ -398,25 +479,29 @@ def redact_text(value: object) -> str:
398
479
  is credential-shaped, alone or in place in the message, and when it holds
399
480
  more than ``_MAX_LITERALS`` such literals. Both checks read the message
400
481
  as given, before anything rewrites it. A message where a credential name
401
- inside a URI assigns a value after the URI's end (`https://h/password =x`)
402
- is withheld too: rewriting the URI to its origin would remove the name
403
- and leave the value. After scrubbing, the text without the placeholders
404
- the scrubbers inserted is checked with the refusal rule, and withheld if
405
- it is still credential-shaped. A placeholder the input itself carries is
406
- shown as `\u2039redacted`.
482
+ inside a URI, as given or percent-decoded, assigns a value after the
483
+ URI's end (`https://h/password :x`) is withheld too: rewriting the URI to
484
+ its origin would remove the name and leave the value. After scrubbing,
485
+ the text without the placeholders the scrubbers inserted is checked with
486
+ the refusal rule, and withheld if it is still credential-shaped. A
487
+ placeholder the input itself carries is shown as `\u2039redacted`.
407
488
  """
408
489
  raw = str(value)
409
- decoded = unquote(raw)
410
- if decoded != raw and is_credential_shaped(decoded):
411
- return WITHHELD
412
- if _repr_reads_as_credential(raw):
413
- return WITHHELD
414
- text = _hide_quoted_userinfo(_INPUT_PLACEHOLDER.sub("\u2039", raw))
415
- if _assignment_crosses_uri(text):
490
+ if "%" in raw:
491
+ decoded = unquote(raw)
492
+ if decoded != raw and is_credential_shaped(decoded):
493
+ return WITHHELD
494
+ if "\\" in raw and _repr_reads_as_credential(raw):
416
495
  return WITHHELD
417
- text = _URI.sub(lambda match: _uri_origin_keeping_prefix(match.group(0)), text)
496
+ text = _INPUT_PLACEHOLDER.sub("\u2039", raw) if "<" in raw else raw
497
+ if "://" in text:
498
+ text = _hide_quoted_userinfo(text)
499
+ if _assignment_crosses_uri(text):
500
+ return WITHHELD
501
+ text = _URI.sub(lambda match: _uri_origin_keeping_prefix(match.group(0)), text)
418
502
  text = redact_credentials(text)
419
- if is_credential_shaped(_INSERTED.sub(" ", text)):
503
+ without_inserted = _INSERTED.sub(" ", text) if "<redacted" in text else text
504
+ if is_credential_shaped(without_inserted):
420
505
  return WITHHELD
421
506
  return text
422
507
 
@@ -7,6 +7,8 @@ from typing import Any
7
7
 
8
8
  import yaml
9
9
 
10
+ from toolgraph.redaction import display_path
11
+
10
12
 
11
13
  class UniqueKeySafeLoader(yaml.SafeLoader):
12
14
  """SafeLoader variant that rejects duplicate mapping keys at any depth."""
@@ -73,7 +75,9 @@ def load_yaml_mapping(path: Path) -> dict[str, Any]:
73
75
  with path.open("rb") as handle:
74
76
  raw = handle.read(MAX_YAML_BYTES + 1)
75
77
  if len(raw) > MAX_YAML_BYTES:
76
- raise ValueError(f"refusing to load {path}: exceeds the {MAX_YAML_BYTES}-byte limit")
78
+ raise ValueError(
79
+ f"refusing to load {display_path(path)}: exceeds the {MAX_YAML_BYTES}-byte limit"
80
+ )
77
81
  try:
78
82
  loader = UniqueKeySafeLoader(raw.decode("utf-8"))
79
83
  try:
@@ -93,5 +97,5 @@ def load_yaml_mapping(path: Path) -> dict[str, Any]:
93
97
  if value is None:
94
98
  return {}
95
99
  if not isinstance(value, dict):
96
- raise ValueError(f"expected a YAML mapping at document root: {path}")
100
+ raise ValueError(f"expected a YAML mapping at document root: {display_path(path)}")
97
101
  return value
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes