From c9fdb1124e3e7badba86553923c70aa49087e1d0 Mon Sep 17 00:00:00 2001
From: mertcan
|?Pd03(%r7t%0z5$V?WI_7w6>5bVnvy13~&oKGz6=h)5 z5^W@oX}id8D0%*g4rMc)(Uxw@IE5k?W1MH^;4Ab_wtR7d@Ut*sTm(S=y$cI@{zJ$X zPpmDk~?n@ObwrZIJ?c(|T?ewmuUTk`L(Jl;A^6wP75jy%*d?J})2(~66* zOpgV<&FIM?ZG)JpKlS+VW~Ff{@9^(G2YxaA^p?Avq07(GvULK+$dosDNwcZA%xr|M zsn*zTP7ZTsJ7H*go`vYpFkl#T6%i-)t(-y8%jNvy;}49*^GWu_c5Fs%_*-V&GbUkp zxFUvaBv7x@JkJF>#^96*JBJr80j5UWdJc)maw3QJK7RNAR9$?>wL+c6v|G&ee)LLH z 0=%Y z`)MX^^jPchm~E#j2Oby=c0IPVe`6s#TU6 vYkwqJD>{(`%W^-NaOBe<6cmHH9N&5;7jkAFR}nd=aj9pw}fKBIS>y(2!Dt znvgi(u|=Z`&diN_-Bqm43xSG)8}}Ps_02ZN+f`X?k7~%J9B6O44#vL5%6v5B9VuW> zXl`@m30wL2YH&kQzZCPRi-#{bfG#0^p8=SJuh;jH>J%N3HPr1h@c}POmmePI7`u-G zm*G!ce(Y0XsxG!SYx?W)>FAKAc;L_0_WZ>~FTaS0 wb@t z{{vKY-A?kOR #`yf@51tTdtKU=(TZKu LCrb%aiyNw(_a6T_#CrS1B-v$ ztuy0icVD#U#R%?5iJ{n7W4L&0TcFRwE|XGpHQ|ht&-3823BCMopda4@Arli+(e@SH z5s(joj|k)xv|DO&SSAWVktwo(;kv9*JQfY?qV1wh);SPYuy>v$#J3zS;m_&i$6cFQ zb5DO1oLohz)Lb}w8OXnq@Dfk#*KD_B 0+YUwIaDD Of3Hr0%M@?PJD8(hg1b6R4ea*AJY-x#-aD9W{SS80FM*pBA zk~Ix}69le(7I+txD%7k+)%hw?>{1=E7$dPHF~39-Ok5wARg`<;B|T--O4u5hd8&*} z=^Jx$OdWDPg0k$I1Ne0jJ?@e=UQ-1lAP+vGrqHMrv?P0z&`sbpI@Go;r0w=Ln87yG z%>s*K)_Cbj1!?z5*Tl;ada269tPceU8AKgn%2GzaedpiD4+AeT+c{&u-ttEcfLHJq zh+*zPjnF2#&e^< y?Ut(sNw@>lFKWj%mgRt=}-tp|2(SNP> zIDFtJ&&lQ3nly8^WLl$+y~4k?8 a-oM7x+$>GRFtR&duaTV(8B!NaXxVPlyS5|f&P#(COIF%d^i6|&P zV_l;qQBWB>?UvQupELTHR_Bt^5k>EoXUbZPr-3#;53#%xhS<1ig@iNL`9F8z %|0Qr&O72dB{I zbahIltDJh%0U`~b oYwdx)5&<2TQDU@zc8{_jYRW!WuLw8jx{$gIQ~OUh>s_ivUV#^kr9Inh5$t&8{8 zkBMevI`;9BdrCsNao?^|k zvu{x~Xcs-vcD^F@Q&-Kg823H3fQ+vY2j6?~m zT5~=$ZwPlBn)X~a_4*AAh%5a?Np=U4HC;HP#_wa+Z))EYLjZs)d}N{-m>BsP2ZDk4 zY4F;0Q4sccY$4-7&B!6R+epss=NankJ^=`p+rT|)mCa!o+If)}ro3Hf_IP*9Jdt)N ztlDV(F;tN5o7jqk@AXn?iMPr`y@RvPIX^>Qh1Y8gofjtXNPRf>Y2YXQGz%jZA}c2Q zG!yN!n#W!T!mI{Tf|53CA26NF?^lKh+yx>2!@|e}ier(CpY~k4#nt9TO3^1H-&4d% zq2?4F>e-|3HQ@HYMo)J5mL4Q2KMdTsx&_?^rFBSVP-b^TJ}93BeLv8iI?nDCxw<1b zN*cH19;D}s68GJOdsiOZRA}#on$RZ$LoLAeYtAEBNi)Wa^=~{|O8nv8!4kBH{uvBX z3Gb?U{5%?iKS^puYXaN*vQx(63{CDAS9|ml+0-NUzv>LQ;EA0;L mB z?>De@b)^ncfHMqP8Z%vyuZLjXbHS2>zJEEmAX*#D-YV3WWXfkwuNEdnOb&Q`ZLy3| z+@SSn*?7n8(e2btl$88}S&m)VGK}uK)) Ba!r{w~6d4tTco#I8GfUy3MNU^JTRY9%Q(v=xbz3Ok=V(f!|HFC4DCJe_ z+n$EjjUoT>C~0P~`IE{`gib5oufFQt^YZiH?U3yI?_SS6k=a-pr@YM6y_lTUcZI6~ zYtByb$ey|HJ|Tg3Igm9DilyKC57911Uch;*h|uGn^JXP$KXq4>FFmiLUSdYE7n=8( z)ZP_Qe=aB<6bs)ltk`Kykqt&g<`&Jiq04 eVIpsFo%O--*v;;K1@VgET)idG%ZNrnr_&&iV zZ6KZ-W53OB6Hx}wTi3fvStms_aL{b=9;z1f7a7f78J<}>T}t%#hDS%|3I(Lf*`Fsk zYo1!9opHWGSaBHqO*x0j107u=hHf6hT7Q{Gx|pimjMR5Ag!c 6nXL#qA5s z!Cgd nG0*xdOZ|3e0Zt^ONs;5CvyZb*O?@ySAz48_$ zVQ&t9Pnr*!wMXHnrEfDTP6sQWIEeYK?7b#TP#cS)$puN!*euUFYke89`fSwoOJ7|q z#w2`DK}9yiIJ>PFZopFiqp)((B2n(;J3E}|w7Zec30_wz+_mn)zq9?{x?uW-$|ymF zH-!iRM1<`iNf57p@J-{^Ws=Qv>!EWYhR%w$a9P#%zLo}5J5JA2{xlIwNXl)#$#eg( znprg0wOBBVfLr8&bZafD|N8S?!WS4NhOZ;;(&pVz6wG*FzjeQOB=T)64oX@EW?N~< zk-*4K9bTOwv`!wOZSZsuO99K7^__dOI^B59^W_sLz^X~-k5zfRmEj-mh(b>Rc2i9g zE?9kyq2^W1Z>v+{CVw+ bV#JHzrjjJ9zhv{sE2TG9 zR?@{sytbW+cC`|5XL4_k#!=O}{ds=v_u$ha{*6oW^Uv8mm_$$A{P2C9{rR`~{oX5Q z>d}w@(e44JV$XZHKYw)PCqGa@vP#mk!m(BYy6mTYuDPrk4sM9uvkY4^0AVB)@ymTI zBpA7TO_JMgALD+cOb?C_X)Sp3CM{P>hoCDDVeQDRAi^TZA|hSK94AiA64BppJ}~fc zpCpu=*9T+eg^WtB^^g=Viy!uIkLAPd7EZ|J;`ub5`RMFH@-~P%vm$J8#n$NHiw#@= zMWt&<89n2ixk!j%-LbF)JD-E+^vz(6uXPdkJaeK!dRUTd9?g4{nz_t0d8cXR+9Y%x zL_LmOPRYwPj}%^v5#lo<9x#Pu5`~*%LBJ{Ys^CZcZP7Svzz(Tlq;^Dzyirb-VePKM zVX%w;jw*op?*yvXAvIRdcZSrFM8$NJPTU^P)162@l^=`!jVWdFU+|d$O2lt!nOS_< zjkCOn#xo+)$T((+P~{2oc5=59(biI85UiS&T!I9oRxs#0TmDWQI$NC{UP{)$AIzrC zx{YY0oAje5x-df(P68bA9#P(Q&YSd=s7pRoC{0tL0@kCC> 7 z0p|CEhM_xOa3+^s (*bwXclYS*Cz+Uq0O9Q)uW zEB7M=b3S!1%Zcd2$CL!i(&w?F?so4bzGgYVGOlJqZCvsp_d5}Ww(Z)EdMRc45gv96 zJlYsG(F61bFagZg3=DH`KSnmP>F>{SsmB;^!qL3nj b5h4Xg+{^*;ZAfuGxN!K&E>Fx1_>zq6ia1jEa8z$pvHi+5 zK;3!+o}eS4+y4GNk=*wlOh~AkQuoOhm&9I#%~J3s?*RAjgwF;FZ6V9VYW>l@xjV CvT=s|5D3%s3UNiTAiV#GDmai z#btRk_}0GSJ&kpD%kOSF927g#@NF1yBFr-0UHM5Zd~yW0VA$(C7dsR(UBnH*nQhSF z3mR>m+%{yopo6aj<>RizcTW+>u8{c>g-u2Scpbpk2BPO}D7C=K%77F7qx2gO0Yr$8 zYbeEw<&JZlQR8p*fd~?BeUw0s)n!6Y=~t`yo9x8jy<=VWuj5zz9;wtED~Y+HoYSRI zPa-p=K~jq2KJe2-{w;tbw%eyPC9!vYBY3yRV @2GlWPF|l%RBaVl3hnf;tPza=iG{EUmrc^4Q)2sx7-8qj zZo4Mt%Z=`C+?p=si|Bpf(0*H1#?G)uKqc4386he7{Po>tKYA8nbnc-j1-xRCVSku* z)k8G79!Bjb8YNhqeYj||>s@mlFxfq6>1aNAz(w+yDbX?fjS;(}`p(5*G1DR7xe~Xn z__Lg_b*hXLt966}ReG{tyyi^#pYkzN9#4JXd|NMDCUNRc*q@gIm3HGB8Ns34`O>S< z6?{3X26Fy98V4v-k)=`V-hHC~@&L&hsm&&bD$&J)- >UJ)I){c?F>% z$Sib-67UYWgZ>oZfaJn}h@+%NJ`IkMsN~3C2n2@wgaIJ~wj%2>Ad0|4
KnToEaU z2~k3^&O$}HWTDa^D={IgC@6WTNN64^=|6QyivFh#Ig$zsqKxvq5*5i+iAsfx#(IkA z=te~jbwAY^ee#~j-(o$H-$DvwL!^KSNIz_dB&g>lQU(X|4EX~a!V3ERABO-5(*a}t z!;JywiCYX(8VABb@!wSs03iFn4xem6XoSH(NuHKJKHwyB?BD4BDmwr``aft{tAEh! zxDZ|tz~LXq6Boh{_zj+$Xj+|NJqa5CM>)CsG;;Q3H8~{M(WtOP~-QP)aCr z5&D#&ITQ(nJ@qw-RDUucyGp3?dB5%0u?UAY#l3`Cx#T>+7dukeffJm%p2xt(GeI VY2p9?yn)_8pbG#n_k;id{soZdf++w1 diff --git a/plugins/shim-cli/hooks/run-shim b/plugins/shim-cli/hooks/run-shim index 724f497..de09dbb 100755 --- a/plugins/shim-cli/hooks/run-shim +++ b/plugins/shim-cli/hooks/run-shim @@ -10,10 +10,6 @@ if command -v shim-hook >/dev/null 2>&1; then exec shim-hook "$client" fi -if command -v shim-guard-hook >/dev/null 2>&1; then - exec shim-guard-hook "$client" -fi - archive="$root/bin/shim.pyz" if [ -n "$root" ] && [ -f "$archive" ]; then for interpreter in python3.13 python3.12 python3.11 python3.10 python3.9 python3 python; do diff --git a/pyproject.toml b/pyproject.toml index 449c79d..e5943a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shim" -version = "0.3.3" +version = "1.0.0" description = "shim-cli: local sensitive-data detection, prompt reporting, opt-in prompt blocking, verified Claude tool-event masking, and API-traffic watching" readme = "README.md" requires-python = ">=3.10,<3.14" @@ -8,7 +8,7 @@ license = "Apache-2.0" license-files = ["LICENSE"] authors = [{ name = "shim Engineering" }] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", @@ -35,7 +35,6 @@ Documentation = "https://github.com/GetSHIM/shim-cli#readme" [project.scripts] shim = "shim_cli.cli.app:main" shim-hook = "shim_cli.hook:main" -shim-guard-hook = "shim_cli.hook:main" [dependency-groups] dev = [ @@ -55,7 +54,7 @@ build-backend = "uv_build" required-version = ">=0.12.5,<0.13" [tool.uv.build-backend] -module-name = ["shim_cli", "shim_guard"] +module-name = "shim_cli" module-root = "src" [tool.pytest.ini_options] diff --git a/scripts/benchmark_hook.py b/scripts/benchmark_hook.py index 16c8b75..77ac230 100644 --- a/scripts/benchmark_hook.py +++ b/scripts/benchmark_hook.py @@ -122,15 +122,12 @@ def benchmark(python: Path, samples_per_fixture: int) -> dict[str, object]: block_samples: list[float] = [] stop_samples: list[float] = [] custom_samples: list[float] = [] - with tempfile.TemporaryDirectory(prefix="shim-guard-benchmark-") as directory: + with tempfile.TemporaryDirectory(prefix="shim-benchmark-") as directory: temporary = Path(directory).resolve() config = temporary / "config.toml" config.write_text('[mode]\nuser-prompt = "enforce"\n', encoding="utf-8") config.chmod(0o600) environment = os.environ.copy() - # SHIM_CONFIG outranks the 0.2.0 name; leaving it set would measure - # whatever settings the developer happens to have. - environment.pop("SHIM_GUARD_CONFIG", None) environment["SHIM_CONFIG"] = str(config) environment["TMPDIR"] = str(temporary) # The configured ceiling of user patterns, on the same safe prompt. diff --git a/scripts/probe/study.py b/scripts/probe/study.py index 2afcf12..95c7d9a 100644 --- a/scripts/probe/study.py +++ b/scripts/probe/study.py @@ -131,14 +131,13 @@ def _environment(config: Path, state: Path | None = None) -> dict[str, str]: key: value for key, value in os.environ.items() if not key.startswith("CLAUDE") } environment["SHIM_CONFIG"] = str(config) - environment.pop("SHIM_GUARD_CONFIG", None) if state is not None: # Never the real ledger; the study writes its own and reads it back. # 0700 or shim refuses the directory as not private, and the failure is # swallowed: an empty ledger looks exactly like a session that did # nothing worth recording. state.mkdir(mode=0o700, parents=True, exist_ok=True) - environment["SHIM_GUARD_STATE_DIR"] = str(state) + environment["XDG_STATE_HOME"] = str(state) return environment diff --git a/src/shim_cli/__init__.py b/src/shim_cli/__init__.py index e19434e..5becc17 100644 --- a/src/shim_cli/__init__.py +++ b/src/shim_cli/__init__.py @@ -1 +1 @@ -__version__ = "0.3.3" +__version__ = "1.0.0" diff --git a/src/shim_cli/cli/diagnostics.py b/src/shim_cli/cli/diagnostics.py index 50e7efa..8add21f 100644 --- a/src/shim_cli/cli/diagnostics.py +++ b/src/shim_cli/cli/diagnostics.py @@ -129,7 +129,7 @@ def _codex_hooks_feature() -> Check: return Check("hooks_feature", "PASS", "Codex hook support is enabled.") -def _legacy_state(client: str) -> Check: +def _legacy_state(client: str, fragment: bool) -> Check: """R7: name every 0.2.0 shape that is still on disk. Changes nothing.""" from shim_cli.clients.copilot import settings as copilot_settings from shim_cli.config import legacy_config_path @@ -137,7 +137,7 @@ def _legacy_state(client: str) -> Check: from shim_cli.settings_files import StateKind, inspect_file found: list[str] = [] - if client == "copilot": + if client == "copilot" and not fragment: legacy = copilot_settings.legacy_target_path() state = inspect_file(legacy, copilot_settings.MAX_CONFIG_BYTES) if state.kind is StateKind.FILE and state.content is not None: @@ -146,10 +146,10 @@ def _legacy_state(client: str) -> Check: f"Hook file uses the old name at {legacy}; " "run shim install copilot to rename it" ) - if _has_legacy_fragment(client): + if fragment: found.append( - f"Hook installed in the 0.2.0 shape; run shim install {client} to " - "rewrite it, because 1.0 will not run this shape" + f"Hook installed in the 0.2.0 shape, which 1.0 does not run; " + f"run shim install {client}" ) settings_file = legacy_config_path() if settings_file is not None and settings_file.is_file(): @@ -169,7 +169,11 @@ def _legacy_state(client: str) -> Check: if not found: return Check("legacy_names", "PASS", "No 0.2.0 names are left on disk.") detail = ". ".join(found) - return Check("legacy_names", "WARN", f"{detail[0].lower()}{detail[1:]}.") + return Check( + "legacy_names", + "FAIL" if fragment else "WARN", + f"{detail[0].lower()}{detail[1:]}.", + ) def _has_legacy_fragment(client: str) -> bool: @@ -331,10 +335,7 @@ def _runner_check(client: str) -> Check: try: with tempfile.TemporaryDirectory(prefix="shim-doctor-") as directory: environment = os.environ.copy() - # SHIM_CONFIG outranks the 0.2.0 name; set both or the fixture - # runs under the user's own settings and reports a false failure. environment["SHIM_CONFIG"] = str(Path(directory).resolve() / "config.toml") - environment.pop("SHIM_GUARD_CONFIG", None) environment["TMPDIR"] = directory safe_result = _run_hook(command, safe, environment, timeout) block_result = _run_hook(command, blocked, environment, timeout) @@ -551,14 +552,13 @@ def doctor(*, client: str, as_json: bool) -> None: legacy_fragment = _has_legacy_fragment(client) hook_state = _hook_state(client, legacy_fragment) rows = _coverage_rows( - client, - legacy_fragment or (hook_state is not None and hook_state.status == "PASS"), + client, hook_state is not None and hook_state.status == "PASS" ) checks.extend( check for check in ( hook_state, - _legacy_state(client), + _legacy_state(client, legacy_fragment), _entity_settings(), _custom_patterns(), _session_record_check(), diff --git a/src/shim_cli/cli/resolution.py b/src/shim_cli/cli/resolution.py index ce639b9..657af0b 100644 --- a/src/shim_cli/cli/resolution.py +++ b/src/shim_cli/cli/resolution.py @@ -35,10 +35,7 @@ def skewed(self) -> bool: def archive_version(archive: Path) -> str | None: try: with zipfile.ZipFile(archive) as bundle: - try: - source = bundle.read("shim_cli/__init__.py").decode("utf-8") - except KeyError: - source = bundle.read("shim_guard/__init__.py").decode("utf-8") + source = bundle.read("shim_cli/__init__.py").decode("utf-8") except (OSError, KeyError, UnicodeDecodeError, zipfile.BadZipFile): return None found = _VERSION.search(source) @@ -69,7 +66,7 @@ def installed_plugins(home: Path | None = None) -> list[dict]: def resolve(plugin_root: Path | None = None, which=shutil.which) -> Resolution: - on_path = which("shim-hook") or which("shim-guard-hook") + on_path = which("shim-hook") root = plugin_root if root is None: configured = os.environ.get("CLAUDE_PLUGIN_ROOT") diff --git a/src/shim_cli/config.py b/src/shim_cli/config.py index a8e8fd2..c6a134f 100644 --- a/src/shim_cli/config.py +++ b/src/shim_cli/config.py @@ -29,10 +29,7 @@ def _default_path(directory: str, home: Path | None) -> Path: def config_path(home: Path | None = None) -> Path: try: - if home is None and ( - configured := os.environ.get("SHIM_CONFIG") - or os.environ.get("SHIM_GUARD_CONFIG") - ): + if home is None and (configured := os.environ.get("SHIM_CONFIG")): target = Path(configured).expanduser() else: target = _default_path("shim", home) @@ -43,9 +40,7 @@ def config_path(home: Path | None = None) -> Path: def legacy_config_path(home: Path | None = None) -> Path | None: """The 0.2.0 location, or None when a variable pins the path.""" - if home is None and ( - os.environ.get("SHIM_CONFIG") or os.environ.get("SHIM_GUARD_CONFIG") - ): + if home is None and os.environ.get("SHIM_CONFIG"): return None try: return _validated_path(_default_path("shim-guard", home)) diff --git a/src/shim_cli/session/ledger.py b/src/shim_cli/session/ledger.py index b91c5bf..e16239c 100644 --- a/src/shim_cli/session/ledger.py +++ b/src/shim_cli/session/ledger.py @@ -32,19 +32,11 @@ def _root_for(directory: str) -> Path: def root_path() -> Path: - configured = os.environ.get("SHIM_GUARD_STATE_DIR") - if not configured: - return _root_for("shim") - root = Path(configured).expanduser() - if not root.is_absolute() or ".." in root.parts: - raise LedgerError("ledger directory is invalid") - return root + return _root_for("shim") def legacy_root_path() -> Path | None: - """The 0.2.0 directory, or None when a variable pins the location.""" - if os.environ.get("SHIM_GUARD_STATE_DIR"): - return None + """The 0.2.0 directory.""" try: return _root_for("shim-guard") except LedgerError: diff --git a/src/shim_cli/session/spool.py b/src/shim_cli/session/spool.py index e6f9cb9..6b80196 100644 --- a/src/shim_cli/session/spool.py +++ b/src/shim_cli/session/spool.py @@ -26,12 +26,6 @@ def _identity() -> int: def root_path() -> Path: - configured = os.environ.get("SHIM_GUARD_SESSION_DIR") - if configured: - root = Path(configured).expanduser() - if not root.is_absolute() or ".." in root.parts: - raise SpoolError("session directory is invalid") - return root return Path(tempfile.gettempdir()) / f"shim-session-{_identity()}" diff --git a/src/shim_guard/__init__.py b/src/shim_guard/__init__.py deleted file mode 100644 index 6111b0e..0000000 --- a/src/shim_guard/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Former name of shim_cli. Kept so a 0.2.0 hook command keeps working.""" - -from shim_cli import __version__ - -__all__ = ["__version__"] diff --git a/src/shim_guard/hook.py b/src/shim_guard/hook.py deleted file mode 100644 index bb19987..0000000 --- a/src/shim_guard/hook.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Former name of shim_cli.hook. Kept so a 0.2.0 hook command keeps working.""" - -from shim_cli.hook import main - -__all__ = ["main"] - -if __name__ == "__main__": - main() diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index a185188..95bc770 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -76,7 +76,7 @@ def _copilot(monkeypatch, tmp_path: Path, version: str = "1.0.80") -> None: def _guard_config(monkeypatch, tmp_path: Path) -> Path: target = tmp_path / "settings" / "config.toml" - monkeypatch.setenv("SHIM_GUARD_CONFIG", str(target)) + monkeypatch.setenv("SHIM_CONFIG", str(target)) return target @@ -495,7 +495,7 @@ def test_doctor_coverage_reads_the_hook_file( @pytest.mark.parametrize("client", sorted(_CLIENT_FIXTURES)) -def test_doctor_counts_a_020_hook_as_installed( +def test_doctor_fails_a_020_hook_that_1_0_does_not_run( client: str, monkeypatch, tmp_path: Path ) -> None: from shim_cli.cli.integrations import client_plan @@ -519,24 +519,26 @@ def test_doctor_counts_a_020_hook_as_installed( ) + "\n" ) - warning = "WARN hook file uses the old name at" else: module = {"claude": claude_settings, "codex": codex_settings}[client] client_plan(client, "install").target.write_bytes( add_groups(b"{}", module.legacy_hook_groups()) ) - warning = ( - f"WARN hook installed in the 0.2.0 shape; run shim install {client} to " - "rewrite it, because 1.0 will not run this shape." - ) text, table, installed = _coverage(client) - assert f"PASS Coverage: {events} of {events} events installed." in text - assert table == ["yes"] * events - assert installed == [True] * events - assert warning in text + assert ( + f"WARN Coverage: 0 of {events} events installed; run shim install {client}." + in text + ) + assert table == ["no"] * events + assert installed == [False] * events + assert ( + f"FAIL hook installed in the 0.2.0 shape, which 1.0 does not run; " + f"run shim install {client}." in text + ) assert "hook group is not installed" not in text + assert runner.invoke(app, ["doctor", client]).exit_code == 2 def test_doctor_not_installed_names_the_command(monkeypatch, tmp_path: Path) -> None: @@ -784,7 +786,6 @@ def test_reset_restores_every_section_not_only_the_entity_list( def test_report_says_so_when_there_is_no_session(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) result = runner.invoke(app, ["report"]) @@ -793,7 +794,6 @@ def test_report_says_so_when_there_is_no_session(monkeypatch, tmp_path: Path) -> def test_report_renders_the_most_recent_session(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) from shim_cli.session import spool spool.append( @@ -816,8 +816,6 @@ def test_report_renders_the_most_recent_session(monkeypatch, tmp_path: Path) -> def test_ledger_purge_deletes_only_what_is_retained(monkeypatch, tmp_path: Path): - monkeypatch.setenv("SHIM_GUARD_STATE_DIR", str(tmp_path / "state")) - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) from shim_cli.session import ledger, spool ledger.append({"action": "mask", "entities": {"SECRET": 1}}) @@ -985,7 +983,7 @@ def test_install_creates_a_config_directory_that_does_not_exist_yet( def test_config_refuses_change_during_confirmation(monkeypatch, tmp_path): target = tmp_path / "config.toml" target.write_text('enabled_entities = ["EMAIL"]\n') - monkeypatch.setenv("SHIM_GUARD_CONFIG", str(target)) + monkeypatch.setenv("SHIM_CONFIG", str(target)) concurrent = b'enabled_entities = ["SECRET"]\n[mode]\nuser-prompt = "enforce"\n' def confirm(*args, **kwargs): @@ -1000,7 +998,7 @@ def confirm(*args, **kwargs): def test_config_refuses_file_created_during_confirmation(monkeypatch, tmp_path): target = tmp_path / "new-parent" / "config.toml" - monkeypatch.setenv("SHIM_GUARD_CONFIG", str(target)) + monkeypatch.setenv("SHIM_CONFIG", str(target)) concurrent = b"ledger = true\n" def confirm(*args, **kwargs): @@ -1170,7 +1168,6 @@ def test_a_reveal_that_is_not_allowed_is_refused( def test_the_doctor_fixture_ignores_the_user_s_own_settings( monkeypatch, tmp_path ) -> None: - """SHIM_CONFIG outranks the 0.2.0 name; the self-test must still be isolated.""" from shim_cli.cli.diagnostics import _runner_check target = tmp_path / "settings" / "config.toml" @@ -1178,7 +1175,6 @@ def test_the_doctor_fixture_ignores_the_user_s_own_settings( target.write_text('[mode]\nuser-prompt = "enforce"\n', encoding="utf-8") target.chmod(0o600) monkeypatch.setenv("SHIM_CONFIG", str(target)) - monkeypatch.delenv("SHIM_GUARD_CONFIG", raising=False) assert _runner_check("claude").status == "PASS" @@ -1246,8 +1242,6 @@ def _legacy_claude_settings(home: Path, *, foreign: bool = False) -> Path: def test_doctor_on_a_020_fragment_does_not_also_say_it_is_not_installed( monkeypatch, tmp_path: Path ) -> None: - """The review found both lines two apart. The first one is false: the hook - is installed, in the shape 0.2.0 wrote.""" home = _claude_home(monkeypatch, tmp_path) _claude(monkeypatch, tmp_path) _legacy_claude_settings(home) @@ -1256,8 +1250,8 @@ def test_doctor_on_a_020_fragment_does_not_also_say_it_is_not_installed( text = " ".join(unstyle(result.output).split()) assert ( - "hook installed in the 0.2.0 shape; run shim install claude to rewrite it, " - "because 1.0 will not run this shape." in text + "FAIL hook installed in the 0.2.0 shape, which 1.0 does not run; " + "run shim install claude." in text ) assert "hook group is not installed" not in text diff --git a/tests/cli/test_migration.py b/tests/cli/test_migration.py index e83547b..a202294 100644 --- a/tests/cli/test_migration.py +++ b/tests/cli/test_migration.py @@ -27,8 +27,6 @@ def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) monkeypatch.setenv("XDG_STATE_HOME", str(home / ".state")) monkeypatch.delenv("SHIM_CONFIG", raising=False) - monkeypatch.delenv("SHIM_GUARD_CONFIG", raising=False) - monkeypatch.delenv("SHIM_GUARD_STATE_DIR", raising=False) return home @@ -297,16 +295,14 @@ def test_doctor_names_every_old_shape_and_changes_nothing( text = runner.invoke(app, ["doctor", client]) checks = {item["name"]: item for item in json.loads(document.output)["checks"]} - assert checks["legacy_names"]["status"] == "WARN" + assert checks["legacy_names"]["status"] == "FAIL" + assert text.exit_code == 2 assert _says(text, "Settings are still at") assert _says(text, "Ledger files are still in") - if client == "copilot": - # Copilot's old shape is the file name, not a fragment inside it. - assert _says(text, "hook file uses the old name") - else: - # The 0.2.0 fragment is installed, in the old shape. Doctor used to say - # "not installed" two lines above this, which was false. - assert _says(text, "hook installed in the 0.2.0 shape") - assert not _says(text, "hook group is not installed") - assert _says(text, f"run shim install {client}") + assert _says( + text, + f"hook installed in the 0.2.0 shape, which 1.0 does not run; " + f"run shim install {client}", + ) + assert not _says(text, "hook group is not installed") assert _shim_state(home) == before diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 3cb2b76..a39db12 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -63,24 +63,19 @@ def test_unsafe_or_relative_settings_paths_are_rejected( with pytest.raises(ValueError, match="safely"): load_entities(link) - monkeypatch.setenv("SHIM_GUARD_CONFIG", "relative/config.toml") + monkeypatch.setenv("SHIM_CONFIG", "relative/config.toml") with pytest.raises(ValueError, match="path"): config_path() - monkeypatch.setenv("SHIM_GUARD_CONFIG", "~shim_cli_missing_user/config.toml") + monkeypatch.setenv("SHIM_CONFIG", "~shim_cli_missing_user/config.toml") with pytest.raises(ValueError, match="path"): config_path() -def test_the_new_configuration_variable_outranks_the_old_one( +def test_the_configuration_variable_names_the_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: new = tmp_path / "new.toml" - old = tmp_path / "old.toml" - - monkeypatch.setenv("SHIM_GUARD_CONFIG", str(old)) - assert config_path() == old - monkeypatch.setenv("SHIM_CONFIG", str(new)) assert config_path() == new diff --git a/tests/conftest.py b/tests/conftest.py index 5672a43..a450526 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,21 @@ from __future__ import annotations +import tempfile + import pytest @pytest.fixture(autouse=True) def _isolated_roots(monkeypatch, tmp_path): root = tmp_path / "shim-roots" - monkeypatch.delenv("SHIM_GUARD_STATE_DIR", raising=False) monkeypatch.setenv("XDG_STATE_HOME", str(root / "state")) monkeypatch.setenv("XDG_CONFIG_HOME", str(root / "config")) - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(root / "session")) - monkeypatch.delenv("SHIM_GUARD_CONFIG", raising=False) + temporary = root / "tmp" + temporary.mkdir(parents=True) + # gettempdir() caches its first answer; the spool and redaction files follow it. + monkeypatch.setenv("TMPDIR", str(temporary)) + monkeypatch.setattr(tempfile, "tempdir", str(temporary)) + monkeypatch.delenv("SHIM_CONFIG", raising=False) # A test that forgets `home=`, or an ambient client variable, would # otherwise reach the real client files. monkeypatch.setenv("HOME", str(root / "home")) diff --git a/tests/contracts/claude/test_claude_hook.py b/tests/contracts/claude/test_claude_hook.py index e80c53c..d40ec09 100644 --- a/tests/contracts/claude/test_claude_hook.py +++ b/tests/contracts/claude/test_claude_hook.py @@ -91,7 +91,7 @@ def test_claude_code_runner_blocks_with_a_private_redaction(tmp_path: Path) -> N result = _run( _payload("Contact alice@example.com"), tmp_path, - env_extra={"SHIM_GUARD_CONFIG": str(settings)}, + env_extra={"SHIM_CONFIG": str(settings)}, ) document = json.loads(result.stdout) path = Path(document["reason"].split(READ_INSTRUCTION, 1)[1]) @@ -131,9 +131,6 @@ def test_an_oversized_tool_event_passes_through_and_still_reaches_the_summary( trace: the session summary under-counted, and the one thing the user needed to know — which read went uninspected — was the thing that went missing. """ - session = tmp_path / "session" - session.mkdir(mode=0o700) - extra = {"SHIM_GUARD_SESSION_DIR": str(session)} raw = json.dumps( { "session_id": "oversized", @@ -145,7 +142,7 @@ def test_an_oversized_tool_event_passes_through_and_still_reaches_the_summary( separators=(",", ":"), ).encode() - result = _run(raw, tmp_path, extra) + result = _run(raw, tmp_path) assert result.returncode == 0 assert result.stderr == b"" @@ -153,7 +150,7 @@ def test_an_oversized_tool_event_passes_through_and_still_reaches_the_summary( records = [ json.loads(line) - for path in session.rglob("*.jsonl") + for path in tmp_path.rglob("*.jsonl") for line in path.read_text(encoding="utf-8").splitlines() if line.strip() ] @@ -180,8 +177,6 @@ def _read(tool: str, tool_input: dict, response: dict) -> bytes: def test_a_masked_read_tells_the_model_and_records_what_it_always_did( tmp_path: Path, ) -> None: - session = tmp_path / "session" - session.mkdir(mode=0o700) raw = _read( "Read", {"file_path": "/work/service/.env"}, @@ -194,7 +189,7 @@ def test_a_masked_read_tells_the_model_and_records_what_it_always_did( }, ) - result = _run(raw, tmp_path, {"SHIM_GUARD_SESSION_DIR": str(session)}) + result = _run(raw, tmp_path) assert (result.returncode, result.stderr) == (0, b"") assert result.stdout == ( @@ -207,7 +202,7 @@ def test_a_masked_read_tells_the_model_and_records_what_it_always_did( ) [line] = [ line - for path in session.rglob("*.jsonl") + for path in tmp_path.rglob("*.jsonl") for line in path.read_text(encoding="utf-8").splitlines() ] record = json.loads(line) diff --git a/tests/contracts/codex/test_hook.py b/tests/contracts/codex/test_hook.py index a3cb625..3564ad9 100644 --- a/tests/contracts/codex/test_hook.py +++ b/tests/contracts/codex/test_hook.py @@ -30,7 +30,7 @@ def _enforcing(tmp_path: Path, **extra: str) -> dict: target = tmp_path / "enforce.toml" target.write_text(ENFORCE_PROMPT, encoding="utf-8") environment = os.environ.copy() - environment["SHIM_GUARD_CONFIG"] = str(target) + environment["SHIM_CONFIG"] = str(target) environment["TMPDIR"] = str(tmp_path) environment.update(extra) return environment @@ -124,7 +124,7 @@ def test_hook_honors_entity_settings_and_rejects_invalid_settings( render_entities(("PHONE",)) + b'\n[mode]\nuser-prompt = "enforce"\n' ) environment = _enforcing(tmp_path) - environment["SHIM_GUARD_CONFIG"] = str(target) + environment["SHIM_CONFIG"] = str(target) _assert_output(_run(_payload("Contact alice@example.com"), env=environment), b"") phone = _run(_payload("Call +90 532 123 45 67"), env=environment) @@ -333,7 +333,7 @@ def block_output(decision, suggestion_path=None): env["SHIM_TEST_SECRET"] = "raw-value-must-not-leak" settings = tmp_path.parent / f"{tmp_path.name}-enforce.toml" settings.write_text(ENFORCE_PROMPT, encoding="utf-8") - env["SHIM_GUARD_CONFIG"] = str(settings) + env["SHIM_CONFIG"] = str(settings) env["TMPDIR"] = str(tmp_path) result = subprocess.run( (sys.executable, "-I", "-B", "-c", code), @@ -477,7 +477,7 @@ def test_hook_persists_only_the_redacted_prompt_in_os_temp(tmp_path: Path) -> No ) config = tmp_path / "enforce.toml" config.write_text(ENFORCE_PROMPT, encoding="utf-8") - env["SHIM_GUARD_CONFIG"] = str(config) + env["SHIM_CONFIG"] = str(config) prompt = "Contact persistence-canary@example.com" before = {item for item in tmp_path.rglob("*") if item.is_file()} diff --git a/tests/contracts/copilot/test_copilot_hook.py b/tests/contracts/copilot/test_copilot_hook.py index f640672..4343745 100644 --- a/tests/contracts/copilot/test_copilot_hook.py +++ b/tests/contracts/copilot/test_copilot_hook.py @@ -25,7 +25,7 @@ def _redaction_files(root): def _run(raw: bytes, tmp_path: Path) -> subprocess.CompletedProcess[bytes]: environment = os.environ.copy() - environment["SHIM_GUARD_CONFIG"] = str(tmp_path / "config.toml") + environment["SHIM_CONFIG"] = str(tmp_path / "config.toml") environment["TMPDIR"] = str(tmp_path) return subprocess.run( COMMAND, diff --git a/tests/contracts/test_compat_package.py b/tests/contracts/test_compat_package.py deleted file mode 100644 index 07a2061..0000000 --- a/tests/contracts/test_compat_package.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -MODULES = ("shim_cli.hook", "shim_guard.hook") -ENFORCE_PROMPT = ( - 'enabled_entities = ["EMAIL", "PHONE", "CREDIT_CARD", "IBAN", "IP_ADDRESS", ' - '"MAC_ADDRESS", "US_SSN", "TR_NATIONAL_ID", "TR_VKN", "SECRET", "DB_URI"]\n' - "\n[mode]\n" - 'user-prompt = "enforce"\n' -) -# The block response names a 0600 redaction file whose suffix is random. -_REDACTION = re.compile(rb"shim-redacted-[^\"]+") - - -def _payload(client: str, prompt: str) -> bytes: - if client == "copilot": - event: dict[str, object] = { - "sessionId": "session", - "timestamp": 1, - "cwd": "/workspace", - "prompt": prompt, - "transformedPrompt": prompt, - } - else: - event = { - "session_id": "thr_test", - "transcript_path": None, - "cwd": "/workspace", - "hook_event_name": "UserPromptSubmit", - "model": "gpt-5", - "turn_id": "turn_test", - "permission_mode": "default", - "prompt": prompt, - } - return json.dumps(event, separators=(",", ":")).encode() - - -FIXTURES = { - "safe": lambda client: _payload(client, "Explain merge sort."), - "finding": lambda client: _payload(client, "Contact alice@example.com"), - "unicode": lambda client: _payload(client, "Merhaba İstanbul 🌍 alice@example.com"), - "malformed": lambda client: b"{not json", - "empty": lambda client: b"", -} - - -def _run(module: str, client: str, raw: bytes, home: Path) -> tuple[int, bytes, bytes]: - home.mkdir(parents=True, exist_ok=True) - config = home / "enforce.toml" - config.write_text(ENFORCE_PROMPT, encoding="utf-8") - environment = os.environ.copy() - environment["TMPDIR"] = str(home) - environment["SHIM_CONFIG"] = str(config) - result = subprocess.run( - (sys.executable, "-I", "-B", "-m", module, client), - input=raw, - capture_output=True, - cwd=ROOT, - env=environment, - check=False, - timeout=60, - ) - scrub = str(home).encode() - return ( - result.returncode, - _REDACTION.sub(b"shim-redacted-X", result.stdout.replace(scrub, b" ")), - _REDACTION.sub(b"shim-redacted-X", result.stderr.replace(scrub, b" ")), - ) - - -@pytest.mark.parametrize("client", ("claude", "codex", "copilot")) -@pytest.mark.parametrize("fixture", tuple(FIXTURES)) -def test_the_old_module_answers_exactly_like_the_new_one( - client: str, fixture: str, tmp_path: Path -) -> None: - raw = FIXTURES[fixture](client) - new = _run("shim_cli.hook", client, raw, tmp_path / "new") - old = _run("shim_guard.hook", client, raw, tmp_path / "old") - - assert old == new - - -def test_the_old_package_exposes_the_hook_entry_point() -> None: - source = ( - "import shim_cli.hook, shim_guard, shim_guard.hook;" - "print(shim_guard.hook.main is shim_cli.hook.main," - " shim_guard.__version__ == shim_cli.__version__)" - ) - result = subprocess.run( - (sys.executable, "-I", "-B", "-c", source), - capture_output=True, - cwd=ROOT, - check=False, - timeout=60, - ) - - assert result.returncode == 0, result.stderr.decode() - assert result.stdout.split() == [b"True", b"True"] - - -def _modules(name: str) -> set[str]: - source = ( - f"import {name}; import json, sys;" - "sys.stdout.write(json.dumps(sorted(sys.modules)))" - ) - result = subprocess.run( - (sys.executable, "-I", "-B", "-c", source), - capture_output=True, - cwd=ROOT, - check=False, - timeout=60, - ) - assert result.returncode == 0, result.stderr.decode() - # The alias package and module are themselves the only permitted difference. - return { - module - for module in json.loads(result.stdout) - if module != "shim_cli" and not module.startswith("shim_guard") - } - - -def test_the_old_module_imports_nothing_the_new_one_does_not() -> None: - assert _modules("shim_guard.hook") - _modules("shim_cli.hook") == set() - - -@pytest.mark.parametrize("module", MODULES) -def test_neither_module_pulls_the_cli_or_the_proxy(module: str) -> None: - imported = _modules(module) - - assert not [name for name in imported if name.startswith("shim_cli.watch")] - assert not [name for name in imported if name in ("typer", "rich")] diff --git a/tests/contracts/test_naming.py b/tests/contracts/test_naming.py index bd5afbe..f59d6e5 100644 --- a/tests/contracts/test_naming.py +++ b/tests/contracts/test_naming.py @@ -19,15 +19,10 @@ SCANNED = ("src/shim_cli", "scripts") OLD_NAMES = ("SHIM Guard", "shim Guard", "shim_guard") -# The only references that may name the old package: the fallback read of a -# 0.2.0 archive, the old configuration variable, and the module spelling each +# The only references that may name the old package: the module spelling each # client matcher recognises so `install` replaces a 0.2.0 fragment in place. _LEGACY_MODULE = 'LEGACY_HOOK_MODULE = "shim_guard.hook"' ALLOWED = { - "src/shim_cli/cli/resolution.py": ('bundle.read("shim_guard/__init__.py")',), - "src/shim_cli/config.py": ( - 'os.environ.get(\n "SHIM_GUARD_CONFIG"\n )', - ), "src/shim_cli/clients/claude/settings.py": (_LEGACY_MODULE,), "src/shim_cli/clients/codex/settings.py": (_LEGACY_MODULE,), "src/shim_cli/clients/copilot/settings.py": (_LEGACY_MODULE,), @@ -69,33 +64,41 @@ def test_no_source_file_still_carries_the_old_name(prefix: str) -> None: assert not offenders, "the old name survives in:\n" + "\n".join(sorted(offenders)) -def test_the_compatibility_package_holds_only_re_exports() -> None: - files = sorted(_tracked("src/shim_guard")) +# The release notes are history and keep the names they shipped with. +HISTORY = "docs/releases/" - assert files == ["src/shim_guard/__init__.py", "src/shim_guard/hook.py"] - for relative in files: - body = (ROOT / relative).read_text(encoding="utf-8") - assert "def " not in body - assert "shim_cli" in body + +def test_no_shim_guard_module_script_or_variable_remains() -> None: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + + assert _tracked("src/shim_guard") == [] + assert list(pyproject["project"]["scripts"]) == ["shim", "shim-hook"] + assert pyproject["tool"]["uv"]["build-backend"]["module-name"] == "shim_cli" + this_file = str(Path(__file__).relative_to(ROOT)) + offenders = [] + for relative in _tracked("."): + if relative.startswith(HISTORY) or relative == this_file: + continue + try: + text = (ROOT / relative).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + offenders += [ + f"{relative}: {name}" + for name in ("shim-guard-hook", "SHIM_GUARD_", "shim_guard/") + if name in text + ] + + assert not offenders, "\n".join(sorted(offenders)) -# R1: the prose allowlist. Each entry is a file whose old-name mentions are the -# migration itself; everything else must be clean. -PROSE_ALLOWED = { - "docs/releases/0.2.0.md", # history, unchanged - "docs/releases/0.3.0.md", - "docs/compatibility.md", - "README.md", - "docs/privacy.md", - "plugins/shim-cli/README.md", -} PROSE_NAMES = ("SHIM Guard", "shim Guard") def test_no_prose_file_still_calls_the_product_by_its_old_name() -> None: offenders = [] for relative in _tracked("."): - if not relative.endswith(".md") or relative in PROSE_ALLOWED: + if not relative.endswith(".md") or relative.startswith(HISTORY): continue try: text = (ROOT / relative).read_text(encoding="utf-8") @@ -106,20 +109,6 @@ def test_no_prose_file_still_calls_the_product_by_its_old_name() -> None: assert not offenders, "\n".join(sorted(offenders)) -def test_the_allowlisted_prose_only_mentions_the_old_name_as_a_migration() -> None: - """An allowlist that stops being needed is an allowlist that rots.""" - unused = [ - relative - for relative in sorted(PROSE_ALLOWED) - if relative != "docs/releases/0.2.0.md" - and "shim-guard" not in (ROOT / relative).read_text(encoding="utf-8") - and "shim_guard" not in (ROOT / relative).read_text(encoding="utf-8") - and "SHIM_GUARD" not in (ROOT / relative).read_text(encoding="utf-8") - ] - - assert not unused, f"remove from PROSE_ALLOWED: {unused}" - - def test_the_release_notes_exist_for_the_declared_version() -> None: version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"] notes = ROOT / "docs" / "releases" / f"{version}.md" diff --git a/tests/contracts/test_readme.py b/tests/contracts/test_readme.py index cea7647..c542ed0 100644 --- a/tests/contracts/test_readme.py +++ b/tests/contracts/test_readme.py @@ -33,6 +33,16 @@ def test_the_readme_does_not_claim_more_tests_than_exist() -> None: assert int(collected.group(1)) >= int(claimed.group(1).replace(",", "")) +def test_the_readme_does_not_call_1_0_alpha() -> None: + text = (ROOT / "README.md").read_text(encoding="utf-8") + + assert "alpha" not in text.lower() + assert ( + "shim-cli is a best-effort guard, not a data-loss prevention boundary." + in " ".join(text.split()) + ) + + def test_the_readme_says_bare_numbers_are_not_phone_numbers() -> None: assert "timestamps, ids and decimals" in (ROOT / "README.md").read_text( encoding="utf-8" diff --git a/tests/plugins/test_launcher.py b/tests/plugins/test_launcher.py index e819ef2..26d0b33 100644 --- a/tests/plugins/test_launcher.py +++ b/tests/plugins/test_launcher.py @@ -102,7 +102,7 @@ def test_launcher_uses_the_bundled_archive_when_the_package_is_absent( "CLAUDE_PLUGIN_ROOT": str(root), "HOME": str(tmp_path), "TMPDIR": str(tmp_path), - "SHIM_GUARD_CONFIG": str(tmp_path / "config.toml"), + "SHIM_CONFIG": str(tmp_path / "config.toml"), } result = _run(client, environment) @@ -122,7 +122,7 @@ def test_launcher_uses_the_bundled_archive_when_the_package_is_absent( @pytest.mark.parametrize("client", CLIENTS) def test_launcher_prefers_the_package_on_path(client: str, tmp_path: Path) -> None: - marker = tmp_path / "shim-guard-hook" + marker = tmp_path / "shim-hook" marker.write_text("#!/bin/sh\nprintf '%s' \"PATH-HOOK:$1\"\n", encoding="utf-8") marker.chmod(0o755) root = tmp_path / "plugin" @@ -152,7 +152,7 @@ def test_launcher_stays_silent_on_a_safe_prompt( "CLAUDE_PLUGIN_ROOT": str(root), "HOME": str(tmp_path), "TMPDIR": str(tmp_path), - "SHIM_GUARD_CONFIG": str(tmp_path / "config.toml"), + "SHIM_CONFIG": str(tmp_path / "config.toml"), }, prompt="Explain merge sort.", ) @@ -254,14 +254,12 @@ def answer(interpreter: str, raw: bytes) -> tuple[int, bytes, bytes]: input=raw, capture_output=True, check=False, - # Each interpreter needs its own spool: conftest sets one session - # directory for the whole test, so without this the second run + # Each interpreter needs its own spool, or the second run # summarises the first run's records too. env=os.environ | { "TMPDIR": str(home), "SHIM_CONFIG": str(config), - "SHIM_GUARD_SESSION_DIR": str(home / "session"), "XDG_STATE_HOME": str(home / "state"), }, timeout=120, @@ -300,14 +298,13 @@ def _archive_members(path: Path) -> dict[str, bytes]: return {name: packaged.read(name) for name in names} -def test_archive_version_reads_the_new_layout_and_the_old_one(tmp_path: Path) -> None: +def test_archive_version_reads_the_archive(tmp_path: Path) -> None: from shim_cli.cli.resolution import archive_version - for package, expected in (("shim_cli", "9.9.9"), ("shim_guard", "0.2.0")): - bundle = tmp_path / f"{package}.pyz" - with zipfile.ZipFile(bundle, "w") as archive: - archive.writestr(f"{package}/__init__.py", f'__version__ = "{expected}"\n') - assert archive_version(bundle) == expected + bundle = tmp_path / "shim_cli.pyz" + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("shim_cli/__init__.py", '__version__ = "9.9.9"\n') + assert archive_version(bundle) == "9.9.9" empty = tmp_path / "empty.pyz" with zipfile.ZipFile(empty, "w") as archive: diff --git a/tests/plugins/test_marketplace.py b/tests/plugins/test_marketplace.py index 803d24a..51a1afa 100644 --- a/tests/plugins/test_marketplace.py +++ b/tests/plugins/test_marketplace.py @@ -77,7 +77,7 @@ def test_both_listings_name_the_same_plugin() -> None: claude = {plugin["name"] for plugin in json.loads(CLAUDE.read_text())["plugins"]} codex = {plugin["name"] for plugin in json.loads(CODEX.read_text())["plugins"]} - assert claude == codex == {"shim-cli", "shim-guard"} + assert claude == codex == {"shim-cli"} for listing, manifest in ( (CLAUDE, ROOT / "plugins/shim-cli/.claude-plugin/plugin.json"), @@ -89,27 +89,16 @@ def test_both_listings_name_the_same_plugin() -> None: ) -ALIAS_DESCRIPTION = ( - "Former name of shim-cli. Existing installs keep updating; " - "new installs should use shim-cli." -) - - @pytest.mark.parametrize("path", (CLAUDE, CODEX), ids=("claude", "codex")) def test_a_listing_is_named_for_the_product(path: Path) -> None: assert json.loads(path.read_text())["name"] == "shim-cli" @pytest.mark.parametrize("path", (CLAUDE, CODEX), ids=("claude", "codex")) -def test_the_alias_entry_keeps_a_020_install_resolvable(path: Path) -> None: - plugins = json.loads(path.read_text())["plugins"] - names = [plugin["name"] for plugin in plugins] - - assert names == ["shim-cli", "shim-guard"] - alias, current = plugins[1], plugins[0] - assert alias["description"] == ALIAS_DESCRIPTION - assert alias["source"] == current["source"], "the alias must not fork the source" - assert alias["category"] == current["category"] +def test_no_shim_guard_plugin_remains(path: Path) -> None: + assert [plugin["name"] for plugin in json.loads(path.read_text())["plugins"]] == [ + "shim-cli" + ] def test_both_listings_point_at_the_same_directory() -> None: diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index d0b1b19..15c0c51 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -102,14 +102,8 @@ def test_the_plugin_readme_documents_the_launcher_order() -> None: assert text.startswith("# shim-cli plugin\n") assert "`hooks/run-shim [plugin-root]`" in text assert "Python 3.9 or newer" in text - for step in ( - "`shim-hook` on `PATH`", - "`shim-guard-hook` on `PATH`", - "` /bin/shim.pyz`", - ): - assert step in text, step assert text.index("`shim-hook` on `PATH`") < text.index( - "`shim-guard-hook` on `PATH`" + "` /bin/shim.pyz`" ) assert "Codex sets no such variable" in text diff --git a/tests/session/test_failure_modes.py b/tests/session/test_failure_modes.py index 2e0b092..3a742f7 100644 --- a/tests/session/test_failure_modes.py +++ b/tests/session/test_failure_modes.py @@ -11,11 +11,6 @@ ROOT = Path(__file__).parents[2] -@pytest.fixture(autouse=True) -def _isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) - - def _run(raw: bytes, client: str = "claude") -> bytes: result = subprocess.run( (sys.executable, "-I", "-B", "-m", "shim_cli.hook", client), @@ -143,7 +138,6 @@ def test_tool_labels_are_safe_in_the_spool_and_summary(tool: str) -> None: def test_an_uninspectable_tool_event_is_still_recorded(monkeypatch, tmp_path) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) from shim_cli import hook from shim_cli.session import spool from shim_cli.session.record import NOT_INSPECTED @@ -176,7 +170,6 @@ def explode(*_args, **_kwargs): def test_uninspected_records_never_keep_raw_event_or_tool_labels( monkeypatch, tmp_path ) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) from shim_cli import hook from shim_cli.session import spool, summary @@ -229,7 +222,6 @@ def explode(*_args, **_kwargs): def test_the_summary_names_an_uninspected_event(monkeypatch, tmp_path) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) from shim_cli.session import spool, summary from shim_cli.session.record import NOT_INSPECTED diff --git a/tests/session/test_hook_session.py b/tests/session/test_hook_session.py index 1d849f9..2ef4e49 100644 --- a/tests/session/test_hook_session.py +++ b/tests/session/test_hook_session.py @@ -14,11 +14,6 @@ SESSION = "0199aa11-2233-4455-6677-889900aabbcc" -@pytest.fixture(autouse=True) -def _isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) - - def _run(payload: dict, client: str = "claude") -> bytes: result = subprocess.run( (sys.executable, "-I", "-B", "-m", "shim_cli.hook", client), @@ -124,9 +119,9 @@ def test_session_end_deletes_the_record() -> None: def test_recording_failure_never_blocks_a_tool_event( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - unusable = tmp_path / "unusable" + unusable = tmp_path / f"shim-session-{os.getuid()}" unusable.mkdir(mode=0o755) - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(unusable)) + monkeypatch.setenv("TMPDIR", str(tmp_path)) output = _run(_read_event("/work/service/.env")) diff --git a/tests/session/test_ledger.py b/tests/session/test_ledger.py index a8c2e4f..8edc16d 100644 --- a/tests/session/test_ledger.py +++ b/tests/session/test_ledger.py @@ -15,7 +15,7 @@ @pytest.fixture(autouse=True) def _isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SHIM_GUARD_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) def _entry(**changes: object) -> dict: @@ -143,15 +143,15 @@ def test_an_absent_ledger_reads_as_empty() -> None: def test_a_directory_other_users_can_read_is_refused(tmp_path: Path) -> None: - (tmp_path / "state").mkdir(mode=0o755, parents=True) + (tmp_path / "shim").mkdir(mode=0o755) with pytest.raises(ledger.LedgerError): ledger.append(_entry(), JANUARY) def test_a_symlinked_month_is_not_followed(tmp_path: Path) -> None: - root = tmp_path / "state" - root.mkdir(mode=0o700, parents=True) + root = tmp_path / "shim" + root.mkdir(mode=0o700) target = tmp_path / "stolen.jsonl" target.write_text("", encoding="utf-8") (root / "ledger-2026-01.jsonl").symlink_to(target) @@ -162,8 +162,8 @@ def test_a_symlinked_month_is_not_followed(tmp_path: Path) -> None: def test_a_symlinked_month_is_not_read(tmp_path: Path) -> None: - root = tmp_path / "state" - root.mkdir(mode=0o700, parents=True) + root = tmp_path / "shim" + root.mkdir(mode=0o700) target = tmp_path / "stolen.jsonl" target.write_text(json.dumps(_entry()) + "\n", encoding="utf-8") (root / "ledger-2026-01.jsonl").symlink_to(target) @@ -177,7 +177,6 @@ def test_remember_persists_only_a_session_key_and_keeps_storage_best_effort( from shim_cli.session import spool from shim_cli.session.record import Record - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) record = Record( client="claude", event="PostToolUse", @@ -220,7 +219,7 @@ def fail_spool(*_args, **_kwargs): def test_unsafe_directory_is_refused_before_pruning(tmp_path): - root = tmp_path / "state" + root = tmp_path / "shim" root.mkdir(mode=0o755) old = root / "ledger-2020-01.jsonl" old.write_bytes(b"keep\n") @@ -269,7 +268,6 @@ def test_a_month_present_on_both_sides_of_the_rename_is_merged( """ from shim_cli.cli import migration - monkeypatch.delenv("SHIM_GUARD_STATE_DIR", raising=False) monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) legacy = tmp_path / "state" / "shim-guard" current = tmp_path / "state" / "shim" @@ -292,7 +290,6 @@ def test_a_merge_that_would_cross_the_size_cap_leaves_the_old_file_alone( ) -> None: from shim_cli.cli import migration - monkeypatch.delenv("SHIM_GUARD_STATE_DIR", raising=False) monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) monkeypatch.setattr(ledger, "MAX_LEDGER_BYTES", 200) legacy = tmp_path / "state" / "shim-guard" diff --git a/tests/session/test_spool.py b/tests/session/test_spool.py index 291e228..8be7ea4 100644 --- a/tests/session/test_spool.py +++ b/tests/session/test_spool.py @@ -12,11 +12,6 @@ SESSION = "0199aa11-2233-4455-6677-889900aabbcc" -@pytest.fixture(autouse=True) -def _isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) - - def _entry(action: str = "mask", **changes: object) -> dict: entry = { "client": "claude", @@ -83,8 +78,8 @@ def test_the_spool_is_private_to_its_owner() -> None: assert stat.S_IMODE(path.stat().st_mode) == 0o600 -def test_a_directory_other_users_can_read_is_refused(tmp_path: Path) -> None: - root = tmp_path / "spools" +def test_a_directory_other_users_can_read_is_refused() -> None: + root = spool.root_path() root.mkdir(mode=0o755, parents=True) with pytest.raises(spool.SpoolError): @@ -92,7 +87,7 @@ def test_a_directory_other_users_can_read_is_refused(tmp_path: Path) -> None: def test_a_symlinked_spool_is_not_followed(tmp_path: Path) -> None: - root = tmp_path / "spools" + root = spool.root_path() root.mkdir(mode=0o700, parents=True) target = tmp_path / "stolen.jsonl" target.write_text("", encoding="utf-8") @@ -190,16 +185,6 @@ def test_a_stem_that_is_not_a_bare_name_is_refused(stem: str) -> None: spool.entries_for_stem(stem) -@pytest.mark.parametrize("configured", ["relative/path", "/tmp/../etc"]) -def test_a_configured_directory_that_is_not_a_plain_path_is_refused( - configured: str, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", configured) - - with pytest.raises(spool.SpoolError): - spool.root_path() - - def test_the_largest_record_this_code_can_produce_fits_the_entry_cap() -> None: from shim_cli.guard import ENTITY_TYPES from shim_cli.session.record import Record @@ -262,7 +247,6 @@ def test_concurrent_hook_processes_do_not_lose_or_tear_records( env={ **os.environ, "PYTHONPATH": str(root / "src"), - "SHIM_GUARD_SESSION_DIR": str(tmp_path / "spools"), }, ) for _ in range(workers) @@ -271,7 +255,6 @@ def test_concurrent_hook_processes_do_not_lose_or_tear_records( process.communicate(input=payload, timeout=120) assert {process.returncode for process in processes} == {0} - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) raw = next(spool.root_path().glob("*.jsonl")).read_text(encoding="utf-8") written = [line for line in raw.splitlines() if line.strip()] @@ -280,7 +263,6 @@ def test_concurrent_hook_processes_do_not_lose_or_tear_records( def test_a_full_spool_says_so_to_the_reader(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) monkeypatch.setattr(spool, "MAX_SPOOL_BYTES", 4_000) monkeypatch.setattr(spool, "MAX_ENTRY_BYTES", 400) @@ -294,7 +276,6 @@ def test_a_full_spool_says_so_to_the_reader(monkeypatch, tmp_path: Path) -> None def test_an_untouched_spool_is_not_capped(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("SHIM_GUARD_SESSION_DIR", str(tmp_path / "spools")) assert spool.capped("never-seen") is False diff --git a/uv.lock b/uv.lock index 15e6645..1b5e9bc 100644 --- a/uv.lock +++ b/uv.lock @@ -177,7 +177,7 @@ wheels = [ [[package]] name = "shim" -version = "0.3.3" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "phonenumbers" }, From 60f75cef50f81227d9f01b1ca3e741947199de37 Mon Sep 17 00:00:00 2001 From: mertcan Date: Tue, 15 Sep 2026 19:49:17 +0300 Subject: [PATCH 2/6] Two doctor and ledger lines that said something untrue Found in the 1.0 smoke pass. `shim ledger show` on an empty ledger told a person whose ledger was on to turn it on. `shim doctor` on a settings file that does not parse printed a second FAIL, "Custom patterns cannot be read; run shim config", whose advice fails for the same reason the first line already names. Co-Authored-By: Claude Opus 5 (1M context) --- src/shim_cli/cli/diagnostics.py | 14 +++++++------- src/shim_cli/cli/report.py | 10 +++++++++- tests/cli/test_cli.py | 26 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/shim_cli/cli/diagnostics.py b/src/shim_cli/cli/diagnostics.py index 8add21f..c4d42ee 100644 --- a/src/shim_cli/cli/diagnostics.py +++ b/src/shim_cli/cli/diagnostics.py @@ -260,19 +260,19 @@ def _entity_settings() -> Check: ) -def _custom_patterns() -> Check: - """A pattern that backtracks would overrun the hook's deadline in the client.""" +def _custom_patterns() -> Check | None: + """A pattern that backtracks would overrun the hook's deadline in the client. + + None when the settings file itself cannot be read: the entity check already + names that file and its fix. + """ from shim_cli.config import load_policy from shim_cli.guard.entities import entry_source, unsafe_pattern try: patterns = load_policy().custom except (OSError, ValueError): - return Check( - "custom_patterns", - "FAIL", - "Custom patterns cannot be read; run `shim config` to review them.", - ) + return None if not patterns: return Check("custom_patterns", "PASS", "No custom patterns are configured.") reasons = [ diff --git a/src/shim_cli/cli/report.py b/src/shim_cli/cli/report.py index dc002bf..564592b 100644 --- a/src/shim_cli/cli/report.py +++ b/src/shim_cli/cli/report.py @@ -126,9 +126,17 @@ def show_ledger(*, as_json: bool) -> None: if as_json: emit_json("ledger-show", "ok", days=0, events=0, entries=[]) else: + from shim_cli.config import load_policy + + try: + on = load_policy().ledger + except (OSError, ValueError): + on = False emit( "PASS", - "The ledger is empty. Turn it on with `shim config --ledger`.", + "The ledger is on and has recorded nothing yet." + if on + else "The ledger is empty. Turn it on with `shim config --ledger`.", ) return diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 95bc770..8f9b5a1 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1138,6 +1138,19 @@ def test_doctor_reports_a_pattern_that_is_already_in_the_file( assert checks["custom_patterns"]["status"] == "FAIL" +def test_doctor_names_an_unreadable_settings_file_once(monkeypatch, tmp_path) -> None: + target = _guard_config(monkeypatch, tmp_path) + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + target.write_text('enabled_entities = ["EMAIL"\n', encoding="utf-8") + target.chmod(0o600) + + result = runner.invoke(app, ["doctor", "claude", "--json"]) + + checks = {item["name"]: item for item in json.loads(result.output)["checks"]} + assert checks["entity_settings"]["status"] == "FAIL" + assert "custom_patterns" not in checks + + def test_reveal_is_written_honoured_and_removed(monkeypatch, tmp_path) -> None: target = _guard_config(monkeypatch, tmp_path) iban = "TR330006100519786457841326" @@ -1412,6 +1425,19 @@ def test_an_empty_ledger_says_how_to_turn_it_on(monkeypatch, tmp_path) -> None: assert "shim config --ledger" in unstyle(result.output) +def test_an_empty_ledger_that_is_on_does_not_say_turn_it_on( + monkeypatch, tmp_path +) -> None: + _guard_config(monkeypatch, tmp_path) + runner.invoke(app, ["config", "--ledger", "--yes"]) + + result = runner.invoke(app, ["ledger", "show"]) + + assert result.exit_code == 0 + assert "The ledger is on and has recorded nothing yet." in unstyle(result.output) + assert "shim config --ledger" not in unstyle(result.output) + + def test_a_failed_removal_is_not_reported_as_a_removal(monkeypatch, tmp_path) -> None: """`removed the old hook file at ...` printed even when the unlink raised, because the message sat outside the suppress. The file was still there and From 028890025c396bb2201a22c51bf84a9780ca7d4f Mon Sep 17 00:00:00 2001 From: mertcan Date: Tue, 15 Sep 2026 19:56:52 +0300 Subject: [PATCH 3/6] 1.0.0 release evidence from the smoke pass Every row of the 1.0.0 section is pasted from the 15 September runs on the candidate, each client in an isolated configuration. The GitHub Copilot CLI row keeps its marker: Copilot 1.0.83 refused every prompt with an account policy error before any hook ran, so release.yml refuses the tag until that row is walked. The 1.0.0 note gains its Verified section. Co-Authored-By: Claude Opus 5 (1M context) --- docs/compatibility.md | 30 ++++++++++++++++-------------- docs/releases/1.0.0.md | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 8ed49d9..eaebad5 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -99,24 +99,26 @@ hooks without it, which is useful to confirm an install and wrong as a habit. ## 1.0.0 release evidence -Recorded PENDING_RELEASE_EVIDENCE (date, macOS version and architecture, CPython and uv versions). -Every row is pasted from the terminal of the day, on the 1.0.0 candidate. +Recorded 15 September 2026 on macOS 26.4 arm64, CPython 3.13.5, uv 0.12.5, on the +1.0.0 candidate at `60f75ce`. Every client ran in an isolated configuration: a +project-scoped Claude Code plugin install, a separate `CODEX_HOME`, and a temporary +`HOME` for the package route, so no real client settings were written. | Evidence | Recorded result | | --- | --- | -| Local gate | `python scripts/check.py` green on the candidate: PENDING_RELEASE_EVIDENCE (test count). | +| Local gate | `python scripts/check.py` green on the candidate: 1,987 tests, lint, format, types, build. | | Tag-time re-verification | `release.yml` re-runs the same gate on the tagged tree, rebuilds from a clean snapshot and requires the fresh build to match the tested artifacts byte for byte. It refuses the tag while this record carries a pending marker. | -| Claude Code | tested: 2.1.263. PENDING_RELEASE_EVIDENCE: plugin from the marketplace with a `PATH` whose only `python3` is 3.9; the Turkish prompt (J1); a `Read` of a JSON file with a 2026 epoch, left intact; a `Read` of a `.env`, the model saying values were masked; `shim doctor claude` with no version `WARN`. | -| Claude Code `shim watch` | PENDING_RELEASE_EVIDENCE: a live session on a subscription sign-in (J6). | -| Codex CLI | tested: 0.151.0. PENDING_RELEASE_EVIDENCE: prompt hook trusted, `observe` and `enforce` (J7); `shim watch -- codex` refused with the sentence. | -| GitHub Copilot CLI | tested: 1.0.80. PENDING_RELEASE_EVIDENCE: `shim install copilot`, hook reviewed and enabled; a safe prompt printed nothing; a synthetic email reached the model redacted (its verbatim repeat quoted); a forced hook error failed open. | -| Package on a 3.9-first `PATH` | PENDING_RELEASE_EVIDENCE: `uv` with `--python` installed 1.0.0 (J2). | -| Upgrade from 0.2.0 | PENDING_RELEASE_EVIDENCE: a 0.2.0 machine upgraded to 1.0 sees `No module named shim_guard` on a prompt, `shim doctor` `FAIL` with the command, runs it, and is clean (J3). | -| When something is wrong | PENDING_RELEASE_EVIDENCE: the numbers table (J8). | -| Leaving | PENDING_RELEASE_EVIDENCE: revert, uninstall, and what is left on disk (J9). | -| Python floor | PENDING_RELEASE_EVIDENCE: `check.py` green in CI on 3.10 and 3.13; the committed archive answers identically on 3.9 and 3.13 (`archive-on-3-9`). | -| SBOM and attestation | PENDING_RELEASE_EVIDENCE: SBOM component count for the tag and the `gh attestation verify` command. | -| Supply-chain workflows | PENDING_RELEASE_EVIDENCE: the first green run of CodeQL, Scorecard, Dependabot and the prose check. | +| Claude Code | tested: 2.1.263. Plugin installed through `claude plugin marketplace add` and `claude plugin install shim-cli@shim-cli`, `PATH` holding only `/usr/bin/python3` 3.9.6 and no shim package; the cached `bin/shim.pyz` is byte identical to the candidate's. A Turkish prompt with a synthetic IBAN: `UserPromptSubmit says: shim: found IBAN (1) in your prompt. Not modified.` A `Read` of `orders.json` reached the model as `"created": 1757496600, "amount": 2000, "cost": 0.0376118499` and the model quoted `1757496600`. A `Read` of a synthetic `.env` reached it as `AWS_ACCESS_KEY_ID= `, `DATABASE_URL= `, `SUPPORT_EMAIL= `, and the model answered that a masking layer had replaced the values and ` ` stands in for the real value, which is not in the file. `Stop`: `masked 1 DB_URI, 1 EMAIL, 1 SECRET (Read .env)`, `warned 1 IBAN (your prompt)`, `1 PHONE (bare numbers, left as they were) (Read orders.json)`, `overhead 148 ms median, 165 ms p95`. `shim doctor claude` on the package: `PASS Claude Code 2.1.263 ... is tested.`, `PASS Coverage: 5 of 5 events installed.`, exit 0. | +| Claude Code `shim watch` | Subscription sign-in, `shim watch -- claude -p "Read calc.py and explain it in one sentence."`: `input 198,465 tokens (exact)`, `spend ~$1.55 (approximate, 2026-08-30 prices; API-key equivalent, this session is on a subscription, not a bill)`, `nothing was modified, and no request body was written to disk`. `--json`: `"spend_basis": "subscription"`, `auth_route` `subscription`. | +| Codex CLI | tested: 0.151.0. Plugin added from the candidate's marketplace into a separate `CODEX_HOME`; trust granted for the invocation with `codex exec --dangerously-bypass-hook-trust`, because persisted trust needs the interactive UI. `user-prompt = "enforce"` with a synthetic email: `hook: UserPromptSubmit Blocked`. `observe`: `hook: UserPromptSubmit Completed`, then the account's usage limit refused the model call. This run found that 0.3.1 and 0.3.2's plugin hook never inspected a Codex prompt (fixed in 0.3.3). `shim watch -- codex`: `FAIL shim watch does not support codex. Codex takes its endpoint from its own configuration, so the proxy would be bypassed and the session measured as empty. The Codex prompt hook is unaffected.` | +| GitHub Copilot CLI | tested: 1.0.80. PENDING_RELEASE_EVIDENCE: `shim install copilot`, hook reviewed and enabled; a safe prompt printed nothing; a synthetic email reached the model redacted (its verbatim repeat quoted); a forced hook error failed open. Not walkable on 15 September: Copilot CLI 1.0.83 answers every prompt, with the real and an isolated `COPILOT_HOME`, `Error: Access denied by policy settings`, before any hook runs. `shim install copilot` and `shim doctor copilot` passed on the package (`PASS Coverage: 1 of 1 events installed.`). | +| Package on a 3.9-first `PATH` | `python3` → 3.9.6 first on `PATH`: `uv tool install --python 3.12 --compile-bytecode` of the candidate wheel installed `shim 1.0.0` with exactly two executables, `shim` and `shim-hook`. `shim install` previewed each fragment in words (`Would add 5 hook entries ... Nothing else in the file changes.`); Codex's install ended `WARN Codex runs a hook only after you trust it: open Codex and accept the shim hook when asked.` A Claude Code prompt through the package hook: nothing on a safe prompt, `shim: found CREDIT_CARD (1) in your prompt. Not modified.` on a synthetic card, 47 ms median. | +| Upgrade from 0.2.0 | A home built with PyPI `shim==0.2.0` (`shim install` for all three clients, `shim config --ledger`), then the candidate installed over it. The 0.2.0 hook line: `No module named 'shim_guard'`, exit 1. `shim doctor claude`: `FAIL hook installed in the 0.2.0 shape, which 1.0 does not run; run shim install claude.` and `WARN Coverage: 0 of 5 events installed`; the same for Codex and Copilot. `shim install` per client: `moved settings to ~/.config/shim/config.toml`, `Replaced the 0.2.0 hook line with the current one.`, and on Copilot `removed the old hook file`. The rewritten hook: `shim: found EMAIL (1) in your prompt. Not modified.`; doctor exit 0, `PASS No 0.2.0 names are left on disk.` | +| When something is wrong | The numbers table on the candidate (`evaluate`, 400 samples each): 2026 timestamps 0 %, random ten-digit integers 7 % (ids with a Turkish mobile shape), ten-decimal floats 0 %, eight ordinary tool-output lines 0 masked; `+90 532 123 45 67`, `(555) 123-4567`, `0212 555 12 34`, `5321234567` and `Tel: 4155552671` still masked. A settings file that does not parse: the prompt hook withholds the prompt with `shim could not inspect this prompt, so it was withheld. Run shim doctor claude for the reason.`, and doctor says `FAIL Settings at ... are invalid: Unclosed array (at line 2, column 1). Run shim config --reset to start over, or edit the line above.` once. The plugin launcher with no Python on `PATH`: `shim: no python3 found on PATH; the prompt was not inspected.`, exit 0. | +| Leaving | `shim revert` for all three clients on the upgraded home: Claude settings kept an unrelated hook and `"theme": "dark"` byte for byte; Codex `hooks.json` became `{}`; the Copilot hook file was removed. `uv tool uninstall shim` removed `shim` and `shim-hook`. Left on disk: the two client settings files and `~/.config/shim/config.toml`. | +| Python floor | CI green on 3.10 and 3.13 (macOS and Ubuntu) for every pull request of the 1.0 cycle; `archive-on-3-9` green; the committed archive answers identically on 3.9 and 3.13. | +| SBOM and attestation | `release.yml` fails the release unless the SBOM lists `shim` at the tag's version and every unconditional pin of `requirements.lock`, and verifies the wheel, source archive and `shim.pyz` against their attestations before publishing. 0.3.2, the first tag to carry it: 11 library components; from a clean download `gh attestation verify shim-0.3.2-py3-none-any.whl --bundle shim-0.3.2.intoto.jsonl --repo GetSHIM/shim-cli` exits 0, and the SBOM bundle verifies with `--predicate-type https://cyclonedx.org/bom`. | +| Supply-chain workflows | On `main` at `f9deea3`, pushed 15 September 2026: CodeQL, Scorecard, prose and CI green. Dependabot opens grouped pull requests (#14 Python, #18 Actions). | ## 0.3.0 release evidence diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md index af9f7c7..7fba0fc 100644 --- a/docs/releases/1.0.0.md +++ b/docs/releases/1.0.0.md @@ -61,5 +61,19 @@ Candidates for 1.1, in this order: Also not in 1.0: a bare phone number in a non-Turkish national format with no cue such as `tel` or `phone` nearby is not detected. A custom pattern covers it. -TODO(release): the "Verified" paragraph, written from the 1.0.0 release -evidence in [compatibility.md](../compatibility.md) on the day of the tag. +## Verified + +`python scripts/check.py` green on the candidate: 1,987 tests, lint, format, +types and a package build; CI covers 3.10 and 3.13 on macOS and Ubuntu, and the +committed archive answers identically on Python 3.9 and 3.13. + +Walked live before the tag, each client in an isolated configuration: Claude +Code 2.1.263 from the marketplace with only the system Python 3.9 on `PATH` +(prompt report, a JSON file left intact, a `.env` masked and the model saying +so, the session summary, `shim watch` on a subscription); Codex CLI 0.151.0 +from the marketplace (`Blocked` under `enforce`, `Completed` under `observe`, +`shim watch` refused with its sentence); the package installed with +`--python 3.12` on a 3.9-first `PATH`; a machine upgraded from 0.2.0 taken +from doctor's `FAIL` to clean with `shim install`; and `shim revert` leaving +unrelated settings as they were. The GitHub Copilot CLI run and every quoted +screen are in [compatibility.md](../compatibility.md). From 593eabcb68ce279212cda482d232c195d5bf9f6d Mon Sep 17 00:00:00 2001 From: mertcan Date: Wed, 16 Sep 2026 10:43:31 +0300 Subject: [PATCH 4/6] Copilot CLI 1.0.83 release evidence Co-Authored-By: Claude Opus 5 (1M context) --- docs/compatibility.md | 2 +- docs/releases/1.0.0.md | 5 +++-- src/shim_cli/clients/copilot/settings.py | 2 +- tests/clients/copilot/test_copilot_settings.py | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index eaebad5..41a4488 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -111,7 +111,7 @@ project-scoped Claude Code plugin install, a separate `CODEX_HOME`, and a tempor | Claude Code | tested: 2.1.263. Plugin installed through `claude plugin marketplace add` and `claude plugin install shim-cli@shim-cli`, `PATH` holding only `/usr/bin/python3` 3.9.6 and no shim package; the cached `bin/shim.pyz` is byte identical to the candidate's. A Turkish prompt with a synthetic IBAN: `UserPromptSubmit says: shim: found IBAN (1) in your prompt. Not modified.` A `Read` of `orders.json` reached the model as `"created": 1757496600, "amount": 2000, "cost": 0.0376118499` and the model quoted `1757496600`. A `Read` of a synthetic `.env` reached it as `AWS_ACCESS_KEY_ID= `, `DATABASE_URL= `, `SUPPORT_EMAIL= `, and the model answered that a masking layer had replaced the values and ` ` stands in for the real value, which is not in the file. `Stop`: `masked 1 DB_URI, 1 EMAIL, 1 SECRET (Read .env)`, `warned 1 IBAN (your prompt)`, `1 PHONE (bare numbers, left as they were) (Read orders.json)`, `overhead 148 ms median, 165 ms p95`. `shim doctor claude` on the package: `PASS Claude Code 2.1.263 ... is tested.`, `PASS Coverage: 5 of 5 events installed.`, exit 0. | | Claude Code `shim watch` | Subscription sign-in, `shim watch -- claude -p "Read calc.py and explain it in one sentence."`: `input 198,465 tokens (exact)`, `spend ~$1.55 (approximate, 2026-08-30 prices; API-key equivalent, this session is on a subscription, not a bill)`, `nothing was modified, and no request body was written to disk`. `--json`: `"spend_basis": "subscription"`, `auth_route` `subscription`. | | Codex CLI | tested: 0.151.0. Plugin added from the candidate's marketplace into a separate `CODEX_HOME`; trust granted for the invocation with `codex exec --dangerously-bypass-hook-trust`, because persisted trust needs the interactive UI. `user-prompt = "enforce"` with a synthetic email: `hook: UserPromptSubmit Blocked`. `observe`: `hook: UserPromptSubmit Completed`, then the account's usage limit refused the model call. This run found that 0.3.1 and 0.3.2's plugin hook never inspected a Codex prompt (fixed in 0.3.3). `shim watch -- codex`: `FAIL shim watch does not support codex. Codex takes its endpoint from its own configuration, so the proxy would be bypassed and the session measured as empty. The Codex prompt hook is unaffected.` | -| GitHub Copilot CLI | tested: 1.0.80. PENDING_RELEASE_EVIDENCE: `shim install copilot`, hook reviewed and enabled; a safe prompt printed nothing; a synthetic email reached the model redacted (its verbatim repeat quoted); a forced hook error failed open. Not walkable on 15 September: Copilot CLI 1.0.83 answers every prompt, with the real and an isolated `COPILOT_HOME`, `Error: Access denied by policy settings`, before any hook runs. `shim install copilot` and `shim doctor copilot` passed on the package (`PASS Coverage: 1 of 1 events installed.`). | +| GitHub Copilot CLI | tested: 1.0.83. Copilot Free sign-in, candidate package in a separate `COPILOT_HOME`. `shim install copilot`: `PASS Installed shim for GitHub Copilot CLI.`; `shim doctor copilot`: `PASS shim's exact GitHub Copilot CLI hook group is present.`, `PASS Coverage: 1 of 1 events installed.`, exit 0. The hook ran in `copilot -p` without a `/hooks` step. A safe prompt: the reply `ok` and no shim line. A synthetic email, the model asked to repeat the prompt verbatim: `please email the report to today`. The hook command pointed at a missing interpreter, same prompt: `please email the report to ayse.yilmaz@example.com today`, exit 0, so a failing hook lets the prompt through unchanged. | | Package on a 3.9-first `PATH` | `python3` → 3.9.6 first on `PATH`: `uv tool install --python 3.12 --compile-bytecode` of the candidate wheel installed `shim 1.0.0` with exactly two executables, `shim` and `shim-hook`. `shim install` previewed each fragment in words (`Would add 5 hook entries ... Nothing else in the file changes.`); Codex's install ended `WARN Codex runs a hook only after you trust it: open Codex and accept the shim hook when asked.` A Claude Code prompt through the package hook: nothing on a safe prompt, `shim: found CREDIT_CARD (1) in your prompt. Not modified.` on a synthetic card, 47 ms median. | | Upgrade from 0.2.0 | A home built with PyPI `shim==0.2.0` (`shim install` for all three clients, `shim config --ledger`), then the candidate installed over it. The 0.2.0 hook line: `No module named 'shim_guard'`, exit 1. `shim doctor claude`: `FAIL hook installed in the 0.2.0 shape, which 1.0 does not run; run shim install claude.` and `WARN Coverage: 0 of 5 events installed`; the same for Codex and Copilot. `shim install` per client: `moved settings to ~/.config/shim/config.toml`, `Replaced the 0.2.0 hook line with the current one.`, and on Copilot `removed the old hook file`. The rewritten hook: `shim: found EMAIL (1) in your prompt. Not modified.`; doctor exit 0, `PASS No 0.2.0 names are left on disk.` | | When something is wrong | The numbers table on the candidate (`evaluate`, 400 samples each): 2026 timestamps 0 %, random ten-digit integers 7 % (ids with a Turkish mobile shape), ten-decimal floats 0 %, eight ordinary tool-output lines 0 masked; `+90 532 123 45 67`, `(555) 123-4567`, `0212 555 12 34`, `5321234567` and `Tel: 4155552671` still masked. A settings file that does not parse: the prompt hook withholds the prompt with `shim could not inspect this prompt, so it was withheld. Run shim doctor claude for the reason.`, and doctor says `FAIL Settings at ... are invalid: Unclosed array (at line 2, column 1). Run shim config --reset to start over, or edit the line above.` once. The plugin launcher with no Python on `PATH`: `shim: no python3 found on PATH; the prompt was not inspected.`, exit 0. | diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md index 7fba0fc..b5d3076 100644 --- a/docs/releases/1.0.0.md +++ b/docs/releases/1.0.0.md @@ -75,5 +75,6 @@ from the marketplace (`Blocked` under `enforce`, `Completed` under `observe`, `shim watch` refused with its sentence); the package installed with `--python 3.12` on a 3.9-first `PATH`; a machine upgraded from 0.2.0 taken from doctor's `FAIL` to clean with `shim install`; and `shim revert` leaving -unrelated settings as they were. The GitHub Copilot CLI run and every quoted -screen are in [compatibility.md](../compatibility.md). +unrelated settings as they were; GitHub Copilot CLI 1.0.83 (the model received +the redacted prompt, and a failing hook let the prompt through). Every quoted +screen is in [compatibility.md](../compatibility.md). diff --git a/src/shim_cli/clients/copilot/settings.py b/src/shim_cli/clients/copilot/settings.py index 8dfe23e..84ba4ae 100644 --- a/src/shim_cli/clients/copilot/settings.py +++ b/src/shim_cli/clients/copilot/settings.py @@ -8,7 +8,7 @@ from shim_cli.clients.hook_settings import MAX_SETTINGS_BYTES, interpreter_path -TESTED_COPILOT_VERSION = "1.0.80" +TESTED_COPILOT_VERSION = "1.0.83" MINIMUM_COPILOT_VERSION = "1.0.80" HOOK_TIMEOUT_SECONDS = 30 MAX_CONFIG_BYTES = MAX_SETTINGS_BYTES diff --git a/tests/clients/copilot/test_copilot_settings.py b/tests/clients/copilot/test_copilot_settings.py index c62d7b3..8c60a07 100644 --- a/tests/clients/copilot/test_copilot_settings.py +++ b/tests/clients/copilot/test_copilot_settings.py @@ -47,7 +47,8 @@ def test_copilot_1080_hook_file_is_exact(tmp_path: Path) -> None: "copilot", ] assert HOOK_TIMEOUT_SECONDS == 30 - assert TESTED_COPILOT_VERSION == MINIMUM_COPILOT_VERSION == "1.0.80" + assert TESTED_COPILOT_VERSION == "1.0.83" + assert MINIMUM_COPILOT_VERSION == "1.0.80" def test_copilot_target_respects_home_and_copilot_home( From 868dd27bb9099a5b223ac979fd4a9fea6f806137 Mon Sep 17 00:00:00 2001 From: mertcan Date: Wed, 16 Sep 2026 13:20:11 +0300 Subject: [PATCH 5/6] README: current corpus and contract counts, 0.3.3 notes, 1.0 evidence link Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6a2f62a..c1071d6 100644 --- a/README.md +++ b/README.md @@ -478,8 +478,8 @@ Every figure here was measured on the released build, not estimated. | Tests | **1,900+**, one command: `python scripts/check.py` — lock, lint, format, types, suite, wheel, sdist | | Hook cost | **70 ms** median end to end, interpreter start included; **42 ms** for a session summary | | With 32 custom patterns | **+0.6 ms** median against the same prompt with none | -| Detector corpus | **570 cases**, graded on exact redacted output rather than category presence | -| Release evidence | SBOM, provenance and Sigstore bundles on the release page from 0.3.2, with the `gh attestation verify` command in [the compatibility record](https://github.com/GetSHIM/shim-cli/blob/main/docs/compatibility.md#030-release-evidence) | +| Detector corpus | **589 cases**, graded on exact redacted output rather than category presence | +| Release evidence | SBOM, provenance and Sigstore bundles on the release page from 0.3.2, with the `gh attestation verify` command in [the compatibility record](https://github.com/GetSHIM/shim-cli/blob/main/docs/compatibility.md#100-release-evidence) |
Hook output is asserted byte for byte, not by shape: a safe event must produce -exactly zero bytes on stdout and stderr. 341 contract tests hold that, plus the +exactly zero bytes on stdout and stderr. 346 contract tests hold that, plus the import boundaries, the rule that no committed file carries the machine it was written on, and a byte-identical rebuild of the shipped plugin archive. @@ -539,6 +539,7 @@ reinstalling later finds your entity choices and custom patterns still there. - [Compatibility](https://github.com/GetSHIM/shim-cli/blob/main/docs/compatibility.md) - [Privacy](https://github.com/GetSHIM/shim-cli/blob/main/docs/privacy.md) - [1.0.0 release notes](https://github.com/GetSHIM/shim-cli/blob/main/docs/releases/1.0.0.md) +- [0.3.3 release notes](https://github.com/GetSHIM/shim-cli/blob/main/docs/releases/0.3.3.md) - [0.3.2 release notes](https://github.com/GetSHIM/shim-cli/blob/main/docs/releases/0.3.2.md) - [0.3.1 release notes](https://github.com/GetSHIM/shim-cli/blob/main/docs/releases/0.3.1.md) - [0.3.0 release notes](https://github.com/GetSHIM/shim-cli/blob/main/docs/releases/0.3.0.md) From eb873abce7aaf957e3816b9d97581ebac0c67138 Mon Sep 17 00:00:00 2001 From: mertcan
Date: Wed, 16 Sep 2026 14:29:06 +0300 Subject: [PATCH 6/6] Say that a failed tool call's output is not masked Found in the 1.0 smoke pass: cat .env && cat missing-file exits 1 and the model read the real key. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++++ docs/privacy.md | 6 ++++++ docs/releases/1.0.0.md | 4 +++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c1071d6..f818c36 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,10 @@ reported. - The host client receives the raw prompt before its hook runs, and other hooks may receive it concurrently. - Detection is best-effort and may miss sensitive values. +- **The output of a failed tool call is not masked.** Claude Code passes it + to a separate hook event that shim does not install, so a command such as + `cat .env && cat missing-file` exits non-zero and the model reads `.env` + as it is, with nothing in the session summary. - A disabled, untrusted, crashed, or timed-out hook may fail open according to client behavior. - Clients, providers, and other tools may retain data independently of shim. diff --git a/docs/privacy.md b/docs/privacy.md index 8ac1eba..bae6c88 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -207,6 +207,12 @@ Commands and local writes are never rewritten. If no inspection is possible, the event passes through unchanged and unmasked with a visible warning; prompt errors still fail closed. +**A tool call that fails is not inspected at all.** Claude Code delivers its +output, including everything the command printed before it failed, to +`PostToolUseFailure`, which shim does not install. `cat .env && cat +missing-file` exits 1, and the model reads `.env` unmasked; no warning is +shown and the session summary does not count it. + **A large field is scanned in pieces, and the seams are the residual risk.** The detector works on at most 100,000 characters at a time. A longer field is cut at the last newline before each boundary and each piece scanned separately, diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md index b5d3076..f320e96 100644 --- a/docs/releases/1.0.0.md +++ b/docs/releases/1.0.0.md @@ -58,7 +58,9 @@ Candidates for 1.1, in this order: - Token cost by language, starting with Turkish. - An image cost line in the watch report. -Also not in 1.0: a bare phone number in a non-Turkish national format with no +Also not in 1.0: the output of a tool call that fails is not masked, because +shim does not install Claude Code's `PostToolUseFailure` event; `cat .env && +cat missing-file` shows the model `.env` as it is. And a bare phone number in a non-Turkish national format with no cue such as `tel` or `phone` nearby is not detected. A custom pattern covers it. ## Verified