@ikon85/agent-workflow-kit 0.23.0 → 0.25.0

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 (32) hide show
  1. package/.agents/skills/setup-workflow/SKILL.md +26 -6
  2. package/.agents/skills/setup-workflow/workflow-advisories.md +97 -0
  3. package/.claude/hooks/_hook_utils.py +35 -0
  4. package/.claude/hooks/_safety_guard.py +24 -0
  5. package/.claude/hooks/baseline-capture-hint.py +25 -0
  6. package/.claude/hooks/block-bg-double-background.py +30 -0
  7. package/.claude/hooks/block-npm-install-in-pnpm.py +30 -0
  8. package/.claude/hooks/block-secrets.py +30 -0
  9. package/.claude/hooks/convention-drift-hint.py +27 -0
  10. package/.claude/hooks/grep-shim-guard.py +30 -0
  11. package/.claude/hooks/loc-offender-forewarn.py +56 -0
  12. package/.claude/hooks/migration-snapshot-reminder.py +24 -0
  13. package/.claude/hooks/pre-refactor-sweep.py +27 -0
  14. package/.claude/hooks/recon-size-hint.py +24 -0
  15. package/.claude/hooks/skill-drift-hint.py +9 -45
  16. package/.claude/hooks/typecheck-on-stop.py +27 -0
  17. package/.claude/hooks/typecheck-on-stop.sh +2 -0
  18. package/.claude/skills/setup-workflow/SKILL.md +26 -6
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +97 -0
  20. package/README.md +27 -0
  21. package/agent-workflow-kit.package.json +94 -6
  22. package/package.json +1 -1
  23. package/scripts/loc_offender_gate.py +24 -0
  24. package/scripts/safety-guardrails/core.py +221 -0
  25. package/scripts/safety-guardrails/search.py +92 -0
  26. package/scripts/security/audit-gate.mjs +122 -0
  27. package/scripts/security/ensure-gitleaks.mjs +101 -0
  28. package/scripts/security/gitleaks-profile.json +14 -0
  29. package/scripts/security/install-git-hooks.mjs +52 -0
  30. package/scripts/workflow-advisories/capabilities.json +33 -0
  31. package/scripts/workflow-advisories/core.py +285 -0
  32. package/src/lib/bundle.mjs +12 -0
@@ -26,7 +26,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
26
26
  | `## Agent skills` + `## Prod` in CLAUDE.md **and** AGENTS.md | one-line pointers + deploy target (Sections C/G + Write) |
27
27
  | `.github/workflows/agent-workflow-kit-update.yml` | optional tested Kit update pull request for GitHub consumers (Section A2) |
28
28
  | `docs/agents/census.md` | optional-census choice, paths, state, and safe disable contract seeded from [census.md](./census.md) (Section A3) |
29
- | `docs/agents/workflow-capabilities.json` + capability assets | consumer-owned Worktree Lifecycle activation and one-time Memory Lifecycle policy seeds (Sections A4–A5) |
29
+ | `docs/agents/workflow-capabilities.json` + capability assets | consumer-owned Worktree Lifecycle, Memory Lifecycle, and Workflow Advisories choices (Sections A4–A6) |
30
30
 
31
31
  ## Idempotency contract — read before writing anything
32
32
 
@@ -57,7 +57,7 @@ Read the current state; don't assume. For every target file, read its first line
57
57
  - `CONTEXT.md`, `CONTEXT-MAP.md`, `docs/adr/` — domain-doc layout.
58
58
  - `docs/agents/`, `docs/agents/skills/`, `docs/conventions/` — prior output of this skill.
59
59
  - `docs/agents/census.md`, `.census/profile.json`, `.census/active.json` — an existing census choice or consumer-owned census to adopt.
60
- - `docs/agents/workflow-capabilities.json`, `.claude/settings.json` — existing capability choices/profile and Worktree Lifecycle hook wiring to adopt.
60
+ - `docs/agents/workflow-capabilities.json`, `.claude/settings.json` — existing capability choices/profile plus Worktree Lifecycle and Workflow Advisories hook wiring to adopt.
61
61
  - `.memory/active/`, `.memory/archive/`, and `.memory/receipts/` — consumer-owned Memory Lifecycle policies or recovery evidence to adopt without normalization.
62
62
  - `gh auth status` (if GitHub) — is `gh` authenticated, and with which scopes?
63
63
 
@@ -199,6 +199,25 @@ activation, roots, retention, restore, or pruning grants. Template files have
199
199
  no kit-managed or `append-managed` region; a future bounded-region contract
200
200
  requires a separate decision.
201
201
 
202
+ ### 2e. Section A6 — Optional Workflow Advisories
203
+
204
+ > Workflow Advisories provides non-blocking, low-noise hints and
205
+ > affected-surface checks around large reads, first-impacting edits, refactors,
206
+ > and Stop. Paths, commands, thresholds, timeouts, and output budgets remain
207
+ > consumer-owned.
208
+
209
+ Read [workflow-advisories.md](./workflow-advisories.md) in full. Reconcile its
210
+ complete `yes / later / no / existing / disable` matrix. If no choice exists,
211
+ ask: *"Should setup activate the portable Workflow Advisories for this
212
+ repository?"* Offer exactly **Yes**, **Later**, and **No**.
213
+
214
+ - **Yes** — reconcile only `workflowAdvisories` in the shared capability
215
+ profile and only the exact kit-owned hook commands named by the seed.
216
+ - **Later / No** — record the choice without hook wiring.
217
+ - **Existing** — adopt profile and wiring byte-safely.
218
+ - **Disable** — remove exact kit-owned commands first, then set `enabled:
219
+ false`; preserve every profile value and unknown key.
220
+
202
221
  ### 3. Section B — Triage labels
203
222
 
204
223
  > When `triage` processes an incoming issue it applies labels (or your tracker's equivalent). It needs strings you've actually configured, or it creates duplicates.
@@ -284,11 +303,12 @@ pre-existing consumer-owned census file. `yes`, `later`, `no`, and an adopted
284
303
  existing census are terminal for ordinary setup reruns. Only an explicit user
285
304
  request changes a recorded choice.
286
305
 
287
- For `docs/agents/workflow-capabilities.json`, reconcile only the selected
306
+ For `docs/agents/workflow-capabilities.json`, reconcile only each selected
288
307
  capability section. Never replace the whole file: Project Release, Memory
289
- Lifecycle, Worktree Lifecycle, future sections, and unknown consumer keys share
290
- this profile. Apply the Worktree Lifecycle transition and hook ownership rules
291
- from [worktree-lifecycle.md](./worktree-lifecycle.md) transactionally.
308
+ Lifecycle, Worktree Lifecycle, Workflow Advisories, future sections, and
309
+ unknown consumer keys share this profile. Apply the transition and hook
310
+ ownership rules from [worktree-lifecycle.md](./worktree-lifecycle.md) and
311
+ [workflow-advisories.md](./workflow-advisories.md) transactionally.
292
312
 
293
313
  For Memory Lifecycle, use only the deterministic setup helper from Section A5.
294
314
  Do not copy templates with shell commands or edit the capability profile by
@@ -0,0 +1,97 @@
1
+ # Workflow Advisories setup contract
2
+
3
+ Workflow Advisories is one opt-in capability backed by the consumer-owned
4
+ `docs/agents/workflow-capabilities.json` profile. Enabling it activates seven
5
+ non-blocking rows: large-read, baseline, pre-refactor, affected-surface Stop,
6
+ convention freshness, migration-artifact reminder, and LoC forewarning. Every
7
+ threshold, glob, branch rule, source map, command, timeout, and output budget
8
+ stays consumer-owned.
9
+
10
+ ## Choice matrix
11
+
12
+ | State | Setup action |
13
+ |---|---|
14
+ | `missing` | Ask `yes / later / no`; do not infer or write before the answer. |
15
+ | `yes` | Reconcile the enabled profile and exact kit-owned hook commands. |
16
+ | `later` | Record the retryable deferral; do not activate hooks. |
17
+ | `no` | Record the opt-out; do not activate hooks. |
18
+ | `existing` | Adopt the consumer profile byte-safely and preserve unknown keys. |
19
+ | `disable` | Remove only kit-owned hook wiring, then set `enabled: false`; retain profile values and unknown keys. |
20
+
21
+ ```json workflow-advisories-setup-effects
22
+ [
23
+ {"state":"missing","choice":null,"operations":[]},
24
+ {"state":"yes","choice":"yes","operations":["record-choice","reconcile-profile-enabled","reconcile-hook-wiring"]},
25
+ {"state":"later","choice":"later","operations":["record-choice"]},
26
+ {"state":"no","choice":"no","operations":["record-choice"]},
27
+ {"state":"existing","choice":"yes","operations":["adopt-existing"]},
28
+ {"state":"disable","choice":"yes","operations":["remove-hook-wiring","update-profile-disabled"]}
29
+ ]
30
+ ```
31
+
32
+ ## Profile shape
33
+
34
+ Reconcile only `workflowAdvisories`; preserve every sibling section and unknown
35
+ key. Consumer values are never normalized on adoption.
36
+
37
+ ```json
38
+ {
39
+ "workflowAdvisories": {
40
+ "choice": "yes",
41
+ "enabled": true,
42
+ "largeRead": {"tools": ["Read"], "lineThreshold": 500, "outputBudget": 500},
43
+ "baseline": {
44
+ "sourceGlobs": ["src/**"],
45
+ "branchRegex": "^(?:feat|fix)/",
46
+ "manifestPath": ".agent/baseline.json",
47
+ "stateDir": ".claude/logs/advisory-state",
48
+ "outputBudget": 500
49
+ },
50
+ "preRefactor": {
51
+ "promptMatchers": ["refactor"],
52
+ "surfaces": [],
53
+ "timeoutSeconds": 15,
54
+ "outputBudget": 1000
55
+ },
56
+ "stopChecks": {"surfaces": [], "timeoutSeconds": 30, "outputBudget": 1000},
57
+ "freshness": {"documents": [], "outputBudget": 1000},
58
+ "migration": {
59
+ "commandMatchers": [],
60
+ "artifact": "",
61
+ "refreshCommand": [],
62
+ "outputBudget": 500
63
+ },
64
+ "locForewarn": {
65
+ "branchRegex": "^(?:feat|fix)/(\\d+)-",
66
+ "issueCommand": ["gh", "issue", "view", "{issue}", "--json", "body", "-q", ".body"],
67
+ "timeoutSeconds": 5,
68
+ "outputBudget": 500
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Empty command surfaces are honest inactive defaults. Setup recommends concrete
75
+ project commands from the tools already present, then asks before activating.
76
+
77
+ ## Hook ownership
78
+
79
+ - PreToolUse on Read:
80
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/recon-size-hint.py"`
81
+ - PreToolUse on Edit/Write/MultiEdit:
82
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/baseline-capture-hint.py"`
83
+ - UserPromptSubmit:
84
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-refactor-sweep.py"`
85
+ - Stop:
86
+ `"$CLAUDE_PROJECT_DIR/.claude/hooks/typecheck-on-stop.sh"`
87
+ - SessionStart convention freshness:
88
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/convention-drift-hint.py"`
89
+ - PostToolUse on Bash migration commands:
90
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/migration-snapshot-reminder.py"`
91
+ - SessionStart LoC forewarning:
92
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/loc-offender-forewarn.py"`
93
+
94
+ All adapters are non-blocking. A failed or timed-out configured command remains
95
+ visibly failed in hook context; it is never reported as green. Issue lookup
96
+ failures silence only the LoC forewarning: the existing pre-push gate remains
97
+ the enforcing authority and stays fail-closed.
package/README.md CHANGED
@@ -332,6 +332,33 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.25.0
336
+
337
+ - added: `.claude/hooks/convention-drift-hint.py`
338
+ - added: `.claude/hooks/loc-offender-forewarn.py`
339
+ - added: `.claude/hooks/migration-snapshot-reminder.py`
340
+ - added: `scripts/workflow-advisories/capabilities.json`
341
+ - changed: `.agents/skills/setup-workflow/workflow-advisories.md`
342
+ - changed: `.claude/hooks/_hook_utils.py`
343
+ - changed: `.claude/hooks/skill-drift-hint.py`
344
+ - changed: `.claude/skills/setup-workflow/workflow-advisories.md`
345
+ - changed: `scripts/loc_offender_gate.py`
346
+ - changed: `scripts/workflow-advisories/core.py`
347
+
348
+ ### 0.24.0
349
+
350
+ - added: `.agents/skills/setup-workflow/workflow-advisories.md`
351
+ - added: `.claude/hooks/baseline-capture-hint.py`
352
+ - added: `.claude/hooks/pre-refactor-sweep.py`
353
+ - added: `.claude/hooks/recon-size-hint.py`
354
+ - added: `.claude/hooks/typecheck-on-stop.py`
355
+ - added: `.claude/hooks/typecheck-on-stop.sh`
356
+ - added: `.claude/skills/setup-workflow/workflow-advisories.md`
357
+ - added: `scripts/workflow-advisories/core.py`
358
+ - changed: `.agents/skills/setup-workflow/SKILL.md`
359
+ - changed: `.claude/hooks/_hook_utils.py`
360
+ - changed: `.claude/skills/setup-workflow/SKILL.md`
361
+
335
362
  ### 0.23.0
336
363
 
337
364
  - added: `assets/memory-templates/meta_decision_layer_choice.md`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.23.0",
2
+ "kitVersion": "0.25.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -573,7 +573,7 @@
573
573
  "kind": "skill",
574
574
  "ownerSkill": "setup-workflow",
575
575
  "surface": "codex",
576
- "sha256": "247250aa38a81b542600db42cfe4d02eecc1236e8af47df1f4def627f4df0668",
576
+ "sha256": "96c0c3ba9ed6b194030a8c45be618caf934e9c4ca8415ea19d726af33cd763cb",
577
577
  "mode": 420,
578
578
  "origin": "kit"
579
579
  },
@@ -613,6 +613,15 @@
613
613
  "mode": 420,
614
614
  "origin": "kit"
615
615
  },
616
+ {
617
+ "path": ".agents/skills/setup-workflow/workflow-advisories.md",
618
+ "kind": "skill",
619
+ "ownerSkill": "setup-workflow",
620
+ "surface": "codex",
621
+ "sha256": "81ce0f5573d5f062adbc87149172e74ab322315bf348025c0ff2c27f8d8ec67b",
622
+ "mode": 420,
623
+ "origin": "kit"
624
+ },
616
625
  {
617
626
  "path": ".agents/skills/setup-workflow/workflow-overview.md",
618
627
  "kind": "skill",
@@ -877,10 +886,17 @@
877
886
  {
878
887
  "path": ".claude/hooks/_hook_utils.py",
879
888
  "kind": "hook",
880
- "sha256": "58a7a8ad6cd4f1660e95343d7a0c637cf8aee92948a9c554befa5851723b3f48",
889
+ "sha256": "9ad70fa80a57c7712df78718c578b58295f446c13a0f1863c69680f3889dd387",
881
890
  "mode": 420,
882
891
  "origin": "kit"
883
892
  },
893
+ {
894
+ "path": ".claude/hooks/baseline-capture-hint.py",
895
+ "kind": "hook",
896
+ "sha256": "8f5cddfbb567afc677cf51f39d37e10804bf03e93d7d2fac7793cb5a42473ed3",
897
+ "mode": 493,
898
+ "origin": "kit"
899
+ },
884
900
  {
885
901
  "path": ".claude/hooks/branch-context.py",
886
902
  "kind": "hook",
@@ -895,6 +911,13 @@
895
911
  "mode": 493,
896
912
  "origin": "kit"
897
913
  },
914
+ {
915
+ "path": ".claude/hooks/convention-drift-hint.py",
916
+ "kind": "hook",
917
+ "sha256": "f89fc3f3f9d9b7508e3cdeeedd0e522af11ae10e638457da3910bfe4e43579f2",
918
+ "mode": 493,
919
+ "origin": "kit"
920
+ },
898
921
  {
899
922
  "path": ".claude/hooks/drift-guard.py",
900
923
  "kind": "hook",
@@ -923,10 +946,38 @@
923
946
  "mode": 493,
924
947
  "origin": "kit"
925
948
  },
949
+ {
950
+ "path": ".claude/hooks/loc-offender-forewarn.py",
951
+ "kind": "hook",
952
+ "sha256": "28ba5edb1898345fd852d1f420bb64c5a2e0dfd66aa8d2b752899fc28a6f80dd",
953
+ "mode": 493,
954
+ "origin": "kit"
955
+ },
956
+ {
957
+ "path": ".claude/hooks/migration-snapshot-reminder.py",
958
+ "kind": "hook",
959
+ "sha256": "693227fae1aa3e782ed5b61a9e8c3fc8c1880a617efd14cb86ca7f38d9a8f204",
960
+ "mode": 493,
961
+ "origin": "kit"
962
+ },
963
+ {
964
+ "path": ".claude/hooks/pre-refactor-sweep.py",
965
+ "kind": "hook",
966
+ "sha256": "24e8554da18b00f2dbbcf6a3e186f9fb675a4b18471ce60e0ac63000f95a4906",
967
+ "mode": 493,
968
+ "origin": "kit"
969
+ },
970
+ {
971
+ "path": ".claude/hooks/recon-size-hint.py",
972
+ "kind": "hook",
973
+ "sha256": "3fd702d8875552ce3b79b5db1dda51403031bcacc80901a13553d2f60a2deb9b",
974
+ "mode": 493,
975
+ "origin": "kit"
976
+ },
926
977
  {
927
978
  "path": ".claude/hooks/skill-drift-hint.py",
928
979
  "kind": "hook",
929
- "sha256": "4842e1c6dfd1042eb924d9cc36a8aa5d0c5a1e95f71e33a82db3322dc9d13fd0",
980
+ "sha256": "2ce5c8cfc85dc76cf93abf1cd061440b8030109231a45a0920e36a5f1c06694a",
930
981
  "mode": 493,
931
982
  "origin": "kit"
932
983
  },
@@ -944,6 +995,20 @@
944
995
  "mode": 493,
945
996
  "origin": "kit"
946
997
  },
998
+ {
999
+ "path": ".claude/hooks/typecheck-on-stop.py",
1000
+ "kind": "hook",
1001
+ "sha256": "459e5f25f5fe97c62687eff1d3e7e76051ba8390e2b2908fc50c16ea53a95250",
1002
+ "mode": 493,
1003
+ "origin": "kit"
1004
+ },
1005
+ {
1006
+ "path": ".claude/hooks/typecheck-on-stop.sh",
1007
+ "kind": "hook",
1008
+ "sha256": "1bb57f53d53d18af92a8c7ce63f46be0658ae7d675a95d387646b4232001f1bc",
1009
+ "mode": 493,
1010
+ "origin": "kit"
1011
+ },
947
1012
  {
948
1013
  "path": ".claude/skills/ask-matt/SKILL.md",
949
1014
  "kind": "skill",
@@ -1642,7 +1707,7 @@
1642
1707
  "kind": "skill",
1643
1708
  "ownerSkill": "setup-workflow",
1644
1709
  "surface": "claude",
1645
- "sha256": "247250aa38a81b542600db42cfe4d02eecc1236e8af47df1f4def627f4df0668",
1710
+ "sha256": "96c0c3ba9ed6b194030a8c45be618caf934e9c4ca8415ea19d726af33cd763cb",
1646
1711
  "mode": 420,
1647
1712
  "origin": "kit"
1648
1713
  },
@@ -1682,6 +1747,15 @@
1682
1747
  "mode": 420,
1683
1748
  "origin": "kit"
1684
1749
  },
1750
+ {
1751
+ "path": ".claude/skills/setup-workflow/workflow-advisories.md",
1752
+ "kind": "skill",
1753
+ "ownerSkill": "setup-workflow",
1754
+ "surface": "claude",
1755
+ "sha256": "81ce0f5573d5f062adbc87149172e74ab322315bf348025c0ff2c27f8d8ec67b",
1756
+ "mode": 420,
1757
+ "origin": "kit"
1758
+ },
1685
1759
  {
1686
1760
  "path": ".claude/skills/setup-workflow/workflow-overview.md",
1687
1761
  "kind": "skill",
@@ -2106,7 +2180,7 @@
2106
2180
  {
2107
2181
  "path": "scripts/loc_offender_gate.py",
2108
2182
  "kind": "script",
2109
- "sha256": "3e96b180bbe922c85ea511da94f118b6d8d8937bc000c8a31b1a88bac211545e",
2183
+ "sha256": "a66650b3b194a00ecc3ba37acae4bbb1aeeb57762d27fb2dfd3bb1fc2734a8a5",
2110
2184
  "mode": 493,
2111
2185
  "origin": "kit"
2112
2186
  },
@@ -2194,6 +2268,20 @@
2194
2268
  "mode": 420,
2195
2269
  "origin": "kit"
2196
2270
  },
2271
+ {
2272
+ "path": "scripts/workflow-advisories/capabilities.json",
2273
+ "kind": "doc",
2274
+ "sha256": "04c731394fa458c141870fb89f2432092b3749955eccbda9b9b8db09636526c9",
2275
+ "mode": 420,
2276
+ "origin": "kit"
2277
+ },
2278
+ {
2279
+ "path": "scripts/workflow-advisories/core.py",
2280
+ "kind": "script",
2281
+ "sha256": "dc47bc20ed1107d230f45ff7158e4fd81d6473be515e03cbd52274f3e87d840d",
2282
+ "mode": 420,
2283
+ "origin": "kit"
2284
+ },
2197
2285
  {
2198
2286
  "path": "scripts/worktree-lifecycle/capabilities.json",
2199
2287
  "kind": "doc",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import json
13
+ import re
13
14
  import subprocess
14
15
  import sys
15
16
  from pathlib import Path
@@ -25,6 +26,29 @@ class GateError(RuntimeError):
25
26
  """A condition the gate cannot evaluate → fail-closed (exit 1)."""
26
27
 
27
28
 
29
+ _ISSUE_MARKER_RE = re.compile(r"<!--\s*loc-offender:\s*([^>]+?)\s*-->")
30
+
31
+
32
+ def parse_issue_marker(body: str) -> list[str]:
33
+ """Read the machine marker planted by board-sync at buildability transition."""
34
+ match = _ISSUE_MARKER_RE.search(body or "")
35
+ return [
36
+ path.strip() for path in match.group(1).split(",") if path.strip()
37
+ ] if match else []
38
+
39
+
40
+ def forewarning_context(body: str, max_lines: int, offenders: set[str]) -> str | None:
41
+ """Render a non-blocking SessionStart warning from the gate-owned contracts."""
42
+ hits = sorted(set(parse_issue_marker(body)) & offenders)
43
+ if not hits:
44
+ return None
45
+ paths = ", ".join(hits)
46
+ return (
47
+ f"LoC offender advisory: {paths} is named by the issue marker and remains "
48
+ f"above the gate threshold ({max_lines} lines). The pre-push gate stays authoritative."
49
+ )
50
+
51
+
28
52
  def _git(args, cwd=None) -> str:
29
53
  r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
30
54
  if r.returncode != 0:
@@ -0,0 +1,221 @@
1
+ """Profile-driven decisions for independently activatable Safety Guardrails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from search import scan as scan_search
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class GuardDecision:
17
+ action: str
18
+ message: str = ""
19
+ log_message: str = ""
20
+
21
+
22
+ def load_profile(path: Path) -> dict[str, Any]:
23
+ document = json.loads(path.read_text(encoding="utf-8"))
24
+ section = document.get("safetyGuardrails")
25
+ if not isinstance(section, dict):
26
+ return {}
27
+ return section
28
+
29
+
30
+ def inactive() -> GuardDecision:
31
+ return GuardDecision("allow")
32
+
33
+
34
+ def _secret_name_is_sensitive(name: str, policy: dict[str, Any]) -> bool:
35
+ suffixes = tuple(policy.get("safeTemplateSuffixes") or ())
36
+ if name.startswith(".env"):
37
+ return not any(name.endswith(suffix) for suffix in suffixes)
38
+ return name in set(policy.get("sensitiveNames") or ())
39
+
40
+
41
+ def _secret_path_is_sensitive(path: str, policy: dict[str, Any]) -> bool:
42
+ candidate = Path(path)
43
+ if _secret_name_is_sensitive(candidate.name, policy):
44
+ return True
45
+ normalized = str(candidate).replace("\\", "/")
46
+ return any(fragment in normalized for fragment in policy.get("sensitivePathFragments") or ())
47
+
48
+
49
+ def secret_decision(policy: dict[str, Any], payload: dict[str, Any]) -> GuardDecision:
50
+ if policy.get("enabled") is not True:
51
+ return inactive()
52
+ tool = payload.get("tool_name")
53
+ tool_input = payload.get("tool_input") or {}
54
+ if tool == "Bash":
55
+ return _secret_bash_decision(policy, str(tool_input.get("command") or ""))
56
+ path = str(tool_input.get("file_path") or "")
57
+ if not path or tool not in {"Read", "Edit", "Write", "MultiEdit"}:
58
+ return inactive()
59
+ if tool == "Write" and Path(path).name.startswith(".env"):
60
+ return inactive()
61
+ if not _secret_path_is_sensitive(path, policy):
62
+ return inactive()
63
+ return GuardDecision(
64
+ "block",
65
+ f"BLOCKED: access to sensitive path '{path}' is not allowed.",
66
+ f"blocked tool={tool} path={path!r}",
67
+ )
68
+
69
+
70
+ _DUMP_COMMANDS = {
71
+ "cat", "head", "tail", "less", "more", "strings", "nl", "xxd", "od",
72
+ "bat", "tac", "rev", "grep", "egrep", "fgrep", "rg", "ag", "awk", "sed", "cut",
73
+ }
74
+ _QUIET_GREP = {"grep", "egrep", "fgrep", "rg", "ag"}
75
+ _INSTALL_RE = re.compile(
76
+ r"\b(?P<manager>npm|pnpm|yarn|bun)\s+(?:install|i|ci)\b"
77
+ )
78
+ _BACKGROUND_RE = re.compile(r"(?<![<>&])&[ \t]*(?:;|$)", re.MULTILINE)
79
+
80
+
81
+ def _secret_bash_decision(policy: dict[str, Any], command: str) -> GuardDecision:
82
+ if not command:
83
+ return inactive()
84
+ try:
85
+ segments = re.split(r"\n|;|&&|\|\||\|", command)
86
+ for segment in segments:
87
+ tokens = shlex.split(segment)
88
+ dumps = set(tokens) & _DUMP_COMMANDS
89
+ paths = [token for token in tokens if _secret_path_is_sensitive(token, policy)]
90
+ if not dumps or not paths:
91
+ continue
92
+ if dumps <= _QUIET_GREP and any(flag == "--quiet" or flag.startswith("-q") for flag in tokens):
93
+ continue
94
+ return GuardDecision(
95
+ "block",
96
+ f"BLOCKED: command would print sensitive path '{paths[0]}' to the transcript.",
97
+ f"blocked bash secret dump path={paths[0]!r}",
98
+ )
99
+ except ValueError:
100
+ return inactive()
101
+ return inactive()
102
+
103
+
104
+ def _effective_cwd(command: str, match_pos: int, fallback: Path) -> Path:
105
+ target = None
106
+ for match in re.finditer(r"(?:^|[;&|]\s*)cd\s+([^\s;&|]+)", command):
107
+ if match.end() <= match_pos:
108
+ target = match.group(1).strip("'\"")
109
+ if target is None:
110
+ return fallback
111
+ path = Path(target).expanduser()
112
+ return path if path.is_absolute() else fallback / path
113
+
114
+
115
+ def _detected_manager(policy: dict[str, Any], start: Path) -> tuple[str, Path] | None:
116
+ lockfiles = policy.get("lockfiles") or {}
117
+ try:
118
+ current = start.resolve()
119
+ except OSError:
120
+ return None
121
+ while True:
122
+ for filename, manager in lockfiles.items():
123
+ candidate = current / filename
124
+ if candidate.is_file():
125
+ return str(manager), candidate
126
+ if current.parent == current:
127
+ return None
128
+ current = current.parent
129
+
130
+
131
+ def package_manager_decision(
132
+ policy: dict[str, Any],
133
+ payload: dict[str, Any],
134
+ cwd: Path,
135
+ ) -> GuardDecision:
136
+ if policy.get("enabled") is not True or payload.get("tool_name") != "Bash":
137
+ return inactive()
138
+ command = str((payload.get("tool_input") or {}).get("command") or "")
139
+ match = _INSTALL_RE.search(command)
140
+ if match is None:
141
+ return inactive()
142
+ requested = match.group("manager")
143
+ detected = _detected_manager(policy, _effective_cwd(command, match.start(), cwd))
144
+ if detected is None or detected[0] == requested:
145
+ return inactive()
146
+ manager, lockfile = detected
147
+ return GuardDecision(
148
+ "block",
149
+ f"BLOCKED: `{match.group(0)}` conflicts with {lockfile.name}. "
150
+ f"Use `{manager} install` in this project.",
151
+ f"blocked package-manager requested={requested} expected={manager} lock={lockfile}",
152
+ )
153
+
154
+
155
+ def double_background_decision(
156
+ policy: dict[str, Any],
157
+ payload: dict[str, Any],
158
+ ) -> GuardDecision:
159
+ if policy.get("enabled") is not True or payload.get("tool_name") != "Bash":
160
+ return inactive()
161
+ surface = str(payload.get("surface") or "claude")
162
+ if surface not in set(policy.get("surfaces") or ()):
163
+ return inactive()
164
+ tool_input = payload.get("tool_input") or {}
165
+ is_background = tool_input.get("run_in_background") is True or (
166
+ str(tool_input.get("run_in_background") or "").lower() == "true"
167
+ )
168
+ command = str(tool_input.get("command") or "")
169
+ if not is_background or not _BACKGROUND_RE.search(command):
170
+ return inactive()
171
+ return GuardDecision(
172
+ "block",
173
+ "BLOCKED: run_in_background already detaches and captures this command; "
174
+ "remove the inner `&` background operator.",
175
+ "blocked double-background command",
176
+ )
177
+
178
+
179
+ def search_shim_decision(
180
+ policy: dict[str, Any],
181
+ payload: dict[str, Any],
182
+ ) -> GuardDecision:
183
+ if (
184
+ policy.get("enabled") is not True
185
+ or policy.get("detected") is not True
186
+ or payload.get("tool_name") != "Bash"
187
+ ):
188
+ return inactive()
189
+ command = str((payload.get("tool_input") or {}).get("command") or "")
190
+ finding = scan_search(command, set(policy.get("commandNames") or ()))
191
+ if finding is None:
192
+ return inactive()
193
+ reason, pattern = finding
194
+ return GuardDecision(
195
+ "block",
196
+ f"BLOCKED: search shim cannot safely evaluate pattern `{pattern}` ({reason}). "
197
+ "Use `--fixed-strings` or `command grep`.",
198
+ f"blocked search-shim breaker reason={reason}",
199
+ )
200
+
201
+
202
+ def evaluate(kind: str, profile: dict[str, Any], payload: dict[str, Any]) -> GuardDecision:
203
+ if kind == "secrets":
204
+ return secret_decision(profile.get("secrets") or {}, payload)
205
+ if kind == "package-manager":
206
+ return package_manager_decision(
207
+ profile.get("packageManager") or {},
208
+ payload,
209
+ Path.cwd(),
210
+ )
211
+ if kind == "double-background":
212
+ return double_background_decision(
213
+ profile.get("doubleBackground") or {},
214
+ payload,
215
+ )
216
+ if kind == "search-shim":
217
+ return search_shim_decision(
218
+ profile.get("searchShim") or {},
219
+ payload,
220
+ )
221
+ return inactive()