ciphergate-cli 0.11.0__tar.gz → 0.12.0__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 (150) hide show
  1. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/CHANGELOG.md +24 -1
  2. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/PKG-INFO +8 -4
  3. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/README.md +7 -3
  4. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/policy-schema.md +13 -0
  5. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/__init__.py +1 -1
  6. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/batch.py +48 -2
  7. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/__init__.py +2 -0
  8. ciphergate_cli-0.12.0/src/ciphergate/reporting/next_steps.py +106 -0
  9. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/reporting/terminal.py +10 -0
  10. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_batch.py +51 -0
  11. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_cli_e2e.py +29 -0
  12. ciphergate_cli-0.12.0/tests/unit/test_next_steps.py +62 -0
  13. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/.gitignore +0 -0
  14. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/LICENSE +0 -0
  15. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/NOTICE +0 -0
  16. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/SECURITY.md +0 -0
  17. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/architecture.md +0 -0
  18. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/claude-code-tooling.md +0 -0
  19. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/detector-coverage.md +0 -0
  20. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/examples.md +0 -0
  21. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/limitations.md +0 -0
  22. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/security-and-privacy.md +0 -0
  23. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/docs/threat-model.md +0 -0
  24. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/expected/sample.dev_local_dev.csv +0 -0
  25. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/expected/sample.dev_local_dev.json +0 -0
  26. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/expected/sample.dev_local_dev.jsonl +0 -0
  27. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/expected/sample.dev_local_dev.log +0 -0
  28. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/sample.csv +0 -0
  29. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/sample.json +0 -0
  30. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/sample.jsonl +0 -0
  31. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/examples/sample.log +0 -0
  32. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/dev_local.yml +0 -0
  33. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/developer_logs.yml +0 -0
  34. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/external_ai.yml +0 -0
  35. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/financial_template.yml +0 -0
  36. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/healthcare_template.yml +0 -0
  37. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/local_debug.yml +0 -0
  38. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/multi_environment.yml +0 -0
  39. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/prd_external_ai.yml +0 -0
  40. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/stg_test.yml +0 -0
  41. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/policies/test_dataset.yml +0 -0
  42. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/pyproject.toml +0 -0
  43. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/scripts/benchmark/README.md +0 -0
  44. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/__main__.py +0 -0
  45. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/cli.py +0 -0
  46. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/config.py +0 -0
  47. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/constants.py +0 -0
  48. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/__init__.py +0 -0
  49. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/base.py +0 -0
  50. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/custom_regex.py +0 -0
  51. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/dates.py +0 -0
  52. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/email.py +0 -0
  53. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/field_names.py +0 -0
  54. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/iban.py +0 -0
  55. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/ip_address.py +0 -0
  56. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/mac_address.py +0 -0
  57. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/network.py +0 -0
  58. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/payment_card.py +0 -0
  59. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/phone.py +0 -0
  60. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/presidio_ner.py +0 -0
  61. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/regex_safety.py +0 -0
  62. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/registry.py +0 -0
  63. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/secrets.py +0 -0
  64. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/detectors/ssn.py +0 -0
  65. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/errors.py +0 -0
  66. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/models/__init__.py +0 -0
  67. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/models/context.py +0 -0
  68. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/models/finding.py +0 -0
  69. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/models/policy.py +0 -0
  70. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/models/report.py +0 -0
  71. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/__init__.py +0 -0
  72. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/atomic_output.py +0 -0
  73. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/inspector.py +0 -0
  74. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/paths.py +0 -0
  75. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/planner.py +0 -0
  76. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/rescan.py +0 -0
  77. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/sanitizer.py +0 -0
  78. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/stdio.py +0 -0
  79. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/pipeline/streams.py +0 -0
  80. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/__init__.py +0 -0
  81. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/context.py +0 -0
  82. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/engine.py +0 -0
  83. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/loader.py +0 -0
  84. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/precedence.py +0 -0
  85. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/templates.py +0 -0
  86. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/policy/validator.py +0 -0
  87. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/py.typed +0 -0
  88. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/accesslog_reader.py +0 -0
  89. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/base.py +0 -0
  90. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/csv_reader.py +0 -0
  91. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/json_reader.py +0 -0
  92. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/jsonl_reader.py +0 -0
  93. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/jsonlog_reader.py +0 -0
  94. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/line_structure.py +0 -0
  95. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/logfmt_reader.py +0 -0
  96. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/stacktrace_reader.py +0 -0
  97. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/syslog_reader.py +0 -0
  98. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/readers/text_reader.py +0 -0
  99. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/reporting/__init__.py +0 -0
  100. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/reporting/audit.py +0 -0
  101. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/reporting/safe_text.py +0 -0
  102. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/reporting/serializer.py +0 -0
  103. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/templates/starter_policy.yml +0 -0
  104. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/__init__.py +0 -0
  105. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/date_shift.py +0 -0
  106. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/generalize.py +0 -0
  107. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/keys.py +0 -0
  108. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/pseudonymize.py +0 -0
  109. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/redact.py +0 -0
  110. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/registry.py +0 -0
  111. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/src/ciphergate/transforms/replace.py +0 -0
  112. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/__init__.py +0 -0
  113. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/conftest.py +0 -0
  114. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/__init__.py +0 -0
  115. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_api_batch_streams.py +0 -0
  116. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_atomic_output.py +0 -0
  117. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_domain_templates.py +0 -0
  118. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_environment_matrix.py +0 -0
  119. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_pipelines.py +0 -0
  120. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/integration/test_stdio.py +0 -0
  121. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/regression/__init__.py +0 -0
  122. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/regression/test_known_cases.py +0 -0
  123. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/__init__.py +0 -0
  124. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/test_key_file.py +0 -0
  125. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/test_no_network.py +0 -0
  126. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/test_no_raw_value_leaks.py +0 -0
  127. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/test_path_safety.py +0 -0
  128. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/test_policy_fail_closed.py +0 -0
  129. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/security/test_review_findings.py +0 -0
  130. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/tooling/__init__.py +0 -0
  131. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/tooling/test_benchmark.py +0 -0
  132. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/tooling/test_ci_supply_chain.py +0 -0
  133. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/tooling/test_claude_tooling.py +0 -0
  134. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/tooling/test_git_tooling.py +0 -0
  135. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/tooling/test_publish_workflow.py +0 -0
  136. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/__init__.py +0 -0
  137. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_accesslog.py +0 -0
  138. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_detectors.py +0 -0
  139. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_jsonlog.py +0 -0
  140. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_logfmt.py +0 -0
  141. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_policy_engine.py +0 -0
  142. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_policy_precedence.py +0 -0
  143. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_policy_templates.py +0 -0
  144. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_policy_validation.py +0 -0
  145. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_presidio_ner.py +0 -0
  146. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_properties.py +0 -0
  147. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_readers.py +0 -0
  148. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_stacktrace.py +0 -0
  149. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_syslog.py +0 -0
  150. {ciphergate_cli-0.11.0 → ciphergate_cli-0.12.0}/tests/unit/test_transforms.py +0 -0
@@ -6,6 +6,28 @@ versioning: [PEP 440](https://peps.python.org/pep-0440/) with SemVer meaning (pr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-11-02
10
+
11
+ Blocked runs now say what to do next, and directory runs no longer hide files they never
12
+ scanned. A strict directory `rescan` that skips files with unrecognised extensions now exits 7
13
+ instead of 0.
14
+
15
+ ### Added
16
+
17
+ - Blocked, review and failed runs print `next:` lines in the terminal summary: what to pass,
18
+ what to change in the policy, or what to remove from the input. The hints come from reason
19
+ codes and never include input values. The JSON report is unchanged.
20
+ - `docs/policy-schema.md` explains `hard` and `guardrail` rules.
21
+ - `.out` and `.err` files are read as plain text, in directories and as single files.
22
+
23
+ ### Fixed
24
+
25
+ - Directory runs warn when files were skipped because their extension is not recognised, so a
26
+ `COMPLETED` run no longer hides files that were never scanned. A strict directory `rescan` now
27
+ fails (exit 7) when it skipped such files, and `sanitize` says they are not in the output.
28
+ - Directory runs warn when `--format` is applied to files whose extension means another format.
29
+ - Directory summaries show the same `next:` hints as single-file runs.
30
+
9
31
  ## [0.11.0] - 2026-10-31
10
32
 
11
33
  Makes a clean rescan harder to mistake for proof of safety. Scripts that read `PASSED` from the
@@ -328,7 +350,8 @@ Pre-release (alpha). Licensed under Apache-2.0. Not a final release: the securit
328
350
  See `docs/limitations.md`. Deferred: optional NER/Presidio detectors, stdin/stdout streaming,
329
351
  directory processing, structured log parsers, key files, reversible token vault, lossy decoding.
330
352
 
331
- [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.11.0...HEAD
353
+ [Unreleased]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.12.0...HEAD
354
+ [0.12.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.11.0...v0.12.0
332
355
  [0.11.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.10.0...v0.11.0
333
356
  [0.10.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.9.0...v0.10.0
334
357
  [0.9.0]: https://github.com/aashishkumar-tech/ciphergate/compare/v0.8.0...v0.9.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ciphergate-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Local-first, environment- and destination-aware inspection and sanitization of logs and test datasets.
5
5
  Project-URL: Homepage, https://github.com/aashishkumar-tech/ciphergate
6
6
  Project-URL: Source, https://github.com/aashishkumar-tech/ciphergate
@@ -50,7 +50,7 @@ vendor ticket. CipherGate replaces that with a local, explainable, scriptable ga
50
50
  inspect -> plan -> sanitize --apply -> rescan
51
51
  ```
52
52
 
53
- > **Status: 0.11.0 (alpha).** No detector finds every sensitive value. A passing run means the
53
+ > **Status: 0.12.0 (alpha).** No detector finds every sensitive value. A passing run means the
54
54
  > configured processing completed under the selected policy; it is **not** proof that the output
55
55
  > contains no sensitive information, and it is not a compliance (GDPR, HIPAA, PCI DSS, ...) or
56
56
  > anonymization guarantee. Policy templates must be reviewed before use.
@@ -287,10 +287,14 @@ ciphergate inspect logs/ --policy policies/developer_logs.yml --environment dev
287
287
  ciphergate sanitize logs/ --policy ... --output build/logs.sanitized --apply
288
288
  ```
289
289
 
290
- - Files are chosen by extension (`.csv`, `.json`, `.jsonl`, `.ndjson`, `.txt`, `.log`), or all
291
- regular files when `--format` is given. Subdirectories are processed only with
290
+ - Files are chosen by extension (`.csv`, `.json`, `.jsonl`, `.ndjson`, `.txt`, `.log`, `.out`,
291
+ `.err`), or all regular files when `--format` is given. Subdirectories are processed only with
292
292
  `--recursive`/`-r` (at most 32 levels). Symlinks, junctions, devices, FIFOs and other
293
293
  extensions are **skipped and listed** in the report with a reason; links are never followed.
294
+ Skipped extensions also produce a warning, because those files were never scanned; a strict
295
+ `rescan` fails (exit 7) when it skipped any, and `sanitize` leaves them out of the output.
296
+ `--format` on a directory applies to every file, and the run warns when that contradicts an
297
+ extension (for example a CSV read as text loses its column names).
294
298
  At most 1000 files by default (`--max-files N`); more fails the run (exit 4) before anything
295
299
  is processed. An empty selection fails with `batch_empty` (exit 4).
296
300
  - Each file is processed exactly like a single-file run, in sorted path order; a failing file
@@ -11,7 +11,7 @@ vendor ticket. CipherGate replaces that with a local, explainable, scriptable ga
11
11
  inspect -> plan -> sanitize --apply -> rescan
12
12
  ```
13
13
 
14
- > **Status: 0.11.0 (alpha).** No detector finds every sensitive value. A passing run means the
14
+ > **Status: 0.12.0 (alpha).** No detector finds every sensitive value. A passing run means the
15
15
  > configured processing completed under the selected policy; it is **not** proof that the output
16
16
  > contains no sensitive information, and it is not a compliance (GDPR, HIPAA, PCI DSS, ...) or
17
17
  > anonymization guarantee. Policy templates must be reviewed before use.
@@ -248,10 +248,14 @@ ciphergate inspect logs/ --policy policies/developer_logs.yml --environment dev
248
248
  ciphergate sanitize logs/ --policy ... --output build/logs.sanitized --apply
249
249
  ```
250
250
 
251
- - Files are chosen by extension (`.csv`, `.json`, `.jsonl`, `.ndjson`, `.txt`, `.log`), or all
252
- regular files when `--format` is given. Subdirectories are processed only with
251
+ - Files are chosen by extension (`.csv`, `.json`, `.jsonl`, `.ndjson`, `.txt`, `.log`, `.out`,
252
+ `.err`), or all regular files when `--format` is given. Subdirectories are processed only with
253
253
  `--recursive`/`-r` (at most 32 levels). Symlinks, junctions, devices, FIFOs and other
254
254
  extensions are **skipped and listed** in the report with a reason; links are never followed.
255
+ Skipped extensions also produce a warning, because those files were never scanned; a strict
256
+ `rescan` fails (exit 7) when it skipped any, and `sanitize` leaves them out of the output.
257
+ `--format` on a directory applies to every file, and the run warns when that contradicts an
258
+ extension (for example a CSV read as text loses its column names).
255
259
  At most 1000 files by default (`--max-files N`); more fails the run (exit 4) before anything
256
260
  is processed. An empty selection fails with `batch_empty` (exit 4).
257
261
  - Each file is processed exactly like a single-file run, in sorted path order; a failing file
@@ -150,6 +150,19 @@ actions/params are **rejected** (`policy_conflict`, naming both rule IDs).
150
150
  Several findings on one value combine: `drop_record` > `drop_field` > whole-value transform
151
151
  (most protective by rank) > span transforms (overlaps merged, most protective wins).
152
152
 
153
+ ## Hard rules and guardrails
154
+
155
+ `hard: true` puts a rule at level 2, above guardrails, field rules and everything else except the
156
+ engine's own constraints. Use it for "never, whatever other rules say", such as blocking
157
+ credentials for untrusted destinations. A hard rule cannot `allow`.
158
+
159
+ `guardrail: true` puts a rule at level 3. It must name `environments` and either
160
+ `target_environments` or `destinations`, so it only fires for a specific environment-to-recipient
161
+ move (for example PRD to `external_ai`). A guardrail cannot `allow`, and a rule cannot be both.
162
+
163
+ A blocked run prints `next:` lines in the terminal summary with what to change, based on the
164
+ reason codes. They never include input values.
165
+
153
166
  ## Transfers
154
167
 
155
168
  ```yaml
@@ -24,7 +24,7 @@ Supported public API (everything else is internal and may change without notice)
24
24
  rescan_directory(root, policy, context=None, *, recursive=False, strict=None) -> BatchReport
25
25
  """
26
26
 
27
- __version__ = "0.11.0"
27
+ __version__ = "0.12.0"
28
28
 
29
29
  from ciphergate.config import RuntimeSettings
30
30
  from ciphergate.constants import (
@@ -96,6 +96,9 @@ class BatchReport:
96
96
  skipped: list[dict[str, str]]
97
97
  output: dict[str, Any] = field(default_factory=dict)
98
98
  warnings: list[str] = field(default_factory=list)
99
+ # Exit code when every processed file passed but the run still cannot count as
100
+ # complete, e.g. a strict rescan that skipped files it could not read.
101
+ incomplete_exit: int = 0
99
102
  run_id: str = field(default_factory=new_run_id)
100
103
  timestamp: str = field(default_factory=utc_now)
101
104
 
@@ -104,7 +107,7 @@ class BatchReport:
104
107
  for result in self.files:
105
108
  if result.exit_code != 0:
106
109
  return result.exit_code
107
- return 0
110
+ return self.incomplete_exit
108
111
 
109
112
  @property
110
113
  def status(self) -> str:
@@ -277,6 +280,34 @@ def _run_each(
277
280
  return results
278
281
 
279
282
 
283
+ def coverage_warnings(
284
+ discovery: Discovery, fmt: InputFormat | None, *, sanitize: bool = False
285
+ ) -> list[str]:
286
+ """Say which files were not scanned, or were read as a format their extension contradicts."""
287
+ warnings: list[str] = []
288
+ unknown = sum(1 for _, reason in discovery.skipped if reason == "unsupported_extension")
289
+ if unknown:
290
+ warnings.append(
291
+ f"{unknown} file(s) were skipped and not scanned because their extension is not "
292
+ "recognised"
293
+ + ("; they are not in the output" if sanitize else "")
294
+ + "; rename them, or pass --format to read every file as one format"
295
+ )
296
+ if fmt is not None:
297
+ mismatched = sum(
298
+ 1
299
+ for rel in discovery.files
300
+ if EXTENSION_FORMATS.get(rel.suffix.lower()) not in (None, fmt)
301
+ )
302
+ if mismatched:
303
+ warnings.append(
304
+ f"--format {fmt} was applied to {mismatched} file(s) whose extension means "
305
+ "another format; structure such as CSV columns or JSON keys may be lost. "
306
+ "Run those files separately without --format"
307
+ )
308
+ return warnings
309
+
310
+
280
311
  def _require_files(discovery: Discovery) -> None:
281
312
  if not discovery.files:
282
313
  raise InputFormatError(
@@ -312,6 +343,8 @@ def analyze_directory(
312
343
  return rescan_file(path, policy, context, strict=strict, runtime=runtime, fmt=fmt)
313
344
 
314
345
  mode = runtime.path_display
346
+ effective_strict = policy.document.settings.strict if strict is None else strict
347
+ unscanned = any(reason == "unsupported_extension" for _, reason in discovery.skipped)
315
348
  return BatchReport(
316
349
  command=command,
317
350
  input_display=display_relative(Path(root.name), 0, root.parent, mode)
@@ -320,6 +353,11 @@ def analyze_directory(
320
353
  recursive=recursive,
321
354
  files=_run_each(discovery, mode, run),
322
355
  skipped=_skipped(discovery, mode),
356
+ warnings=coverage_warnings(discovery, fmt),
357
+ # A strict rescan is a gate: files it could not read must not count as passed.
358
+ incomplete_exit=int(ExitCode.RESCAN_FAILED)
359
+ if command == "rescan" and effective_strict and unscanned
360
+ else 0,
323
361
  )
324
362
 
325
363
 
@@ -472,6 +510,7 @@ def sanitize_directory(
472
510
  results,
473
511
  _skipped(discovery, mode),
474
512
  output={"written": False, "path": shown_out, "withheld_reason": "apply_not_set"},
513
+ warnings=coverage_warnings(discovery, options.format, sanitize=True),
475
514
  )
476
515
 
477
516
  parent = output.parent if str(output.parent) else Path(".")
@@ -491,7 +530,14 @@ def sanitize_directory(
491
530
  mode,
492
531
  lambda path, rel: sanitize_file(path, staging / rel, policy, context, file_options),
493
532
  )
494
- report = BatchReport("sanitize", shown_root, recursive, results, _skipped(discovery, mode))
533
+ report = BatchReport(
534
+ "sanitize",
535
+ shown_root,
536
+ recursive,
537
+ results,
538
+ _skipped(discovery, mode),
539
+ warnings=coverage_warnings(discovery, options.format, sanitize=True),
540
+ )
495
541
  for result in results:
496
542
  if result.report is not None:
497
543
  result.report.output["path"] = f"{shown_out}/{result.display}"
@@ -33,6 +33,8 @@ EXTENSION_FORMATS: dict[str, InputFormat] = {
33
33
  ".ndjson": InputFormat.JSONL,
34
34
  ".txt": InputFormat.TEXT,
35
35
  ".log": InputFormat.TEXT,
36
+ ".out": InputFormat.TEXT,
37
+ ".err": InputFormat.TEXT,
36
38
  }
37
39
 
38
40
  _JSON_PREFIXES = (
@@ -0,0 +1,106 @@
1
+ """Short next-step hints for runs that did not complete. Built from reason codes only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ciphergate.constants import ReasonCode
8
+
9
+ _HINTS: dict[str, str] = {
10
+ ReasonCode.CREDENTIAL_DETECTED_HARD_BLOCK: (
11
+ "a credential was found: remove it from the input and rotate it if it is real; "
12
+ "no policy can let credentials through"
13
+ ),
14
+ ReasonCode.EXPLICIT_HARD_BLOCK: (
15
+ "a hard rule blocks these values for this destination: remove them from the input, or "
16
+ "use a policy written for a destination allowed to receive them; "
17
+ "'ciphergate explain' shows which rule wins"
18
+ ),
19
+ ReasonCode.ENVIRONMENT_GUARDRAIL: (
20
+ "a guardrail rule blocks this environment-to-destination move; check the context, or "
21
+ "change the guardrail in the policy after review"
22
+ ),
23
+ ReasonCode.PRD_TO_NONPRODUCTION_BLOCKED: (
24
+ "production data cannot go to a non-production target without a reviewed 'transfers' "
25
+ "approval in the policy (docs/policy-schema.md#transfers)"
26
+ ),
27
+ ReasonCode.NO_APPLICABLE_RULE_REQUIRES_REVIEW: (
28
+ "no rule covers some findings: add rules for the categories listed above, then run "
29
+ "'ciphergate plan' again"
30
+ ),
31
+ ReasonCode.NO_APPLICABLE_RULE_BLOCKS: (
32
+ "no rule covers some findings: add rules for the categories listed above, then run "
33
+ "'ciphergate plan' again"
34
+ ),
35
+ ReasonCode.UNKNOWN_CONTEXT_REQUIRES_REVIEW: (
36
+ "the environment or destination is 'unknown': pass the real values with --environment "
37
+ "and --destination"
38
+ ),
39
+ ReasonCode.HIGH_RISK_ALLOW_REQUIRES_ACKNOWLEDGEMENT: (
40
+ "an allow rule reaches high-risk data toward an untrusted destination: narrow the rule, "
41
+ "or set acknowledge_risk: true after review"
42
+ ),
43
+ ReasonCode.UNSUPPORTED_TRANSFORM: (
44
+ "a rule's action cannot run on this format or location: choose another action for it"
45
+ ),
46
+ ReasonCode.ENVIRONMENT_REQUIRED_BUT_MISSING: (
47
+ "pass --environment (dev, stg, prd) or pin context.environment in the policy"
48
+ ),
49
+ ReasonCode.DESTINATION_REQUIRED_BUT_MISSING: (
50
+ "pass --destination (for example local_dev or external_ai) or pin context.destination "
51
+ "in the policy"
52
+ ),
53
+ ReasonCode.TARGET_ENVIRONMENT_REQUIRED_BUT_MISSING: (
54
+ "pass --target-environment or pin context.target_environment in the policy"
55
+ ),
56
+ ReasonCode.ENVIRONMENT_CONTEXT_CONFLICT: (
57
+ "a CLI flag contradicts the policy's pinned context: drop the flag, or use a policy for "
58
+ "that context"
59
+ ),
60
+ ReasonCode.UNSUPPORTED_ENVIRONMENT: "use one of the documented environment values",
61
+ ReasonCode.STRICT_POLICY_REQUIRED: (
62
+ "this destination needs a strict policy: set settings.strict: true"
63
+ ),
64
+ ReasonCode.RESCAN_REQUIRED: (
65
+ "this destination needs the rescan gate: do not disable settings.rescan in the policy"
66
+ ),
67
+ ReasonCode.CREDENTIAL_DETECTION_REQUIRED: (
68
+ "this destination needs credential detection: remove secret_pattern and field_name from "
69
+ "detectors.disabled"
70
+ ),
71
+ ReasonCode.KEY_SCAN_REQUIRED: (
72
+ "this destination needs JSON key scanning: set settings.scan_json_keys: true"
73
+ ),
74
+ ReasonCode.RESIDUAL_FINDING: (
75
+ "the rescan still found gating categories in the output: add rules for them, then run again"
76
+ ),
77
+ }
78
+
79
+ _KEY_HINT = (
80
+ "set {env} to at least 32 random bytes in your environment (never on the command line); "
81
+ "see 'Pseudonymization key' in the README"
82
+ )
83
+
84
+
85
+ def next_steps(report: dict[str, Any]) -> list[str]:
86
+ """Hints for a run that ended with a non-zero exit code; empty otherwise."""
87
+ if not report.get("exit_code"):
88
+ return []
89
+ codes = [gate["reason_code"] for gate in report.get("gates", [])]
90
+ if report.get("command") != "rescan":
91
+ # A rescan's policy decisions describe the output, not what to fix in the input.
92
+ codes += list(report.get("summary", {}).get("by_reason_code", {}))
93
+ rescan = report.get("rescan")
94
+ if rescan and rescan.get("failed"):
95
+ codes.append(ReasonCode.RESIDUAL_FINDING)
96
+ hints: list[str] = []
97
+ for code in codes:
98
+ hint = _HINTS.get(code)
99
+ if hint and hint not in hints:
100
+ hints.append(hint)
101
+ for key in report.get("keys", []):
102
+ if key.get("status") != "available":
103
+ hint = _KEY_HINT.format(env=key["key_env"])
104
+ if hint not in hints:
105
+ hints.append(hint)
106
+ return hints
@@ -7,6 +7,7 @@ from typing import Any
7
7
  from ciphergate.constants import DISCLAIMER, RESCAN_LIMIT
8
8
  from ciphergate.models.report import RunReport
9
9
  from ciphergate.pipeline.batch import BatchReport
10
+ from ciphergate.reporting.next_steps import next_steps
10
11
  from ciphergate.reporting.safe_text import escape_control
11
12
 
12
13
  SUMMARY_ITEMS = 15
@@ -119,6 +120,8 @@ def render_summary(report: RunReport, *, verbose: bool = False) -> str:
119
120
  lines.append(f" output: not written ({out['withheld_reason']})")
120
121
  for warning in d["warnings"]:
121
122
  lines.append(f" warning: {escape_control(warning)}")
123
+ for hint in next_steps(d):
124
+ lines.append(f" next: {hint}")
122
125
  lines.append(f" note: {DISCLAIMER}")
123
126
  return "\n".join(lines) + "\n"
124
127
 
@@ -158,6 +161,13 @@ def render_batch_summary(report: BatchReport, *, verbose: bool = False) -> str:
158
161
  lines.append(f" output: not written ({out.get('withheld_reason')})")
159
162
  for warning in d["warnings"]:
160
163
  lines.append(f" warning: {escape_control(warning)}")
164
+ hints: list[str] = []
165
+ for entry in d["files"]:
166
+ for hint in next_steps(entry.get("report") or {}):
167
+ if hint not in hints:
168
+ hints.append(hint)
169
+ for hint in hints:
170
+ lines.append(f" next: {hint}")
161
171
  lines.append(" note: per-file details are in the JSON report (--report).")
162
172
  lines.append(f" note: {DISCLAIMER}")
163
173
  return "\n".join(lines) + "\n"
@@ -354,3 +354,54 @@ def test_entry_replaced_after_discovery_is_not_processed(
354
354
  )
355
355
  assert calls == []
356
356
  assert results[0].error_code == "input_changed_type" and results[0].exit_code != 0
357
+
358
+
359
+ # --------------------------------------------------------------------------- coverage
360
+
361
+
362
+ def test_out_and_err_files_are_read_as_text(tmp_path: Path) -> None:
363
+ root = tmp_path / "in"
364
+ root.mkdir()
365
+ (root / "app.out").write_text("started\n", encoding="utf-8")
366
+ (root / "app.err").write_text("failed\n", encoding="utf-8")
367
+ d = discover(root, recursive=False, fmt=None, max_files=10)
368
+ assert [p.as_posix() for p in d.files] == ["app.err", "app.out"] and not d.skipped
369
+
370
+
371
+ def test_skipped_files_are_a_warning_and_fail_a_strict_rescan(tmp_path: Path, env) -> None:
372
+ root = tmp_path / "in"
373
+ root.mkdir()
374
+ (root / "clean.log").write_text("service started\n", encoding="utf-8")
375
+ (root / "notes.md").write_text("not scanned\n", encoding="utf-8")
376
+ r = invoke("inspect", root, *PRD_EXTERNAL, env=env)
377
+ assert r.exit_code == 0 and "1 file(s) were skipped and not scanned" in r.stdout
378
+ strict = invoke("rescan", root, *PRD_EXTERNAL, env=env)
379
+ assert strict.exit_code == 7 and "not scanned" in strict.stdout
380
+ assert invoke("rescan", root, *PRD_EXTERNAL, "--no-strict", env=env).exit_code == 0
381
+
382
+
383
+ def test_sanitize_warns_that_skipped_files_are_not_in_the_output(tmp_path: Path, env) -> None:
384
+ root = tmp_path / "in"
385
+ root.mkdir()
386
+ (root / "clean.log").write_text("service started\n", encoding="utf-8")
387
+ (root / "notes.md").write_text("not scanned\n", encoding="utf-8")
388
+ out = tmp_path / "out"
389
+ r = invoke("sanitize", root, *PRD_EXTERNAL, "-o", out, "--apply", env=env)
390
+ assert r.exit_code == 0 and "they are not in the output" in r.stdout
391
+ assert sorted(p.name for p in out.iterdir()) == ["clean.log"]
392
+
393
+
394
+ def test_forced_format_that_contradicts_an_extension_warns(tmp_path: Path, env) -> None:
395
+ root = _tree(tmp_path)
396
+ report = tmp_path / "report.json"
397
+ r = invoke("inspect", root, "--format", "text", *PRD_EXTERNAL, "--report", report, env=env)
398
+ assert "--format text was applied to 1 file(s)" in r.stdout
399
+ assert any("--format text" in w for w in _report(report)["warnings"])
400
+
401
+
402
+ def test_directory_summary_carries_next_steps(tmp_path: Path) -> None:
403
+ root = tmp_path / "in"
404
+ root.mkdir()
405
+ (root / "app.log").write_text(SECRET_LINE, encoding="utf-8")
406
+ r = invoke("inspect", root, "--policy", POLICIES / "developer_logs.yml")
407
+ assert r.exit_code == 1 and "next: pass --environment" in r.stdout
@@ -197,6 +197,35 @@ def test_sanitize_missing_key_exit_6(tmp_path: Path, no_key_env: None) -> None:
197
197
  assert r.exit_code == 6 and not out.exists() and "missing" in r.stdout
198
198
 
199
199
 
200
+ def test_blocked_run_says_what_to_do_next(tmp_path: Path) -> None:
201
+ src = tmp_path / "raw.csv"
202
+ shutil.copy(EXAMPLES / "sample.csv", src)
203
+ out = tmp_path / "out.csv"
204
+ r = invoke("sanitize", src, "--policy", POLICIES / "developer_logs.yml", "-o", out, "--apply")
205
+ assert r.exit_code == 1 and not out.exists()
206
+ assert "next: pass --environment" in r.stdout and "next: pass --destination" in r.stdout
207
+
208
+
209
+ def test_successful_run_has_no_next_steps(tmp_path: Path) -> None:
210
+ clean = tmp_path / "clean.log"
211
+ clean.write_text("service started\n", encoding="utf-8")
212
+ out = tmp_path / "out.log"
213
+ r = invoke(
214
+ "sanitize",
215
+ clean,
216
+ "--policy",
217
+ POLICIES / "developer_logs.yml",
218
+ "--environment",
219
+ "dev",
220
+ "--destination",
221
+ "local_dev",
222
+ "-o",
223
+ out,
224
+ "--apply",
225
+ )
226
+ assert r.exit_code == 0 and "next:" not in r.stdout
227
+
228
+
200
229
  def test_rescan_summary_never_says_a_bare_pass(tmp_path: Path) -> None:
201
230
  policy = POLICIES / "prd_external_ai.yml"
202
231
  clean = tmp_path / "clean.log"
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ciphergate.constants import ReasonCode
6
+ from ciphergate.reporting.next_steps import _HINTS, next_steps
7
+
8
+
9
+ def report(**extra: Any) -> dict[str, Any]:
10
+ base: dict[str, Any] = {
11
+ "command": "sanitize",
12
+ "exit_code": 1,
13
+ "gates": [],
14
+ "summary": {"by_reason_code": {}},
15
+ "keys": [],
16
+ "rescan": None,
17
+ }
18
+ base.update(extra)
19
+ return base
20
+
21
+
22
+ def test_successful_run_has_no_hints() -> None:
23
+ blocked = {"by_reason_code": {ReasonCode.EXPLICIT_HARD_BLOCK: 1}}
24
+ assert next_steps(report(exit_code=0, summary=blocked)) == []
25
+
26
+
27
+ def test_gates_decisions_keys_and_rescan_each_give_a_hint() -> None:
28
+ hints = next_steps(
29
+ report(
30
+ gates=[{"reason_code": ReasonCode.DESTINATION_REQUIRED_BUT_MISSING}],
31
+ summary={"by_reason_code": {ReasonCode.CREDENTIAL_DETECTED_HARD_BLOCK: 2}},
32
+ keys=[{"key_env": "CIPHERGATE_HMAC_KEY", "status": "missing"}],
33
+ rescan={"failed": True},
34
+ )
35
+ )
36
+ assert len(hints) == 4
37
+ assert "--destination" in hints[0] and "rotate" in hints[1]
38
+ assert "rescan" in hints[2] and "CIPHERGATE_HMAC_KEY" in hints[3]
39
+
40
+
41
+ def test_duplicate_hints_are_shown_once() -> None:
42
+ summary = {
43
+ "by_reason_code": {
44
+ ReasonCode.NO_APPLICABLE_RULE_REQUIRES_REVIEW: 1,
45
+ ReasonCode.NO_APPLICABLE_RULE_BLOCKS: 1,
46
+ }
47
+ }
48
+ assert len(next_steps(report(summary=summary))) == 1
49
+
50
+
51
+ def test_rescan_hints_ignore_the_policy_decisions() -> None:
52
+ summary = {"by_reason_code": {ReasonCode.EXPLICIT_HARD_BLOCK: 1}}
53
+ hints = next_steps(
54
+ report(command="rescan", exit_code=7, summary=summary, rescan={"failed": True})
55
+ )
56
+ assert hints == [_HINTS[ReasonCode.RESIDUAL_FINDING]]
57
+
58
+
59
+ def test_hints_are_fixed_text() -> None:
60
+ # Hints come from a fixed table, so they can never carry input values.
61
+ unknown = {"by_reason_code": {"explicit_policy_match": 3, "not_a_code": 1}}
62
+ assert next_steps(report(summary=unknown)) == []
File without changes
File without changes