From 98f4196e3843a5613cf6cf024e2d6371c48744fb Mon Sep 17 00:00:00 2001 From: Caio Vicentino Date: Fri, 16 Oct 2026 16:32:11 -0300 Subject: [PATCH 1/4] 0.1.42: the guards read variables set on the same line; preflight no longer guesses at steps it cannot read From a measured dogfooding log (687 commands of one Claude Code session, ekbasis-claude-hook 0.1.41 in log mode). The git guard and the shell guard read $NAME / ${NAME} with its value when the line itself sets NAME earlier to a known value (shell.resolve): a command made only of NAME=value words, at the top level, unconditional, the only place the line sets NAME. Anything else stays "cannot foresee" as before (environment, set twice, used before set, export/read/for/local, loops, conditions, pipelines, subshells, command substitution, eval/source/functions). A resolved word keeps the lexer's quoted flag, so `$S/hooktest.*` still expands as a pattern; the shell guard's state names the values it used. Preflight: a step that runs code given inline to an interpreter (python -c, a program on stdin or in a here-document, node -e, ...) or runs on another machine (ssh, scp/rsync host:) is never named as the failing step (Step.opaque); the other steps are still judged, and when none of them changes anything the line is "cannot judge" (silent unless EKBASIS_PREFLIGHT_FAIL_CLOSED=1). Root cause of "for ... do ... done ... likely to fail (checked in code)": one-line chains were cut at ; into commands named for/do/done/case/esac, which shell_facts found "not installed"; loops and conditions on one line are now "cannot judge", as in scripts. Two more code facts that misfired in the replay: a redirection into a folder an earlier step creates (mkdir -p out; ... > out/log), and a command given by its path (./node_modules/.bin/tsc) taken as "not installed". Replay through 0.1.41 and 0.1.42, hosted API, same state: lines flagged 299 -> 137; preflight warnings 171 -> 52 (on commands that really failed 6 -> 3, the 3 kept name the failing step); shell guard "could not foresee" 64 -> 19; git guard "cannot foresee" 76 -> 15. Lines whose flag went away because a variable was read, re-run with the work at stake put back: 15 of 15 flagged. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 28 +++ README.md | 11 +- docs/SECURITY.md | 3 +- ekbasis/_version.py | 2 +- ekbasis/claude_code_hook.py | 9 +- ekbasis/preflight.py | 155 +++++++++++-- ekbasis/shell.py | 164 +++++++++++++- plugins/ekbasis/.claude-plugin/plugin.json | 2 +- plugins/ekbasis/lib/ekbasis/_version.py | 2 +- .../ekbasis/lib/ekbasis/claude_code_hook.py | 9 +- plugins/ekbasis/lib/ekbasis/preflight.py | 155 +++++++++++-- plugins/ekbasis/lib/ekbasis/shell.py | 164 +++++++++++++- pyproject.toml | 2 +- server.json | 4 +- tests/test_line_vars_0142.py | 208 ++++++++++++++++++ tests/test_preflight_0142.py | 188 ++++++++++++++++ 16 files changed, 1053 insertions(+), 53 deletions(-) create mode 100644 tests/test_line_vars_0142.py create mode 100644 tests/test_preflight_0142.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1cf894..3e693a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,34 @@ +## [0.1.42] — 2026-10-16 + +### Changed +- **The guards read a variable set earlier on the same line.** The git guard and the shell guard read `$NAME` or + `${NAME}` with its value when the line itself sets NAME before, to a known value (`G=/repo; git -C "$G" status`, + `D=build; rm -rf "$D"`): a command made only of NAME=value words (a literal, quoted or not, or names already known), + run unconditionally at the top level, and the only place the line sets NAME. Every other variable is still not + guessed and stays "cannot foresee": one from the environment, one set twice, used before it is set, exported, read, + set in a loop, a condition, a pipeline or a subshell, set by command substitution, and every variable of a line with + eval, source or a function. The shell guard's state names the values it used. +- **Preflight does not guess at a step it cannot read.** A step that runs code given inline to an interpreter + (`python3 -c`, `python3 - <<'EOF'`, `node -e`, ...) or runs on another machine (ssh, scp or rsync to or from a host) + is never named as the failing one; the other steps of the line are still judged, and when none of them changes + anything the line is "cannot judge": silent, or one warning with `EKBASIS_PREFLIGHT_FAIL_CLOSED=1`. + +### Fixed +- Preflight cut a loop or a condition written on one line (`for f in a b; do ...; done`) at its `;` into commands + named `for`, `do`, `done`, `case`, `esac`, which code then found "not installed" and decided as failing ("checked in + code"). Such a line is now "cannot judge", as in scripts. Code also no longer decides as failing a redirection into a + folder that an earlier step of the line creates (`mkdir -p out; ... > out/log`), nor a command given by its path + (`./node_modules/.bin/tsc`) as "not installed". + +Measured by replaying the 687 commands of one Claude Code session's hook log through 0.1.41 and 0.1.42 (log mode, the +hosted API, shell guard and preflight on, same state): lines flagged 299 → 137; preflight warnings 171 → 52 (on commands +that really failed: 6 → 3, and the 3 kept name the step that failed); "could not foresee" from the shell guard 64 → 19 +and from the git guard 76 → 15. Each flag that went away because a variable was read was checked again with the work at +stake put back: 0.1.42 flags all 15 of those lines. + ## [0.1.41] — 2026-10-15 ### Added diff --git a/README.md b/README.md index 890ef11..24412e0 100644 --- a/README.md +++ b/README.md @@ -652,8 +652,8 @@ The agent studies behind this section, on demo apps, on real self-hosted Gitea, the reason; it stays silent otherwise. Since 0.1.2 it **fails closed**: when it cannot foresee a line (the server cannot be reached or does not answer within `EKBASIS_HOOK_DEADLINE`, the repository cannot be read, git is pointed at another repository, the line changes folder in a way the hook does not follow, or it has parts the hook cannot - evaluate: subshells, variables in a git command, nested shells, aliases), Claude Code asks you to confirm and says - why. `EKBASIS_FAIL_OPEN=1` restores 0.1.1's silence. The hook answers in JSON and exits 0, since in Claude Code an + evaluate: subshells, variables in a git command other than one set earlier on the same line to a known value, nested + shells, aliases), Claude Code asks you to confirm and says why. `EKBASIS_FAIL_OPEN=1` restores 0.1.1's silence. The hook answers in JSON and exits 0, since in Claude Code an exit code other than 2 does not block. The hook is a warning layer that can be wrong, not a security boundary: keep Claude Code's own confirmations, backups and least privilege. In `.claude/settings.json` (one project) or `~/.claude/settings.json` (all): @@ -717,7 +717,8 @@ The agent studies behind this section, on demo apps, on real self-hosted Gitea, (`>` emptying a file it also reads, `;` after a failed `cd`, rsync's trailing slash, `cp -r SRC/.`, a link with a trailing slash, xargs and spaces, `tar -x`, commands that refuse and change nothing, `rm -f`, `cp -u`). Same exit codes and the same fail-closed rule as `git-check`; a line with variables in its paths, subshells or nested shells is - "cannot foresee". In the Claude Code hook it is opt-in (`EKBASIS_SHELL_GUARD=1`) for lines that can change files + "cannot foresee" (since 0.1.42 a variable set earlier on the same line to a known value, `D=build; rm -rf "$D"`, is + read with it; one from the environment, set twice or set by command substitution is not). In the Claude Code hook it is opt-in (`EKBASIS_SHELL_GUARD=1`) for lines that can change files (since 0.1.3 also the shell part of lines with git). Measured on 292 fresh scenarios of 46 command forms it was not designed on (bash on Linux, the truth from running them): content lost right 91.4%, 93.2% of the losses flagged with 11.9% false alarms, failures right 96.2%, against 54.8% for a list of destructive commands (82.0% flagged, 67.9% false alarms). What it misses: see @@ -764,7 +765,9 @@ The agent studies behind this section, on demo apps, on real self-hosted Gitea, add (a backup copy, a new folder, CREATE TABLE), on their own; a plan that fails atomically (`sqlite3 -bail` inside `BEGIN ... COMMIT`). - **When it cannot foresee** (loops, conditionals, functions, other dot-commands, variables it cannot resolve), it says - nothing: it is an extra check (`--fail-closed` / `EKBASIS_PREFLIGHT_FAIL_CLOSED=1` warns instead). + nothing: it is an extra check (`--fail-closed` / `EKBASIS_PREFLIGHT_FAIL_CLOSED=1` warns instead). Since 0.1.42 a + step that runs code given inline to an interpreter (`python3 -c`, `python3 - <<'EOF'`) or runs on another machine + (ssh, scp/rsync to a host) is never named as the failing one; the other steps of the line are still judged. In the Claude Code hook it is opt-in: `EKBASIS_PREFLIGHT=1`. In headless runs an "ask" is a denial, so the warning says so, and the same command run again in the same session goes through (`EKBASIS_PREFLIGHT_REPEAT=0`: always warn). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 43418ce..607eff3 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -110,7 +110,8 @@ Measured on 292 fresh scenarios: no line of any file's content reached a prompt. - **Command parsing**: the hook reads `&&`, `||`, `;`, `|`, quotes, here-documents, `cd` (anywhere since 0.1.3), `pushd`/`popd`, subshells and `git -C`. Since 0.1.2, `bash -c`, `eval`, aliases, subshells, variables in a git command and lines it cannot follow are "cannot foresee" (a - confirmation) instead of passing unchecked; a script or program that does the damage itself (`python x.py`, + confirmation) instead of passing unchecked (since 0.1.42 a variable set earlier on the same line to a known value is + read with it; a value from outside the line is never guessed); a script or program that does the damage itself (`python x.py`, `make clean`, an npm script) is not seen. - **Domain**: git, and shell file commands as a prototype. Other commands get no opinion, which is not approval. - **File and branch names** are still shown to the model (measured above; adversarial training is on the roadmap). diff --git a/ekbasis/_version.py b/ekbasis/_version.py index 6e862d0..6728003 100644 --- a/ekbasis/_version.py +++ b/ekbasis/_version.py @@ -1 +1 @@ -__version__ = "0.1.41" +__version__ = "0.1.42" diff --git a/ekbasis/claude_code_hook.py b/ekbasis/claude_code_hook.py index 9c3bd95..fdb318e 100644 --- a/ekbasis/claude_code_hook.py +++ b/ekbasis/claude_code_hook.py @@ -54,6 +54,13 @@ EKBASIS_REQUEST (1: experimental, give the guards the last message the user typed; 0.1.23), EKBASIS_GUARD_REPEAT (1: warn once, the same command run again goes through; 0.1.24). +0.1.42: a variable set earlier on the same line to a known value (`G=/repo; git -C "$G" status`, `D=build; rm -rf +"$D"`) is read with that value by the git guard and the shell guard (ekbasis.shell.resolve); any other variable (from the +environment, set twice, exported, read, set in a loop or a condition, or by command substitution) is still not guessed: +"cannot foresee", as before. Preflight never names as failing a step that runs code given inline to an interpreter or +runs on another machine (the other steps are still judged), and says nothing on loops and conditions written on one +line (it cannot judge them). + Safer route: when the hook warns about git commands, it also offers the alternative with the same intent that Ekbasis checked and found safe (ekbasis.safer), e.g. `git stash push && git reset --hard`, or says that none passed. It is searched last, in the time the deadline leaves; when the search cannot finish, the warning goes out without a route. @@ -220,7 +227,7 @@ def plan_line(command: str, cwd: str) -> Plan: the line cannot be judged (structure the hook does not follow, variables in git commands, git in a nested shell, git through xargs unless it only lists or safely deletes, a folder it cannot tell).""" toks, problems = S.lex(command, parens=True) - items = _scoped(toks) + items = _scoped(S.resolve(toks)) # 0.1.42: variables set earlier on the line to a known value are read with it gitlike = bool(GIT_WORD.search(command)) plan = Plan() depth, balanced = 0, True diff --git a/ekbasis/preflight.py b/ekbasis/preflight.py index 7816f35..24f9a7e 100644 --- a/ekbasis/preflight.py +++ b/ekbasis/preflight.py @@ -39,9 +39,12 @@ `sqlite3` command fails before any statement runs: for a line made only of sqlite3 calls preflight says so, by code, without a warning (nothing changes), and in a longer line that step fails like any command that is not installed. `check_sql` (the Python API, for SQL you run some other way) asks Ekbasis when there is no sqlite3 shell to run a copy -with. What it cannot follow (loops, conditionals, functions, other dot-commands, variables it cannot resolve) is "cannot -judge": by default preflight then says nothing, since it is an extra check (`fail_closed=True` turns it into a -warning). +with. What it cannot follow (loops, conditionals, functions, other dot-commands, variables it cannot resolve; since +0.1.42 loops and conditions on one line too) is "cannot judge": by default preflight then says nothing, since it is an +extra check (`fail_closed=True` turns it into a warning). A step it cannot judge on its own (0.1.42: code given inline to +an interpreter, as in `python3 -c`, `python3 - < bool: @@ -189,8 +193,8 @@ def cannot_foresee(self) -> bool: def message(self) -> str: pl = self.plan if self.cannot_judge: - return ("Ekbasis preflight could not check this multi-step change (" + "; ".join(pl.unread) + "). If a " - "step fails midway, the steps before it stay applied.") + return ("Ekbasis preflight could not check this multi-step change (" + "; ".join(not_judged(pl)) + "). If " + "a step fails midway, the steps before it stay applied.") k, n = self.first, len(pl.steps) step = pl.steps[k - 1].text.replace("\n", " ") short = step if len(step) <= 160 else step[:157] + "..." @@ -215,6 +219,10 @@ def message(self) -> str: out.append(f"Step{'s' if len(self.runs_after) > 1 else ''} {_spans(self.runs_after)} would still run " f"(because {what}){tail}.") out.append("The change would be left half applied.") + opq = [k for k, st in enumerate(pl.steps, 1) if st.opaque] + if opq: + out.append(f"Step{'s' if len(opq) > 1 else ''} {_spans(opq)} {'were' if len(opq) > 1 else 'was'} not judged " + f"({'; '.join(dict.fromkeys(pl.steps[k - 1].opaque for k in opq))}).") return " ".join(out) def summary(self) -> str: @@ -227,7 +235,8 @@ def summary(self) -> str: + f"; P(some step fails) {100 * self.p_any:.0f}%)"] for i, (s, p) in enumerate(zip(pl.steps, self.p_fail), 1): mark = " <- first failure" if i == self.first else "" - tag = " (code)" if i in self.by_code else (" (number followed)" if i in self.by_numbers else "") + tag = " (code)" if i in self.by_code else (" (number followed)" if i in self.by_numbers else + (" (not judged)" if s.opaque else "")) t = s.text.replace("\n", " ") out.append(f" {100 * p:3.0f}% fails {i:2d}. [{s.effect}] {t if len(t) <= 100 else t[:97] + '...'}{tag}{mark}") if self.error: @@ -239,6 +248,15 @@ def summary(self) -> str: return "\n".join(out) +def not_judged(plan: Plan) -> list: + """What preflight did not follow or judge on this plan: the plan's unread parts, then each opaque step's reason.""" + opq = {} + for k, st in enumerate(plan.steps, 1): + if st.opaque: + opq.setdefault(st.opaque, []).append(k) + return list(plan.unread) + [f"step{'s' if len(ks) > 1 else ''} {_spans(ks)}: {why}" for why, ks in opq.items()] + + def _spans(ks: list) -> str: ks = sorted(ks) runs, start, prev = [], ks[0], ks[0] @@ -817,6 +835,74 @@ def _rewrite_groups(line: str, cwd: str, virtual: dict) -> tuple[str, list]: notes.append("group") +# 0.1.42: parts of a line preflight cannot judge. A step that runs code it does not see (an interpreter given its program +# with -c / -e or on its input) or that runs on another machine was judged as an ordinary command before (most often +# "likely to fail"): such a step is now never named as the failing one (Step.opaque), while the other steps of the +# line are still judged; when no other step could be, the line is "cannot judge". A loop or a condition written on one +# line was cut at its ; into commands named for, do, done ..., which code then found "not installed": the whole line +# is "cannot judge", as for scripts. +U_CONTROL = "control flow or functions on the line (if, for, while, case, function)" +U_INLINE = "code given inline, which preflight does not read: python -c, a program on standard input or in a here-document" +U_REMOTE = "a command that runs on another machine or copies to or from one: ssh, scp, rsync host:" +CONTROL_WORDS = {"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac", "select", + "function", "{", "}"} +INTERPRETERS = re.compile(r"(?:(python|pypy)(?:\d+(?:\.\d+)*)?|(node|nodejs)|(ruby)|(perl)|(php)|(bash|sh|zsh|dash|ksh))$") +TIMEOUT_VALUE = {"-s", "--signal", "-k", "--kill-after"} +REMOTE_OPERAND = re.compile(r"^(?:[^@/\s:]+@)?[A-Za-z0-9_.\[\]-]+::?(?!//)|^rsync://") + + +def _unwrap(sc) -> tuple[str, list]: + """The command a simple command runs and its argument words, past assignments, reserved words and the commands that + run another one: sudo, env, nohup, time, command, exec, `nice [-n N]`, `timeout [options] DURATION`.""" + words, i = list(sc.words), 0 + while i < len(words): + t = words[i].text + if S.ASSIGN.match(t) or (t in S.RESERVED and not words[i].dynamic): + i += 1 + elif t in ("timeout", "nice") or t in S.PREFIXES or t == "sudo": + i += 1 + while i < len(words) and (words[i].text.startswith("-") or (t == "env" and S.ASSIGN.match(words[i].text))): + i += 2 if words[i].text in TIMEOUT_VALUE | {"-n", "-u", "-g"} else 1 + i += 1 if t == "timeout" else 0 # the duration + else: + return os.path.basename(t), words[i + 1:] + return "", [] + + +def _inline_or_remote(sc) -> str | None: + """U_INLINE or U_REMOTE for one simple command, or None.""" + name, args = _unwrap(sc) + texts = [w.text for w in args] + pos = [t for t in texts if not t.startswith("-")] + if name in ("ssh", "mosh", "autossh", "sftp") and pos: + return U_REMOTE + if name in ("scp", "rsync") and any(REMOTE_OPERAND.match(t) for t in pos): + return U_REMOTE + m = INTERPRETERS.match(name or "") + if not m: + return None + python, node, ruby, perl, php, shell = m.groups() + inline = {"node": ("e", "p"), "ruby": ("e",), "perl": ("e", "E"), "php": ("r",), "shell": ("c", "s")} + letters = ("c",) if python else inline["node" if node else "ruby" if ruby else "perl" if perl else "php" if php else + "shell"] + i = 0 + while i < len(texts) and texts[i].startswith("-") and texts[i] != "-": + t = texts[i] + if t in ("--eval", "--print") and node: + return U_INLINE + if t == "--": + i += 1 + break + if not t.startswith("--") and any(x in t[1:] for x in letters): + return U_INLINE + if python and t in ("-m",): + return None # a module: a program on disk, like a script + i += 2 if (python and t in ("-W", "-X")) else 1 + if i >= len(texts) or texts[i] == "-": + return U_INLINE # no program named: it is read from the input (a here-document, a pipe, < file) + return None + + def _pipelines(ps) -> list: out = [] for sc in ps.commands: @@ -996,6 +1082,7 @@ def _shell_steps(lines_or_pipes, line: str, cwd0: str, bodies: dict, virtual: di effect, ctx = READ, False piped = None sql_call = None + opaque = "" if len(simples) == 2: n0, a0, _ = S.command_name(list(simples[0].words)) files = [w.text for w in simples[0].words[a0 + 1:] if not w.text.startswith("-")] @@ -1006,6 +1093,11 @@ def _shell_steps(lines_or_pipes, line: str, cwd0: str, bodies: dict, virtual: di except CannotJudge: piped = None for sc in simples: + w0 = sc.words[0] if sc.words else None + if w0 is not None and not w0.dynamic and not w0.quoted and ( + w0.text in CONTROL_WORDS or (len(sc.words) > 1 and sc.words[1].text == "{")): + unread.append(U_CONTROL) + opaque = opaque or _inline_or_remote(sc) or "" call = _sqlite_call(sc, cwd, bodies, piped if sc is simples[-1] else None, virtual) \ if S.command_name(list(sc.words))[0] == "sqlite3" else None if call is not None: @@ -1013,7 +1105,7 @@ def _shell_steps(lines_or_pipes, line: str, cwd0: str, bodies: dict, virtual: di continue e, c = shell_effect(sc, cwd) effect, ctx = _max(effect, e), ctx or c - step = Step(text=text, effect=effect, joined_by=joined, ctx=ctx, cwd=cwd) + step = Step(text=text, effect=effect, joined_by=joined, ctx=ctx, cwd=cwd, opaque=opaque) if sql_call is not None: db, sql, bail, un = sql_call sub = _sql_plan(db, sql, bail, un, cwd, "sqlite3") @@ -1514,7 +1606,19 @@ def shell_facts(plan: Plan) -> tuple[dict, list]: """({step index (1-based): the fact that makes it fail}, [fact lines for the state]). Only for paths no earlier step of the plan names (an earlier step could change them).""" import glob as _glob - fails, lines, named = {}, [], set() + fails, lines, named, made = {}, [], set(), set() + + def made_above(p): + """0.1.42: p or a folder above it is named by an earlier command that can create it (mkdir -p out; ... > + out/log): whether it exists then is not known now.""" + while True: + if p in made: + return True + up = os.path.dirname(p) + if up == p: + return False + p = up + for k, s in enumerate(plan.steps, 1): cwd = s.cwd or plan.cwd for sc in S.parse(s.text).commands: @@ -1543,7 +1647,7 @@ def fresh(t): parts.append(cur) cur = os.path.dirname(cur) blocker = next((x for x in reversed(parts) if os.path.exists(x) and not os.path.isdir(x)), None) - if blocker: + if blocker and blocker not in named: fact = f"{os.path.relpath(blocker, cwd)} is a file, so `mkdir {w.text}` fails" elif os.path.isdir(p) and "-p" not in [a.text for a in args]: fact = f"{w.text} already exists, so `mkdir {w.text}` (without -p) fails" @@ -1587,7 +1691,7 @@ def fresh(t): if not any("c" in w.text.lstrip("-") for w in args[:1] + [a for a in args if a.text.startswith("-") and not a.text.startswith("--")]): arc = None - if arc is not None and not arc.dynamic and fresh(arc.text): + if arc is not None and not arc.dynamic and fresh(arc.text) and not made_above(os.path.dirname(full(arc.text))): parent = os.path.dirname(full(arc.text)) if not os.path.isdir(parent): what = "is a file" if os.path.exists(parent) else "does not exist" @@ -1610,20 +1714,24 @@ def fresh(t): continue p = full(r.target.text) parent = os.path.dirname(p) - if fresh(r.target.text): + if fresh(r.target.text) and not made_above(parent): if not os.path.isdir(parent): fact = f"{os.path.relpath(parent, cwd)} is not a folder, so writing {r.target.text} fails" elif not os.access(parent, os.W_OK) and not (os.path.exists(p) and os.access(p, os.W_OK)): fact = f"{os.path.relpath(parent, cwd)}/ is read-only for you, so writing {r.target.text} fails" - if not fact and name and name not in SHELL_BUILTINS and os.sep not in name and not shutil.which(name): + if not fact and name and name not in SHELL_BUILTINS and name not in CONTROL_WORDS and \ + os.sep not in sc.words[at].text and not shutil.which(name): # (0.1.42: ./node_modules/.bin/tsc is a path) fact = f"`{name}` is not installed here (command not found), so that command fails" if fact and k not in fails: fails[k] = fact lines.append(fact) + creates = name not in CTX and name not in DESTROY_CMDS and shell_effect(sc, cwd)[0] != READ for w in args: if not w.dynamic: named.add(full(w.text.rstrip("/"))) named.add(full(w.text)) + if creates: + made.add(full(w.text.rstrip("/"))) for r in sc.redirects: if r.target is not None: named.add(full(r.target.text)) @@ -1702,10 +1810,11 @@ def one(k): return client.ask(_shell_prompt(v, sub, shell_facts(sub)[1]), {"f": S.shell_fails(k)})["f"].p_yes if walk and n > 1: - with cf.ThreadPoolExecutor(min(8, n - 1)) as ex: - w = [0.0] + list(ex.map(one, range(2, n + 1))) - req += n - 1 - p = [max(a, b) for a, b in zip(p, w)] + ks = [k for k in range(2, n + 1) if not plan.steps[k - 1].opaque] + with cf.ThreadPoolExecutor(min(8, max(1, len(ks)))) as ex: + w = dict(zip(ks, ex.map(one, ks))) + req += len(ks) + p = [max(a, w.get(k, 0.0)) for k, a in enumerate(p, 1)] # sqlite3 calls inside the chain: judged as SQL with their database's state for k, s in enumerate(plan.steps, 1): if s.sql and s.db: @@ -1720,6 +1829,10 @@ def one(k): for k, fact in fails.items(): p[k - 1] = 1.0 by_code.append(k) + for k, st in enumerate(plan.steps, 1): # 0.1.42: never named as the failing step (code it does not read, or + if st.opaque: # another machine) + p[k - 1] = 0.0 + by_code = [x for x in by_code if x != k] return p, prompt, sorted(by_code), [], req, p_lost @@ -1736,6 +1849,11 @@ def check_plan(plan: Plan, client: Ekbasis | None = None, fail_threshold: float if plan.unread: return PreflightVerdict(plan, [0.0] * n, 0.0, None, risky=fail_closed, cannot_judge=True, reasons=["cannot foresee: " + "; ".join(plan.unread)]) + opaque = [k for k, st in enumerate(plan.steps, 1) if st.opaque] + if opaque and not any(st.writes for st in plan.steps if not st.opaque): + # 0.1.42: only steps it does not judge change anything: no judged failure could leave a change half done + return PreflightVerdict(plan, [0.0] * n, 0.0, None, risky=fail_closed, cannot_judge=True, + reasons=["cannot foresee: " + "; ".join(not_judged(plan))]) if plan.kind == "sql" and plan.sqlite3_line and not sqlite_cli(): return PreflightVerdict(plan, [0.0] * n, 0.0, None, by="code", copy_note=( "the sqlite3 command-line tool is not installed here, so the line fails before any statement runs and " @@ -1795,6 +1913,11 @@ def check_plan(plan: Plan, client: Ekbasis | None = None, fail_threshold: float v.reason, v.reason_p = str(a.value), a.confidence v.reasons.append(f"step {v.first} {'failed on a copy' if v.by == 'copy' else 'likely fails'}: " f"{plan.steps[v.first - 1].text[:120]}") + elif opaque: + # 0.1.42: the steps it judged are fine, the others were not judged: as for the parts it cannot follow, say + # nothing unless fail_closed + v.cannot_judge, v.risky = True, fail_closed + v.reasons.append("cannot foresee: " + "; ".join(not_judged(plan))) v.ms = v.ms or 1000 * (time.monotonic() - t0) return v diff --git a/ekbasis/shell.py b/ekbasis/shell.py index 88a083f..37ea7c6 100644 --- a/ekbasis/shell.py +++ b/ekbasis/shell.py @@ -11,7 +11,7 @@ Nothing is executed. The guard reads the folder (names, sizes, and file contents only to compute the fingerprints) and, on Linux, `ls --version` and `bash --version` once, to name the versions. Lines with variables, command substitution, subshells, here-documents or brace expansion are not evaluated: the state says so, and the listing may miss what those -parts touch. +parts touch. Since 0.1.42 a variable set earlier on the same line to a known value is read with it (resolve()). """ from __future__ import annotations @@ -157,6 +157,7 @@ class Word: dynamic: bool = False # a $ or ` the shell would expand (the guard does not) start: int = -1 # where it is in the line (0.1.3; -1 for a here-document delimiter) end: int = -1 + values: tuple = () # 0.1.42: (name, value) of the variables set earlier on the line that it was read with @dataclass @@ -337,9 +338,161 @@ def put(t, r): return toks, problems -def parse(line: str) -> Parsed: - """The simple commands of a command line, with their redirections and the operators that join them.""" - toks, problems = lex(line) +# ---------------------------------------------------------------- variables set on the line itself (0.1.42) + +NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +PLAIN_VALUE = re.compile(r"[A-Za-z0-9_./@%+=:,-]+") # a value that an unquoted $NAME passes on unchanged (no split, no glob) +OPENS = {"for", "while", "until", "select", "if", "case", "{"} +CLOSES = {"done", "fi", "esac", "}"} +SETS_NAMES = {"read", "unset", "export", "local", "declare", "typeset", "readonly", "mapfile", "readarray", "getopts", + "printf", "let"} + + +def _ref(raw: str, i: int, env: dict): + """The value of the reference $NAME or ${NAME} at raw[i] (a "$") and where it ends; None for anything else ($1, $?, + ${NAME:-x}, $(...), a name not in env).""" + m = re.compile(r"\$\{(" + NAME.pattern + r")\}|\$(" + NAME.pattern + r")").match(raw, i) + if not m: + return None + name = m.group(1) or m.group(2) + return (name, env[name], m.end()) if name in env else None + + +def _expand_raw(raw: str, env: dict, assign: bool = False): + """A word as typed, with $NAME / ${NAME} replaced by env's values, quotes removed: (text, glob, [(name, value)]), + or None when some part stays unknown (another variable or parameter, a substitution, a value an unquoted reference + would split or turn into a pattern, a ~ in an assignment's value).""" + out, used, glob, i, n = [], [], False, 0, len(raw) + while i < n: + c = raw[i] + if c == "\\": + out.append(raw[i + 1] if i + 1 < n else "\\") + i += 2 + elif c == "'": + j = raw.find("'", i + 1) + if j < 0: + return None + out.append(raw[i + 1:j]) + i = j + 1 + elif c == '"': + j = i + 1 + while j < n and raw[j] != '"': + d = raw[j] + if d == "\\" and j + 1 < n and raw[j + 1] in '$`"\\\n': + out.append("" if raw[j + 1] == "\n" else raw[j + 1]) + j += 2 + elif d == "`": + return None + elif d == "$": + r = _ref(raw, j, env) + if r is None: + return None + used.append(r[:2]) + out.append(r[1]) + j = r[2] + else: + out.append(d) + j += 1 + if j >= n: + return None + i = j + 1 + elif c == "`": + return None + elif c == "$": + r = _ref(raw, i, env) + if r is None or (not assign and not PLAIN_VALUE.fullmatch(r[1])): + return None + used.append(r[:2]) + out.append(r[1]) + i = r[2] + else: + if assign and c == "~" and (i == 0 or raw[i - 1] == ":"): + return None + glob = glob or (c in "*?[" and not assign) + out.append(c) + i += 1 + return "".join(out), glob, used + + +def resolve(toks: list) -> list: + """0.1.42: the tokens of lex(line, parens=True) with each $NAME or ${NAME} replaced by its value when NAME is set + earlier on the same line to a value known there: a command made only of NAME=value words (a literal, quoted or not, + or names already known), run unconditionally in this shell (at the top level, first or after ; or a new line, not + in a pipeline, a subshell, a group, a loop or a condition, not in the background), and the only place the line sets + NAME. Every other variable stays unknown, as before: one from the environment, one set twice or set by export, read, + for, local and the like, one used before it is set, and every variable of a line with eval, source or a function. + A word read this way is no longer dynamic, and its `values` names the variables it was read with. Values from + outside the line are never guessed.""" + if not any(isinstance(t, Word) and t.dynamic for t in toks): + return toks + cmds, start, joined, depth = [], 0, "", 0 + for i, t in enumerate(toks + [("op", ";", "")]): + if isinstance(t, tuple) and t[1] in CONTROL + ("(", ")"): + if start < i: + cmds.append({"a": start, "b": i, "joined": joined, "depth": depth, + "after": t[1] if i < len(toks) else ""}) + if t[1] in ("(", ")"): + depth += 1 if t[1] == "(" else -1 + if depth < 0: + return toks + joined, start = t[1], i + 1 + if depth != 0: + return toks + sets, plain_at, nest = {}, {}, 0 + for c in cmds: + span = toks[c["a"]:c["b"]] + ws = [t for t in span if isinstance(t, Word)] + texts = [w.text for w in ws] + first = texts[0] if ws and not ws[0].dynamic else "" + nxt2 = toks[c["b"] + 1] if c["b"] + 1 < len(toks) else None + if first in ("eval", "source", ".", "function") or ( + len(ws) == 1 and c["after"] == "(" and isinstance(nxt2, tuple) and nxt2[1] == ")"): + return toks # code the line does not show could set any name: eval, source, a function + if first in CLOSES: + nest -= 1 + only_assign = bool(ws) and all(ASSIGN.match(x) for x in texts) and len(ws) == len(span) + for w in ws: + m = ASSIGN.match(w.text) + if m: + name = w.text[:m.end() - 1] + sets[name] = sets.get(name, 0) + 1 + if only_assign and nest == 0 and c["depth"] == 0 and c["joined"] in ("", ";", "\n") and \ + c["after"] not in ("|", "|&", "&", "("): + plain_at.setdefault(name, w) + for r in re.finditer(r"\$\{(" + NAME.pattern + r")[^}A-Za-z0-9_]", w.raw): + sets[r.group(1)] = sets.get(r.group(1), 0) + 2 # ${NAME:=x}, ${NAME[i]} and the like + if first in SETS_NAMES or first in ("for", "select"): + for x in texts[1:]: + if NAME.fullmatch(x): + sets[x] = sets.get(x, 0) + 2 + if first in OPENS: + nest += 1 + if "IFS" in sets: + return toks + known = {n: w for n, w in plain_at.items() if sets.get(n) == 1} + env, out = {}, list(toks) + for i, t in enumerate(toks): + if not isinstance(t, Word) or (i and isinstance(toks[i - 1], tuple) and toks[i - 1][1] == "<<"): + continue # (a here-document's delimiter) + if t.dynamic: + r = _expand_raw(t.raw, env) + if r is not None: + out[i] = Word(r[0], t.raw, r[1], t.quoted, False, t.start, t.end, tuple(r[2])) + for name, w in known.items(): + if w is t: + v = _expand_raw(t.raw[len(name) + 1:], env, assign=True) + if v is not None and "~" not in v[0]: + env[name] = v[0] + return out + + +def parse(line: str, resolve_names: bool = True) -> Parsed: + """The simple commands of a command line, with their redirections and the operators that join them. Variables set + earlier on the line to a known value are read with it (resolve(); resolve_names=False: as typed).""" + toks, problems = lex(line, parens=True) + if resolve_names: + toks = resolve(toks) + toks = [t for t in toks if not (isinstance(t, tuple) and t[1] in ("(", ")"))] cmds, words, redirs, joined = [], [], [], "" i = 0 while i < len(toks): @@ -882,6 +1035,9 @@ def read_line(self, k: int, parsed: Parsed): self.unread.append(f"command {k}: {p}") args_w = s.words[at + 1:] self.dynamic_parts(k, name, s, at, args_w) + for w in list(s.words) + [rd.target for rd in s.redirects if rd.target is not None]: + for var, val in w.values: # 0.1.42: a variable set earlier on the line, read with its value + self.facts.append(f"in command {k}, ${var} is {_q(val)} (set earlier on the line)") if name == "find": for i, w in enumerate(args_w[:-1]): if w.text in FIND_PATTERN_ARGS and args_w[i + 1].glob and not args_w[i + 1].quoted: diff --git a/plugins/ekbasis/.claude-plugin/plugin.json b/plugins/ekbasis/.claude-plugin/plugin.json index 0ca1cef..ec22bda 100644 --- a/plugins/ekbasis/.claude-plugin/plugin.json +++ b/plugins/ekbasis/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ekbasis", "displayName": "Ekbasis", - "version": "0.1.41", + "version": "0.1.42", "description": "Foresee before you act: a git guard hook for the Bash tool, an opt-in monitor hook for every tool (EKBASIS_MONITOR=1), the ekbasis-guard skill and the Ekbasis MCP server (calibrated consequence predictions). Needs EKBASIS_API_KEY or EKBASIS_URL.", "author": { "name": "Caio Vicentino", diff --git a/plugins/ekbasis/lib/ekbasis/_version.py b/plugins/ekbasis/lib/ekbasis/_version.py index 6e862d0..6728003 100644 --- a/plugins/ekbasis/lib/ekbasis/_version.py +++ b/plugins/ekbasis/lib/ekbasis/_version.py @@ -1 +1 @@ -__version__ = "0.1.41" +__version__ = "0.1.42" diff --git a/plugins/ekbasis/lib/ekbasis/claude_code_hook.py b/plugins/ekbasis/lib/ekbasis/claude_code_hook.py index 9c3bd95..fdb318e 100644 --- a/plugins/ekbasis/lib/ekbasis/claude_code_hook.py +++ b/plugins/ekbasis/lib/ekbasis/claude_code_hook.py @@ -54,6 +54,13 @@ EKBASIS_REQUEST (1: experimental, give the guards the last message the user typed; 0.1.23), EKBASIS_GUARD_REPEAT (1: warn once, the same command run again goes through; 0.1.24). +0.1.42: a variable set earlier on the same line to a known value (`G=/repo; git -C "$G" status`, `D=build; rm -rf +"$D"`) is read with that value by the git guard and the shell guard (ekbasis.shell.resolve); any other variable (from the +environment, set twice, exported, read, set in a loop or a condition, or by command substitution) is still not guessed: +"cannot foresee", as before. Preflight never names as failing a step that runs code given inline to an interpreter or +runs on another machine (the other steps are still judged), and says nothing on loops and conditions written on one +line (it cannot judge them). + Safer route: when the hook warns about git commands, it also offers the alternative with the same intent that Ekbasis checked and found safe (ekbasis.safer), e.g. `git stash push && git reset --hard`, or says that none passed. It is searched last, in the time the deadline leaves; when the search cannot finish, the warning goes out without a route. @@ -220,7 +227,7 @@ def plan_line(command: str, cwd: str) -> Plan: the line cannot be judged (structure the hook does not follow, variables in git commands, git in a nested shell, git through xargs unless it only lists or safely deletes, a folder it cannot tell).""" toks, problems = S.lex(command, parens=True) - items = _scoped(toks) + items = _scoped(S.resolve(toks)) # 0.1.42: variables set earlier on the line to a known value are read with it gitlike = bool(GIT_WORD.search(command)) plan = Plan() depth, balanced = 0, True diff --git a/plugins/ekbasis/lib/ekbasis/preflight.py b/plugins/ekbasis/lib/ekbasis/preflight.py index 7816f35..24f9a7e 100644 --- a/plugins/ekbasis/lib/ekbasis/preflight.py +++ b/plugins/ekbasis/lib/ekbasis/preflight.py @@ -39,9 +39,12 @@ `sqlite3` command fails before any statement runs: for a line made only of sqlite3 calls preflight says so, by code, without a warning (nothing changes), and in a longer line that step fails like any command that is not installed. `check_sql` (the Python API, for SQL you run some other way) asks Ekbasis when there is no sqlite3 shell to run a copy -with. What it cannot follow (loops, conditionals, functions, other dot-commands, variables it cannot resolve) is "cannot -judge": by default preflight then says nothing, since it is an extra check (`fail_closed=True` turns it into a -warning). +with. What it cannot follow (loops, conditionals, functions, other dot-commands, variables it cannot resolve; since +0.1.42 loops and conditions on one line too) is "cannot judge": by default preflight then says nothing, since it is an +extra check (`fail_closed=True` turns it into a warning). A step it cannot judge on its own (0.1.42: code given inline to +an interpreter, as in `python3 -c`, `python3 - < bool: @@ -189,8 +193,8 @@ def cannot_foresee(self) -> bool: def message(self) -> str: pl = self.plan if self.cannot_judge: - return ("Ekbasis preflight could not check this multi-step change (" + "; ".join(pl.unread) + "). If a " - "step fails midway, the steps before it stay applied.") + return ("Ekbasis preflight could not check this multi-step change (" + "; ".join(not_judged(pl)) + "). If " + "a step fails midway, the steps before it stay applied.") k, n = self.first, len(pl.steps) step = pl.steps[k - 1].text.replace("\n", " ") short = step if len(step) <= 160 else step[:157] + "..." @@ -215,6 +219,10 @@ def message(self) -> str: out.append(f"Step{'s' if len(self.runs_after) > 1 else ''} {_spans(self.runs_after)} would still run " f"(because {what}){tail}.") out.append("The change would be left half applied.") + opq = [k for k, st in enumerate(pl.steps, 1) if st.opaque] + if opq: + out.append(f"Step{'s' if len(opq) > 1 else ''} {_spans(opq)} {'were' if len(opq) > 1 else 'was'} not judged " + f"({'; '.join(dict.fromkeys(pl.steps[k - 1].opaque for k in opq))}).") return " ".join(out) def summary(self) -> str: @@ -227,7 +235,8 @@ def summary(self) -> str: + f"; P(some step fails) {100 * self.p_any:.0f}%)"] for i, (s, p) in enumerate(zip(pl.steps, self.p_fail), 1): mark = " <- first failure" if i == self.first else "" - tag = " (code)" if i in self.by_code else (" (number followed)" if i in self.by_numbers else "") + tag = " (code)" if i in self.by_code else (" (number followed)" if i in self.by_numbers else + (" (not judged)" if s.opaque else "")) t = s.text.replace("\n", " ") out.append(f" {100 * p:3.0f}% fails {i:2d}. [{s.effect}] {t if len(t) <= 100 else t[:97] + '...'}{tag}{mark}") if self.error: @@ -239,6 +248,15 @@ def summary(self) -> str: return "\n".join(out) +def not_judged(plan: Plan) -> list: + """What preflight did not follow or judge on this plan: the plan's unread parts, then each opaque step's reason.""" + opq = {} + for k, st in enumerate(plan.steps, 1): + if st.opaque: + opq.setdefault(st.opaque, []).append(k) + return list(plan.unread) + [f"step{'s' if len(ks) > 1 else ''} {_spans(ks)}: {why}" for why, ks in opq.items()] + + def _spans(ks: list) -> str: ks = sorted(ks) runs, start, prev = [], ks[0], ks[0] @@ -817,6 +835,74 @@ def _rewrite_groups(line: str, cwd: str, virtual: dict) -> tuple[str, list]: notes.append("group") +# 0.1.42: parts of a line preflight cannot judge. A step that runs code it does not see (an interpreter given its program +# with -c / -e or on its input) or that runs on another machine was judged as an ordinary command before (most often +# "likely to fail"): such a step is now never named as the failing one (Step.opaque), while the other steps of the +# line are still judged; when no other step could be, the line is "cannot judge". A loop or a condition written on one +# line was cut at its ; into commands named for, do, done ..., which code then found "not installed": the whole line +# is "cannot judge", as for scripts. +U_CONTROL = "control flow or functions on the line (if, for, while, case, function)" +U_INLINE = "code given inline, which preflight does not read: python -c, a program on standard input or in a here-document" +U_REMOTE = "a command that runs on another machine or copies to or from one: ssh, scp, rsync host:" +CONTROL_WORDS = {"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac", "select", + "function", "{", "}"} +INTERPRETERS = re.compile(r"(?:(python|pypy)(?:\d+(?:\.\d+)*)?|(node|nodejs)|(ruby)|(perl)|(php)|(bash|sh|zsh|dash|ksh))$") +TIMEOUT_VALUE = {"-s", "--signal", "-k", "--kill-after"} +REMOTE_OPERAND = re.compile(r"^(?:[^@/\s:]+@)?[A-Za-z0-9_.\[\]-]+::?(?!//)|^rsync://") + + +def _unwrap(sc) -> tuple[str, list]: + """The command a simple command runs and its argument words, past assignments, reserved words and the commands that + run another one: sudo, env, nohup, time, command, exec, `nice [-n N]`, `timeout [options] DURATION`.""" + words, i = list(sc.words), 0 + while i < len(words): + t = words[i].text + if S.ASSIGN.match(t) or (t in S.RESERVED and not words[i].dynamic): + i += 1 + elif t in ("timeout", "nice") or t in S.PREFIXES or t == "sudo": + i += 1 + while i < len(words) and (words[i].text.startswith("-") or (t == "env" and S.ASSIGN.match(words[i].text))): + i += 2 if words[i].text in TIMEOUT_VALUE | {"-n", "-u", "-g"} else 1 + i += 1 if t == "timeout" else 0 # the duration + else: + return os.path.basename(t), words[i + 1:] + return "", [] + + +def _inline_or_remote(sc) -> str | None: + """U_INLINE or U_REMOTE for one simple command, or None.""" + name, args = _unwrap(sc) + texts = [w.text for w in args] + pos = [t for t in texts if not t.startswith("-")] + if name in ("ssh", "mosh", "autossh", "sftp") and pos: + return U_REMOTE + if name in ("scp", "rsync") and any(REMOTE_OPERAND.match(t) for t in pos): + return U_REMOTE + m = INTERPRETERS.match(name or "") + if not m: + return None + python, node, ruby, perl, php, shell = m.groups() + inline = {"node": ("e", "p"), "ruby": ("e",), "perl": ("e", "E"), "php": ("r",), "shell": ("c", "s")} + letters = ("c",) if python else inline["node" if node else "ruby" if ruby else "perl" if perl else "php" if php else + "shell"] + i = 0 + while i < len(texts) and texts[i].startswith("-") and texts[i] != "-": + t = texts[i] + if t in ("--eval", "--print") and node: + return U_INLINE + if t == "--": + i += 1 + break + if not t.startswith("--") and any(x in t[1:] for x in letters): + return U_INLINE + if python and t in ("-m",): + return None # a module: a program on disk, like a script + i += 2 if (python and t in ("-W", "-X")) else 1 + if i >= len(texts) or texts[i] == "-": + return U_INLINE # no program named: it is read from the input (a here-document, a pipe, < file) + return None + + def _pipelines(ps) -> list: out = [] for sc in ps.commands: @@ -996,6 +1082,7 @@ def _shell_steps(lines_or_pipes, line: str, cwd0: str, bodies: dict, virtual: di effect, ctx = READ, False piped = None sql_call = None + opaque = "" if len(simples) == 2: n0, a0, _ = S.command_name(list(simples[0].words)) files = [w.text for w in simples[0].words[a0 + 1:] if not w.text.startswith("-")] @@ -1006,6 +1093,11 @@ def _shell_steps(lines_or_pipes, line: str, cwd0: str, bodies: dict, virtual: di except CannotJudge: piped = None for sc in simples: + w0 = sc.words[0] if sc.words else None + if w0 is not None and not w0.dynamic and not w0.quoted and ( + w0.text in CONTROL_WORDS or (len(sc.words) > 1 and sc.words[1].text == "{")): + unread.append(U_CONTROL) + opaque = opaque or _inline_or_remote(sc) or "" call = _sqlite_call(sc, cwd, bodies, piped if sc is simples[-1] else None, virtual) \ if S.command_name(list(sc.words))[0] == "sqlite3" else None if call is not None: @@ -1013,7 +1105,7 @@ def _shell_steps(lines_or_pipes, line: str, cwd0: str, bodies: dict, virtual: di continue e, c = shell_effect(sc, cwd) effect, ctx = _max(effect, e), ctx or c - step = Step(text=text, effect=effect, joined_by=joined, ctx=ctx, cwd=cwd) + step = Step(text=text, effect=effect, joined_by=joined, ctx=ctx, cwd=cwd, opaque=opaque) if sql_call is not None: db, sql, bail, un = sql_call sub = _sql_plan(db, sql, bail, un, cwd, "sqlite3") @@ -1514,7 +1606,19 @@ def shell_facts(plan: Plan) -> tuple[dict, list]: """({step index (1-based): the fact that makes it fail}, [fact lines for the state]). Only for paths no earlier step of the plan names (an earlier step could change them).""" import glob as _glob - fails, lines, named = {}, [], set() + fails, lines, named, made = {}, [], set(), set() + + def made_above(p): + """0.1.42: p or a folder above it is named by an earlier command that can create it (mkdir -p out; ... > + out/log): whether it exists then is not known now.""" + while True: + if p in made: + return True + up = os.path.dirname(p) + if up == p: + return False + p = up + for k, s in enumerate(plan.steps, 1): cwd = s.cwd or plan.cwd for sc in S.parse(s.text).commands: @@ -1543,7 +1647,7 @@ def fresh(t): parts.append(cur) cur = os.path.dirname(cur) blocker = next((x for x in reversed(parts) if os.path.exists(x) and not os.path.isdir(x)), None) - if blocker: + if blocker and blocker not in named: fact = f"{os.path.relpath(blocker, cwd)} is a file, so `mkdir {w.text}` fails" elif os.path.isdir(p) and "-p" not in [a.text for a in args]: fact = f"{w.text} already exists, so `mkdir {w.text}` (without -p) fails" @@ -1587,7 +1691,7 @@ def fresh(t): if not any("c" in w.text.lstrip("-") for w in args[:1] + [a for a in args if a.text.startswith("-") and not a.text.startswith("--")]): arc = None - if arc is not None and not arc.dynamic and fresh(arc.text): + if arc is not None and not arc.dynamic and fresh(arc.text) and not made_above(os.path.dirname(full(arc.text))): parent = os.path.dirname(full(arc.text)) if not os.path.isdir(parent): what = "is a file" if os.path.exists(parent) else "does not exist" @@ -1610,20 +1714,24 @@ def fresh(t): continue p = full(r.target.text) parent = os.path.dirname(p) - if fresh(r.target.text): + if fresh(r.target.text) and not made_above(parent): if not os.path.isdir(parent): fact = f"{os.path.relpath(parent, cwd)} is not a folder, so writing {r.target.text} fails" elif not os.access(parent, os.W_OK) and not (os.path.exists(p) and os.access(p, os.W_OK)): fact = f"{os.path.relpath(parent, cwd)}/ is read-only for you, so writing {r.target.text} fails" - if not fact and name and name not in SHELL_BUILTINS and os.sep not in name and not shutil.which(name): + if not fact and name and name not in SHELL_BUILTINS and name not in CONTROL_WORDS and \ + os.sep not in sc.words[at].text and not shutil.which(name): # (0.1.42: ./node_modules/.bin/tsc is a path) fact = f"`{name}` is not installed here (command not found), so that command fails" if fact and k not in fails: fails[k] = fact lines.append(fact) + creates = name not in CTX and name not in DESTROY_CMDS and shell_effect(sc, cwd)[0] != READ for w in args: if not w.dynamic: named.add(full(w.text.rstrip("/"))) named.add(full(w.text)) + if creates: + made.add(full(w.text.rstrip("/"))) for r in sc.redirects: if r.target is not None: named.add(full(r.target.text)) @@ -1702,10 +1810,11 @@ def one(k): return client.ask(_shell_prompt(v, sub, shell_facts(sub)[1]), {"f": S.shell_fails(k)})["f"].p_yes if walk and n > 1: - with cf.ThreadPoolExecutor(min(8, n - 1)) as ex: - w = [0.0] + list(ex.map(one, range(2, n + 1))) - req += n - 1 - p = [max(a, b) for a, b in zip(p, w)] + ks = [k for k in range(2, n + 1) if not plan.steps[k - 1].opaque] + with cf.ThreadPoolExecutor(min(8, max(1, len(ks)))) as ex: + w = dict(zip(ks, ex.map(one, ks))) + req += len(ks) + p = [max(a, w.get(k, 0.0)) for k, a in enumerate(p, 1)] # sqlite3 calls inside the chain: judged as SQL with their database's state for k, s in enumerate(plan.steps, 1): if s.sql and s.db: @@ -1720,6 +1829,10 @@ def one(k): for k, fact in fails.items(): p[k - 1] = 1.0 by_code.append(k) + for k, st in enumerate(plan.steps, 1): # 0.1.42: never named as the failing step (code it does not read, or + if st.opaque: # another machine) + p[k - 1] = 0.0 + by_code = [x for x in by_code if x != k] return p, prompt, sorted(by_code), [], req, p_lost @@ -1736,6 +1849,11 @@ def check_plan(plan: Plan, client: Ekbasis | None = None, fail_threshold: float if plan.unread: return PreflightVerdict(plan, [0.0] * n, 0.0, None, risky=fail_closed, cannot_judge=True, reasons=["cannot foresee: " + "; ".join(plan.unread)]) + opaque = [k for k, st in enumerate(plan.steps, 1) if st.opaque] + if opaque and not any(st.writes for st in plan.steps if not st.opaque): + # 0.1.42: only steps it does not judge change anything: no judged failure could leave a change half done + return PreflightVerdict(plan, [0.0] * n, 0.0, None, risky=fail_closed, cannot_judge=True, + reasons=["cannot foresee: " + "; ".join(not_judged(plan))]) if plan.kind == "sql" and plan.sqlite3_line and not sqlite_cli(): return PreflightVerdict(plan, [0.0] * n, 0.0, None, by="code", copy_note=( "the sqlite3 command-line tool is not installed here, so the line fails before any statement runs and " @@ -1795,6 +1913,11 @@ def check_plan(plan: Plan, client: Ekbasis | None = None, fail_threshold: float v.reason, v.reason_p = str(a.value), a.confidence v.reasons.append(f"step {v.first} {'failed on a copy' if v.by == 'copy' else 'likely fails'}: " f"{plan.steps[v.first - 1].text[:120]}") + elif opaque: + # 0.1.42: the steps it judged are fine, the others were not judged: as for the parts it cannot follow, say + # nothing unless fail_closed + v.cannot_judge, v.risky = True, fail_closed + v.reasons.append("cannot foresee: " + "; ".join(not_judged(plan))) v.ms = v.ms or 1000 * (time.monotonic() - t0) return v diff --git a/plugins/ekbasis/lib/ekbasis/shell.py b/plugins/ekbasis/lib/ekbasis/shell.py index 88a083f..37ea7c6 100644 --- a/plugins/ekbasis/lib/ekbasis/shell.py +++ b/plugins/ekbasis/lib/ekbasis/shell.py @@ -11,7 +11,7 @@ Nothing is executed. The guard reads the folder (names, sizes, and file contents only to compute the fingerprints) and, on Linux, `ls --version` and `bash --version` once, to name the versions. Lines with variables, command substitution, subshells, here-documents or brace expansion are not evaluated: the state says so, and the listing may miss what those -parts touch. +parts touch. Since 0.1.42 a variable set earlier on the same line to a known value is read with it (resolve()). """ from __future__ import annotations @@ -157,6 +157,7 @@ class Word: dynamic: bool = False # a $ or ` the shell would expand (the guard does not) start: int = -1 # where it is in the line (0.1.3; -1 for a here-document delimiter) end: int = -1 + values: tuple = () # 0.1.42: (name, value) of the variables set earlier on the line that it was read with @dataclass @@ -337,9 +338,161 @@ def put(t, r): return toks, problems -def parse(line: str) -> Parsed: - """The simple commands of a command line, with their redirections and the operators that join them.""" - toks, problems = lex(line) +# ---------------------------------------------------------------- variables set on the line itself (0.1.42) + +NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +PLAIN_VALUE = re.compile(r"[A-Za-z0-9_./@%+=:,-]+") # a value that an unquoted $NAME passes on unchanged (no split, no glob) +OPENS = {"for", "while", "until", "select", "if", "case", "{"} +CLOSES = {"done", "fi", "esac", "}"} +SETS_NAMES = {"read", "unset", "export", "local", "declare", "typeset", "readonly", "mapfile", "readarray", "getopts", + "printf", "let"} + + +def _ref(raw: str, i: int, env: dict): + """The value of the reference $NAME or ${NAME} at raw[i] (a "$") and where it ends; None for anything else ($1, $?, + ${NAME:-x}, $(...), a name not in env).""" + m = re.compile(r"\$\{(" + NAME.pattern + r")\}|\$(" + NAME.pattern + r")").match(raw, i) + if not m: + return None + name = m.group(1) or m.group(2) + return (name, env[name], m.end()) if name in env else None + + +def _expand_raw(raw: str, env: dict, assign: bool = False): + """A word as typed, with $NAME / ${NAME} replaced by env's values, quotes removed: (text, glob, [(name, value)]), + or None when some part stays unknown (another variable or parameter, a substitution, a value an unquoted reference + would split or turn into a pattern, a ~ in an assignment's value).""" + out, used, glob, i, n = [], [], False, 0, len(raw) + while i < n: + c = raw[i] + if c == "\\": + out.append(raw[i + 1] if i + 1 < n else "\\") + i += 2 + elif c == "'": + j = raw.find("'", i + 1) + if j < 0: + return None + out.append(raw[i + 1:j]) + i = j + 1 + elif c == '"': + j = i + 1 + while j < n and raw[j] != '"': + d = raw[j] + if d == "\\" and j + 1 < n and raw[j + 1] in '$`"\\\n': + out.append("" if raw[j + 1] == "\n" else raw[j + 1]) + j += 2 + elif d == "`": + return None + elif d == "$": + r = _ref(raw, j, env) + if r is None: + return None + used.append(r[:2]) + out.append(r[1]) + j = r[2] + else: + out.append(d) + j += 1 + if j >= n: + return None + i = j + 1 + elif c == "`": + return None + elif c == "$": + r = _ref(raw, i, env) + if r is None or (not assign and not PLAIN_VALUE.fullmatch(r[1])): + return None + used.append(r[:2]) + out.append(r[1]) + i = r[2] + else: + if assign and c == "~" and (i == 0 or raw[i - 1] == ":"): + return None + glob = glob or (c in "*?[" and not assign) + out.append(c) + i += 1 + return "".join(out), glob, used + + +def resolve(toks: list) -> list: + """0.1.42: the tokens of lex(line, parens=True) with each $NAME or ${NAME} replaced by its value when NAME is set + earlier on the same line to a value known there: a command made only of NAME=value words (a literal, quoted or not, + or names already known), run unconditionally in this shell (at the top level, first or after ; or a new line, not + in a pipeline, a subshell, a group, a loop or a condition, not in the background), and the only place the line sets + NAME. Every other variable stays unknown, as before: one from the environment, one set twice or set by export, read, + for, local and the like, one used before it is set, and every variable of a line with eval, source or a function. + A word read this way is no longer dynamic, and its `values` names the variables it was read with. Values from + outside the line are never guessed.""" + if not any(isinstance(t, Word) and t.dynamic for t in toks): + return toks + cmds, start, joined, depth = [], 0, "", 0 + for i, t in enumerate(toks + [("op", ";", "")]): + if isinstance(t, tuple) and t[1] in CONTROL + ("(", ")"): + if start < i: + cmds.append({"a": start, "b": i, "joined": joined, "depth": depth, + "after": t[1] if i < len(toks) else ""}) + if t[1] in ("(", ")"): + depth += 1 if t[1] == "(" else -1 + if depth < 0: + return toks + joined, start = t[1], i + 1 + if depth != 0: + return toks + sets, plain_at, nest = {}, {}, 0 + for c in cmds: + span = toks[c["a"]:c["b"]] + ws = [t for t in span if isinstance(t, Word)] + texts = [w.text for w in ws] + first = texts[0] if ws and not ws[0].dynamic else "" + nxt2 = toks[c["b"] + 1] if c["b"] + 1 < len(toks) else None + if first in ("eval", "source", ".", "function") or ( + len(ws) == 1 and c["after"] == "(" and isinstance(nxt2, tuple) and nxt2[1] == ")"): + return toks # code the line does not show could set any name: eval, source, a function + if first in CLOSES: + nest -= 1 + only_assign = bool(ws) and all(ASSIGN.match(x) for x in texts) and len(ws) == len(span) + for w in ws: + m = ASSIGN.match(w.text) + if m: + name = w.text[:m.end() - 1] + sets[name] = sets.get(name, 0) + 1 + if only_assign and nest == 0 and c["depth"] == 0 and c["joined"] in ("", ";", "\n") and \ + c["after"] not in ("|", "|&", "&", "("): + plain_at.setdefault(name, w) + for r in re.finditer(r"\$\{(" + NAME.pattern + r")[^}A-Za-z0-9_]", w.raw): + sets[r.group(1)] = sets.get(r.group(1), 0) + 2 # ${NAME:=x}, ${NAME[i]} and the like + if first in SETS_NAMES or first in ("for", "select"): + for x in texts[1:]: + if NAME.fullmatch(x): + sets[x] = sets.get(x, 0) + 2 + if first in OPENS: + nest += 1 + if "IFS" in sets: + return toks + known = {n: w for n, w in plain_at.items() if sets.get(n) == 1} + env, out = {}, list(toks) + for i, t in enumerate(toks): + if not isinstance(t, Word) or (i and isinstance(toks[i - 1], tuple) and toks[i - 1][1] == "<<"): + continue # (a here-document's delimiter) + if t.dynamic: + r = _expand_raw(t.raw, env) + if r is not None: + out[i] = Word(r[0], t.raw, r[1], t.quoted, False, t.start, t.end, tuple(r[2])) + for name, w in known.items(): + if w is t: + v = _expand_raw(t.raw[len(name) + 1:], env, assign=True) + if v is not None and "~" not in v[0]: + env[name] = v[0] + return out + + +def parse(line: str, resolve_names: bool = True) -> Parsed: + """The simple commands of a command line, with their redirections and the operators that join them. Variables set + earlier on the line to a known value are read with it (resolve(); resolve_names=False: as typed).""" + toks, problems = lex(line, parens=True) + if resolve_names: + toks = resolve(toks) + toks = [t for t in toks if not (isinstance(t, tuple) and t[1] in ("(", ")"))] cmds, words, redirs, joined = [], [], [], "" i = 0 while i < len(toks): @@ -882,6 +1035,9 @@ def read_line(self, k: int, parsed: Parsed): self.unread.append(f"command {k}: {p}") args_w = s.words[at + 1:] self.dynamic_parts(k, name, s, at, args_w) + for w in list(s.words) + [rd.target for rd in s.redirects if rd.target is not None]: + for var, val in w.values: # 0.1.42: a variable set earlier on the line, read with its value + self.facts.append(f"in command {k}, ${var} is {_q(val)} (set earlier on the line)") if name == "find": for i, w in enumerate(args_w[:-1]): if w.text in FIND_PATTERN_ARGS and args_w[i + 1].glob and not args_w[i + 1].quoted: diff --git a/pyproject.toml b/pyproject.toml index bc82d7f..7d4cc8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ekbasis" -version = "0.1.41" +version = "0.1.42" description = "Ekbasis client: what happens if I do this? Typed, calibrated consequence predictions in one pass, chained simulation and a git guard" readme = "README.md" requires-python = ">=3.9" diff --git a/server.json b/server.json index 02b04af..2b0f563 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.OpenInterpretability/ekbasis", "title": "Ekbasis", "description": "Foresee what git commands and multi-step changes will do, as calibrated probabilities", - "version": "0.1.41", + "version": "0.1.42", "websiteUrl": "https://openinterp.org/ekbasis", "repository": { "url": "https://github.com/OpenInterpretability/ekbasis", @@ -14,7 +14,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "ekbasis", - "version": "0.1.41", + "version": "0.1.42", "runtimeHint": "uvx", "runtimeArguments": [ {"type": "named", "name": "--python", "value": ">=3.10", "description": "the MCP SDK needs Python 3.10 or later"}, diff --git a/tests/test_line_vars_0142.py b/tests/test_line_vars_0142.py new file mode 100644 index 0000000..7615871 --- /dev/null +++ b/tests/test_line_vars_0142.py @@ -0,0 +1,208 @@ +"""0.1.42: a variable set earlier on the same line to a known value is read with it, by the shell guard and the git +guard (shell.resolve). The lines are real ones from a Claude Code session's hook log (689 commands, 2026-10-16), with +paths, hosts and names replaced. Everything else stays as before: a variable from the environment, set twice, used +before it is set, exported, set in a loop, a condition, a pipeline or a subshell, or by command substitution, is not +guessed ("cannot foresee"). Offline: a fake client.""" +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from ekbasis import claude_code_hook as H +from ekbasis import shell as S +from ekbasis.client import Answer + +ENV = dict(os.environ, GIT_AUTHOR_NAME="Dev", GIT_AUTHOR_EMAIL="dev@example.invalid", GIT_COMMITTER_NAME="Dev", + GIT_COMMITTER_EMAIL="dev@example.invalid", GIT_CONFIG_NOSYSTEM="1", LC_ALL="C") + + +def sh(cmd, cwd): + subprocess.run(["bash", "-c", cmd], cwd=cwd, env=ENV, check=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +class Fake: + """'lost' with p_lost, every other yes/no with p_fail; counts the requests.""" + + def __init__(self, p_lost=0.0, p_fail=0.0): + self.p_lost, self.p_fail, self.requests = p_lost, p_fail, [] + + def ask(self, state, questions, read_once=False, images=None): + self.requests.append(state) + out = {} + for k in questions: + if k == "why": + out[k] = Answer(value="other", confidence=0.9, probabilities={"other": 0.9}) + continue + p = self.p_lost if k == "lost" else self.p_fail + out[k] = Answer(value=p >= 0.5, confidence=max(p, 1 - p), probabilities={"yes": p, "no": 1 - p}, p_yes=p) + return out + + +def words(line): + return [[(w.text, w.dynamic) for w in c.words] for c in S.parse(line).commands] + + +class TestResolve(unittest.TestCase): + def test_literal_assignments_are_read(self): + self.assertEqual(words('D=/tmp/x; rm -rf "$D"')[1], [("rm", False), ("-rf", False), ("/tmp/x", False)]) + self.assertEqual(words('G=/a; T="$G/b"; ls ${T}/c $G')[2], [("ls", False), ("/a/b/c", False), ("/a", False)]) + self.assertEqual(words("D='/a b'; rm \"$D\"")[1][1], ("/a b", False)) + w = S.parse('D=/a; rm "$D/x"').commands[1].words[1] + self.assertEqual(w.values, (("D", "/a"),)) + + def test_quoting_is_kept(self): + self.assertEqual(words("D=/a; echo '$D' \\$D")[1][1:], [("$D", False), ("$D", False)]) + self.assertEqual(words('D="a b"; rm $D')[1][1], ("$D", True)) # unquoted: it would split + self.assertEqual(words('D="*.log"; rm $D')[1][1], ("$D", True)) # unquoted: it would be a pattern + self.assertEqual(words('D=x; rm $D.*')[1][1], ("x.*", False)) + self.assertTrue(S.parse('D=x; rm $D.*').commands[1].words[1].glob) + + def test_what_stays_unknown(self): + for line in ['rm -rf "$HOME_X/build"', # not set on the line + 'rm "$D"; D=/a', # used before it is set + 'D=/a; rm "$D"; D=/b; rm "$D"', # set twice + 'export D=/a; rm "$D"', # exported + 'D=/a rm "$D"', # an assignment for that command only + 'false && D=/a; rm "$D"', # set only if the command before succeeds + 'D=/a | cat; rm "$D"', # in a pipeline: a subshell + '(D=/a); rm "$D"', # in a subshell + 'if x; then D=/a; fi; rm "$D"', # in a condition + 'for D in a b; do rm "$D"; done', # a loop variable + 'D=$(pwd); rm "$D"', # command substitution + 'D=`pwd`; rm "$D"', + 'D=/a; read D; rm "$D"', + 'D=/a; eval "$X"; rm "$D"', # eval or source could set anything + 'D=/a; source env.sh; rm "$D"', + 'f() { D=/x; }; D=/a; f; rm "$D"', + 'D=~/x; rm "$D"', # ~ in a value + 'D=/a; rm "${D:-/b}"', + 'IFS=/; D=/a; rm $D']: + rms = [c for c in S.parse(line).commands if S.command_name(c.words)[0] == "rm"] + self.assertTrue(rms and all(any(w.dynamic for w in c.words[1:]) for c in rms), line) + + def test_here_document_body_and_delimiter_are_untouched(self): + ps = S.parse("D=/a; cat < \"$D/f\"\n$D\nEOF") + rd = ps.commands[1].redirects + self.assertEqual([(r.op, r.target.text) for r in rd], [("<<", "EOF"), (">", "/a/f")]) + + +class Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.repo = os.path.join(self.tmp, "release skopos") + os.makedirs(self.repo) + for c in ("git init -q -b main", "git config commit.gpgsign false", "printf 'tracked\\n' > results.json", + "mkdir -p work && printf 'v1\\n' > work/kept.txt", "git add -A", "git commit -qm init", + "printf 'v2\\n' > work/kept.txt", "printf 'draft\\n' > work/untracked.txt"): + sh(c, self.repo) + self.env = mock.patch.dict(os.environ, {"TMPDIR": tempfile.mkdtemp(), "WORK_DIR": self.repo}, clear=False) + self.env.start() + for k in ("EKBASIS_GUARD_MODE", "EKBASIS_FAIL_OPEN", "EKBASIS_SHELL_GUARD", "EKBASIS_PREFLIGHT", + "EKBASIS_GIT_GUARD", "EKBASIS_GUARD_REPEAT", "EKBASIS_PREFLIGHT_FAIL_CLOSED"): + os.environ.pop(k, None) + tempfile.tempdir = None + + def tearDown(self): + self.env.stop() + tempfile.tempdir = None + + def hook(self, line, fake, env=None, session="s1"): + data = {"tool_name": "Bash", "tool_input": {"command": line}, "cwd": self.tmp, "session_id": session} + out = io.StringIO() + with mock.patch.dict(os.environ, env or {}), mock.patch.object(H, "Ekbasis", return_value=fake), \ + mock.patch.object(sys, "stdin", io.StringIO(json.dumps(data))), redirect_stdout(out): + self.assertEqual(H.main(), 0) + return json.loads(out.getvalue())["hookSpecificOutput"] if out.getvalue().strip() else None + + +class TestLogLines(Base): + """Lines of the hook log that the guards could not foresee in 0.1.41, now read.""" + + def test_shell_guard_reads_a_folder_set_on_the_line(self): + line = f'RS="{self.repo}"; mkdir -p "$RS/prelaunch_out" && cp results.json "$RS/prelaunch_out/"' + v = S.inspect([line], self.tmp) + self.assertEqual(v.unread, []) + self.assertIn(os.path.join(self.repo, "prelaunch_out"), v.touched) + self.assertIn(f'$RS is "{self.repo}" (set earlier on the line)', v.state) + + def test_two_names_one_built_from_the_other(self): + line = f'SPD={self.tmp}/scratch; T="$SPD/tools"; rm -rf $SPD/dryrun && mkdir -p $SPD/dryrun "$T"' + self.assertEqual(S.inspect([line], self.tmp).unread, []) + + def test_git_guard_follows_cd_and_git_c_with_a_variable(self): + for line in (f'G="{self.repo}"; cd "$G"; git status --short; git log --oneline -3', + f'G="{self.repo}"; git -C "$G" diff --stat; git -C "$G" log -1 --format=%H'): + p = H.plan_line(line, self.tmp) + self.assertEqual(p.why, [], line) + self.assertEqual({s["repo"] for s in p.steps}, {self.repo}, line) + self.assertIsNone(self.hook(f'G="{self.repo}"; cd "$G"; git status --short', Fake())) + + +class TestStillFlagged(Base): + def test_git_reset_hard_with_uncommitted_work(self): + for line in ("git -C 'release skopos' reset --hard", f'G="{self.repo}"; cd "$G" && git reset --hard'): + f = Fake(p_lost=0.9) + a = self.hook(line, f) + self.assertEqual(a["permissionDecision"], "ask", line) + self.assertIn("may permanently lose uncommitted work", a["permissionDecisionReason"], line) + self.assertTrue(f.requests, line) + + def test_rm_of_a_folder_set_on_the_line_with_uncommitted_files(self): + f = Fake(p_lost=0.9) + a = self.hook(f'D="{self.repo}/work"; rm -rf "$D"', f, {"EKBASIS_SHELL_GUARD": "1"}) + self.assertEqual(a["permissionDecision"], "ask") + self.assertIn("may permanently lose file content", a["permissionDecisionReason"]) + self.assertNotIn("could not foresee", a["permissionDecisionReason"]) + self.assertIn("untracked.txt", a["permissionDecisionReason"]) + self.assertTrue(any(f'$D is "{self.repo}/work"' in s for s in f.requests)) + + def test_a_pattern_after_a_variable_still_expands(self): + # the log's line: S=; rm -rf $S/hooktest.* (a test repository with uncommitted work) + os.makedirs(os.path.join(self.tmp, "hooktest.a1")) + with open(os.path.join(self.tmp, "hooktest.a1", "draft.txt"), "w") as fh: + fh.write("work\n") + line = f"S={self.tmp}; rm -rf $S/hooktest.*; ls" + v = S.inspect([line], self.tmp) + self.assertEqual(v.unread, []) + self.assertEqual([how for t, how in v.touched.items() if t.endswith("hooktest.a1")], ["tree"]) # expanded + f = Fake(p_lost=0.9) + a = self.hook(line, f, {"EKBASIS_SHELL_GUARD": "1"}) + self.assertIn("may permanently lose file content", a["permissionDecisionReason"]) + self.assertIn("draft.txt", a["permissionDecisionReason"]) + + def test_a_variable_from_the_environment_is_not_guessed(self): + f = Fake(p_lost=0.0) + a = self.hook('rm -rf "$WORK_DIR/work"', f, {"EKBASIS_SHELL_GUARD": "1"}) # set in the environment only + self.assertEqual(a["permissionDecision"], "ask") + self.assertIn("could not foresee every part of the line", a["permissionDecisionReason"]) + + def test_a_reassigned_variable_is_not_guessed(self): + for line in (f'D="{self.repo}/work"; rm -rf "$D"; D=/nonexistent; rm -rf "$D"', + f'rm -rf "$D"; D="{self.repo}/work"', + f'export D="{self.repo}/work"; rm -rf "$D"'): + a = self.hook(line, Fake(p_lost=0.0), {"EKBASIS_SHELL_GUARD": "1"}) + self.assertEqual(a["permissionDecision"], "ask", line) + self.assertIn("could not foresee every part of the line", a["permissionDecisionReason"], line) + + def test_git_with_an_unknown_variable_still_cannot_be_judged(self): + a = self.hook('git -C "$WORK_DIR" reset --hard', Fake(p_lost=0.0)) + self.assertEqual(a["permissionDecision"], "ask") + self.assertIn("variable", a["permissionDecisionReason"]) + + def test_inline_code_does_not_hide_an_rm_outside_it(self): + line = "cd 'release skopos' && python3 - <<'EOF'\nprint('cleanup')\nEOF\nrm -rf work" + f = Fake(p_lost=0.9, p_fail=0.99) + a = self.hook(line, f, {"EKBASIS_SHELL_GUARD": "1", "EKBASIS_PREFLIGHT": "1"}) + self.assertEqual(a["permissionDecision"], "ask") + self.assertIn("Ekbasis (shell guard): may permanently lose file content", a["permissionDecisionReason"]) + self.assertNotIn("`python3 -`", a["permissionDecisionReason"]) # preflight never names the inline code + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_preflight_0142.py b/tests/test_preflight_0142.py new file mode 100644 index 0000000..ba8cc2b --- /dev/null +++ b/tests/test_preflight_0142.py @@ -0,0 +1,188 @@ +"""0.1.42: preflight says nothing on what it cannot judge, as its docstring says. From a Claude Code session's hook log +(689 commands, 2026-10-16; paths, hosts and names replaced): steps that run code given inline to an interpreter +(`python3 - <<'EOF'`, `python3 -c`, `node -e`), steps that run on another machine (ssh, scp/rsync with host:) and loops +written on one line came out "step N is likely to fail". Loops were cut at their `;` into commands named for, do, done, +case, esac, which code then found "not installed" and decided as failing (checked in code). A redirection into a folder +an earlier step creates (`mkdir -p out; ... > out/log`) was also decided as failing. Offline: a fake client.""" +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from ekbasis import claude_code_hook as H +from ekbasis import preflight as PF +from ekbasis.client import Answer + + +class Fake: + """Step k fails with fails.get(k, p_fail) (the walk's question `f` too); nothing is lost; counts the requests.""" + + def __init__(self, p_fail=0.99, fails=None): + self.p_fail, self.fails, self.requests = p_fail, fails or {}, [] + + def ask(self, state, questions, read_once=False, images=None): + self.requests.append(state) + out = {} + for k in questions: + if k == "why": + out[k] = Answer(value="other", confidence=0.9, probabilities={"other": 0.9}) + continue + if k == "lost": + p = 0.0 + elif k == "f": + n = len([x for x in state.split("Actions, in order:")[1].split("\n\n")[0].splitlines() + if x.strip()[:1].isdigit()]) + p = self.fails.get(n, self.p_fail) + else: + p = self.fails.get(int(k[1:]), self.p_fail) + out[k] = Answer(value=p >= 0.5, confidence=max(p, 1 - p), probabilities={"yes": p, "no": 1 - p}, p_yes=p) + return out + + +def box(root): + for d in ("compare", "dry", "notes", "public", "tools"): + os.makedirs(os.path.join(root, d), exist_ok=True) + for p in ("analyze.py", "chart.png", "chart.tmp", "sanity10.jsonl", "compare/P8.json", "compare/P8_same.json", + "notes/pin.txt", "dry/old.json", "build.py", "a.json", "tools/release.sh"): + with open(os.path.join(root, p), "w") as fh: + fh.write("x\n") + return root + + +# the log's lines, as typed (with {box} for the folder) +INLINE = ["cd {box} && python3 - <<'EOF'\np = 'analyze.py'\ns = open(p).read()\nopen(p, 'w').write(s.replace('a', 'b'))\n" + "EOF\ncp chart.png public/chart.png; rm -f chart.tmp", + "head -1 sanity10.jsonl | python3 -I -c \"import json,sys; r=json.loads(sys.stdin.read()); print(sorted(r))\"; " + "cp analyze.py dry/; rm -f dry/old.json", + "timeout 300 python3 - <<'EOF' 2>&1 | grep -v NotOpenSSL\nprint(1)\nEOF\nmv a.json dry/a.json; rm -f chart.tmp", + "node -e \"require('fs').rmSync('chart.tmp')\"; cp chart.png public/; rm -f a.json"] +REMOTE = ["timeout 60 scp -q -P 2222 notes/pin.txt root@host.example:/root/pin.txt && mv notes/pin.txt notes/pin.sent; " + "rm -f chart.tmp", + "timeout 90 ssh -o ConnectTimeout=15 -p 2222 root@host.example 'P=2577534; kill $P'; sleep 1; " + "mv a.json dry/; rm -f chart.tmp", + "rsync -a dry/ root@host.example:/root/dry/; rm -rf dry; mv a.json notes/"] +LOOPS = ["cd {box}/compare && for f in P8.json P8_same.json; do echo \"=== $f\"; cat $f; echo; done", + "for p in $(pgrep -f driver2.sh); do a=$(ps -o args= -p $p); case \"$a\" in *driver2.sh*) kill $p;; esac; " + "done; sleep 1; rm -f chart.tmp", + "find compare -type f | head -50 | while read f; do cp \"$f\" dry/; done; rm -f a.json"] + + +class TestCannotJudge(unittest.TestCase): + def setUp(self): + self.box = box(tempfile.mkdtemp()) + + def lines(self, group): + return [x.replace("{box}", self.box) for x in group] + + def test_inline_and_remote_steps_are_never_named(self): + for group, why in ((INLINE, PF.U_INLINE), (REMOTE, PF.U_REMOTE)): + for line in self.lines(group): + plan = PF.plan_line(line, self.box) + self.assertEqual(plan.unread, [], line) + opaque = [k for k, st in enumerate(plan.steps, 1) if st.opaque] + self.assertTrue(opaque and all(plan.steps[k - 1].opaque == why for k in opaque), line) + # the model says only those steps fail: nothing is named, and the line is "cannot judge" (silent) + f = Fake(p_fail=0.01, fails={k: 0.99 for k in opaque}) + v = PF.check_line(line, self.box, client=f, copy=False) + self.assertIsNone(v.first, line) + self.assertTrue(v.cannot_judge and not v.risky, line) + self.assertTrue(PF.check_line(line, self.box, client=Fake(p_fail=0.01, fails={k: 0.99 for k in opaque}), + copy=False, fail_closed=True).risky, line) + # every step fails: the first one named is one it judges + v = PF.check_line(line, self.box, client=Fake(p_fail=0.99), copy=False) + self.assertNotIn(v.first, opaque, line) + + def test_loops_on_one_line_are_not_judged(self): + for line in self.lines(LOOPS): + f = Fake() + v = PF.check_line(line, self.box, client=f, copy=False) + self.assertTrue(v.cannot_judge and not v.risky, line) + self.assertIn(PF.U_CONTROL, v.plan.unread, line) + self.assertEqual(f.requests, [], line) + + def test_a_step_after_inline_code_is_still_judged(self): + # the log's line: an inline edit of a script, then the script; the script failed, after the edit was applied + line = ("cd {box} && python3 - <<'EOF'\np = 'build.py'\ns = open(p).read()\nopen(p, 'w').write(s)\nEOF\n" + "python3 build.py").replace("{box}", self.box) + v = PF.check_line(line, self.box, client=Fake(p_fail=0.01, fails={3: 0.6}), copy=False, walk=False) + self.assertEqual((v.first, v.risky), (3, True)) + self.assertIn("Step 2 was not judged", v.message()) + + def test_no_reserved_word_is_a_missing_command(self): + for line in self.lines(LOOPS): + plan = PF.plan_line(line, self.box) + facts = PF.shell_facts(plan)[1] + self.assertFalse([x for x in facts if "is not installed" in x], line) + + def test_a_script_file_and_a_local_chain_are_still_judged(self): + line = "cp a.json dry/a.json; python3 build.py; rm -f a.json" + plan = PF.plan_line(line, self.box) + self.assertEqual(plan.unread, []) + + class Second(Fake): + def ask(self, state, questions, read_once=False, images=None): + out = super().ask(state, questions, read_once, images) + for k in questions: + if k not in ("why", "lost"): + p = 0.95 if k in ("f2",) else 0.01 + out[k] = Answer(value=p >= 0.5, confidence=max(p, 1 - p), probabilities={"yes": p, "no": 1 - p}, + p_yes=p) + return out + v = PF.check_line(line, self.box, client=Second(), copy=False, walk=False) + self.assertEqual(v.first, 2) + self.assertTrue(v.risky) # rm still runs after the failing step + self.assertIn("would still run", v.message()) + + def test_a_command_given_by_its_path_is_not_a_missing_command(self): + os.makedirs(os.path.join(self.box, "node_modules", ".bin")) + plan = PF.plan_line("cp a.json dry/; ./node_modules/.bin/tsc --noEmit -p .; rm -f chart.tmp", self.box) + self.assertEqual(PF.shell_facts(plan)[0], {}) + + def test_a_folder_an_earlier_step_creates(self): + line = (f"rm -rf {self.box}/dryrun && mkdir -p {self.box}/dryrun; DRY_RUN=1 bash tools/release.sh > " + f"{self.box}/dryrun/rel.log 2>&1; echo \"exit=$?\"") + plan = PF.plan_line(line, self.box) + self.assertEqual(PF.shell_facts(plan)[0], {}) + line = "echo x > nope/rel.log; cp a.json b.json; rm -f chart.tmp" # nothing creates nope/: still a fact + self.assertIn("nope is not a folder", PF.shell_facts(PF.plan_line(line, self.box))[0].get(1, "")) + + +class TestHook(unittest.TestCase): + def setUp(self): + self.box = box(tempfile.mkdtemp()) + self.env = mock.patch.dict(os.environ, {"EKBASIS_PREFLIGHT": "1", "EKBASIS_GIT_GUARD": "0", + "TMPDIR": tempfile.mkdtemp()}) + self.env.start() + for k in ("EKBASIS_GUARD_MODE", "EKBASIS_SHELL_GUARD", "EKBASIS_PREFLIGHT_FAIL_CLOSED", "EKBASIS_FAIL_OPEN"): + os.environ.pop(k, None) + tempfile.tempdir = None + + def tearDown(self): + self.env.stop() + tempfile.tempdir = None + + def hook(self, line, env=None, session="s1"): + data = {"tool_name": "Bash", "tool_input": {"command": line}, "cwd": self.box, "session_id": session} + out = io.StringIO() + with mock.patch.dict(os.environ, env or {}), mock.patch.object(H, "Ekbasis", return_value=Fake(p_fail=0.01)), \ + mock.patch.object(sys, "stdin", io.StringIO(json.dumps(data))), redirect_stdout(out): + self.assertEqual(H.main(), 0) + return json.loads(out.getvalue())["hookSpecificOutput"] if out.getvalue().strip() else None + + def test_silent_by_default_and_warns_once_when_fail_closed(self): + line = INLINE[1] + self.assertIsNone(self.hook(line)) + on = {"EKBASIS_PREFLIGHT_FAIL_CLOSED": "1"} + a = self.hook(line, on, session="s2") + self.assertEqual(a["permissionDecision"], "ask") + self.assertIn("could not check this multi-step change", a["permissionDecisionReason"]) + self.assertIn("code given inline", a["permissionDecisionReason"]) + self.assertIsNone(self.hook(line, on, session="s2")) # once + + +if __name__ == "__main__": + unittest.main() From 72917bafa63dd046d10f4a68296ba5c0b9623d37 Mon Sep 17 00:00:00 2001 From: Caio Vicentino Date: Fri, 16 Oct 2026 16:46:00 -0300 Subject: [PATCH 2/4] 0.1.42 security: credentials typed on a command line no longer leave the machine or land in the logs The command line went verbatim into the state the client sends (hook, CLI, MCP tools, Python API) and into the hook's log: a key typed on it (printf 'vck_...' > f, export TOKEN=..., curl -H "Authorization: Bearer ...", https://user:token@..., --password=...) reached the server and the disk. ekbasis.redact.redact_line now runs on every state Ekbasis.ask sends, on feedback notes, on the hook's log record, reasons and messages, and on the monitor's log. It replaces credential-looking values with placeholders made of [A-Za-z0-9_] (REDACTED_1, vck_REDACTED_2; the same value gets the same placeholder): known prefixes (sk-ant-, sk-, sk_live_, vck_, hf_, ghp_/gho_/ghs_, github_pat_, xox?-, AKIA/ASIA, glpat-, npm_, pypi-AgE, AIza, JWTs), PEM private key bodies, Authorization/X-Api-Key/X-Auth-Token and Cookie header values, URL passwords, --password/--token/--api-key/--secret values, values of assignments and JSON/YAML keys named like token/secret/passw/api_key/auth(not author)/credential/private_key (8+ characters, not a number, boolean or path), and long random-looking words as a last resort. Variables and substitutions ($TOKEN, $(cat key)), paths, git refs, commit ids, sha256 sums and UUIDs stay. Quotes, spaces, redirections and paths stay where they are, so the parsed line has the same shape (tested). The hook's log records "redacted": n. EKBASIS_REDACT=0 turns it off. Measured: on the 687 logged lines it changes none (no credential left in that log; no path, ref or hash touched). On 14 of them with made-up credentials put on them, run through the hook with and without redaction on the hosted API (one line at a time, at most 2 requests in flight): the same decision and the same kinds of warning for all 14, probabilities within 17 points; the redacted logs never held the credential. README and docs/SECURITY.md say what leaves the machine and the limits of redaction. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 ++ README.md | 9 + docs/SECURITY.md | 30 ++++ ekbasis/claude_code_hook.py | 10 +- ekbasis/client.py | 5 +- ekbasis/monitor_hook.py | 3 +- ekbasis/redact.py | 122 ++++++++++++++ .../ekbasis/lib/ekbasis/claude_code_hook.py | 10 +- plugins/ekbasis/lib/ekbasis/client.py | 5 +- plugins/ekbasis/lib/ekbasis/monitor_hook.py | 3 +- plugins/ekbasis/lib/ekbasis/redact.py | 122 ++++++++++++++ tests/test_redact_0142.py | 159 ++++++++++++++++++ 12 files changed, 477 insertions(+), 12 deletions(-) create mode 100644 ekbasis/redact.py create mode 100644 plugins/ekbasis/lib/ekbasis/redact.py create mode 100644 tests/test_redact_0142.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e693a8..3ad8b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,17 @@ ## [0.1.42] — 2026-10-16 +### Security +- **Credentials typed on a command line no longer leave the machine or land in the logs.** The state the client sends + (`Ekbasis.ask`: the hook, the CLI, the MCP tools and the Python API) held the command line as typed, and so did the + hook's log in log mode: `printf '%s' 'vck_…' > f`, `export TOKEN=…`, `curl -H "Authorization: Bearer …"`, + `https://user:token@…` or `--password=…` reached the server and the disk. Every state sent, the hook's log and + messages, and the monitor's log now go through `ekbasis.redact.redact_line`, which replaces credential-looking values + with placeholders made of letters, digits and underscores (`REDACTED_1`, `vck_REDACTED_2`) and keeps quotes, spaces, + redirections and paths, so the guards read the line the same way. The hook's log records `"redacted": n`. + `EKBASIS_REDACT=0` turns it off. What it recognizes, and what it does not, is in + [docs/SECURITY.md](docs/SECURITY.md#what-leaves-the-machine-and-credentials-on-the-command-line-0142). + ### Changed - **The guards read a variable set earlier on the same line.** The git guard and the shell guard read `$NAME` or `${NAME}` with its value when the line itself sets NAME before, to a known value (`G=/repo; git -C "$G" status`, diff --git a/README.md b/README.md index 24412e0..88b2bd3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ The client needs a server: the hosted API (`EKBASIS_URL=https://openinterp.org/a key at [openinterp.org/console](https://openinterp.org/console)) or your own, with the open weights ([Quick start](#quick-start)). +**What leaves your machine** (to the server you choose): the state of each check, as text. For the guards that is the +command line, with credentials typed on it replaced (since 0.1.42: known key prefixes, Authorization and Cookie +headers, passwords in URLs, `--password`/`--token` values, values of `*TOKEN*`/`*SECRET*`/`*PASSWORD*`-like names, +private keys and long random-looking strings; `EKBASIS_REDACT=0` turns it off), and a listing of what the line could +touch: names, sizes, ages and short fingerprints keyed anew for each check, never file contents; for git, the +repository's state (branches, the names of changed files, commit subjects). Redaction is a safety net with limits +([docs/SECURITY.md](docs/SECURITY.md)): a short or unusual secret without a known prefix or name can pass. The hook's +log (log mode) stays on your machine and holds the redacted line. + - **Command line and Python** (standard library only, Python ≥ 3.9): `ekbasis`, `ekbasis-claude-hook`, and `ekbasis-mcp` with the `mcp` extra. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 607eff3..315e773 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -105,6 +105,36 @@ many lines a pattern written in the command matches in the files it names (the c files, folders and archive members, which can themselves be sensitive: run the server where you would run the agent. Measured on 292 fresh scenarios: no line of any file's content reached a prompt. +## What leaves the machine, and credentials on the command line (0.1.42) + +What the client sends to the server you set (the hosted API or your own) is the state of each check, as text: for the +guards, the command line itself, a listing of the paths it could touch (names, types, sizes, ages, links, short keyed +fingerprints; never contents) and, for git, the repository's state (branches, changed file names, commit subjects, +stash messages). The hook's log (EKBASIS_GUARD_MODE=log) and the monitor's log stay on your machine. + +Until 0.1.41 the command line went verbatim: a secret typed on it (`printf '%s' 'vck_…' > f`, `export TOKEN=…`, +`curl -H "Authorization: Bearer …"`, `git remote set-url origin https://user:token@…`, `--password=…`) reached the +server and the local logs. Since 0.1.42 every state the client sends (`Ekbasis.ask`, so the hook, the CLI, the MCP +tools and the Python API alike) and both logs go through `ekbasis.redact.redact_line`, which replaces credential-looking +values with a placeholder made of letters, digits and underscores (`REDACTED_1`, `vck_REDACTED_2`): the same value gets +the same placeholder, and quotes, spaces, redirections and paths stay where they are, so the guards read the line the +same way. It replaces: tokens with a known prefix (sk-ant-, sk-, sk_live_, vck_, hf_, ghp_/gho_/ghs_, github_pat_, +xox?-, AKIA/ASIA, glpat-, npm_, pypi-AgE, AIza, JWTs), private key blocks, Authorization/X-Api-Key/X-Auth-Token/Cookie +header values, passwords in URLs, the values of --password/--token/--api-key/--secret and similar options, the values of +assignments and JSON/YAML keys whose name contains token, secret, passw, api_key, auth (not author), credential or +private_key (8 characters or more, not a number, a boolean or a path), and, as a last resort, words of 32 or more +characters that look random (mixed case, digits, high entropy). A variable or a substitution (`$TOKEN`, +`$(cat key)`) is not a secret on the line and stays. Paths, file names, git refs, commit ids, sha256 sums and UUIDs are +never taken for keys. On the 687 commands of the session log behind 0.1.42 it changed no line (none still holds a +credential). On 14 of those lines with made-up credentials put on them (a key in a file, a Bearer token, an exported +token), each run through the hook with and without redaction on the hosted API, every decision and every kind of +warning was the same; the probabilities moved by up to 17 points, as much as repeated runs of an unchanged line move. + +Its limits: a secret without a known prefix that is short, all one case or hexadecimal, and is not the value of one of +those names, headers or options, passes; so does one split across words or encoded. It is a safety net, not a reason to +type secrets on a command line: read them from a file or the environment instead (`$(cat ~/.config/x/key)`), which the +line then shows only as a reference. `EKBASIS_REDACT=0` turns redaction off (for example with your own server). + ## Known gaps - **Command parsing**: the hook reads `&&`, `||`, `;`, `|`, quotes, here-documents, `cd` (anywhere since 0.1.3), diff --git a/ekbasis/claude_code_hook.py b/ekbasis/claude_code_hook.py index fdb318e..bec5754 100644 --- a/ekbasis/claude_code_hook.py +++ b/ekbasis/claude_code_hook.py @@ -83,6 +83,7 @@ from . import prompts as P from . import recover as R from . import safer as SF +from .redact import redact from . import shell as S from .client import CannotJudge, Ekbasis @@ -376,7 +377,8 @@ def _log_path() -> str: def _shadow(decision: str, reason: str = "") -> None: """EKBASIS_GUARD_MODE=log: never block; append what the hook would have done to a local JSONL file (this machine only; nothing is sent anywhere) so a trial period shows what it would have stopped before it may stop anything.""" - rec = {"t": round(time.time(), 3), "decision": decision, "reason": reason, **_LOG_CTX} + reason = redact(reason)[0] # 0.1.42: no credential typed on the line in the log (main redacts the command; "redacted": + rec = {"t": round(time.time(), 3), "decision": decision, "reason": reason, **_LOG_CTX} # how many values it replaced) try: path = _log_path() os.makedirs(os.path.dirname(path) or ".", exist_ok=True) @@ -403,6 +405,7 @@ def _repeat_on() -> bool: def _decide(reason: str) -> None: if _log_mode(): return _shadow("ask", reason) + reason = redact(reason)[0] mode = "deny" if os.environ.get("EKBASIS_GUARD_MODE") == "deny" else "ask" print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": mode, "permissionDecisionReason": reason}})) @@ -413,7 +416,7 @@ def _cannot_judge(why: str, session: str | None = None, rkey: str | None = None) _shadow("ask_cannot_foresee", why) return 0 if os.environ.get("EKBASIS_FAIL_OPEN") == "1": - print(f"ekbasis hook: cannot foresee ({why}); EKBASIS_FAIL_OPEN=1: letting it through", file=sys.stderr) + print(f"ekbasis hook: cannot foresee ({redact(why)[0]}); EKBASIS_FAIL_OPEN=1: letting it through", file=sys.stderr) return 0 again = "" if _repeat_on() and session and rkey: @@ -610,7 +613,8 @@ def main() -> int: return 0 line = (data.get("tool_input") or {}).get("command", "") cwd = data.get("cwd") or os.getcwd() - _LOG_CTX.update(command=line, cwd=cwd, session=data.get("session_id")) + logged, n = redact(line) + _LOG_CTX.update(command=logged, cwd=cwd, session=data.get("session_id"), redacted=n) session = data.get("session_id") rkey = "guard:" + hashlib.sha256(f"{os.path.realpath(cwd)}\0{line}".encode()).hexdigest()[:32] if _repeat_on() and session and _seen_any(session, rkey): diff --git a/ekbasis/client.py b/ekbasis/client.py index 9ae20fd..fce5734 100644 --- a/ekbasis/client.py +++ b/ekbasis/client.py @@ -17,6 +17,7 @@ from ._version import __version__ +from .redact import redact_line class EkbasisError(RuntimeError): @@ -239,7 +240,7 @@ def ask(self, state: str, questions: dict, read_once: bool = False, images=None) """Every question about the same state, answered in one request (the server batches them; read_once=True reads the state once for all of them, on models trained for it). images: data URIs or base64 strings (vLLM backend).""" _check_questions(questions) - body = {"state": state, "questions": questions} + body = {"state": redact_line(state), "questions": questions} # 0.1.42: credentials on a command line stay here if read_once: body["read_once"] = True if images: @@ -258,7 +259,7 @@ def feedback(self, verdict: str, request_id: str | None = None, note: str | None raise ValueError("no request id: pass one, or make a call to the hosted API first") body = {"request_id": rid, "verdict": verdict} if note: - body["note"] = str(note)[:500] + body["note"] = redact_line(str(note))[:500] headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" diff --git a/ekbasis/monitor_hook.py b/ekbasis/monitor_hook.py index 27708fb..cedc9fd 100644 --- a/ekbasis/monitor_hook.py +++ b/ekbasis/monitor_hook.py @@ -22,6 +22,7 @@ from . import prompts as P from .claude_code_hook import user_request from .client import CannotJudge, Ekbasis +from .redact import redact_line READ_ONLY = {"Read", "Glob", "Grep", "LS", "WebSearch", "TodoWrite", "TodoRead", "NotebookRead", "ExitPlanMode", "BashOutput", "ListMcpResourcesTool", "ReadMcpResourceTool"} @@ -75,7 +76,7 @@ def _log(entry: dict) -> None: try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "a", encoding="utf-8") as f: - f.write(json.dumps(entry) + "\n") + f.write(redact_line(json.dumps(entry)) + "\n") # 0.1.42: no credential typed in a call stays in the log except OSError: pass diff --git a/ekbasis/redact.py b/ekbasis/redact.py new file mode 100644 index 0000000..eb4e748 --- /dev/null +++ b/ekbasis/redact.py @@ -0,0 +1,122 @@ +"""Credentials out of what leaves this machine (0.1.42): every state the client sends (ask), the hook logs, the monitor +log and the hooks' messages go through redact_line(). + +The guards never send a file's content (the shell guard sends names, sizes, ages and short keyed fingerprints), but +the command line itself is part of the state, and a secret typed on it (`printf '%s' 'vck_...' > f`, `export +TOKEN=...`, `curl -H "Authorization: Bearer ..."`, `git remote set-url origin https://user:token@...`, `--password=...`) +would otherwise reach the server and the local logs as typed. redact_line() replaces such values with a placeholder +made only of letters, digits and underscores (`REDACTED_1`, `vck_REDACTED_2`): the same value gets the same +placeholder, and quotes, spaces, redirections and paths stay where they are, so the line reads the same to the guards. + +What it replaces, in this order: +- the body of a PEM private key (`-----BEGIN ... PRIVATE KEY-----` ... `-----END ...`); +- tokens with a known prefix: sk-ant-, sk-/sk-proj-, sk_live_/sk_test_/rk_live_, vck_, hf_, ghp_/gho_/ghs_/ghu_/ghr_, + github_pat_, xoxa-/xoxb-/xoxp-/xoxr-, AKIA/ASIA (AWS key ids), glpat-, npm_, pypi-AgE, AIza (Google), JWTs (eyJ... + with two dots); +- the value of an Authorization, Proxy-Authorization, X-Api-Key, Api-Key or X-Auth-Token header (the scheme, Bearer + or Basic, stays), and of a Cookie header; +- the password in a URL (`scheme://user:PASSWORD@host`); +- the value of --password, --passwd, --token, --api-key, --apikey, --secret, --client-secret, --access-token, + --auth-token (`--token=X` or `--token X`); +- the value of an assignment or a JSON/YAML key whose name contains token (not tokens or tokenizer), secret, passw, + api_key/apikey, auth (not author), credential or private_key (`GITHUB_TOKEN=...`, `"api_key": "..."`), when the value + has 8 or more characters and is not a number, true/false/none/null or a path; +- as a last resort, a word of 32 or more letters, digits, `_`, `-`, `+`, `=` with upper and lower case letters, at least + two digits and high entropy (base64-like keys without a known prefix). +A value that is a variable or a substitution (`$TOKEN`, `$(cat key)`) is not a secret on the line and stays. Paths, +file names, git refs and hashes stay: hexadecimal strings (commit ids, sha256 sums), words with `/` or `.`, and UUIDs +are never taken for keys. + +Limits: a secret without a known prefix that is short, all one case, or hexadecimal, and is not the value of one of +the names, headers or options above, is not recognized. Redaction is a safety net, not a reason to type secrets on a +command line. EKBASIS_REDACT=0 turns it off (for example with your own server).""" +from __future__ import annotations + +import math +import os +import re + +_WORD = r"[A-Za-z0-9_\-]" +PREFIXED = [ + ("sk-ant", re.compile(r"(?()][^\s'\";&|<>()]*)") +JSON_KEY = re.compile(r"(?i)([\"']" + NAMED + r"[\"']\s*:\s*)([\"'])([^\"'$`\s]+)([\"'])") +YAML_KEY = re.compile(r"(?im)^(\s*" + NAMED + r"\s*:\s+)([^\s\"'$`#][^\s#]*)$") +LONG = re.compile(r"(? float: + n = len(s) + return -sum(c / n * math.log2(c / n) for c in (s.count(x) for x in set(s))) + + +def _looks_random(w: str) -> bool: + return (not HEX.fullmatch(w) and any(c.isupper() for c in w) and any(c.islower() for c in w) + and sum(c.isdigit() for c in w) >= 2 and _entropy(w) >= 4.0) + + +def redact(text: str) -> tuple[str, int]: + """(text with credential-looking values replaced, how many distinct values were replaced). Placeholders keep a + known prefix (vck_REDACTED_1) and are made of [A-Za-z0-9_] only.""" + if not text or os.environ.get("EKBASIS_REDACT") == "0": + return text, 0 + seen: dict = {} + + def ph(value: str, prefix: str = "") -> str: + if value not in seen: + seen[value] = len(seen) + 1 + return f"{prefix}REDACTED_{seen[value]}" + + def skip(v: str) -> bool: + return not v or v.startswith(("$", "`")) or "REDACTED_" in v + + def skip_named(v: str) -> bool: + """A value by name only: not a flag, a number, a word like true, or a path (TOKEN_FILE=/run/secrets/t).""" + return (skip(v) or len(v) < 8 or v.lower() in ("true", "false", "none", "null") or v.replace(".", "").isdigit() + or v.startswith(("/", "./", "../", "~"))) + + text = PEM.sub(lambda m: m.group(1) + ("\n" if m.group(2).startswith("\n") else "") + ph(m.group(2).strip()) + + ("\n" if m.group(2).endswith("\n") else "") + m.group(3), text) + for _, rx in PREFIXED: + text = rx.sub(lambda m: m.group(1) + ph(m.group(0)[len(m.group(1)):] if m.group(1) else m.group(0)), text) + text = HEADER.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + + (m.group(4) if skip(m.group(4)) else ph(m.group(4))), text) + text = COOKIE.sub(lambda m: m.group(1) + (m.group(2) if skip(m.group(2).strip()) else ph(m.group(2).strip()) + + m.group(2)[len(m.group(2).rstrip()):]), text) + text = URL_PASS.sub(lambda m: m.group(1) + (m.group(2) if skip(m.group(2)) else ph(m.group(2))) + m.group(3), text) + text = OPTION.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) if skip(m.group(3)) else ph(m.group(3))), text) + text = ASSIGN.sub(lambda m: m.group(1) + m.group(2) + m.group(3) + + (m.group(4) if skip_named(m.group(4)) else ph(m.group(4))), text) + text = JSON_KEY.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) if skip_named(m.group(3)) else ph(m.group(3))) + + m.group(4), text) + text = YAML_KEY.sub(lambda m: m.group(1) + (m.group(2) if skip_named(m.group(2)) else ph(m.group(2))), text) + text = LONG.sub(lambda m: ph(m.group(1)) if _looks_random(m.group(1)) and "REDACTED_" not in m.group(1) + else m.group(1), text) + return text, len(seen) + + +def redact_line(text: str) -> str: + """The text with credential-looking values replaced (see redact()).""" + return redact(text)[0] diff --git a/plugins/ekbasis/lib/ekbasis/claude_code_hook.py b/plugins/ekbasis/lib/ekbasis/claude_code_hook.py index fdb318e..bec5754 100644 --- a/plugins/ekbasis/lib/ekbasis/claude_code_hook.py +++ b/plugins/ekbasis/lib/ekbasis/claude_code_hook.py @@ -83,6 +83,7 @@ from . import prompts as P from . import recover as R from . import safer as SF +from .redact import redact from . import shell as S from .client import CannotJudge, Ekbasis @@ -376,7 +377,8 @@ def _log_path() -> str: def _shadow(decision: str, reason: str = "") -> None: """EKBASIS_GUARD_MODE=log: never block; append what the hook would have done to a local JSONL file (this machine only; nothing is sent anywhere) so a trial period shows what it would have stopped before it may stop anything.""" - rec = {"t": round(time.time(), 3), "decision": decision, "reason": reason, **_LOG_CTX} + reason = redact(reason)[0] # 0.1.42: no credential typed on the line in the log (main redacts the command; "redacted": + rec = {"t": round(time.time(), 3), "decision": decision, "reason": reason, **_LOG_CTX} # how many values it replaced) try: path = _log_path() os.makedirs(os.path.dirname(path) or ".", exist_ok=True) @@ -403,6 +405,7 @@ def _repeat_on() -> bool: def _decide(reason: str) -> None: if _log_mode(): return _shadow("ask", reason) + reason = redact(reason)[0] mode = "deny" if os.environ.get("EKBASIS_GUARD_MODE") == "deny" else "ask" print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": mode, "permissionDecisionReason": reason}})) @@ -413,7 +416,7 @@ def _cannot_judge(why: str, session: str | None = None, rkey: str | None = None) _shadow("ask_cannot_foresee", why) return 0 if os.environ.get("EKBASIS_FAIL_OPEN") == "1": - print(f"ekbasis hook: cannot foresee ({why}); EKBASIS_FAIL_OPEN=1: letting it through", file=sys.stderr) + print(f"ekbasis hook: cannot foresee ({redact(why)[0]}); EKBASIS_FAIL_OPEN=1: letting it through", file=sys.stderr) return 0 again = "" if _repeat_on() and session and rkey: @@ -610,7 +613,8 @@ def main() -> int: return 0 line = (data.get("tool_input") or {}).get("command", "") cwd = data.get("cwd") or os.getcwd() - _LOG_CTX.update(command=line, cwd=cwd, session=data.get("session_id")) + logged, n = redact(line) + _LOG_CTX.update(command=logged, cwd=cwd, session=data.get("session_id"), redacted=n) session = data.get("session_id") rkey = "guard:" + hashlib.sha256(f"{os.path.realpath(cwd)}\0{line}".encode()).hexdigest()[:32] if _repeat_on() and session and _seen_any(session, rkey): diff --git a/plugins/ekbasis/lib/ekbasis/client.py b/plugins/ekbasis/lib/ekbasis/client.py index 9ae20fd..fce5734 100644 --- a/plugins/ekbasis/lib/ekbasis/client.py +++ b/plugins/ekbasis/lib/ekbasis/client.py @@ -17,6 +17,7 @@ from ._version import __version__ +from .redact import redact_line class EkbasisError(RuntimeError): @@ -239,7 +240,7 @@ def ask(self, state: str, questions: dict, read_once: bool = False, images=None) """Every question about the same state, answered in one request (the server batches them; read_once=True reads the state once for all of them, on models trained for it). images: data URIs or base64 strings (vLLM backend).""" _check_questions(questions) - body = {"state": state, "questions": questions} + body = {"state": redact_line(state), "questions": questions} # 0.1.42: credentials on a command line stay here if read_once: body["read_once"] = True if images: @@ -258,7 +259,7 @@ def feedback(self, verdict: str, request_id: str | None = None, note: str | None raise ValueError("no request id: pass one, or make a call to the hosted API first") body = {"request_id": rid, "verdict": verdict} if note: - body["note"] = str(note)[:500] + body["note"] = redact_line(str(note))[:500] headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" diff --git a/plugins/ekbasis/lib/ekbasis/monitor_hook.py b/plugins/ekbasis/lib/ekbasis/monitor_hook.py index 27708fb..cedc9fd 100644 --- a/plugins/ekbasis/lib/ekbasis/monitor_hook.py +++ b/plugins/ekbasis/lib/ekbasis/monitor_hook.py @@ -22,6 +22,7 @@ from . import prompts as P from .claude_code_hook import user_request from .client import CannotJudge, Ekbasis +from .redact import redact_line READ_ONLY = {"Read", "Glob", "Grep", "LS", "WebSearch", "TodoWrite", "TodoRead", "NotebookRead", "ExitPlanMode", "BashOutput", "ListMcpResourcesTool", "ReadMcpResourceTool"} @@ -75,7 +76,7 @@ def _log(entry: dict) -> None: try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "a", encoding="utf-8") as f: - f.write(json.dumps(entry) + "\n") + f.write(redact_line(json.dumps(entry)) + "\n") # 0.1.42: no credential typed in a call stays in the log except OSError: pass diff --git a/plugins/ekbasis/lib/ekbasis/redact.py b/plugins/ekbasis/lib/ekbasis/redact.py new file mode 100644 index 0000000..eb4e748 --- /dev/null +++ b/plugins/ekbasis/lib/ekbasis/redact.py @@ -0,0 +1,122 @@ +"""Credentials out of what leaves this machine (0.1.42): every state the client sends (ask), the hook logs, the monitor +log and the hooks' messages go through redact_line(). + +The guards never send a file's content (the shell guard sends names, sizes, ages and short keyed fingerprints), but +the command line itself is part of the state, and a secret typed on it (`printf '%s' 'vck_...' > f`, `export +TOKEN=...`, `curl -H "Authorization: Bearer ..."`, `git remote set-url origin https://user:token@...`, `--password=...`) +would otherwise reach the server and the local logs as typed. redact_line() replaces such values with a placeholder +made only of letters, digits and underscores (`REDACTED_1`, `vck_REDACTED_2`): the same value gets the same +placeholder, and quotes, spaces, redirections and paths stay where they are, so the line reads the same to the guards. + +What it replaces, in this order: +- the body of a PEM private key (`-----BEGIN ... PRIVATE KEY-----` ... `-----END ...`); +- tokens with a known prefix: sk-ant-, sk-/sk-proj-, sk_live_/sk_test_/rk_live_, vck_, hf_, ghp_/gho_/ghs_/ghu_/ghr_, + github_pat_, xoxa-/xoxb-/xoxp-/xoxr-, AKIA/ASIA (AWS key ids), glpat-, npm_, pypi-AgE, AIza (Google), JWTs (eyJ... + with two dots); +- the value of an Authorization, Proxy-Authorization, X-Api-Key, Api-Key or X-Auth-Token header (the scheme, Bearer + or Basic, stays), and of a Cookie header; +- the password in a URL (`scheme://user:PASSWORD@host`); +- the value of --password, --passwd, --token, --api-key, --apikey, --secret, --client-secret, --access-token, + --auth-token (`--token=X` or `--token X`); +- the value of an assignment or a JSON/YAML key whose name contains token (not tokens or tokenizer), secret, passw, + api_key/apikey, auth (not author), credential or private_key (`GITHUB_TOKEN=...`, `"api_key": "..."`), when the value + has 8 or more characters and is not a number, true/false/none/null or a path; +- as a last resort, a word of 32 or more letters, digits, `_`, `-`, `+`, `=` with upper and lower case letters, at least + two digits and high entropy (base64-like keys without a known prefix). +A value that is a variable or a substitution (`$TOKEN`, `$(cat key)`) is not a secret on the line and stays. Paths, +file names, git refs and hashes stay: hexadecimal strings (commit ids, sha256 sums), words with `/` or `.`, and UUIDs +are never taken for keys. + +Limits: a secret without a known prefix that is short, all one case, or hexadecimal, and is not the value of one of +the names, headers or options above, is not recognized. Redaction is a safety net, not a reason to type secrets on a +command line. EKBASIS_REDACT=0 turns it off (for example with your own server).""" +from __future__ import annotations + +import math +import os +import re + +_WORD = r"[A-Za-z0-9_\-]" +PREFIXED = [ + ("sk-ant", re.compile(r"(?()][^\s'\";&|<>()]*)") +JSON_KEY = re.compile(r"(?i)([\"']" + NAMED + r"[\"']\s*:\s*)([\"'])([^\"'$`\s]+)([\"'])") +YAML_KEY = re.compile(r"(?im)^(\s*" + NAMED + r"\s*:\s+)([^\s\"'$`#][^\s#]*)$") +LONG = re.compile(r"(? float: + n = len(s) + return -sum(c / n * math.log2(c / n) for c in (s.count(x) for x in set(s))) + + +def _looks_random(w: str) -> bool: + return (not HEX.fullmatch(w) and any(c.isupper() for c in w) and any(c.islower() for c in w) + and sum(c.isdigit() for c in w) >= 2 and _entropy(w) >= 4.0) + + +def redact(text: str) -> tuple[str, int]: + """(text with credential-looking values replaced, how many distinct values were replaced). Placeholders keep a + known prefix (vck_REDACTED_1) and are made of [A-Za-z0-9_] only.""" + if not text or os.environ.get("EKBASIS_REDACT") == "0": + return text, 0 + seen: dict = {} + + def ph(value: str, prefix: str = "") -> str: + if value not in seen: + seen[value] = len(seen) + 1 + return f"{prefix}REDACTED_{seen[value]}" + + def skip(v: str) -> bool: + return not v or v.startswith(("$", "`")) or "REDACTED_" in v + + def skip_named(v: str) -> bool: + """A value by name only: not a flag, a number, a word like true, or a path (TOKEN_FILE=/run/secrets/t).""" + return (skip(v) or len(v) < 8 or v.lower() in ("true", "false", "none", "null") or v.replace(".", "").isdigit() + or v.startswith(("/", "./", "../", "~"))) + + text = PEM.sub(lambda m: m.group(1) + ("\n" if m.group(2).startswith("\n") else "") + ph(m.group(2).strip()) + + ("\n" if m.group(2).endswith("\n") else "") + m.group(3), text) + for _, rx in PREFIXED: + text = rx.sub(lambda m: m.group(1) + ph(m.group(0)[len(m.group(1)):] if m.group(1) else m.group(0)), text) + text = HEADER.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + + (m.group(4) if skip(m.group(4)) else ph(m.group(4))), text) + text = COOKIE.sub(lambda m: m.group(1) + (m.group(2) if skip(m.group(2).strip()) else ph(m.group(2).strip()) + + m.group(2)[len(m.group(2).rstrip()):]), text) + text = URL_PASS.sub(lambda m: m.group(1) + (m.group(2) if skip(m.group(2)) else ph(m.group(2))) + m.group(3), text) + text = OPTION.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) if skip(m.group(3)) else ph(m.group(3))), text) + text = ASSIGN.sub(lambda m: m.group(1) + m.group(2) + m.group(3) + + (m.group(4) if skip_named(m.group(4)) else ph(m.group(4))), text) + text = JSON_KEY.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) if skip_named(m.group(3)) else ph(m.group(3))) + + m.group(4), text) + text = YAML_KEY.sub(lambda m: m.group(1) + (m.group(2) if skip_named(m.group(2)) else ph(m.group(2))), text) + text = LONG.sub(lambda m: ph(m.group(1)) if _looks_random(m.group(1)) and "REDACTED_" not in m.group(1) + else m.group(1), text) + return text, len(seen) + + +def redact_line(text: str) -> str: + """The text with credential-looking values replaced (see redact()).""" + return redact(text)[0] diff --git a/tests/test_redact_0142.py b/tests/test_redact_0142.py new file mode 100644 index 0000000..8ac62be --- /dev/null +++ b/tests/test_redact_0142.py @@ -0,0 +1,159 @@ +"""0.1.42: credentials typed on a command line do not leave the machine (the state the client sends) nor land in the +hook and monitor logs (ekbasis.redact). Every pattern; no false redaction of paths, hashes, branch names and sums in +common commands; the line keeps its shell structure, so the guards read it the same way. Offline; the keys below are +made up.""" +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from ekbasis import claude_code_hook as H +from ekbasis import monitor_hook as MH +from ekbasis import shell as S +from ekbasis.client import Ekbasis +from ekbasis.redact import redact, redact_line + +FAKE = "Zq7RkT2mW9xLp4Vb8NcY3HsJd6Fg5Ae1" # 32 characters, made up +AWS = "AKIA" + "ZQ7RKT2MW9XLP4VB" # (built in pieces: no credential-shaped literal in the source) +JWT = "eyJ" + "hbGciOiJIUzI1NiJ9." + "eyJ" + "zdWIiOiJ0ZXN0In0." + FAKE + +# (line, the secret that must be gone, what must be there instead) +CASES = [ + (f"printf '%s' 'vck_{FAKE}' > ~/.config/vercel/key", f"vck_{FAKE}", "'vck_REDACTED_1' > ~/.config/vercel/key"), + (f"export ANTHROPIC_API_KEY=sk-ant-api03-{FAKE}", FAKE, "sk-ant-REDACTED_1"), + (f"OPENAI_API_KEY=sk-proj-{FAKE} python3 run.py", FAKE, "sk-proj-REDACTED_1"), + (f"curl -u sk_live_{FAKE}: https://api.example.invalid", FAKE, "sk_live_REDACTED_1"), + (f"huggingface-cli login --token hf_{FAKE}", FAKE, "hf_REDACTED_1"), + (f"git clone https://ghp_{FAKE}@github.com/org/repo.git", FAKE, "ghp_REDACTED_1@github.com"), + (f"gh auth login --with-token <<< github_pat_11ABCDE_{FAKE}", FAKE, "github_pat_REDACTED_1"), + (f"curl -d token=xoxb-1234-{FAKE} https://slack.example.invalid", FAKE, "xoxb-REDACTED_1"), + ("aws configure set aws_access_key_id " + AWS, AWS, "AKIAREDACTED_1"), + (f"git push https://oauth2:glpat-{FAKE}@gitlab.example.invalid/x.git", FAKE, "glpat-REDACTED_1"), + (f"npm config set //registry.npmjs.org/:_authToken npm_{FAKE}abcd", FAKE, "npm_REDACTED_1"), + (f"twine upload -p pypi-AgEIcHlwaS5vcmc{FAKE} dist/*", FAKE, "pypi-AgEREDACTED_1"), + (f"curl 'https://maps.example.invalid?key=AIzaSy{FAKE}abc'", FAKE, "AIzaREDACTED_1"), + (f"curl -H 'Authorization: Bearer {JWT}' x", JWT, "Bearer REDACTED_1"), + (f'curl -H "Authorization: Bearer {FAKE}" https://x.example.invalid', FAKE, '"Authorization: Bearer REDACTED_1"'), + ('curl -H "Authorization: Basic dXNlcjpwYXNzd29yZA==" https://x', "dXNlcjpwYXNzd29yZA==", "Basic REDACTED_1"), + (f"curl -H 'X-Api-Key: {FAKE}' https://x", FAKE, "'X-Api-Key: REDACTED_1'"), + ("curl -H 'Cookie: session=abc123def; csrftoken=x9y8z7' https://x", "abc123def", "'Cookie: REDACTED_1'"), + ("git remote set-url origin https://caio:hunter2pass@github.com/org/repo.git", "hunter2pass", + "https://caio:REDACTED_1@github.com/org/repo.git"), + ("psql postgres://app:S3cr3tPw@localhost:5432/db -c 'select 1'", "S3cr3tPw", "postgres://app:REDACTED_1@localhost"), + ("mysql -u root --password=Pa55word db < dump.sql", "Pa55word", "--password=REDACTED_1 db < dump.sql"), + (f"tool --api-key {FAKE} --out result.json", FAKE, "--api-key REDACTED_1 --out result.json"), + (f'export GITHUB_TOKEN="{FAKE}"; gh pr list', FAKE, 'GITHUB_TOKEN="REDACTED_1"; gh pr list'), + ("DB_PASSWORD=correct-horse-battery npm run migrate", "correct-horse-battery", "DB_PASSWORD=REDACTED_1 npm run"), + (f'curl -d \'{{"api_key": "{FAKE}", "name": "x"}}\' https://x', FAKE, '"api_key": "REDACTED_1"'), + (f"cat > key.pem <<'EOF'\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmU\n{FAKE}\n" + "-----END OPENSSH PRIVATE KEY-----\nEOF\nchmod 600 key.pem", FAKE, + "-----BEGIN OPENSSH PRIVATE KEY-----\nREDACTED_1\n-----END OPENSSH PRIVATE KEY-----"), + (f"echo 'QmFzZTY0{FAKE}' | base64 -d > secret.bin", FAKE, "echo 'REDACTED_1' | base64 -d > secret.bin"), +] + +# common commands that must not change: paths, file names, git refs, hashes, sums, UUIDs, variables, short values +UNCHANGED = [ + "git log --oneline -3 3f786850e387550fdab836ed7e6dc881de23001b", + "git checkout -b feature/auth-token-refresh && git log --author=caio --oneline", + "echo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 results.json | shasum -a 256 -c", + "cd /private/tmp/claude-501/-Volumes-SSD-Major-cbmachine/e5f74f7b-1878-42c4-b742-a5a481ca6f4e/scratchpad && ls", + "docker pull python@sha256:3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed", + "GIT_AUTHOR_NAME=Dev GIT_AUTHOR_EMAIL=dev@example.invalid git commit -m 'auth: refresh tokens'", + 'curl -H "Authorization: Bearer $(cat ~/.config/ekbasis/ops_read_secret)" https://openinterp.org/api/ops', + 'export HF_TOKEN=$(cat ~/.cache/huggingface/token); curl -H "Authorization: Bearer $HF_TOKEN" https://x', + "HF_HUB_DISABLE_IMPLICIT_TOKEN=1 PREVIEW_SWITCH_TOKEN=0 python3 -c 'print(1)'", + "python3 run.py --max_tokens 4096 --tokenizer Qwen/Qwen3-8B --input_tokens_per_check 484", + "TOKEN_FILE=/run/secrets/api_token ./deploy.sh", + "git@github.com:OpenInterpretability/ekbasis.git", + "rsync -a results_skopos/ /Volumes/SSD\\ Major/backup/results_skopos/", + "ls ~/.local/share/ekbasis-hook/venv/lib/python3.9/site-packages/ekbasis", + "python3 -c \"import x; print(x.TestAVeryLongFunctionNameNumber12AndMore34)\"", + "git rev-parse HEAD:results_skopos/runs # a001e2f703a92c33f9b44b29e5c8706b966547c1", +] + + +def shape(line): + """What the guards read from a line: commands, operators, word counts, redirections, quoting.""" + return [(c.joined_by, len(c.words), [r.op for r in c.redirects], [w.quoted for w in c.words]) + for c in S.parse(line).commands] + + +class TestRedact(unittest.TestCase): + def test_every_pattern(self): + for line, secret, want in CASES: + out, n = redact(line) + self.assertNotIn(secret, out, line) + self.assertIn(want, out, line) + self.assertGreaterEqual(n, 1, line) + + def test_no_false_redaction(self): + for line in UNCHANGED: + self.assertEqual(redact(line), (line, 0), line) + + def test_structure_is_kept(self): + for line, _, _ in CASES: + self.assertEqual(shape(redact_line(line)), shape(line), line) + + def test_same_value_same_placeholder(self): + out, n = redact(f"export API_TOKEN={FAKE}; curl -H 'X-Api-Key: {FAKE}' -H 'X-Auth-Token: other-{FAKE}x' x") + self.assertEqual(n, 2) + self.assertEqual(out.count("REDACTED_1"), 2) + self.assertIn("REDACTED_2", out) + + def test_switch_off(self): + with mock.patch.dict(os.environ, {"EKBASIS_REDACT": "0"}): + self.assertEqual(redact(CASES[0][0]), (CASES[0][0], 0)) + + +class TestWhatLeaves(unittest.TestCase): + def test_the_client_sends_the_redacted_state(self): + sent = {} + + def fake_call(path, body=None): + sent.update(body) + return {"answers": {"q": {"value": "no", "confidence": 0.9, "probabilities": {"yes": 0.1, "no": 0.9}, + "p_yes": 0.1}}} + c = Ekbasis(url="http://127.0.0.1:9") + with mock.patch.object(c, "_call", fake_call): + c.ask(f"Commands:\n1. export GITHUB_TOKEN=ghp_{FAKE}\n2. rm -rf build", + {"q": {"type": "noul", "instructions": "Is anything lost?"}}) + self.assertNotIn(FAKE, json.dumps(sent)) + self.assertIn("ghp_REDACTED_1", sent["state"]) + self.assertIn("rm -rf build", sent["state"]) + + def test_hook_log_and_messages(self): + d = tempfile.mkdtemp() + log = os.path.join(d, "hook_log.jsonl") + line = f"export HF_TOKEN=hf_{FAKE}; git $SUB main" # cannot be judged: no model needed + data = json.dumps({"tool_name": "Bash", "tool_input": {"command": line}, "cwd": d, "session_id": "s"}) + env = {"EKBASIS_GUARD_MODE": "log", "EKBASIS_HOOK_LOG": log} + with mock.patch.dict(os.environ, env), mock.patch.object(sys, "stdin", io.StringIO(data)), \ + redirect_stdout(io.StringIO()), mock.patch.object(sys, "stderr", io.StringIO()) as err: + H.main() + text = open(log).read() + self.assertNotIn(FAKE, text + err.getvalue()) + rec = json.loads(text.splitlines()[-1]) + self.assertEqual((rec["command"], rec["redacted"]), ("export HF_TOKEN=hf_REDACTED_1; git $SUB main", 1)) + out = io.StringIO() + data = json.dumps({"tool_name": "Bash", "tool_input": {"command": line + " # "}, "cwd": d, "session_id": "s"}) + with mock.patch.object(sys, "stdin", io.StringIO(data)), redirect_stdout(out): + for k in ("EKBASIS_GUARD_MODE", "EKBASIS_FAIL_OPEN"): + os.environ.pop(k, None) + H.main() + self.assertNotIn(FAKE, out.getvalue()) + + def test_monitor_log(self): + d = tempfile.mkdtemp() + log = os.path.join(d, "monitor_log.jsonl") + with mock.patch.dict(os.environ, {"EKBASIS_MONITOR_LOG": log}): + MH._log({"t": 1.0, "tool": "Bash", "call": f"Bash: curl -H 'Authorization: Bearer {FAKE}' x"}) + self.assertNotIn(FAKE, open(log).read()) + json.loads(open(log).read()) # still JSON + + +if __name__ == "__main__": + unittest.main() From f3a4e38089c49187e66bed98f1e52a0a3a82b6f2 Mon Sep 17 00:00:00 2001 From: Caio Vicentino Date: Fri, 16 Oct 2026 16:53:45 -0300 Subject: [PATCH 3/4] Tests: independent of how the temporary folder is written (Linux /tmp) and of which tools are installed On Linux the shell guard lists a path under the folder as relative, on macOS (/var -> /private/var) as absolute: the test now accepts either. The one-line loop test checks that no reserved word is called "not installed", not that no command is (ps is missing from slim images). Full suite in python:3.12-slim: 389 passed. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_line_vars_0142.py | 2 +- tests/test_preflight_0142.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_line_vars_0142.py b/tests/test_line_vars_0142.py index 7615871..c8deda7 100644 --- a/tests/test_line_vars_0142.py +++ b/tests/test_line_vars_0142.py @@ -128,7 +128,7 @@ def test_shell_guard_reads_a_folder_set_on_the_line(self): line = f'RS="{self.repo}"; mkdir -p "$RS/prelaunch_out" && cp results.json "$RS/prelaunch_out/"' v = S.inspect([line], self.tmp) self.assertEqual(v.unread, []) - self.assertIn(os.path.join(self.repo, "prelaunch_out"), v.touched) + self.assertTrue(any(t.endswith(os.path.join("release skopos", "prelaunch_out")) for t in v.touched), v.touched) self.assertIn(f'$RS is "{self.repo}" (set earlier on the line)', v.state) def test_two_names_one_built_from_the_other(self): diff --git a/tests/test_preflight_0142.py b/tests/test_preflight_0142.py index ba8cc2b..a379149 100644 --- a/tests/test_preflight_0142.py +++ b/tests/test_preflight_0142.py @@ -116,7 +116,7 @@ def test_no_reserved_word_is_a_missing_command(self): for line in self.lines(LOOPS): plan = PF.plan_line(line, self.box) facts = PF.shell_facts(plan)[1] - self.assertFalse([x for x in facts if "is not installed" in x], line) + self.assertFalse([x for x in facts if any(f"`{w}` is not installed" in x for w in PF.CONTROL_WORDS)], line) def test_a_script_file_and_a_local_chain_are_still_judged(self): line = "cp a.json dry/a.json; python3 build.py; rm -f a.json" From e8e3756009a6343cf4484bbbffe1b9eaf3acf182 Mon Sep 17 00:00:00 2001 From: Caio Vicentino Date: Fri, 16 Oct 2026 17:00:47 -0300 Subject: [PATCH 4/4] Redaction: the questions too (instructions and choice criteria), labels restored in the answers Ekbasis.ask redacted the state but not the questions, which can carry runtime values: recap() puts the caller's rules into `instructions`, and the MCP predict_consequences tool takes caller-written questions and criteria. Every string of every question is now redacted before sending (instructions, criteria labels and descriptions; "type" is untouched). Labels are redacted in one pass per question so distinct labels stay distinct, and a redacted label is mapped back to the caller's in the answer (value, probabilities, calibrated probabilities). Test: a recap()'d question with a key in the rules and an MCP-style choice question with keys in a label and a description; nothing leaves, and the answer carries the caller's label. CHANGELOG and SECURITY say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++- docs/SECURITY.md | 6 ++-- ekbasis/client.py | 46 +++++++++++++++++++++++++-- plugins/ekbasis/lib/ekbasis/client.py | 46 +++++++++++++++++++++++++-- tests/test_redact_0142.py | 29 +++++++++++++++++ 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad8b6f..549ec2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,9 @@ `https://user:token@…` or `--password=…` reached the server and the disk. Every state sent, the hook's log and messages, and the monitor's log now go through `ekbasis.redact.redact_line`, which replaces credential-looking values with placeholders made of letters, digits and underscores (`REDACTED_1`, `vck_REDACTED_2`) and keeps quotes, spaces, - redirections and paths, so the guards read the line the same way. The hook's log records `"redacted": n`. + redirections and paths, so the guards read the line the same way. The questions are redacted too (their + instructions, which `recap()` fills with your rules, and choice labels and descriptions; a redacted label comes back + as yours in the answer). The hook's log records `"redacted": n`. `EKBASIS_REDACT=0` turns it off. What it recognizes, and what it does not, is in [docs/SECURITY.md](docs/SECURITY.md#what-leaves-the-machine-and-credentials-on-the-command-line-0142). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 315e773..b87a22e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -114,8 +114,10 @@ stash messages). The hook's log (EKBASIS_GUARD_MODE=log) and the monitor's log s Until 0.1.41 the command line went verbatim: a secret typed on it (`printf '%s' 'vck_…' > f`, `export TOKEN=…`, `curl -H "Authorization: Bearer …"`, `git remote set-url origin https://user:token@…`, `--password=…`) reached the -server and the local logs. Since 0.1.42 every state the client sends (`Ekbasis.ask`, so the hook, the CLI, the MCP -tools and the Python API alike) and both logs go through `ekbasis.redact.redact_line`, which replaces credential-looking +server and the local logs. Since 0.1.42 every state and every question the client sends (`Ekbasis.ask`, so the hook, +the CLI, the MCP tools and the Python API alike; in questions, the instructions, which `recap()` fills with your rules, +and choice labels and descriptions, a redacted label coming back as yours in the answer) and both logs go through +`ekbasis.redact.redact_line`, which replaces credential-looking values with a placeholder made of letters, digits and underscores (`REDACTED_1`, `vck_REDACTED_2`): the same value gets the same placeholder, and quotes, spaces, redirections and paths stay where they are, so the guards read the line the same way. It replaces: tokens with a known prefix (sk-ant-, sk-, sk_live_, vck_, hf_, ghp_/gho_/ghs_, github_pat_, diff --git a/ekbasis/client.py b/ekbasis/client.py index fce5734..99acd2b 100644 --- a/ekbasis/client.py +++ b/ekbasis/client.py @@ -17,7 +17,7 @@ from ._version import __version__ -from .redact import redact_line +from .redact import redact, redact_line class EkbasisError(RuntimeError): @@ -166,6 +166,45 @@ def from_api(cls, a: dict) -> "Answer": QUESTION_TYPES = ("noul", "choice", "score") +def _redact_questions(questions: dict) -> tuple[dict, dict]: + """0.1.42: every string of the questions as sent (instructions, which recap() fills with the caller's rules, and the + criteria's labels and descriptions, which callers such as the MCP tool write), credentials replaced (ekbasis.redact). + Returns (the questions to send, {question: {label as sent: the caller's label}}) so that answers come back with the + caller's labels. "type" is never touched.""" + out, labels = {}, {} + for name, q in questions.items(): + sent = {} + for k, v in q.items(): + if k == "type": + sent[k] = v + elif k == "criteria" and isinstance(v, dict): + keys = [str(x) for x in v] + red = redact("\n".join(keys))[0].split("\n") # one pass: distinct values, distinct placeholders + if len(red) != len(keys) or len(set(red)) != len(red): + red = [x if redact(x)[1] == 0 else f"REDACTED_LABEL_{i + 1}" for i, x in enumerate(keys)] + sent[k] = {r: redact_line(d) if isinstance(d, str) else d for r, d in zip(red, v.values())} + labels[name] = {r: orig for r, orig in zip(red, v) if r != str(orig)} + elif isinstance(v, str): + sent[k] = redact_line(v) + elif isinstance(v, list): + sent[k] = [redact_line(x) if isinstance(x, str) else x for x in v] + else: + sent[k] = v + out[name] = sent + return out, {n: m for n, m in labels.items() if m} + + +def _restore_labels(a: "Answer", m: dict) -> "Answer": + """The answer with the caller's labels where a label was redacted before sending.""" + if not m: + return a + a.value = m.get(a.value, a.value) if isinstance(a.value, str) else a.value + a.probabilities = {m.get(k, k): p for k, p in a.probabilities.items()} + if a.calibrated is not None: + a.calibrated.probabilities = {m.get(k, k): p for k, p in a.calibrated.probabilities.items()} + return a + + def _check_questions(questions) -> None: """Refuse a malformed question before sending it. The server reads the question from `instructions` and ignores any other field, so a question in `question` (or with no text) would get one same answer whatever it asked.""" @@ -240,13 +279,14 @@ def ask(self, state: str, questions: dict, read_once: bool = False, images=None) """Every question about the same state, answered in one request (the server batches them; read_once=True reads the state once for all of them, on models trained for it). images: data URIs or base64 strings (vLLM backend).""" _check_questions(questions) - body = {"state": redact_line(state), "questions": questions} # 0.1.42: credentials on a command line stay here + sent, labels = _redact_questions(questions) # 0.1.42: credentials typed in a line, rules or question stay here + body = {"state": redact_line(state), "questions": sent} if read_once: body["read_once"] = True if images: body["images"] = list(images) d = self._call("/v1/systemone", body) - return {k: Answer.from_api(v) for k, v in d["answers"].items()} + return {k: _restore_labels(Answer.from_api(v), labels.get(k, {})) for k, v in d["answers"].items()} def feedback(self, verdict: str, request_id: str | None = None, note: str | None = None, retry_s: float = 2.0) -> dict: """Tell the hosted API how an answer turned out: "correct", "wrong", "prevented_harm" (it stopped a real diff --git a/plugins/ekbasis/lib/ekbasis/client.py b/plugins/ekbasis/lib/ekbasis/client.py index fce5734..99acd2b 100644 --- a/plugins/ekbasis/lib/ekbasis/client.py +++ b/plugins/ekbasis/lib/ekbasis/client.py @@ -17,7 +17,7 @@ from ._version import __version__ -from .redact import redact_line +from .redact import redact, redact_line class EkbasisError(RuntimeError): @@ -166,6 +166,45 @@ def from_api(cls, a: dict) -> "Answer": QUESTION_TYPES = ("noul", "choice", "score") +def _redact_questions(questions: dict) -> tuple[dict, dict]: + """0.1.42: every string of the questions as sent (instructions, which recap() fills with the caller's rules, and the + criteria's labels and descriptions, which callers such as the MCP tool write), credentials replaced (ekbasis.redact). + Returns (the questions to send, {question: {label as sent: the caller's label}}) so that answers come back with the + caller's labels. "type" is never touched.""" + out, labels = {}, {} + for name, q in questions.items(): + sent = {} + for k, v in q.items(): + if k == "type": + sent[k] = v + elif k == "criteria" and isinstance(v, dict): + keys = [str(x) for x in v] + red = redact("\n".join(keys))[0].split("\n") # one pass: distinct values, distinct placeholders + if len(red) != len(keys) or len(set(red)) != len(red): + red = [x if redact(x)[1] == 0 else f"REDACTED_LABEL_{i + 1}" for i, x in enumerate(keys)] + sent[k] = {r: redact_line(d) if isinstance(d, str) else d for r, d in zip(red, v.values())} + labels[name] = {r: orig for r, orig in zip(red, v) if r != str(orig)} + elif isinstance(v, str): + sent[k] = redact_line(v) + elif isinstance(v, list): + sent[k] = [redact_line(x) if isinstance(x, str) else x for x in v] + else: + sent[k] = v + out[name] = sent + return out, {n: m for n, m in labels.items() if m} + + +def _restore_labels(a: "Answer", m: dict) -> "Answer": + """The answer with the caller's labels where a label was redacted before sending.""" + if not m: + return a + a.value = m.get(a.value, a.value) if isinstance(a.value, str) else a.value + a.probabilities = {m.get(k, k): p for k, p in a.probabilities.items()} + if a.calibrated is not None: + a.calibrated.probabilities = {m.get(k, k): p for k, p in a.calibrated.probabilities.items()} + return a + + def _check_questions(questions) -> None: """Refuse a malformed question before sending it. The server reads the question from `instructions` and ignores any other field, so a question in `question` (or with no text) would get one same answer whatever it asked.""" @@ -240,13 +279,14 @@ def ask(self, state: str, questions: dict, read_once: bool = False, images=None) """Every question about the same state, answered in one request (the server batches them; read_once=True reads the state once for all of them, on models trained for it). images: data URIs or base64 strings (vLLM backend).""" _check_questions(questions) - body = {"state": redact_line(state), "questions": questions} # 0.1.42: credentials on a command line stay here + sent, labels = _redact_questions(questions) # 0.1.42: credentials typed in a line, rules or question stay here + body = {"state": redact_line(state), "questions": sent} if read_once: body["read_once"] = True if images: body["images"] = list(images) d = self._call("/v1/systemone", body) - return {k: Answer.from_api(v) for k, v in d["answers"].items()} + return {k: _restore_labels(Answer.from_api(v), labels.get(k, {})) for k, v in d["answers"].items()} def feedback(self, verdict: str, request_id: str | None = None, note: str | None = None, retry_s: float = 2.0) -> dict: """Tell the hosted API how an answer turned out: "correct", "wrong", "prevented_harm" (it stopped a real diff --git a/tests/test_redact_0142.py b/tests/test_redact_0142.py index 8ac62be..6ecb982 100644 --- a/tests/test_redact_0142.py +++ b/tests/test_redact_0142.py @@ -125,6 +125,35 @@ def fake_call(path, body=None): self.assertIn("ghp_REDACTED_1", sent["state"]) self.assertIn("rm -rf build", sent["state"]) + def test_questions_are_redacted_too(self): + # a recap()'d question carries the caller's rules; an MCP-style choice question carries the caller's criteria + from ekbasis import prompts as P + sent = {} + label = f"hf_{FAKE}" + + def fake_call(path, body=None): + sent.update(body) + crit = list(body["questions"]["b"]["criteria"]) + return {"answers": { + "a": {"type": "noul", "value": False, "confidence": 0.9, "probability": 0.1}, + "b": {"type": "choice", "choice": crit[0], "confidence": 0.8, + "probabilities": {crit[0]: 0.8, crit[1]: 0.2}, + "calibrated": {"temperature": 1.5, "confidence": 0.7, + "probabilities": {crit[0]: 0.7, crit[1]: 0.3}}}}} + questions = { + "a": P.recap(P.yes_no("Is the deploy key printed?"), f"The deploy key is vck_{FAKE}; never print it."), + "b": {"type": "choice", "instructions": "Which token does the job use?", + "criteria": {label: "the old token", "other": f"one that starts like sk-ant-api03-{FAKE}"}}} + c = Ekbasis(url="http://127.0.0.1:9") + with mock.patch.object(c, "_call", fake_call): + ans = c.ask("State: a CI job.", questions) + self.assertNotIn(FAKE, json.dumps(sent)) + self.assertIn("vck_REDACTED_1", sent["questions"]["a"]["instructions"]) + self.assertEqual(sent["questions"]["a"]["type"], "noul") + self.assertEqual(ans["b"].value, label) # the caller's label comes back + self.assertEqual(set(ans["b"].probabilities), {label, "other"}) + self.assertEqual(set(ans["b"].calibrated.probabilities), {label, "other"}) + def test_hook_log_and_messages(self): d = tempfile.mkdtemp() log = os.path.join(d, "hook_log.jsonl")