controlzero 1.13.11__tar.gz → 1.13.13__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 (145) hide show
  1. {controlzero-1.13.11 → controlzero-1.13.13}/.gitignore +14 -1
  2. {controlzero-1.13.11 → controlzero-1.13.13}/CHANGELOG.md +383 -1
  3. {controlzero-1.13.11 → controlzero-1.13.13}/PKG-INFO +1 -1
  4. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/__init__.py +3 -2
  5. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/action_aliases.py +68 -1
  6. controlzero-1.13.13/controlzero/_internal/action_subject.py +474 -0
  7. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/dlp_output_observer.py +7 -1
  8. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/dlp_scanner.py +148 -2
  9. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/enforcement_outcome.py +115 -12
  10. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/enforcer.py +586 -32
  11. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/hook_extractors.py +29 -0
  12. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/audit_remote.py +26 -0
  13. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/debug_bundle.py +21 -1
  14. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/doctor.py +84 -23
  15. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/enforcement.py +156 -11
  16. controlzero-1.13.13/controlzero/cli/enforcement_verdict.py +1316 -0
  17. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/base.py +54 -9
  18. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/cursor_cli.py +21 -3
  19. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/kiro.py +77 -7
  20. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/main.py +615 -159
  21. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/setup_wizard.py +55 -9
  22. controlzero-1.13.13/controlzero/cli/templates/kiro/kiro.yaml +176 -0
  23. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/client.py +636 -9
  24. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hook_integrity.py +36 -4
  25. controlzero-1.13.13/controlzero/host_invocation.py +723 -0
  26. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/_export_dlp.py +7 -1
  27. {controlzero-1.13.11 → controlzero-1.13.13}/pyproject.toml +17 -1
  28. controlzero-1.13.11/controlzero/cli/templates/kiro/kiro.yaml +0 -102
  29. {controlzero-1.13.11 → controlzero-1.13.13}/LICENSE +0 -0
  30. {controlzero-1.13.11 → controlzero-1.13.13}/README.md +0 -0
  31. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/__main__.py +0 -0
  32. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/__init__.py +0 -0
  33. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/_messages.py +0 -0
  34. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/action_validator.py +0 -0
  35. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/bundle.py +0 -0
  36. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/condition_ops.py +0 -0
  37. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/credential_hook.py +0 -0
  38. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/credential_scanner.py +0 -0
  39. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/credentials_data/__init__.py +0 -0
  40. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/credentials_data/built_in.yaml +0 -0
  41. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/dlp_remediation.py +0 -0
  42. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/io_capture_producer.py +0 -0
  43. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/source_labels.py +0 -0
  44. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/tool_extractors.json +0 -0
  45. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/_internal/types.py +0 -0
  46. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/audit_local.py +0 -0
  47. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/canonical.py +0 -0
  48. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/__init__.py +0 -0
  49. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/__main__.py +0 -0
  50. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/_secrets.py +0 -0
  51. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/agent_config.py +0 -0
  52. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/console.py +0 -0
  53. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/exec_cmd.py +0 -0
  54. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hook_entry.py +0 -0
  55. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/__init__.py +0 -0
  56. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/antigravity.py +0 -0
  57. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/claims.py +0 -0
  58. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/claude_code.py +0 -0
  59. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/codex_cli.py +0 -0
  60. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/cursor.py +0 -0
  61. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/gemini_cli.py +0 -0
  62. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/hosts/unknown.py +0 -0
  63. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/kiro_adapter.py +0 -0
  64. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/maturity.py +0 -0
  65. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/migrate.py +0 -0
  66. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/output.py +0 -0
  67. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/spool_cmd.py +0 -0
  68. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/telemetry_consent.py +0 -0
  69. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/antigravity/hooks.json +0 -0
  70. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/antigravity.yaml +0 -0
  71. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/autogen.yaml +0 -0
  72. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/claude-code.yaml +0 -0
  73. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/codex-cli.yaml +0 -0
  74. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/cost-cap.yaml +0 -0
  75. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/crewai.yaml +0 -0
  76. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/cursor.yaml +0 -0
  77. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/gemini-cli.yaml +0 -0
  78. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/generic.yaml +0 -0
  79. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/kiro/ide-file-save.kiro.hook +0 -0
  80. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/kiro/ide-pre-tool-use.kiro.hook +0 -0
  81. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/kiro/ide-prompt-submit.kiro.hook +0 -0
  82. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/langchain.yaml +0 -0
  83. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/mcp.yaml +0 -0
  84. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/templates/rag.yaml +0 -0
  85. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/cli/update_cmd.py +0 -0
  86. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/device.py +0 -0
  87. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/enrollment.py +0 -0
  88. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/error_codes.py +0 -0
  89. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/error_codes.yaml +0 -0
  90. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/errors.py +0 -0
  91. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hitl/__init__.py +0 -0
  92. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hitl/grant_protocol.py +0 -0
  93. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hitl/mock.py +0 -0
  94. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hitl/pending_approval.py +0 -0
  95. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hitl/secret_leak_guard.py +0 -0
  96. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hitl/status.py +0 -0
  97. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hooks/__init__.py +0 -0
  98. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hooks/tool_output_handler.py +0 -0
  99. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/hosted_policy.py +0 -0
  100. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/__init__.py +0 -0
  101. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/_mask_apply.py +0 -0
  102. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/anthropic.py +0 -0
  103. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/autogen.py +0 -0
  104. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/braintrust.py +0 -0
  105. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/crewai/__init__.py +0 -0
  106. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/crewai/agent.py +0 -0
  107. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/crewai/crew.py +0 -0
  108. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/crewai/task.py +0 -0
  109. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/crewai/tool.py +0 -0
  110. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/google.py +0 -0
  111. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/google_adk/__init__.py +0 -0
  112. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/google_adk/agent.py +0 -0
  113. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/google_adk/tool.py +0 -0
  114. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/__init__.py +0 -0
  115. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/agent.py +0 -0
  116. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/callbacks.py +0 -0
  117. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/chain.py +0 -0
  118. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/graph.py +0 -0
  119. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/modern.py +0 -0
  120. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langchain/tool.py +0 -0
  121. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/langfuse.py +0 -0
  122. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/litellm.py +0 -0
  123. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/openai.py +0 -0
  124. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/pydantic_ai.py +0 -0
  125. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/integrations/vercel_ai.py +0 -0
  126. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/layout_migration.py +0 -0
  127. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/policy_loader.py +0 -0
  128. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/secrets/__init__.py +0 -0
  129. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/secrets/reference.py +0 -0
  130. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/secrets/resolver.py +0 -0
  131. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/secrets/secretstr.py +0 -0
  132. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/__init__.py +0 -0
  133. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_compress.py +0 -0
  134. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_constants.py +0 -0
  135. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_crc32c.py +0 -0
  136. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_crypto.py +0 -0
  137. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_frame.py +0 -0
  138. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_keyring.py +0 -0
  139. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_metrics.py +0 -0
  140. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_spool.py +0 -0
  141. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_state.py +0 -0
  142. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/_uploader.py +0 -0
  143. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/spool/cz-audit-v1.dict +0 -0
  144. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/tamper.py +0 -0
  145. {controlzero-1.13.11 → controlzero-1.13.13}/controlzero/tracecontext.py +0 -0
@@ -141,8 +141,21 @@ site/
141
141
 
142
142
  # Rust
143
143
  target/
144
- Cargo.lock
145
144
  *.rlib
145
+ # Cargo.lock is COMMITTED, deliberately. Cargo's guidance is to commit the
146
+ # lockfile for binaries and applications and omit it only for libraries
147
+ # published to a registry. Nothing here is published to crates.io: every
148
+ # package in this repo is either a cdylib/staticlib compiled into the
149
+ # Python/Node SDKs across the FFI boundary (crates/*) or a shipped binary
150
+ # (services/shadow-ai-scout). Both are applications for lockfile purposes.
151
+ #
152
+ # It was ignored until 2026-08-14, which meant every CI run re-resolved
153
+ # dependencies from scratch. A third party publishing a new version could,
154
+ # and did, break main with no commit to this repo: icu_* 2.3.0 shipped with
155
+ # MSRV 1.88 and `cargo clippy` on our pinned 1.86.0 started failing on a
156
+ # tree nobody had touched. It also meant the artifact that was tested was
157
+ # not provably the artifact that shipped, so a dependency audit of one said
158
+ # nothing about the other.
146
159
 
147
160
  # Python virtual environments
148
161
  venv/
@@ -1,5 +1,387 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.13.13 (2026-08-16): a read-class METHOD could satisfy an allow the SQL would deny
6
+
7
+ **Security fix. If you use a `database:read` allow rule, read this.**
8
+
9
+ Our own documented read-only-database recipe permitted `DROP TABLE`. Driven
10
+ against the published 1.13.12 wheel, using that page's own policy and its own
11
+ verification command:
12
+
13
+ controlzero test database --method query --policy policy.yaml \
14
+ --args '{"sql":"DROP TABLE users"}' -> ALLOW, policy_id=read-only-db
15
+
16
+ same policy, no --method -> DENY
17
+
18
+ The alias table maps `read -> [query, SELECT, ...]`. A caller-supplied
19
+ `method="query"` is itself a read-class alias, so `database:read` was injected
20
+ FROM THE METHOD and satisfied the allow before the SQL extractor's `admin`
21
+ class was consulted. "Most dangerous class wins" is the documented semantics;
22
+ it did not happen whenever a method was supplied -- which is the SDK call shape
23
+ we document (`cz.guard("database", method="query", args={"sql": ...})`).
24
+
25
+ **Not a regression.** The `query -> read` alias predates the recipe. It has
26
+ never worked. All releases up to and including 1.13.12 are affected.
27
+
28
+ **Fixed:** when a method-derived class and an argument-derived class are both
29
+ present, the most dangerous now governs. Method-only calls keep legacy alias
30
+ expansion; concrete SQL overrides a stale caller-supplied semantic context.
31
+ Python and Node.
32
+
33
+ **If you cannot upgrade yet,** put explicit denies ahead of the allow. Verified
34
+ on the vulnerable 1.13.12 build: DROP / DELETE / TRUNCATE / GRANT and the
35
+ `SELECT 1; DROP TABLE x` piggyback all deny, while `SELECT` still allows.
36
+
37
+ rules:
38
+ - id: no-destructive-sql
39
+ deny: 'database:admin'
40
+ - id: no-writes
41
+ deny: 'database:write'
42
+ - id: read-only-db
43
+ allow: 'database:read'
44
+
45
+ A `deny: 'database:admin'` rule was never affected -- it matches the
46
+ argument-derived class regardless of the method. Only the allow-only shape was
47
+ vulnerable.
48
+
49
+ Tests: 124 explicit cells (31 method modes x 4 SQL classes) plus the four
50
+ literal CLI cases, mirrored in Node. Adding a class or a method alias fails the
51
+ matrix until its cells are filled.
52
+
53
+
54
+ ## 1.13.12 (2026-08-16): your audit trail now states whether the HOST enforced, and deny rules that never fired now fire
55
+
56
+ Two things in this release change what a customer can conclude from what we
57
+ ship. Read them before the rest.
58
+
59
+ **1. The audit row used to claim a block the host never performs (#2113, #2114,
60
+ #2236).** The row is the artefact an auditor reads to decide whether a customer
61
+ was protected. Until this release, every decision row that REACHED the host-event
62
+ branch came out `enforcement_outcome: enforced`,
63
+ `enforcement_reason: host_enforced`, `enforced: True` -- and on the observe-only
64
+ surfaces that was false.
65
+
66
+ Stated precisely, because the scope matters: this was never all rows. The
67
+ pre-fix `resolve_enforcement` returned earlier and correctly for a non-blocking
68
+ effect (`not_applicable / effect_not_blocking`), a post-execution event
69
+ (`not_enforced / post_execution_event`) and an unknown tier
70
+ (`indeterminate / unknown_tier`), none of which depend on the declaration. The
71
+ rows that were wrong are the ones those guards let through: a blocking effect,
72
+ on a known tier, at a pre-execution event -- which is exactly the population an
73
+ auditor cares about, because it is the set where a block was supposedly imposed.
74
+
75
+ #2203 taught `resolve_enforcement` to consult each adapter's own
76
+ `CoverageDecl`, then wired it into two of its three consumers: the resolver and
77
+ the deny message. The third -- the audit row -- was missed. `client.py` had been
78
+ reading `raw_event` / `deciding_events` / `observe_events` out of
79
+ `context["cz_enforcement_ctx"]` since #2203, but nothing ever put them there.
80
+ Every row was therefore resolved against an empty
81
+ `EventDeclaration(raw="", deciding=(), observe=())`, `declares_anything` was
82
+ False on every row ever written, so the two declaration-dependent branches --
83
+ `not_enforced / event_declared_observe` and
84
+ `indeterminate / event_not_declared_deciding` -- could not fire at all, and any
85
+ row reaching them fell through to the affirmative default instead.
86
+
87
+ Driven on pristine `origin/main`, `kiro_cli` + `userPromptSubmit`:
88
+
89
+ enforcement_outcome : enforced
90
+ enforcement_reason : host_enforced
91
+ enforced : True
92
+
93
+ `KiroCLIAdapter` declares that exact event OBSERVE-ONLY, and kiro-cli 2.16.2 was
94
+ driven answering the prompt after BOTH an exit-2 hook and a structured stdout
95
+ deny. The same row now reads:
96
+
97
+ enforcement_outcome : not_enforced
98
+ enforcement_reason : event_declared_observe_only
99
+ enforced : False
100
+
101
+ **What this means for you.** Rows already in your audit store that say
102
+ `enforced / host_enforced` for a prompt-submission event on an observe-only host
103
+ are not evidence the call was blocked. Nothing about the host's behaviour
104
+ changed in this release -- kiro-cli did not start or stop enforcing anything.
105
+ What changed is that the record now distinguishes "the host imposed this" from
106
+ "we asked and cannot establish that it was imposed". If you query or alert on
107
+ `enforced`, expect observe-only surfaces to move from True to False, and treat
108
+ that as the field becoming correct rather than as a regression.
109
+
110
+ **2. A deny rule naming a host tool name was inert on the shipped hook path
111
+ (#2245).** `deny: Read` was accepted by `validate`, previewed as DENY by
112
+ `test`, and silently ALLOWED by the real `controlzero hook-check`. Three
113
+ surfaces told the customer they were protected while the enforcement path
114
+ ignored the rule, and the audit row for the allowed call was byte-identical to
115
+ one written with no rule authored at all. Measured across the shipped security
116
+ template -- 11 template tool names -- the base enforced 4 of 11 bare names; this
117
+ release enforces 11 of 11, and `controlzero test` now agrees with the hook
118
+ because both build the subject through the hook's own factory.
119
+
120
+ ### Changed: what an audit row asserts about enforcement (#2113, #2114, #2236)
121
+
122
+ - **Enforcement is now bound to the host's own event declaration.** The
123
+ `cz_enforcement_ctx` keys the audit path has always read are now actually
124
+ populated, so the per-host branch can fire.
125
+
126
+ - **An absent enforcement-context key is silence, not an empty declaration
127
+ (#2218).** "This host declared nothing" and "this host declared an explicitly
128
+ empty deciding set" are different facts and are now encoded in the TYPE rather
129
+ than in a flag, so the two cannot collapse into one another.
130
+
131
+ - **`render()` now receives the host event.** It previously did not, so the WIRE
132
+ posture was resolved against an empty `EventDeclaration` while the audit row
133
+ used the real one. A single `hook-check` run could put
134
+ `indeterminate / event_not_declared_deciding` on the wire and write
135
+ `enforced / host_enforced` to the row -- two answers to "was the customer
136
+ protected?" out of one process, on 7 of the 9 shipped adapters. The method's
137
+ own docstring said this could not happen.
138
+
139
+ - **`CursorCLIAdapter.render` no longer drops the posture.** Its override could
140
+ not accept the event pair and rebuilt its `RenderResult` without
141
+ `enforcement`, silently downgrading the ask-path posture to NOT_FINALIZED. A
142
+ control applied at one point on one path, hidden in a subclass override.
143
+
144
+ - **A contradictory declaration is now a named posture, INDETERMINATE.** An
145
+ adapter declaring one event both deciding and observe was previously resolved
146
+ by EVALUATION ORDER -- `is_declared_observe()` is tested first, so the observe
147
+ branch won by position over a contradiction nobody was told about. A
148
+ contradictory declaration is not evidence the host let the call through, and
149
+ `_enforcement_notice` now carries a specific line for it that says it is our
150
+ defect to fix, instead of inheriting the generic "could not confirm" that
151
+ reads identically to the host merely being unobservable. No shipped adapter is
152
+ currently self-contradictory, and that is asserted in both directions.
153
+
154
+ ### Fixed
155
+
156
+ - **A deny rule naming a HOST tool name was inert on the hook path (#2245).**
157
+ The originally-filed hypothesis -- a colon-less subject -- is REFUTED and
158
+ pinned as such: every matcher formats `tool:method` unconditionally, and
159
+ `Read:*` already matched the empty-method subject `Read:`. The real mechanism
160
+ is an alias-resolution asymmetry: the hook resolved the HOST name to its
161
+ canonical family name (`Read` -> `file_read`) and discarded the host spelling
162
+ before the gate ran, while the rule kept the host spelling. `Bash` survived
163
+ only because it is its own canonical name. The gate is now asked about every
164
+ name the call actually carried -- the host spelling AND its canonical family
165
+ name -- and NEVER about sibling aliases, because the alias map is many-to-one
166
+ and canonicalising the RULE would make `deny: Edit` also block `Write`. Both
167
+ directions are pinned. The widening ships behind a phase-1 monotone-closing
168
+ gate: closing flips ship, opening flips are suppressed and counted as
169
+ `match_semantics=new_suppressed`.
170
+
171
+ - **The #2245 phase gate could not see the context axis, and a deny became an
172
+ allow (#2320).** #2245 injects `subject_origin` into the decision context.
173
+ Rule conditions merge the whole context, so a rule can match on a key that did
174
+ not exist before -- and `_choose_match`'s monotone-closing gate enumerated the
175
+ ACTION axis only, so it could not observe a flip caused that way. Driven on
176
+ `main`:
177
+
178
+ base c99dba92e rc=2 DENY policy_id=old-deny (no match_semantics)
179
+ main 6dd972815 rc=0 ALLOW policy_id=origin-allow match_semantics=unchanged
180
+ fixed rc=2 DENY policy_id=old-deny match_semantics=new_suppressed
181
+
182
+ The audit stamped `unchanged` on the exact call whose effect flipped. That is
183
+ worse than an absent field: the record actively asserted the false thing. The
184
+ fix declares, per `SubjectOrigin`, which context keys pre-existed
185
+ (`PRE_WIDENING_CONTEXT_KEYS`, sourced from the pre-fix tree) and evaluates the
186
+ rule walk against both contexts. It is a positive set with import-time
187
+ exhaustiveness, so a new origin is unshippable until declared. `unchanged` is
188
+ now true BY CONSTRUCTION rather than by assumption, and `match_semantics`
189
+ gains `indeterminate` for "the pre-widening verdict could not be established".
190
+ The 11 x 3 action matrix is unchanged at 33/33, so #2245 loses nothing.
191
+
192
+ - **Kiro sub-agent SPAWNS were not gated at `preToolUse` (#2331).**
193
+ `_KIRO_V3_PRE_TOOL_MATCHERS` wired `execute_bash` / `fs_write` / `fs_read` /
194
+ `use_aws` only, so an operator who wrote `deny: use_subagent` had a rule that
195
+ could not fire. Driven on kiro-cli 2.18.1: with the old matcher list
196
+ `use_subagent` reached `preToolUse` ZERO times and the sub-agent completed;
197
+ with the spawn matchers added it reaches `preToolUse` and exit 2 blocks it
198
+ ("PreToolHook blocked the tool execution").
199
+
200
+ **This is admission control in SCOPE, not enforcement in strength**, and the
201
+ limit was observed rather than theorised: in the same run the model responded
202
+ to the blocked spawn by doing the identical work through its own
203
+ `execute_bash`. The Kiro coverage tier stays `partial`.
204
+
205
+ **`use_subagent` is the only spawn tool established as real on kiro-cli
206
+ 2.18.1.** The matcher tuple carries five names. Do not read that as five
207
+ gateable spawn surfaces:
208
+
209
+ - `delegate` -- INERT. Experimental and disabled; the binary says so verbatim
210
+ ("Delegate tool is experimental and not enabled. Use /experiment to enable
211
+ it").
212
+ - `agent_crew` -- INERT. Present in the binary's alias table but not exposed.
213
+ - `spawn_session` -- INERT, and structurally so: it is not a tool name at all
214
+ but a `command` enum value inside the `session_management` tool, which makes
215
+ it wrong as a MATCHER rather than merely unused.
216
+ - `session_management` -- **status NOT ESTABLISHED.** The driven enumeration
217
+ accounted for the three names above; it recorded nothing either way about
218
+ this one, and the shipped Kiro template still carries commented guidance
219
+ naming it. It is neither claimed as coverage nor asserted to be inert here,
220
+ because neither has been driven. Treat it as unproven until it is.
221
+
222
+ Driven: the agent's own tool enumeration returned 13 tools and none of the
223
+ three named INERT above, and declaring all four explicitly surfaced none of
224
+ them. They are carried so the matcher is already in place if a future kiro-cli
225
+ exposes them. Today only `use_subagent` is shown to gate anything, and this
226
+ release claims nothing more.
227
+
228
+ - **`controlzero verify --prove` certified enforcement from an exit code we
229
+ produce (#2305).** Driven on a box where all seven host binaries were ABSENT,
230
+ with the hook repointed at a script whose entire body is
231
+ `cat > /dev/null; exit 2`, it reported `[GREEN] claude_code -- effective gate
232
+ holds + real deny blocked` and `status: ENFORCING`, exit 0. The same shim
233
+ exiting 0 produced RED, which is what establishes that no host was in the loop
234
+ at any point. Three states that must never collapse -- ENFORCING /
235
+ NOT_ENFORCING / UNVERIFIABLE -- were two, and the collapse always resolved
236
+ optimistically. `ENFORCING` is now reachable from ONE point in one resolver,
237
+ and the gate there is deliberately NOT "some host evidence exists" -- that
238
+ weaker gate shipped in the first draft of this module and was refuted, because
239
+ "a config file has a foreign key" unlocked the same sentence as an executed
240
+ binary. Evidence licenses NAMED claims, and an affirmative requires the
241
+ specific `HOST_PRESENT` claim, plus `effective_state_ok` and `probe_blocked`
242
+ both true -- so the verdict object cannot be constructed while asserting
243
+ something it does not hold. `HostEvidence` is produced by collectors that read
244
+ an artifact the HOST emitted, and evidence collected from one adapter cannot
245
+ certify another. Stated exactly rather than absolutely: this closes the
246
+ accidental paths, not a determined one -- the private sentinel can be imported
247
+ deliberately, so this is a chokepoint, not a proof of unforgeability. Also
248
+ fixed in the same pass: probe
249
+ errors collapsed into "did not block"; `doctor` rendered a bare all-clear
250
+ having examined nothing and swallowed reader exceptions; antigravity presence
251
+ tested a path the installer never writes; and install self-checks claimed a
252
+ host blocked something.
253
+
254
+ - **Cross-SDK prompt vocabulary is now pinned (#2331).** Every test asserted one
255
+ SDK against literals it restated itself, so a rename on either side left both
256
+ suites green -- the #2071 shape that let #2108 be closed while still live in
257
+ Python. The Node suite now EXTRACTS the vocabulary from Python source at run
258
+ time and drives Node against it, asserting an exact-set IFF over a bounded
259
+ pool rather than one-way containment, with a match-count and uniqueness
260
+ assertion on every extraction so a moved symbol fails loudly instead of
261
+ passing vacuously.
262
+
263
+ ### Changed (AUDIT SCHEMA BREAK: the per-finding `masked` boolean is gone)
264
+
265
+ - **A DLP finding's `masked: True` is replaced by `mask_state` (#2096).**
266
+ `masked` was stamped from the RULE'S ACTION (`m.action == "mask"`), which is
267
+ what the rule says to DO -- never evidence that anything was done. A call
268
+ DENIED on `mask_unsupported_on_host` redacted nothing and never ran, yet
269
+ produced a findings array byte-identical to one for a call that really was
270
+ redacted. `args_hash` is taken over the ORIGINAL arguments on every path, so
271
+ nothing else in the row discriminated either. Established by driving
272
+ `Client.guard` against pristine `origin/main`: four deny scenarios and one
273
+ allow scenario all emitted `'masked': True`.
274
+
275
+ A boolean cannot carry this truth, so it is now a closed vocabulary, matching
276
+ the Node SDK exactly and asserted against the shared cross-SDK manifest
277
+ `tests/parity/dlp_control_outcomes.json`:
278
+
279
+ | `mask_state` | meaning |
280
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
281
+ | `applied` | the caller acknowledged consuming the redacted args via `decision.apply_masked_args()`. The ONLY value that asserts that acknowledgement. |
282
+ | `declared_not_confirmed` | the SDK redacted into `decision.masked_args` and the caller declared `supports_arg_rewrite=True`; whether it forwards them is not observable. |
283
+ | `not_applied` | a mask rule matched and NOTHING was redacted (the call was denied instead). |
284
+ | `not_applicable` | the rule's action is `detect`/`block`, or this is a post-execution output observation or a third-party export scan. |
285
+
286
+ Treat any unrecognised value as NOT `applied`.
287
+
288
+ **What `applied` does NOT mean.** It is one link short of "the tool received
289
+ redacted bytes", deliberately. `apply_masked_args()` emits the record and then
290
+ returns the args, so a caller can acknowledge and still forward the originals.
291
+ No value in this vocabulary asserts delivery to the tool, because the SDK
292
+ cannot observe what the caller does next.
293
+
294
+ - **`DLPScanner.get_findings_for_audit(matches)` now REQUIRES a second
295
+ argument**, `mask_state`, and rejects both `"applied"` and any value outside
296
+ the vocabulary with a `TypeError` before a single finding is built. The
297
+ posture for an unknown value is REJECT, not downgrade and not pass-through.
298
+
299
+ - **Every decision audit row now carries `dlp_mask_state`**, including rows with
300
+ no DLP findings at all (`not_applicable`). Absence is not a state: "no mask
301
+ was in play" and "a mask fired and was not honoured" are separate, queryable
302
+ facts.
303
+
304
+ - **`decision.masked_args` is deep-cloned and frozen** before `guard()` returns.
305
+ An in-place mutation through the ordinary mapping and sequence operations
306
+ raises the new, exported `MaskedArgsFrozenError` (a `TypeError` subclass) at
307
+ the line attempting it, rather than succeeding and leaving the
308
+ `dlp_mask_applied` record describing bytes the caller never forwarded. Take
309
+ `dict(decision.masked_args)` for a mutable copy. Chosen over a hash comparison
310
+ deliberately: a comparison detects the tamper only if someone remembers to
311
+ call it.
312
+
313
+ **The guarantee is BOUNDED, and the bound is stated rather than implied.**
314
+ Python has no true deep freeze. The protection covers the JSON-shaped tree,
315
+ which is the only shape a redaction is produced in; it is not a claim that
316
+ every in-place mutation is impossible. Specifically: a `set` / `frozenset`
317
+ leaf and a custom mutable object leaf are returned BY IDENTITY, so mutating
318
+ one the caller still holds does change the tree the acknowledgement returns.
319
+ Both are left alone deliberately -- neither is JSON-representable, so neither
320
+ can survive the RFC 8785 canonicaliser or any host envelope, meaning a mask
321
+ decision cannot legitimately carry one.
322
+
323
+ ### Added
324
+
325
+ - **`decision.apply_masked_args()`** -- the single producer of
326
+ `mask_state: "applied"`. Present only on a decision that carries a redaction;
327
+ `None` everywhere else, so "there is nothing to acknowledge" and "the caller
328
+ declined to acknowledge" stay distinguishable. Calling it emits a
329
+ `dlp_mask_applied` audit event carrying `args_hash` + `masked_args_hash` (the
330
+ pair an auditor joins on to see that the bytes actually changed) and returns
331
+ the frozen redacted arguments.
332
+
333
+ - **`controlzero.MaskedArgsFrozenError`** is exported from the package root.
334
+
335
+ ### Known limitations, stated plainly
336
+
337
+ - The Python SDK's DLP decision fields -- `dlp_findings`, `dlp_status`,
338
+ `dlp_masked` and now `dlp_mask_state` -- are written to the LOCAL audit log
339
+ only. `audit_remote._build_wire_entry` is an explicit allowlist and forwards
340
+ none of them, so none of this is visible in a hosted audit trail today. That
341
+ is pre-existing behaviour and unchanged here; #2096 corrects what the record
342
+ SAYS, not where it is shipped.
343
+
344
+ - The Go SDK's `GetFindingsForAudit` gained the same closed vocabulary, but it
345
+ still has NO first-party caller: the Go `Guard` attaches no findings to any
346
+ decision or audit row, so no Go audit row carries a `mask_state`. That is a
347
+ declared gap, pinned by a test that fails the day anyone wires it up. The Go
348
+ SDK also left the build on 2026-08-14 (#2291) and is not published from this
349
+ release.
350
+
351
+ - **Kiro sub-agent coverage is CONFIG-SCOPED, not absent, and one path stays
352
+ ungated.** It would be wrong in both directions to say sub-agents were
353
+ previously ungoverned. Driven on kiro-cli 2.18.1, 19 legs, each with a nonce
354
+ side-effect proving the call actually ran: a sub-agent that runs an agent
355
+ config carrying our hooks DOES fire `preToolUse` and `postToolUse` inside the
356
+ sub-agent (leg G), and a child hook exiting 2 blocks the sub-agent's own tool
357
+ call (leg J). Hooks resolve from the SUB-AGENT'S OWN config and are not
358
+ inherited from the parent.
359
+
360
+ The genuine gap is leg K: a sub-agent spawned WITHOUT an explicit `agent_name`
361
+ runs an implicit config that carries no hooks, and setting
362
+ `chat.defaultAgent` to a hooked agent does not help -- leg K establishes that
363
+ the implicit agent does not resolve to it, so the path cannot be reached by
364
+ writing hooks anywhere on disk. That is kiro **#7755**, which is OPEN with no
365
+ maintainer response and no assignee. Do not cite #7671 for this: it is CLOSED
366
+ AS COMPLETED, was filed against 0.11.130, and the behaviour it describes
367
+ ("hooks will not trigger in subagents") is contradicted by the 2.18.1 binary.
368
+ The vendor documentation still carries that sentence; for a claim about what
369
+ ships today, the binary outranks the doc.
370
+
371
+ Gating the SPAWN is what makes `deny: use_subagent` reachable, and it narrows
372
+ the ungoverned surface rather than eliminating it.
373
+
374
+ - **Scope of every driven Kiro claim in this release: kiro-cli 2.18.1 on
375
+ macOS**, `chat --no-interactive`, `chat.enableSubagent=true`. It is NOT a
376
+ claim about the whole `>=2.1.0,<3.0.0` range the adapter declares.
377
+
378
+ - **Kiro matches by ALIAS FAMILY, not literal matcher name.** Observed on
379
+ 2.18.1 in two independent pairs: an `execute_bash` matcher fired on
380
+ `tool_name` `shell`, and `fs_write` fired on `write`. Each matcher must stay
381
+ a single tool-name token -- a regex-style `shell|execute_bash` alternation
382
+ matches a tool LITERALLY named that, which is to say nothing at all, and
383
+ would silently disable enforcement.
384
+
3
385
  ## 1.13.11 (2026-08-10): kiro_cli prompts were never actually scanned
4
386
 
5
387
  1.13.10 said "Prompt submission is now a scanned surface on the hosts that
@@ -610,7 +992,7 @@ coverage declaration intentionally remains observe-only.
610
992
 
611
993
  ### Fixed
612
994
 
613
- - **kiro-cli Windows / non-UTF-8 install crash (Ken Lee report).** `controlzero
995
+ - **kiro-cli Windows / non-UTF-8 install crash (customer report).** `controlzero
614
996
  kiro init` captured kiro-cli's output in text mode using the OS locale codec, so
615
997
  kiro-cli's UTF-8 output crashed the install's subprocess reader thread on
616
998
  non-UTF-8 Windows locales (Korean CP949, Japanese SJIS, Chinese GBK). That crash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlzero
3
- Version: 1.13.11
3
+ Version: 1.13.13
4
4
  Summary: AI agent governance: policies, audit, and observability for tool calls. Works locally with no signup.
5
5
  Project-URL: Homepage, https://controlzero.ai
6
6
  Project-URL: Documentation, https://docs.controlzero.ai
@@ -19,7 +19,7 @@ for framework examples (LangChain, LangGraph, OpenAI Agents, Anthropic, CrewAI,
19
19
  AutoGen, Pydantic AI, MCP servers, and more).
20
20
  """
21
21
 
22
- from controlzero.client import Client
22
+ from controlzero.client import Client, MaskedArgsFrozenError
23
23
  from controlzero.errors import (
24
24
  PolicyDecision,
25
25
  PolicyDeniedError,
@@ -41,10 +41,11 @@ from controlzero.hitl.grant_protocol import (
41
41
  from controlzero.policy_loader import load_policy
42
42
  from controlzero.secrets import SecretRef, SecretStr, secret_ref
43
43
 
44
- __version__ = "1.13.11"
44
+ __version__ = "1.13.13"
45
45
 
46
46
  __all__ = [
47
47
  "Client",
48
+ "MaskedArgsFrozenError",
48
49
  "PendingApproval",
49
50
  "SecretRef",
50
51
  "SecretStr",
@@ -87,6 +87,68 @@ for cls, aliases in _CLASSES.items():
87
87
  _CANONICAL_TO_LEGACY[canon] = {_canonical(a) for a in aliases}
88
88
 
89
89
 
90
+ # Conflict-only ordering for a caller method versus the class derived from the
91
+ # concrete SQL arguments (#2340). ``admin`` and ``exec`` are both privileged,
92
+ # top-tier classes: neither may be treated as safer than the other. When they
93
+ # conflict, the argument-derived class wins the tie because it describes the
94
+ # SQL that will actually execute; the caller method is compatibility metadata.
95
+ _DATABASE_CLASS_DANGER: dict[str, int] = {
96
+ "read": 0,
97
+ "write": 1,
98
+ "admin": 2,
99
+ "exec": 2,
100
+ }
101
+
102
+
103
+ def _database_classes_for_action(action: str) -> set[str]:
104
+ """Return canonical database classes represented by one action."""
105
+ if not isinstance(action, str) or not action or ":" not in action:
106
+ return set()
107
+ tool, method = action.split(":", 1)
108
+ if tool != TOOL:
109
+ return set()
110
+ if method in _DATABASE_CLASS_DANGER:
111
+ return {method}
112
+ return {
113
+ canonical_action.split(":", 1)[1]
114
+ for canonical_action in _LEGACY_TO_CANONICAL.get(method, ())
115
+ }
116
+
117
+
118
+ def resolve_database_class_conflict(
119
+ method_action: str,
120
+ argument_action: str,
121
+ ) -> str | None:
122
+ """Return the sole governing action for a method/argument class conflict.
123
+
124
+ ``None`` means there is no explicit conflict to resolve: at least one side
125
+ is unclassified, or both sides represent exactly the same single class. In
126
+ those cases callers retain the additive compatibility behavior.
127
+
128
+ A returned action is a canonical ``database:<class>``. The evaluator must
129
+ build candidates from that action alone, making a less-dangerous method
130
+ alias absent rather than hoping a later deny rule detects it.
131
+ """
132
+ method_classes = _database_classes_for_action(method_action)
133
+ argument_classes = _database_classes_for_action(argument_action)
134
+
135
+ # Explicit positive gate: conflict resolution only applies when BOTH
136
+ # sources carry recognized classes and the argument side is unambiguous.
137
+ if not method_classes or len(argument_classes) != 1:
138
+ return None
139
+ argument_class = next(iter(argument_classes))
140
+ if method_classes == {argument_class}:
141
+ return None
142
+
143
+ method_class = max(method_classes, key=_DATABASE_CLASS_DANGER.__getitem__)
144
+ if (
145
+ _DATABASE_CLASS_DANGER[argument_class]
146
+ >= _DATABASE_CLASS_DANGER[method_class]
147
+ ):
148
+ return _canonical(argument_class)
149
+ return _canonical(method_class)
150
+
151
+
90
152
  def expand_candidate_actions(actions: Iterable[str]) -> list[str]:
91
153
  """Expand an iterable of candidate actions to include every known alias.
92
154
 
@@ -162,4 +224,9 @@ def alias_table_json() -> str:
162
224
  return json.dumps(payload, indent=2, sort_keys=False)
163
225
 
164
226
 
165
- __all__ = ["TOOL", "expand_candidate_actions", "alias_table_json"]
227
+ __all__ = [
228
+ "TOOL",
229
+ "alias_table_json",
230
+ "expand_candidate_actions",
231
+ "resolve_database_class_conflict",
232
+ ]