Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,47 @@

<!-- /skopos:entry -->

## [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 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).

### 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
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -652,8 +661,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):
Expand Down Expand Up @@ -717,7 +726,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
Expand Down Expand Up @@ -764,7 +774,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).
Expand Down
35 changes: 34 additions & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,45 @@ 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 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_,
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),
`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).
Expand Down
2 changes: 1 addition & 1 deletion ekbasis/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.41"
__version__ = "0.1.42"
19 changes: 15 additions & 4 deletions ekbasis/claude_code_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -76,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

Expand Down Expand Up @@ -220,7 +228,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
Expand Down Expand Up @@ -369,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)
Expand All @@ -396,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}}))
Expand All @@ -406,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:
Expand Down Expand Up @@ -603,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):
Expand Down
Loading
Loading