petfishframework 0.3.1__tar.gz → 0.3.2__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 (160) hide show
  1. {petfishframework-0.3.1 → petfishframework-0.3.2}/PKG-INFO +3 -3
  2. {petfishframework-0.3.1 → petfishframework-0.3.2}/README.md +2 -2
  3. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/api.md +1 -1
  4. {petfishframework-0.3.1 → petfishframework-0.3.2}/pyproject.toml +1 -1
  5. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/__init__.py +1 -1
  6. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/environment.py +4 -1
  7. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/session.py +16 -0
  8. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/credentials/broker.py +34 -0
  9. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/credentials/token.py +28 -3
  10. petfishframework-0.3.2/src/petfishframework/policies/conditions.py +436 -0
  11. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/policies/engine.py +4 -1
  12. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/policies/rule.py +4 -1
  13. petfishframework-0.3.2/src/petfishframework/policies/validator.py +100 -0
  14. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/base.py +1 -1
  15. petfishframework-0.3.2/tests/test_credential_broker.py +231 -0
  16. petfishframework-0.3.2/tests/test_yaml_policy.py +650 -0
  17. {petfishframework-0.3.1 → petfishframework-0.3.2}/uv.lock +5 -5
  18. petfishframework-0.3.1/src/petfishframework/policies/conditions.py +0 -176
  19. petfishframework-0.3.1/tests/test_credential_broker.py +0 -81
  20. petfishframework-0.3.1/tests/test_yaml_policy.py +0 -325
  21. {petfishframework-0.3.1 → petfishframework-0.3.2}/.env.example +0 -0
  22. {petfishframework-0.3.1 → petfishframework-0.3.2}/.github/workflows/ci.yml +0 -0
  23. {petfishframework-0.3.1 → petfishframework-0.3.2}/.github/workflows/publish.yml +0 -0
  24. {petfishframework-0.3.1 → petfishframework-0.3.2}/.gitignore +0 -0
  25. {petfishframework-0.3.1 → petfishframework-0.3.2}/.sisyphus/plans/v0.1.6-plan.md +0 -0
  26. {petfishframework-0.3.1 → petfishframework-0.3.2}/.sisyphus/plans/v0.2.0-plan.md +0 -0
  27. {petfishframework-0.3.1 → petfishframework-0.3.2}/.sisyphus/plans/v0.3.0-plan.md +0 -0
  28. {petfishframework-0.3.1 → petfishframework-0.3.2}/AGENTS.md +0 -0
  29. {petfishframework-0.3.1 → petfishframework-0.3.2}/AGENTS.md.new +0 -0
  30. {petfishframework-0.3.1 → petfishframework-0.3.2}/CHANGELOG.md +0 -0
  31. {petfishframework-0.3.1 → petfishframework-0.3.2}/CONTRIBUTING.md +0 -0
  32. {petfishframework-0.3.1 → petfishframework-0.3.2}/LICENSE +0 -0
  33. {petfishframework-0.3.1 → petfishframework-0.3.2}/SECURITY.md +0 -0
  34. {petfishframework-0.3.1 → petfishframework-0.3.2}/conftest.py +0 -0
  35. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/architecture.md +0 -0
  36. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/benchmark-results.md +0 -0
  37. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/development-plan.md +0 -0
  38. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/development.md +0 -0
  39. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/enterprise-expense-demo.md +0 -0
  40. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_0_1_4_playground_recommendations.md +0 -0
  41. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_playground_report.md +0 -0
  42. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_production_readiness_roadmap.md +0 -0
  43. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_recommendations.md +0 -0
  44. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_v0_1_6_playground_review.md +0 -0
  45. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_v0_1_7_review.md +0 -0
  46. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_v0_1_8_review.md +0 -0
  47. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_v0_2_1_review.md +0 -0
  48. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/archives/petfishframework_v0_2_2_review.md +0 -0
  49. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/feedbacks/petfishframework_v0_3_0_review.md +0 -0
  50. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/release-checklist.md +0 -0
  51. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/competitor-analysis/README.md +0 -0
  52. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/competitor-analysis/competitor-matrix.md +0 -0
  53. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/competitor-analysis/market-brief.md +0 -0
  54. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/competitor-analysis/positioning-map.md +0 -0
  55. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/competitor-analysis/swot-analysis.md +0 -0
  56. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/literature-review/inclusion-exclusion-criteria.md +0 -0
  57. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/literature-review/literature-matrix.md +0 -0
  58. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/literature-review/literature-review.md +0 -0
  59. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/literature-review/search-strategy.md +0 -0
  60. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/research/reference-repos-absorption.md +0 -0
  61. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/skeleton-completeness-checklist.md +0 -0
  62. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/test-results-report.md +0 -0
  63. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/usage-guide.md +0 -0
  64. {petfishframework-0.3.1 → petfishframework-0.3.2}/docs/validation-roadmap.md +0 -0
  65. {petfishframework-0.3.1 → petfishframework-0.3.2}/initialization-report.md +0 -0
  66. {petfishframework-0.3.1 → petfishframework-0.3.2}/mcp/README.md +0 -0
  67. {petfishframework-0.3.1 → petfishframework-0.3.2}/mcp/connection-checklist.md +0 -0
  68. {petfishframework-0.3.1 → petfishframework-0.3.2}/mcp/mcp-config.example.json +0 -0
  69. {petfishframework-0.3.1 → petfishframework-0.3.2}/opencode.json +0 -0
  70. {petfishframework-0.3.1 → petfishframework-0.3.2}/qa/code-review-checklist.md +0 -0
  71. {petfishframework-0.3.1 → petfishframework-0.3.2}/qa/qa-review.md +0 -0
  72. {petfishframework-0.3.1 → petfishframework-0.3.2}/qa/qc-gate-decision.md +0 -0
  73. {petfishframework-0.3.1 → petfishframework-0.3.2}/qa/test-plan.md +0 -0
  74. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/config.py +0 -0
  75. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/__init__.py +0 -0
  76. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/agent.py +0 -0
  77. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/compiled.py +0 -0
  78. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/contracts.py +0 -0
  79. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/conversation.py +0 -0
  80. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/events.py +0 -0
  81. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/structured.py +0 -0
  82. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/core/types.py +0 -0
  83. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/credentials/__init__.py +0 -0
  84. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/mcp/__init__.py +0 -0
  85. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/mcp/client.py +0 -0
  86. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/mcp/server.py +0 -0
  87. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/mcp/stdio_transport.py +0 -0
  88. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/mcp/wrapper.py +0 -0
  89. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/models/__init__.py +0 -0
  90. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/models/anthropic.py +0 -0
  91. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/models/fake.py +0 -0
  92. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/models/openai.py +0 -0
  93. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/observability/__init__.py +0 -0
  94. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/observability/sinks.py +0 -0
  95. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/permissions/__init__.py +0 -0
  96. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/permissions/model.py +0 -0
  97. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/policies/__init__.py +0 -0
  98. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/policies/test_runner.py +0 -0
  99. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reasoning/__init__.py +0 -0
  100. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reasoning/lats.py +0 -0
  101. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reasoning/llm_plus_p.py +0 -0
  102. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reasoning/react.py +0 -0
  103. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/__init__.py +0 -0
  104. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/audit_report.py +0 -0
  105. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/cost.py +0 -0
  106. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/cost_report.py +0 -0
  107. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/pass_at_k.py +0 -0
  108. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/replay.py +0 -0
  109. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/retry.py +0 -0
  110. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/reliability/timeout.py +0 -0
  111. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/retrieval/__init__.py +0 -0
  112. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/retrieval/adaptive.py +0 -0
  113. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/retrieval/crag.py +0 -0
  114. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/retrieval/memory_store.py +0 -0
  115. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/__init__.py +0 -0
  116. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/agent_tool.py +0 -0
  117. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/calculator.py +0 -0
  118. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/path_planner.py +0 -0
  119. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/registry.py +0 -0
  120. {petfishframework-0.3.1 → petfishframework-0.3.2}/src/petfishframework/tools/word_sorter.py +0 -0
  121. {petfishframework-0.3.1 → petfishframework-0.3.2}/tasks/backlog.md +0 -0
  122. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/integration/__init__.py +0 -0
  123. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/integration/test_anthropic_integration.py +0 -0
  124. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/integration/test_openai_integration.py +0 -0
  125. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/policies/enterprise-expense.tests.yaml +0 -0
  126. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_agent_tool.py +0 -0
  127. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_anthropic_adapter.py +0 -0
  128. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_api_agent_lifecycle.py +0 -0
  129. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_api_edge_cases.py +0 -0
  130. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_api_integration.py +0 -0
  131. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_api_public_surface.py +0 -0
  132. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_api_type_safety.py +0 -0
  133. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_async.py +0 -0
  134. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_audit_report_v2.py +0 -0
  135. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_conversation.py +0 -0
  136. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_credential_integration.py +0 -0
  137. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_degrade.py +0 -0
  138. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_degrade_failclosed.py +0 -0
  139. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_enterprise_demo.py +0 -0
  140. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_m2_m3_m4.py +0 -0
  141. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_mcp.py +0 -0
  142. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_openai_adapter.py +0 -0
  143. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_pass_at_k.py +0 -0
  144. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_permission_semantics.py +0 -0
  145. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_quickstart_smoke.py +0 -0
  146. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_replay.py +0 -0
  147. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_retrieval.py +0 -0
  148. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_retry.py +0 -0
  149. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_skeleton.py +0 -0
  150. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_stdio_transport.py +0 -0
  151. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_streaming.py +0 -0
  152. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_structured.py +0 -0
  153. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_tool_registry.py +0 -0
  154. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_v016_semantics.py +0 -0
  155. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_v018.py +0 -0
  156. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_v019.py +0 -0
  157. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_v2_interface_compatibility.py +0 -0
  158. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_v2_lats.py +0 -0
  159. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_v2_llm_plus_p.py +0 -0
  160. {petfishframework-0.3.1 → petfishframework-0.3.2}/tests/test_yaml_policy_suite.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: petfishframework
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A lightweight runtime framework for reliable, auditable, budget-aware, and permission-aware AI agents.
5
5
  Project-URL: Homepage, https://github.com/kylecui/petfishFramework
6
6
  Project-URL: Documentation, https://github.com/kylecui/petfishFramework/tree/master/docs
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
36
36
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kylecui/petfishFramework/blob/master/LICENSE)
37
- [![Tests: 259](https://img.shields.io/badge/tests-259-brightgreen.svg)](https://github.com/kylecui/petfishFramework/tree/master/tests/)
37
+ [![Tests: 284](https://img.shields.io/badge/tests-259-brightgreen.svg)](https://github.com/kylecui/petfishFramework/tree/master/tests/)
38
38
 
39
39
  **Status: Alpha** — API may change. Core runtime works; see [Roadmap](#roadmap).
40
40
 
@@ -279,7 +279,7 @@ petfishFramework is **Alpha**. API may change before v1.0.
279
279
  git clone https://github.com/kylecui/petfishFramework.git
280
280
  cd petfishFramework
281
281
  uv sync --all-extras
282
- uv run pytest # 259 tests
282
+ uv run pytest # 284 tests
283
283
  uv run ruff check src/ tests/
284
284
  ```
285
285
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kylecui/petfishFramework/blob/master/LICENSE)
7
- [![Tests: 259](https://img.shields.io/badge/tests-259-brightgreen.svg)](https://github.com/kylecui/petfishFramework/tree/master/tests/)
7
+ [![Tests: 284](https://img.shields.io/badge/tests-259-brightgreen.svg)](https://github.com/kylecui/petfishFramework/tree/master/tests/)
8
8
 
9
9
  **Status: Alpha** — API may change. Core runtime works; see [Roadmap](#roadmap).
10
10
 
@@ -249,7 +249,7 @@ petfishFramework is **Alpha**. API may change before v1.0.
249
249
  git clone https://github.com/kylecui/petfishFramework.git
250
250
  cd petfishFramework
251
251
  uv sync --all-extras
252
- uv run pytest # 259 tests
252
+ uv run pytest # 284 tests
253
253
  uv run ruff check src/ tests/
254
254
  ```
255
255
 
@@ -1,6 +1,6 @@
1
1
  # petfishFramework API Reference
2
2
 
3
- This document is the authoritative reference for the public API of `petfishFramework` v0.3.1. Every signature, field, and example below is derived from the source code and from the tests that exercise it.
3
+ This document is the authoritative reference for the public API of `petfishFramework` v0.3.2. Every signature, field, and example below is derived from the source code and from the tests that exercise it.
4
4
 
5
5
  ## 1. Overview
6
6
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "petfishframework"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "A lightweight runtime framework for reliable, auditable, budget-aware, and permission-aware AI agents."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -15,7 +15,7 @@ from .reasoning import LATS, LLMPlusP, ReAct
15
15
  from .reliability.replay import ReplayMode
16
16
  from .tools.base import BaseTool
17
17
 
18
- __version__ = "0.3.1"
18
+ __version__ = "0.3.2"
19
19
 
20
20
  __all__ = [
21
21
  "Agent",
@@ -334,7 +334,10 @@ class RuntimeEnvironment(Environment):
334
334
  return
335
335
  if self.credential_broker is None:
336
336
  return
337
- token = self.credential_broker.issue_token(tool.name, tool_name=tool.name)
337
+ credential_name = getattr(tool, "credential_name", None) or tool.name
338
+ token = self.credential_broker.issue_token(
339
+ credential_name, tool_name=tool.name, max_uses=1
340
+ )
338
341
  args["_credential_token"] = token
339
342
 
340
343
  def _prepare_tool_call(self, ref: ToolRef, args: dict) -> tuple[Tool | None, Any]:
@@ -133,6 +133,7 @@ class Session:
133
133
  self._result = result
134
134
 
135
135
  self._save_conversation_history(result)
136
+ self._cleanup_credentials()
136
137
 
137
138
  self.events.emit(
138
139
  "session.end",
@@ -150,6 +151,21 @@ class Session:
150
151
  )
151
152
  return result
152
153
 
154
+ def _cleanup_credentials(self) -> None:
155
+ """Clean up scoped credentials at session end."""
156
+ broker = self.credential_broker
157
+ if broker is None:
158
+ return
159
+
160
+ cleanup_expired = getattr(broker, "cleanup_expired", None)
161
+ if callable(cleanup_expired):
162
+ cleanup_expired()
163
+
164
+ revoke_all_for_tool = getattr(broker, "revoke_all_for_tool", None)
165
+ if callable(revoke_all_for_tool):
166
+ for tool in self.tools:
167
+ revoke_all_for_tool(tool.name)
168
+
153
169
  def _save_conversation_history(self, result: Result) -> None:
154
170
  """Persist previous history plus the current user/assistant turn."""
155
171
  if self.conversation_store is None or self.conversation_id is None:
@@ -28,6 +28,7 @@ class CredentialBroker:
28
28
  name: str,
29
29
  tool_name: str,
30
30
  ttl_s: float | None = None,
31
+ max_uses: int = 0,
31
32
  ) -> ScopedToken:
32
33
  """Issue a scoped, time-limited token for a specific tool."""
33
34
  if name not in self._credentials:
@@ -42,6 +43,7 @@ class CredentialBroker:
42
43
  token_id=token_id,
43
44
  tool_name=tool_name,
44
45
  expires_at=expires_at,
46
+ max_uses=max_uses,
45
47
  _secret=secret,
46
48
  )
47
49
  self._active_tokens[token_id] = token
@@ -58,6 +60,38 @@ class CredentialBroker:
58
60
  """Revoke a token immediately."""
59
61
  self._active_tokens.pop(token_id, None)
60
62
 
63
+ def revoke_all_for_tool(self, tool_name: str) -> int:
64
+ """Revoke all active tokens issued for a specific tool.
65
+
66
+ Returns the number of tokens revoked.
67
+ """
68
+ token_ids = [
69
+ token_id
70
+ for token_id, token in self._active_tokens.items()
71
+ if token.tool_name == tool_name
72
+ ]
73
+ for token_id in token_ids:
74
+ del self._active_tokens[token_id]
75
+ return len(token_ids)
76
+
77
+ def revoke_all(self) -> int:
78
+ """Revoke all active tokens.
79
+
80
+ Returns the number of tokens revoked.
81
+ """
82
+ count = len(self._active_tokens)
83
+ self._active_tokens.clear()
84
+ return count
85
+
86
+ @property
87
+ def active_token_count(self) -> int:
88
+ """Return the number of currently active tokens."""
89
+ return len(self._active_tokens)
90
+
91
+ def list_active_tokens(self) -> list[str]:
92
+ """Return token IDs for all active tokens (secrets are not exposed)."""
93
+ return list(self._active_tokens.keys())
94
+
61
95
  def cleanup_expired(self) -> int:
62
96
  """Remove all expired tokens. Returns count removed."""
63
97
  expired = [
@@ -5,7 +5,7 @@ import time
5
5
  from dataclasses import dataclass, field
6
6
 
7
7
 
8
- @dataclass(frozen=True)
8
+ @dataclass
9
9
  class ScopedToken:
10
10
  """A time-limited, tool-scoped credential token.
11
11
 
@@ -15,23 +15,48 @@ class ScopedToken:
15
15
  token_id: str # unique ID
16
16
  tool_name: str # which tool this token is for
17
17
  expires_at: float # unix timestamp
18
+ max_uses: int = 0 # 0 = unlimited uses
18
19
  _secret: str = field(default="", repr=False, compare=False) # hidden from repr
20
+ _uses: int = field(default=0, repr=False)
19
21
 
20
22
  def is_valid(self) -> bool:
21
23
  """Check if token is still valid (not expired)."""
22
24
  return time.time() < self.expires_at
23
25
 
26
+ def use(self) -> bool:
27
+ """Consume one use of the token.
28
+
29
+ Returns True if the use is allowed, False if max_uses exceeded.
30
+ """
31
+ self._uses += 1
32
+ if self.max_uses > 0 and self._uses > self.max_uses:
33
+ return False
34
+ return True
35
+
36
+ @property
37
+ def uses_remaining(self) -> int | None:
38
+ """Remaining allowed uses, or None when unlimited."""
39
+ if self.max_uses <= 0:
40
+ return None
41
+ return max(0, self.max_uses - self._uses)
42
+
24
43
  def get_secret(self) -> str:
25
44
  """Get the actual secret. Only callable by trusted code."""
26
45
  if not self.is_valid():
27
46
  raise ValueError("Token expired")
47
+ if not self.use():
48
+ raise ValueError("Token exceeded max uses")
28
49
  return self._secret
29
50
 
30
51
  def __repr__(self) -> str:
31
52
  return (
32
53
  f"ScopedToken(token_id={self.token_id!r}, tool_name={self.tool_name!r}, "
33
- f"expires_at={self.expires_at}, _secret='[REDACTED]')"
54
+ f"expires_at={self.expires_at}, max_uses={self.max_uses}, "
55
+ f"_secret='[REDACTED]')"
34
56
  )
35
57
 
36
58
  def __str__(self) -> str:
37
- return f"ScopedToken({self.token_id}, tool={self.tool_name}, valid={self.is_valid()})"
59
+ return (
60
+ f"ScopedToken({self.token_id}, tool={self.tool_name}, "
61
+ f"valid={self.is_valid()}, uses_remaining={self.uses_remaining})"
62
+ )
@@ -0,0 +1,436 @@
1
+ """Condition matchers for YAML policy rules (v0.3.2).
2
+
3
+ Each matcher receives the value declared in the YAML ``when:`` block plus the
4
+ SARC evaluation context (Subject, Action, Resource, AccessContext) and the
5
+ metadata of the tool being invoked. Matchers return ``True`` when the condition
6
+ is satisfied.
7
+
8
+ Unknown condition keys are fail-closed (return ``False``).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable
13
+
14
+ from petfishframework.core.contracts import RiskLevel
15
+ from petfishframework.permissions.model import AccessContext, Action, Resource, Subject
16
+
17
+ ConditionMatcher = Callable[
18
+ [Any, Subject, Action, Resource, AccessContext, dict[str, Any]],
19
+ bool,
20
+ ]
21
+
22
+
23
+ def _match_action_tool_name(
24
+ value: Any,
25
+ subject: Subject, # noqa: ARG001
26
+ action: Action,
27
+ resource: Resource, # noqa: ARG001
28
+ context: AccessContext, # noqa: ARG001
29
+ tool_metadata: dict[str, Any], # noqa: ARG001
30
+ ) -> bool:
31
+ """Exact string match against ``action.tool_name``."""
32
+ return action.tool_name == value
33
+
34
+
35
+ def _match_subject_role_in(
36
+ value: Any,
37
+ subject: Subject,
38
+ action: Action, # noqa: ARG001
39
+ resource: Resource, # noqa: ARG001
40
+ context: AccessContext, # noqa: ARG001
41
+ tool_metadata: dict[str, Any], # noqa: ARG001
42
+ ) -> bool:
43
+ """True if any role in ``value`` is present in ``subject.roles``."""
44
+ return any(role in subject.roles for role in value)
45
+
46
+
47
+ def _match_subject_role_not_in(
48
+ value: Any,
49
+ subject: Subject,
50
+ action: Action, # noqa: ARG001
51
+ resource: Resource, # noqa: ARG001
52
+ context: AccessContext, # noqa: ARG001
53
+ tool_metadata: dict[str, Any], # noqa: ARG001
54
+ ) -> bool:
55
+ """True if NO role in ``value`` is present in ``subject.roles``."""
56
+ return not any(role in subject.roles for role in value)
57
+
58
+
59
+ def _match_action_args_amount_gt(
60
+ value: Any,
61
+ subject: Subject, # noqa: ARG001
62
+ action: Action,
63
+ resource: Resource, # noqa: ARG001
64
+ context: AccessContext, # noqa: ARG001
65
+ tool_metadata: dict[str, Any], # noqa: ARG001
66
+ ) -> bool:
67
+ """Numeric greater-than comparison against ``action.args["amount"]``."""
68
+ amount = action.args.get("amount", 0)
69
+ try:
70
+ return float(amount) > float(value)
71
+ except (TypeError, ValueError):
72
+ return False
73
+
74
+
75
+ def _match_action_args_amount_lt(
76
+ value: Any,
77
+ subject: Subject, # noqa: ARG001
78
+ action: Action,
79
+ resource: Resource, # noqa: ARG001
80
+ context: AccessContext, # noqa: ARG001
81
+ tool_metadata: dict[str, Any], # noqa: ARG001
82
+ ) -> bool:
83
+ """Numeric less-than comparison against ``action.args["amount"]``."""
84
+ amount = action.args.get("amount", 0)
85
+ try:
86
+ return float(amount) < float(value)
87
+ except (TypeError, ValueError):
88
+ return False
89
+
90
+
91
+ def _match_tool_side_effect(
92
+ value: Any,
93
+ subject: Subject, # noqa: ARG001
94
+ action: Action, # noqa: ARG001
95
+ resource: Resource, # noqa: ARG001
96
+ context: AccessContext, # noqa: ARG001
97
+ tool_metadata: dict[str, Any],
98
+ ) -> bool:
99
+ """Boolean match against ``tool_metadata["side_effect"]``."""
100
+ if not tool_metadata:
101
+ return False
102
+ return tool_metadata.get("side_effect") == value
103
+
104
+
105
+ def _match_tool_external_egress(
106
+ value: Any,
107
+ subject: Subject, # noqa: ARG001
108
+ action: Action, # noqa: ARG001
109
+ resource: Resource, # noqa: ARG001
110
+ context: AccessContext, # noqa: ARG001
111
+ tool_metadata: dict[str, Any],
112
+ ) -> bool:
113
+ """Boolean match against ``tool_metadata["external_egress"]``."""
114
+ if not tool_metadata:
115
+ return False
116
+ return tool_metadata.get("external_egress") == value
117
+
118
+
119
+ def _match_action_args_amount_eq(
120
+ value: Any,
121
+ subject: Subject, # noqa: ARG001
122
+ action: Action,
123
+ resource: Resource, # noqa: ARG001
124
+ context: AccessContext, # noqa: ARG001
125
+ tool_metadata: dict[str, Any], # noqa: ARG001
126
+ ) -> bool:
127
+ """Numeric equality comparison against ``action.args["amount"]``."""
128
+ amount = action.args.get("amount", 0)
129
+ try:
130
+ return float(amount) == float(value)
131
+ except (TypeError, ValueError):
132
+ return False
133
+
134
+
135
+ def _match_action_args_amount_gte(
136
+ value: Any,
137
+ subject: Subject, # noqa: ARG001
138
+ action: Action,
139
+ resource: Resource, # noqa: ARG001
140
+ context: AccessContext, # noqa: ARG001
141
+ tool_metadata: dict[str, Any], # noqa: ARG001
142
+ ) -> bool:
143
+ """Numeric greater-than-or-equal comparison against ``action.args["amount"]``."""
144
+ amount = action.args.get("amount", 0)
145
+ try:
146
+ return float(amount) >= float(value)
147
+ except (TypeError, ValueError):
148
+ return False
149
+
150
+
151
+ def _match_action_args_amount_lte(
152
+ value: Any,
153
+ subject: Subject, # noqa: ARG001
154
+ action: Action,
155
+ resource: Resource, # noqa: ARG001
156
+ context: AccessContext, # noqa: ARG001
157
+ tool_metadata: dict[str, Any], # noqa: ARG001
158
+ ) -> bool:
159
+ """Numeric less-than-or-equal comparison against ``action.args["amount"]``."""
160
+ amount = action.args.get("amount", 0)
161
+ try:
162
+ return float(amount) <= float(value)
163
+ except (TypeError, ValueError):
164
+ return False
165
+
166
+
167
+ def _match_subject_role_count_gte(
168
+ value: Any,
169
+ subject: Subject,
170
+ action: Action, # noqa: ARG001
171
+ resource: Resource, # noqa: ARG001
172
+ context: AccessContext, # noqa: ARG001
173
+ tool_metadata: dict[str, Any], # noqa: ARG001
174
+ ) -> bool:
175
+ """True if ``subject.roles`` has at least ``value`` entries."""
176
+ try:
177
+ return len(subject.roles) >= int(value)
178
+ except (TypeError, ValueError):
179
+ return False
180
+
181
+
182
+ def _match_subject_clearance_eq(
183
+ value: Any,
184
+ subject: Subject,
185
+ action: Action, # noqa: ARG001
186
+ resource: Resource, # noqa: ARG001
187
+ context: AccessContext, # noqa: ARG001
188
+ tool_metadata: dict[str, Any], # noqa: ARG001
189
+ ) -> bool:
190
+ """Exact match against ``subject.clearance``."""
191
+ return subject.clearance == value
192
+
193
+
194
+ def _match_subject_tenant_id_eq(
195
+ value: Any,
196
+ subject: Subject,
197
+ action: Action, # noqa: ARG001
198
+ resource: Resource, # noqa: ARG001
199
+ context: AccessContext, # noqa: ARG001
200
+ tool_metadata: dict[str, Any], # noqa: ARG001
201
+ ) -> bool:
202
+ """Exact match against ``subject.tenant_id``."""
203
+ return subject.tenant_id == value
204
+
205
+
206
+ def _match_resource_classification_eq(
207
+ value: Any,
208
+ subject: Subject, # noqa: ARG001
209
+ action: Action, # noqa: ARG001
210
+ resource: Resource,
211
+ context: AccessContext, # noqa: ARG001
212
+ tool_metadata: dict[str, Any], # noqa: ARG001
213
+ ) -> bool:
214
+ """Exact match against ``resource.classification``."""
215
+ return resource.classification == value
216
+
217
+
218
+ def _match_resource_tags_contains(
219
+ value: Any,
220
+ subject: Subject, # noqa: ARG001
221
+ action: Action, # noqa: ARG001
222
+ resource: Resource,
223
+ context: AccessContext, # noqa: ARG001
224
+ tool_metadata: dict[str, Any], # noqa: ARG001
225
+ ) -> bool:
226
+ """True if any tag in ``value`` is present in ``resource.tags``."""
227
+ return any(tag in resource.tags for tag in value)
228
+
229
+
230
+ def _match_tool_risk_level_eq(
231
+ value: Any,
232
+ subject: Subject, # noqa: ARG001
233
+ action: Action, # noqa: ARG001
234
+ resource: Resource, # noqa: ARG001
235
+ context: AccessContext, # noqa: ARG001
236
+ tool_metadata: dict[str, Any],
237
+ ) -> bool:
238
+ """Match against ``tool_metadata["risk_level"]`` (string or ``RiskLevel`` enum)."""
239
+ if not tool_metadata:
240
+ return False
241
+ risk_level = tool_metadata.get("risk_level")
242
+ if risk_level is None:
243
+ return False
244
+ if isinstance(value, RiskLevel):
245
+ return risk_level == value
246
+ if isinstance(risk_level, RiskLevel):
247
+ return risk_level.value == str(value).lower()
248
+ return str(risk_level).lower() == str(value).lower()
249
+
250
+
251
+ def _match_tool_capabilities_contains(
252
+ value: Any,
253
+ subject: Subject, # noqa: ARG001
254
+ action: Action, # noqa: ARG001
255
+ resource: Resource, # noqa: ARG001
256
+ context: AccessContext, # noqa: ARG001
257
+ tool_metadata: dict[str, Any],
258
+ ) -> bool:
259
+ """True if any capability in ``value`` is present in tool capabilities."""
260
+ if not tool_metadata:
261
+ return False
262
+ capabilities = tool_metadata.get("capabilities", ())
263
+ return any(capability in capabilities for capability in value)
264
+
265
+
266
+ def _match_tool_requires_credentials(
267
+ value: Any,
268
+ subject: Subject, # noqa: ARG001
269
+ action: Action, # noqa: ARG001
270
+ resource: Resource, # noqa: ARG001
271
+ context: AccessContext, # noqa: ARG001
272
+ tool_metadata: dict[str, Any],
273
+ ) -> bool:
274
+ """Boolean match against ``tool_metadata["requires_credentials"]``."""
275
+ if not tool_metadata:
276
+ return False
277
+ return tool_metadata.get("requires_credentials") == value
278
+
279
+
280
+ def _match_context_session_risk_gt(
281
+ value: Any,
282
+ subject: Subject, # noqa: ARG001
283
+ action: Action, # noqa: ARG001
284
+ resource: Resource, # noqa: ARG001
285
+ context: AccessContext,
286
+ tool_metadata: dict[str, Any], # noqa: ARG001
287
+ ) -> bool:
288
+ """Numeric greater-than comparison against ``context.session_risk``."""
289
+ try:
290
+ return float(context.session_risk) > float(value)
291
+ except (TypeError, ValueError):
292
+ return False
293
+
294
+
295
+ def _match_context_prompt_risk_gt(
296
+ value: Any,
297
+ subject: Subject, # noqa: ARG001
298
+ action: Action, # noqa: ARG001
299
+ resource: Resource, # noqa: ARG001
300
+ context: AccessContext,
301
+ tool_metadata: dict[str, Any], # noqa: ARG001
302
+ ) -> bool:
303
+ """Numeric greater-than comparison against ``context.prompt_risk``."""
304
+ try:
305
+ return float(context.prompt_risk) > float(value)
306
+ except (TypeError, ValueError):
307
+ return False
308
+
309
+
310
+ def _match_unknown(
311
+ value: Any, # noqa: ARG001
312
+ subject: Subject, # noqa: ARG001
313
+ action: Action, # noqa: ARG001
314
+ resource: Resource, # noqa: ARG001
315
+ context: AccessContext, # noqa: ARG001
316
+ tool_metadata: dict[str, Any], # noqa: ARG001
317
+ ) -> bool:
318
+ """Fail-closed default for unrecognized condition keys."""
319
+ return False
320
+
321
+
322
+ _MATCHERS: dict[str, ConditionMatcher] = {
323
+ "action.tool_name": _match_action_tool_name,
324
+ "subject.role_in": _match_subject_role_in,
325
+ "subject.role_not_in": _match_subject_role_not_in,
326
+ "action.args.amount_gt": _match_action_args_amount_gt,
327
+ "action.args.amount_lt": _match_action_args_amount_lt,
328
+ "action.args.amount_eq": _match_action_args_amount_eq,
329
+ "action.args.amount_gte": _match_action_args_amount_gte,
330
+ "action.args.amount_lte": _match_action_args_amount_lte,
331
+ "tool.side_effect": _match_tool_side_effect,
332
+ "tool.external_egress": _match_tool_external_egress,
333
+ "subject.role_count_gte": _match_subject_role_count_gte,
334
+ "subject.clearance_eq": _match_subject_clearance_eq,
335
+ "subject.tenant_id_eq": _match_subject_tenant_id_eq,
336
+ "resource.classification_eq": _match_resource_classification_eq,
337
+ "resource.tags_contains": _match_resource_tags_contains,
338
+ "tool.risk_level_eq": _match_tool_risk_level_eq,
339
+ "tool.capabilities_contains": _match_tool_capabilities_contains,
340
+ "tool.requires_credentials": _match_tool_requires_credentials,
341
+ "context.session_risk_gt": _match_context_session_risk_gt,
342
+ "context.prompt_risk_gt": _match_context_prompt_risk_gt,
343
+ }
344
+
345
+
346
+ def match_condition(
347
+ key: str,
348
+ value: Any,
349
+ subject: Subject,
350
+ action: Action,
351
+ resource: Resource,
352
+ context: AccessContext,
353
+ tool_metadata: dict[str, Any],
354
+ ) -> bool:
355
+ """Evaluate a single condition key/value pair.
356
+
357
+ Unknown keys fail closed (return ``False``). Empty condition sets are
358
+ handled by the caller and match everything.
359
+
360
+ Supports the generic prefix matcher ``action.args.<field>_eq`` for equality
361
+ checks against any key in ``action.args``.
362
+ """
363
+ matcher = _MATCHERS.get(key)
364
+ if matcher is not None:
365
+ return matcher(value, subject, action, resource, context, tool_metadata)
366
+
367
+ if key.startswith("action.args.") and key.endswith("_eq"):
368
+ field = key[len("action.args.") : -len("_eq")]
369
+ return action.args.get(field) == value
370
+
371
+ return _match_unknown(value, subject, action, resource, context, tool_metadata)
372
+
373
+
374
+ def match_conditions_with_combinators(
375
+ conditions: dict[str, Any],
376
+ subject: Subject,
377
+ action: Action,
378
+ resource: Resource,
379
+ context: AccessContext,
380
+ tool_metadata: dict[str, Any],
381
+ ) -> bool:
382
+ """Evaluate conditions supporting ``any``/``all``/``not`` combinators.
383
+
384
+ Flat dictionaries continue to act as implicit AND (backward compatible).
385
+ An empty condition dict matches everything (default-allow semantics).
386
+ """
387
+ if not conditions:
388
+ return True
389
+
390
+ if "any" in conditions:
391
+ sub_conditions = conditions["any"]
392
+ if not isinstance(sub_conditions, list):
393
+ return False
394
+ return any(
395
+ match_conditions_with_combinators(sub, subject, action, resource, context, tool_metadata)
396
+ for sub in sub_conditions
397
+ )
398
+
399
+ if "all" in conditions:
400
+ sub_conditions = conditions["all"]
401
+ if not isinstance(sub_conditions, list):
402
+ return False
403
+ return all(
404
+ match_conditions_with_combinators(sub, subject, action, resource, context, tool_metadata)
405
+ for sub in sub_conditions
406
+ )
407
+
408
+ if "not" in conditions:
409
+ inner = conditions["not"]
410
+ if not isinstance(inner, dict):
411
+ return False
412
+ return not match_conditions_with_combinators(inner, subject, action, resource, context, tool_metadata)
413
+
414
+ return all(
415
+ match_condition(key, value, subject, action, resource, context, tool_metadata)
416
+ for key, value in conditions.items()
417
+ )
418
+
419
+
420
+ def match_all_conditions(
421
+ conditions: dict[str, Any],
422
+ subject: Subject,
423
+ action: Action,
424
+ resource: Resource,
425
+ context: AccessContext,
426
+ tool_metadata: dict[str, Any],
427
+ ) -> bool:
428
+ """Return True if every condition in the rule matches.
429
+
430
+ An empty condition dict matches everything (default-allow semantics).
431
+ Delegates to ``match_conditions_with_combinators`` for ``any``/``all``/``not``
432
+ support while preserving the legacy flat-dictionary AND behavior.
433
+ """
434
+ return match_conditions_with_combinators(
435
+ conditions, subject, action, resource, context, tool_metadata
436
+ )
@@ -1,4 +1,4 @@
1
- """YAML Policy Engine (v0.3.0 Phase A1).
1
+ """YAML Policy Engine (v0.3.2).
2
2
 
3
3
  Loads authorization rules from YAML and evaluates them in priority order.
4
4
  Implements the PermissionPolicy protocol so it can be plugged directly into
@@ -58,6 +58,9 @@ class YamlPolicy:
58
58
  self._tool_metadata[name] = {
59
59
  "side_effect": getattr(tool, "side_effect", False),
60
60
  "external_egress": getattr(tool, "external_egress", False),
61
+ "risk_level": getattr(tool, "risk_level", None),
62
+ "capabilities": getattr(tool, "capabilities", ()),
63
+ "requires_credentials": getattr(tool, "requires_credentials", False),
61
64
  }
62
65
 
63
66
  def evaluate(self, subject: Subject, action: Action, resource: Resource, context: AccessContext) -> Decision: