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 +10 -0
- ekbasis/__main__.py +5 -0
- ekbasis/_version.py +1 -0
- ekbasis/claude_code_hook.py +502 -0
- ekbasis/claude_plugin.py +86 -0
- ekbasis/cli.py +223 -0
- ekbasis/client.py +182 -0
- ekbasis/git.py +848 -0
- ekbasis/mcp_server.py +75 -0
- ekbasis/preflight.py +1828 -0
- ekbasis/prompts.py +283 -0
- ekbasis/recover.py +258 -0
- ekbasis/shell.py +1333 -0
- ekbasis/simulate.py +225 -0
- ekbasis/verify.py +437 -0
- ekbasis/verify_policy.json +1 -0
- ekbasis-0.1.9.dist-info/METADATA +733 -0
- ekbasis-0.1.9.dist-info/RECORD +23 -0
- ekbasis-0.1.9.dist-info/WHEEL +5 -0
- ekbasis-0.1.9.dist-info/entry_points.txt +4 -0
- ekbasis-0.1.9.dist-info/licenses/LICENSE +202 -0
- ekbasis-0.1.9.dist-info/licenses/NOTICE +17 -0
- ekbasis-0.1.9.dist-info/top_level.txt +1 -0
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
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())
|
ekbasis/claude_plugin.py
ADDED
|
@@ -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())
|