ekbasis 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ekbasis/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Ekbasis client: ask a served Ekbasis what happens if an action is taken (typed, calibrated answers in one pass),
2
+ chain it over long sequences, and guard git commands (and, as a prototype, shell command lines) before they run.
3
+ Standard library only."""
4
+ from ._version import __version__
5
+ from .client import Answer, CannotForesee, CannotJudge, Ekbasis, EkbasisError, FeedbackRejected, last_request_id
6
+ from .prompts import choice, git_state, number, recap, world_state, yes_no
7
+ from .simulate import Simulation, Threshold, simulate, threshold
8
+
9
+ __all__ = ["Answer", "CannotForesee", "CannotJudge", "Ekbasis", "EkbasisError", "FeedbackRejected", "last_request_id", "Simulation", "Threshold", "choice", "git_state", "number", "recap",
10
+ "simulate", "threshold", "world_state", "yes_no"]
ekbasis/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ekbasis/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.9"
@@ -0,0 +1,502 @@
1
+ """Claude Code PreToolUse hook: before the Bash tool runs git commands, ask Ekbasis what they will do. If they may
2
+ permanently lose uncommitted work, Claude Code asks you to confirm (or blocks, EKBASIS_GUARD_MODE=deny), showing why.
3
+
4
+ The guard is a warning layer that can be wrong, not a security boundary: keep Claude Code's confirmations, backups and
5
+ least privilege (docs/SECURITY.md).
6
+
7
+ It fails closed (0.1.2): when it cannot judge a line (the server cannot be reached or does not answer in time, the
8
+ repository or folder cannot be read, git is pointed at another repository, the line changes folder in a way the hook
9
+ does not follow, or it has parts the hook cannot evaluate such as subshells, variables in a git command, nested shells,
10
+ git aliases), Claude Code asks you to confirm, saying why. EKBASIS_FAIL_OPEN=1 restores 0.1.1's behaviour (say nothing
11
+ and let the command go through Claude Code's normal permission flow). A hook only blocks or asks through its JSON
12
+ answer; an exit code other than 2 does not block, so this hook always answers in JSON and exits 0.
13
+
14
+ Opt-in (a prototype): with EKBASIS_SHELL_GUARD=1, a Bash line that has no git command but can change files (rm, mv,
15
+ cp, rsync, find, sed -i, tar, a `>` redirection, ...) goes to the shell guard (ekbasis.shell), with the same threshold,
16
+ mode and fail-closed rule.
17
+
18
+ 0.1.3 (from the real-terminal study, capability/real_terminal): less friction, same prompts where nothing changed.
19
+ - Read-only git (status, log, diff, show, branch/tag/stash/worktree listings, ...) does not ask the model.
20
+ - `cd DIR` anywhere in the line, `pushd`/`popd`, subshells `( ... )` and `git -C DIR` are followed instead of "cannot
21
+ judge"; a folder that an earlier `git worktree add` (or `git clone`, `mkdir`) of the same line creates is known to
22
+ hold no uncommitted work, so the worktree route (`git worktree add ../wt b && cd ../wt && git cherry-pick X`) passes.
23
+ - With EKBASIS_SHELL_GUARD=1, the shell part of a line that also runs git (its other commands, and redirections of the
24
+ git commands) goes to the shell guard too.
25
+ - No model call (and no ask) when nothing could be lost for good: a repository with no uncommitted work, or, for the
26
+ shell guard, when every path the line could change is committed in git or ignored and rebuildable (ekbasis.recover).
27
+ EKBASIS_SHORTCUTS=0 turns these code checks off (0.1.2's behaviour: the model is asked).
28
+ - Lost committed work, a separate check done by code: branch deletion (-D), forced branch moves, `reset <commit>`,
29
+ tag deletion and the like that leave commits no branch, tag, remote-tracking branch or stash holds (git.committed_loss).
30
+
31
+ Since 0.1.6: preflight, opt-in with EKBASIS_PREFLIGHT=1 (ekbasis.preflight). A line that runs a multi-step
32
+ change (a sqlite3 run of several statements, a shell script, a chain of commands with two or more steps that change
33
+ something) is checked before it runs: on a copy when it can be (local files and SQLite; shell steps only inside the
34
+ macOS sandbox; EKBASIS_PREFLIGHT_COPY=0 turns that off), otherwise by Ekbasis, and code works out whether the first
35
+ failure would leave the change half applied. Only
36
+ that case warns; a plan that fails atomically, or a failing check that only reads, does not. In a headless run an "ask"
37
+ is a denial, so the warning says so and the same command in the same session goes through the second time
38
+ (EKBASIS_PREFLIGHT_REPEAT=0: always warn). When preflight cannot judge a line it says nothing
39
+ (EKBASIS_PREFLIGHT_FAIL_CLOSED=1: it warns once, the same way).
40
+
41
+ Install (after `pip install ./ekbasis`), in .claude/settings.json (one project) or ~/.claude/settings.json (all):
42
+ {"hooks": {"PreToolUse": [{"matcher": "Bash",
43
+ "hooks": [{"type": "command", "command": "ekbasis-claude-hook", "timeout": 30}]}]}}
44
+ env: EKBASIS_URL (server), EKBASIS_LOST_THRESHOLD (0.2), EKBASIS_GUARD_MODE (ask | deny), EKBASIS_FETCH (1: git fetch
45
+ first so the state shows the real remote; slower), EKBASIS_SHELL_GUARD (1: also check other lines that change files),
46
+ EKBASIS_FAIL_OPEN (1: stay silent when it cannot judge), EKBASIS_HOOK_DEADLINE (seconds, default 25, for the whole
47
+ line: keep it below the hook's "timeout", or Claude Code stops the hook first and the command goes through unchecked),
48
+ EKBASIS_SHORTCUTS (0: always ask the model, as 0.1.2 did), EKBASIS_PREFLIGHT (1: check multi-step changes before they run),
49
+ EKBASIS_PREFLIGHT_REPEAT (1, the default: a warned command passes when it is run again in the same session),
50
+ EKBASIS_GIT_GUARD (0: no git checks, for preflight alone), EKBASIS_PREFLIGHT_COPY (0: never run on a copy),
51
+ EKBASIS_PREFLIGHT_FAIL_CLOSED (1: warn when preflight cannot judge).
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import hashlib
56
+ import json
57
+ import os
58
+ import re
59
+ import shlex
60
+ import sys
61
+ import tempfile
62
+ import threading
63
+ import time
64
+ from dataclasses import dataclass, field
65
+
66
+ from . import git as G
67
+ from . import preflight as PF
68
+ from . import prompts as P
69
+ from . import recover as R
70
+ from . import shell as S
71
+ from .client import CannotJudge, Ekbasis
72
+
73
+ QUIET_REDIRECT = re.compile(r"[0-9&]*>>?\s*/dev/null|[0-9]*>&[0-9-]")
74
+ CHANGES_FILES = re.compile(r"(^|[\s;&|(])(rm|rmdir|unlink|mv|cp|rsync|shred|truncate|ln|tar|unzip|dd|install|xargs|tee|"
75
+ r"perl|chmod|chown|find)(\s|$)|(^|[\s;&|(])sed\s+(-[a-zA-Z]*i|--in-place)|>")
76
+ GIT_WORD = re.compile(r"(^|[\s;&|(`'\"/])git(\s|$)")
77
+ GIT_ENV = re.compile(r"^GIT_(DIR|WORK_TREE|INDEX_FILE|OBJECT_DIRECTORY|COMMON_DIR|NAMESPACE)=")
78
+ STRUCTURE = {S.P_SUBSHELL, S.P_GROUP, S.P_PROCSUB, S.P_QUOTE, S.P_HEREDOC_OPEN}
79
+ STRUCTURE_013 = STRUCTURE - {S.P_SUBSHELL} # 0.1.3 follows subshells
80
+ CLONE_VALUE = ("-b", "--branch", "-o", "--origin", "--depth", "--reference", "-c", "--config", "-u", "--upload-pack",
81
+ "--separate-git-dir", "--template", "-j", "--jobs", "--filter", "--shallow-since", "--shallow-exclude")
82
+
83
+
84
+ def git_commands(command: str) -> tuple[str | None, list[str]]:
85
+ """The git commands in a shell command line, and a leading `cd DIR` (the 0.1.1 helper, kept for callers)."""
86
+ where, cmds, _ = read_line(command)
87
+ return where, cmds
88
+
89
+
90
+ def read_line(command: str) -> tuple[str | None, list[str], list[str]]:
91
+ """(a leading `cd DIR`, the git commands as strings, why the line cannot be judged). Reasons: structure the hook
92
+ does not follow (subshells, grouping, an unclosed quote), a cd/pushd/popd other than a leading cd, a variable or
93
+ substitution in a git command (a commit message's value is fine), GIT_DIR-like variables, git run inside a nested
94
+ shell, eval or xargs."""
95
+ ps = S.parse(command)
96
+ where, cmds, why = None, [], []
97
+ gitlike = bool(GIT_WORD.search(command))
98
+ for j, s in enumerate(ps.commands):
99
+ name, at, _ = S.command_name(list(s.words))
100
+ args = s.words[at + 1:]
101
+ if name == "cd" and j == 0 and len(args) <= 1 and not any(w.dynamic for w in args):
102
+ where = args[0].text if args else "~"
103
+ continue
104
+ if name in ("cd", "pushd", "popd") and gitlike:
105
+ why.append("the line changes folder in a way the hook does not follow")
106
+ if name in S.NESTED and gitlike and (name in ("eval", "source", ".") or any(w.text == "-c" for w in args)):
107
+ why.append("git may run inside a nested shell or eval")
108
+ if name == "xargs" and any(w.text == "git" for w in args):
109
+ why.append("git runs through xargs")
110
+ if name != "git":
111
+ continue
112
+ if any(GIT_ENV.match(w.text) for w in s.words[:at]):
113
+ why.append("a GIT_DIR-like variable points git elsewhere")
114
+ if any(w.dynamic and not (i > 0 and args[i - 1].text in S.MESSAGE_OPTS) for i, w in enumerate(args)):
115
+ why.append("a git command has a variable or substitution the hook does not evaluate")
116
+ cmds.append(shlex.join(["git"] + [w.text for w in args]))
117
+ if cmds or gitlike:
118
+ why += [p for p in ps.problems if p in STRUCTURE]
119
+ return where, cmds, list(dict.fromkeys(why))
120
+
121
+
122
+ def changes_files(command: str) -> bool:
123
+ """Whether a line has a command that can change files (redirections to /dev/null do not count)."""
124
+ return bool(CHANGES_FILES.search(QUIET_REDIRECT.sub(" ", command)))
125
+
126
+
127
+ @dataclass
128
+ class Plan:
129
+ steps: list = field(default_factory=list) # git commands: {"repo", "cmd" (without -C), "fresh"}
130
+ shell_line: str = "" # the line for the shell guard: git commands replaced by `true`
131
+ why: list = field(default_factory=list) # why the line cannot be judged
132
+
133
+
134
+ def _safe_branch_delete(command: str) -> bool:
135
+ """git branch -d (without -D or force): git itself refuses to delete a branch that is not merged."""
136
+ sub, args = P.parse_git(command)
137
+ sf = P.short_flags(args)
138
+ return sub == "branch" and ("d" in sf or "--delete" in args) and not (sf & {"D", "f"} or "--force" in args)
139
+
140
+
141
+ def _scoped(toks):
142
+ """Simple commands and subshell marks from S.lex(line, parens=True): [("cmd", Simple) | ("(",) | (")",)]."""
143
+ items, words, redirs, joined = [], [], [], ""
144
+ i = 0
145
+ while i < len(toks):
146
+ t = toks[i]
147
+ if isinstance(t, tuple):
148
+ _, op, fd = t
149
+ if op in ("(", ")") or op in S.CONTROL:
150
+ if words or redirs:
151
+ items.append(("cmd", S.Simple(words, redirs, joined)))
152
+ words, redirs = [], []
153
+ if op in ("(", ")"):
154
+ items.append((op,))
155
+ joined = ""
156
+ else:
157
+ joined = op
158
+ else:
159
+ target = toks[i + 1] if i + 1 < len(toks) and isinstance(toks[i + 1], S.Word) else None
160
+ redirs.append(S.Redirect(op, fd, target))
161
+ if target is not None:
162
+ i += 1
163
+ else:
164
+ words.append(t)
165
+ i += 1
166
+ if words or redirs:
167
+ items.append(("cmd", S.Simple(words, redirs, joined)))
168
+ return items
169
+
170
+
171
+ def _without(line: str, simples) -> str:
172
+ """The line with each of these simple commands' words replaced by `true` (their redirections stay)."""
173
+ edits = []
174
+ for sc in simples:
175
+ ws = [w for w in sc.words if w.start >= 0]
176
+ if not ws:
177
+ continue
178
+ edits.append((ws[0].start, ws[0].end, "true"))
179
+ edits += [(w.start, w.end, "") for w in ws[1:]]
180
+ for a, b, rep in sorted(edits, reverse=True):
181
+ line = line[:a] + rep + line[b:]
182
+ return line
183
+
184
+
185
+ def plan_line(command: str, cwd: str) -> Plan:
186
+ """0.1.3: the git commands of a line with the folder each runs in (following cd, pushd/popd, subshells and git -C),
187
+ the folders the line itself creates (git worktree add, git clone, mkdir), the line for the shell guard, and why
188
+ the line cannot be judged (structure the hook does not follow, variables in git commands, git in a nested shell,
189
+ git through xargs unless it only lists or safely deletes, a folder it cannot tell)."""
190
+ toks, problems = S.lex(command, parens=True)
191
+ items = _scoped(toks)
192
+ gitlike = bool(GIT_WORD.search(command))
193
+ plan = Plan()
194
+ depth, balanced = 0, True
195
+ for it in items:
196
+ depth += {"(": 1, ")": -1}.get(it[0], 0)
197
+ balanced = balanced and depth >= 0
198
+ balanced = balanced and depth == 0
199
+ cur, scopes, dirstack, fresh, replaced = os.path.normpath(cwd), [], [], {}, []
200
+
201
+ def at(base, target):
202
+ return os.path.normpath(os.path.join(base, os.path.expanduser(target)))
203
+
204
+ def fresh_of(path):
205
+ p = path
206
+ while True:
207
+ if p in fresh:
208
+ return fresh[p]
209
+ parent = os.path.dirname(p)
210
+ if parent == p:
211
+ return None
212
+ p = parent
213
+
214
+ for it in items:
215
+ if it[0] == "(":
216
+ scopes.append((cur, list(dirstack)))
217
+ continue
218
+ if it[0] == ")":
219
+ if scopes:
220
+ cur, dirstack = scopes.pop()
221
+ continue
222
+ sc = it[1]
223
+ name, k0, _ = S.command_name(list(sc.words))
224
+ args = sc.words[k0 + 1:]
225
+ if name in ("cd", "pushd"):
226
+ targets = [w for w in args if not (w.text.startswith("-") and w.text != "-")]
227
+ if any(w.dynamic for w in args) or len(targets) > 1 or (targets and targets[0].text == "-"):
228
+ if gitlike:
229
+ plan.why.append("the line changes folder in a way the hook does not follow")
230
+ cur = None
231
+ elif cur is not None:
232
+ new = at(cur, targets[0].text if targets else "~")
233
+ if name == "pushd":
234
+ dirstack.append(cur)
235
+ cur = new if (os.path.isdir(new) or fresh_of(new)) else None
236
+ continue
237
+ if name == "popd":
238
+ cur = dirstack.pop() if dirstack else None
239
+ continue
240
+ if name in S.NESTED and gitlike and (name in ("eval", "source", ".") or any(w.text == "-c" for w in args)):
241
+ plan.why.append("git may run inside a nested shell or eval")
242
+ if name == "mkdir" and cur is not None:
243
+ for w in args:
244
+ if not w.text.startswith("-") and not w.dynamic:
245
+ fresh.setdefault(at(cur, w.text), {"kind": "dir"})
246
+ if name == "xargs" and any(w.text == "git" for w in args):
247
+ j = next(i for i, w in enumerate(args) if w.text == "git")
248
+ inner = shlex.join([w.text for w in args[j:]])
249
+ if any(w.dynamic for w in args[j:]) or not (G.read_only(inner) or _safe_branch_delete(inner)):
250
+ plan.why.append("git runs through xargs")
251
+ else:
252
+ replaced.append(sc) # it only lists or safely deletes branches: nothing for the shell guard
253
+ continue
254
+ if name != "git":
255
+ continue
256
+ replaced.append(sc)
257
+ if any(GIT_ENV.match(w.text) for w in sc.words[:k0]):
258
+ plan.why.append("a GIT_DIR-like variable points git elsewhere")
259
+ if any(w.dynamic and not (i > 0 and args[i - 1].text in S.MESSAGE_OPTS) for i, w in enumerate(args)):
260
+ plan.why.append("a git command has a variable or substitution the hook does not evaluate")
261
+ texts = [w.text for w in args]
262
+ repo, i, keep = cur, 0, []
263
+ while i < len(texts) and texts[i].startswith("-"):
264
+ o = texts[i]
265
+ if o == "-C" and i + 1 < len(texts):
266
+ repo = at(repo, texts[i + 1]) if repo is not None else None
267
+ i += 2
268
+ elif o.split("=", 1)[0] in ("--git-dir", "--work-tree"):
269
+ plan.why.append("git is pointed at another repository with --git-dir or --work-tree")
270
+ i += 1 if "=" in o else 2
271
+ elif o in ("-c", "--namespace") and i + 1 < len(texts):
272
+ keep += texts[i:i + 2]
273
+ i += 2
274
+ else:
275
+ keep.append(o)
276
+ i += 1
277
+ cmd = shlex.join(["git"] + keep + texts[i:])
278
+ if repo is None:
279
+ plan.why.append("git runs in a folder the hook cannot tell (after a cd it could not follow)")
280
+ continue
281
+ fr = fresh_of(repo)
282
+ if not os.path.isdir(repo) and not fr:
283
+ plan.why.append(f"git runs in a folder that does not exist: {repo}")
284
+ continue
285
+ plan.steps.append({"repo": repo, "cmd": cmd, "fresh": fr})
286
+ sub, sargs = P.parse_git(cmd)
287
+ if sub == "worktree" and sargs[:1] == ["add"]:
288
+ rest = sargs[1:]
289
+ pos = P._positional(rest, ("-b", "-B", "--reason"))
290
+ if pos:
291
+ path = at(repo, pos[0])
292
+ b = G._value(rest, "-B") or G._value(rest, "-b")
293
+ base = fr["base"] if fr and fr.get("kind") == "worktree" else repo
294
+ detach = "--detach" in rest or "-d" in rest
295
+ if b:
296
+ head = ("branch", "refs/heads/" + b)
297
+ elif len(pos) > 1 and not detach and os.path.isdir(base) and \
298
+ G._git(base, "rev-parse", "--verify", "--quiet", "refs/heads/" + pos[1])[0] == 0:
299
+ head = ("branch", "refs/heads/" + pos[1])
300
+ elif len(pos) > 1:
301
+ head = ("rev", pos[1])
302
+ elif detach:
303
+ head = ("rev", "HEAD")
304
+ else:
305
+ head = ("branch", "refs/heads/" + os.path.basename(path))
306
+ fresh[path] = {"kind": "worktree", "base": base, "head": head}
307
+ elif sub == "clone":
308
+ pos = P._positional(sargs, CLONE_VALUE)
309
+ if pos:
310
+ dest = pos[1] if len(pos) > 1 else re.sub(r"\.git$", "", os.path.basename(pos[0].rstrip("/")))
311
+ fresh[at(repo, dest)] = {"kind": "clone"}
312
+ if plan.steps or gitlike:
313
+ plan.why += [p for p in problems if p in STRUCTURE_013]
314
+ if not balanced:
315
+ plan.why.append("unbalanced parentheses")
316
+ plan.shell_line = _without(command, replaced) if replaced else command
317
+ plan.why = list(dict.fromkeys(plan.why))
318
+ return plan
319
+
320
+
321
+ def _decide(reason: str) -> None:
322
+ mode = "deny" if os.environ.get("EKBASIS_GUARD_MODE") == "deny" else "ask"
323
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": mode,
324
+ "permissionDecisionReason": reason}}))
325
+
326
+
327
+ def _cannot_judge(why: str) -> int:
328
+ if os.environ.get("EKBASIS_FAIL_OPEN") == "1":
329
+ print(f"ekbasis hook: cannot foresee ({why}); EKBASIS_FAIL_OPEN=1: letting it through", file=sys.stderr)
330
+ return 0
331
+ _decide(f"Ekbasis could not foresee what this command will do ({why}). Confirm it only if you know it is safe.")
332
+ return 0
333
+
334
+
335
+ def _with_deadline(fn, seconds: float):
336
+ """fn() in a thread; CannotJudge if it does not finish in time (the thread is abandoned)."""
337
+ out = {}
338
+
339
+ def run():
340
+ try:
341
+ out["v"] = fn()
342
+ except BaseException as e: # noqa: BLE001 (re-raised below)
343
+ out["e"] = e
344
+
345
+ t = threading.Thread(target=run, daemon=True)
346
+ t.start()
347
+ t.join(seconds)
348
+ if t.is_alive():
349
+ raise CannotJudge(f"the check did not finish within {seconds:g} s")
350
+ if "e" in out:
351
+ raise out["e"]
352
+ return out["v"]
353
+
354
+
355
+ def _seen_path(session: str) -> str:
356
+ return os.path.join(tempfile.gettempdir(), "ekbasis-preflight", re.sub(r"[^A-Za-z0-9_.-]", "_", session) + ".json")
357
+
358
+
359
+ def _preflight_key(line: str, cwd: str) -> str:
360
+ """The command and what it would run: a script or SQL file edited since the warning is checked again."""
361
+ content = ""
362
+ try:
363
+ plan = PF.plan_line(line, cwd)
364
+ if plan is not None:
365
+ content = "\n".join(s.text + "".join("\n" + x.text for x in (s.sql or [])) for s in plan.steps)
366
+ except Exception: # noqa: BLE001 — the line alone is the key then
367
+ pass
368
+ return hashlib.sha256(f"{os.path.realpath(cwd)}\0{line}\0{content}".encode()).hexdigest()[:32]
369
+
370
+
371
+ def _seen(session: str | None, key: str) -> bool:
372
+ if not session or os.environ.get("EKBASIS_PREFLIGHT_REPEAT", "1") == "0":
373
+ return False
374
+ try:
375
+ with open(_seen_path(session)) as fh:
376
+ return key in json.load(fh)
377
+ except (OSError, ValueError):
378
+ return False
379
+
380
+
381
+ def _remember(session: str | None, key: str) -> None:
382
+ if not session:
383
+ return
384
+ path = _seen_path(session)
385
+ try:
386
+ os.makedirs(os.path.dirname(path), exist_ok=True)
387
+ try:
388
+ with open(path) as fh:
389
+ keys = json.load(fh)
390
+ except (OSError, ValueError):
391
+ keys = []
392
+ with open(path, "w") as fh:
393
+ json.dump(keys + [key], fh)
394
+ except OSError:
395
+ pass
396
+
397
+
398
+ def _preflight(line: str, cwd: str, session: str | None, client, left) -> str | None:
399
+ """The preflight warning for this line, or None (since 0.1.6). Silent when it cannot judge, unless
400
+ EKBASIS_PREFLIGHT_FAIL_CLOSED=1: preflight is an extra check, not the guard against lost work."""
401
+ key = _preflight_key(line, cwd)
402
+ if _seen(session, key):
403
+ return None # warned before in this session: the second run goes through
404
+ again = "" if os.environ.get("EKBASIS_PREFLIGHT_REPEAT", "1") == "0" or not session else \
405
+ " Fix the plan first, or run the same command again to go ahead anyway."
406
+ fail_closed = os.environ.get("EKBASIS_PREFLIGHT_FAIL_CLOSED") == "1"
407
+ copy = os.environ.get("EKBASIS_PREFLIGHT_COPY", "1") != "0"
408
+ try:
409
+ v = _with_deadline(lambda: PF.check_line(line, cwd, client=client, fail_closed=fail_closed, copy=copy), left())
410
+ except CannotJudge as e:
411
+ if not fail_closed:
412
+ print(f"ekbasis preflight: cannot foresee ({e}); saying nothing", file=sys.stderr)
413
+ return None
414
+ _remember(session, key)
415
+ return f"Ekbasis preflight could not check this multi-step change ({e}).{again}"
416
+ if v is None or not v.risky:
417
+ if v is not None and v.cannot_judge:
418
+ print("ekbasis preflight: cannot foresee (" + "; ".join(v.plan.unread) + "); saying nothing", file=sys.stderr)
419
+ return None
420
+ _remember(session, key)
421
+ return v.message() + again
422
+
423
+
424
+ def main() -> int:
425
+ try:
426
+ data = json.load(sys.stdin)
427
+ except (ValueError, OSError) as e:
428
+ return _cannot_judge(f"could not read the hook input: {e}")
429
+ try:
430
+ if data.get("tool_name") != "Bash":
431
+ return 0
432
+ line = (data.get("tool_input") or {}).get("command", "")
433
+ cwd = data.get("cwd") or os.getcwd()
434
+ threshold = float(os.environ.get("EKBASIS_LOST_THRESHOLD", "0.2"))
435
+ deadline = float(os.environ.get("EKBASIS_HOOK_DEADLINE", "25"))
436
+ shortcuts = os.environ.get("EKBASIS_SHORTCUTS", "1") != "0"
437
+ t0 = time.monotonic()
438
+
439
+ def left() -> float:
440
+ r = deadline - (time.monotonic() - t0)
441
+ if r <= 0:
442
+ raise CannotJudge(f"the check did not finish within {deadline:g} s")
443
+ return r
444
+
445
+ client = Ekbasis(timeout=deadline, surface="claude-hook")
446
+ plan = plan_line(line, cwd)
447
+ if plan.why and os.environ.get("EKBASIS_GIT_GUARD", "1") != "0":
448
+ return _cannot_judge("; ".join(plan.why))
449
+ found = []
450
+ if os.environ.get("EKBASIS_GIT_GUARD", "1") == "0":
451
+ plan.steps = [] # preflight only: no git checks
452
+ for loss in G.committed_loss(plan.steps) if plan.steps else []:
453
+ found.append(f"Ekbasis (committed work, checked by code): this line {G.describe_loss(loss)}. Keep a branch "
454
+ "or tag on those commits first if you need them.")
455
+ for st in plan.steps:
456
+ for loss in G.remote_loss(st["repo"], [st["cmd"]]):
457
+ found.append(f"Ekbasis (remote work, checked by code): this line force-pushes over {loss['commits']} "
458
+ f"commit(s) that {loss['ref']} holds and the local branch does not. Push or merge those "
459
+ "commits first, or use --force-with-lease.")
460
+ groups: dict = {}
461
+ for st in plan.steps:
462
+ groups.setdefault((st["repo"], st["fresh"] is not None), []).append(st["cmd"])
463
+ for (repo, is_fresh), cmds in groups.items():
464
+ if is_fresh:
465
+ continue # created earlier in this line: nothing uncommitted there yet
466
+ if shortcuts and all(G.read_only(c) or R.clean_recoverable(repo, c) for c in cmds):
467
+ continue # only reads, or a git clean that removes only rebuildable ignored files
468
+ problem = G.readable(repo)
469
+ if problem:
470
+ if "not a git repository" in problem.lower():
471
+ continue # outside a repository the git commands fail or create one: nothing uncommitted to lose
472
+ return _cannot_judge(f"cannot read a git repository at {repo}: {problem}")
473
+ if shortcuts and R.no_uncommitted_work(repo):
474
+ continue
475
+ v = _with_deadline(lambda: G.check(cmds, repo=repo, client=client,
476
+ fetch=os.environ.get("EKBASIS_FETCH") == "1",
477
+ lost_threshold=threshold), left())
478
+ if v.risky:
479
+ found.append(f"Ekbasis: {'; '.join(v.reasons)}. Commands: {' ; '.join(cmds)}. "
480
+ "Consider saving the work first (commit, or git stash -u).")
481
+ if os.environ.get("EKBASIS_SHELL_GUARD") == "1" and plan.shell_line.strip() and changes_files(plan.shell_line):
482
+ skip = (lambda view: R.touched_recoverable(view, cwd)) if shortcuts else None
483
+ v = _with_deadline(lambda: S.check([plan.shell_line], cwd=cwd, client=client, lost_threshold=threshold,
484
+ skip=skip), left())
485
+ if v.risky:
486
+ what = "could not foresee every part of the line" if not v.lost_risky else "; ".join(v.reasons)
487
+ found.append(f"Ekbasis (shell guard): {what}. Line: {line}. Consider keeping a copy of the files first.")
488
+ if os.environ.get("EKBASIS_PREFLIGHT") == "1":
489
+ warn = _preflight(line, cwd, data.get("session_id"), client, left)
490
+ if warn:
491
+ found.append(warn)
492
+ except CannotJudge as e:
493
+ return _cannot_judge(str(e))
494
+ except Exception as e: # noqa: BLE001 (anything else: cannot judge)
495
+ return _cannot_judge(f"{type(e).__name__}: {e}")
496
+ if found:
497
+ _decide(" Also: ".join(found))
498
+ return 0
499
+
500
+
501
+ if __name__ == "__main__":
502
+ sys.exit(main())
@@ -0,0 +1,86 @@
1
+ """Entry points of the Claude Code plugin (plugins/ekbasis/), which runs its own copy of this package (plugins/ekbasis/lib,
2
+ kept identical by scripts/sync_plugin.py) through plugins/ekbasis/scripts/run.sh and claude_plugin.py:
3
+
4
+ session-start SessionStart hook: when Ekbasis is not set up, one line saying how (the git guard stays off); when it
5
+ is set up, nothing
6
+ hook PreToolUse hook (Bash): ekbasis.claude_code_hook, unchanged, when Ekbasis is set up; when it is not,
7
+ it says nothing and Claude Code's normal permission flow applies
8
+ mcp the MCP server (ekbasis.mcp_server)
9
+ bin/ekbasis the command line (ekbasis.cli); not set up, it says so and exits 3 (cannot foresee)
10
+
11
+ Set up means EKBASIS_URL or EKBASIS_API_KEY is set (in the shell, or in the "env" block of Claude Code's settings.json).
12
+ A key without a URL means the hosted API. The plugin does not call the default http://127.0.0.1:8000 on its own: an
13
+ unconfigured plugin would otherwise ask to confirm every git command that can change files ("cannot reach the server").
14
+ Once either variable is set, the hook is the same fail-closed hook as `ekbasis-claude-hook`, and the launcher turns any
15
+ failure of the hook itself (an import error, a crash, a Python too old or missing, no answer in time) into an "ask".
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+
23
+ HOSTED_URL = "https://openinterp.org/api/v1"
24
+ CONSOLE = "https://openinterp.org/console"
25
+ SETUP = (f"Ekbasis plugin: not set up, so the git guard is off. Get a key at {CONSOLE} and set EKBASIS_API_KEY=ekb_... "
26
+ "(shell, or \"env\" in ~/.claude/settings.json), or set EKBASIS_URL to your own server; then restart Claude Code.")
27
+ NOT_SET_UP = 3 # the CLI's "cannot foresee" exit code
28
+
29
+
30
+ def configured(env=None) -> bool:
31
+ env = os.environ if env is None else env
32
+ return bool(env.get("EKBASIS_URL", "").strip() or env.get("EKBASIS_API_KEY", "").strip())
33
+
34
+
35
+ def configure(env=None) -> bool:
36
+ """Whether Ekbasis is set up. A key without a URL points the client at the hosted API (env is changed in place)."""
37
+ env = os.environ if env is None else env
38
+ if env.get("EKBASIS_API_KEY", "").strip() and not env.get("EKBASIS_URL", "").strip():
39
+ env["EKBASIS_URL"] = HOSTED_URL
40
+ return configured(env)
41
+
42
+
43
+ def session_start(env=None) -> int:
44
+ if not configure(env):
45
+ print(json.dumps({"systemMessage": SETUP,
46
+ "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": SETUP}}))
47
+ return 0
48
+
49
+
50
+ def hook(env=None) -> int:
51
+ if not configure(env):
52
+ return 0
53
+ from . import claude_code_hook
54
+ return claude_code_hook.main()
55
+
56
+
57
+ def mcp(env=None) -> int:
58
+ configure(env)
59
+ from . import mcp_server
60
+ mcp_server.main()
61
+ return 0
62
+
63
+
64
+ def cli(argv=None, env=None) -> int:
65
+ """bin/ekbasis: the command line, but not set up it says so (exit 3, cannot foresee) instead of calling
66
+ http://127.0.0.1:8000; --help, and --url given on the command line, still work."""
67
+ argv = sys.argv[1:] if argv is None else argv
68
+ wants_help = not argv or any(a in ("-h", "--help") for a in argv)
69
+ if not configure(env) and not wants_help and not any(a == "--url" or a.startswith("--url=") for a in argv):
70
+ print(SETUP.replace("so the git guard is off", "so it cannot foresee anything"), file=sys.stderr)
71
+ return NOT_SET_UP
72
+ from . import cli as C
73
+ return C.main(argv)
74
+
75
+
76
+ def main(argv=None) -> int:
77
+ argv = sys.argv[1:] if argv is None else argv
78
+ run = {"session-start": session_start, "hook": hook, "mcp": mcp}.get(argv[0] if argv else "")
79
+ if run is None:
80
+ print("usage: claude_plugin.py session-start | hook | mcp", file=sys.stderr)
81
+ return 1
82
+ return run()
83
+
84
+
85
+ if __name__ == "__main__":
86
+ sys.exit(main())