protect-context 2.0.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """protect-context — agent guardrail suite (Python core: shared store, MCP server, CLI)."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,5 @@
1
+ """`python -m protect_context` entry point — delegates to the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,57 @@
1
+ """Context budget accounting (SPEC §1 #12): tool-output chars per session,
2
+ estimated tokens (chars/4), per-tool breakdown with top-5.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from . import store
8
+
9
+ TOKEN_DIVISOR = 4
10
+
11
+
12
+ def compute(events: list[dict] | None = None, session: str | None = None, top: int = 5) -> dict:
13
+ if events is None:
14
+ events = store.read_jsonl(store.events_path())
15
+ session = session or store.session_id()
16
+ per_tool: dict[str, int] = {}
17
+ n = 0
18
+ for e in events:
19
+ if e.get("session") != session or e.get("kind") != "tool_result":
20
+ continue
21
+ n += 1
22
+ raw_chars = e.get("out_chars")
23
+ chars = (
24
+ raw_chars
25
+ if isinstance(raw_chars, int)
26
+ and not isinstance(raw_chars, bool)
27
+ and raw_chars >= 0
28
+ else 0
29
+ )
30
+ raw_tool = e.get("tool")
31
+ tool = raw_tool if isinstance(raw_tool, str) and raw_tool else "unknown"
32
+ per_tool[tool] = per_tool.get(tool, 0) + chars
33
+ chars = sum(per_tool.values())
34
+ try:
35
+ bounded_top = max(0, min(int(top), 5))
36
+ except (TypeError, ValueError):
37
+ bounded_top = 5
38
+ top_tools = sorted(per_tool.items(), key=lambda kv: (-kv[1], kv[0]))[:bounded_top]
39
+ return {
40
+ "session": session,
41
+ "chars": chars,
42
+ "tokens_est": chars // TOKEN_DIVISOR,
43
+ "events": n,
44
+ "top": top_tools,
45
+ }
46
+
47
+
48
+ def render(b: dict) -> str:
49
+ top = " · ".join(
50
+ f"{store.display_text(t, 40) or 'unknown'}={c}" for t, c in b["top"][:5]
51
+ ) or "none"
52
+ return (
53
+ f"PC budget · session {store.display_text(b['session'], 80)}\n"
54
+ f"tool output: {b['chars']} chars (~{b['tokens_est']} tokens est) over {b['events']} tool results\n"
55
+ f"top: {top}\n"
56
+ "Nudge: keep tool outputs bounded (head/tail/digest); use targeted reads, not whole-file dumps."
57
+ )
protect_context/cli.py ADDED
@@ -0,0 +1,307 @@
1
+ """CLI: `python -m protect_context <verb>` — same verbs as the MCP tools; the
2
+ test harness and the no-MCP fallback (SPEC §3).
3
+
4
+ Exit codes: 0 ok · 1 warning triggered · 2 error.
5
+ Every output ends with a one-line actionable "Nudge:".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from . import (
13
+ budget,
14
+ contract,
15
+ diffscope,
16
+ journal,
17
+ layers,
18
+ loops,
19
+ orientation,
20
+ preflight,
21
+ report,
22
+ store,
23
+ verify,
24
+ )
25
+
26
+ NUDGE_ERROR = "Nudge: fix the error above and re-run; thresholds live in .agent-guard/config.json."
27
+
28
+
29
+ def _tags(raw: str) -> list[str]:
30
+ return [t.strip() for t in (raw or "").split(",") if t.strip()]
31
+
32
+
33
+ def cmd_init(args: argparse.Namespace) -> int:
34
+ store.ensure()
35
+ cons = contract.load() # seeds contract.json on first use
36
+ st = store.load_state()
37
+ sid = store.session_id()
38
+ session_entry = store.session_state(st, sid)
39
+ if not session_entry.get("started"):
40
+ session_entry["started"] = store.now_ts()
41
+ session_entry["git_head"] = diffscope.git_head()
42
+ st["current_session"] = {
43
+ "id": sid,
44
+ "started": session_entry["started"],
45
+ "git_head": session_entry["git_head"],
46
+ }
47
+ store.save_state(st)
48
+ print(f"PC store ready · {store.guard_dir()}")
49
+ print(
50
+ f"config {len(store.load_config())} keys · contract {len(cons)} constraints · "
51
+ f"session {store.session_id()}"
52
+ )
53
+ # Goal #4: the orientation digest (repo tree, languages, conventions
54
+ # files) ships with init so the CLI surface can orient a fresh session.
55
+ # The digest's own trailing Nudge is dropped — init's Nudge closes the
56
+ # output (every CLI output ends with exactly one Nudge line).
57
+ print("\n".join(orientation.digest().splitlines()[:-1]))
58
+ print("Nudge: thresholds live in .agent-guard/config.json — tune them any time.")
59
+ return 0
60
+
61
+
62
+ def cmd_status(args: argparse.Namespace) -> int:
63
+ text, level = report.status()
64
+ print(text)
65
+ return level
66
+
67
+
68
+ def cmd_verify(args: argparse.Namespace) -> int:
69
+ if not args.command.strip():
70
+ print("PC verify · error: command must not be blank")
71
+ print("Nudge: pass a concrete verification command tied to the claim.")
72
+ return 2
73
+ cfg = store.load_config()
74
+ blocked = loops.command_block(args.command, cfg)
75
+ if blocked:
76
+ print(
77
+ f"PC verify blocked · {store.display_text(blocked['cmd'], 80)} · "
78
+ f"{blocked['count']} prior failures"
79
+ )
80
+ print("Nudge: change approach or gather new information — do not re-run unchanged.")
81
+ return 1
82
+ entry = verify.run_verify(args.command, args.timeout_s)
83
+ print(verify.render_entry(entry))
84
+ return 0 if entry["ok"] else 1
85
+
86
+
87
+ def cmd_evidence(args: argparse.Namespace) -> int:
88
+ runs = verify.recent(args.limit)
89
+ print(verify.render_recent(runs, verify.count()))
90
+ return 0
91
+
92
+
93
+ def cmd_contract(args: argparse.Namespace) -> int:
94
+ if args.action == "list":
95
+ print(contract.render(contract.load()))
96
+ personal = contract.user_constraint_count()
97
+ if personal:
98
+ print(
99
+ f"personal layer: {personal} from {layers.user_contract_path()}"
100
+ " (applies to every project; a project row with the same id or text wins)"
101
+ )
102
+ print("Nudge: honor these constraints; they are re-injected whenever they change.")
103
+ return 0
104
+ if args.action == "add":
105
+ scope = contract.USER_SCOPE if getattr(args, "global_scope", False) else contract.PROJECT_SCOPE
106
+ try:
107
+ c, cons = contract.add(args.text, scope=scope)
108
+ except contract.ContractIntegrityError as e:
109
+ print(f"contract: {e}")
110
+ print("Nudge: repair or restore the contract file, then retry the mutation.")
111
+ return 2
112
+ except ValueError as e:
113
+ print(f"contract: {e}")
114
+ print("Nudge: pass a concrete, non-blank constraint.")
115
+ return 2
116
+ where = "personal layer" if scope == contract.USER_SCOPE else "this project"
117
+ if c is None:
118
+ print(f"contract: identical constraint already present ({len(cons)} total)")
119
+ print("Nudge: reuse the existing constraint instead of duplicating it.")
120
+ return 0
121
+ print(f"added {c['id']} to {where}")
122
+ print(contract.render(cons))
123
+ warn = contract.brief_cap_warning(len(cons))
124
+ if warn:
125
+ print(warn)
126
+ print("Nudge: keep the contract tight — fewer, sharper constraints survive re-injection.")
127
+ return 0
128
+ if args.action == "remove":
129
+ scope = contract.USER_SCOPE if getattr(args, "global_scope", False) else contract.PROJECT_SCOPE
130
+ try:
131
+ removed = contract.remove(args.id, scope=scope)
132
+ except contract.ContractIntegrityError as e:
133
+ print(f"contract: {e}")
134
+ print("Nudge: repair or restore the contract file, then retry the mutation.")
135
+ return 2
136
+ safe_id = store.display_text(args.id, contract.RENDER_ID_MAX)
137
+ if removed:
138
+ print(f"removed {safe_id}")
139
+ print(contract.render(contract.load()))
140
+ print("Nudge: keep the contract tight — fewer, sharper constraints survive re-injection.")
141
+ return 0
142
+ if scope == contract.PROJECT_SCOPE and any(
143
+ c.get("id") == args.id for c in contract.load()
144
+ ):
145
+ print(f"{safe_id} lives in the personal layer, not this project")
146
+ print("Nudge: re-run with --global to remove it from every project.")
147
+ return 2
148
+ print(f"no constraint with id {safe_id!r}")
149
+ print("Nudge: run 'contract list' to see current ids.")
150
+ return 2
151
+ print(NUDGE_ERROR)
152
+ return 2
153
+
154
+
155
+ def cmd_note(args: argparse.Namespace) -> int:
156
+ try:
157
+ entry = journal.add(args.type, args.text, _tags(args.tags))
158
+ except ValueError as e:
159
+ print(f"PC error: {e}")
160
+ print(NUDGE_ERROR)
161
+ return 2
162
+ print(journal.render_ack(entry, journal.count()))
163
+ return 0
164
+
165
+
166
+ def cmd_journal(args: argparse.Namespace) -> int:
167
+ entries = journal.recent(args.limit, args.type)
168
+ print(journal.render_recent(entries, journal.count(args.type), args.type))
169
+ return 0
170
+
171
+
172
+ def cmd_loops(args: argparse.Namespace) -> int:
173
+ cfg = store.load_config()
174
+ streak_list = loops.streaks()
175
+ print(loops.render(streak_list, cfg))
176
+ return 1 if loops.would_block(streak_list, cfg) else 0
177
+
178
+
179
+ def cmd_diff(args: argparse.Namespace) -> int:
180
+ st = diffscope.diff_audit(args.baseline)
181
+ print(diffscope.render(st))
182
+ if "error" in st:
183
+ return 2
184
+ return 0 if st["verdict"] == "ok" else 1
185
+
186
+
187
+ def cmd_preflight(args: argparse.Namespace) -> int:
188
+ res = preflight.run_preflight()
189
+ print(preflight.render(res))
190
+ return 1 if (
191
+ res["missing"]
192
+ or not res["git_repo"]
193
+ or res.get("omitted_tools")
194
+ or res.get("probe_issues")
195
+ ) else 0
196
+
197
+
198
+ def cmd_budget(args: argparse.Namespace) -> int:
199
+ print(budget.render(budget.compute()))
200
+ return 0
201
+
202
+
203
+ def cmd_orientation(args: argparse.Namespace) -> int:
204
+ print(orientation.digest())
205
+ return 0
206
+
207
+
208
+ def cmd_mcp(args: argparse.Namespace) -> int:
209
+ # Same stdio server as `python -m protect_context.mcp_server`; this verb is
210
+ # what the registry package entry launches (`uvx protect-context mcp`).
211
+ from .mcp_server import main as serve
212
+
213
+ serve()
214
+ return 0
215
+
216
+
217
+ def build_parser() -> argparse.ArgumentParser:
218
+ p = argparse.ArgumentParser(
219
+ prog="protect_context",
220
+ description="protect-context guardrail CLI (advisory-first; no network, no LLM)",
221
+ )
222
+ sub = p.add_subparsers(dest="verb", required=True)
223
+
224
+ sp = sub.add_parser("init", help="create .agent-guard/ with default config + seed contract")
225
+ sp.set_defaults(func=cmd_init)
226
+
227
+ sp = sub.add_parser("status", help="compact status dashboard")
228
+ sp.set_defaults(func=cmd_status)
229
+
230
+ sp = sub.add_parser("verify", help="run CMD and record evidence (shell verbatim)")
231
+ sp.add_argument("command")
232
+ sp.add_argument("--timeout", dest="timeout_s", type=int, default=180)
233
+ sp.set_defaults(func=cmd_verify)
234
+
235
+ sp = sub.add_parser("evidence", help="recent verify runs")
236
+ sp.add_argument("--limit", type=int, default=5)
237
+ sp.set_defaults(func=cmd_evidence)
238
+
239
+ sp = sub.add_parser("contract", help="contract store: list | add TEXT | remove ID")
240
+ csub = sp.add_subparsers(dest="action", required=True)
241
+ csub.add_parser("list")
242
+ a = csub.add_parser("add")
243
+ a.add_argument("text")
244
+ a.add_argument(
245
+ "--global",
246
+ dest="global_scope",
247
+ action="store_true",
248
+ help="add to the personal layer (~/.config/protect-context) instead of this project",
249
+ )
250
+ r = csub.add_parser("remove")
251
+ r.add_argument("id")
252
+ r.add_argument(
253
+ "--global",
254
+ dest="global_scope",
255
+ action="store_true",
256
+ help="remove from the personal layer instead of this project",
257
+ )
258
+ sp.set_defaults(func=cmd_contract)
259
+
260
+ sp = sub.add_parser("note", help="journal entry: decision|correction|risk|note TEXT")
261
+ sp.add_argument("type", choices=list(store.JOURNAL_TYPES))
262
+ sp.add_argument("text")
263
+ sp.add_argument("--tags", default="", help="comma-separated tags")
264
+ sp.set_defaults(func=cmd_note)
265
+
266
+ sp = sub.add_parser("journal", help="read recent journal entries")
267
+ sp.add_argument("--limit", type=int, default=journal.READ_LIMIT_DEFAULT)
268
+ sp.add_argument("--type", choices=list(store.JOURNAL_TYPES), default=None)
269
+ sp.set_defaults(func=cmd_journal)
270
+
271
+ sp = sub.add_parser("loops", help="failing-command streaks")
272
+ sp.set_defaults(func=cmd_loops)
273
+
274
+ sp = sub.add_parser("diff", help="diff audit vs HEAD (or --baseline ref)")
275
+ sp.add_argument("--baseline", default=None)
276
+ sp.set_defaults(func=cmd_diff)
277
+
278
+ sp = sub.add_parser("preflight", help="tool presence/versions + git repo check")
279
+ sp.set_defaults(func=cmd_preflight)
280
+
281
+ sp = sub.add_parser("budget", help="session tool-output budget")
282
+ sp.set_defaults(func=cmd_budget)
283
+
284
+ sp = sub.add_parser("orientation", help="bounded repo tree, languages, and conventions")
285
+ sp.set_defaults(func=cmd_orientation)
286
+
287
+ sp = sub.add_parser("mcp", help="serve the MCP tools on stdio (terminal session)")
288
+ sp.set_defaults(func=cmd_mcp)
289
+
290
+ return p
291
+
292
+
293
+ def main(argv: list[str] | None = None) -> int:
294
+ args = build_parser().parse_args(argv)
295
+ try:
296
+ return int(args.func(args))
297
+ except KeyboardInterrupt: # pragma: no cover
298
+ print(NUDGE_ERROR)
299
+ return 2
300
+ except Exception as e: # CLI boundary: report compactly, never crash ugly
301
+ print(f"PC error: {store.display_text(e, 240) or 'operation failed'}")
302
+ print(NUDGE_ERROR)
303
+ return 2
304
+
305
+
306
+ if __name__ == "__main__": # `python -m protect_context.cli` = `python -m protect_context`
307
+ raise SystemExit(main())