gargoyle-receipts 0.5.0.dev0__tar.gz → 0.5.2.dev0__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 (65) hide show
  1. {gargoyle_receipts-0.5.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.2.dev0}/PKG-INFO +2 -2
  2. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/README.md +1 -1
  3. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/entra_login.py +45 -4
  4. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0/gargoyle_receipts.egg-info}/PKG-INFO +2 -2
  5. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/pyproject.toml +1 -1
  6. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_entra_login.py +43 -0
  7. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/LICENSE +0 -0
  8. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/NOTICE +0 -0
  9. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/__init__.py +0 -0
  10. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/approval.py +0 -0
  11. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/approver_cli.py +0 -0
  12. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/approver_trust.py +0 -0
  13. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/cli.py +0 -0
  14. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/consumer.py +0 -0
  15. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/envelope.py +0 -0
  16. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/evidence.py +0 -0
  17. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/execution.py +0 -0
  18. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/fs_server.py +0 -0
  19. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/identity.py +0 -0
  20. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/issuer.py +0 -0
  21. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/issuer_service.py +0 -0
  22. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/jws.py +0 -0
  23. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/key_record_source.py +0 -0
  24. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/mcp_server.py +0 -0
  25. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/notify.py +0 -0
  26. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/opa.py +0 -0
  27. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/pending_store.py +0 -0
  28. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/policy.py +0 -0
  29. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/published_record_source.py +0 -0
  30. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/replay_cache.py +0 -0
  31. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/rules.py +0 -0
  32. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/static/approve.html +0 -0
  33. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/static/approve.js +0 -0
  34. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle/verifier.py +0 -0
  35. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  36. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  37. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  38. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  39. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  40. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/setup.cfg +0 -0
  41. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_annotations.py +0 -0
  42. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_approver_cli.py +0 -0
  43. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_canonical.py +0 -0
  44. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_cli.py +0 -0
  45. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_console_js.py +0 -0
  46. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_consumer.py +0 -0
  47. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_entra_identity.py +0 -0
  48. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_evidence.py +0 -0
  49. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_execution.py +0 -0
  50. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_fs_server.py +0 -0
  51. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_identity.py +0 -0
  52. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_issuer_service.py +0 -0
  53. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_jws.py +0 -0
  54. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_mcp_proxy.py +0 -0
  55. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_mcp_self_issue.py +0 -0
  56. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_mcp_server.py +0 -0
  57. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_notify.py +0 -0
  58. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_opa.py +0 -0
  59. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_policy.py +0 -0
  60. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_published_record.py +0 -0
  61. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_rules.py +0 -0
  62. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_stdio_encoding.py +0 -0
  63. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_threshold.py +0 -0
  64. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_typescript_conformance.py +0 -0
  65. {gargoyle_receipts-0.5.0.dev0 → gargoyle_receipts-0.5.2.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.0.dev0
3
+ Version: 0.5.2.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.5.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -69,10 +69,30 @@ class TokenCache:
69
69
  pass
70
70
 
71
71
 
72
+ def public_roots_http():
73
+ """A requests session that trusts the public CA roots (certifi) whatever
74
+ REQUESTS_CA_BUNDLE says. A deployment pins the issuer's private CA with
75
+ that variable; Entra's endpoints are not signed by it, so the two trust
76
+ domains must not share one setting. requests applies the environment
77
+ bundle per request and lets it beat Session.verify, so the override is
78
+ made per request too. Proxies from the environment still apply."""
79
+ import certifi
80
+ import requests
81
+
82
+ class PublicRootsSession(requests.Session):
83
+ def request(self, method, url, **kwargs):
84
+ if kwargs.get("verify") in (None, True):
85
+ kwargs["verify"] = certifi.where()
86
+ return super().request(method, url, **kwargs)
87
+
88
+ return PublicRootsSession()
89
+
90
+
72
91
  def _app(tenant: str, client_id: str, cache: TokenCache, app_factory: Optional[Callable] = None) -> Any:
73
92
  if app_factory is not None:
74
93
  return app_factory(client_id=client_id, authority=AUTHORITY.format(tenant=tenant), token_cache=cache.cache)
75
- return _msal().PublicClientApplication(client_id, authority=AUTHORITY.format(tenant=tenant), token_cache=cache.cache)
94
+ return _msal().PublicClientApplication(client_id, authority=AUTHORITY.format(tenant=tenant), token_cache=cache.cache,
95
+ http_client=public_roots_http())
76
96
 
77
97
 
78
98
  def device_code_login(tenant: str, client_id: str, scope: str, cache_path: str, *,
@@ -105,8 +125,26 @@ class EntraTokenSource:
105
125
  def __init__(self, tenant: str, client_id: str, scope: str, cache_path: str, *,
106
126
  app_factory: Optional[Callable] = None, msal_module: Any = None) -> None:
107
127
  self.scope = scope
108
- self._cache = TokenCache(cache_path, msal_module)
109
- self._app = _app(tenant, client_id, self._cache, app_factory)
128
+ self._args = (tenant, client_id, cache_path, app_factory, msal_module)
129
+ self._cache: Optional[TokenCache] = None
130
+ self._app_obj: Any = None
131
+
132
+ @property
133
+ def _app(self) -> Any:
134
+ # Built on first use: MSAL fetches the tenant's OpenID configuration
135
+ # when the application object is created, and that must not happen
136
+ # while the MCP client is starting the middleware, nor kill it if
137
+ # the network is down. A failure here surfaces as LoginRequired on
138
+ # the call, which the middleware turns into a refusal.
139
+ if self._app_obj is None:
140
+ tenant, client_id, cache_path, app_factory, msal_module = self._args
141
+ self._cache = TokenCache(cache_path, msal_module)
142
+ try:
143
+ self._app_obj = _app(tenant, client_id, self._cache, app_factory)
144
+ except Exception as exc:
145
+ raise LoginRequired("Entra could not be reached to validate the sign-in: %s: %s"
146
+ % (type(exc).__name__, str(exc)[:200])) from exc
147
+ return self._app_obj
110
148
 
111
149
  def token(self) -> str:
112
150
  accounts: List[Dict[str, Any]] = self._app.get_accounts()
@@ -121,7 +159,10 @@ class EntraTokenSource:
121
159
 
122
160
  @property
123
161
  def principal(self) -> Optional[str]:
124
- accounts = self._app.get_accounts()
162
+ try:
163
+ accounts = self._app.get_accounts()
164
+ except LoginRequired:
165
+ return None
125
166
  return accounts[0].get("username") if accounts else None
126
167
 
127
168
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.0.dev0
3
+ Version: 0.5.2.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.5.0.dev0"
7
+ version = "0.5.2.dev0"
8
8
  description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -124,3 +124,46 @@ def test_login_command_rewrites_the_client_config(tmp_path, monkeypatch, capsys)
124
124
  # Everything the middleware needs to pick the Entra source is there.
125
125
  for k in ("GARGOYLE_MCP_ENTRA_TENANT", "GARGOYLE_MCP_ENTRA_CLIENT_ID", "GARGOYLE_MCP_ENTRA_SCOPE", "GARGOYLE_MCP_ENTRA_TOKEN_CACHE"):
126
126
  assert env[k] and mcp[k] == env[k]
127
+
128
+
129
+ def test_msal_gets_a_public_roots_http_client_and_is_built_lazily(tmp_path, monkeypatch):
130
+ """REQUESTS_CA_BUNDLE pins the issuer's private CA for the middleware; MSAL
131
+ must still trust the public roots. And nothing touches the network until
132
+ the first token is asked for."""
133
+ from gargoyle import entra_login
134
+ import certifi
135
+ built = []
136
+
137
+ class CapturingMsal(FakeMsal):
138
+ class PublicClientApplication:
139
+ def __init__(self, client_id, authority, token_cache, http_client=None):
140
+ built.append(http_client)
141
+ self.inner = FakeApp(client_id, authority, token_cache)
142
+ def get_accounts(self): return self.inner.get_accounts()
143
+ def acquire_token_silent(self, scopes, account): return self.inner.acquire_token_silent(scopes, account)
144
+ monkeypatch.setattr(entra_login, "_msal", lambda: CapturingMsal)
145
+ monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(tmp_path / "private-ca.crt"))
146
+ cache = tmp_path / "cache.json"
147
+ device_code_login("t", "c", "s", str(cache), app_factory=FakeApp, msal_module=FakeMsal, prompt=lambda m: None)
148
+ src = EntraTokenSource("t", "c", "s", str(cache), msal_module=CapturingMsal)
149
+ assert built == [] # constructed nothing yet
150
+ assert src.token() == "at-refreshed"
151
+ assert len(built) == 1 # one MSAL app, with our session
152
+ # Through requests' own environment merge: the private CA in REQUESTS_CA_BUNDLE loses.
153
+ import requests
154
+ sent = {}
155
+ def fake_send(self, prep, **kw):
156
+ sent.update(kw); r = requests.Response(); r.status_code = 200; r._content = b"{}"; return r
157
+ monkeypatch.setattr(requests.Session, "send", fake_send)
158
+ built[0].get("https://login.microsoftonline.com/x/v2.0/.well-known/openid-configuration")
159
+ assert sent["verify"] == certifi.where()
160
+
161
+ # A tenant that cannot be reached is a refusal at call time, not a crash at startup.
162
+ class Unreachable(FakeMsal):
163
+ class PublicClientApplication:
164
+ def __init__(self, *a, **k): raise OSError("name resolution failed")
165
+ monkeypatch.setattr(entra_login, "_msal", lambda: Unreachable)
166
+ off = EntraTokenSource("t", "c", "s", str(cache), msal_module=Unreachable)
167
+ with pytest.raises(LoginRequired, match="could not be reached"):
168
+ off.token()
169
+ assert off.principal is None