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.
- plainmarker-0.49.0/LICENSE +21 -0
- plainmarker-0.49.0/PKG-INFO +147 -0
- plainmarker-0.49.0/README.md +132 -0
- plainmarker-0.49.0/keeper_core/__init__.py +12 -0
- plainmarker-0.49.0/keeper_core/__main__.py +11 -0
- plainmarker-0.49.0/keeper_core/accept_baseline.py +327 -0
- plainmarker-0.49.0/keeper_core/auditor.py +565 -0
- plainmarker-0.49.0/keeper_core/baseline.py +125 -0
- plainmarker-0.49.0/keeper_core/calibrate.py +150 -0
- plainmarker-0.49.0/keeper_core/calibrate_auditor.py +320 -0
- plainmarker-0.49.0/keeper_core/checks.py +995 -0
- plainmarker-0.49.0/keeper_core/cli.py +267 -0
- plainmarker-0.49.0/keeper_core/config.py +188 -0
- plainmarker-0.49.0/keeper_core/doctor.py +80 -0
- plainmarker-0.49.0/keeper_core/interrogation.py +210 -0
- plainmarker-0.49.0/keeper_core/models.py +155 -0
- plainmarker-0.49.0/keeper_core/onboarding/__init__.py +6 -0
- plainmarker-0.49.0/keeper_core/onboarding/builtin_provider.py +311 -0
- plainmarker-0.49.0/keeper_core/onboarding/onboard.py +136 -0
- plainmarker-0.49.0/keeper_core/onboarding/provider.py +58 -0
- plainmarker-0.49.0/keeper_core/onboarding/risk.py +61 -0
- plainmarker-0.49.0/keeper_core/onboarding/summary.py +52 -0
- plainmarker-0.49.0/keeper_core/ranking.py +144 -0
- plainmarker-0.49.0/keeper_core/redact.py +66 -0
- plainmarker-0.49.0/keeper_core/report.py +704 -0
- plainmarker-0.49.0/keeper_core/sast_rules/javascript.yaml +43 -0
- plainmarker-0.49.0/keeper_core/sast_rules/python.yaml +71 -0
- plainmarker-0.49.0/keeper_core/seatbelt.py +270 -0
- plainmarker-0.49.0/keeper_core/session_verify.py +975 -0
- plainmarker-0.49.0/keeper_core/shell_audit.py +1058 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/config.local-ollama.yaml +36 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/config.openrouter-deepseek.yaml +55 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/config.openrouter-free.yaml +58 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/config.yaml +30 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/decision-ledger.md +24 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/failure-library.md +18 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/hard-truths.yaml +18 -0
- plainmarker-0.49.0/keeper_core/templates/keeper/project-state.md +28 -0
- plainmarker-0.49.0/keeper_core/witness.py +128 -0
- plainmarker-0.49.0/keeper_core/witness_store.py +360 -0
- plainmarker-0.49.0/plainmarker.egg-info/PKG-INFO +147 -0
- plainmarker-0.49.0/plainmarker.egg-info/SOURCES.txt +79 -0
- plainmarker-0.49.0/plainmarker.egg-info/dependency_links.txt +1 -0
- plainmarker-0.49.0/plainmarker.egg-info/entry_points.txt +2 -0
- plainmarker-0.49.0/plainmarker.egg-info/requires.txt +3 -0
- plainmarker-0.49.0/plainmarker.egg-info/top_level.txt +1 -0
- plainmarker-0.49.0/pyproject.toml +27 -0
- plainmarker-0.49.0/setup.cfg +4 -0
- plainmarker-0.49.0/tests/test_accept_baseline.py +164 -0
- plainmarker-0.49.0/tests/test_acknowledge.py +298 -0
- plainmarker-0.49.0/tests/test_alembic_revision_demotion.py +156 -0
- plainmarker-0.49.0/tests/test_auditor.py +565 -0
- plainmarker-0.49.0/tests/test_baseline.py +146 -0
- plainmarker-0.49.0/tests/test_calibrate.py +157 -0
- plainmarker-0.49.0/tests/test_calibrate_auditor.py +382 -0
- plainmarker-0.49.0/tests/test_check.py +149 -0
- plainmarker-0.49.0/tests/test_checks.py +484 -0
- plainmarker-0.49.0/tests/test_cli.py +62 -0
- plainmarker-0.49.0/tests/test_config.py +171 -0
- plainmarker-0.49.0/tests/test_deps_osv.py +326 -0
- plainmarker-0.49.0/tests/test_doctor.py +100 -0
- plainmarker-0.49.0/tests/test_egress.py +87 -0
- plainmarker-0.49.0/tests/test_egress_scope.py +146 -0
- plainmarker-0.49.0/tests/test_exit_contract.py +45 -0
- plainmarker-0.49.0/tests/test_history.py +122 -0
- plainmarker-0.49.0/tests/test_interrogate_config.py +78 -0
- plainmarker-0.49.0/tests/test_interrogation.py +108 -0
- plainmarker-0.49.0/tests/test_keeperignore.py +222 -0
- plainmarker-0.49.0/tests/test_onboarding.py +196 -0
- plainmarker-0.49.0/tests/test_precision_corpus.py +64 -0
- plainmarker-0.49.0/tests/test_ranking.py +189 -0
- plainmarker-0.49.0/tests/test_redact.py +121 -0
- plainmarker-0.49.0/tests/test_report.py +640 -0
- plainmarker-0.49.0/tests/test_seatbelt.py +262 -0
- plainmarker-0.49.0/tests/test_session_verify.py +568 -0
- plainmarker-0.49.0/tests/test_session_verify_failclosed.py +156 -0
- plainmarker-0.49.0/tests/test_shell_audit.py +913 -0
- plainmarker-0.49.0/tests/test_smoke_cli.py +202 -0
- plainmarker-0.49.0/tests/test_witness.py +173 -0
- plainmarker-0.49.0/tests/test_witness_gate.py +128 -0
- 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
|