plainmarker 0.49.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 (81) hide show
  1. plainmarker-0.49.0/LICENSE +21 -0
  2. plainmarker-0.49.0/PKG-INFO +147 -0
  3. plainmarker-0.49.0/README.md +132 -0
  4. plainmarker-0.49.0/keeper_core/__init__.py +12 -0
  5. plainmarker-0.49.0/keeper_core/__main__.py +11 -0
  6. plainmarker-0.49.0/keeper_core/accept_baseline.py +327 -0
  7. plainmarker-0.49.0/keeper_core/auditor.py +565 -0
  8. plainmarker-0.49.0/keeper_core/baseline.py +125 -0
  9. plainmarker-0.49.0/keeper_core/calibrate.py +150 -0
  10. plainmarker-0.49.0/keeper_core/calibrate_auditor.py +320 -0
  11. plainmarker-0.49.0/keeper_core/checks.py +995 -0
  12. plainmarker-0.49.0/keeper_core/cli.py +267 -0
  13. plainmarker-0.49.0/keeper_core/config.py +188 -0
  14. plainmarker-0.49.0/keeper_core/doctor.py +80 -0
  15. plainmarker-0.49.0/keeper_core/interrogation.py +210 -0
  16. plainmarker-0.49.0/keeper_core/models.py +155 -0
  17. plainmarker-0.49.0/keeper_core/onboarding/__init__.py +6 -0
  18. plainmarker-0.49.0/keeper_core/onboarding/builtin_provider.py +311 -0
  19. plainmarker-0.49.0/keeper_core/onboarding/onboard.py +136 -0
  20. plainmarker-0.49.0/keeper_core/onboarding/provider.py +58 -0
  21. plainmarker-0.49.0/keeper_core/onboarding/risk.py +61 -0
  22. plainmarker-0.49.0/keeper_core/onboarding/summary.py +52 -0
  23. plainmarker-0.49.0/keeper_core/ranking.py +144 -0
  24. plainmarker-0.49.0/keeper_core/redact.py +66 -0
  25. plainmarker-0.49.0/keeper_core/report.py +704 -0
  26. plainmarker-0.49.0/keeper_core/sast_rules/javascript.yaml +43 -0
  27. plainmarker-0.49.0/keeper_core/sast_rules/python.yaml +71 -0
  28. plainmarker-0.49.0/keeper_core/seatbelt.py +270 -0
  29. plainmarker-0.49.0/keeper_core/session_verify.py +975 -0
  30. plainmarker-0.49.0/keeper_core/shell_audit.py +1058 -0
  31. plainmarker-0.49.0/keeper_core/templates/keeper/config.local-ollama.yaml +36 -0
  32. plainmarker-0.49.0/keeper_core/templates/keeper/config.openrouter-deepseek.yaml +55 -0
  33. plainmarker-0.49.0/keeper_core/templates/keeper/config.openrouter-free.yaml +58 -0
  34. plainmarker-0.49.0/keeper_core/templates/keeper/config.yaml +30 -0
  35. plainmarker-0.49.0/keeper_core/templates/keeper/decision-ledger.md +24 -0
  36. plainmarker-0.49.0/keeper_core/templates/keeper/failure-library.md +18 -0
  37. plainmarker-0.49.0/keeper_core/templates/keeper/hard-truths.yaml +18 -0
  38. plainmarker-0.49.0/keeper_core/templates/keeper/project-state.md +28 -0
  39. plainmarker-0.49.0/keeper_core/witness.py +128 -0
  40. plainmarker-0.49.0/keeper_core/witness_store.py +360 -0
  41. plainmarker-0.49.0/plainmarker.egg-info/PKG-INFO +147 -0
  42. plainmarker-0.49.0/plainmarker.egg-info/SOURCES.txt +79 -0
  43. plainmarker-0.49.0/plainmarker.egg-info/dependency_links.txt +1 -0
  44. plainmarker-0.49.0/plainmarker.egg-info/entry_points.txt +2 -0
  45. plainmarker-0.49.0/plainmarker.egg-info/requires.txt +3 -0
  46. plainmarker-0.49.0/plainmarker.egg-info/top_level.txt +1 -0
  47. plainmarker-0.49.0/pyproject.toml +27 -0
  48. plainmarker-0.49.0/setup.cfg +4 -0
  49. plainmarker-0.49.0/tests/test_accept_baseline.py +164 -0
  50. plainmarker-0.49.0/tests/test_acknowledge.py +298 -0
  51. plainmarker-0.49.0/tests/test_alembic_revision_demotion.py +156 -0
  52. plainmarker-0.49.0/tests/test_auditor.py +565 -0
  53. plainmarker-0.49.0/tests/test_baseline.py +146 -0
  54. plainmarker-0.49.0/tests/test_calibrate.py +157 -0
  55. plainmarker-0.49.0/tests/test_calibrate_auditor.py +382 -0
  56. plainmarker-0.49.0/tests/test_check.py +149 -0
  57. plainmarker-0.49.0/tests/test_checks.py +484 -0
  58. plainmarker-0.49.0/tests/test_cli.py +62 -0
  59. plainmarker-0.49.0/tests/test_config.py +171 -0
  60. plainmarker-0.49.0/tests/test_deps_osv.py +326 -0
  61. plainmarker-0.49.0/tests/test_doctor.py +100 -0
  62. plainmarker-0.49.0/tests/test_egress.py +87 -0
  63. plainmarker-0.49.0/tests/test_egress_scope.py +146 -0
  64. plainmarker-0.49.0/tests/test_exit_contract.py +45 -0
  65. plainmarker-0.49.0/tests/test_history.py +122 -0
  66. plainmarker-0.49.0/tests/test_interrogate_config.py +78 -0
  67. plainmarker-0.49.0/tests/test_interrogation.py +108 -0
  68. plainmarker-0.49.0/tests/test_keeperignore.py +222 -0
  69. plainmarker-0.49.0/tests/test_onboarding.py +196 -0
  70. plainmarker-0.49.0/tests/test_precision_corpus.py +64 -0
  71. plainmarker-0.49.0/tests/test_ranking.py +189 -0
  72. plainmarker-0.49.0/tests/test_redact.py +121 -0
  73. plainmarker-0.49.0/tests/test_report.py +640 -0
  74. plainmarker-0.49.0/tests/test_seatbelt.py +262 -0
  75. plainmarker-0.49.0/tests/test_session_verify.py +568 -0
  76. plainmarker-0.49.0/tests/test_session_verify_failclosed.py +156 -0
  77. plainmarker-0.49.0/tests/test_shell_audit.py +913 -0
  78. plainmarker-0.49.0/tests/test_smoke_cli.py +202 -0
  79. plainmarker-0.49.0/tests/test_witness.py +173 -0
  80. plainmarker-0.49.0/tests/test_witness_gate.py +128 -0
  81. plainmarker-0.49.0/tests/test_witness_store.py +464 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodny Fernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: plainmarker
3
+ Version: 0.49.0
4
+ Summary: A fail-closed, fully-local, no-account in-loop verifier for AI-written code: deterministic checks the instant your agent writes, and it never claims 'safe'.
5
+ Author: Rodny Fernandez
6
+ License: MIT
7
+ Keywords: security,secret-scanning,ai-code,claude-code,in-loop,verification,ground-truth
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pyyaml>=6
12
+ Requires-Dist: pathspec>=0.12
13
+ Requires-Dist: cryptography>=49.0.0
14
+ Dynamic: license-file
15
+
16
+ # plainmarker
17
+
18
+ **A fail-closed second opinion on the code your AI agent just wrote — deterministic, in-loop checks that need no model and no account and never send your code off your machine.**
19
+
20
+ plainmarker is an in-loop verifier for developers running AI coding agents (Claude Code, Cursor, Codex).
21
+ The moment the agent edits a file, plainmarker reads what the code, tests, and scans *actually do*, not
22
+ what the agent claims, and tells you in plain language what's dangerous right now: a hardcoded secret,
23
+ a `curl | sh` install hook. These are **deterministic checks** — detect-secrets, a
24
+ vendored offline Semgrep ruleset, and a shell-command auditor — not the writer agent's say-so, so they
25
+ can't just agree with it, and they run entirely on your machine. (`plainmarker check` additionally screens
26
+ your dependencies against the OSV advisory database, sending package names — never your code. plainmarker's
27
+ deeper, opt-in `plainmarker audit` adds an independent reviewer on a *different* AI model family and discloses
28
+ what it sends; the in-loop seatbelt runs no model.) It never calls your code
29
+ "safe" — only which checks passed, and that this is not a guarantee — and when a check can't finish, it
30
+ says so (fail-closed) instead of pretending all-clear. It also remembers *why* each decision was made
31
+ and re-checks it every session, so trust compounds instead of resetting.
32
+
33
+ You don't have to read the diff to know what your agent just did to your project.
34
+
35
+ > **Status: in-loop wedge, v0.49.0.** The in-loop `plainmarker seatbelt` + an actionable verdict + a
36
+ > one-line install are the current focus. Running the target's tests in a sandbox and the agent-facing
37
+ > API are deferred to later milestones (see `docs/ROADMAP.md`).
38
+
39
+ ## The one rule
40
+
41
+ **Ground truth only.** plainmarker never reports what the AI *says* it did. It reports
42
+ what the code, the tests, the scans, and the live system *actually are*. It never
43
+ claims a project is "safe," only which specific checks passed, and that this is not a
44
+ guarantee.
45
+
46
+ ## Privacy
47
+
48
+ Local-capable, and **honest about egress.** The independent Auditor uses a cloud model by
49
+ default, so whenever it runs — and when onboarding writes its plain-English summary — plainmarker tells
50
+ you, in plain language, that your code or project facts left your machine. Turn on `local_only` with
51
+ a local Auditor model and nothing leaves your computer. No telemetry, ever.
52
+
53
+ ### Choosing the Auditor model
54
+
55
+ Three ready-to-use Auditor configs ship under `keeper_core/templates/keeper/` — point at one with
56
+ `plainmarker audit <path> --config <file>`:
57
+
58
+ - **Default — NVIDIA-served DeepSeek** (no config file needed): a capable cloud model; set `NVIDIA_API_KEY`.
59
+ - **Free — OpenRouter** (`config.openrouter-free.yaml`): run the Auditor at no cost on a free,
60
+ different-family model; set `OPENROUTER_API_KEY`. Egress is disclosed like any cloud model. ⚠️ Free models
61
+ may **log/train on inputs**, so **if you are not 100% sure your project is free of private/customer data,
62
+ use the local option below instead.** They are also **rate-limited**: a throttle shows up honestly as
63
+ "could not determine" (never a fake pass), so a first run that says "the reviewer did not run" is usually a
64
+ rate-limit — run `plainmarker doctor` for the exact reason, then retry / switch model / go local.
65
+ - **Local / private — Ollama** (`config.local-ollama.yaml`): zero egress, unlimited; `local_only: true`
66
+ refuses any remote endpoint. Best for private code, and the only option with a benchmarked model
67
+ (`qwen2.5-coder:7b`, validated in DECISIONS D-022).
68
+
69
+ ## Architecture (Core + adapters)
70
+
71
+ - **`keeper_core/`** — the standalone engine. All real logic. Knows nothing about
72
+ Claude Code and runs on its own.
73
+ - **`adapters/`** — thin wrappers that let a host use Core. The Claude Code plugin is
74
+ the first; a standalone CLI/service and others follow. Adapters contain no logic of
75
+ their own.
76
+
77
+ See `docs/ARCHITECTURE.md` for the full plain-language map of the parts, and
78
+ `docs/INDEX.md` for where everything lives.
79
+
80
+ ## Install
81
+
82
+ ```
83
+ uvx plainmarker check .
84
+ ```
85
+
86
+ That's the whole install: [`uvx`](https://docs.astral.sh/uv/) fetches a compatible Python and runs
87
+ `plainmarker` — no clone, no global setup. For a command that stays around, `uv tool install plainmarker`
88
+ (or `pipx install plainmarker`), then just `plainmarker <command>`.
89
+
90
+ **For the in-loop catch** — run `plainmarker seatbelt .` right after an agent edits and it tells you
91
+ what's dangerous *now* plus a paste-ready fix to hand back to the agent. Firing it *automatically* the
92
+ moment your agent writes a file means adding a Claude Code PostToolUse hook that calls `plainmarker
93
+ seatbelt` yourself — that wiring is manual today (a packaged hook is on the roadmap, see
94
+ `docs/ROADMAP.md`), and `plainmarker` never edits your `~/.claude` config for you. That hook only fires
95
+ in repos you opt into the watch with `mkdir -p .keeper && touch .keeper/.watch`.
96
+
97
+ **For the opt-in `plainmarker audit`** (the independent, different-family model reviewer) add one API key
98
+ for the Auditor model — read from an environment variable, never stored in the repo (see *Choosing the
99
+ Auditor model* above).
100
+
101
+ ## What you can do today
102
+
103
+ Point `plainmarker` at any project (each writes its receipts under that project's `.keeper/` folder):
104
+
105
+ | Command | What it does |
106
+ |---|---|
107
+ | `plainmarker doctor` | check your setup and that the two models are reachable |
108
+ | `plainmarker onboard <path>` | map a project (files, stack, dependencies, risk areas) + a plain summary |
109
+ | `plainmarker seatbelt <path>` | **in-loop:** after your agent edits a file, what's dangerous NOW + a paste-ready fix to hand back to the agent |
110
+ | `plainmarker check <path>` | what changed since plainmarker last looked, what's dangerous now, and what it did NOT check |
111
+ | `plainmarker baseline <path>` | read-only hard checks: exposed secrets, vulnerable dependencies, code-vulnerability patterns |
112
+ | `plainmarker audit <path>` | the independent different-family reviewer over ground truth (facts vs concerns) |
113
+ | `plainmarker interrogate <path>` | a few plain questions; flags where the code disagrees with your intent |
114
+ | `plainmarker report <path>` | one plain-language report: what's true, broken, unsure, and to decide |
115
+ | `plainmarker calibrate` | prove it catches planted flaws and passes clean projects (and see its blind spots) |
116
+
117
+ plainmarker never claims a project is "safe" — only which specific checks passed, and that this is not a
118
+ guarantee.
119
+
120
+ ## Exit codes (for CI, pre-commit, and agent hooks)
121
+
122
+ A script can gate on plainmarker's exit code. There are two shapes, both fail-closed in the way that matters:
123
+ **a real danger never exits `0`.**
124
+
125
+ | Code | Meaning |
126
+ |---|---|
127
+ | `0` | Nothing to act on (see per-verb note below). |
128
+ | `1` | **STOP** — something needs a human. |
129
+ | `2` | Usage error (bad arguments; standard argparse). Not a security signal. |
130
+
131
+ - **`plainmarker seatbelt`** and **`plainmarker baseline`** are **fail-closed gates**: exit `0` only when nothing
132
+ dangerous was found **and** every check actually ran. A danger, *or* a check that could not finish
133
+ (timeout / offline / missing tool), *or* a crash → `1`. There is deliberately **no exit code meaning
134
+ "could not determine"** — doubt collapses to `1`, never `0`, so a hook can treat any non-zero as
135
+ "don't trust this yet." (`baseline` is strict: with no network for the dependency check it returns
136
+ `1`.)
137
+ - **`plainmarker check`** is the change **narrator**: it exits `1` only when a real danger is present *right
138
+ now*, and `0` otherwise. A gracefully-undetermined (`unknown`) check — a scanner that timed out or
139
+ is offline — is disclosed in the report text and in `--json` as `fail_closed` / `could_not_check`,
140
+ **not** in the exit code (a check that outright *crashes* still exits `1`). If you want
141
+ fail-closed-on-unknown gating from `check`, read `--json` `fail_closed`, not the exit code (or gate
142
+ with `seatbelt`).
143
+ - **`plainmarker audit`** additionally needs the Auditor model reachable.
144
+
145
+ ## License
146
+
147
+ [MIT](./LICENSE).
@@ -0,0 +1,132 @@
1
+ # plainmarker
2
+
3
+ **A fail-closed second opinion on the code your AI agent just wrote — deterministic, in-loop checks that need no model and no account and never send your code off your machine.**
4
+
5
+ plainmarker is an in-loop verifier for developers running AI coding agents (Claude Code, Cursor, Codex).
6
+ The moment the agent edits a file, plainmarker reads what the code, tests, and scans *actually do*, not
7
+ what the agent claims, and tells you in plain language what's dangerous right now: a hardcoded secret,
8
+ a `curl | sh` install hook. These are **deterministic checks** — detect-secrets, a
9
+ vendored offline Semgrep ruleset, and a shell-command auditor — not the writer agent's say-so, so they
10
+ can't just agree with it, and they run entirely on your machine. (`plainmarker check` additionally screens
11
+ your dependencies against the OSV advisory database, sending package names — never your code. plainmarker's
12
+ deeper, opt-in `plainmarker audit` adds an independent reviewer on a *different* AI model family and discloses
13
+ what it sends; the in-loop seatbelt runs no model.) It never calls your code
14
+ "safe" — only which checks passed, and that this is not a guarantee — and when a check can't finish, it
15
+ says so (fail-closed) instead of pretending all-clear. It also remembers *why* each decision was made
16
+ and re-checks it every session, so trust compounds instead of resetting.
17
+
18
+ You don't have to read the diff to know what your agent just did to your project.
19
+
20
+ > **Status: in-loop wedge, v0.49.0.** The in-loop `plainmarker seatbelt` + an actionable verdict + a
21
+ > one-line install are the current focus. Running the target's tests in a sandbox and the agent-facing
22
+ > API are deferred to later milestones (see `docs/ROADMAP.md`).
23
+
24
+ ## The one rule
25
+
26
+ **Ground truth only.** plainmarker never reports what the AI *says* it did. It reports
27
+ what the code, the tests, the scans, and the live system *actually are*. It never
28
+ claims a project is "safe," only which specific checks passed, and that this is not a
29
+ guarantee.
30
+
31
+ ## Privacy
32
+
33
+ Local-capable, and **honest about egress.** The independent Auditor uses a cloud model by
34
+ default, so whenever it runs — and when onboarding writes its plain-English summary — plainmarker tells
35
+ you, in plain language, that your code or project facts left your machine. Turn on `local_only` with
36
+ a local Auditor model and nothing leaves your computer. No telemetry, ever.
37
+
38
+ ### Choosing the Auditor model
39
+
40
+ Three ready-to-use Auditor configs ship under `keeper_core/templates/keeper/` — point at one with
41
+ `plainmarker audit <path> --config <file>`:
42
+
43
+ - **Default — NVIDIA-served DeepSeek** (no config file needed): a capable cloud model; set `NVIDIA_API_KEY`.
44
+ - **Free — OpenRouter** (`config.openrouter-free.yaml`): run the Auditor at no cost on a free,
45
+ different-family model; set `OPENROUTER_API_KEY`. Egress is disclosed like any cloud model. ⚠️ Free models
46
+ may **log/train on inputs**, so **if you are not 100% sure your project is free of private/customer data,
47
+ use the local option below instead.** They are also **rate-limited**: a throttle shows up honestly as
48
+ "could not determine" (never a fake pass), so a first run that says "the reviewer did not run" is usually a
49
+ rate-limit — run `plainmarker doctor` for the exact reason, then retry / switch model / go local.
50
+ - **Local / private — Ollama** (`config.local-ollama.yaml`): zero egress, unlimited; `local_only: true`
51
+ refuses any remote endpoint. Best for private code, and the only option with a benchmarked model
52
+ (`qwen2.5-coder:7b`, validated in DECISIONS D-022).
53
+
54
+ ## Architecture (Core + adapters)
55
+
56
+ - **`keeper_core/`** — the standalone engine. All real logic. Knows nothing about
57
+ Claude Code and runs on its own.
58
+ - **`adapters/`** — thin wrappers that let a host use Core. The Claude Code plugin is
59
+ the first; a standalone CLI/service and others follow. Adapters contain no logic of
60
+ their own.
61
+
62
+ See `docs/ARCHITECTURE.md` for the full plain-language map of the parts, and
63
+ `docs/INDEX.md` for where everything lives.
64
+
65
+ ## Install
66
+
67
+ ```
68
+ uvx plainmarker check .
69
+ ```
70
+
71
+ That's the whole install: [`uvx`](https://docs.astral.sh/uv/) fetches a compatible Python and runs
72
+ `plainmarker` — no clone, no global setup. For a command that stays around, `uv tool install plainmarker`
73
+ (or `pipx install plainmarker`), then just `plainmarker <command>`.
74
+
75
+ **For the in-loop catch** — run `plainmarker seatbelt .` right after an agent edits and it tells you
76
+ what's dangerous *now* plus a paste-ready fix to hand back to the agent. Firing it *automatically* the
77
+ moment your agent writes a file means adding a Claude Code PostToolUse hook that calls `plainmarker
78
+ seatbelt` yourself — that wiring is manual today (a packaged hook is on the roadmap, see
79
+ `docs/ROADMAP.md`), and `plainmarker` never edits your `~/.claude` config for you. That hook only fires
80
+ in repos you opt into the watch with `mkdir -p .keeper && touch .keeper/.watch`.
81
+
82
+ **For the opt-in `plainmarker audit`** (the independent, different-family model reviewer) add one API key
83
+ for the Auditor model — read from an environment variable, never stored in the repo (see *Choosing the
84
+ Auditor model* above).
85
+
86
+ ## What you can do today
87
+
88
+ Point `plainmarker` at any project (each writes its receipts under that project's `.keeper/` folder):
89
+
90
+ | Command | What it does |
91
+ |---|---|
92
+ | `plainmarker doctor` | check your setup and that the two models are reachable |
93
+ | `plainmarker onboard <path>` | map a project (files, stack, dependencies, risk areas) + a plain summary |
94
+ | `plainmarker seatbelt <path>` | **in-loop:** after your agent edits a file, what's dangerous NOW + a paste-ready fix to hand back to the agent |
95
+ | `plainmarker check <path>` | what changed since plainmarker last looked, what's dangerous now, and what it did NOT check |
96
+ | `plainmarker baseline <path>` | read-only hard checks: exposed secrets, vulnerable dependencies, code-vulnerability patterns |
97
+ | `plainmarker audit <path>` | the independent different-family reviewer over ground truth (facts vs concerns) |
98
+ | `plainmarker interrogate <path>` | a few plain questions; flags where the code disagrees with your intent |
99
+ | `plainmarker report <path>` | one plain-language report: what's true, broken, unsure, and to decide |
100
+ | `plainmarker calibrate` | prove it catches planted flaws and passes clean projects (and see its blind spots) |
101
+
102
+ plainmarker never claims a project is "safe" — only which specific checks passed, and that this is not a
103
+ guarantee.
104
+
105
+ ## Exit codes (for CI, pre-commit, and agent hooks)
106
+
107
+ A script can gate on plainmarker's exit code. There are two shapes, both fail-closed in the way that matters:
108
+ **a real danger never exits `0`.**
109
+
110
+ | Code | Meaning |
111
+ |---|---|
112
+ | `0` | Nothing to act on (see per-verb note below). |
113
+ | `1` | **STOP** — something needs a human. |
114
+ | `2` | Usage error (bad arguments; standard argparse). Not a security signal. |
115
+
116
+ - **`plainmarker seatbelt`** and **`plainmarker baseline`** are **fail-closed gates**: exit `0` only when nothing
117
+ dangerous was found **and** every check actually ran. A danger, *or* a check that could not finish
118
+ (timeout / offline / missing tool), *or* a crash → `1`. There is deliberately **no exit code meaning
119
+ "could not determine"** — doubt collapses to `1`, never `0`, so a hook can treat any non-zero as
120
+ "don't trust this yet." (`baseline` is strict: with no network for the dependency check it returns
121
+ `1`.)
122
+ - **`plainmarker check`** is the change **narrator**: it exits `1` only when a real danger is present *right
123
+ now*, and `0` otherwise. A gracefully-undetermined (`unknown`) check — a scanner that timed out or
124
+ is offline — is disclosed in the report text and in `--json` as `fail_closed` / `could_not_check`,
125
+ **not** in the exit code (a check that outright *crashes* still exits `1`). If you want
126
+ fail-closed-on-unknown gating from `check`, read `--json` `fail_closed`, not the exit code (or gate
127
+ with `seatbelt`).
128
+ - **`plainmarker audit`** additionally needs the Auditor model reachable.
129
+
130
+ ## License
131
+
132
+ [MIT](./LICENSE).
@@ -0,0 +1,12 @@
1
+ """plainmarker Core — the standalone engine.
2
+
3
+ All of plainmarker's real logic lives here. This package assumes NOTHING about Claude
4
+ Code (or any other host) and is designed to run on its own. Host-specific surfaces
5
+ (the Claude Code plugin, a future standalone CLI service, etc.) live under
6
+ ``adapters/`` and only call into this package.
7
+
8
+ Milestone 0, Step 1: this is the skeleton. The feature modules described in
9
+ ``docs/ARCHITECTURE.md`` are added one per later build-kit step.
10
+ """
11
+
12
+ __version__ = "0.49.0"
@@ -0,0 +1,11 @@
1
+ """Run plainmarker Core as a module: ``python -m keeper_core ...``.
2
+
3
+ This is the invocation path the Claude Code plugin's MCP server will use later
4
+ (Step 5): it launches Core as a subprocess instead of importing it, so the plugin
5
+ never has to carry a copy of Core's logic.
6
+ """
7
+
8
+ from keeper_core.cli import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
@@ -0,0 +1,327 @@
1
+ """Accept-baseline (D-079) + per-finding acknowledge (D-082): the OWNER's noise filter for `plainmarker check`.
2
+
3
+ `plainmarker accept <path>` records the current findings' content-bound IDs to `.keeper/accepted.json`;
4
+ `plainmarker check` then headlines only NEW findings (IDs not in the store) and quietly counts the accepted
5
+ ones. `plainmarker accept <path> --only <selector> --reason "..."` acknowledges ONE finding and records WHY,
6
+ so `plainmarker check` keeps just that one quiet and shows the reason (the decision is remembered) until the
7
+ code at that spot changes — the content-bound id then changes and the finding re-surfaces on its own.
8
+
9
+ NOT A SECURITY BOUNDARY. `plainmarker check` is the ADVISORY narrator and is fully silenceable by an agent
10
+ with file-write (this store, `.keeperignore`, or editing `.keeper/`). The SIGNED GATE (`plainmarker baseline`
11
+ /`sign`/`audit`) IGNORES this store and reports everything — it is the only trust boundary. Therefore this
12
+ module is imported ONLY by the keeper-check path; `baseline.py`/`auditor.py`/`witness_store.py` MUST NOT
13
+ import it (enforced mechanically by tests/test_accept_baseline.py::test_gate_modules_do_not_import_accept).
14
+
15
+ Content-bound IDs (a rotated secret / changed source line gets a new id and re-surfaces) are an
16
+ honest-user RE-REVIEW aid, NOT an adversary defense.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import re
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ _STORE = "accepted.json"
28
+ _SCHEMA = 2 # 1 = {schema,recorded,accepted}; 2 adds {reasons:{id:{...}}}
29
+
30
+ # Only these two checks are acceptable in v1. Shell HARD findings (download-and-run / exfil) are
31
+ # deliberately NEVER acceptable, and shell has no receipt + a 20-cap, so it is excluded entirely.
32
+ _ACCEPTABLE = ("secrets", "code_vulnerabilities")
33
+
34
+ # A selector is FILE:LINE[:TAG]. The file part has NO colon (receipt paths are relative posix paths), so
35
+ # the first ':' ends the file and the optional TAG (a detect-secrets type / semgrep rule) may itself
36
+ # contain ':'. Resolution is fail-CLOSED at the call site (exactly one id, or acknowledge nothing).
37
+ _SELECTOR_RE = re.compile(r"^(?P<file>[^:]+):(?P<line>\d+)(?::(?P<tag>.+))?$")
38
+
39
+
40
+ def _accept_path(project_path) -> Path:
41
+ return Path(project_path) / ".keeper" / _STORE
42
+
43
+
44
+ def _today() -> str:
45
+ return datetime.now(timezone.utc).date().isoformat()
46
+
47
+
48
+ def _clean_reason(s) -> str:
49
+ """Neutralize a stored reason for display: drop control chars / newlines / ANSI (an agent-writable
50
+ store must not be able to inject plainmarker-looking lines into the report) and cap the length. Applied
51
+ BOTH when storing (fresh) and when loading (a forged store), so neither path can forge output."""
52
+ return "".join(ch for ch in str(s) if ch.isprintable()).strip()[:200]
53
+
54
+
55
+ def _line_at(path: Path, line) -> str:
56
+ """The stripped source line at 1-based `line`, or "" if unreadable/out of range (fail-safe)."""
57
+ if not isinstance(line, int) or line < 1:
58
+ return ""
59
+ try:
60
+ with path.open(encoding="utf-8", errors="replace") as fh:
61
+ for i, text in enumerate(fh, 1):
62
+ if i == line:
63
+ return text.strip()
64
+ except OSError:
65
+ return ""
66
+ return ""
67
+
68
+
69
+ def _secret_rows(receipt: dict) -> list[dict]:
70
+ # ONE row per REAL (placeholder-demoted) finding, joined to raw.results for the unsalted hashed_secret
71
+ # — so a placeholder never mints an id and a rotated value re-surfaces. This is the single source of
72
+ # truth for secret ids (finding_ids derives from it).
73
+ raw = ((receipt.get("raw") or {}).get("results")) or {}
74
+ rows: list[dict] = []
75
+ consumed: dict[str, set[int]] = {} # file -> raw indices already mapped, so N distinct secrets on the
76
+ for f in receipt.get("findings") or []: # SAME (line,type) map to N distinct hashes (not all to the first)
77
+ file, line, typ = f.get("file"), f.get("line"), f.get("type")
78
+ used = consumed.setdefault(file, set())
79
+ hashed = None
80
+ for i, entry in enumerate(raw.get(file, [])):
81
+ if i in used or entry.get("line_number") != line or entry.get("type") != typ:
82
+ continue
83
+ used.add(i)
84
+ hashed = entry.get("hashed_secret")
85
+ break
86
+ rid = (f"secrets\0{file}\0{hashed}" if hashed # fallback so a finding never VANISHES from the count
87
+ else f"secrets\0{file}\0L{line}\0{typ}")
88
+ rows.append({"id": rid, "file": file, "line": line, "type": typ,
89
+ "content_bound": hashed is not None}) # fallback id is line+type bound (NOT acknowledgeable)
90
+ return rows
91
+
92
+
93
+ def _sast_rows(receipt: dict, root: Path) -> list[dict]:
94
+ # ID off (file, rule, hash-of-source-line-CONTENT) — NOT the line number — so a line shift keeps the
95
+ # acceptance but an in-place edit at that location re-surfaces it for re-review.
96
+ rows: list[dict] = []
97
+ for f in receipt.get("findings") or []:
98
+ file, rule, line = f.get("file"), f.get("rule"), f.get("line")
99
+ content_bound = bool(file) and rule is not None
100
+ if not content_bound: # malformed (never from real semgrep) -> fallback so none vanishes
101
+ rid = f"code_vulnerabilities\0{file or '?'}\0{rule}\0L{line}"
102
+ else:
103
+ line_text = _line_at(root / file, line)
104
+ digest = hashlib.sha256(line_text.encode("utf-8", "replace")).hexdigest()
105
+ rid = f"code_vulnerabilities\0{file}\0{rule}\0{digest}"
106
+ if not line_text: # unreadable/empty line -> sha256("") is NOT value-binding -> refuse
107
+ content_bound = False
108
+ rows.append({"id": rid, "file": file, "rule": rule, "line": line, "content_bound": content_bound})
109
+ return rows
110
+
111
+
112
+ def finding_rows(check_result, root) -> list[dict]:
113
+ """Per-finding rows for a fail CheckResult (secrets + code_vulnerabilities only): each is
114
+ {id, file, line, type|rule}. Same fail-closed contract as finding_ids (a non-fail / unknown /
115
+ receiptless / non-acceptable check yields [])."""
116
+ if (not check_result or check_result.status != "fail"
117
+ or check_result.check not in _ACCEPTABLE or not check_result.receipt_path):
118
+ return []
119
+ try:
120
+ receipt = json.loads(Path(check_result.receipt_path).read_text(encoding="utf-8"))
121
+ except (OSError, ValueError):
122
+ return []
123
+ return _secret_rows(receipt) if check_result.check == "secrets" else _sast_rows(receipt, Path(root))
124
+
125
+
126
+ def finding_ids(check_result, root) -> set[str]:
127
+ """Content-bound IDs for a fail CheckResult's findings (secrets + code_vulnerabilities only).
128
+
129
+ A non-fail / unknown / receiptless check yields an EMPTY set, so it can never be marked "all
130
+ accepted". Derives from finding_rows so the id logic has one home."""
131
+ return {r["id"] for r in finding_rows(check_result, root)}
132
+
133
+
134
+ def finding_label(row: dict) -> str:
135
+ """The human-readable label for one finding row: FILE:LINE (TAG). SANITIZED — a receipt file path or
136
+ type string can carry a newline / ANSI (an adversarial agent can name a file `evil\\nkeeper ...`), so
137
+ this is the single chokepoint that neutralizes label injection into the report (the twin of the reason
138
+ sanitizer). Every render site routes a finding's display through here."""
139
+ tag = row.get("type") or row.get("rule")
140
+ return _clean_reason(f"{row.get('file')}:{row.get('line')}" + (f" ({tag})" if tag else ""))
141
+
142
+
143
+ def resolve_selector(rows: list[dict], selector: str) -> set[str]:
144
+ """The set of finding ids a FILE:LINE[:TAG] selector matches. The CALLER fail-CLOSES on len != 1 —
145
+ a 0/ambiguous match must acknowledge nothing, never the wrong finding or all of them."""
146
+ m = _SELECTOR_RE.match((selector or "").strip())
147
+ if not m:
148
+ return set()
149
+ file, line, tag = m.group("file"), int(m.group("line")), m.group("tag")
150
+ out: set[str] = set()
151
+ for r in rows:
152
+ if r.get("file") != file or r.get("line") != line:
153
+ continue
154
+ if tag is not None and (r.get("type") or r.get("rule")) != tag:
155
+ continue
156
+ out.add(r["id"])
157
+ return out
158
+
159
+
160
+ def load_store(project_path) -> dict:
161
+ """The full accept store, sanitized + fail-OPEN. Returns {schema, recorded, accepted:list,
162
+ reasons:{id:{reason,recorded}}}; any problem (missing/malformed/forged) degrades to an empty store
163
+ so a broken store never HIDES a finding. Schema-1 stores (no `reasons`) load as reasons={}."""
164
+ empty = {"schema": _SCHEMA, "recorded": "", "accepted": [], "reasons": {}}
165
+ try:
166
+ obj = json.loads(_accept_path(project_path).read_text(encoding="utf-8"))
167
+ except (OSError, ValueError):
168
+ return empty
169
+ if not isinstance(obj, dict):
170
+ return empty
171
+ accepted = [x for x in (obj.get("accepted") or []) if isinstance(x, str)]
172
+ reasons: dict = {}
173
+ src = obj.get("reasons")
174
+ if isinstance(src, dict):
175
+ for k, v in src.items():
176
+ if isinstance(k, str) and isinstance(v, dict) and isinstance(v.get("reason"), str):
177
+ rec = v.get("recorded")
178
+ reasons[k] = {"reason": _clean_reason(v["reason"]), # forged store can't inject output
179
+ "recorded": _clean_reason(rec) if isinstance(rec, str) else ""}
180
+ recorded = obj.get("recorded")
181
+ return {"schema": _SCHEMA, "recorded": recorded if isinstance(recorded, str) else "",
182
+ "accepted": accepted, "reasons": reasons}
183
+
184
+
185
+ def save_store(project_path, store: dict) -> Path:
186
+ """Write the store as schema-2, deterministically. GC: a reason is kept ONLY if its id is still in
187
+ `accepted` (so an orphaned reason — its finding rotated/edited away — never lingers)."""
188
+ accepted = sorted(set(store.get("accepted", [])))
189
+ keep = set(accepted)
190
+ reasons = {k: v for k, v in (store.get("reasons") or {}).items() if k in keep}
191
+ p = _accept_path(project_path)
192
+ p.parent.mkdir(parents=True, exist_ok=True)
193
+ p.write_text(json.dumps({"schema": _SCHEMA, "recorded": store.get("recorded", ""),
194
+ "accepted": accepted, "reasons": reasons}, indent=2) + "\n", encoding="utf-8")
195
+ return p
196
+
197
+
198
+ def load_accepted(project_path) -> set[str]:
199
+ """The accepted-finding IDs, or an EMPTY set on any problem (fail-OPEN). Unchanged API."""
200
+ return set(load_store(project_path)["accepted"])
201
+
202
+
203
+ def load_reasons(project_path) -> dict:
204
+ """{id: {reason, recorded}} for findings the owner acknowledged with a reason. Fail-open ({})."""
205
+ return load_store(project_path)["reasons"]
206
+
207
+
208
+ def save_accepted(project_path, ids, recorded: str = "") -> Path:
209
+ """Record `ids` as the accepted set, MERGING into the existing store so a per-finding reason is never
210
+ clobbered (a surviving id keeps its reason; a vanished id's reason is GC'd by save_store)."""
211
+ store = load_store(project_path)
212
+ store["accepted"] = sorted(set(ids))
213
+ store["recorded"] = recorded
214
+ return save_store(project_path, store)
215
+
216
+
217
+ def _scan_rows(path: Path) -> tuple[list[dict], list[str]]:
218
+ """Scan secrets + sast with the SAME exclude set as plainmarker check (.keeperignore) and return
219
+ (all finding rows, names of checks that could not be scanned)."""
220
+ from keeper_core.checks import sast_check, secrets_check
221
+ from keeper_core.onboarding.builtin_provider import project_excludes
222
+ evidence = path / ".keeper" / "evidence"
223
+ excludes = project_excludes(path) # MUST match plainmarker check's scan set, else the baseline
224
+ rows: list[dict] = [] # records findings `plainmarker check` never sees (mismatch)
225
+ unscanned: list[str] = []
226
+ for check in (secrets_check, sast_check):
227
+ r = check(path, evidence, excludes)
228
+ if r.status == "unknown": # a timed-out / unavailable scanner accepted NOTHING — say so
229
+ unscanned.append(r.check)
230
+ rows.extend(finding_rows(r, path))
231
+ return rows, unscanned
232
+
233
+
234
+ def _acknowledge_one(path: Path, rows: list[dict], selector: str, reason: str,
235
+ recorded: str, as_json: bool) -> int:
236
+ """Acknowledge exactly ONE finding (fail-CLOSED): refuse on no reason, no match, or an ambiguous
237
+ selector. Records the reason and echoes what was acknowledged (informed consent)."""
238
+ if not reason.strip():
239
+ print("--only needs --reason \"why this is OK\" — the reason is recorded and shown on later "
240
+ "`plainmarker check`. Nothing acknowledged.")
241
+ return 1
242
+ matches = resolve_selector(rows, selector)
243
+ if len(matches) != 1:
244
+ if not matches:
245
+ print(f"No current finding matches {selector!r}. Run `plainmarker check` to see the exact "
246
+ "FILE:LINE to use. Nothing acknowledged.")
247
+ else:
248
+ cands = sorted({finding_label(r) for r in rows if r["id"] in matches})
249
+ if len(cands) == 1: # N indistinguishable findings at one spot (same type)
250
+ print(f"{selector!r} matches {len(matches)} findings at the same spot ({cands[0]}) that "
251
+ "cannot be told apart by selector — fix them, or use `plainmarker accept` to accept all "
252
+ "current findings. Nothing acknowledged.")
253
+ else:
254
+ print(f"{selector!r} matches {len(matches)} findings — add the type/rule to pick one "
255
+ f"(e.g. {cands[0]!r}). Candidates:")
256
+ for c in cands:
257
+ print(f" - {c}")
258
+ print("Nothing acknowledged.")
259
+ return 1
260
+ rid = next(iter(matches))
261
+ backing = [r for r in rows if r["id"] == rid]
262
+ if len(backing) > 1: # one content-bound id can back N byte-identical lines
263
+ others = sorted({finding_label(r) for r in backing if finding_label(r) != selector})
264
+ print(f"{selector!r} has the same content as {len(backing) - 1} other finding(s) "
265
+ f"({', '.join(others) or 'elsewhere'}), so acknowledging it would silence those too — and a "
266
+ "future identical line would inherit your reason. Fix them, or use `plainmarker accept` to accept "
267
+ "all. Nothing acknowledged.")
268
+ return 1
269
+ row = backing[0]
270
+ if not row.get("content_bound", True): # a fallback (line+type) id would hide a ROTATED value
271
+ print(f"plainmarker could not fingerprint the content of {finding_label(row)}, so it can't safely "
272
+ "remember this one — a changed value would silently ride the acknowledgement. Fix it instead. "
273
+ "Nothing acknowledged.")
274
+ return 1
275
+ reason = _clean_reason(reason)
276
+ store = load_store(path)
277
+ store["accepted"] = sorted(set(store["accepted"]) | {rid})
278
+ store["reasons"][rid] = {"reason": reason, "recorded": recorded or _today()}
279
+ save_store(path, store)
280
+ if as_json:
281
+ print(json.dumps({"acknowledged": finding_label(row), "reason": reason}, indent=2))
282
+ return 0
283
+ print(f"Acknowledged {finding_label(row)} — \"{reason}\".")
284
+ print("Future `plainmarker check` keeps this one quiet (and shows your reason) until the code at that spot "
285
+ "changes. A noise filter, NOT a safety guarantee — `plainmarker baseline` still reports it.")
286
+ return 0
287
+
288
+
289
+ def run_accept_cli(project_path: str, recorded: str = "", as_json: bool = False,
290
+ only: str | None = None, reason: str = "") -> int:
291
+ """`plainmarker accept <path>`: re-scan (secrets + code-vulns, local) and record the findings as reviewed,
292
+ printing exactly what is accepted (informed consent). With `--only <selector> --reason "..."`,
293
+ acknowledge a SINGLE finding and record why (fail-closed on an ambiguous/absent selector)."""
294
+ path = Path(project_path).expanduser().resolve()
295
+ if not path.is_dir():
296
+ print(f"Not a folder: {path}")
297
+ return 1
298
+ from keeper_core.baseline import _protect_keeper_dir
299
+ _protect_keeper_dir(path)
300
+ rows, unscanned = _scan_rows(path)
301
+
302
+ if only is not None:
303
+ return _acknowledge_one(path, rows, only, reason, recorded, as_json)
304
+
305
+ ids = {r["id"] for r in rows}
306
+ # A check that could NOT be scanned this run (timed out / unavailable) must not silently wipe its prior
307
+ # acknowledgements + reasons — preserve them (ids are "<check>\0...") so a transient scanner failure
308
+ # never destroys the owner's recorded decisions. (Disclosed below.)
309
+ preserved: set[str] = set()
310
+ if unscanned:
311
+ pref = tuple(f"{c}\0" for c in unscanned)
312
+ preserved = {i for i in load_store(path)["accepted"] if i.startswith(pref)}
313
+ by_check = {"secrets": sum(1 for i in ids if i.startswith("secrets\0")),
314
+ "code_vulnerabilities": sum(1 for i in ids if i.startswith("code_vulnerabilities\0"))}
315
+ save_accepted(path, ids | preserved, recorded) # MERGE: preserves reasons for surviving ids; GCs the rest
316
+ if as_json:
317
+ print(json.dumps({"accepted": len(ids), "by_check": by_check, "unscanned": unscanned,
318
+ "preserved_unscanned": len(preserved)}, indent=2))
319
+ return 0
320
+ parts = ", ".join(f"{n} {'secret' if c == 'secrets' else 'code-vuln'}(s)"
321
+ for c, n in by_check.items() if n)
322
+ print(f"Accepted {len(ids)} finding(s)" + (f" ({parts})" if parts else "") + ".")
323
+ for c in unscanned:
324
+ print(f"⚠ {c}: could not scan (timed out / unavailable) — kept your earlier acknowledgements for it.")
325
+ print("Future `plainmarker check` will flag only NEW findings. This is a noise filter, NOT a safety "
326
+ "guarantee — `plainmarker baseline` always scans everything.")
327
+ return 0