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
1 change: 1 addition & 0 deletions .vedeiignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ d0dead6eceff137cafd15c0029bf1214 # AKIA...LE example key, cmd/vedei-hook/main_t
2040ad664a71a504a6ff863b0b78a4f0 # generated PIS vector, space-separated, detect/br/extract_test.go
62949e15935851bd24d617292e7230f8 # generated titulo vector, space-separated, detect/br/extract_test.go
ec2a7642f9821664294f09e54573ca66 # generated CNS vector, detect/br/extract_test.go
c3fca3e7a0151668ec85c974b0f4f57f # the made-up credential from the corpus, also in transcript/stats_test.go
3d916d891e1aab79bc68592c2d7cd3a5 # the first alphanumeric CNPJ the Receita issued; an earlier version of this file quoted it in a comment, so git history still holds it

# --- the one path rule: the labeled corpus ---
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ vedei reduces what lands there and shows what already did. It does not replace d

Documented upstream: [#44868](https://github.com/anthropics/claude-code/issues/44868), [#50014](https://github.com/anthropics/claude-code/issues/50014), [#95680](https://github.com/anthropics/claude-code/issues/95680).

**Which agents' transcripts are read.** `vedei transcript scan` and `scrub` cover Claude Code (`~/.claude/projects/`) and Codex (`~/.codex/sessions/`). Two other agents keep the same kind of record and are **not** read yet:

| agent | where it keeps sessions | why not yet |
|---|---|---|
| Gemini CLI | `~/.gemini/tmp/<project hash>/chats/`, JSON files | the format is documented but untested against real sessions; reading it blind would report coverage it does not have |
| Cursor | `state.vscdb`, a SQLite database under the Cursor user directory | reading it needs a SQLite driver, and `scrub` cannot safely rewrite a database |

Both are plaintext on disk too. Until they are supported, treat their directories as holding whatever their agents read.

## What "validate" means here

| | Secret | Personal data |
Expand Down
10 changes: 10 additions & 0 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ O vedei reduz o que entra ali e mostra o que já entrou. Não substitui criptogr

Documentado no próprio repositório do Claude Code: [#44868](https://github.com/anthropics/claude-code/issues/44868), [#50014](https://github.com/anthropics/claude-code/issues/50014), [#95680](https://github.com/anthropics/claude-code/issues/95680).


**De quais agentes o transcript é lido.** `vedei transcript scan` e `scrub` cobrem Claude Code (`~/.claude/projects/`) e Codex (`~/.codex/sessions/`). Dois outros agentes guardam o mesmo tipo de registro e **ainda não** são lidos:

| agente | onde guarda as sessões | por que ainda não |
|---|---|---|
| Gemini CLI | `~/.gemini/tmp/<hash do projeto>/chats/`, arquivos JSON | o formato está documentado, mas não foi testado contra sessões reais; ler às cegas reportaria uma cobertura que não existe |
| Cursor | `state.vscdb`, um banco SQLite no diretório de usuário do Cursor | ler exige um driver SQLite, e o `scrub` não reescreve um banco com segurança |

Os dois também ficam em texto puro no disco. Até serem suportados, trate esses diretórios como contendo tudo que os agentes leram.

## O que "validar" significa aqui

| | Segredo | Dado pessoal |
Expand Down
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ tokens are detected by the upstream corpus.
written to disk. `--idle-timeout` makes it exit when unused.
- A request is capped at 16 MB, so one client cannot decide how much memory the
process holds.
- **None of the above holds on Windows.** vedei builds there, and Windows 10
1803+ supports Unix-domain sockets, but `os.Chmod` on Windows only toggles the
read-only attribute: the `0600` and `0700` modes restrict nobody, because
Windows controls access with ACLs. The socket's privacy then rests on the
default ACL of the user's profile directory — normally the user,
Administrators and SYSTEM — which vedei does not verify. It is also untested
on Windows. Until that changes, run `vedei hook` without the daemon on
Windows (see `hooks/README.md`).

**Findings crossing the socket cannot carry the raw value.** `Finding.Raw` is
`json:"-"`, so the process boundary enforces ADR-003 rather than trusting it.
Expand Down
58 changes: 44 additions & 14 deletions cmd/vedei-hook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ func main() {
// for as long as it lived.
fallbackTimeout := flag.Duration("fallback-timeout", 30*time.Second,
"give up on the fallback command after this long")
fallback := flag.String("fallback", "", `command to fall back to, or "" to look for vedei beside this binary; "-" to disable`)
fallback := flag.String("fallback", "", `vedei binary to fall back to, or "" to look for one beside this binary; "-" to disable`)
failClosed := flag.Bool("fail-closed", false,
"when detection cannot run, withhold the tool output instead of passing it through unscanned")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()

Expand All @@ -52,17 +54,17 @@ func main() {
return // nothing to write back
}

out, ok := viaDaemon(context.Background(), input, *socket, *timeout)
out, ok := viaDaemon(context.Background(), input, *socket, *timeout, *failClosed)
if !ok {
out = viaFallback(input, *fallback, *fallbackTimeout)
out = viaFallback(input, *fallback, *fallbackTimeout, *failClosed)
}
_, _ = os.Stdout.Write(out)
}

// viaDaemon rewrites the event through the daemon, reporting whether it was
// able to. A false means the daemon could not be reached or could not answer;
// it does not mean the event was clean.
func viaDaemon(ctx context.Context, input []byte, socket string, timeout time.Duration) ([]byte, bool) {
func viaDaemon(ctx context.Context, input []byte, socket string, timeout time.Duration, failClosed bool) ([]byte, bool) {
if socket == "" {
return nil, false
}
Expand All @@ -82,6 +84,10 @@ func viaDaemon(ctx context.Context, input []byte, socket string, timeout time.Du
// being unreachable. Re-running it elsewhere would fail the same way,
// so the event is served, unredacted.
served = true
if resp.Degraded && failClosed {
fmt.Fprintln(os.Stderr, "vedei: output withheld, the daemon could not scan it")
return hookevent.Withheld, true
}
if resp.Degraded || !resp.Redacted {
return "", false
}
Expand All @@ -95,11 +101,29 @@ func viaDaemon(ctx context.Context, input []byte, socket string, timeout time.Du

// viaFallback hands the event to the full binary. On any failure — no binary,
// a non-zero exit, empty output — the original event is returned.
func viaFallback(input []byte, override string, timeout time.Duration) []byte {
func viaFallback(input []byte, override string, timeout time.Duration, failClosed bool) []byte {
// Every way this function gives up returns the same thing: the input as it
// arrived, or — under --fail-closed — the input with its output withheld.
giveUp := func() []byte {
if failClosed {
return hookevent.Withhold(input)
}
return input
}

bin, args := resolveFallback(override)
if bin == "" {
fmt.Fprintln(os.Stderr, "vedei: no daemon and no vedei binary found; event passed through unredacted")
return input
if failClosed {
fmt.Fprintln(os.Stderr, "vedei: no daemon and no vedei binary found; output withheld")
} else {
fmt.Fprintln(os.Stderr, "vedei: no daemon and no vedei binary found; event passed through unredacted")
}
return giveUp()
}
// The full binary is told to fail closed too, so a fallback that cannot
// scan withholds rather than passing the output through.
if failClosed {
args = append(args, "--fail-closed")
}

ctx, cancel := context.WithTimeout(context.Background(), timeout)
Expand All @@ -110,37 +134,43 @@ func viaFallback(input []byte, override string, timeout time.Duration) []byte {
cmd.Stderr = os.Stderr
out, err := cmd.Output()
if err != nil || len(out) == 0 {
return input
return giveUp()
}
return out
}

// resolveFallback picks the command to fall back to: the --fallback flag, then
// VEDEI_BIN, then a vedei sitting beside this binary, then one on PATH.
// resolveFallback picks the vedei binary to fall back to: the --fallback flag,
// then VEDEI_BIN, then a vedei sitting beside this binary, then one on PATH.
// Every source is run the same way, as "vedei hook --no-daemon".
//
// --fallback used to be run with no arguments at all, which made vedei print
// its help — and the help text was written back to the agent in place of the
// tool's output. A path given here is a vedei binary like any other source.
//
// The sibling is tried before PATH so an installation is self-contained: the
// pair is built and shipped together, and a different vedei earlier on PATH
// should not quietly take over.
func resolveFallback(override string) (bin string, args []string) {
hookArgs := []string{"hook", "--no-daemon"}
switch override {
case "-":
return "", nil
case "":
default:
return override, nil
return override, hookArgs
}

if env := os.Getenv("VEDEI_BIN"); env != "" {
return env, []string{"hook", "--no-daemon"}
return env, hookArgs
}
if self, err := os.Executable(); err == nil {
sibling := filepath.Join(filepath.Dir(self), "vedei")
if st, err := os.Stat(sibling); err == nil && !st.IsDir() {
return sibling, []string{"hook", "--no-daemon"}
return sibling, hookArgs
}
}
if found, err := exec.LookPath("vedei"); err == nil {
return found, []string{"hook", "--no-daemon"}
return found, hookArgs
}
return "", nil
}
49 changes: 47 additions & 2 deletions cmd/vedei-hook/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,27 @@ func TestHookClient_FallsBackToTheFullBinary(t *testing.T) {

out := runHook(t, hook, event("cpf "+testCPF),
"--socket", shortSocket(t), "--fallback", vedei)
if strings.Contains(out, testCPF) {
t.Fatalf("the fallback did not redact: %s", out)
assertRedactedEvent(t, out)
}

// assertRedactedEvent checks that out is the event, with its output redacted.
//
// "The value is gone" is not enough on its own. --fallback once ran vedei with
// no arguments, so the output was vedei's help text: no CPF in it, and a test
// that only looked for the CPF passed while the agent received a usage screen
// instead of its tool's result.
func assertRedactedEvent(t *testing.T, out string) {
t.Helper()
var ev map[string]any
if err := json.Unmarshal([]byte(out), &ev); err != nil {
t.Fatalf("the output is not the event: %v\n%s", err, out)
}
resp, _ := ev["tool_response"].(string)
if strings.Contains(resp, testCPF) {
t.Fatalf("the value got through: %s", resp)
}
if !strings.Contains(resp, "vedei: cpf redacted") {
t.Errorf("tool_response = %q, want the value redacted", resp)
}
}

Expand All @@ -158,6 +177,32 @@ func TestHookClient_PassesThroughWithNothingAvailable(t *testing.T) {
}
}

// No daemon and no fallback is the case --fail-closed exists for: nothing can
// scan the output, so it is withheld instead of passed on.
func TestHookClient_FailClosedWithholdsWithNothingAvailable(t *testing.T) {
_, hook := bins(t)

out := runHook(t, hook, event("cpf "+testCPF),
"--socket", shortSocket(t), "--fallback", "-", "--fail-closed")
if strings.Contains(out, testCPF) {
t.Fatalf("the unscanned value got through: %s", out)
}
if !strings.Contains(out, "output withheld") {
t.Errorf("no withheld notice: %s", out)
}
}

// The flag is passed on to the full binary when the client falls back to it,
// so a fallback that cannot scan withholds too. Here the fallback works, and
// the value is redacted rather than withheld.
func TestHookClient_FailClosedFallbackStillRedacts(t *testing.T) {
vedei, hook := bins(t)

out := runHook(t, hook, event("cpf "+testCPF),
"--socket", shortSocket(t), "--fallback", vedei, "--fail-closed")
assertRedactedEvent(t, out)
}

func TestHookClient_FailsOpenOnMalformedInput(t *testing.T) {
vedei, hook := bins(t)
socket := shortSocket(t)
Expand Down
89 changes: 89 additions & 0 deletions cmd/vedei/failclosed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package main

import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/mkmuniz/vedei/internal/hookevent"
)

// degradedOpts makes detection fail on purpose: an input cap smaller than any
// event marks the scan degraded, the same state a panicking engine produces.
func degradedOpts(failClosed bool) redactOpts {
return redactOpts{noDaemon: true, maxInput: 5, failClosed: failClosed}
}

// The default stays fail-open (ADR-005): a scan that could not run passes the
// event through unchanged, so the session keeps working.
func TestRunHook_FailOpenByDefault(t *testing.T) {
in := []byte(`{"tool_name":"Read","tool_response":"cpf ` + testCPF + `"}`)
if got := runHook(context.Background(), in, degradedOpts(false)); !bytes.Equal(got, in) {
t.Errorf("fail-open altered the event:\n got %s\nwant %s", got, in)
}
}

// --fail-closed is for the environments where an unscanned CPF reaching a
// model is a reportable incident: the output is withheld instead.
func TestRunHook_FailClosedWithholds(t *testing.T) {
in := []byte(`{"tool_name":"Read","tool_response":"cpf ` + testCPF + `"}`)
got := string(runHook(context.Background(), in, degradedOpts(true)))

if strings.Contains(got, testCPF) {
t.Fatalf("the unscanned value got through: %s", got)
}
if !strings.Contains(got, "output withheld") {
t.Errorf("no withheld notice: %s", got)
}
if !strings.Contains(got, `"tool_name":"Read"`) {
t.Errorf("the rest of the event was lost: %s", got)
}
}

// --fail-closed changes only what happens when detection fails. A scan that
// runs and finds nothing still passes the text through.
func TestRunHook_FailClosedLeavesCleanTextAlone(t *testing.T) {
in := []byte(`{"tool_response":"nada aqui"}`)
opts := redactOpts{noDaemon: true, failClosed: true}
if got := runHook(context.Background(), in, opts); !bytes.Equal(got, in) {
t.Errorf("a clean event was altered: %s", got)
}
}

// stream under --fail-closed writes the notice, none of the input, and exits
// with an error rather than reporting clean.
func TestStream_FailClosed(t *testing.T) {
bin := os.Getenv("VEDEI_BIN")
if bin == "" {
bin = "../../bin/vedei"
}
if _, err := os.Stat(bin); err != nil {
t.Skip("binary not built; run make build")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, bin, "stream", "--no-daemon", "--max-input", "5", "--fail-closed", //nolint:gosec // fixed path
"--socket", filepath.Join(t.TempDir(), "absent.sock"))
cmd.Stdin = strings.NewReader("cpf " + testCPF)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()

var exitErr *exec.ExitError
if err == nil || !errors.As(err, &exitErr) || exitErr.ExitCode() != exitError {
t.Fatalf("exit = %v, want %d", err, exitError)
}
if strings.Contains(out.String(), testCPF) {
t.Fatalf("the unscanned value got through: %q", out.String())
}
if strings.TrimSpace(out.String()) != hookevent.Withheld {
t.Errorf("stdout = %q, want only the withheld notice", out.String())
}
}
38 changes: 25 additions & 13 deletions cmd/vedei/hook.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -40,19 +41,7 @@ unchanged. A hook that blocks is worse than a hook that misses something.`,
if err != nil {
return nil
}
out := hookevent.Rewrite(input, func(text string) (string, bool) {
res := redact(cmd.Context(), text, detect.Metadata{Source: "agent-hook"}, opts)
if res.Degraded || !res.Redacted {
return "", false
}
// Report on stderr, which the agent shows the user but does
// not send to the model, so a person can see what was held back.
for _, f := range res.Findings {
fmt.Fprintf(os.Stderr, "vedei: redacted %s\n", f.Type)
}
return res.Text, true
})
if _, err := os.Stdout.Write(out); err != nil {
if _, err := os.Stdout.Write(runHook(cmd.Context(), input, opts)); err != nil {
return err
}
return nil
Expand All @@ -62,3 +51,26 @@ unchanged. A hook that blocks is worse than a hook that misses something.`,
bindRedactFlags(cmd, &opts, true)
return cmd
}

// runHook rewrites one hook event. Reports go to stderr, which the agent shows
// the user but does not send to the model, so a person can see what was held
// back.
func runHook(ctx context.Context, input []byte, opts redactOpts) []byte {
return hookevent.Rewrite(input, func(text string) (string, bool) {
res := redact(ctx, text, detect.Metadata{Source: "agent-hook"}, opts)
if res.Degraded {
if opts.failClosed {
fmt.Fprintf(os.Stderr, "vedei: output withheld, detection failed: %v\n", res.Err)
return hookevent.Withheld, true
}
return "", false
}
if !res.Redacted {
return "", false
}
for _, f := range res.Findings {
fmt.Fprintf(os.Stderr, "vedei: redacted %s\n", f.Type)
}
return res.Text, true
})
}
Loading
Loading