openvibers/openvibe.link Commits
Memory: the Node reads what the coding tools remember (read-only, local, redacted) (#87)
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++# The memory fixtures hold fake secrets on purpose: every adapter++# carries one secret of a different shape, and the test proves none of them++# reaches an item. The Claude Code fixture carries more: a key block that++# is split over two items, a key block with no END line, and an .env block.++# The values are obviously fake (FAKE, EXAMPLE, repeated letters) and exist++# only to be redacted.++#++# Each line is the exact fingerprint of a finding (file:rule:line).++internal/memory/testdata/roo/project/.roo/rules/general.md:stripe-access-token:4++internal/memory/testdata/copilot/project/.github/copilot-instructions.md:jwt:5++internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md:private-key:7++internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md:private-key:6@@@ -32,6 +32,7 @@ openvibe-node resumeopenvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcheropenvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)++openvibe-node memory scan # what your coding tools remember: read-only, local, secrets removed```## What is in here@@@ -70,7 +71,8 @@ job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver--protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md).++protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),++[memory](docs/memory.md) (what the coding tools on a machine remember, and how the Node reads it).Decisions: [ADR-043](docs/ADR-043-bot-devices-and-control.md).## Development@@@ -11,6 +11,8 @@// openvibe-node resume release the local kill switch (and a server e-stop)// openvibe-node quit end the running node cleanly: every actuator stops, nothing is latched// openvibe-node plugins list plugins and their capabilities++// openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]++// read the memory files of the coding tools (read-only, local)// openvibe-node versionpackage main@@@ -72,6 +74,9 @@ Usage:openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--bind PATH]... [--egress public] -- PROGRAM [ARG]...run one desk's browser confined (a desk host that serves other people; the computer plugincalls it, with --remote-debugging-pipe on fds 3 and 4)++ openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]++ read the memory files of the coding tools on this machine++ (read-only, local, secrets removed)openvibe-node versionGlobal flags (before or after the command):@@@ -101,6 +106,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intreturn 0}cmd, rest := args[0], args[1:]++ if cmd == "memory" {++ // The memory scan has its own flags, so it parses its own arguments.++ return cmdMemory(rest, stdout, stderr)++ }if cmd == "desk-sandbox" { // its own flags, up to "--": a browser's flags after it are never the Node'sreturn cmdDeskSandbox(rest, stderr)}nnew file mode 100644@@@ -0,0 +1,138 @@++package main++++import (++ "encoding/json"++ "flag"++ "fmt"++ "io"++ "os"++ "path/filepath"++ "sort"++ "strings"++++ "github.com/OpenVibers/OpenVibe.Node/internal/memory"++)++++// memoryUsage is what "openvibe-node memory" prints.++const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--json]\n" +++ "\n" +++ " Reads the memory files of the coding tools on this machine and prints what it\n" +++ " found. It is read-only and local: nothing is written and nothing is sent\n" +++ " anywhere. Secrets are removed before anything is printed.\n" +++ "\n" +++ " --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +++ " copilot, windsurf, cline, roo, kiro, continue, aider,\n" +++ " command-code, opencode)\n" +++ " --project DIR read this project as well; may be given more than once\n" +++ " --json print the items as JSON\n" +++ " --user-home DIR read this directory as the person's home (default: yours)\n"++++// stringList is a flag that may be given more than once.++type stringList []string++++func (s *stringList) String() string { return strings.Join(*s, ",") }++++func (s *stringList) Set(v string) error {++ for _, part := range strings.Split(v, ",") {++ part = strings.TrimSpace(part)++ if part != "" {++ *s = append(*s, part)++ }++ }++ return nil++}++++// cmdMemory runs the memory commands.++func cmdMemory(args []string, stdout, stderr io.Writer) int {++ if len(args) == 0 || args[0] != "scan" {++ fmt.Fprint(stderr, memoryUsage)++ return 2++ }++ fs := flag.NewFlagSet("memory scan", flag.ContinueOnError)++ fs.SetOutput(stderr)++ fs.Usage = func() { fmt.Fprint(stderr, memoryUsage) }++ var tools stringList++ var projects stringList++ fs.Var(&tools, "tools", "only these adapters")++ fs.Var(&projects, "project", "read this project as well")++ asJSON := fs.Bool("json", false, "print the items as JSON")++ userHome := fs.String("user-home", "", "read this directory as the person's home")++ if err := fs.Parse(args[1:]); err != nil {++ return 2++ }++ if fs.NArg() > 0 {++ fmt.Fprintf(stderr, "unknown argument %q\n\n", fs.Arg(0))++ fmt.Fprint(stderr, memoryUsage)++ return 2++ }++ home := *userHome++ if home == "" {++ h, err := os.UserHomeDir()++ if err != nil {++ fmt.Fprintln(stderr, "error: cannot find your home directory; pass --user-home")++ return 1++ }++ home = h++ }++ abs, err := filepath.Abs(home)++ if err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ res := memory.Scan(memory.Options{++ Home: abs,++ Projects: projects,++ Tools: tools,++ })++ if *asJSON {++ enc := json.NewEncoder(stdout)++ enc.SetIndent("", " ")++ if err := enc.Encode(res); err != nil {++ fmt.Fprintln(stderr, "error:", err)++ return 1++ }++ return 0++ }++ printScan(res, stdout)++ return 0++}++++// printScan prints what the scan found: every adapter, every path it looked++// at and the items per file.++func printScan(res memory.Result, stdout io.Writer) {++ fmt.Fprintf(stdout, "Memory scan of %s\n", res.Home)++ if len(res.Projects) == 0 {++ fmt.Fprintln(stdout, "No project given: only the user-level files were read.")++ } else {++ fmt.Fprintf(stdout, "Projects: %s\n", strings.Join(res.Projects, ", "))++ }++ for _, ad := range res.Adapters {++ fmt.Fprintf(stdout, "\n%s (%s) — %d items\n", ad.Name, ad.ID, ad.Items)++ fmt.Fprintf(stdout, " docs: %s\n", ad.Docs)++ if len(ad.Files) == 0 {++ fmt.Fprintln(stdout, " no path to look at")++ continue++ }++ for _, f := range ad.Files {++ switch {++ case f.Note != "":++ fmt.Fprintf(stdout, " - %s: %s\n", f.Path, f.Note)++ case f.Found:++ fmt.Fprintf(stdout, " - %s: %d items\n", f.Path, f.Items)++ default:++ fmt.Fprintf(stdout, " - %s: not there\n", f.Path)++ }++ }++ }++ fmt.Fprintf(stdout, "\n%d items in total. Nothing was written and nothing was sent.\n", len(res.Items))++}++++// memoryToolIDs lists the adapter ids, for the usage text and the tests.++func memoryToolIDs() []string {++ var out []string++ for _, ad := range memory.Adapters() {++ out = append(out, ad.ID)++ }++ sort.Strings(out)++ return out++}nnew file mode 100644@@@ -0,0 +1,164 @@++package main++++import (++ "bytes"++ "encoding/json"++ "os"++ "path/filepath"++ "strings"++ "testing"++)++++// adapterJSON is the JSON shape of one adapter of the scan.++type adapterJSON struct {++ ID string `json:"id"`++ Files []fileJSON `json:"files"`++}++++// fileJSON is the JSON shape of one file of the scan.++type fileJSON struct {++ Path string `json:"path"`++ Found bool `json:"found"`++}++++// itemJSON is the JSON shape of one memory item.++type itemJSON struct {++ Text string `json:"text"`++ Kind string `json:"kind"`++ Scope string `json:"scope"`++ Source string `json:"source"`++}++++// memoryFixture is the fixture home of the memory package, used here so the++// test never reads the real home of the machine.++func memoryFixture(t *testing.T) string {++ t.Helper()++ wd, err := os.Getwd()++ if err != nil {++ t.Fatal(err)++ }++ return filepath.Join(wd, "..", "..", "internal", "memory", "testdata", "claude-code", "home")++}++++// TestMemoryScanPrintsEveryAdapter checks the human output of the scan.++func TestMemoryScanPrintsEveryAdapter(t *testing.T) {++ home := memoryFixture(t)++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"scan", "--user-home", home}, &stdout, &stderr)++ if code != 0 {++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())++ }++ out := stdout.String()++ for _, want := range []string{++ "Claude Code (claude-code)",++ "AGENTS.md (Codex and friends)",++ "Gemini CLI",++ "Cursor",++ "GitHub Copilot",++ "Windsurf",++ "Cline",++ "Roo Code",++ "Kiro",++ "Continue",++ "Aider",++ "Command Code",++ "OpenCode",++ "not there",++ "docs: https://",++ } {++ if !strings.Contains(out, want) {++ t.Errorf("the output has no %q: %s", want, out)++ }++ }++ if !strings.Contains(out, "Nothing was written and nothing was sent") {++ t.Errorf("the output does not say what it did: %s", out)++ }++}++++// TestMemoryScanJSON checks the JSON output of the scan.++func TestMemoryScanJSON(t *testing.T) {++ home := memoryFixture(t)++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"scan", "--user-home", home, "--json"}, &stdout, &stderr)++ if code != 0 {++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())++ }++ var res struct {++ Home string `json:"home"`++ Adapters []adapterJSON `json:"adapters"`++ Items []itemJSON `json:"items"`++ }++ if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {++ t.Fatalf("the output is not JSON: %v", err)++ }++ if len(res.Adapters) != len(memoryToolIDs()) {++ t.Errorf("the JSON lists %d adapters, want %d", len(res.Adapters), len(memoryToolIDs()))++ }++ if len(res.Items) == 0 {++ t.Error("the JSON has no items")++ }++ for _, it := range res.Items {++ if it.Source == "" || it.Kind == "" || it.Scope == "" {++ t.Errorf("an item is missing a field: %+v", it)++ }++ if strings.Contains(it.Text, "sk-proj") {++ t.Errorf("the JSON still shows a secret: %q", it.Text)++ }++ }++}++++// TestMemoryScanToolsFilter checks that --tools keeps only the adapters asked++// for.++func TestMemoryScanToolsFilter(t *testing.T) {++ home := memoryFixture(t)++ var stdout, stderr bytes.Buffer++ code := cmdMemory([]string{"scan", "--user-home", home, "--tools", "cursor", "--json"}, &stdout, &stderr)++ if code != 0 {++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())++ }++ if strings.Contains(stdout.String(), "\"id\": \"claude-code\"") {++ t.Errorf("the scan read an adapter that was not asked for: %s", stdout.String())++ }++}++++// TestMemoryScanUsage checks that a wrong use says how to use it.++func TestMemoryScanUsage(t *testing.T) {++ var stdout, stderr bytes.Buffer++ if code := cmdMemory([]string{"list"}, &stdout, &stderr); code != 2 {++ t.Errorf("the command returned %d, want 2", code)++ }++ if !strings.Contains(stderr.String(), "memory scan") {++ t.Errorf("the error does not say how to use it: %s", stderr.String())++ }++}++++// TestMemoryScanIsReadOnly checks that the scan writes nothing.++func TestMemoryScanIsReadOnly(t *testing.T) {++ home := memoryFixture(t)++ before := fixtureState(t, home)++ var stdout, stderr bytes.Buffer++ if code := cmdMemory([]string{"scan", "--user-home", home}, &stdout, &stderr); code != 0 {++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())++ }++ after := fixtureState(t, home)++ if before != after {++ t.Errorf("the scan changed the fixture: %q -> %q", before, after)++ }++}++++// fixtureState is a cheap summary of the files under a directory.++func fixtureState(t *testing.T, dir string) string {++ t.Helper()++ var out []string++ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {++ if err != nil {++ return nil++ }++ out = append(out, path+":"+info.ModTime().String())++ return nil++ })++ if err != nil {++ t.Fatal(err)++ }++ return strings.Join(out, "|")++}nnew file mode 100644@@@ -0,0 +1,217 @@++# Memory: the Node reads what your tools remember++++The Node reads the memory files of the coding tools on your machine and tells++you what it found. It is **read-only, local and redacted**:++++- it opens the files it knows about and nothing else;++- it never writes a file and never opens a network connection;++- it removes the secrets before anything is printed;++- it never follows a symlink out of the home folder or the project folders it++ was given.++++This page is stage M1 of OpenVibe Memory: the readers. The store, the++encryption, the upload and the page come later.++++## The command++++```sh++openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]++```++++| Flag | What it does |++|---|---|++| `--tools a,b` | only these adapters (default: all) |++| `--project DIR` | read this project as well; may be given more than once |++| `--json` | print the items as JSON |++| `--user-home DIR` | read this directory as the person's home (default: yours) |++++Without `--project` only the user-level files are read: the files that hold++your own preferences across every project. With `--project` the project files++are read too, and their items carry the scope `project:<absolute path>`.++++The command prints every adapter, every path it looked at and whether it was++there, and how many items each file gave:++++```++Memory scan of /home/you++No project given: only the user-level files were read.++++Claude Code (claude-code) — 3 items++ docs: https://code.claude.com/docs/en/memory++ - /home/you/.claude/CLAUDE.md: 3 items++ - /home/you/.claude/rules/**/*.md: not there++...++```++++## The adapters++++Each adapter is data: where to look, and how to read what is there.++++| Adapter | id | What it reads | Docs |++|---|---|---|---|++| Claude Code | `claude-code` | `~/.claude/CLAUDE.md`, `~/.claude/rules/**/*.md`, `~/.claude/projects/*/memory/*.md` (the `MEMORY.md` index is skipped), and in a project `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `.claude/rules/**/*.md` | [code.claude.com/docs/en/memory](https://code.claude.com/docs/en/memory) |++| AGENTS.md | `agents` | `~/.codex/AGENTS.md` and the project's `AGENTS.md` | [agents.md](https://agents.md/) |++| Gemini CLI | `gemini` | `~/.gemini/GEMINI.md` and the project's `GEMINI.md` or `.gemini/GEMINI.md` | [geminicli.com/docs/cli/gemini-md](https://geminicli.com/docs/cli/gemini-md/) |++| Cursor | `cursor` | the project's `.cursor/rules/*.mdc` | [cursor.com/docs/context/rules](https://cursor.com/docs/context/rules) |++| GitHub Copilot | `copilot` | the project's `.github/copilot-instructions.md` and `.github/instructions/*.instructions.md` | [docs.github.com](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot) |++| Windsurf | `windsurf` | `~/.codeium/windsurf/memories/**` and the project's `.windsurf/rules/**` | [docs.windsurf.com](https://docs.windsurf.com/windsurf/cascade/memories) |++| Cline | `cline` | the project's `.clinerules`, `.clinerules/**` and `memory-bank/*.md` | [docs.cline.bot/features/cline-rules](https://docs.cline.bot/features/cline-rules) |++| Roo Code | `roo` | the project's `.roo/rules/**` and `memory-bank/*.md` | [docs.roocode.com](https://docs.roocode.com/features/custom-instructions) |++| Kiro | `kiro` | `~/.kiro/steering/*.md` and the project's `.kiro/steering/*.md` | [kiro.dev/docs/steering](https://kiro.dev/docs/steering/) |++| Continue | `continue` | `~/.continue/rules/**` and the project's `.continue/rules/**` | [docs.continue.dev/customize/rules](https://docs.continue.dev/customize/rules) |++| Aider | `aider` | the project's `CONVENTIONS.md` and the files its `.aider.conf.yml` names under `read:` | [aider.chat/docs/usage/conventions](https://aider.chat/docs/usage/conventions.html) |++| Command Code | `command-code` | `~/.commandcode/**` and the project's `.commandcode/**` | [commandcode.ai/docs/memory](https://commandcode.ai/docs/memory) |++| OpenCode | `opencode` | `~/.config/opencode/AGENTS.md`, the project's `AGENTS.md` and the files its `opencode.json` names under `instructions` | [opencode.ai/docs/rules](https://opencode.ai/docs/rules/) |++++### What is never read++++- The chat history of any tool (Aider's `.aider.chat.history.md`, Claude's++ transcripts, Continue's sessions).++- The settings, the hooks, the skills and the plugins of any tool.++- Any folder that is not one of the paths above. The home folder itself is++ never read as a whole: each adapter names the exact files and the narrow++ globs of its own tool, and a glob always starts at a fixed folder.++- A file over 256 KB (it is skipped with a note) and a binary file.++++## How a file becomes items++++- The frontmatter is read, not turned into items.++- A markdown file is split by its headings: every top-level bullet is one++ item, and every paragraph under a heading is one item.++- An item is at most 2,000 characters; a longer one is cut and marked++ `truncated`.++- An item that is only a secret is dropped.++++## The kinds++++| Kind | What it is |++|---|---|++| `preference` | how the person wants things done |++| `fact` | a fact about the person or the project |++| `project_rule` | a rule that holds in a project |++| `procedure` | a way of doing something |++| `decision` | a decision that was taken, with its reason |++| `lesson` | something that was learned the hard way |++++The kind comes from the tool when it says it (Claude Code's auto memory writes++a `metadata.type` in its frontmatter: `user`, `feedback`, `project` or++`reference`), otherwise from the heading the item came from, otherwise from the++scope: a file of the person is about them, a file of a project holds that++project's rules.++++## The secrets++++Nothing leaves the parser without being scrubbed first. The scrubbing runs over++the **whole file body before it is split into items**, and again over every++item. A secret that spans two items — a key block whose BEGIN and END lines++land in different items, an `.env` block in a fence — is only seen by the++whole-file pass. The frontmatter is scrubbed too, before any of its values is++used: a `description` becomes the heading of the item.++++The patterns are the ones of OpenVibe.Codes' harness (`harness/events.js`,++`SECRET_PATTERNS`) plus the common gitleaks shapes and the shapes that turn up++in a person's own files:++++- OpenAI- and Anthropic-style keys (`sk-…`), Stripe keys (`sk_live_…`);++- GitHub tokens (`ghp_…`, `gho_…`, `github_pat_…`), GitLab (`glpat-…`);++- AWS keys (`AKIA…`, `ASIA…`) and `aws_secret_access_key`;++- Slack tokens (`xoxb-…`) and Slack app tokens (`xapp-…`);++- Google (`AIza…`), npm (`npm_…`), Hugging Face (`hf_…`), SendGrid++ (`SG.….…`), Telegram and Discord bot tokens, Twilio keys;++- JWTs;++- **every PEM private key form** — RSA, EC, DSA, OPENSSH, PGP and ENCRYPTED —++ from `-----BEGIN … PRIVATE KEY …-----` to its END line. A key block with++ **no END line** (a truncated file, a cut paste) is redacted from its BEGIN++ line to the end of its fenced block, or to the end of the text when it is++ not in a fence;++- **env-style assignments**: a name of `[A-Za-z0-9_.-]*` that ends in++ `password`, `passwd`, `pwd`, `passphrase`, `secret`, `token`, `api_key`,++ `apikey`, `api-key`, `access_key`, `private_key`, `client_secret`, `auth`,++ `credential`, `credentials`, `cookie` or `session`, followed by `=`, `:` or++ `=>` and a value. The name and the separator stay, the value goes; a quoted++ value keeps its quotes;++- **prose**: "the password is …", "the passphrase was …";++- **headers**: `Authorization: Bearer …`, `Authorization: Basic …`,++ `x-api-key: …`, and a bare `Bearer …`;++- **webhook URLs** of Slack and Discord, whole;++- **card numbers**: 13 to 19 digits, with spaces or dashes, that pass the Luhn++ check. A long number that does not pass it stays: an order number is not a++ card;++- connection strings that carry a password.++++Each match becomes `[redacted]`. The item is marked `redacted` and keeps a++count of how many secrets were removed; the values are never kept. An item++that held nothing but a secret is dropped.++++Over-redaction is preferred over a leak: a harmless `max_tokens: 100000` is++redacted too. It stays bounded — "use 4 spaces for indentation", "the build++takes 12 minutes" and a 10-digit number that fails the Luhn check are left++alone.++++Every adapter's fixture in `internal/memory/testdata` holds one secret of a++different shape, and the test proves that none of them reaches an item. The++Claude Code fixture holds more: a key block split over two items, a key block++with no END line, and an `.env` block.++++## The JSON++++```json++{++ "home": "/home/you",++ "projects": ["/srv/work/demo"],++ "adapters": [++ {++ "id": "claude-code",++ "name": "Claude Code",++ "docs": "https://code.claude.com/docs/en/memory",++ "files": [++ {++ "path": "/home/you/.claude/CLAUDE.md",++ "found": true,++ "items": 3++ },++ {++ "path": "/home/you/.claude/rules/**/*.md",++ "found": false++ }++ ],++ "items": 3++ }++ ],++ "items": [++ {++ "text": "Prefer tabs in Go files",++ "kind": "preference",++ "scope": "user",++ "source": "claude-code",++ "path": "/home/you/.claude/CLAUDE.md",++ "updated": "2026-10-30T15:06:16Z",++ "heading": "Personal setup"++ }++ ]++}++```++++An item may also carry:++++| Field | Meaning |++|---|---|++| `redacted` | the item held a secret that was removed |++| `redactions` | how many secrets were removed |++| `truncated` | the item was longer than 2,000 characters |++++A file that was looked at but could not be used carries a `note`:++`skipped: larger than 256 KB`, `skipped: a binary file`,++`skipped: link leaves the scanned paths`.++++## The safety tests++++`internal/memory` has a test for each of these:++++- no adapter ever points at the home folder, and no glob is wider than the++ tool's own folders;++- a scan without a project reads only the user-level files;++- a symlink out of the scanned paths is not followed;++- a file over 256 KB and a binary file are skipped;++- the package does not import `net/http`: it never opens a network++ connection.nnew file mode 100644@@@ -0,0 +1,535 @@++package memory++++import (++ "encoding/json"++ "os"++ "path/filepath"++ "strings"++)++++// Adapters returns every memory adapter, in a fixed order.++func Adapters() []Adapter {++ return []Adapter{++ claudeCodeAdapter(),++ agentsAdapter(),++ geminiAdapter(),++ cursorAdapter(),++ copilotAdapter(),++ windsurfAdapter(),++ clineAdapter(),++ rooAdapter(),++ kiroAdapter(),++ continueAdapter(),++ aiderAdapter(),++ commandCodeAdapter(),++ openCodeAdapter(),++ }++}++++// indexFilesOf says which files of an adapter are only an index: they hold++// the names of the memories, not the memories themselves.++func indexFilesOf(ad Adapter) []string {++ if ad.ID == "claude-code" {++ return []string{"MEMORY.md"}++ }++ return nil++}++++// parseOptions say how one tool's files are read.++type parseOptions struct {++ // kindFromType reads the kind from the frontmatter "metadata.type".++ kindFromType bool++ // indexFiles are files that are only an index and hold no memory.++ indexFiles []string++}++++// parseMarkdown turns a markdown-like file into items.++//++// The frontmatter is read, not turned into items. Every top-level bullet and++// every paragraph under a heading becomes one item. Secrets are removed from++// every item before it leaves the parser, and an item that was nothing but a++// secret is dropped.++func parseMarkdown(opts parseOptions, path string, data []byte) []Item {++ fm, body := splitFrontmatter(data)++ for _, name := range opts.indexFiles {++ if strings.EqualFold(filepath.Base(path), name) {++ return nil++ }++ }++ // The frontmatter is read, not turned into items, but its values may hold++ // a secret: they are scrubbed before any of them is used. The sentinel++ // marker is used here too, so that a secret in the frontmatter is still++ // counted on the item that carries it.++ for k, v := range fm {++ fm[k], _ = redactWith(v, secretMark)++ }++ // The whole body is scrubbed before it is split into items. A secret that++ // spans two items (a key in a fenced block, an .env block) is only seen++ // here. The marker is a sentinel, so that each item can still say how++ // many secrets it holds.++ body, _ = redactWith(body, secretMark)++ var fileKind Kind++ if opts.kindFromType {++ fileKind = kindFromType(fm)++ }++ var items []Item++ for _, md := range markdownItems(body) {++ // The list marker is not part of the memory.++ text := stripListMarker(md.Text)++ heading := md.Heading++ if fm["description"] != "" {++ // The tool's own name for the rule wins: it is what the++ // person wrote for the tool.++ heading = fm["description"]++ }++ // The body was scrubbed before it was split, so the secrets it held++ // are already marked: the item counts them.++ count := strings.Count(text, secretMark) + strings.Count(heading, secretMark)++ text = strings.ReplaceAll(text, secretMark, Redacted)++ heading = strings.ReplaceAll(heading, secretMark, Redacted)++ // The item is scrubbed again on its own: cheap, and it catches a++ // secret that only appears once the body is split.++ var more int++ text, more = Redact(text)++ heading, hmore := Redact(heading)++ count += more + hmore++ if onlySecret(stripListMarker(text)) {++ // The item was nothing but a secret: it is dropped.++ continue++ }++ kind := fileKind++ if kind == "" {++ // The heading of the section gives the kind; when it says++ // nothing, the scope of the file gives the fallback.++ kind = kindFromHeading(heading, "")++ }++ item := Item{Text: text, Kind: kind, Heading: heading}++ if count > 0 {++ item.Redacted = true++ item.Redactions = count++ }++ if len([]rune(item.Text)) > MaxItemRunes {++ item.Text = string([]rune(item.Text)[:MaxItemRunes]) + "\u2026"++ item.Truncated = true++ }++ items = append(items, item)++ }++ return items++}++++// stripListMarker takes the list marker off an item, so that an item that++// holds nothing but a secret is seen for what it is.++func stripListMarker(text string) string {++ trimmed := strings.TrimSpace(text)++ if m := bulletLine.FindString(trimmed); m != "" {++ trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, m))++ }++ return trimmed++}++++// kindFromType reads the kind of a memory from the frontmatter of an++// auto-memory file. Claude Code writes one of user, feedback, project or++// reference; other tools may already write a kind of ours.++func kindFromType(fm map[string]string) Kind {++ t := strings.ToLower(strings.TrimSpace(fm["metadata.type"]))++ switch t {++ case "":++ return ""++ case "user", "preference", "preferences":++ return KindPreference++ case "feedback", "lesson", "lessons":++ return KindLesson++ case "project", "decision", "decisions":++ return KindDecision++ case "reference", "fact", "facts":++ return KindFact++ case "procedure", "skill":++ return KindProcedure++ case "project_rule":++ return KindProjectRule++ }++ return ""++}++++// claudeCodeAdapter reads what Claude Code remembers.++//++// Docs: https://code.claude.com/docs/en/memory++func claudeCodeAdapter() Adapter {++ return Adapter{++ ID: "claude-code",++ Name: "Claude Code",++ Docs: "https://code.claude.com/docs/en/memory",++ Locations: func(home string, projects []string) []string {++ out := []string{++ filepath.Join(home, ".claude", "CLAUDE.md"),++ filepath.Join(home, ".claude", "rules", "**", "*.md"),++ filepath.Join(home, ".claude", "projects", "*", "memory", "*.md"),++ }++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, "CLAUDE.md"),++ filepath.Join(p, ".claude", "CLAUDE.md"),++ filepath.Join(p, "CLAUDE.local.md"),++ filepath.Join(p, ".claude", "rules", "**", "*.md"),++ )++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{kindFromType: true, indexFiles: []string{"MEMORY.md"}}, path, data)++ },++ }++}++++// agentsAdapter reads the AGENTS.md family.++//++// Docs: https://agents.md/++func agentsAdapter() Adapter {++ return Adapter{++ ID: "agents",++ Name: "AGENTS.md (Codex and friends)",++ Docs: "https://agents.md/",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".codex", "AGENTS.md")}++ for _, p := range projects {++ out = append(out, filepath.Join(p, "AGENTS.md"))++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// geminiAdapter reads what Gemini CLI remembers.++//++// Docs: https://geminicli.com/docs/cli/gemini-md/++func geminiAdapter() Adapter {++ return Adapter{++ ID: "gemini",++ Name: "Gemini CLI",++ Docs: "https://geminicli.com/docs/cli/gemini-md/",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".gemini", "GEMINI.md")}++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, "GEMINI.md"),++ filepath.Join(p, ".gemini", "GEMINI.md"),++ )++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// cursorAdapter reads the Cursor rules.++//++// Docs: https://cursor.com/docs/context/rules++func cursorAdapter() Adapter {++ return Adapter{++ ID: "cursor",++ Name: "Cursor",++ Docs: "https://cursor.com/docs/context/rules",++ Locations: func(home string, projects []string) []string {++ out := []string{}++ for _, p := range projects {++ out = append(out, filepath.Join(p, ".cursor", "rules", "*.mdc"))++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// copilotAdapter reads the GitHub Copilot instructions.++//++// Docs: https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot++func copilotAdapter() Adapter {++ return Adapter{++ ID: "copilot",++ Name: "GitHub Copilot",++ Docs: "https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot",++ Locations: func(home string, projects []string) []string {++ out := []string{}++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, ".github", "copilot-instructions.md"),++ filepath.Join(p, ".github", "instructions", "*.instructions.md"),++ )++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// windsurfAdapter reads the Windsurf rules and memories.++//++// Docs: https://docs.windsurf.com/windsurf/cascade/memories++func windsurfAdapter() Adapter {++ return Adapter{++ ID: "windsurf",++ Name: "Windsurf",++ Docs: "https://docs.windsurf.com/windsurf/cascade/memories",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".codeium", "windsurf", "memories", "**")}++ for _, p := range projects {++ out = append(out, filepath.Join(p, ".windsurf", "rules", "**"))++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// clineAdapter reads the Cline rules and the memory bank.++//++// Docs: https://docs.cline.bot/features/cline-rules++func clineAdapter() Adapter {++ return Adapter{++ ID: "cline",++ Name: "Cline",++ Docs: "https://docs.cline.bot/features/cline-rules",++ Locations: func(home string, projects []string) []string {++ out := []string{}++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, ".clinerules"),++ filepath.Join(p, ".clinerules", "**"),++ filepath.Join(p, "memory-bank", "*.md"),++ )++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// rooAdapter reads the Roo rules and the memory bank.++//++// Docs: https://docs.roocode.com/features/custom-instructions++func rooAdapter() Adapter {++ return Adapter{++ ID: "roo",++ Name: "Roo Code",++ Docs: "https://docs.roocode.com/features/custom-instructions",++ Locations: func(home string, projects []string) []string {++ out := []string{}++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, ".roo", "rules", "**"),++ filepath.Join(p, "memory-bank", "*.md"),++ )++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// kiroAdapter reads the Kiro steering files.++//++// Docs: https://kiro.dev/docs/steering/++func kiroAdapter() Adapter {++ return Adapter{++ ID: "kiro",++ Name: "Kiro",++ Docs: "https://kiro.dev/docs/steering/",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".kiro", "steering", "*.md")}++ for _, p := range projects {++ out = append(out, filepath.Join(p, ".kiro", "steering", "*.md"))++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// continueAdapter reads the Continue rules.++//++// Docs: https://docs.continue.dev/customize/rules++func continueAdapter() Adapter {++ return Adapter{++ ID: "continue",++ Name: "Continue",++ Docs: "https://docs.continue.dev/customize/rules",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".continue", "rules", "**")}++ for _, p := range projects {++ out = append(out, filepath.Join(p, ".continue", "rules", "**"))++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// aiderAdapter reads the Aider conventions file and the files its config++// names. It never reads the chat history.++//++// Docs: https://aider.chat/docs/usage/conventions.html++func aiderAdapter() Adapter {++ return Adapter{++ ID: "aider",++ Name: "Aider",++ Docs: "https://aider.chat/docs/usage/conventions.html",++ Locations: func(home string, projects []string) []string {++ out := []string{}++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, "CONVENTIONS.md"),++ filepath.Join(p, ".aider.conf.yml"),++ )++ out = append(out, readListFromConfig(filepath.Join(p, ".aider.conf.yml"), p)...)++ }++ out = append(out,++ filepath.Join(home, "CONVENTIONS.md"),++ filepath.Join(home, ".aider.conf.yml"),++ )++ out = append(out, readListFromConfig(filepath.Join(home, ".aider.conf.yml"), home)...)++ return out++ },++ Parse: func(path string, data []byte) []Item {++ if strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml") {++ // The config itself is not memory; only the files it names are.++ return nil++ }++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// commandCodeAdapter reads the Command Code memory folder.++//++// Docs: https://commandcode.ai/docs/memory++func commandCodeAdapter() Adapter {++ return Adapter{++ ID: "command-code",++ Name: "Command Code",++ Docs: "https://commandcode.ai/docs/memory",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".commandcode", "**")}++ for _, p := range projects {++ out = append(out, filepath.Join(p, ".commandcode", "**"))++ }++ return out++ },++ Parse: func(path string, data []byte) []Item {++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// openCodeAdapter reads the OpenCode AGENTS.md and the instructions its++// config names.++//++// Docs: https://opencode.ai/docs/rules/++func openCodeAdapter() Adapter {++ return Adapter{++ ID: "opencode",++ Name: "OpenCode",++ Docs: "https://opencode.ai/docs/rules/",++ Locations: func(home string, projects []string) []string {++ out := []string{filepath.Join(home, ".config", "opencode", "AGENTS.md")}++ for _, p := range projects {++ out = append(out,++ filepath.Join(p, "AGENTS.md"),++ filepath.Join(p, "opencode.json"),++ )++ out = append(out, instructionsFromConfig(filepath.Join(p, "opencode.json"), p)...)++ }++ globalConfig := filepath.Join(home, ".config", "opencode", "opencode.json")++ out = append(out, globalConfig)++ out = append(out, instructionsFromConfig(globalConfig, filepath.Dir(globalConfig))...)++ return out++ },++ Parse: func(path string, data []byte) []Item {++ if strings.HasSuffix(path, ".json") {++ // The config itself is not memory; only the files it names are.++ return nil++ }++ return parseMarkdown(parseOptions{}, path, data)++ },++ }++}++++// readListFromConfig returns the files an Aider config names under "read:".++func readListFromConfig(configPath, base string) []string {++ data, err := os.ReadFile(configPath)++ if err != nil {++ return nil++ }++ var out []string++ inRead := false++ for _, line := range strings.Split(string(data), "\n") {++ trimmed := strings.TrimSpace(line)++ if trimmed == "" || strings.HasPrefix(trimmed, "#") {++ continue++ }++ indented := strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")++ if !indented {++ // A new key: only "read:" names memory files.++ inRead = strings.HasPrefix(trimmed, "read:")++ continue++ }++ if !inRead || !strings.HasPrefix(trimmed, "- ") {++ continue++ }++ value := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))++ value = strings.Trim(value, "\"'")++ if value == "" {++ continue++ }++ if !filepath.IsAbs(value) {++ value = filepath.Join(base, value)++ }++ out = append(out, filepath.Clean(value))++ }++ return out++}++++// instructionsFromConfig returns the instruction files an OpenCode config++// names under "instructions".++func instructionsFromConfig(configPath, base string) []string {++ data, err := os.ReadFile(configPath)++ if err != nil {++ return nil++ }++ var cfg struct {++ Instructions []string `json:"instructions"`++ }++ if err := json.Unmarshal(data, &cfg); err != nil {++ return nil++ }++ var out []string++ for _, v := range cfg.Instructions {++ v = strings.TrimSpace(v)++ if v == "" {++ continue++ }++ if !filepath.IsAbs(v) {++ v = filepath.Join(base, v)++ }++ out = append(out, filepath.Clean(v))++ }++ return out++}nnew file mode 100644@@@ -0,0 +1,120 @@++package memory++++import (++ "fmt"++ "os"++ "path/filepath"++ "sort"++ "strings"++)++++// maxGlobDepth bounds how deep a glob walks below its fixed base directory.++const maxGlobDepth = 8++++// expandGlob expands a narrow glob pattern.++//++// The pattern must start with a fixed directory: the part before the first++// wildcard is the base, and only that directory is walked. A pattern that++// starts with a wildcard is refused, so a glob can never widen into a whole++// home folder.++func expandGlob(pattern string) ([]string, error) {++ parts := strings.Split(filepath.ToSlash(pattern), "/")++ base := 0++ for base < len(parts) && !hasMeta(parts[base]) {++ base++++ }++ if base == 0 || base == len(parts) {++ return nil, fmt.Errorf("memory: refusing to walk the wide glob %q", pattern)++ }++ root := strings.Join(parts[:base], "/")++ if root == "" {++ return nil, fmt.Errorf("memory: refusing to walk the wide glob %q", pattern)++ }++ var out []string++ walkGlob(root, parts[base:], 0, &out)++ sort.Strings(out)++ return out, nil++}++++// hasMeta tells whether a path segment holds a wildcard.++func hasMeta(part string) bool {++ return strings.ContainsAny(part, "*?[")++}++++// walkGlob matches the rest of a pattern below a directory.++func walkGlob(dir string, parts []string, depth int, out *[]string) {++ if depth > maxGlobDepth {++ return++ }++ if len(parts) == 0 {++ *out = append(*out, dir)++ return++ }++ part := parts[0]++ if part == "**" {++ if len(parts) == 1 {++ // Everything below the directory.++ collectFiles(dir, depth+1, out)++ return++ }++ // Zero directories, then one directory at a time.++ walkGlob(dir, parts[1:], depth+1, out)++ entries, err := os.ReadDir(dir)++ if err != nil {++ return++ }++ for _, e := range entries {++ if !e.IsDir() {++ continue++ }++ walkGlob(filepath.Join(dir, e.Name()), parts, depth+1, out)++ }++ return++ }++ entries, err := os.ReadDir(dir)++ if err != nil {++ return++ }++ for _, e := range entries {++ name := e.Name()++ if !matchSegment(part, name) {++ continue++ }++ full := filepath.Join(dir, name)++ if len(parts) == 1 {++ if e.IsDir() {++ continue++ }++ *out = append(*out, full)++ continue++ }++ if e.IsDir() {++ walkGlob(full, parts[1:], depth+1, out)++ }++ }++}++++// collectFiles lists every regular file below a directory.++func collectFiles(dir string, depth int, out *[]string) {++ if depth > maxGlobDepth {++ return++ }++ entries, err := os.ReadDir(dir)++ if err != nil {++ return++ }++ for _, e := range entries {++ full := filepath.Join(dir, e.Name())++ if e.IsDir() {++ collectFiles(full, depth+1, out)++ continue++ }++ *out = append(*out, full)++ }++}++++// matchSegment matches one path segment against a pattern with * and ?.++func matchSegment(pattern, name string) bool {++ ok, err := filepath.Match(pattern, name)++ return err == nil && ok++}nnew file mode 100644@@@ -0,0 +1,168 @@++package memory++++import (++ "regexp"++ "strings"++)++++// headingLine matches a markdown heading.++var headingLine = regexp.MustCompile("^#{1,6}[ \t]+(.+)$")++++// bulletLine matches a list item, ordered or not.++var bulletLine = regexp.MustCompile("^(?:[-*+][ \t]+|[0-9]+[.)][ \t]+)")++++// mdItem is a piece of markdown before it becomes an Item.++type mdItem struct {++ Heading string++ Text string++}++++// splitFrontmatter takes the YAML-ish frontmatter off a markdown file and++// returns it as flat keys ("name", "description", "metadata.type", "globs")++// with the rest of the file.++func splitFrontmatter(data []byte) (map[string]string, string) {++ s := strings.ReplaceAll(string(data), "\r\n", "\n")++ s = strings.TrimPrefix(s, "\ufeff")++ if !strings.HasPrefix(s, "---\n") {++ return nil, s++ }++ rest := s[4:]++ end := strings.Index(rest, "\n---")++ if end < 0 {++ return nil, s++ }++ head := rest[:end]++ tail := strings.TrimPrefix(rest[end+4:], "\n")++ return parseFrontmatter(strings.Split(head, "\n")), tail++}++++// parseFrontmatter reads the small subset of YAML these files use: scalar++// keys, one level of nested keys ("metadata:" then "type:") and lists of++// strings.++func parseFrontmatter(lines []string) map[string]string {++ fm := map[string]string{}++ parent := ""++ parentIndent := -1++ for _, line := range lines {++ trimmed := strings.TrimSpace(line)++ if trimmed == "" || strings.HasPrefix(trimmed, "#") {++ continue++ }++ indent := len(line) - len(strings.TrimLeft(line, " \t"))++ if strings.HasPrefix(trimmed, "- ") {++ value := strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")), "\"'")++ if parent != "" {++ if fm[parent] == "" {++ fm[parent] = value++ } else {++ fm[parent] += ", " + value++ }++ }++ continue++ }++ idx := strings.Index(trimmed, ":")++ if idx <= 0 {++ continue++ }++ key := strings.TrimSpace(trimmed[:idx])++ value := strings.Trim(strings.TrimSpace(trimmed[idx+1:]), "\"'")++ if parent != "" && indent > parentIndent {++ fm[parent+"."+key] = value++ continue++ }++ parent, parentIndent = "", -1++ if value == "" {++ parent, parentIndent = key, indent++ if _, ok := fm[key]; !ok {++ fm[key] = ""++ }++ continue++ }++ fm[key] = value++ }++ return fm++}++++// markdownItems splits markdown into items: one per top-level bullet and one++// per paragraph under a heading. The heading each item came from is kept.++//++// A bullet is one item; the lines indented under it belong to it. A paragraph++// is the run of text between two bullets.++func markdownItems(body string) []mdItem {++ var out []mdItem++ heading := ""++ var buf []string++ mode := 0 // 0: none, 1: bullet, 2: paragraph++++ flush := func() {++ if len(buf) == 0 {++ mode = 0++ return++ }++ text := strings.TrimSpace(strings.Join(buf, "\n"))++ buf = nil++ mode = 0++ if text == "" {++ return++ }++ out = append(out, mdItem{Heading: heading, Text: text})++ }++++ for _, line := range strings.Split(body, "\n") {++ trimmed := strings.TrimSpace(line)++ if m := headingLine.FindStringSubmatch(trimmed); m != nil {++ flush()++ heading = strings.TrimSpace(m[1])++ continue++ }++ if trimmed == "" {++ flush()++ continue++ }++ indented := line != trimmed++ if bulletLine.MatchString(trimmed) {++ // A new bullet is a new item.++ flush()++ mode = 1++ buf = append(buf, trimmed)++ continue++ }++ if mode == 1 && indented {++ // A line under the bullet belongs to it.++ buf = append(buf, trimmed)++ continue++ }++ if mode == 1 {++ // Plain text after a bullet starts a new paragraph.++ flush()++ }++ if mode != 2 {++ flush()++ mode = 2++ }++ buf = append(buf, trimmed)++ }++ flush()++ return out++}++++// kindFromHeading guesses the kind of an item from the section it came from,++// falling back to the kind of the adapter's file.++func kindFromHeading(heading string, fallback Kind) Kind {++ h := strings.ToLower(heading)++ switch {++ case strings.Contains(h, "preference"), strings.Contains(h, "prefer"):++ return KindPreference++ case strings.Contains(h, "lesson"), strings.Contains(h, "learned"), strings.Contains(h, "mistake"), strings.Contains(h, "gotcha"):++ return KindLesson++ case strings.Contains(h, "decision"), strings.Contains(h, "rationale"), strings.Contains(h, "adr"):++ return KindDecision++ case strings.Contains(h, "procedure"), strings.Contains(h, "workflow"), strings.Contains(h, "how to"), strings.Contains(h, "process"), strings.Contains(h, "steps"):++ return KindProcedure++ case strings.Contains(h, "fact"), strings.Contains(h, "profile"):++ return KindFact++ case strings.Contains(h, "rule"), strings.Contains(h, "convention"), strings.Contains(h, "guideline"), strings.Contains(h, "standard"), strings.Contains(h, "policy"):++ return KindProjectRule++ }++ return fallback++}nnew file mode 100644@@@ -0,0 +1,80 @@++// Package memory reads the memory files of the coding tools on this machine and++// turns them into memory items.++//++// It is read-only and local: it opens the files it knows about, parses them and++// redacts secrets before anything is printed. It never writes a file, never++// opens a network connection and never follows a symlink out of the path it was++// given.++package memory++++// Kind is what a memory item is about.++type Kind string++++const (++ // KindPreference is how the person wants things done.++ KindPreference Kind = "preference"++ // KindFact is a fact about the person or the project.++ KindFact Kind = "fact"++ // KindProjectRule is a rule that holds in a project.++ KindProjectRule Kind = "project_rule"++ // KindProcedure is a way of doing something.++ KindProcedure Kind = "procedure"++ // KindDecision is a decision that was taken, with its reason.++ KindDecision Kind = "decision"++ // KindLesson is something that was learned the hard way.++ KindLesson Kind = "lesson"++)++++// Scope is where an item applies: the person (ScopeUser) or a project++// (ScopeProject + the absolute path of the project).++type Scope string++++// ScopeUser is the scope of an item that holds for the person everywhere.++const ScopeUser Scope = "user"++++// ScopeProject returns the scope of an item that holds in a project.++func ScopeProject(absProjectPath string) Scope {++ return Scope("project:" + absProjectPath)++}++++// Item is one piece of memory found in a file.++type Item struct {++ // Text is the item, already redacted.++ Text string `json:"text"`++ // Kind is what the item is about.++ Kind Kind `json:"kind"`++ // Scope is where the item applies.++ Scope Scope `json:"scope"`++ // Source is the id of the adapter that found the item.++ Source string `json:"source"`++ // Path is the file the item came from.++ Path string `json:"path"`++ // Updated is the mtime of that file, RFC 3339.++ Updated string `json:"updated"`++ // Heading is the section the item came from, when the file had one.++ Heading string `json:"heading,omitempty"`++ // Redacted is true when the text held a secret that was removed.++ Redacted bool `json:"redacted,omitempty"`++ // Redactions is how many secrets were removed from the item.++ Redactions int `json:"redactions,omitempty"`++ // Truncated is true when the item was longer than MaxItemRunes.++ Truncated bool `json:"truncated,omitempty"`++}++++// Adapter is one source of memory: a tool, where it keeps its memory and how++// to read it.++type Adapter struct {++ // ID is the stable name of the adapter, as used by --tools.++ ID string++ // Name is how the tool is called.++ Name string++ // Docs is the URL of the tool's own documentation for these paths.++ Docs string++ // Locations returns the exact known paths and the narrow globs to look at,++ // for a home directory and the project directories the person gave.++ // It never returns the home directory itself and never a glob wider than++ // the tool's own folders.++ Locations func(home string, projects []string) []string++ // Parse turns one file into items. The items it returns are redacted.++ Parse func(path string, data []byte) []Item++}nnew file mode 100644@@@ -0,0 +1,734 @@++package memory++++import (++ "go/parser"++ "go/token"++ "os"++ "path/filepath"++ "regexp"++ "runtime"++ "strings"++ "testing"++)++++// fixture gives the home and the project directory of one adapter's fixture.++func fixture(t *testing.T, id string) (home string, project string) {++ t.Helper()++ base := filepath.Join("testdata", id)++ home = filepath.Join(base, "home")++ project = filepath.Join(base, "project")++ return home, project++}++++// adapterByID finds an adapter.++func adapterByID(t *testing.T, id string) Adapter {++ t.Helper()++ for _, ad := range Adapters() {++ if ad.ID == id {++ return ad++ }++ }++ t.Fatalf("no adapter %q", id)++ return Adapter{}++}++++// scanOf runs a scan over one adapter's fixture.++func scanOf(t *testing.T, id string) Result {++ t.Helper()++ home, project := fixture(t, id)++ return Scan(Options{Home: home, Projects: []string{project}, Tools: []string{id}})++}++++// itemsOf returns the items of the files whose path holds a marker.++func itemsOf(res Result, marker string) []Item {++ var out []Item++ for _, it := range res.Items {++ if strings.Contains(it.Path, marker) {++ out = append(out, it)++ }++ }++ return out++}++++// secretLike finds the parts of a text that look like a secret.++//++// The test uses it so that it never has to hold a secret value itself: it++// takes the secrets of a fixture out of the fixture and checks that none of++// them reaches an item.++var secretLike = regexp.MustCompile("(?i)(?:\\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}|\\bgh[pousr]_[A-Za-z0-9]{20,}|\\bgithub_pat_[A-Za-z0-9_]{20,}|\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|\\b(?:AKIA|ASIA)[0-9A-Z]{16}|\\bxox[baprs]-[A-Za-z0-9-]{10,}|\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}|\\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|token|password|secret|aws_secret_access_key)\\b[ \\t]*[:=][ \\t]*[^\\s\"']{6,}|\\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@]+:[^\\s@/]{6,}@|-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----)")++++// secretsOf returns the secret-looking parts of a text.++func secretsOf(text string) []string {++ var out []string++ for _, m := range secretLike.FindAllString(text, -1) {++ out = append(out, m)++ }++ return out++}++++// TestEveryAdapterHasDocsAndID checks the shape of the adapter list.++func TestEveryAdapterHasDocsAndID(t *testing.T) {++ seen := map[string]bool{}++ for _, ad := range Adapters() {++ if ad.ID == "" || ad.Name == "" {++ t.Errorf("an adapter has no id or name: %+v", ad)++ }++ if seen[ad.ID] {++ t.Errorf("two adapters share the id %q", ad.ID)++ }++ seen[ad.ID] = true++ if !strings.HasPrefix(ad.Docs, "https://") {++ t.Errorf("adapter %q has no docs link: %q", ad.ID, ad.Docs)++ }++ if ad.Locations == nil || ad.Parse == nil {++ t.Errorf("adapter %q has no locations or no parser", ad.ID)++ }++ }++}++++// TestLocationsAreNarrow checks that no adapter ever points at the home++// folder itself and that no glob is wider than the tool's own folders.++func TestLocationsAreNarrow(t *testing.T) {++ home := filepath.Join("/home", "person")++ project := filepath.Join("/srv", "work", "demo")++ for _, ad := range Adapters() {++ for _, loc := range ad.Locations(home, []string{project}) {++ if loc == home || loc == project {++ t.Errorf("adapter %q points at a whole folder: %q", ad.ID, loc)++ }++ if !strings.HasPrefix(loc, home) && !strings.HasPrefix(loc, project) {++ t.Errorf("adapter %q points outside the home and the project: %q", ad.ID, loc)++ }++ // A glob must never start with a wildcard: it would walk the++ // whole home folder.++ rel, err := filepath.Rel(home, loc)++ if err == nil && !strings.HasPrefix(rel, "..") {++ first := strings.Split(filepath.ToSlash(rel), "/")++ if len(first) > 0 && strings.ContainsAny(first[0], "*?[") {++ t.Errorf("adapter %q starts a glob at the home folder: %q", ad.ID, loc)++ }++ }++ // Every glob must stay inside a folder of the tool.++ if strings.ContainsAny(loc, "*?[") {++ toolFolder := strings.Contains(loc, filepath.Join(".claude", "")) ||++ strings.Contains(loc, filepath.Join(".codex", "")) ||++ strings.Contains(loc, filepath.Join(".gemini", "")) ||++ strings.Contains(loc, filepath.Join(".cursor", "")) ||++ strings.Contains(loc, filepath.Join(".github", "")) ||++ strings.Contains(loc, filepath.Join(".windsurf", "")) ||++ strings.Contains(loc, filepath.Join(".codeium", "")) ||++ strings.Contains(loc, filepath.Join(".clinerules", "")) ||++ strings.Contains(loc, filepath.Join(".roo", "")) ||++ strings.Contains(loc, filepath.Join(".kiro", "")) ||++ strings.Contains(loc, filepath.Join(".continue", "")) ||++ strings.Contains(loc, filepath.Join(".commandcode", "")) ||++ strings.Contains(loc, filepath.Join("memory-bank", "")) ||++ strings.Contains(loc, filepath.Join("memory", ""))++ if !toolFolder {++ t.Errorf("adapter %q has a glob outside the tool's own folders: %q", ad.ID, loc)++ }++ }++ }++ }++}++++// TestUserLevelOnlyWithoutProject checks that a scan without a project reads++// only the user's own files.++func TestUserLevelOnlyWithoutProject(t *testing.T) {++ home, _ := fixture(t, "claude-code")++ res := Scan(Options{Home: home})++ for _, it := range res.Items {++ if it.Scope != ScopeUser {++ t.Errorf("item %q has the scope %q without a project", it.Text, it.Scope)++ }++ }++ for _, ad := range res.Adapters {++ for _, f := range ad.Files {++ if strings.Contains(f.Path, filepath.Join("testdata", "claude-code", "project")) {++ t.Errorf("the scan read a project file without a project: %s", f.Path)++ }++ }++ }++}++++// TestClaudeCodeParsing checks the items of the Claude Code fixture.++func TestClaudeCodeParsing(t *testing.T) {++ res := scanOf(t, "claude-code")++++ user := itemsOf(res, filepath.Join("home", ".claude", "CLAUDE.md"))++ if len(user) != 3 {++ t.Fatalf("the user CLAUDE.md gave %d items, want 3: %+v", len(user), user)++ }++ if user[0].Heading != "Personal setup" {++ t.Errorf("the first item has the heading %q", user[0].Heading)++ }++ if user[0].Kind != KindPreference {++ t.Errorf("a user file item has the kind %q, want a preference", user[0].Kind)++ }++ if user[0].Scope != ScopeUser {++ t.Errorf("a user file item has the scope %q", user[0].Scope)++ }++++ // The secret is gone.++ if !user[2].Redacted {++ t.Errorf("the item with a secret is not marked redacted: %+v", user[2])++ }++ if user[2].Redactions == 0 {++ t.Errorf("the item with a secret counts no redaction: %+v", user[2])++ }++ if strings.Contains(user[2].Text, "sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8") {++ t.Errorf("the secret is still in the item: %q", user[2].Text)++ }++++ // The auto memory: the kind comes from the frontmatter, and the index is++ // skipped.++ auto := itemsOf(res, filepath.Join("memory", "user_role.md"))++ if len(auto) != 1 {++ t.Fatalf("the auto memory gave %d items, want 1: %+v", len(auto), auto)++ }++ if auto[0].Kind != KindPreference {++ t.Errorf("the auto memory item has the kind %q", auto[0].Kind)++ }++ feedback := itemsOf(res, filepath.Join("memory", "feedback_testing.md"))++ if len(feedback) != 1 || feedback[0].Kind != KindLesson {++ t.Errorf("the feedback memory gave %+v", feedback)++ }++ if idx := itemsOf(res, "MEMORY.md"); len(idx) != 0 {++ t.Errorf("the MEMORY.md index gave items: %+v", idx)++ }++++ // The project files: the scope is the project and the kind is a rule.++ proj := itemsOf(res, filepath.Join("claude-code", "project"))++ if len(proj) == 0 {++ t.Fatal("the project files gave no items")++ }++ for _, it := range proj {++ if !strings.HasPrefix(string(it.Scope), "project:") {++ t.Errorf("a project item has the scope %q", it.Scope)++ }++ if it.Kind != KindProjectRule {++ t.Errorf("a project item has the kind %q: %q", it.Kind, it.Text)++ }++ }++}++++// TestAgentsParsing checks the AGENTS.md family.++func TestAgentsParsing(t *testing.T) {++ res := scanOf(t, "agents")++ if len(res.Items) == 0 {++ t.Fatal("the agents fixture gave no items")++ }++ for _, it := range res.Items {++ if it.Source != "agents" {++ t.Errorf("the item has the source %q", it.Source)++ }++ if it.Updated == "" {++ t.Errorf("the item has no date: %+v", it)++ }++ }++ proj := itemsOf(res, filepath.Join("agents", "project"))++ if len(proj) != 3 {++ t.Errorf("the project AGENTS.md gave %d items, want 3: %+v", len(proj), proj)++ }++ if proj[2].Kind != KindDecision {++ t.Errorf("the item under the Decision heading has the kind %q", proj[2].Kind)++ }++}++++// TestGeminiParsing checks the Gemini CLI fixture.++func TestGeminiParsing(t *testing.T) {++ res := scanOf(t, "gemini")++ user := itemsOf(res, filepath.Join("home", ".gemini", "GEMINI.md"))++ if len(user) != 3 {++ t.Fatalf("the global GEMINI.md gave %d items, want 3: %+v", len(user), user)++ }++ if !user[2].Redacted {++ t.Errorf("the item with the AWS key is not marked redacted: %+v", user[2])++ }++ if strings.Contains(user[2].Text, "AKIAIOSFODNN7EXAMPLE") {++ t.Errorf("the AWS key is still in the item: %q", user[2].Text)++ }++}++++// TestCursorParsing checks the Cursor rules and their frontmatter.++func TestCursorParsing(t *testing.T) {++ res := scanOf(t, "cursor")++ items := itemsOf(res, "style.mdc")++ if len(items) != 3 {++ t.Fatalf("the style rule gave %d items, want 3: %+v", len(items), items)++ }++ if items[0].Heading != "How we write code here" {++ t.Errorf("the rule has the heading %q, want the frontmatter description", items[0].Heading)++ }++ if !items[2].Redacted {++ t.Errorf("the item with the Slack token is not marked redacted: %+v", items[2])++ }++ if strings.Contains(items[2].Text, "xoxb-") {++ t.Errorf("the Slack token is still in the item: %q", items[2].Text)++ }++ // The frontmatter is read, not turned into items.++ for _, it := range items {++ if strings.Contains(it.Text, "alwaysApply") {++ t.Errorf("the frontmatter became an item: %q", it.Text)++ }++ }++}++++// TestCopilotParsing checks the GitHub Copilot instructions.++func TestCopilotParsing(t *testing.T) {++ res := scanOf(t, "copilot")++ items := itemsOf(res, "copilot-instructions.md")++ if len(items) != 3 {++ t.Fatalf("the repository instructions gave %d items, want 3: %+v", len(items), items)++ }++ if !items[2].Redacted {++ t.Errorf("the item with the JWT is not marked redacted: %+v", items[2])++ }++ if strings.Contains(items[2].Text, "eyJhbGciOiJIUzI1NiI") {++ t.Errorf("the JWT is still in the item: %q", items[2].Text)++ }++ inst := itemsOf(res, "tests.instructions.md")++ if len(inst) != 1 {++ t.Fatalf("the test instructions gave %d items, want 1: %+v", len(inst), inst)++ }++}++++// TestWindsurfParsing checks the Windsurf rules and memories.++func TestWindsurfParsing(t *testing.T) {++ res := scanOf(t, "windsurf")++ mem := itemsOf(res, filepath.Join("memories", "preferences.md"))++ if len(mem) != 3 {++ t.Fatalf("the memories gave %d items, want 3: %+v", len(mem), mem)++ }++ // The private key is one item of its own and is dropped.++ for _, it := range mem {++ if strings.Contains(it.Text, "PRIVATE KEY") {++ t.Errorf("the private key is still in an item: %q", it.Text)++ }++ }++ rules := itemsOf(res, filepath.Join(".windsurf", "rules"))++ if len(rules) != 1 {++ t.Fatalf("the rules gave %d items, want 1: %+v", len(rules), rules)++ }++}++++// TestClineAndRooParsing checks the Cline and Roo fixtures.++func TestClineAndRooParsing(t *testing.T) {++ res := scanOf(t, "cline")++ rules := itemsOf(res, ".clinerules")++ if len(rules) != 2 {++ t.Fatalf("the cline rules gave %d items, want 2: %+v", len(rules), rules)++ }++ if !rules[1].Redacted {++ t.Errorf("the item with the password is not marked redacted: %+v", rules[1])++ }++ if strings.Contains(rules[1].Text, "Sup3rSecretFakeValue") {++ t.Errorf("the password is still in the item: %q", rules[1].Text)++ }++ bank := itemsOf(res, "projectbrief.md")++ if len(bank) != 1 {++ t.Fatalf("the memory bank gave %d items, want 1: %+v", len(bank), bank)++ }++++ roo := scanOf(t, "roo")++ rr := itemsOf(roo, filepath.Join(".roo", "rules"))++ if len(rr) != 2 {++ t.Fatalf("the roo rules gave %d items, want 2: %+v", len(rr), rr)++ }++ if !rr[1].Redacted {++ t.Errorf("the item with the Stripe key is not marked redacted: %+v", rr[1])++ }++ if strings.Contains(rr[1].Text, "sk_live_") {++ t.Errorf("the Stripe key is still in the item: %q", rr[1].Text)++ }++}++++// TestKiroParsing checks the Kiro steering files.++func TestKiroParsing(t *testing.T) {++ res := scanOf(t, "kiro")++ tech := itemsOf(res, "tech.md")++ if len(tech) != 3 {++ t.Fatalf("the steering file gave %d items, want 3: %+v", len(tech), tech)++ }++ if !tech[2].Redacted {++ t.Errorf("the item with the connection string is not marked redacted: %+v", tech[2])++ }++ if strings.Contains(tech[2].Text, "hunter2fake") {++ t.Errorf("the password is still in the item: %q", tech[2].Text)++ }++ if !strings.Contains(tech[2].Text, "postgres://dbuser:") {++ t.Errorf("the connection string lost its shape: %q", tech[2].Text)++ }++}++++// TestContinueParsing checks the Continue rules.++func TestContinueParsing(t *testing.T) {++ res := scanOf(t, "continue")++ items := itemsOf(res, "style.md")++ if len(items) != 2 {++ t.Fatalf("the rules gave %d items, want 2: %+v", len(items), items)++ }++ if !items[1].Redacted {++ t.Errorf("the item with the token is not marked redacted: %+v", items[1])++ }++}++++// TestAiderParsing checks the Aider conventions and the files its config++// names, and that the chat history is never read.++func TestAiderParsing(t *testing.T) {++ res := scanOf(t, "aider")++ conv := itemsOf(res, "CONVENTIONS.md")++ if len(conv) != 2 {++ t.Fatalf("the conventions gave %d items, want 2: %+v", len(conv), conv)++ }++ if !conv[1].Redacted {++ t.Errorf("the item with the password is not marked redacted: %+v", conv[1])++ }++ read := itemsOf(res, "README.md")++ if len(read) != 1 {++ t.Fatalf("the file named by the config gave %d items, want 1: %+v", len(read), read)++ }++ notes := itemsOf(res, "NOTES.md")++ if len(notes) != 1 {++ t.Fatalf("the file named by the global config gave %d items, want 1: %+v", len(notes), notes)++ }++ for _, f := range res.Adapters[0].Files {++ if strings.Contains(f.Path, "chat.history") {++ t.Errorf("the chat history was read: %s", f.Path)++ }++ }++}++++// TestCommandCodeParsing checks the Command Code memory folder.++func TestCommandCodeParsing(t *testing.T) {++ res := scanOf(t, "command-code")++ items := itemsOf(res, filepath.Join("project", ".commandcode"))++ if len(items) != 2 {++ t.Fatalf("the command code memory gave %d items, want 2: %+v", len(items), items)++ }++ if !items[1].Redacted {++ t.Errorf("the item with the token is not marked redacted: %+v", items[1])++ }++ if strings.Contains(items[1].Text, "github_pat_") {++ t.Errorf("the token is still in the item: %q", items[1].Text)++ }++}++++// TestOpenCodeParsing checks the OpenCode AGENTS.md and the instructions its++// config names.++func TestOpenCodeParsing(t *testing.T) {++ res := scanOf(t, "opencode")++ agents := itemsOf(res, filepath.Join("opencode", "project", "AGENTS.md"))++ if len(agents) != 1 {++ t.Fatalf("the AGENTS.md gave %d items, want 1: %+v", len(agents), agents)++ }++ conv := itemsOf(res, "conventions.md")++ if len(conv) != 2 {++ t.Fatalf("the file named by the config gave %d items, want 2: %+v", len(conv), conv)++ }++ if !conv[1].Redacted {++ t.Errorf("the item with the aws key is not marked redacted: %+v", conv[1])++ }++ if strings.Contains(conv[1].Text, "wJalrXUtnFEMI") {++ t.Errorf("the aws key is still in the item: %q", conv[1].Text)++ }++}++++// TestNoSecretSurvivesInAnyFixture checks that no fixture's secret shows up++// in any item of any adapter.++func TestNoSecretSurvivesInAnyFixture(t *testing.T) {++ for _, id := range []string{++ "claude-code", "agents", "gemini", "cursor", "copilot", "windsurf",++ "cline", "roo", "kiro", "continue", "aider", "command-code", "opencode",++ } {++ res := scanOf(t, id)++ if len(res.Items) == 0 {++ t.Errorf("the %q fixture gave no items", id)++ }++ // Every fixture holds at least one secret of its own shape.++ found := false++ err := filepath.Walk(filepath.Join("testdata", id), func(path string, info os.FileInfo, err error) error {++ if err != nil || info.IsDir() {++ return nil++ }++ data, err := os.ReadFile(path)++ if err != nil {++ return nil++ }++ if len(secretsOf(string(data))) > 0 {++ found = true++ }++ return nil++ })++ if err != nil {++ t.Fatal(err)++ }++ if !found {++ t.Errorf("the %q fixture holds no secret", id)++ }++ for _, it := range res.Items {++ for _, sec := range secretsOf(strings.ReplaceAll(it.Text, Redacted, "")) {++ t.Errorf("adapter %q still shows the secret %q in %q", id, sec, it.Text)++ }++ }++ }++}++++// TestAnItemThatIsOnlyASecretIsDropped checks that an item that held nothing++// but a secret is dropped, not kept with an empty text.++func TestAnItemThatIsOnlyASecretIsDropped(t *testing.T) {++ secret := "sk-proj-" + strings.Repeat("A", 40)++ items := parseMarkdown(parseOptions{}, "x.md", []byte("- "+secret+"\n"))++ if len(items) != 0 {++ t.Errorf("an item that was only a secret was kept: %+v", items)++ }++ items = parseMarkdown(parseOptions{}, "x.md", []byte("- The key is "+secret+"\n"))++ if len(items) != 1 {++ t.Fatalf("an item with a secret in it was dropped: %+v", items)++ }++ if !items[0].Redacted || items[0].Redactions != 1 {++ t.Errorf("the item is not marked redacted: %+v", items[0])++ }++}++++// TestRedactShapes checks every shape of secret the harness knows.++func TestRedactShapes(t *testing.T) {++ cases := map[string]string{++ "openai": "sk-" + "proj-" + strings.Repeat("A", 40),++ "anthropic": "sk-" + "ant-api03-" + strings.Repeat("B", 40),++ "github": "gh" + "p_" + strings.Repeat("C", 36),++ "github_pat": "github_" + "pat_" + strings.Repeat("D", 36),++ "jwt": "ey" + "JhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0." + strings.Repeat("E", 43),++ "pem": "-----BEGIN RSA " + "PRIVATE KEY-----\n" + strings.Repeat("F", 60) + "\n-----END RSA PRIVATE KEY-----",++ "aws": "AK" + "IAIOSFODNN7EXAMPLE",++ "slack": "xox" + "b-000000000000-000000000000-" + strings.Repeat("G", 33),++ "stripe": "sk" + "_live_" + strings.Repeat("H", 24),++ "assignment": "pass" + "word=" + strings.Repeat("I", 12),++ "connection": "postgres://dbuser:" + strings.Repeat("J", 10) + "@db.internal:5432/app",++ }++ for name, secret := range cases {++ text, count := Redact("the value is " + secret + " ok")++ if count == 0 {++ t.Errorf("%s: the secret was not found", name)++ continue++ }++ if strings.Contains(text, secret) {++ t.Errorf("%s: the secret is still in %q", name, text)++ }++ if !strings.Contains(text, Redacted) {++ t.Errorf("%s: the text has no redacted marker: %q", name, text)++ }++ }++}++++// TestOverSizeFileIsSkipped checks that a file over 256 KB is skipped.++func TestOverSizeFileIsSkipped(t *testing.T) {++ dir := t.TempDir()++ if err := os.MkdirAll(filepath.Join(dir, ".codex"), 0o755); err != nil {++ t.Fatal(err)++ }++ big := filepath.Join(dir, ".codex", "AGENTS.md")++ if err := os.WriteFile(big, []byte(strings.Repeat("a line of text\n", 30000)), 0o644); err != nil {++ t.Fatal(err)++ }++ res := Scan(Options{Home: dir, Tools: []string{"agents"}})++ found := false++ for _, ad := range res.Adapters {++ for _, f := range ad.Files {++ if f.Path == big {++ found = true++ if !strings.Contains(f.Note, "256 KB") {++ t.Errorf("the big file was not skipped with a note: %+v", f)++ }++ if f.Items != 0 {++ t.Errorf("the big file gave %d items", f.Items)++ }++ }++ }++ }++ if !found {++ t.Error("the big file was not even looked at")++ }++}++++// TestBinaryFileIsSkipped checks that a binary file is skipped.++func TestBinaryFileIsSkipped(t *testing.T) {++ dir := t.TempDir()++ if err := os.MkdirAll(filepath.Join(dir, ".codex"), 0o755); err != nil {++ t.Fatal(err)++ }++ bin := filepath.Join(dir, ".codex", "AGENTS.md")++ if err := os.WriteFile(bin, []byte{0x00, 0x01, 0x02, 0x03}, 0o644); err != nil {++ t.Fatal(err)++ }++ res := Scan(Options{Home: dir, Tools: []string{"agents"}})++ for _, ad := range res.Adapters {++ for _, f := range ad.Files {++ if f.Path == bin && !strings.Contains(f.Note, "binary") {++ t.Errorf("the binary file was not skipped with a note: %+v", f)++ }++ }++ }++}++++// TestSymlinkOutIsNotFollowed checks that a link out of the scanned paths is++// not read.++func TestSymlinkOutIsNotFollowed(t *testing.T) {++ outside := t.TempDir()++ secretFile := filepath.Join(outside, "outside.md")++ if err := os.WriteFile(secretFile, []byte("# Outside\n\n- a secret "+"gh"+"p_"+strings.Repeat("K", 36)+"\n"), 0o644); err != nil {++ t.Fatal(err)++ }++ home := t.TempDir()++ if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {++ t.Fatal(err)++ }++ link := filepath.Join(home, ".codex", "AGENTS.md")++ if err := os.Symlink(secretFile, link); err != nil {++ t.Skipf("this machine cannot make a link: %v", err)++ }++ res := Scan(Options{Home: home, Tools: []string{"agents"}})++ for _, it := range res.Items {++ if strings.Contains(it.Text, "gh"+"p_") {++ t.Errorf("the link out was read: %+v", it)++ }++ }++ for _, ad := range res.Adapters {++ for _, f := range ad.Files {++ if f.Path == link && f.Items != 0 {++ t.Errorf("the link out gave %d items", f.Items)++ }++ }++ }++}++++// TestLongItemIsTruncated checks that an item over 2000 characters is cut.++func TestLongItemIsTruncated(t *testing.T) {++ long := strings.Repeat("x", 3000)++ items := parseMarkdown(parseOptions{}, "x.md", []byte("- "+long+"\n"))++ if len(items) != 1 {++ t.Fatalf("got %d items, want 1", len(items))++ }++ if !items[0].Truncated {++ t.Error("the long item is not marked truncated")++ }++ if len([]rune(items[0].Text)) != MaxItemRunes+1 {++ t.Errorf("the item has %d runes, want %d", len([]rune(items[0].Text)), MaxItemRunes+1)++ }++ if !strings.HasSuffix(items[0].Text, "…") {++ t.Error("the item does not end with an ellipsis")++ }++}++++// TestPackageHasNoNetworkImport checks that the package never opens a++// network connection: it does not import net/http.++func TestPackageHasNoNetworkImport(t *testing.T) {++ entries, err := os.ReadDir(".")++ if err != nil {++ t.Fatal(err)++ }++ fset := token.NewFileSet()++ for _, e := range entries {++ name := e.Name()++ if e.IsDir() || !strings.HasSuffix(name, ".go") {++ continue++ }++ file, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly)++ if err != nil {++ t.Fatalf("%s does not parse: %v", name, err)++ }++ for _, imp := range file.Imports {++ path := strings.Trim(imp.Path.Value, "\"")++ if path == "net" || strings.HasPrefix(path, "net/") {++ t.Errorf("%s imports %s", name, path)++ }++ }++ }++}++++// TestMarkdownSplitting checks how a markdown file becomes items.++func TestMarkdownSplitting(t *testing.T) {++ body := "# Title\n\nA paragraph under the title.\n\n" +++ "## Section\n\n- first bullet\n- second bullet\n\nA paragraph in the section.\n"++ items := markdownItems(body)++ if len(items) != 4 {++ t.Fatalf("got %d items, want 4: %+v", len(items), items)++ }++ if items[0].Heading != "Title" {++ t.Errorf("the first item has the heading %q", items[0].Heading)++ }++ if items[1].Heading != "Section" {++ t.Errorf("the bullet has the heading %q", items[1].Heading)++ }++ if items[1].Text != "- first bullet" {++ t.Errorf("the bullet item is %q", items[1].Text)++ }++ if items[2].Text != "- second bullet" {++ t.Errorf("the second bullet item is %q", items[2].Text)++ }++ if items[3].Text != "A paragraph in the section." {++ t.Errorf("the paragraph item is %q", items[3].Text)++ }++}++++// TestFrontmatterIsReadNotTurnedIntoItems checks the frontmatter handling.++func TestFrontmatterIsReadNotTurnedIntoItems(t *testing.T) {++ data := []byte("---\nname: a_rule\ndescription: A rule about the api\nmetadata:\n type: feedback\nglobs: \"**/*.go\"\n---\n\n# Heading\n\n- one item\n")++ items := parseMarkdown(parseOptions{kindFromType: true}, "rule.md", data)++ if len(items) != 1 {++ t.Fatalf("got %d items, want 1: %+v", len(items), items)++ }++ if items[0].Kind != KindLesson {++ t.Errorf("the item has the kind %q, want a lesson", items[0].Kind)++ }++ if items[0].Heading != "A rule about the api" {++ t.Errorf("the item has the heading %q", items[0].Heading)++ }++ for _, it := range items {++ if strings.Contains(it.Text, "name: a_rule") {++ t.Errorf("the frontmatter became an item: %q", it.Text)++ }++ }++}++++// TestScanReportsEveryPath checks that the scan says which path it looked++// at and whether it was there.++func TestScanReportsEveryPath(t *testing.T) {++ res := scanOf(t, "claude-code")++ var report *AdapterReport++ for i := range res.Adapters {++ if res.Adapters[i].ID == "claude-code" {++ report = &res.Adapters[i]++ }++ }++ if report == nil {++ t.Fatal("the claude-code adapter is not in the report")++ }++ if len(report.Files) == 0 {++ t.Fatal("the report lists no files")++ }++ seen := map[string]bool{}++ for _, f := range report.Files {++ seen[f.Path] = true++ }++ if !seen[filepath.Join("testdata", "claude-code", "home", ".claude", "CLAUDE.md")] {++ t.Errorf("the user CLAUDE.md is not in the report: %+v", report.Files)++ }++}++++// TestOnlySecretsAreDropped checks that a file whose only content is a secret++// gives no items.++func TestOnlySecretsAreDropped(t *testing.T) {++ items := parseMarkdown(parseOptions{}, "x.md", []byte("AK"+"IAIOSFODNN7EXAMPLE\n"))++ if len(items) != 0 {++ t.Errorf("a file that held only a secret gave items: %+v", items)++ }++}++++// TestRuntimeGuard keeps the tests portable: the paths are built with++// filepath.Join, not with a slash.++func TestRuntimeGuard(t *testing.T) {++ if runtime.GOOS == "" {++ t.Fatal("no runtime")++ }++}nnew file mode 100644@@@ -0,0 +1,342 @@++package memory++++import (++ "fmt"++ "regexp"++ "strings"++)++++// secretPattern is one shape of secret and how to replace it. Keep is the++// number of capture groups that are kept in front of the redacted part, so an++// assignment still reads as "password=[redacted]".++type secretPattern struct {++ re *regexp.Regexp++ Keep int++ // Template, when set, is used to rebuild the match: "$1" and friends are++ // the capture groups, and "%s" is the marker that replaces the secret.++ Template string++}++++// secretNameSuffixes are the words a credential name may end in. A name is a++// run of [A-Za-z0-9_.-] that ends in one of these, so DATABASE_PASSWORD,++// MY_SECRET and GITHUB_TOKEN are names, and so are password and api_key.++const secretNameSuffixes = "password|passwd|pwd|passphrase|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth|credential|credentials|cookie|session"++++// secretNamePattern is a credential name: a run of [A-Za-z0-9_.-] that ends in++// one of the words above. The name may end in an "s" as well, so that++// "max_tokens" and "api_keys" are names too.++const secretNamePattern = "(?:" + secretNameSuffixes + ")s?"++++// secretPatterns are the shapes we remove before an item is shown or stored.++//++// The first five are the patterns of OpenVibe.Codes' harness (harness/events.js,++// SECRET_PATTERNS). The rest are the common gitleaks shapes and the shapes++// that turn up in a person's own files: AWS keys, GitHub and GitLab tokens,++// Slack and Discord tokens, Stripe keys, OpenAI- and Anthropic-style keys,++// JWTs, PEM private keys in every form, env-style assignments, headers, the++// webhooks of chat services and the card numbers that pass the Luhn check.++var secretPatterns = []secretPattern{++ // A webhook URL carries its token in the path: the whole URL goes.++ {re: regexp.MustCompile("https://hooks\\.slack\\.com/services/[^\\s\"']+")},++ {re: regexp.MustCompile("https://(?:discord|discordapp)\\.com/api/webhooks/[^\\s\"']+")},++ // A connection string with a password: the scheme, the user and the host++ // stay, the password goes.++ {++ re: regexp.MustCompile("([a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@]+:)([^\\s@/]+)(@[^\\s\"']*)"),++ Keep: 1,++ Template: "$1%s$3",++ },++ // A private key block, in every form: RSA, EC, DSA, OPENSSH, PGP and++ // ENCRYPTED.++ {re: regexp.MustCompile("(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----.*?-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")},++ // The keys of the tools and the services.++ {re: regexp.MustCompile("\\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}")},++ {re: regexp.MustCompile("\\bgh[pousr]_[A-Za-z0-9]{20,}")},++ {re: regexp.MustCompile("\\bgithub_pat_[A-Za-z0-9_]{20,}")},++ {re: regexp.MustCompile("\\bglpat-[0-9A-Za-z_-]{20,}")},++ {re: regexp.MustCompile("\\bnpm_[A-Za-z0-9]{36}")},++ {re: regexp.MustCompile("\\bhf_[A-Za-z0-9]{30,}")},++ {re: regexp.MustCompile("\\bAIza[0-9A-Za-z_-]{35}")},++ {re: regexp.MustCompile("\\bSK[0-9a-fA-F]{32}\\b")},++ {re: regexp.MustCompile("\\bxox[baprs]-[A-Za-z0-9-]{10,}")},++ {re: regexp.MustCompile("\\bxapp-[0-9A-Za-z-]{10,}")},++ {re: regexp.MustCompile("\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}")},++ {re: regexp.MustCompile("\\bSG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}")},++ {re: regexp.MustCompile("\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b")},++ {re: regexp.MustCompile("[MN][A-Za-z\\d]{23,25}\\.[\\w-]{6}\\.[\\w-]{27,}")},++ {re: regexp.MustCompile("\\bhbt_[A-Za-z0-9_-]{16,}")},++ {re: regexp.MustCompile("\\bpgsk_[A-Za-z0-9_]{16,}")},++ {re: regexp.MustCompile("\\bAKIA[0-9A-Z]{16}")},++ {re: regexp.MustCompile("\\bASIA[0-9A-Z]{16}")},++ {re: regexp.MustCompile("\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}")},++ // A header that carries a credential.++ {++ re: regexp.MustCompile("(?i)(authorization[ \\t]*:[ \\t]*(?:bearer|basic)[ \\t]+)(\\S+)"),++ Keep: 1,++ },++ {++ re: regexp.MustCompile("(?i)(x-api-key[ \\t]*:[ \\t]*)(\\S+)"),++ Keep: 1,++ },++ {++ re: regexp.MustCompile("(?i)\\b(bearer[ \\t]+)([A-Za-z0-9._~+/=-]{20,})"),++ Keep: 1,++ },++ // An assignment: the name and the separator stay, the value goes. The++ // name ends in a word that means "this is a credential".++ //++ // A quoted value keeps its quotes, so the text still reads as the++ // config it was.++ {++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)\"([^\"\\n]{4,})\""),++ Template: "$1$2\"%s\"",++ },++ {++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)'([^'\\n]{4,})'"),++ Template: "$1$2'%s'",++ },++ // A value whose closing quote was lost: it still goes, to the end of++ // the line.++ {++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)\"([^\"\\n]{4,})"),++ Template: "$1$2\"%s",++ },++ // An unquoted value.++ {++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)([^\\s\"',;]{6,})"),++ Keep: 2,++ },++ // Prose: "the password is hunter2".++ {++ re: regexp.MustCompile("(?i)\\b(password|passphrase|passcode)([ \\t]+(?:is|was|=)[ \\t]+)(\\S{4,})"),++ Keep: 2,++ },++}++++// cardCandidate is a run of 13 to 19 digits with optional spaces or dashes++// between them. Whether it is a card is decided by the Luhn check.++var cardCandidate = regexp.MustCompile("[0-9](?:[ -]?[0-9]){12,18}")++++// pemBegin and pemEnd are the lines of a private key block.++var (++ pemBegin = regexp.MustCompile("-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")++ pemEnd = regexp.MustCompile("-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")++)++++// The bytes of the two fence markers of markdown.++const (++ fenceTick = 0x60++ fenceTilde = 0x7e++)++++// Redacted is what a secret is replaced with.++const Redacted = "[redacted]"++++// secretMark is the sentinel the body of a file is scrubbed with, before it++// is split into items. It is a string that never appears in a real file, so++// that an item can still count the secrets the whole body held.++const secretMark = "\x00redacted\x00"++++// Redact removes the secrets from a text. It returns the text and how many++// secrets were removed; the secrets themselves are never returned.++//++// It is safe to call on a text that was already redacted: a value that is++// already the redacted marker is left alone.++func Redact(text string) (string, int) {++ return redactWith(text, Redacted)++}++++// redactWith removes the secrets from a text and writes the marker in place++// of each one.++//++// The marker is a parameter so that a caller that scrubs a whole file before++// splitting it into items can still tell how many secrets each item held: it++// counts the markers it finds.++func redactWith(text string, marker string) (string, int) {++ count := 0++ text = redactUnterminatedKey(text, marker, &count)++ for _, p := range secretPatterns {++ text = p.replace(text, marker, &count)++ }++ text = redactCards(text, marker, &count)++ return text, count++}++++// replace swaps every match of the pattern for the redacted marker, keeping++// the groups the pattern says to keep.++func (p secretPattern) replace(text string, marker string, count *int) string {++ return p.re.ReplaceAllStringFunc(text, func(m string) string {++ if strings.Contains(m, marker) {++ // Already redacted: there is nothing to hide here.++ return m++ }++ *count++++ if p.Template != "" {++ return p.re.ReplaceAllString(m, fmt.Sprintf(p.Template, marker))++ }++ if p.Keep == 0 {++ return marker++ }++ groups := p.re.FindStringSubmatch(m)++ if len(groups) <= p.Keep {++ return marker++ }++ kept := ""++ for i := 1; i <= p.Keep; i++ {++ kept += groups[i]++ }++ return kept + marker++ })++}++++// redactUnterminatedKey redacts a private key block that has no END line: a++// truncated file, or a key pasted in halves.++//++// The key is redacted from its BEGIN line to the end of the fence it sits in,++// or to the end of the text when it is not in a fence. A block that has its++// END line is left to the pattern list, which redacts the whole block.++func redactUnterminatedKey(text string, marker string, count *int) string {++ blocks := pemBegin.FindAllStringIndex(text, -1)++ // Work from the last block backwards, so the indexes of the earlier ones++ // stay valid.++ for i := len(blocks) - 1; i >= 0; i-- {++ start, after := blocks[i][0], blocks[i][1]++ if pemEnd.MatchString(text[after:]) {++ // The block is complete: the pattern list redacts it whole.++ continue++ }++ end := len(text)++ if fence := fenceEnd(text, start); fence > start {++ end = fence++ }++ text = text[:start] + marker + "\n" + text[end:]++ *count++++ }++ return text++}++++// fenceEnd returns the index of the fence that closes the block the given++// position sits in. It returns the end of the text when the position is not++// inside a fence.++func fenceEnd(text string, pos int) int {++ char, width := openFence(text, pos)++ if char == 0 {++ // Not inside a fence: the key runs to the end of the text.++ return len(text)++ }++ for i := pos; i < len(text); {++ n := fenceRun(text, i)++ if n == 0 {++ i++++ continue++ }++ if text[i] == char && n >= width {++ return i++ }++ i += n++ }++ return len(text)++}++++// openFence returns the character and the width of the fence that is open at++// the given position, or 0 when the position is not inside a fence.++func openFence(text string, pos int) (byte, int) {++ var char byte++ var width int++ for i := 0; i < pos; {++ n := fenceRun(text, i)++ if n == 0 {++ i++++ continue++ }++ if char == 0 {++ char, width = text[i], n++ } else if text[i] == char && n >= width {++ char, width = 0, 0++ }++ i += n++ }++ return char, width++}++++// fenceRun returns the length of the run of fence characters at s[i] when the++// run is at least three characters long, and 0 otherwise.++func fenceRun(s string, i int) int {++ if i >= len(s) || (s[i] != fenceTick && s[i] != fenceTilde) {++ return 0++ }++ n := 0++ for i+n < len(s) && s[i+n] == s[i] {++ n++++ }++ if n < 3 {++ return 0++ }++ return n++}++++// redactCards redacts the card numbers that pass the Luhn check. A long++// number that does not pass it is left alone: an order number is not a card.++func redactCards(text string, marker string, count *int) string {++ var out strings.Builder++ last := 0++ for _, loc := range cardCandidate.FindAllStringIndex(text, -1) {++ start, end := loc[0], loc[1]++ if start > 0 && isDigit(text[start-1]) {++ // Part of a longer number.++ continue++ }++ j := end++ for j < len(text) && text[j] == ' ' {++ j++++ }++ if j < len(text) && isDigit(text[j]) {++ // The number continues past the match.++ continue++ }++ digits := ""++ for i := start; i < end; i++ {++ if isDigit(text[i]) {++ digits += string(text[i])++ }++ }++ if len(digits) < 13 || len(digits) > 19 || !luhn(digits) {++ continue++ }++ out.WriteString(text[last:start])++ out.WriteString(marker)++ *count++++ last = end++ }++ out.WriteString(text[last:])++ return out.String()++}++++// isDigit tells whether a byte is a digit.++func isDigit(b byte) bool {++ return b >= '0' && b <= '9'++}++++// luhn tells whether a run of digits is a card number.++func luhn(digits string) bool {++ sum := 0++ double := false++ for i := len(digits) - 1; i >= 0; i-- {++ d := int(digits[i] - '0')++ if double {++ d *= 2++ if d > 9 {++ d -= 9++ }++ }++ sum += d++ double = !double++ }++ return sum%10 == 0++}++++// onlySecret tells whether a text holds nothing but redacted secrets.++func onlySecret(text string) bool {++ rest := strings.ReplaceAll(text, Redacted, "")++ return strings.TrimSpace(rest) == ""++}nnew file mode 100644@@@ -0,0 +1,588 @@++package memory++++import (++ "encoding/json"++ "os"++ "path/filepath"++ "strings"++ "testing"++)++++// halves joins the two halves of a value.++//++// The test files of this repository are read by a secret scanner too, and++// these fixtures hold fake secrets on purpose. Writing a whole secret in one++// piece would make the scanner report the test itself, so every value that++// looks like a secret is cut in two.++func halves(a, b string) string { return a + b }++++// The fake secrets the shapes are tested with. Every one of them is written++// in halves, so that the secret scanner of the repository does not read this++// test file as a file that holds a secret.++var (++ secretOpenSSH = halves("b3BlbnNzaC1rZXktdjEAAAAABG5vbm", "UAAAAEbm9uZQ")++ secretPGP = halves("lQOYBFc0OaJBCFAVvJ0lhbXVs", "C")++ secretBasic = halves("ZGVtbzpwYXNzd29yZ", "A==")++ secretJWT = halves("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N", "TY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U")++ secretOpenAI = halves("sk-proj-A1b2C3d4E5f6G7", "h8I9j0K1l2M3n4O5p6Q7r8")++ secretAnthropic = halves("sk-ant-api03-BBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBBBBBBBBBB")++ secretStripe = halves("sk_live_0000", "00000000000FAKE")++ secretRSABody = halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE")++ // The lines of a key block are written in two pieces too, so that++ // the scanner does not read this test file as a file that holds a key.++ pemBeginRSA = "-----BEGIN RSA " + "PRIVATE KEY-----\n"++ pemEndRSA = "-----END RSA " + "PRIVATE KEY-----\n"++)++++// plantedSecrets are the fake secrets the fixtures hold, one list per++// adapter. The whole-output check looks for every one of them.++var plantedSecrets = map[string][]string{++ "agents": {++ halves("ghp_AAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAA"),++ },++ "aider": {++ halves("Sup3rSecre", "tFakeValue"),++ },++ "claude-code": {++ halves("sk-proj-A1b2C3d4E5f6G7", "h8I9j0K1l2M3n4O5p6Q7r8"),++ halves("MIIEowIBAAKCAQEAtESTKEYONLYOPENSSH", "FAKE000000000000"),++ halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE0000000000000000"),++ halves("fake-db-pa", "ssword-123"),++ halves("fake-stripe", "-secret-999"),++ halves("correct horse ", "battery staple"),++ },++ "cline": {++ halves("Sup3rSecre", "tFakeValue"),++ },++ "command-code": {++ halves("github_pat_11AAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAA"),++ },++ "continue": {++ halves("ghp_BBBBBBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBBB"),++ },++ "copilot": {++ halves("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N", "TY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"),++ },++ "cursor": {++ halves("xoxb-0000000", "000000000FAKE"),++ },++ "gemini": {++ halves("AKIAIOSFOD", "NN7EXAMPLE"),++ },++ "kiro": {++ halves("hunte", "r2fake"),++ },++ "opencode": {++ halves("wJalrXUtnFEMI0000000", "0000000000000000FAKE"),++ },++ "roo": {++ halves("sk_live_000000", "0000000000FAKE"),++ },++ "windsurf": {++ halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE0000000000000000"),++ },++}++++// fixtureAdapters is every adapter that has a fixture.++var fixtureAdapters = []string{++ "agents",++ "aider",++ "claude-code",++ "cline",++ "command-code",++ "continue",++ "copilot",++ "cursor",++ "gemini",++ "kiro",++ "opencode",++ "roo",++ "windsurf",++}++++// pemBlock is a private key block with the given label and body.++func pemBlock(label, body string) string {++ return "-----BEGIN " + label + "-----\n" + body + "\n-----END " + label + "-----\n"++}++++// TestRedactEveryShape checks every shape of secret one by one: the value++// goes, the words around it stay.++func TestRedactEveryShape(t *testing.T) {++ cases := []struct {++ name string++ text string++ secret string++ gone []string++ keep []string++ }{++ {++ name: "pem rsa",++ text: "the deploy key is\n" + pemBlock("RSA PRIVATE KEY", "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE") + "and the deploy works",++ secret: "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE",++ gone: []string{"PRIVATE KEY"},++ keep: []string{"the deploy key is", "and the deploy works"},++ },++ {++ name: "pem ec",++ text: "the key is " + pemBlock("EC PRIVATE KEY", "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + "in the vault",++ secret: "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB",++ gone: []string{"PRIVATE KEY"},++ keep: []string{"the key is", "in the vault"},++ },++ {++ name: "pem openssh",++ text: "the key is " + pemBlock("OPENSSH PRIVATE KEY", secretOpenSSH) + "in the vault",++ secret: secretOpenSSH,++ gone: []string{"PRIVATE KEY"},++ keep: []string{"the key is", "in the vault"},++ },++ {++ name: "pem pgp",++ text: "the key is " + pemBlock("PGP PRIVATE KEY BLOCK", secretPGP) + "in the vault",++ secret: secretPGP,++ gone: []string{"PRIVATE KEY"},++ keep: []string{"the key is", "in the vault"},++ },++ {++ name: "pem encrypted",++ text: "the key is " + pemBlock("ENCRYPTED PRIVATE KEY", "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE") + "in the vault",++ secret: "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE",++ gone: []string{"PRIVATE KEY"},++ keep: []string{"the key is", "in the vault"},++ },++ {++ name: "env assignment",++ text: "the line is DATABASE_PASSWORD=hunter2xyz in the file",++ secret: "hunter2xyz",++ keep: []string{"DATABASE_PASSWORD=", "in the file"},++ },++ {++ name: "env assignment with a dot in the name",++ text: "the line is MY.SECRET=abcdefghijkl in the file",++ secret: "abcdefghijkl",++ keep: []string{"MY.SECRET=", "in the file"},++ },++ {++ name: "env assignment with a colon",++ text: "the line is api_key: abcdefghijklmnop in the file",++ secret: "abcdefghijklmnop",++ keep: []string{"api_key:", "in the file"},++ },++ {++ name: "env assignment with an arrow",++ text: "the line is access_key => abcdefghijklmnop in the file",++ secret: "abcdefghijklmnop",++ keep: []string{"access_key =>", "in the file"},++ },++ {++ name: "env assignment quoted",++ text: "the line is password: \"correct horse battery staple\" in the file",++ secret: "correct horse battery staple",++ keep: []string{"password:", "in the file"},++ },++ {++ name: "env assignment single quoted",++ text: "the line is client_secret: 'correct horse battery staple' in the file",++ secret: "correct horse battery staple",++ keep: []string{"client_secret:", "in the file"},++ },++ {++ name: "env assignment with a plural name",++ text: "the line is max_tokens: 100000 in the file",++ secret: "100000",++ keep: []string{"max_tokens:", "in the file"},++ },++ {++ name: "prose",++ text: "the password is hunter2xyz and it must not be shown",++ secret: "hunter2xyz",++ keep: []string{"the password is", "and it must not be shown"},++ },++ {++ name: "prose with was",++ text: "the passphrase was correcthorsebattery and it must not be shown",++ secret: "correcthorsebattery",++ keep: []string{"the passphrase was", "and it must not be shown"},++ },++ {++ name: "authorization bearer",++ text: "the header is Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",++ secret: "abcdefghijklmnopqrstuvwxyz012345",++ keep: []string{"Authorization: Bearer", "and it must not be shown"},++ },++ {++ name: "authorization basic",++ text: "the header is Authorization: Basic " + secretBasic + " and it must not be shown",++ secret: secretBasic,++ keep: []string{"Authorization: Basic", "and it must not be shown"},++ },++ {++ name: "x api key",++ text: "the header is x-api-key: abcdefghijklmnopqrst and it must not be shown",++ secret: "abcdefghijklmnopqrst",++ keep: []string{"x-api-key:", "and it must not be shown"},++ },++ {++ name: "bare bearer",++ text: "the header is Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",++ secret: "abcdefghijklmnopqrstuvwxyz012345",++ keep: []string{"Bearer", "and it must not be shown"},++ },++ {++ name: "google api key",++ text: "the key is AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and it must not be shown",++ secret: "AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "gitlab token",++ text: "the token is glpat-cccccccccccccccccccc and it must not be shown",++ secret: "glpat-cccccccccccccccccccc",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "npm token",++ text: "the token is npm_dddddddddddddddddddddddddddddddddddd and it must not be shown",++ secret: "npm_dddddddddddddddddddddddddddddddddddd",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "hugging face token",++ text: "the token is hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee and it must not be shown",++ secret: "hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "sendgrid key",++ text: "the key is SG.ffffffffffffffffffff.gggggggggggggggggggg and it must not be shown",++ secret: "SG.ffffffffffffffffffff.gggggggggggggggggggg",++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "telegram bot token",++ text: "the token is 123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh and it must not be shown",++ secret: "123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "discord bot token",++ text: "the token is Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk and it must not be shown",++ secret: "Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "twilio key",++ text: "the key is SK" + strings.Repeat("1a", 16) + " and it must not be shown",++ secret: "SK" + strings.Repeat("1a", 16),++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "slack app token",++ text: "the token is xapp-llllllllll and it must not be shown",++ secret: "xapp-llllllllll",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "openvibe token",++ text: "the token is hbt_mmmmmmmmmmmmmmmm and it must not be shown",++ secret: "hbt_mmmmmmmmmmmmmmmm",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "phoenix grove key",++ text: "the key is pgsk_nnnnnnnnnnnnnnnn and it must not be shown",++ secret: "pgsk_nnnnnnnnnnnnnnnn",++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "slack webhook",++ text: "the hook is https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX and it must not be shown",++ secret: "https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX",++ keep: []string{"the hook is", "and it must not be shown"},++ },++ {++ name: "discord webhook",++ text: "the hook is https://discord.com/api/webhooks/1234/abcdefghijklmnop and it must not be shown",++ secret: "https://discord.com/api/webhooks/1234/abcdefghijklmnop",++ keep: []string{"the hook is", "and it must not be shown"},++ },++ {++ name: "card number",++ text: "the card is 4111111111111111 and it must not be shown",++ secret: "4111111111111111",++ keep: []string{"the card is", "and it must not be shown"},++ },++ {++ name: "card number with spaces",++ text: "the card is 4111 1111 1111 1111 and it must not be shown",++ secret: "4111 1111 1111 1111",++ keep: []string{"the card is", "and it must not be shown"},++ },++ {++ name: "aws key",++ text: "the key is AKIAIOSFODNN7EXAMPLE and it must not be shown",++ secret: "AKIAIOSFODNN7EXAMPLE",++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "github token",++ text: "the token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",++ secret: "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "github fine grained token",++ text: "the token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",++ secret: "github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "jwt",++ text: "the token is " + secretJWT + " and it must not be shown",++ secret: secretJWT,++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "openai key",++ text: "the key is " + secretOpenAI + " and it must not be shown",++ secret: secretOpenAI,++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "anthropic key",++ text: "the key is " + secretAnthropic + " and it must not be shown",++ secret: secretAnthropic,++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "stripe key",++ text: "the key is " + secretStripe + " and it must not be shown",++ secret: secretStripe,++ keep: []string{"the key is", "and it must not be shown"},++ },++ {++ name: "slack token",++ text: "the token is xoxb-0000000000000000FAKE and it must not be shown",++ secret: "xoxb-0000000000000000FAKE",++ keep: []string{"the token is", "and it must not be shown"},++ },++ {++ name: "connection string",++ text: "the url is postgres://dbuser:[email protected]:5432/app and it must not be shown",++ secret: "hunter2fake",++ keep: []string{"postgres://dbuser:", "@db.internal:5432/app", "and it must not be shown"},++ },++ }++ for _, c := range cases {++ out, n := Redact(c.text)++ if n == 0 {++ t.Errorf("%s: nothing was redacted: %q", c.name, out)++ continue++ }++ if strings.Contains(out, c.secret) {++ t.Errorf("%s: the secret is still there: %q", c.name, out)++ }++ for _, gone := range c.gone {++ if strings.Contains(out, gone) {++ t.Errorf("%s: %q is still there: %q", c.name, gone, out)++ }++ }++ for _, keep := range c.keep {++ if !strings.Contains(out, keep) {++ t.Errorf("%s: the text lost %q: %q", c.name, keep, out)++ }++ }++ }++}++++// TestRedactASecretThatSpansTwoItems checks a key block whose BEGIN and END++// lines land in two different items: only the whole-file pass sees it.++func TestRedactASecretThatSpansTwoItems(t *testing.T) {++ doc := "the deploy key:\n\n" +++ "```text\n" +++ pemBeginRSA +++ secretRSABody + "\n" +++ "\n" +++ secretRSABody + "2\n" +++ pemEndRSA +++ "```\n"++ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))++ if len(items) == 0 {++ t.Fatal("the file gave no items")++ }++ for _, it := range items {++ if strings.Contains(it.Text, "PRIVATE KEY") {++ t.Errorf("the key is still in an item: %q", it.Text)++ }++ if strings.Contains(it.Text, "MIIEow") {++ t.Errorf("the key body is still in an item: %q", it.Text)++ }++ }++ found := false++ for _, it := range items {++ if it.Redacted {++ found = true++ }++ }++ if !found {++ t.Errorf("nothing was marked redacted: %+v", items)++ }++}++++// TestRedactAKeyWithNoEndLine checks a key block that was cut off: it goes++// from its BEGIN line to the end of its fence, and the text after the fence++// stays.++func TestRedactAKeyWithNoEndLine(t *testing.T) {++ doc := "the vault key:\n\n" +++ "```text\n" +++ pemBeginRSA +++ secretRSABody + "\n" +++ "```\n\n" +++ "the notes after the block stay\n"++ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))++ if len(items) == 0 {++ t.Fatal("the file gave no items")++ }++ joined := ""++ for _, it := range items {++ if strings.Contains(it.Text, "MIIEow") {++ t.Errorf("the key body is still in an item: %q", it.Text)++ }++ if strings.Contains(it.Text, "PRIVATE KEY") {++ t.Errorf("the key is still in an item: %q", it.Text)++ }++ joined += it.Text + "\n"++ }++ if !strings.Contains(joined, "the notes after the block stay") {++ t.Errorf("the text after the fence was swallowed: %q", joined)++ }++}++++// TestRedactAnEnvBlockInAFence checks an .env block in a fence: every value++// goes, every name stays.++func TestRedactAnEnvBlockInAFence(t *testing.T) {++ doc := "the environment:\n\n" +++ "```env\n" +++ "DATABASE_PASSWORD=\"correct horse battery staple\"\n" +++ "STRIPE_SECRET=" + secretStripe + "\n" +++ "SESSION_TOKEN=hunter2xyz\n" +++ "```\n"++ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))++ if len(items) == 0 {++ t.Fatal("the file gave no items")++ }++ joined := ""++ for _, it := range items {++ joined += it.Text + "\n"++ }++ for _, secret := range []string{"hunter2xyz", secretStripe, "correct horse battery staple"} {++ if strings.Contains(joined, secret) {++ t.Errorf("the value %q is still there: %q", secret, joined)++ }++ }++ for _, name := range []string{"DATABASE_PASSWORD=", "STRIPE_SECRET=", "SESSION_TOKEN="} {++ if !strings.Contains(joined, name) {++ t.Errorf("the name %q is gone: %q", name, joined)++ }++ }++}++++// TestRedactASecretInTheFrontmatter checks a secret in the frontmatter: it++// is gone before the description is used as a heading.++func TestRedactASecretInTheFrontmatter(t *testing.T) {++ doc := "---\n" +++ "name: a_rule\n" +++ "description: The rule about GITHUB_TOKEN=ghp_" + strings.Repeat("Q", 36) + "\n" +++ "metadata:\n" +++ " type: feedback\n" +++ "---\n\n" +++ "# Heading\n\n" +++ "- one item\n"++ items := parseMarkdown(parseOptions{kindFromType: true}, "x.md", []byte(doc))++ if len(items) != 1 {++ t.Fatalf("the file gave %d items, want 1: %+v", len(items), items)++ }++ if strings.Contains(items[0].Heading, "ghp_") {++ t.Errorf("the secret is still in the heading: %q", items[0].Heading)++ }++ if !strings.Contains(items[0].Heading, Redacted) {++ t.Errorf("the heading is not redacted: %q", items[0].Heading)++ }++ if !items[0].Redacted || items[0].Redactions == 0 {++ t.Errorf("the item is not marked redacted: %+v", items[0])++ }++ if items[0].Kind != KindLesson {++ t.Errorf("the item has the kind %q", items[0].Kind)++ }++}++++// TestTheWholeOutputHoldsNoPlantedSecret runs the scan over every fixture,++// writes all the items out as JSON and checks that no planted secret is in++// it.++func TestTheWholeOutputHoldsNoPlantedSecret(t *testing.T) {++ for _, id := range fixtureAdapters {++ values, ok := plantedSecrets[id]++ if !ok || len(values) == 0 {++ t.Errorf("the %q fixture has no planted secret listed", id)++ continue++ }++ // Every listed value is really in the fixture: the list does not++ // drift away from the files.++ home, project := fixture(t, id)++ for _, value := range values {++ found := false++ for _, dir := range []string{home, project} {++ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {++ if err != nil || info.IsDir() {++ return nil++ }++ data, err := os.ReadFile(path)++ if err != nil {++ return nil++ }++ if strings.Contains(string(data), value) {++ found = true++ }++ return nil++ })++ if err != nil {++ t.Fatal(err)++ }++ }++ if !found {++ t.Errorf("the %q fixture does not hold %q any more", id, value)++ }++ }++ res := scanOf(t, id)++ out, err := json.Marshal(res.Items)++ if err != nil {++ t.Fatal(err)++ }++ for _, value := range values {++ if strings.Contains(string(out), value) {++ t.Errorf("adapter %q still shows the planted secret %q", id, value)++ }++ }++ }++}++++// TestOverRedactionStaysBounded checks that the harmless things a file says++// are not touched.++func TestOverRedactionStaysBounded(t *testing.T) {++ harmless := []string{++ "use 4 spaces for indentation",++ "the build takes 12 minutes",++ "5551234567",++ "the port is 4321 and the timeout is 30 seconds",++ "the release is 1.24.0 and the build number is 1234",++ }++ for _, text := range harmless {++ out, n := Redact(text)++ if n != 0 {++ t.Errorf("%q was redacted: %q", text, out)++ }++ if out != text {++ t.Errorf("%q was changed: %q", text, out)++ }++ }++}nnew file mode 100644@@@ -0,0 +1,238 @@++package memory++++import (++ "os"++ "path/filepath"++ "sort"++ "strings"++ "time"++)++++// Limits of the scan.++const (++ // MaxFileBytes is the size a file may have before it is skipped.++ MaxFileBytes = 256 * 1024++ // MaxItemRunes is the length of an item before it is cut.++ MaxItemRunes = 2000++)++++// Options say what to scan.++type Options struct {++ // Home is the home directory of the person whose memory is read.++ Home string++ // Projects are the project directories to read, in addition to the++ // user-level paths.++ Projects []string++ // Tools are the adapter ids to read. Empty means every adapter.++ Tools []string++}++++// FileReport says what a scan found in one file.++type FileReport struct {++ // Path is the file that was looked at.++ Path string `json:"path"`++ // Found is true when the file was there.++ Found bool `json:"found"`++ // Items is how many items came out of the file.++ Items int `json:"items"`++ // Note says why a file was skipped, when it was.++ Note string `json:"note,omitempty"`++}++++// AdapterReport says what a scan found for one tool.++type AdapterReport struct {++ ID string `json:"id"`++ Name string `json:"name"`++ Docs string `json:"docs"`++ Files []FileReport `json:"files"`++ Items int `json:"items"`++}++++// Result is what a scan found.++type Result struct {++ Home string `json:"home"`++ Projects []string `json:"projects"`++ Adapters []AdapterReport `json:"adapters"`++ Items []Item `json:"items"`++}++++// Scan reads the memory of the tools the person uses, locally and read-only.++//++// It never writes a file, never opens a network connection and never follows++// a symlink out of the home directory or the project directories it was given.++func Scan(opts Options) Result {++ res := Result{Home: opts.Home, Adapters: []AdapterReport{}, Items: []Item{}}++ projects := make([]string, 0, len(opts.Projects))++ for _, p := range opts.Projects {++ abs, err := filepath.Abs(p)++ if err != nil {++ continue++ }++ projects = append(projects, abs)++ }++ sort.Strings(projects)++ res.Projects = projects++++ want := map[string]bool{}++ for _, t := range opts.Tools {++ want[strings.ToLower(strings.TrimSpace(t))] = true++ }++++ for _, ad := range Adapters() {++ if len(want) > 0 && !want[ad.ID] {++ continue++ }++ report := AdapterReport{ID: ad.ID, Name: ad.Name, Docs: ad.Docs, Files: []FileReport{}}++ for _, loc := range ad.Locations(opts.Home, projects) {++ paths := resolveLocation(loc, opts.Home, projects)++ if len(paths) == 0 && strings.ContainsAny(loc, "*?[") {++ // A glob that matched nothing is still a path we looked at.++ report.Files = append(report.Files, FileReport{Path: loc})++ continue++ }++ for _, path := range paths {++ fr := readFile(ad, path, opts.Home, projects, &res.Items)++ report.Files = append(report.Files, fr)++ report.Items += fr.Items++ }++ }++ res.Adapters = append(res.Adapters, report)++ }++ return res++}++++// resolveLocation turns a location into the files it stands for: one file++// for an exact path, the matches of a glob for a pattern.++func resolveLocation(loc, home string, projects []string) []string {++ if !strings.ContainsAny(loc, "*?[") {++ return []string{loc}++ }++ matches, err := expandGlob(loc)++ if err != nil {++ return nil++ }++ allowed := allowedRoots(home, projects)++ out := make([]string, 0, len(matches))++ for _, m := range matches {++ if !withinAny(m, allowed) {++ continue++ }++ out = append(out, m)++ }++ return out++}++++// allowedRoots are the directories a file may live in.++func allowedRoots(home string, projects []string) []string {++ roots := make([]string, 0, len(projects)+1)++ if home != "" {++ roots = append(roots, home)++ }++ roots = append(roots, projects...)++ return roots++}++++// withinAny tells whether a path is inside one of the roots.++func withinAny(path string, roots []string) bool {++ for _, root := range roots {++ if root == "" {++ continue++ }++ if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {++ return true++ }++ }++ return false++}++++// readFile reads one file and turns it into items.++func readFile(ad Adapter, path, home string, projects []string, items *[]Item) FileReport {++ fr := FileReport{Path: path}++ info, err := os.Lstat(path)++ if err != nil {++ return fr++ }++ if info.Mode()&os.ModeSymlink != 0 {++ // A link is only followed when it stays inside the paths we were given.++ resolved, err := filepath.EvalSymlinks(path)++ if err != nil || !withinAny(resolved, allowedRoots(home, projects)) {++ fr.Note = "skipped: link leaves the scanned paths"++ return fr++ }++ info, err = os.Stat(resolved)++ if err != nil {++ fr.Note = "skipped: link cannot be read"++ return fr++ }++ path = resolved++ }++ if info.IsDir() {++ fr.Note = "skipped: a directory"++ return fr++ }++ if info.Size() > MaxFileBytes {++ fr.Note = "skipped: larger than 256 KB"++ return fr++ }++ data, err := os.ReadFile(path)++ if err != nil {++ fr.Note = "skipped: cannot be read"++ return fr++ }++ if isBinary(data) {++ fr.Note = "skipped: a binary file"++ return fr++ }++ fr.Found = true++ for _, name := range indexFilesOf(ad) {++ if strings.EqualFold(filepath.Base(path), name) {++ fr.Note = "skipped: an index of the memories, not a memory"++ return fr++ }++ }++ updated := info.ModTime().UTC().Format(time.RFC3339)++ scope := scopeFor(path, projects)++ for _, it := range ad.Parse(path, data) {++ it.Source = ad.ID++ it.Path = path++ it.Updated = updated++ it.Scope = scope++ if it.Kind == "" {++ // A file of the person's own is about them; a file of a project++ // holds that project's rules.++ if scope == ScopeUser {++ it.Kind = KindPreference++ } else {++ it.Kind = KindProjectRule++ }++ }++ *items = append(*items, it)++ fr.Items++++ }++ return fr++}++++// scopeFor gives the project a file belongs to, or the person when it belongs++// to none.++func scopeFor(path string, projects []string) Scope {++ for _, p := range projects {++ if path == p || strings.HasPrefix(path, p+string(filepath.Separator)) {++ return ScopeProject(p)++ }++ }++ return ScopeUser++}++++// isBinary tells whether a file holds binary data.++func isBinary(data []byte) bool {++ head := data++ if len(head) > 8000 {++ head = head[:8000]++ }++ for _, b := range head {++ if b == 0 {++ return true++ }++ }++ return false++}nnew file mode 100644@@@ -0,0 +1,7 @@++# Codex global++++## Preferences++++- Answer in plain English, no preamble++- Never touch the vendor directory++- The release token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnnew file mode 100644@@@ -0,0 +1,10 @@++# Agents++++## Project rules++++- Run the tests before committing++- Do not edit generated files++++## Decision++++We keep the ledger in SQLite for now.nnew file mode 100644@@@ -0,0 +1,3 @@++model: gpt-4o++read:++ - NOTES.mdnnew file mode 100644@@@ -0,0 +1,3 @@++# Notes++++The person prefers short commit messages.nnew file mode 100644@@@ -0,0 +1,4 @@++model: gpt-4o++read:++ - README.md++ - docs/architecture.mdnnew file mode 100644@@@ -0,0 +1,4 @@++# Conventions++++- Run `go test ./...` before every commit++- The database password=Sup3rSecretFakeValue is only for the fixturennew file mode 100644@@@ -0,0 +1,3 @@++# Demo project++++A small service for invoices.nnew file mode 100644@@@ -0,0 +1,3 @@++# Architecture++++The service is one binary and one database.nnew file mode 100644@@@ -0,0 +1,8 @@++# Personal setup++++- Prefer tabs in Go files and spaces everywhere else++- The test command is `go test ./...` and it must pass before a commit++++## Credentials++++The staging API key is sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 and it is only for the sandbox.nnew file mode 100644@@@ -0,0 +1,4 @@++# Memory index++++- [User role](user_role.md)++- [Feedback on tests](feedback_testing.md)nnew file mode 100644@@@ -0,0 +1,8 @@++---++name: feedback_testing++description: What the person corrected++metadata:++ type: feedback++---++++Do not run the whole suite while iterating; run the one test.nnew file mode 100644@@@ -0,0 +1,8 @@++---++name: user_role++description: The person running the project++metadata:++ type: user++---++++The person is a staff engineer who prefers short answers.nnew file mode 100644@@@ -0,0 +1,25 @@++# Sandbox++++- The sandbox runs against its own database++++```text++-----BEGIN OPENSSH PRIVATE KEY-----++++MIIEowIBAAKCAQEAtESTKEYONLYOPENSSHFAKE000000000000++-----END OPENSSH PRIVATE KEY-----++```++++- The sandbox environment is written down here++++```env++DATABASE_PASSWORD=fake-db-password-123++STRIPE_SECRET=fake-stripe-secret-999++SESSION_SECRET="correct horse battery staple"++```++++- The vault key was pasted in halves++++```text++-----BEGIN RSA PRIVATE KEY-----++MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE0000000000000000++```nnew file mode 100644@@@ -0,0 +1,9 @@++---++paths:++ - "**/*_test.go"++---++++# Testing rules++++- Every change comes with a test++- Never commit a failing testnnew file mode 100644@@@ -0,0 +1,3 @@++# Local Claude notes++++- The sandbox URL is http://localhost:4321nnew file mode 100644@@@ -0,0 +1,9 @@++---++paths:++ - "src/api/**"++---++++# API rules++++- Validate every input++- Use the standard error formatnnew file mode 100644@@@ -0,0 +1,3 @@++# Personal for this project++++- Use the fixture database, never the real onennew file mode 100644@@@ -0,0 +1,10 @@++# Demo project++++## Conventions++++- Use 2-space indentation++- Keep the public API small++++## Build++++Run `npm run build` before pushing.nnew file mode 100644@@@ -0,0 +1,4 @@++# Cline rules++++- Always read the memory bank first++- The database password=Sup3rSecretFakeValue is only for the fixturennew file mode 100644@@@ -0,0 +1,3 @@++# Project brief++++The project is a small invoicing service.nnew file mode 100644@@@ -0,0 +1,3 @@++# Command Code memory++++- Keep the answers shortnnew file mode 100644@@@ -0,0 +1,4 @@++# Command Code project memory++++- The build is `make build`++- The personal token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnnew file mode 100644@@@ -0,0 +1,4 @@++# Style++++- Use tabs in Go++- The personal token=ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB is for the sandbox onlynnew file mode 100644@@@ -0,0 +1,5 @@++# Repository instructions++++- Use the shared client in packages/api++- Keep the changelog updated++- The session token is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8Unnew file mode 100644@@@ -0,0 +1,7 @@++---++applyTo: "**/*.test.ts"++---++++# Test instructions++++- Use the fixture builder, not raw objectsnnew file mode 100644@@@ -0,0 +1,11 @@++---++description: Database rules++globs:++ - "**/*.sql"++alwaysApply: false++---++++# Database++++- Every migration is reversible++- No table without an indexnnew file mode 100644@@@ -0,0 +1,11 @@++---++description: How we write code here++globs: "**/*.go"++alwaysApply: true++---++++# Style++++- Error messages start lowercase++- Comments explain why, not what++- The Slack token for the alerts room is xoxb-0000000000000000FAKEnnew file mode 100644@@@ -0,0 +1,5 @@++# Global Gemini context++++- Write release notes in the past tense++- Keep summaries under five lines++- The bucket key is AKIAIOSFODNN7EXAMPLEnnew file mode 100644@@@ -0,0 +1,10 @@++# Project context++++## Stack++++- Go 1.24 for the services++- Postgres 16 for the data++++## Style++++- Errors are wrapped with %wnnew file mode 100644@@@ -0,0 +1,3 @@++# Product++++An invoicing service for small businesses.nnew file mode 100644@@@ -0,0 +1,5 @@++# Technology stack++++- Go with chi for the HTTP layer++- Postgres 16++- The staging URL is postgres://dbuser:[email protected]:5432/appnnew file mode 100644@@@ -0,0 +1,3 @@++# OpenCode global++++- Ask before changing the CInnew file mode 100644@@@ -0,0 +1,3 @@++# OpenCode instructions++++- Read the schema before writing a migrationnnew file mode 100644@@@ -0,0 +1,4 @@++# Conventions++++- Every migration has a down step++- The aws_secret_access_key = wJalrXUtnFEMI00000000000000000000000FAKE is a fixturennew file mode 100644@@@ -0,0 +1,4 @@++{++ "$schema": "https://opencode.ai/config.json",++ "instructions": ["docs/conventions.md"]++}nnew file mode 100644@@@ -0,0 +1,4 @@++# Roo rules++++- Never edit files outside the workspace++- The api key is sk_live_0000000000000000FAKEnnew file mode 100644@@@ -0,0 +1,3 @@++# Tech context++++- Node 22 and TypeScript 5nnew file mode 100644@@@ -0,0 +1,9 @@++# Preferences++++- I like small pull requests++- I read the diff before the description++- The deploy key lives here:++++-----BEGIN RSA PRIVATE KEY-----++MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE0000000000000000++-----END RSA PRIVATE KEY-----nnew file mode 100644@@@ -0,0 +1,3 @@++# General rules++++- Use pnpm, never npm
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+# The memory fixtures hold fake secrets on purpose: every adapter
+# carries one secret of a different shape, and the test proves none of them
+# reaches an item. The Claude Code fixture carries more: a key block that
+# is split over two items, a key block with no END line, and an .env block.
+# The values are obviously fake (FAKE, EXAMPLE, repeated letters) and exist
+# only to be redacted.
+#
+# Each line is the exact fingerprint of a finding (file:rule:line).
+internal/memory/testdata/roo/project/.roo/rules/general.md:stripe-access-token:4
+internal/memory/testdata/copilot/project/.github/copilot-instructions.md:jwt:5
+internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md:private-key:7
+internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md:private-key:6
@@ -32,6 +32,7 @@ openvibe-node resume
@@ -32,6 +32,7 @@ openvibe-node resume
openvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcher
openvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcher
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
+openvibe-node memory scan # what your coding tools remember: read-only, local, secrets removed
```
```
## What is in here
## What is in here
@@ -70,7 +71,8 @@ job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.
@@ -70,7 +71,8 @@ job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
-protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md).
+protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
+[memory](docs/memory.md) (what the coding tools on a machine remember, and how the Node reads it).
Decisions: [ADR-043](docs/ADR-043-bot-devices-and-control.md).
Decisions: [ADR-043](docs/ADR-043-bot-devices-and-control.md).
## Development
## Development
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
// openvibe-node resume release the local kill switch (and a server e-stop)
// openvibe-node resume release the local kill switch (and a server e-stop)
// openvibe-node quit end the running node cleanly: every actuator stops, nothing is latched
// openvibe-node quit end the running node cleanly: every actuator stops, nothing is latched
// openvibe-node plugins list plugins and their capabilities
// openvibe-node plugins list plugins and their capabilities
+// openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
+// read the memory files of the coding tools (read-only, local)
// openvibe-node version
// openvibe-node version
package main
package main
@@ -72,6 +74,9 @@ Usage:
@@ -72,6 +74,9 @@ Usage:
openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--bind PATH]... [--egress public] -- PROGRAM [ARG]...
openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--bind PATH]... [--egress public] -- PROGRAM [ARG]...
run one desk's browser confined (a desk host that serves other people; the computer plugin
run one desk's browser confined (a desk host that serves other people; the computer plugin
calls it, with --remote-debugging-pipe on fds 3 and 4)
calls it, with --remote-debugging-pipe on fds 3 and 4)
+ openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
+ read the memory files of the coding tools on this machine
+ (read-only, local, secrets removed)
openvibe-node version
openvibe-node version
Global flags (before or after the command):
Global flags (before or after the command):
@@ -101,6 +106,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -101,6 +106,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 0
return 0
}
}
cmd, rest := args[0], args[1:]
cmd, rest := args[0], args[1:]
+ if cmd == "memory" {
+ // The memory scan has its own flags, so it parses its own arguments.
+ return cmdMemory(rest, stdout, stderr)
+ }
if cmd == "desk-sandbox" { // its own flags, up to "--": a browser's flags after it are never the Node's
if cmd == "desk-sandbox" { // its own flags, up to "--": a browser's flags after it are never the Node's
return cmdDeskSandbox(rest, stderr)
return cmdDeskSandbox(rest, stderr)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/memory"
+)
+
+// memoryUsage is what "openvibe-node memory" prints.
+const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--json]\n" +
+ "\n" +
+ " Reads the memory files of the coding tools on this machine and prints what it\n" +
+ " found. It is read-only and local: nothing is written and nothing is sent\n" +
+ " anywhere. Secrets are removed before anything is printed.\n" +
+ "\n" +
+ " --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +
+ " copilot, windsurf, cline, roo, kiro, continue, aider,\n" +
+ " command-code, opencode)\n" +
+ " --project DIR read this project as well; may be given more than once\n" +
+ " --json print the items as JSON\n" +
+ " --user-home DIR read this directory as the person's home (default: yours)\n"
+
+// stringList is a flag that may be given more than once.
+type stringList []string
+
+func (s *stringList) String() string { return strings.Join(*s, ",") }
+
+func (s *stringList) Set(v string) error {
+ for _, part := range strings.Split(v, ",") {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ *s = append(*s, part)
+ }
+ }
+ return nil
+}
+
+// cmdMemory runs the memory commands.
+func cmdMemory(args []string, stdout, stderr io.Writer) int {
+ if len(args) == 0 || args[0] != "scan" {
+ fmt.Fprint(stderr, memoryUsage)
+ return 2
+ }
+ fs := flag.NewFlagSet("memory scan", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ fs.Usage = func() { fmt.Fprint(stderr, memoryUsage) }
+ var tools stringList
+ var projects stringList
+ fs.Var(&tools, "tools", "only these adapters")
+ fs.Var(&projects, "project", "read this project as well")
+ asJSON := fs.Bool("json", false, "print the items as JSON")
+ userHome := fs.String("user-home", "", "read this directory as the person's home")
+ if err := fs.Parse(args[1:]); err != nil {
+ return 2
+ }
+ if fs.NArg() > 0 {
+ fmt.Fprintf(stderr, "unknown argument %q\n\n", fs.Arg(0))
+ fmt.Fprint(stderr, memoryUsage)
+ return 2
+ }
+ home := *userHome
+ if home == "" {
+ h, err := os.UserHomeDir()
+ if err != nil {
+ fmt.Fprintln(stderr, "error: cannot find your home directory; pass --user-home")
+ return 1
+ }
+ home = h
+ }
+ abs, err := filepath.Abs(home)
+ if err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ res := memory.Scan(memory.Options{
+ Home: abs,
+ Projects: projects,
+ Tools: tools,
+ })
+ if *asJSON {
+ enc := json.NewEncoder(stdout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(res); err != nil {
+ fmt.Fprintln(stderr, "error:", err)
+ return 1
+ }
+ return 0
+ }
+ printScan(res, stdout)
+ return 0
+}
+
+// printScan prints what the scan found: every adapter, every path it looked
+// at and the items per file.
+func printScan(res memory.Result, stdout io.Writer) {
+ fmt.Fprintf(stdout, "Memory scan of %s\n", res.Home)
+ if len(res.Projects) == 0 {
+ fmt.Fprintln(stdout, "No project given: only the user-level files were read.")
+ } else {
+ fmt.Fprintf(stdout, "Projects: %s\n", strings.Join(res.Projects, ", "))
+ }
+ for _, ad := range res.Adapters {
+ fmt.Fprintf(stdout, "\n%s (%s) — %d items\n", ad.Name, ad.ID, ad.Items)
+ fmt.Fprintf(stdout, " docs: %s\n", ad.Docs)
+ if len(ad.Files) == 0 {
+ fmt.Fprintln(stdout, " no path to look at")
+ continue
+ }
+ for _, f := range ad.Files {
+ switch {
+ case f.Note != "":
+ fmt.Fprintf(stdout, " - %s: %s\n", f.Path, f.Note)
+ case f.Found:
+ fmt.Fprintf(stdout, " - %s: %d items\n", f.Path, f.Items)
+ default:
+ fmt.Fprintf(stdout, " - %s: not there\n", f.Path)
+ }
+ }
+ }
+ fmt.Fprintf(stdout, "\n%d items in total. Nothing was written and nothing was sent.\n", len(res.Items))
+}
+
+// memoryToolIDs lists the adapter ids, for the usage text and the tests.
+func memoryToolIDs() []string {
+ var out []string
+ for _, ad := range memory.Adapters() {
+ out = append(out, ad.ID)
+ }
+ sort.Strings(out)
+ return out
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// adapterJSON is the JSON shape of one adapter of the scan.
+type adapterJSON struct {
+ ID string `json:"id"`
+ Files []fileJSON `json:"files"`
+}
+
+// fileJSON is the JSON shape of one file of the scan.
+type fileJSON struct {
+ Path string `json:"path"`
+ Found bool `json:"found"`
+}
+
+// itemJSON is the JSON shape of one memory item.
+type itemJSON struct {
+ Text string `json:"text"`
+ Kind string `json:"kind"`
+ Scope string `json:"scope"`
+ Source string `json:"source"`
+}
+
+// memoryFixture is the fixture home of the memory package, used here so the
+// test never reads the real home of the machine.
+func memoryFixture(t *testing.T) string {
+ t.Helper()
+ wd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return filepath.Join(wd, "..", "..", "internal", "memory", "testdata", "claude-code", "home")
+}
+
+// TestMemoryScanPrintsEveryAdapter checks the human output of the scan.
+func TestMemoryScanPrintsEveryAdapter(t *testing.T) {
+ home := memoryFixture(t)
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"scan", "--user-home", home}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
+ }
+ out := stdout.String()
+ for _, want := range []string{
+ "Claude Code (claude-code)",
+ "AGENTS.md (Codex and friends)",
+ "Gemini CLI",
+ "Cursor",
+ "GitHub Copilot",
+ "Windsurf",
+ "Cline",
+ "Roo Code",
+ "Kiro",
+ "Continue",
+ "Aider",
+ "Command Code",
+ "OpenCode",
+ "not there",
+ "docs: https://",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("the output has no %q: %s", want, out)
+ }
+ }
+ if !strings.Contains(out, "Nothing was written and nothing was sent") {
+ t.Errorf("the output does not say what it did: %s", out)
+ }
+}
+
+// TestMemoryScanJSON checks the JSON output of the scan.
+func TestMemoryScanJSON(t *testing.T) {
+ home := memoryFixture(t)
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"scan", "--user-home", home, "--json"}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
+ }
+ var res struct {
+ Home string `json:"home"`
+ Adapters []adapterJSON `json:"adapters"`
+ Items []itemJSON `json:"items"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
+ t.Fatalf("the output is not JSON: %v", err)
+ }
+ if len(res.Adapters) != len(memoryToolIDs()) {
+ t.Errorf("the JSON lists %d adapters, want %d", len(res.Adapters), len(memoryToolIDs()))
+ }
+ if len(res.Items) == 0 {
+ t.Error("the JSON has no items")
+ }
+ for _, it := range res.Items {
+ if it.Source == "" || it.Kind == "" || it.Scope == "" {
+ t.Errorf("an item is missing a field: %+v", it)
+ }
+ if strings.Contains(it.Text, "sk-proj") {
+ t.Errorf("the JSON still shows a secret: %q", it.Text)
+ }
+ }
+}
+
+// TestMemoryScanToolsFilter checks that --tools keeps only the adapters asked
+// for.
+func TestMemoryScanToolsFilter(t *testing.T) {
+ home := memoryFixture(t)
+ var stdout, stderr bytes.Buffer
+ code := cmdMemory([]string{"scan", "--user-home", home, "--tools", "cursor", "--json"}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
+ }
+ if strings.Contains(stdout.String(), "\"id\": \"claude-code\"") {
+ t.Errorf("the scan read an adapter that was not asked for: %s", stdout.String())
+ }
+}
+
+// TestMemoryScanUsage checks that a wrong use says how to use it.
+func TestMemoryScanUsage(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ if code := cmdMemory([]string{"list"}, &stdout, &stderr); code != 2 {
+ t.Errorf("the command returned %d, want 2", code)
+ }
+ if !strings.Contains(stderr.String(), "memory scan") {
+ t.Errorf("the error does not say how to use it: %s", stderr.String())
+ }
+}
+
+// TestMemoryScanIsReadOnly checks that the scan writes nothing.
+func TestMemoryScanIsReadOnly(t *testing.T) {
+ home := memoryFixture(t)
+ before := fixtureState(t, home)
+ var stdout, stderr bytes.Buffer
+ if code := cmdMemory([]string{"scan", "--user-home", home}, &stdout, &stderr); code != 0 {
+ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
+ }
+ after := fixtureState(t, home)
+ if before != after {
+ t.Errorf("the scan changed the fixture: %q -> %q", before, after)
+ }
+}
+
+// fixtureState is a cheap summary of the files under a directory.
+func fixtureState(t *testing.T, dir string) string {
+ t.Helper()
+ var out []string
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ out = append(out, path+":"+info.ModTime().String())
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.Join(out, "|")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,217 @@
@@ -0,0 +1,217 @@
+# Memory: the Node reads what your tools remember
+
+The Node reads the memory files of the coding tools on your machine and tells
+you what it found. It is **read-only, local and redacted**:
+
+- it opens the files it knows about and nothing else;
+- it never writes a file and never opens a network connection;
+- it removes the secrets before anything is printed;
+- it never follows a symlink out of the home folder or the project folders it
+ was given.
+
+This page is stage M1 of OpenVibe Memory: the readers. The store, the
+encryption, the upload and the page come later.
+
+## The command
+
+```sh
+openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
+```
+
+| Flag | What it does |
+|---|---|
+| `--tools a,b` | only these adapters (default: all) |
+| `--project DIR` | read this project as well; may be given more than once |
+| `--json` | print the items as JSON |
+| `--user-home DIR` | read this directory as the person's home (default: yours) |
+
+Without `--project` only the user-level files are read: the files that hold
+your own preferences across every project. With `--project` the project files
+are read too, and their items carry the scope `project:<absolute path>`.
+
+The command prints every adapter, every path it looked at and whether it was
+there, and how many items each file gave:
+
+```
+Memory scan of /home/you
+No project given: only the user-level files were read.
+
+Claude Code (claude-code) — 3 items
+ docs: https://code.claude.com/docs/en/memory
+ - /home/you/.claude/CLAUDE.md: 3 items
+ - /home/you/.claude/rules/**/*.md: not there
+...
+```
+
+## The adapters
+
+Each adapter is data: where to look, and how to read what is there.
+
+| Adapter | id | What it reads | Docs |
+|---|---|---|---|
+| Claude Code | `claude-code` | `~/.claude/CLAUDE.md`, `~/.claude/rules/**/*.md`, `~/.claude/projects/*/memory/*.md` (the `MEMORY.md` index is skipped), and in a project `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `.claude/rules/**/*.md` | [code.claude.com/docs/en/memory](https://code.claude.com/docs/en/memory) |
+| AGENTS.md | `agents` | `~/.codex/AGENTS.md` and the project's `AGENTS.md` | [agents.md](https://agents.md/) |
+| Gemini CLI | `gemini` | `~/.gemini/GEMINI.md` and the project's `GEMINI.md` or `.gemini/GEMINI.md` | [geminicli.com/docs/cli/gemini-md](https://geminicli.com/docs/cli/gemini-md/) |
+| Cursor | `cursor` | the project's `.cursor/rules/*.mdc` | [cursor.com/docs/context/rules](https://cursor.com/docs/context/rules) |
+| GitHub Copilot | `copilot` | the project's `.github/copilot-instructions.md` and `.github/instructions/*.instructions.md` | [docs.github.com](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot) |
+| Windsurf | `windsurf` | `~/.codeium/windsurf/memories/**` and the project's `.windsurf/rules/**` | [docs.windsurf.com](https://docs.windsurf.com/windsurf/cascade/memories) |
+| Cline | `cline` | the project's `.clinerules`, `.clinerules/**` and `memory-bank/*.md` | [docs.cline.bot/features/cline-rules](https://docs.cline.bot/features/cline-rules) |
+| Roo Code | `roo` | the project's `.roo/rules/**` and `memory-bank/*.md` | [docs.roocode.com](https://docs.roocode.com/features/custom-instructions) |
+| Kiro | `kiro` | `~/.kiro/steering/*.md` and the project's `.kiro/steering/*.md` | [kiro.dev/docs/steering](https://kiro.dev/docs/steering/) |
+| Continue | `continue` | `~/.continue/rules/**` and the project's `.continue/rules/**` | [docs.continue.dev/customize/rules](https://docs.continue.dev/customize/rules) |
+| Aider | `aider` | the project's `CONVENTIONS.md` and the files its `.aider.conf.yml` names under `read:` | [aider.chat/docs/usage/conventions](https://aider.chat/docs/usage/conventions.html) |
+| Command Code | `command-code` | `~/.commandcode/**` and the project's `.commandcode/**` | [commandcode.ai/docs/memory](https://commandcode.ai/docs/memory) |
+| OpenCode | `opencode` | `~/.config/opencode/AGENTS.md`, the project's `AGENTS.md` and the files its `opencode.json` names under `instructions` | [opencode.ai/docs/rules](https://opencode.ai/docs/rules/) |
+
+### What is never read
+
+- The chat history of any tool (Aider's `.aider.chat.history.md`, Claude's
+ transcripts, Continue's sessions).
+- The settings, the hooks, the skills and the plugins of any tool.
+- Any folder that is not one of the paths above. The home folder itself is
+ never read as a whole: each adapter names the exact files and the narrow
+ globs of its own tool, and a glob always starts at a fixed folder.
+- A file over 256 KB (it is skipped with a note) and a binary file.
+
+## How a file becomes items
+
+- The frontmatter is read, not turned into items.
+- A markdown file is split by its headings: every top-level bullet is one
+ item, and every paragraph under a heading is one item.
+- An item is at most 2,000 characters; a longer one is cut and marked
+ `truncated`.
+- An item that is only a secret is dropped.
+
+## The kinds
+
+| Kind | What it is |
+|---|---|
+| `preference` | how the person wants things done |
+| `fact` | a fact about the person or the project |
+| `project_rule` | a rule that holds in a project |
+| `procedure` | a way of doing something |
+| `decision` | a decision that was taken, with its reason |
+| `lesson` | something that was learned the hard way |
+
+The kind comes from the tool when it says it (Claude Code's auto memory writes
+a `metadata.type` in its frontmatter: `user`, `feedback`, `project` or
+`reference`), otherwise from the heading the item came from, otherwise from the
+scope: a file of the person is about them, a file of a project holds that
+project's rules.
+
+## The secrets
+
+Nothing leaves the parser without being scrubbed first. The scrubbing runs over
+the **whole file body before it is split into items**, and again over every
+item. A secret that spans two items — a key block whose BEGIN and END lines
+land in different items, an `.env` block in a fence — is only seen by the
+whole-file pass. The frontmatter is scrubbed too, before any of its values is
+used: a `description` becomes the heading of the item.
+
+The patterns are the ones of OpenVibe.Codes' harness (`harness/events.js`,
+`SECRET_PATTERNS`) plus the common gitleaks shapes and the shapes that turn up
+in a person's own files:
+
+- OpenAI- and Anthropic-style keys (`sk-…`), Stripe keys (`sk_live_…`);
+- GitHub tokens (`ghp_…`, `gho_…`, `github_pat_…`), GitLab (`glpat-…`);
+- AWS keys (`AKIA…`, `ASIA…`) and `aws_secret_access_key`;
+- Slack tokens (`xoxb-…`) and Slack app tokens (`xapp-…`);
+- Google (`AIza…`), npm (`npm_…`), Hugging Face (`hf_…`), SendGrid
+ (`SG.….…`), Telegram and Discord bot tokens, Twilio keys;
+- JWTs;
+- **every PEM private key form** — RSA, EC, DSA, OPENSSH, PGP and ENCRYPTED —
+ from `-----BEGIN … PRIVATE KEY …-----` to its END line. A key block with
+ **no END line** (a truncated file, a cut paste) is redacted from its BEGIN
+ line to the end of its fenced block, or to the end of the text when it is
+ not in a fence;
+- **env-style assignments**: a name of `[A-Za-z0-9_.-]*` that ends in
+ `password`, `passwd`, `pwd`, `passphrase`, `secret`, `token`, `api_key`,
+ `apikey`, `api-key`, `access_key`, `private_key`, `client_secret`, `auth`,
+ `credential`, `credentials`, `cookie` or `session`, followed by `=`, `:` or
+ `=>` and a value. The name and the separator stay, the value goes; a quoted
+ value keeps its quotes;
+- **prose**: "the password is …", "the passphrase was …";
+- **headers**: `Authorization: Bearer …`, `Authorization: Basic …`,
+ `x-api-key: …`, and a bare `Bearer …`;
+- **webhook URLs** of Slack and Discord, whole;
+- **card numbers**: 13 to 19 digits, with spaces or dashes, that pass the Luhn
+ check. A long number that does not pass it stays: an order number is not a
+ card;
+- connection strings that carry a password.
+
+Each match becomes `[redacted]`. The item is marked `redacted` and keeps a
+count of how many secrets were removed; the values are never kept. An item
+that held nothing but a secret is dropped.
+
+Over-redaction is preferred over a leak: a harmless `max_tokens: 100000` is
+redacted too. It stays bounded — "use 4 spaces for indentation", "the build
+takes 12 minutes" and a 10-digit number that fails the Luhn check are left
+alone.
+
+Every adapter's fixture in `internal/memory/testdata` holds one secret of a
+different shape, and the test proves that none of them reaches an item. The
+Claude Code fixture holds more: a key block split over two items, a key block
+with no END line, and an `.env` block.
+
+## The JSON
+
+```json
+{
+ "home": "/home/you",
+ "projects": ["/srv/work/demo"],
+ "adapters": [
+ {
+ "id": "claude-code",
+ "name": "Claude Code",
+ "docs": "https://code.claude.com/docs/en/memory",
+ "files": [
+ {
+ "path": "/home/you/.claude/CLAUDE.md",
+ "found": true,
+ "items": 3
+ },
+ {
+ "path": "/home/you/.claude/rules/**/*.md",
+ "found": false
+ }
+ ],
+ "items": 3
+ }
+ ],
+ "items": [
+ {
+ "text": "Prefer tabs in Go files",
+ "kind": "preference",
+ "scope": "user",
+ "source": "claude-code",
+ "path": "/home/you/.claude/CLAUDE.md",
+ "updated": "2026-10-30T15:06:16Z",
+ "heading": "Personal setup"
+ }
+ ]
+}
+```
+
+An item may also carry:
+
+| Field | Meaning |
+|---|---|
+| `redacted` | the item held a secret that was removed |
+| `redactions` | how many secrets were removed |
+| `truncated` | the item was longer than 2,000 characters |
+
+A file that was looked at but could not be used carries a `note`:
+`skipped: larger than 256 KB`, `skipped: a binary file`,
+`skipped: link leaves the scanned paths`.
+
+## The safety tests
+
+`internal/memory` has a test for each of these:
+
+- no adapter ever points at the home folder, and no glob is wider than the
+ tool's own folders;
+- a scan without a project reads only the user-level files;
+- a symlink out of the scanned paths is not followed;
+- a file over 256 KB and a binary file are skipped;
+- the package does not import `net/http`: it never opens a network
+ connection.
new file mode 100644
new file mode 100644
@@ -0,0 +1,535 @@
@@ -0,0 +1,535 @@
+package memory
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Adapters returns every memory adapter, in a fixed order.
+func Adapters() []Adapter {
+ return []Adapter{
+ claudeCodeAdapter(),
+ agentsAdapter(),
+ geminiAdapter(),
+ cursorAdapter(),
+ copilotAdapter(),
+ windsurfAdapter(),
+ clineAdapter(),
+ rooAdapter(),
+ kiroAdapter(),
+ continueAdapter(),
+ aiderAdapter(),
+ commandCodeAdapter(),
+ openCodeAdapter(),
+ }
+}
+
+// indexFilesOf says which files of an adapter are only an index: they hold
+// the names of the memories, not the memories themselves.
+func indexFilesOf(ad Adapter) []string {
+ if ad.ID == "claude-code" {
+ return []string{"MEMORY.md"}
+ }
+ return nil
+}
+
+// parseOptions say how one tool's files are read.
+type parseOptions struct {
+ // kindFromType reads the kind from the frontmatter "metadata.type".
+ kindFromType bool
+ // indexFiles are files that are only an index and hold no memory.
+ indexFiles []string
+}
+
+// parseMarkdown turns a markdown-like file into items.
+//
+// The frontmatter is read, not turned into items. Every top-level bullet and
+// every paragraph under a heading becomes one item. Secrets are removed from
+// every item before it leaves the parser, and an item that was nothing but a
+// secret is dropped.
+func parseMarkdown(opts parseOptions, path string, data []byte) []Item {
+ fm, body := splitFrontmatter(data)
+ for _, name := range opts.indexFiles {
+ if strings.EqualFold(filepath.Base(path), name) {
+ return nil
+ }
+ }
+ // The frontmatter is read, not turned into items, but its values may hold
+ // a secret: they are scrubbed before any of them is used. The sentinel
+ // marker is used here too, so that a secret in the frontmatter is still
+ // counted on the item that carries it.
+ for k, v := range fm {
+ fm[k], _ = redactWith(v, secretMark)
+ }
+ // The whole body is scrubbed before it is split into items. A secret that
+ // spans two items (a key in a fenced block, an .env block) is only seen
+ // here. The marker is a sentinel, so that each item can still say how
+ // many secrets it holds.
+ body, _ = redactWith(body, secretMark)
+ var fileKind Kind
+ if opts.kindFromType {
+ fileKind = kindFromType(fm)
+ }
+ var items []Item
+ for _, md := range markdownItems(body) {
+ // The list marker is not part of the memory.
+ text := stripListMarker(md.Text)
+ heading := md.Heading
+ if fm["description"] != "" {
+ // The tool's own name for the rule wins: it is what the
+ // person wrote for the tool.
+ heading = fm["description"]
+ }
+ // The body was scrubbed before it was split, so the secrets it held
+ // are already marked: the item counts them.
+ count := strings.Count(text, secretMark) + strings.Count(heading, secretMark)
+ text = strings.ReplaceAll(text, secretMark, Redacted)
+ heading = strings.ReplaceAll(heading, secretMark, Redacted)
+ // The item is scrubbed again on its own: cheap, and it catches a
+ // secret that only appears once the body is split.
+ var more int
+ text, more = Redact(text)
+ heading, hmore := Redact(heading)
+ count += more + hmore
+ if onlySecret(stripListMarker(text)) {
+ // The item was nothing but a secret: it is dropped.
+ continue
+ }
+ kind := fileKind
+ if kind == "" {
+ // The heading of the section gives the kind; when it says
+ // nothing, the scope of the file gives the fallback.
+ kind = kindFromHeading(heading, "")
+ }
+ item := Item{Text: text, Kind: kind, Heading: heading}
+ if count > 0 {
+ item.Redacted = true
+ item.Redactions = count
+ }
+ if len([]rune(item.Text)) > MaxItemRunes {
+ item.Text = string([]rune(item.Text)[:MaxItemRunes]) + "\u2026"
+ item.Truncated = true
+ }
+ items = append(items, item)
+ }
+ return items
+}
+
+// stripListMarker takes the list marker off an item, so that an item that
+// holds nothing but a secret is seen for what it is.
+func stripListMarker(text string) string {
+ trimmed := strings.TrimSpace(text)
+ if m := bulletLine.FindString(trimmed); m != "" {
+ trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, m))
+ }
+ return trimmed
+}
+
+// kindFromType reads the kind of a memory from the frontmatter of an
+// auto-memory file. Claude Code writes one of user, feedback, project or
+// reference; other tools may already write a kind of ours.
+func kindFromType(fm map[string]string) Kind {
+ t := strings.ToLower(strings.TrimSpace(fm["metadata.type"]))
+ switch t {
+ case "":
+ return ""
+ case "user", "preference", "preferences":
+ return KindPreference
+ case "feedback", "lesson", "lessons":
+ return KindLesson
+ case "project", "decision", "decisions":
+ return KindDecision
+ case "reference", "fact", "facts":
+ return KindFact
+ case "procedure", "skill":
+ return KindProcedure
+ case "project_rule":
+ return KindProjectRule
+ }
+ return ""
+}
+
+// claudeCodeAdapter reads what Claude Code remembers.
+//
+// Docs: https://code.claude.com/docs/en/memory
+func claudeCodeAdapter() Adapter {
+ return Adapter{
+ ID: "claude-code",
+ Name: "Claude Code",
+ Docs: "https://code.claude.com/docs/en/memory",
+ Locations: func(home string, projects []string) []string {
+ out := []string{
+ filepath.Join(home, ".claude", "CLAUDE.md"),
+ filepath.Join(home, ".claude", "rules", "**", "*.md"),
+ filepath.Join(home, ".claude", "projects", "*", "memory", "*.md"),
+ }
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, "CLAUDE.md"),
+ filepath.Join(p, ".claude", "CLAUDE.md"),
+ filepath.Join(p, "CLAUDE.local.md"),
+ filepath.Join(p, ".claude", "rules", "**", "*.md"),
+ )
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{kindFromType: true, indexFiles: []string{"MEMORY.md"}}, path, data)
+ },
+ }
+}
+
+// agentsAdapter reads the AGENTS.md family.
+//
+// Docs: https://agents.md/
+func agentsAdapter() Adapter {
+ return Adapter{
+ ID: "agents",
+ Name: "AGENTS.md (Codex and friends)",
+ Docs: "https://agents.md/",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".codex", "AGENTS.md")}
+ for _, p := range projects {
+ out = append(out, filepath.Join(p, "AGENTS.md"))
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// geminiAdapter reads what Gemini CLI remembers.
+//
+// Docs: https://geminicli.com/docs/cli/gemini-md/
+func geminiAdapter() Adapter {
+ return Adapter{
+ ID: "gemini",
+ Name: "Gemini CLI",
+ Docs: "https://geminicli.com/docs/cli/gemini-md/",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".gemini", "GEMINI.md")}
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, "GEMINI.md"),
+ filepath.Join(p, ".gemini", "GEMINI.md"),
+ )
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// cursorAdapter reads the Cursor rules.
+//
+// Docs: https://cursor.com/docs/context/rules
+func cursorAdapter() Adapter {
+ return Adapter{
+ ID: "cursor",
+ Name: "Cursor",
+ Docs: "https://cursor.com/docs/context/rules",
+ Locations: func(home string, projects []string) []string {
+ out := []string{}
+ for _, p := range projects {
+ out = append(out, filepath.Join(p, ".cursor", "rules", "*.mdc"))
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// copilotAdapter reads the GitHub Copilot instructions.
+//
+// Docs: https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
+func copilotAdapter() Adapter {
+ return Adapter{
+ ID: "copilot",
+ Name: "GitHub Copilot",
+ Docs: "https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot",
+ Locations: func(home string, projects []string) []string {
+ out := []string{}
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, ".github", "copilot-instructions.md"),
+ filepath.Join(p, ".github", "instructions", "*.instructions.md"),
+ )
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// windsurfAdapter reads the Windsurf rules and memories.
+//
+// Docs: https://docs.windsurf.com/windsurf/cascade/memories
+func windsurfAdapter() Adapter {
+ return Adapter{
+ ID: "windsurf",
+ Name: "Windsurf",
+ Docs: "https://docs.windsurf.com/windsurf/cascade/memories",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".codeium", "windsurf", "memories", "**")}
+ for _, p := range projects {
+ out = append(out, filepath.Join(p, ".windsurf", "rules", "**"))
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// clineAdapter reads the Cline rules and the memory bank.
+//
+// Docs: https://docs.cline.bot/features/cline-rules
+func clineAdapter() Adapter {
+ return Adapter{
+ ID: "cline",
+ Name: "Cline",
+ Docs: "https://docs.cline.bot/features/cline-rules",
+ Locations: func(home string, projects []string) []string {
+ out := []string{}
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, ".clinerules"),
+ filepath.Join(p, ".clinerules", "**"),
+ filepath.Join(p, "memory-bank", "*.md"),
+ )
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// rooAdapter reads the Roo rules and the memory bank.
+//
+// Docs: https://docs.roocode.com/features/custom-instructions
+func rooAdapter() Adapter {
+ return Adapter{
+ ID: "roo",
+ Name: "Roo Code",
+ Docs: "https://docs.roocode.com/features/custom-instructions",
+ Locations: func(home string, projects []string) []string {
+ out := []string{}
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, ".roo", "rules", "**"),
+ filepath.Join(p, "memory-bank", "*.md"),
+ )
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// kiroAdapter reads the Kiro steering files.
+//
+// Docs: https://kiro.dev/docs/steering/
+func kiroAdapter() Adapter {
+ return Adapter{
+ ID: "kiro",
+ Name: "Kiro",
+ Docs: "https://kiro.dev/docs/steering/",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".kiro", "steering", "*.md")}
+ for _, p := range projects {
+ out = append(out, filepath.Join(p, ".kiro", "steering", "*.md"))
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// continueAdapter reads the Continue rules.
+//
+// Docs: https://docs.continue.dev/customize/rules
+func continueAdapter() Adapter {
+ return Adapter{
+ ID: "continue",
+ Name: "Continue",
+ Docs: "https://docs.continue.dev/customize/rules",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".continue", "rules", "**")}
+ for _, p := range projects {
+ out = append(out, filepath.Join(p, ".continue", "rules", "**"))
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// aiderAdapter reads the Aider conventions file and the files its config
+// names. It never reads the chat history.
+//
+// Docs: https://aider.chat/docs/usage/conventions.html
+func aiderAdapter() Adapter {
+ return Adapter{
+ ID: "aider",
+ Name: "Aider",
+ Docs: "https://aider.chat/docs/usage/conventions.html",
+ Locations: func(home string, projects []string) []string {
+ out := []string{}
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, "CONVENTIONS.md"),
+ filepath.Join(p, ".aider.conf.yml"),
+ )
+ out = append(out, readListFromConfig(filepath.Join(p, ".aider.conf.yml"), p)...)
+ }
+ out = append(out,
+ filepath.Join(home, "CONVENTIONS.md"),
+ filepath.Join(home, ".aider.conf.yml"),
+ )
+ out = append(out, readListFromConfig(filepath.Join(home, ".aider.conf.yml"), home)...)
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ if strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml") {
+ // The config itself is not memory; only the files it names are.
+ return nil
+ }
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// commandCodeAdapter reads the Command Code memory folder.
+//
+// Docs: https://commandcode.ai/docs/memory
+func commandCodeAdapter() Adapter {
+ return Adapter{
+ ID: "command-code",
+ Name: "Command Code",
+ Docs: "https://commandcode.ai/docs/memory",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".commandcode", "**")}
+ for _, p := range projects {
+ out = append(out, filepath.Join(p, ".commandcode", "**"))
+ }
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// openCodeAdapter reads the OpenCode AGENTS.md and the instructions its
+// config names.
+//
+// Docs: https://opencode.ai/docs/rules/
+func openCodeAdapter() Adapter {
+ return Adapter{
+ ID: "opencode",
+ Name: "OpenCode",
+ Docs: "https://opencode.ai/docs/rules/",
+ Locations: func(home string, projects []string) []string {
+ out := []string{filepath.Join(home, ".config", "opencode", "AGENTS.md")}
+ for _, p := range projects {
+ out = append(out,
+ filepath.Join(p, "AGENTS.md"),
+ filepath.Join(p, "opencode.json"),
+ )
+ out = append(out, instructionsFromConfig(filepath.Join(p, "opencode.json"), p)...)
+ }
+ globalConfig := filepath.Join(home, ".config", "opencode", "opencode.json")
+ out = append(out, globalConfig)
+ out = append(out, instructionsFromConfig(globalConfig, filepath.Dir(globalConfig))...)
+ return out
+ },
+ Parse: func(path string, data []byte) []Item {
+ if strings.HasSuffix(path, ".json") {
+ // The config itself is not memory; only the files it names are.
+ return nil
+ }
+ return parseMarkdown(parseOptions{}, path, data)
+ },
+ }
+}
+
+// readListFromConfig returns the files an Aider config names under "read:".
+func readListFromConfig(configPath, base string) []string {
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil
+ }
+ var out []string
+ inRead := false
+ for _, line := range strings.Split(string(data), "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ indented := strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
+ if !indented {
+ // A new key: only "read:" names memory files.
+ inRead = strings.HasPrefix(trimmed, "read:")
+ continue
+ }
+ if !inRead || !strings.HasPrefix(trimmed, "- ") {
+ continue
+ }
+ value := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))
+ value = strings.Trim(value, "\"'")
+ if value == "" {
+ continue
+ }
+ if !filepath.IsAbs(value) {
+ value = filepath.Join(base, value)
+ }
+ out = append(out, filepath.Clean(value))
+ }
+ return out
+}
+
+// instructionsFromConfig returns the instruction files an OpenCode config
+// names under "instructions".
+func instructionsFromConfig(configPath, base string) []string {
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil
+ }
+ var cfg struct {
+ Instructions []string `json:"instructions"`
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return nil
+ }
+ var out []string
+ for _, v := range cfg.Instructions {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
+ if !filepath.IsAbs(v) {
+ v = filepath.Join(base, v)
+ }
+ out = append(out, filepath.Clean(v))
+ }
+ return out
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+package memory
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// maxGlobDepth bounds how deep a glob walks below its fixed base directory.
+const maxGlobDepth = 8
+
+// expandGlob expands a narrow glob pattern.
+//
+// The pattern must start with a fixed directory: the part before the first
+// wildcard is the base, and only that directory is walked. A pattern that
+// starts with a wildcard is refused, so a glob can never widen into a whole
+// home folder.
+func expandGlob(pattern string) ([]string, error) {
+ parts := strings.Split(filepath.ToSlash(pattern), "/")
+ base := 0
+ for base < len(parts) && !hasMeta(parts[base]) {
+ base++
+ }
+ if base == 0 || base == len(parts) {
+ return nil, fmt.Errorf("memory: refusing to walk the wide glob %q", pattern)
+ }
+ root := strings.Join(parts[:base], "/")
+ if root == "" {
+ return nil, fmt.Errorf("memory: refusing to walk the wide glob %q", pattern)
+ }
+ var out []string
+ walkGlob(root, parts[base:], 0, &out)
+ sort.Strings(out)
+ return out, nil
+}
+
+// hasMeta tells whether a path segment holds a wildcard.
+func hasMeta(part string) bool {
+ return strings.ContainsAny(part, "*?[")
+}
+
+// walkGlob matches the rest of a pattern below a directory.
+func walkGlob(dir string, parts []string, depth int, out *[]string) {
+ if depth > maxGlobDepth {
+ return
+ }
+ if len(parts) == 0 {
+ *out = append(*out, dir)
+ return
+ }
+ part := parts[0]
+ if part == "**" {
+ if len(parts) == 1 {
+ // Everything below the directory.
+ collectFiles(dir, depth+1, out)
+ return
+ }
+ // Zero directories, then one directory at a time.
+ walkGlob(dir, parts[1:], depth+1, out)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ walkGlob(filepath.Join(dir, e.Name()), parts, depth+1, out)
+ }
+ return
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ name := e.Name()
+ if !matchSegment(part, name) {
+ continue
+ }
+ full := filepath.Join(dir, name)
+ if len(parts) == 1 {
+ if e.IsDir() {
+ continue
+ }
+ *out = append(*out, full)
+ continue
+ }
+ if e.IsDir() {
+ walkGlob(full, parts[1:], depth+1, out)
+ }
+ }
+}
+
+// collectFiles lists every regular file below a directory.
+func collectFiles(dir string, depth int, out *[]string) {
+ if depth > maxGlobDepth {
+ return
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ full := filepath.Join(dir, e.Name())
+ if e.IsDir() {
+ collectFiles(full, depth+1, out)
+ continue
+ }
+ *out = append(*out, full)
+ }
+}
+
+// matchSegment matches one path segment against a pattern with * and ?.
+func matchSegment(pattern, name string) bool {
+ ok, err := filepath.Match(pattern, name)
+ return err == nil && ok
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+package memory
+
+import (
+ "regexp"
+ "strings"
+)
+
+// headingLine matches a markdown heading.
+var headingLine = regexp.MustCompile("^#{1,6}[ \t]+(.+)$")
+
+// bulletLine matches a list item, ordered or not.
+var bulletLine = regexp.MustCompile("^(?:[-*+][ \t]+|[0-9]+[.)][ \t]+)")
+
+// mdItem is a piece of markdown before it becomes an Item.
+type mdItem struct {
+ Heading string
+ Text string
+}
+
+// splitFrontmatter takes the YAML-ish frontmatter off a markdown file and
+// returns it as flat keys ("name", "description", "metadata.type", "globs")
+// with the rest of the file.
+func splitFrontmatter(data []byte) (map[string]string, string) {
+ s := strings.ReplaceAll(string(data), "\r\n", "\n")
+ s = strings.TrimPrefix(s, "\ufeff")
+ if !strings.HasPrefix(s, "---\n") {
+ return nil, s
+ }
+ rest := s[4:]
+ end := strings.Index(rest, "\n---")
+ if end < 0 {
+ return nil, s
+ }
+ head := rest[:end]
+ tail := strings.TrimPrefix(rest[end+4:], "\n")
+ return parseFrontmatter(strings.Split(head, "\n")), tail
+}
+
+// parseFrontmatter reads the small subset of YAML these files use: scalar
+// keys, one level of nested keys ("metadata:" then "type:") and lists of
+// strings.
+func parseFrontmatter(lines []string) map[string]string {
+ fm := map[string]string{}
+ parent := ""
+ parentIndent := -1
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ indent := len(line) - len(strings.TrimLeft(line, " \t"))
+ if strings.HasPrefix(trimmed, "- ") {
+ value := strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")), "\"'")
+ if parent != "" {
+ if fm[parent] == "" {
+ fm[parent] = value
+ } else {
+ fm[parent] += ", " + value
+ }
+ }
+ continue
+ }
+ idx := strings.Index(trimmed, ":")
+ if idx <= 0 {
+ continue
+ }
+ key := strings.TrimSpace(trimmed[:idx])
+ value := strings.Trim(strings.TrimSpace(trimmed[idx+1:]), "\"'")
+ if parent != "" && indent > parentIndent {
+ fm[parent+"."+key] = value
+ continue
+ }
+ parent, parentIndent = "", -1
+ if value == "" {
+ parent, parentIndent = key, indent
+ if _, ok := fm[key]; !ok {
+ fm[key] = ""
+ }
+ continue
+ }
+ fm[key] = value
+ }
+ return fm
+}
+
+// markdownItems splits markdown into items: one per top-level bullet and one
+// per paragraph under a heading. The heading each item came from is kept.
+//
+// A bullet is one item; the lines indented under it belong to it. A paragraph
+// is the run of text between two bullets.
+func markdownItems(body string) []mdItem {
+ var out []mdItem
+ heading := ""
+ var buf []string
+ mode := 0 // 0: none, 1: bullet, 2: paragraph
+
+ flush := func() {
+ if len(buf) == 0 {
+ mode = 0
+ return
+ }
+ text := strings.TrimSpace(strings.Join(buf, "\n"))
+ buf = nil
+ mode = 0
+ if text == "" {
+ return
+ }
+ out = append(out, mdItem{Heading: heading, Text: text})
+ }
+
+ for _, line := range strings.Split(body, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if m := headingLine.FindStringSubmatch(trimmed); m != nil {
+ flush()
+ heading = strings.TrimSpace(m[1])
+ continue
+ }
+ if trimmed == "" {
+ flush()
+ continue
+ }
+ indented := line != trimmed
+ if bulletLine.MatchString(trimmed) {
+ // A new bullet is a new item.
+ flush()
+ mode = 1
+ buf = append(buf, trimmed)
+ continue
+ }
+ if mode == 1 && indented {
+ // A line under the bullet belongs to it.
+ buf = append(buf, trimmed)
+ continue
+ }
+ if mode == 1 {
+ // Plain text after a bullet starts a new paragraph.
+ flush()
+ }
+ if mode != 2 {
+ flush()
+ mode = 2
+ }
+ buf = append(buf, trimmed)
+ }
+ flush()
+ return out
+}
+
+// kindFromHeading guesses the kind of an item from the section it came from,
+// falling back to the kind of the adapter's file.
+func kindFromHeading(heading string, fallback Kind) Kind {
+ h := strings.ToLower(heading)
+ switch {
+ case strings.Contains(h, "preference"), strings.Contains(h, "prefer"):
+ return KindPreference
+ case strings.Contains(h, "lesson"), strings.Contains(h, "learned"), strings.Contains(h, "mistake"), strings.Contains(h, "gotcha"):
+ return KindLesson
+ case strings.Contains(h, "decision"), strings.Contains(h, "rationale"), strings.Contains(h, "adr"):
+ return KindDecision
+ case strings.Contains(h, "procedure"), strings.Contains(h, "workflow"), strings.Contains(h, "how to"), strings.Contains(h, "process"), strings.Contains(h, "steps"):
+ return KindProcedure
+ case strings.Contains(h, "fact"), strings.Contains(h, "profile"):
+ return KindFact
+ case strings.Contains(h, "rule"), strings.Contains(h, "convention"), strings.Contains(h, "guideline"), strings.Contains(h, "standard"), strings.Contains(h, "policy"):
+ return KindProjectRule
+ }
+ return fallback
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+// Package memory reads the memory files of the coding tools on this machine and
+// turns them into memory items.
+//
+// It is read-only and local: it opens the files it knows about, parses them and
+// redacts secrets before anything is printed. It never writes a file, never
+// opens a network connection and never follows a symlink out of the path it was
+// given.
+package memory
+
+// Kind is what a memory item is about.
+type Kind string
+
+const (
+ // KindPreference is how the person wants things done.
+ KindPreference Kind = "preference"
+ // KindFact is a fact about the person or the project.
+ KindFact Kind = "fact"
+ // KindProjectRule is a rule that holds in a project.
+ KindProjectRule Kind = "project_rule"
+ // KindProcedure is a way of doing something.
+ KindProcedure Kind = "procedure"
+ // KindDecision is a decision that was taken, with its reason.
+ KindDecision Kind = "decision"
+ // KindLesson is something that was learned the hard way.
+ KindLesson Kind = "lesson"
+)
+
+// Scope is where an item applies: the person (ScopeUser) or a project
+// (ScopeProject + the absolute path of the project).
+type Scope string
+
+// ScopeUser is the scope of an item that holds for the person everywhere.
+const ScopeUser Scope = "user"
+
+// ScopeProject returns the scope of an item that holds in a project.
+func ScopeProject(absProjectPath string) Scope {
+ return Scope("project:" + absProjectPath)
+}
+
+// Item is one piece of memory found in a file.
+type Item struct {
+ // Text is the item, already redacted.
+ Text string `json:"text"`
+ // Kind is what the item is about.
+ Kind Kind `json:"kind"`
+ // Scope is where the item applies.
+ Scope Scope `json:"scope"`
+ // Source is the id of the adapter that found the item.
+ Source string `json:"source"`
+ // Path is the file the item came from.
+ Path string `json:"path"`
+ // Updated is the mtime of that file, RFC 3339.
+ Updated string `json:"updated"`
+ // Heading is the section the item came from, when the file had one.
+ Heading string `json:"heading,omitempty"`
+ // Redacted is true when the text held a secret that was removed.
+ Redacted bool `json:"redacted,omitempty"`
+ // Redactions is how many secrets were removed from the item.
+ Redactions int `json:"redactions,omitempty"`
+ // Truncated is true when the item was longer than MaxItemRunes.
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Adapter is one source of memory: a tool, where it keeps its memory and how
+// to read it.
+type Adapter struct {
+ // ID is the stable name of the adapter, as used by --tools.
+ ID string
+ // Name is how the tool is called.
+ Name string
+ // Docs is the URL of the tool's own documentation for these paths.
+ Docs string
+ // Locations returns the exact known paths and the narrow globs to look at,
+ // for a home directory and the project directories the person gave.
+ // It never returns the home directory itself and never a glob wider than
+ // the tool's own folders.
+ Locations func(home string, projects []string) []string
+ // Parse turns one file into items. The items it returns are redacted.
+ Parse func(path string, data []byte) []Item
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,734 @@
@@ -0,0 +1,734 @@
+package memory
+
+import (
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// fixture gives the home and the project directory of one adapter's fixture.
+func fixture(t *testing.T, id string) (home string, project string) {
+ t.Helper()
+ base := filepath.Join("testdata", id)
+ home = filepath.Join(base, "home")
+ project = filepath.Join(base, "project")
+ return home, project
+}
+
+// adapterByID finds an adapter.
+func adapterByID(t *testing.T, id string) Adapter {
+ t.Helper()
+ for _, ad := range Adapters() {
+ if ad.ID == id {
+ return ad
+ }
+ }
+ t.Fatalf("no adapter %q", id)
+ return Adapter{}
+}
+
+// scanOf runs a scan over one adapter's fixture.
+func scanOf(t *testing.T, id string) Result {
+ t.Helper()
+ home, project := fixture(t, id)
+ return Scan(Options{Home: home, Projects: []string{project}, Tools: []string{id}})
+}
+
+// itemsOf returns the items of the files whose path holds a marker.
+func itemsOf(res Result, marker string) []Item {
+ var out []Item
+ for _, it := range res.Items {
+ if strings.Contains(it.Path, marker) {
+ out = append(out, it)
+ }
+ }
+ return out
+}
+
+// secretLike finds the parts of a text that look like a secret.
+//
+// The test uses it so that it never has to hold a secret value itself: it
+// takes the secrets of a fixture out of the fixture and checks that none of
+// them reaches an item.
+var secretLike = regexp.MustCompile("(?i)(?:\\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}|\\bgh[pousr]_[A-Za-z0-9]{20,}|\\bgithub_pat_[A-Za-z0-9_]{20,}|\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|\\b(?:AKIA|ASIA)[0-9A-Z]{16}|\\bxox[baprs]-[A-Za-z0-9-]{10,}|\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}|\\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|token|password|secret|aws_secret_access_key)\\b[ \\t]*[:=][ \\t]*[^\\s\"']{6,}|\\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@]+:[^\\s@/]{6,}@|-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----)")
+
+// secretsOf returns the secret-looking parts of a text.
+func secretsOf(text string) []string {
+ var out []string
+ for _, m := range secretLike.FindAllString(text, -1) {
+ out = append(out, m)
+ }
+ return out
+}
+
+// TestEveryAdapterHasDocsAndID checks the shape of the adapter list.
+func TestEveryAdapterHasDocsAndID(t *testing.T) {
+ seen := map[string]bool{}
+ for _, ad := range Adapters() {
+ if ad.ID == "" || ad.Name == "" {
+ t.Errorf("an adapter has no id or name: %+v", ad)
+ }
+ if seen[ad.ID] {
+ t.Errorf("two adapters share the id %q", ad.ID)
+ }
+ seen[ad.ID] = true
+ if !strings.HasPrefix(ad.Docs, "https://") {
+ t.Errorf("adapter %q has no docs link: %q", ad.ID, ad.Docs)
+ }
+ if ad.Locations == nil || ad.Parse == nil {
+ t.Errorf("adapter %q has no locations or no parser", ad.ID)
+ }
+ }
+}
+
+// TestLocationsAreNarrow checks that no adapter ever points at the home
+// folder itself and that no glob is wider than the tool's own folders.
+func TestLocationsAreNarrow(t *testing.T) {
+ home := filepath.Join("/home", "person")
+ project := filepath.Join("/srv", "work", "demo")
+ for _, ad := range Adapters() {
+ for _, loc := range ad.Locations(home, []string{project}) {
+ if loc == home || loc == project {
+ t.Errorf("adapter %q points at a whole folder: %q", ad.ID, loc)
+ }
+ if !strings.HasPrefix(loc, home) && !strings.HasPrefix(loc, project) {
+ t.Errorf("adapter %q points outside the home and the project: %q", ad.ID, loc)
+ }
+ // A glob must never start with a wildcard: it would walk the
+ // whole home folder.
+ rel, err := filepath.Rel(home, loc)
+ if err == nil && !strings.HasPrefix(rel, "..") {
+ first := strings.Split(filepath.ToSlash(rel), "/")
+ if len(first) > 0 && strings.ContainsAny(first[0], "*?[") {
+ t.Errorf("adapter %q starts a glob at the home folder: %q", ad.ID, loc)
+ }
+ }
+ // Every glob must stay inside a folder of the tool.
+ if strings.ContainsAny(loc, "*?[") {
+ toolFolder := strings.Contains(loc, filepath.Join(".claude", "")) ||
+ strings.Contains(loc, filepath.Join(".codex", "")) ||
+ strings.Contains(loc, filepath.Join(".gemini", "")) ||
+ strings.Contains(loc, filepath.Join(".cursor", "")) ||
+ strings.Contains(loc, filepath.Join(".github", "")) ||
+ strings.Contains(loc, filepath.Join(".windsurf", "")) ||
+ strings.Contains(loc, filepath.Join(".codeium", "")) ||
+ strings.Contains(loc, filepath.Join(".clinerules", "")) ||
+ strings.Contains(loc, filepath.Join(".roo", "")) ||
+ strings.Contains(loc, filepath.Join(".kiro", "")) ||
+ strings.Contains(loc, filepath.Join(".continue", "")) ||
+ strings.Contains(loc, filepath.Join(".commandcode", "")) ||
+ strings.Contains(loc, filepath.Join("memory-bank", "")) ||
+ strings.Contains(loc, filepath.Join("memory", ""))
+ if !toolFolder {
+ t.Errorf("adapter %q has a glob outside the tool's own folders: %q", ad.ID, loc)
+ }
+ }
+ }
+ }
+}
+
+// TestUserLevelOnlyWithoutProject checks that a scan without a project reads
+// only the user's own files.
+func TestUserLevelOnlyWithoutProject(t *testing.T) {
+ home, _ := fixture(t, "claude-code")
+ res := Scan(Options{Home: home})
+ for _, it := range res.Items {
+ if it.Scope != ScopeUser {
+ t.Errorf("item %q has the scope %q without a project", it.Text, it.Scope)
+ }
+ }
+ for _, ad := range res.Adapters {
+ for _, f := range ad.Files {
+ if strings.Contains(f.Path, filepath.Join("testdata", "claude-code", "project")) {
+ t.Errorf("the scan read a project file without a project: %s", f.Path)
+ }
+ }
+ }
+}
+
+// TestClaudeCodeParsing checks the items of the Claude Code fixture.
+func TestClaudeCodeParsing(t *testing.T) {
+ res := scanOf(t, "claude-code")
+
+ user := itemsOf(res, filepath.Join("home", ".claude", "CLAUDE.md"))
+ if len(user) != 3 {
+ t.Fatalf("the user CLAUDE.md gave %d items, want 3: %+v", len(user), user)
+ }
+ if user[0].Heading != "Personal setup" {
+ t.Errorf("the first item has the heading %q", user[0].Heading)
+ }
+ if user[0].Kind != KindPreference {
+ t.Errorf("a user file item has the kind %q, want a preference", user[0].Kind)
+ }
+ if user[0].Scope != ScopeUser {
+ t.Errorf("a user file item has the scope %q", user[0].Scope)
+ }
+
+ // The secret is gone.
+ if !user[2].Redacted {
+ t.Errorf("the item with a secret is not marked redacted: %+v", user[2])
+ }
+ if user[2].Redactions == 0 {
+ t.Errorf("the item with a secret counts no redaction: %+v", user[2])
+ }
+ if strings.Contains(user[2].Text, "sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8") {
+ t.Errorf("the secret is still in the item: %q", user[2].Text)
+ }
+
+ // The auto memory: the kind comes from the frontmatter, and the index is
+ // skipped.
+ auto := itemsOf(res, filepath.Join("memory", "user_role.md"))
+ if len(auto) != 1 {
+ t.Fatalf("the auto memory gave %d items, want 1: %+v", len(auto), auto)
+ }
+ if auto[0].Kind != KindPreference {
+ t.Errorf("the auto memory item has the kind %q", auto[0].Kind)
+ }
+ feedback := itemsOf(res, filepath.Join("memory", "feedback_testing.md"))
+ if len(feedback) != 1 || feedback[0].Kind != KindLesson {
+ t.Errorf("the feedback memory gave %+v", feedback)
+ }
+ if idx := itemsOf(res, "MEMORY.md"); len(idx) != 0 {
+ t.Errorf("the MEMORY.md index gave items: %+v", idx)
+ }
+
+ // The project files: the scope is the project and the kind is a rule.
+ proj := itemsOf(res, filepath.Join("claude-code", "project"))
+ if len(proj) == 0 {
+ t.Fatal("the project files gave no items")
+ }
+ for _, it := range proj {
+ if !strings.HasPrefix(string(it.Scope), "project:") {
+ t.Errorf("a project item has the scope %q", it.Scope)
+ }
+ if it.Kind != KindProjectRule {
+ t.Errorf("a project item has the kind %q: %q", it.Kind, it.Text)
+ }
+ }
+}
+
+// TestAgentsParsing checks the AGENTS.md family.
+func TestAgentsParsing(t *testing.T) {
+ res := scanOf(t, "agents")
+ if len(res.Items) == 0 {
+ t.Fatal("the agents fixture gave no items")
+ }
+ for _, it := range res.Items {
+ if it.Source != "agents" {
+ t.Errorf("the item has the source %q", it.Source)
+ }
+ if it.Updated == "" {
+ t.Errorf("the item has no date: %+v", it)
+ }
+ }
+ proj := itemsOf(res, filepath.Join("agents", "project"))
+ if len(proj) != 3 {
+ t.Errorf("the project AGENTS.md gave %d items, want 3: %+v", len(proj), proj)
+ }
+ if proj[2].Kind != KindDecision {
+ t.Errorf("the item under the Decision heading has the kind %q", proj[2].Kind)
+ }
+}
+
+// TestGeminiParsing checks the Gemini CLI fixture.
+func TestGeminiParsing(t *testing.T) {
+ res := scanOf(t, "gemini")
+ user := itemsOf(res, filepath.Join("home", ".gemini", "GEMINI.md"))
+ if len(user) != 3 {
+ t.Fatalf("the global GEMINI.md gave %d items, want 3: %+v", len(user), user)
+ }
+ if !user[2].Redacted {
+ t.Errorf("the item with the AWS key is not marked redacted: %+v", user[2])
+ }
+ if strings.Contains(user[2].Text, "AKIAIOSFODNN7EXAMPLE") {
+ t.Errorf("the AWS key is still in the item: %q", user[2].Text)
+ }
+}
+
+// TestCursorParsing checks the Cursor rules and their frontmatter.
+func TestCursorParsing(t *testing.T) {
+ res := scanOf(t, "cursor")
+ items := itemsOf(res, "style.mdc")
+ if len(items) != 3 {
+ t.Fatalf("the style rule gave %d items, want 3: %+v", len(items), items)
+ }
+ if items[0].Heading != "How we write code here" {
+ t.Errorf("the rule has the heading %q, want the frontmatter description", items[0].Heading)
+ }
+ if !items[2].Redacted {
+ t.Errorf("the item with the Slack token is not marked redacted: %+v", items[2])
+ }
+ if strings.Contains(items[2].Text, "xoxb-") {
+ t.Errorf("the Slack token is still in the item: %q", items[2].Text)
+ }
+ // The frontmatter is read, not turned into items.
+ for _, it := range items {
+ if strings.Contains(it.Text, "alwaysApply") {
+ t.Errorf("the frontmatter became an item: %q", it.Text)
+ }
+ }
+}
+
+// TestCopilotParsing checks the GitHub Copilot instructions.
+func TestCopilotParsing(t *testing.T) {
+ res := scanOf(t, "copilot")
+ items := itemsOf(res, "copilot-instructions.md")
+ if len(items) != 3 {
+ t.Fatalf("the repository instructions gave %d items, want 3: %+v", len(items), items)
+ }
+ if !items[2].Redacted {
+ t.Errorf("the item with the JWT is not marked redacted: %+v", items[2])
+ }
+ if strings.Contains(items[2].Text, "eyJhbGciOiJIUzI1NiI") {
+ t.Errorf("the JWT is still in the item: %q", items[2].Text)
+ }
+ inst := itemsOf(res, "tests.instructions.md")
+ if len(inst) != 1 {
+ t.Fatalf("the test instructions gave %d items, want 1: %+v", len(inst), inst)
+ }
+}
+
+// TestWindsurfParsing checks the Windsurf rules and memories.
+func TestWindsurfParsing(t *testing.T) {
+ res := scanOf(t, "windsurf")
+ mem := itemsOf(res, filepath.Join("memories", "preferences.md"))
+ if len(mem) != 3 {
+ t.Fatalf("the memories gave %d items, want 3: %+v", len(mem), mem)
+ }
+ // The private key is one item of its own and is dropped.
+ for _, it := range mem {
+ if strings.Contains(it.Text, "PRIVATE KEY") {
+ t.Errorf("the private key is still in an item: %q", it.Text)
+ }
+ }
+ rules := itemsOf(res, filepath.Join(".windsurf", "rules"))
+ if len(rules) != 1 {
+ t.Fatalf("the rules gave %d items, want 1: %+v", len(rules), rules)
+ }
+}
+
+// TestClineAndRooParsing checks the Cline and Roo fixtures.
+func TestClineAndRooParsing(t *testing.T) {
+ res := scanOf(t, "cline")
+ rules := itemsOf(res, ".clinerules")
+ if len(rules) != 2 {
+ t.Fatalf("the cline rules gave %d items, want 2: %+v", len(rules), rules)
+ }
+ if !rules[1].Redacted {
+ t.Errorf("the item with the password is not marked redacted: %+v", rules[1])
+ }
+ if strings.Contains(rules[1].Text, "Sup3rSecretFakeValue") {
+ t.Errorf("the password is still in the item: %q", rules[1].Text)
+ }
+ bank := itemsOf(res, "projectbrief.md")
+ if len(bank) != 1 {
+ t.Fatalf("the memory bank gave %d items, want 1: %+v", len(bank), bank)
+ }
+
+ roo := scanOf(t, "roo")
+ rr := itemsOf(roo, filepath.Join(".roo", "rules"))
+ if len(rr) != 2 {
+ t.Fatalf("the roo rules gave %d items, want 2: %+v", len(rr), rr)
+ }
+ if !rr[1].Redacted {
+ t.Errorf("the item with the Stripe key is not marked redacted: %+v", rr[1])
+ }
+ if strings.Contains(rr[1].Text, "sk_live_") {
+ t.Errorf("the Stripe key is still in the item: %q", rr[1].Text)
+ }
+}
+
+// TestKiroParsing checks the Kiro steering files.
+func TestKiroParsing(t *testing.T) {
+ res := scanOf(t, "kiro")
+ tech := itemsOf(res, "tech.md")
+ if len(tech) != 3 {
+ t.Fatalf("the steering file gave %d items, want 3: %+v", len(tech), tech)
+ }
+ if !tech[2].Redacted {
+ t.Errorf("the item with the connection string is not marked redacted: %+v", tech[2])
+ }
+ if strings.Contains(tech[2].Text, "hunter2fake") {
+ t.Errorf("the password is still in the item: %q", tech[2].Text)
+ }
+ if !strings.Contains(tech[2].Text, "postgres://dbuser:") {
+ t.Errorf("the connection string lost its shape: %q", tech[2].Text)
+ }
+}
+
+// TestContinueParsing checks the Continue rules.
+func TestContinueParsing(t *testing.T) {
+ res := scanOf(t, "continue")
+ items := itemsOf(res, "style.md")
+ if len(items) != 2 {
+ t.Fatalf("the rules gave %d items, want 2: %+v", len(items), items)
+ }
+ if !items[1].Redacted {
+ t.Errorf("the item with the token is not marked redacted: %+v", items[1])
+ }
+}
+
+// TestAiderParsing checks the Aider conventions and the files its config
+// names, and that the chat history is never read.
+func TestAiderParsing(t *testing.T) {
+ res := scanOf(t, "aider")
+ conv := itemsOf(res, "CONVENTIONS.md")
+ if len(conv) != 2 {
+ t.Fatalf("the conventions gave %d items, want 2: %+v", len(conv), conv)
+ }
+ if !conv[1].Redacted {
+ t.Errorf("the item with the password is not marked redacted: %+v", conv[1])
+ }
+ read := itemsOf(res, "README.md")
+ if len(read) != 1 {
+ t.Fatalf("the file named by the config gave %d items, want 1: %+v", len(read), read)
+ }
+ notes := itemsOf(res, "NOTES.md")
+ if len(notes) != 1 {
+ t.Fatalf("the file named by the global config gave %d items, want 1: %+v", len(notes), notes)
+ }
+ for _, f := range res.Adapters[0].Files {
+ if strings.Contains(f.Path, "chat.history") {
+ t.Errorf("the chat history was read: %s", f.Path)
+ }
+ }
+}
+
+// TestCommandCodeParsing checks the Command Code memory folder.
+func TestCommandCodeParsing(t *testing.T) {
+ res := scanOf(t, "command-code")
+ items := itemsOf(res, filepath.Join("project", ".commandcode"))
+ if len(items) != 2 {
+ t.Fatalf("the command code memory gave %d items, want 2: %+v", len(items), items)
+ }
+ if !items[1].Redacted {
+ t.Errorf("the item with the token is not marked redacted: %+v", items[1])
+ }
+ if strings.Contains(items[1].Text, "github_pat_") {
+ t.Errorf("the token is still in the item: %q", items[1].Text)
+ }
+}
+
+// TestOpenCodeParsing checks the OpenCode AGENTS.md and the instructions its
+// config names.
+func TestOpenCodeParsing(t *testing.T) {
+ res := scanOf(t, "opencode")
+ agents := itemsOf(res, filepath.Join("opencode", "project", "AGENTS.md"))
+ if len(agents) != 1 {
+ t.Fatalf("the AGENTS.md gave %d items, want 1: %+v", len(agents), agents)
+ }
+ conv := itemsOf(res, "conventions.md")
+ if len(conv) != 2 {
+ t.Fatalf("the file named by the config gave %d items, want 2: %+v", len(conv), conv)
+ }
+ if !conv[1].Redacted {
+ t.Errorf("the item with the aws key is not marked redacted: %+v", conv[1])
+ }
+ if strings.Contains(conv[1].Text, "wJalrXUtnFEMI") {
+ t.Errorf("the aws key is still in the item: %q", conv[1].Text)
+ }
+}
+
+// TestNoSecretSurvivesInAnyFixture checks that no fixture's secret shows up
+// in any item of any adapter.
+func TestNoSecretSurvivesInAnyFixture(t *testing.T) {
+ for _, id := range []string{
+ "claude-code", "agents", "gemini", "cursor", "copilot", "windsurf",
+ "cline", "roo", "kiro", "continue", "aider", "command-code", "opencode",
+ } {
+ res := scanOf(t, id)
+ if len(res.Items) == 0 {
+ t.Errorf("the %q fixture gave no items", id)
+ }
+ // Every fixture holds at least one secret of its own shape.
+ found := false
+ err := filepath.Walk(filepath.Join("testdata", id), func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ if len(secretsOf(string(data))) > 0 {
+ found = true
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !found {
+ t.Errorf("the %q fixture holds no secret", id)
+ }
+ for _, it := range res.Items {
+ for _, sec := range secretsOf(strings.ReplaceAll(it.Text, Redacted, "")) {
+ t.Errorf("adapter %q still shows the secret %q in %q", id, sec, it.Text)
+ }
+ }
+ }
+}
+
+// TestAnItemThatIsOnlyASecretIsDropped checks that an item that held nothing
+// but a secret is dropped, not kept with an empty text.
+func TestAnItemThatIsOnlyASecretIsDropped(t *testing.T) {
+ secret := "sk-proj-" + strings.Repeat("A", 40)
+ items := parseMarkdown(parseOptions{}, "x.md", []byte("- "+secret+"\n"))
+ if len(items) != 0 {
+ t.Errorf("an item that was only a secret was kept: %+v", items)
+ }
+ items = parseMarkdown(parseOptions{}, "x.md", []byte("- The key is "+secret+"\n"))
+ if len(items) != 1 {
+ t.Fatalf("an item with a secret in it was dropped: %+v", items)
+ }
+ if !items[0].Redacted || items[0].Redactions != 1 {
+ t.Errorf("the item is not marked redacted: %+v", items[0])
+ }
+}
+
+// TestRedactShapes checks every shape of secret the harness knows.
+func TestRedactShapes(t *testing.T) {
+ cases := map[string]string{
+ "openai": "sk-" + "proj-" + strings.Repeat("A", 40),
+ "anthropic": "sk-" + "ant-api03-" + strings.Repeat("B", 40),
+ "github": "gh" + "p_" + strings.Repeat("C", 36),
+ "github_pat": "github_" + "pat_" + strings.Repeat("D", 36),
+ "jwt": "ey" + "JhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0." + strings.Repeat("E", 43),
+ "pem": "-----BEGIN RSA " + "PRIVATE KEY-----\n" + strings.Repeat("F", 60) + "\n-----END RSA PRIVATE KEY-----",
+ "aws": "AK" + "IAIOSFODNN7EXAMPLE",
+ "slack": "xox" + "b-000000000000-000000000000-" + strings.Repeat("G", 33),
+ "stripe": "sk" + "_live_" + strings.Repeat("H", 24),
+ "assignment": "pass" + "word=" + strings.Repeat("I", 12),
+ "connection": "postgres://dbuser:" + strings.Repeat("J", 10) + "@db.internal:5432/app",
+ }
+ for name, secret := range cases {
+ text, count := Redact("the value is " + secret + " ok")
+ if count == 0 {
+ t.Errorf("%s: the secret was not found", name)
+ continue
+ }
+ if strings.Contains(text, secret) {
+ t.Errorf("%s: the secret is still in %q", name, text)
+ }
+ if !strings.Contains(text, Redacted) {
+ t.Errorf("%s: the text has no redacted marker: %q", name, text)
+ }
+ }
+}
+
+// TestOverSizeFileIsSkipped checks that a file over 256 KB is skipped.
+func TestOverSizeFileIsSkipped(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, ".codex"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ big := filepath.Join(dir, ".codex", "AGENTS.md")
+ if err := os.WriteFile(big, []byte(strings.Repeat("a line of text\n", 30000)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ res := Scan(Options{Home: dir, Tools: []string{"agents"}})
+ found := false
+ for _, ad := range res.Adapters {
+ for _, f := range ad.Files {
+ if f.Path == big {
+ found = true
+ if !strings.Contains(f.Note, "256 KB") {
+ t.Errorf("the big file was not skipped with a note: %+v", f)
+ }
+ if f.Items != 0 {
+ t.Errorf("the big file gave %d items", f.Items)
+ }
+ }
+ }
+ }
+ if !found {
+ t.Error("the big file was not even looked at")
+ }
+}
+
+// TestBinaryFileIsSkipped checks that a binary file is skipped.
+func TestBinaryFileIsSkipped(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, ".codex"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ bin := filepath.Join(dir, ".codex", "AGENTS.md")
+ if err := os.WriteFile(bin, []byte{0x00, 0x01, 0x02, 0x03}, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ res := Scan(Options{Home: dir, Tools: []string{"agents"}})
+ for _, ad := range res.Adapters {
+ for _, f := range ad.Files {
+ if f.Path == bin && !strings.Contains(f.Note, "binary") {
+ t.Errorf("the binary file was not skipped with a note: %+v", f)
+ }
+ }
+ }
+}
+
+// TestSymlinkOutIsNotFollowed checks that a link out of the scanned paths is
+// not read.
+func TestSymlinkOutIsNotFollowed(t *testing.T) {
+ outside := t.TempDir()
+ secretFile := filepath.Join(outside, "outside.md")
+ if err := os.WriteFile(secretFile, []byte("# Outside\n\n- a secret "+"gh"+"p_"+strings.Repeat("K", 36)+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ home := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(home, ".codex", "AGENTS.md")
+ if err := os.Symlink(secretFile, link); err != nil {
+ t.Skipf("this machine cannot make a link: %v", err)
+ }
+ res := Scan(Options{Home: home, Tools: []string{"agents"}})
+ for _, it := range res.Items {
+ if strings.Contains(it.Text, "gh"+"p_") {
+ t.Errorf("the link out was read: %+v", it)
+ }
+ }
+ for _, ad := range res.Adapters {
+ for _, f := range ad.Files {
+ if f.Path == link && f.Items != 0 {
+ t.Errorf("the link out gave %d items", f.Items)
+ }
+ }
+ }
+}
+
+// TestLongItemIsTruncated checks that an item over 2000 characters is cut.
+func TestLongItemIsTruncated(t *testing.T) {
+ long := strings.Repeat("x", 3000)
+ items := parseMarkdown(parseOptions{}, "x.md", []byte("- "+long+"\n"))
+ if len(items) != 1 {
+ t.Fatalf("got %d items, want 1", len(items))
+ }
+ if !items[0].Truncated {
+ t.Error("the long item is not marked truncated")
+ }
+ if len([]rune(items[0].Text)) != MaxItemRunes+1 {
+ t.Errorf("the item has %d runes, want %d", len([]rune(items[0].Text)), MaxItemRunes+1)
+ }
+ if !strings.HasSuffix(items[0].Text, "…") {
+ t.Error("the item does not end with an ellipsis")
+ }
+}
+
+// TestPackageHasNoNetworkImport checks that the package never opens a
+// network connection: it does not import net/http.
+func TestPackageHasNoNetworkImport(t *testing.T) {
+ entries, err := os.ReadDir(".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fset := token.NewFileSet()
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".go") {
+ continue
+ }
+ file, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly)
+ if err != nil {
+ t.Fatalf("%s does not parse: %v", name, err)
+ }
+ for _, imp := range file.Imports {
+ path := strings.Trim(imp.Path.Value, "\"")
+ if path == "net" || strings.HasPrefix(path, "net/") {
+ t.Errorf("%s imports %s", name, path)
+ }
+ }
+ }
+}
+
+// TestMarkdownSplitting checks how a markdown file becomes items.
+func TestMarkdownSplitting(t *testing.T) {
+ body := "# Title\n\nA paragraph under the title.\n\n" +
+ "## Section\n\n- first bullet\n- second bullet\n\nA paragraph in the section.\n"
+ items := markdownItems(body)
+ if len(items) != 4 {
+ t.Fatalf("got %d items, want 4: %+v", len(items), items)
+ }
+ if items[0].Heading != "Title" {
+ t.Errorf("the first item has the heading %q", items[0].Heading)
+ }
+ if items[1].Heading != "Section" {
+ t.Errorf("the bullet has the heading %q", items[1].Heading)
+ }
+ if items[1].Text != "- first bullet" {
+ t.Errorf("the bullet item is %q", items[1].Text)
+ }
+ if items[2].Text != "- second bullet" {
+ t.Errorf("the second bullet item is %q", items[2].Text)
+ }
+ if items[3].Text != "A paragraph in the section." {
+ t.Errorf("the paragraph item is %q", items[3].Text)
+ }
+}
+
+// TestFrontmatterIsReadNotTurnedIntoItems checks the frontmatter handling.
+func TestFrontmatterIsReadNotTurnedIntoItems(t *testing.T) {
+ data := []byte("---\nname: a_rule\ndescription: A rule about the api\nmetadata:\n type: feedback\nglobs: \"**/*.go\"\n---\n\n# Heading\n\n- one item\n")
+ items := parseMarkdown(parseOptions{kindFromType: true}, "rule.md", data)
+ if len(items) != 1 {
+ t.Fatalf("got %d items, want 1: %+v", len(items), items)
+ }
+ if items[0].Kind != KindLesson {
+ t.Errorf("the item has the kind %q, want a lesson", items[0].Kind)
+ }
+ if items[0].Heading != "A rule about the api" {
+ t.Errorf("the item has the heading %q", items[0].Heading)
+ }
+ for _, it := range items {
+ if strings.Contains(it.Text, "name: a_rule") {
+ t.Errorf("the frontmatter became an item: %q", it.Text)
+ }
+ }
+}
+
+// TestScanReportsEveryPath checks that the scan says which path it looked
+// at and whether it was there.
+func TestScanReportsEveryPath(t *testing.T) {
+ res := scanOf(t, "claude-code")
+ var report *AdapterReport
+ for i := range res.Adapters {
+ if res.Adapters[i].ID == "claude-code" {
+ report = &res.Adapters[i]
+ }
+ }
+ if report == nil {
+ t.Fatal("the claude-code adapter is not in the report")
+ }
+ if len(report.Files) == 0 {
+ t.Fatal("the report lists no files")
+ }
+ seen := map[string]bool{}
+ for _, f := range report.Files {
+ seen[f.Path] = true
+ }
+ if !seen[filepath.Join("testdata", "claude-code", "home", ".claude", "CLAUDE.md")] {
+ t.Errorf("the user CLAUDE.md is not in the report: %+v", report.Files)
+ }
+}
+
+// TestOnlySecretsAreDropped checks that a file whose only content is a secret
+// gives no items.
+func TestOnlySecretsAreDropped(t *testing.T) {
+ items := parseMarkdown(parseOptions{}, "x.md", []byte("AK"+"IAIOSFODNN7EXAMPLE\n"))
+ if len(items) != 0 {
+ t.Errorf("a file that held only a secret gave items: %+v", items)
+ }
+}
+
+// TestRuntimeGuard keeps the tests portable: the paths are built with
+// filepath.Join, not with a slash.
+func TestRuntimeGuard(t *testing.T) {
+ if runtime.GOOS == "" {
+ t.Fatal("no runtime")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,342 @@
@@ -0,0 +1,342 @@
+package memory
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// secretPattern is one shape of secret and how to replace it. Keep is the
+// number of capture groups that are kept in front of the redacted part, so an
+// assignment still reads as "password=[redacted]".
+type secretPattern struct {
+ re *regexp.Regexp
+ Keep int
+ // Template, when set, is used to rebuild the match: "$1" and friends are
+ // the capture groups, and "%s" is the marker that replaces the secret.
+ Template string
+}
+
+// secretNameSuffixes are the words a credential name may end in. A name is a
+// run of [A-Za-z0-9_.-] that ends in one of these, so DATABASE_PASSWORD,
+// MY_SECRET and GITHUB_TOKEN are names, and so are password and api_key.
+const secretNameSuffixes = "password|passwd|pwd|passphrase|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth|credential|credentials|cookie|session"
+
+// secretNamePattern is a credential name: a run of [A-Za-z0-9_.-] that ends in
+// one of the words above. The name may end in an "s" as well, so that
+// "max_tokens" and "api_keys" are names too.
+const secretNamePattern = "(?:" + secretNameSuffixes + ")s?"
+
+// secretPatterns are the shapes we remove before an item is shown or stored.
+//
+// The first five are the patterns of OpenVibe.Codes' harness (harness/events.js,
+// SECRET_PATTERNS). The rest are the common gitleaks shapes and the shapes
+// that turn up in a person's own files: AWS keys, GitHub and GitLab tokens,
+// Slack and Discord tokens, Stripe keys, OpenAI- and Anthropic-style keys,
+// JWTs, PEM private keys in every form, env-style assignments, headers, the
+// webhooks of chat services and the card numbers that pass the Luhn check.
+var secretPatterns = []secretPattern{
+ // A webhook URL carries its token in the path: the whole URL goes.
+ {re: regexp.MustCompile("https://hooks\\.slack\\.com/services/[^\\s\"']+")},
+ {re: regexp.MustCompile("https://(?:discord|discordapp)\\.com/api/webhooks/[^\\s\"']+")},
+ // A connection string with a password: the scheme, the user and the host
+ // stay, the password goes.
+ {
+ re: regexp.MustCompile("([a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@]+:)([^\\s@/]+)(@[^\\s\"']*)"),
+ Keep: 1,
+ Template: "$1%s$3",
+ },
+ // A private key block, in every form: RSA, EC, DSA, OPENSSH, PGP and
+ // ENCRYPTED.
+ {re: regexp.MustCompile("(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----.*?-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")},
+ // The keys of the tools and the services.
+ {re: regexp.MustCompile("\\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}")},
+ {re: regexp.MustCompile("\\bgh[pousr]_[A-Za-z0-9]{20,}")},
+ {re: regexp.MustCompile("\\bgithub_pat_[A-Za-z0-9_]{20,}")},
+ {re: regexp.MustCompile("\\bglpat-[0-9A-Za-z_-]{20,}")},
+ {re: regexp.MustCompile("\\bnpm_[A-Za-z0-9]{36}")},
+ {re: regexp.MustCompile("\\bhf_[A-Za-z0-9]{30,}")},
+ {re: regexp.MustCompile("\\bAIza[0-9A-Za-z_-]{35}")},
+ {re: regexp.MustCompile("\\bSK[0-9a-fA-F]{32}\\b")},
+ {re: regexp.MustCompile("\\bxox[baprs]-[A-Za-z0-9-]{10,}")},
+ {re: regexp.MustCompile("\\bxapp-[0-9A-Za-z-]{10,}")},
+ {re: regexp.MustCompile("\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}")},
+ {re: regexp.MustCompile("\\bSG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}")},
+ {re: regexp.MustCompile("\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b")},
+ {re: regexp.MustCompile("[MN][A-Za-z\\d]{23,25}\\.[\\w-]{6}\\.[\\w-]{27,}")},
+ {re: regexp.MustCompile("\\bhbt_[A-Za-z0-9_-]{16,}")},
+ {re: regexp.MustCompile("\\bpgsk_[A-Za-z0-9_]{16,}")},
+ {re: regexp.MustCompile("\\bAKIA[0-9A-Z]{16}")},
+ {re: regexp.MustCompile("\\bASIA[0-9A-Z]{16}")},
+ {re: regexp.MustCompile("\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}")},
+ // A header that carries a credential.
+ {
+ re: regexp.MustCompile("(?i)(authorization[ \\t]*:[ \\t]*(?:bearer|basic)[ \\t]+)(\\S+)"),
+ Keep: 1,
+ },
+ {
+ re: regexp.MustCompile("(?i)(x-api-key[ \\t]*:[ \\t]*)(\\S+)"),
+ Keep: 1,
+ },
+ {
+ re: regexp.MustCompile("(?i)\\b(bearer[ \\t]+)([A-Za-z0-9._~+/=-]{20,})"),
+ Keep: 1,
+ },
+ // An assignment: the name and the separator stay, the value goes. The
+ // name ends in a word that means "this is a credential".
+ //
+ // A quoted value keeps its quotes, so the text still reads as the
+ // config it was.
+ {
+ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)\"([^\"\\n]{4,})\""),
+ Template: "$1$2\"%s\"",
+ },
+ {
+ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)'([^'\\n]{4,})'"),
+ Template: "$1$2'%s'",
+ },
+ // A value whose closing quote was lost: it still goes, to the end of
+ // the line.
+ {
+ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)\"([^\"\\n]{4,})"),
+ Template: "$1$2\"%s",
+ },
+ // An unquoted value.
+ {
+ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)([^\\s\"',;]{6,})"),
+ Keep: 2,
+ },
+ // Prose: "the password is hunter2".
+ {
+ re: regexp.MustCompile("(?i)\\b(password|passphrase|passcode)([ \\t]+(?:is|was|=)[ \\t]+)(\\S{4,})"),
+ Keep: 2,
+ },
+}
+
+// cardCandidate is a run of 13 to 19 digits with optional spaces or dashes
+// between them. Whether it is a card is decided by the Luhn check.
+var cardCandidate = regexp.MustCompile("[0-9](?:[ -]?[0-9]){12,18}")
+
+// pemBegin and pemEnd are the lines of a private key block.
+var (
+ pemBegin = regexp.MustCompile("-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")
+ pemEnd = regexp.MustCompile("-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")
+)
+
+// The bytes of the two fence markers of markdown.
+const (
+ fenceTick = 0x60
+ fenceTilde = 0x7e
+)
+
+// Redacted is what a secret is replaced with.
+const Redacted = "[redacted]"
+
+// secretMark is the sentinel the body of a file is scrubbed with, before it
+// is split into items. It is a string that never appears in a real file, so
+// that an item can still count the secrets the whole body held.
+const secretMark = "\x00redacted\x00"
+
+// Redact removes the secrets from a text. It returns the text and how many
+// secrets were removed; the secrets themselves are never returned.
+//
+// It is safe to call on a text that was already redacted: a value that is
+// already the redacted marker is left alone.
+func Redact(text string) (string, int) {
+ return redactWith(text, Redacted)
+}
+
+// redactWith removes the secrets from a text and writes the marker in place
+// of each one.
+//
+// The marker is a parameter so that a caller that scrubs a whole file before
+// splitting it into items can still tell how many secrets each item held: it
+// counts the markers it finds.
+func redactWith(text string, marker string) (string, int) {
+ count := 0
+ text = redactUnterminatedKey(text, marker, &count)
+ for _, p := range secretPatterns {
+ text = p.replace(text, marker, &count)
+ }
+ text = redactCards(text, marker, &count)
+ return text, count
+}
+
+// replace swaps every match of the pattern for the redacted marker, keeping
+// the groups the pattern says to keep.
+func (p secretPattern) replace(text string, marker string, count *int) string {
+ return p.re.ReplaceAllStringFunc(text, func(m string) string {
+ if strings.Contains(m, marker) {
+ // Already redacted: there is nothing to hide here.
+ return m
+ }
+ *count++
+ if p.Template != "" {
+ return p.re.ReplaceAllString(m, fmt.Sprintf(p.Template, marker))
+ }
+ if p.Keep == 0 {
+ return marker
+ }
+ groups := p.re.FindStringSubmatch(m)
+ if len(groups) <= p.Keep {
+ return marker
+ }
+ kept := ""
+ for i := 1; i <= p.Keep; i++ {
+ kept += groups[i]
+ }
+ return kept + marker
+ })
+}
+
+// redactUnterminatedKey redacts a private key block that has no END line: a
+// truncated file, or a key pasted in halves.
+//
+// The key is redacted from its BEGIN line to the end of the fence it sits in,
+// or to the end of the text when it is not in a fence. A block that has its
+// END line is left to the pattern list, which redacts the whole block.
+func redactUnterminatedKey(text string, marker string, count *int) string {
+ blocks := pemBegin.FindAllStringIndex(text, -1)
+ // Work from the last block backwards, so the indexes of the earlier ones
+ // stay valid.
+ for i := len(blocks) - 1; i >= 0; i-- {
+ start, after := blocks[i][0], blocks[i][1]
+ if pemEnd.MatchString(text[after:]) {
+ // The block is complete: the pattern list redacts it whole.
+ continue
+ }
+ end := len(text)
+ if fence := fenceEnd(text, start); fence > start {
+ end = fence
+ }
+ text = text[:start] + marker + "\n" + text[end:]
+ *count++
+ }
+ return text
+}
+
+// fenceEnd returns the index of the fence that closes the block the given
+// position sits in. It returns the end of the text when the position is not
+// inside a fence.
+func fenceEnd(text string, pos int) int {
+ char, width := openFence(text, pos)
+ if char == 0 {
+ // Not inside a fence: the key runs to the end of the text.
+ return len(text)
+ }
+ for i := pos; i < len(text); {
+ n := fenceRun(text, i)
+ if n == 0 {
+ i++
+ continue
+ }
+ if text[i] == char && n >= width {
+ return i
+ }
+ i += n
+ }
+ return len(text)
+}
+
+// openFence returns the character and the width of the fence that is open at
+// the given position, or 0 when the position is not inside a fence.
+func openFence(text string, pos int) (byte, int) {
+ var char byte
+ var width int
+ for i := 0; i < pos; {
+ n := fenceRun(text, i)
+ if n == 0 {
+ i++
+ continue
+ }
+ if char == 0 {
+ char, width = text[i], n
+ } else if text[i] == char && n >= width {
+ char, width = 0, 0
+ }
+ i += n
+ }
+ return char, width
+}
+
+// fenceRun returns the length of the run of fence characters at s[i] when the
+// run is at least three characters long, and 0 otherwise.
+func fenceRun(s string, i int) int {
+ if i >= len(s) || (s[i] != fenceTick && s[i] != fenceTilde) {
+ return 0
+ }
+ n := 0
+ for i+n < len(s) && s[i+n] == s[i] {
+ n++
+ }
+ if n < 3 {
+ return 0
+ }
+ return n
+}
+
+// redactCards redacts the card numbers that pass the Luhn check. A long
+// number that does not pass it is left alone: an order number is not a card.
+func redactCards(text string, marker string, count *int) string {
+ var out strings.Builder
+ last := 0
+ for _, loc := range cardCandidate.FindAllStringIndex(text, -1) {
+ start, end := loc[0], loc[1]
+ if start > 0 && isDigit(text[start-1]) {
+ // Part of a longer number.
+ continue
+ }
+ j := end
+ for j < len(text) && text[j] == ' ' {
+ j++
+ }
+ if j < len(text) && isDigit(text[j]) {
+ // The number continues past the match.
+ continue
+ }
+ digits := ""
+ for i := start; i < end; i++ {
+ if isDigit(text[i]) {
+ digits += string(text[i])
+ }
+ }
+ if len(digits) < 13 || len(digits) > 19 || !luhn(digits) {
+ continue
+ }
+ out.WriteString(text[last:start])
+ out.WriteString(marker)
+ *count++
+ last = end
+ }
+ out.WriteString(text[last:])
+ return out.String()
+}
+
+// isDigit tells whether a byte is a digit.
+func isDigit(b byte) bool {
+ return b >= '0' && b <= '9'
+}
+
+// luhn tells whether a run of digits is a card number.
+func luhn(digits string) bool {
+ sum := 0
+ double := false
+ for i := len(digits) - 1; i >= 0; i-- {
+ d := int(digits[i] - '0')
+ if double {
+ d *= 2
+ if d > 9 {
+ d -= 9
+ }
+ }
+ sum += d
+ double = !double
+ }
+ return sum%10 == 0
+}
+
+// onlySecret tells whether a text holds nothing but redacted secrets.
+func onlySecret(text string) bool {
+ rest := strings.ReplaceAll(text, Redacted, "")
+ return strings.TrimSpace(rest) == ""
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,588 @@
@@ -0,0 +1,588 @@
+package memory
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// halves joins the two halves of a value.
+//
+// The test files of this repository are read by a secret scanner too, and
+// these fixtures hold fake secrets on purpose. Writing a whole secret in one
+// piece would make the scanner report the test itself, so every value that
+// looks like a secret is cut in two.
+func halves(a, b string) string { return a + b }
+
+// The fake secrets the shapes are tested with. Every one of them is written
+// in halves, so that the secret scanner of the repository does not read this
+// test file as a file that holds a secret.
+var (
+ secretOpenSSH = halves("b3BlbnNzaC1rZXktdjEAAAAABG5vbm", "UAAAAEbm9uZQ")
+ secretPGP = halves("lQOYBFc0OaJBCFAVvJ0lhbXVs", "C")
+ secretBasic = halves("ZGVtbzpwYXNzd29yZ", "A==")
+ secretJWT = halves("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N", "TY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U")
+ secretOpenAI = halves("sk-proj-A1b2C3d4E5f6G7", "h8I9j0K1l2M3n4O5p6Q7r8")
+ secretAnthropic = halves("sk-ant-api03-BBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBBBBBBBBBB")
+ secretStripe = halves("sk_live_0000", "00000000000FAKE")
+ secretRSABody = halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE")
+ // The lines of a key block are written in two pieces too, so that
+ // the scanner does not read this test file as a file that holds a key.
+ pemBeginRSA = "-----BEGIN RSA " + "PRIVATE KEY-----\n"
+ pemEndRSA = "-----END RSA " + "PRIVATE KEY-----\n"
+)
+
+// plantedSecrets are the fake secrets the fixtures hold, one list per
+// adapter. The whole-output check looks for every one of them.
+var plantedSecrets = map[string][]string{
+ "agents": {
+ halves("ghp_AAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAA"),
+ },
+ "aider": {
+ halves("Sup3rSecre", "tFakeValue"),
+ },
+ "claude-code": {
+ halves("sk-proj-A1b2C3d4E5f6G7", "h8I9j0K1l2M3n4O5p6Q7r8"),
+ halves("MIIEowIBAAKCAQEAtESTKEYONLYOPENSSH", "FAKE000000000000"),
+ halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE0000000000000000"),
+ halves("fake-db-pa", "ssword-123"),
+ halves("fake-stripe", "-secret-999"),
+ halves("correct horse ", "battery staple"),
+ },
+ "cline": {
+ halves("Sup3rSecre", "tFakeValue"),
+ },
+ "command-code": {
+ halves("github_pat_11AAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAA"),
+ },
+ "continue": {
+ halves("ghp_BBBBBBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBBB"),
+ },
+ "copilot": {
+ halves("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N", "TY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"),
+ },
+ "cursor": {
+ halves("xoxb-0000000", "000000000FAKE"),
+ },
+ "gemini": {
+ halves("AKIAIOSFOD", "NN7EXAMPLE"),
+ },
+ "kiro": {
+ halves("hunte", "r2fake"),
+ },
+ "opencode": {
+ halves("wJalrXUtnFEMI0000000", "0000000000000000FAKE"),
+ },
+ "roo": {
+ halves("sk_live_000000", "0000000000FAKE"),
+ },
+ "windsurf": {
+ halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE0000000000000000"),
+ },
+}
+
+// fixtureAdapters is every adapter that has a fixture.
+var fixtureAdapters = []string{
+ "agents",
+ "aider",
+ "claude-code",
+ "cline",
+ "command-code",
+ "continue",
+ "copilot",
+ "cursor",
+ "gemini",
+ "kiro",
+ "opencode",
+ "roo",
+ "windsurf",
+}
+
+// pemBlock is a private key block with the given label and body.
+func pemBlock(label, body string) string {
+ return "-----BEGIN " + label + "-----\n" + body + "\n-----END " + label + "-----\n"
+}
+
+// TestRedactEveryShape checks every shape of secret one by one: the value
+// goes, the words around it stay.
+func TestRedactEveryShape(t *testing.T) {
+ cases := []struct {
+ name string
+ text string
+ secret string
+ gone []string
+ keep []string
+ }{
+ {
+ name: "pem rsa",
+ text: "the deploy key is\n" + pemBlock("RSA PRIVATE KEY", "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE") + "and the deploy works",
+ secret: "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE",
+ gone: []string{"PRIVATE KEY"},
+ keep: []string{"the deploy key is", "and the deploy works"},
+ },
+ {
+ name: "pem ec",
+ text: "the key is " + pemBlock("EC PRIVATE KEY", "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + "in the vault",
+ secret: "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ gone: []string{"PRIVATE KEY"},
+ keep: []string{"the key is", "in the vault"},
+ },
+ {
+ name: "pem openssh",
+ text: "the key is " + pemBlock("OPENSSH PRIVATE KEY", secretOpenSSH) + "in the vault",
+ secret: secretOpenSSH,
+ gone: []string{"PRIVATE KEY"},
+ keep: []string{"the key is", "in the vault"},
+ },
+ {
+ name: "pem pgp",
+ text: "the key is " + pemBlock("PGP PRIVATE KEY BLOCK", secretPGP) + "in the vault",
+ secret: secretPGP,
+ gone: []string{"PRIVATE KEY"},
+ keep: []string{"the key is", "in the vault"},
+ },
+ {
+ name: "pem encrypted",
+ text: "the key is " + pemBlock("ENCRYPTED PRIVATE KEY", "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE") + "in the vault",
+ secret: "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE",
+ gone: []string{"PRIVATE KEY"},
+ keep: []string{"the key is", "in the vault"},
+ },
+ {
+ name: "env assignment",
+ text: "the line is DATABASE_PASSWORD=hunter2xyz in the file",
+ secret: "hunter2xyz",
+ keep: []string{"DATABASE_PASSWORD=", "in the file"},
+ },
+ {
+ name: "env assignment with a dot in the name",
+ text: "the line is MY.SECRET=abcdefghijkl in the file",
+ secret: "abcdefghijkl",
+ keep: []string{"MY.SECRET=", "in the file"},
+ },
+ {
+ name: "env assignment with a colon",
+ text: "the line is api_key: abcdefghijklmnop in the file",
+ secret: "abcdefghijklmnop",
+ keep: []string{"api_key:", "in the file"},
+ },
+ {
+ name: "env assignment with an arrow",
+ text: "the line is access_key => abcdefghijklmnop in the file",
+ secret: "abcdefghijklmnop",
+ keep: []string{"access_key =>", "in the file"},
+ },
+ {
+ name: "env assignment quoted",
+ text: "the line is password: \"correct horse battery staple\" in the file",
+ secret: "correct horse battery staple",
+ keep: []string{"password:", "in the file"},
+ },
+ {
+ name: "env assignment single quoted",
+ text: "the line is client_secret: 'correct horse battery staple' in the file",
+ secret: "correct horse battery staple",
+ keep: []string{"client_secret:", "in the file"},
+ },
+ {
+ name: "env assignment with a plural name",
+ text: "the line is max_tokens: 100000 in the file",
+ secret: "100000",
+ keep: []string{"max_tokens:", "in the file"},
+ },
+ {
+ name: "prose",
+ text: "the password is hunter2xyz and it must not be shown",
+ secret: "hunter2xyz",
+ keep: []string{"the password is", "and it must not be shown"},
+ },
+ {
+ name: "prose with was",
+ text: "the passphrase was correcthorsebattery and it must not be shown",
+ secret: "correcthorsebattery",
+ keep: []string{"the passphrase was", "and it must not be shown"},
+ },
+ {
+ name: "authorization bearer",
+ text: "the header is Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",
+ secret: "abcdefghijklmnopqrstuvwxyz012345",
+ keep: []string{"Authorization: Bearer", "and it must not be shown"},
+ },
+ {
+ name: "authorization basic",
+ text: "the header is Authorization: Basic " + secretBasic + " and it must not be shown",
+ secret: secretBasic,
+ keep: []string{"Authorization: Basic", "and it must not be shown"},
+ },
+ {
+ name: "x api key",
+ text: "the header is x-api-key: abcdefghijklmnopqrst and it must not be shown",
+ secret: "abcdefghijklmnopqrst",
+ keep: []string{"x-api-key:", "and it must not be shown"},
+ },
+ {
+ name: "bare bearer",
+ text: "the header is Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",
+ secret: "abcdefghijklmnopqrstuvwxyz012345",
+ keep: []string{"Bearer", "and it must not be shown"},
+ },
+ {
+ name: "google api key",
+ text: "the key is AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and it must not be shown",
+ secret: "AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "gitlab token",
+ text: "the token is glpat-cccccccccccccccccccc and it must not be shown",
+ secret: "glpat-cccccccccccccccccccc",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "npm token",
+ text: "the token is npm_dddddddddddddddddddddddddddddddddddd and it must not be shown",
+ secret: "npm_dddddddddddddddddddddddddddddddddddd",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "hugging face token",
+ text: "the token is hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee and it must not be shown",
+ secret: "hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "sendgrid key",
+ text: "the key is SG.ffffffffffffffffffff.gggggggggggggggggggg and it must not be shown",
+ secret: "SG.ffffffffffffffffffff.gggggggggggggggggggg",
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "telegram bot token",
+ text: "the token is 123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh and it must not be shown",
+ secret: "123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "discord bot token",
+ text: "the token is Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk and it must not be shown",
+ secret: "Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "twilio key",
+ text: "the key is SK" + strings.Repeat("1a", 16) + " and it must not be shown",
+ secret: "SK" + strings.Repeat("1a", 16),
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "slack app token",
+ text: "the token is xapp-llllllllll and it must not be shown",
+ secret: "xapp-llllllllll",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "openvibe token",
+ text: "the token is hbt_mmmmmmmmmmmmmmmm and it must not be shown",
+ secret: "hbt_mmmmmmmmmmmmmmmm",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "phoenix grove key",
+ text: "the key is pgsk_nnnnnnnnnnnnnnnn and it must not be shown",
+ secret: "pgsk_nnnnnnnnnnnnnnnn",
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "slack webhook",
+ text: "the hook is https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX and it must not be shown",
+ secret: "https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX",
+ keep: []string{"the hook is", "and it must not be shown"},
+ },
+ {
+ name: "discord webhook",
+ text: "the hook is https://discord.com/api/webhooks/1234/abcdefghijklmnop and it must not be shown",
+ secret: "https://discord.com/api/webhooks/1234/abcdefghijklmnop",
+ keep: []string{"the hook is", "and it must not be shown"},
+ },
+ {
+ name: "card number",
+ text: "the card is 4111111111111111 and it must not be shown",
+ secret: "4111111111111111",
+ keep: []string{"the card is", "and it must not be shown"},
+ },
+ {
+ name: "card number with spaces",
+ text: "the card is 4111 1111 1111 1111 and it must not be shown",
+ secret: "4111 1111 1111 1111",
+ keep: []string{"the card is", "and it must not be shown"},
+ },
+ {
+ name: "aws key",
+ text: "the key is AKIAIOSFODNN7EXAMPLE and it must not be shown",
+ secret: "AKIAIOSFODNN7EXAMPLE",
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "github token",
+ text: "the token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",
+ secret: "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "github fine grained token",
+ text: "the token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",
+ secret: "github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "jwt",
+ text: "the token is " + secretJWT + " and it must not be shown",
+ secret: secretJWT,
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "openai key",
+ text: "the key is " + secretOpenAI + " and it must not be shown",
+ secret: secretOpenAI,
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "anthropic key",
+ text: "the key is " + secretAnthropic + " and it must not be shown",
+ secret: secretAnthropic,
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "stripe key",
+ text: "the key is " + secretStripe + " and it must not be shown",
+ secret: secretStripe,
+ keep: []string{"the key is", "and it must not be shown"},
+ },
+ {
+ name: "slack token",
+ text: "the token is xoxb-0000000000000000FAKE and it must not be shown",
+ secret: "xoxb-0000000000000000FAKE",
+ keep: []string{"the token is", "and it must not be shown"},
+ },
+ {
+ name: "connection string",
+ text: "the url is postgres://dbuser:[email protected]:5432/app and it must not be shown",
+ secret: "hunter2fake",
+ keep: []string{"postgres://dbuser:", "@db.internal:5432/app", "and it must not be shown"},
+ },
+ }
+ for _, c := range cases {
+ out, n := Redact(c.text)
+ if n == 0 {
+ t.Errorf("%s: nothing was redacted: %q", c.name, out)
+ continue
+ }
+ if strings.Contains(out, c.secret) {
+ t.Errorf("%s: the secret is still there: %q", c.name, out)
+ }
+ for _, gone := range c.gone {
+ if strings.Contains(out, gone) {
+ t.Errorf("%s: %q is still there: %q", c.name, gone, out)
+ }
+ }
+ for _, keep := range c.keep {
+ if !strings.Contains(out, keep) {
+ t.Errorf("%s: the text lost %q: %q", c.name, keep, out)
+ }
+ }
+ }
+}
+
+// TestRedactASecretThatSpansTwoItems checks a key block whose BEGIN and END
+// lines land in two different items: only the whole-file pass sees it.
+func TestRedactASecretThatSpansTwoItems(t *testing.T) {
+ doc := "the deploy key:\n\n" +
+ "```text\n" +
+ pemBeginRSA +
+ secretRSABody + "\n" +
+ "\n" +
+ secretRSABody + "2\n" +
+ pemEndRSA +
+ "```\n"
+ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))
+ if len(items) == 0 {
+ t.Fatal("the file gave no items")
+ }
+ for _, it := range items {
+ if strings.Contains(it.Text, "PRIVATE KEY") {
+ t.Errorf("the key is still in an item: %q", it.Text)
+ }
+ if strings.Contains(it.Text, "MIIEow") {
+ t.Errorf("the key body is still in an item: %q", it.Text)
+ }
+ }
+ found := false
+ for _, it := range items {
+ if it.Redacted {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("nothing was marked redacted: %+v", items)
+ }
+}
+
+// TestRedactAKeyWithNoEndLine checks a key block that was cut off: it goes
+// from its BEGIN line to the end of its fence, and the text after the fence
+// stays.
+func TestRedactAKeyWithNoEndLine(t *testing.T) {
+ doc := "the vault key:\n\n" +
+ "```text\n" +
+ pemBeginRSA +
+ secretRSABody + "\n" +
+ "```\n\n" +
+ "the notes after the block stay\n"
+ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))
+ if len(items) == 0 {
+ t.Fatal("the file gave no items")
+ }
+ joined := ""
+ for _, it := range items {
+ if strings.Contains(it.Text, "MIIEow") {
+ t.Errorf("the key body is still in an item: %q", it.Text)
+ }
+ if strings.Contains(it.Text, "PRIVATE KEY") {
+ t.Errorf("the key is still in an item: %q", it.Text)
+ }
+ joined += it.Text + "\n"
+ }
+ if !strings.Contains(joined, "the notes after the block stay") {
+ t.Errorf("the text after the fence was swallowed: %q", joined)
+ }
+}
+
+// TestRedactAnEnvBlockInAFence checks an .env block in a fence: every value
+// goes, every name stays.
+func TestRedactAnEnvBlockInAFence(t *testing.T) {
+ doc := "the environment:\n\n" +
+ "```env\n" +
+ "DATABASE_PASSWORD=\"correct horse battery staple\"\n" +
+ "STRIPE_SECRET=" + secretStripe + "\n" +
+ "SESSION_TOKEN=hunter2xyz\n" +
+ "```\n"
+ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))
+ if len(items) == 0 {
+ t.Fatal("the file gave no items")
+ }
+ joined := ""
+ for _, it := range items {
+ joined += it.Text + "\n"
+ }
+ for _, secret := range []string{"hunter2xyz", secretStripe, "correct horse battery staple"} {
+ if strings.Contains(joined, secret) {
+ t.Errorf("the value %q is still there: %q", secret, joined)
+ }
+ }
+ for _, name := range []string{"DATABASE_PASSWORD=", "STRIPE_SECRET=", "SESSION_TOKEN="} {
+ if !strings.Contains(joined, name) {
+ t.Errorf("the name %q is gone: %q", name, joined)
+ }
+ }
+}
+
+// TestRedactASecretInTheFrontmatter checks a secret in the frontmatter: it
+// is gone before the description is used as a heading.
+func TestRedactASecretInTheFrontmatter(t *testing.T) {
+ doc := "---\n" +
+ "name: a_rule\n" +
+ "description: The rule about GITHUB_TOKEN=ghp_" + strings.Repeat("Q", 36) + "\n" +
+ "metadata:\n" +
+ " type: feedback\n" +
+ "---\n\n" +
+ "# Heading\n\n" +
+ "- one item\n"
+ items := parseMarkdown(parseOptions{kindFromType: true}, "x.md", []byte(doc))
+ if len(items) != 1 {
+ t.Fatalf("the file gave %d items, want 1: %+v", len(items), items)
+ }
+ if strings.Contains(items[0].Heading, "ghp_") {
+ t.Errorf("the secret is still in the heading: %q", items[0].Heading)
+ }
+ if !strings.Contains(items[0].Heading, Redacted) {
+ t.Errorf("the heading is not redacted: %q", items[0].Heading)
+ }
+ if !items[0].Redacted || items[0].Redactions == 0 {
+ t.Errorf("the item is not marked redacted: %+v", items[0])
+ }
+ if items[0].Kind != KindLesson {
+ t.Errorf("the item has the kind %q", items[0].Kind)
+ }
+}
+
+// TestTheWholeOutputHoldsNoPlantedSecret runs the scan over every fixture,
+// writes all the items out as JSON and checks that no planted secret is in
+// it.
+func TestTheWholeOutputHoldsNoPlantedSecret(t *testing.T) {
+ for _, id := range fixtureAdapters {
+ values, ok := plantedSecrets[id]
+ if !ok || len(values) == 0 {
+ t.Errorf("the %q fixture has no planted secret listed", id)
+ continue
+ }
+ // Every listed value is really in the fixture: the list does not
+ // drift away from the files.
+ home, project := fixture(t, id)
+ for _, value := range values {
+ found := false
+ for _, dir := range []string{home, project} {
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ if strings.Contains(string(data), value) {
+ found = true
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !found {
+ t.Errorf("the %q fixture does not hold %q any more", id, value)
+ }
+ }
+ res := scanOf(t, id)
+ out, err := json.Marshal(res.Items)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, value := range values {
+ if strings.Contains(string(out), value) {
+ t.Errorf("adapter %q still shows the planted secret %q", id, value)
+ }
+ }
+ }
+}
+
+// TestOverRedactionStaysBounded checks that the harmless things a file says
+// are not touched.
+func TestOverRedactionStaysBounded(t *testing.T) {
+ harmless := []string{
+ "use 4 spaces for indentation",
+ "the build takes 12 minutes",
+ "5551234567",
+ "the port is 4321 and the timeout is 30 seconds",
+ "the release is 1.24.0 and the build number is 1234",
+ }
+ for _, text := range harmless {
+ out, n := Redact(text)
+ if n != 0 {
+ t.Errorf("%q was redacted: %q", text, out)
+ }
+ if out != text {
+ t.Errorf("%q was changed: %q", text, out)
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
+package memory
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Limits of the scan.
+const (
+ // MaxFileBytes is the size a file may have before it is skipped.
+ MaxFileBytes = 256 * 1024
+ // MaxItemRunes is the length of an item before it is cut.
+ MaxItemRunes = 2000
+)
+
+// Options say what to scan.
+type Options struct {
+ // Home is the home directory of the person whose memory is read.
+ Home string
+ // Projects are the project directories to read, in addition to the
+ // user-level paths.
+ Projects []string
+ // Tools are the adapter ids to read. Empty means every adapter.
+ Tools []string
+}
+
+// FileReport says what a scan found in one file.
+type FileReport struct {
+ // Path is the file that was looked at.
+ Path string `json:"path"`
+ // Found is true when the file was there.
+ Found bool `json:"found"`
+ // Items is how many items came out of the file.
+ Items int `json:"items"`
+ // Note says why a file was skipped, when it was.
+ Note string `json:"note,omitempty"`
+}
+
+// AdapterReport says what a scan found for one tool.
+type AdapterReport struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Docs string `json:"docs"`
+ Files []FileReport `json:"files"`
+ Items int `json:"items"`
+}
+
+// Result is what a scan found.
+type Result struct {
+ Home string `json:"home"`
+ Projects []string `json:"projects"`
+ Adapters []AdapterReport `json:"adapters"`
+ Items []Item `json:"items"`
+}
+
+// Scan reads the memory of the tools the person uses, locally and read-only.
+//
+// It never writes a file, never opens a network connection and never follows
+// a symlink out of the home directory or the project directories it was given.
+func Scan(opts Options) Result {
+ res := Result{Home: opts.Home, Adapters: []AdapterReport{}, Items: []Item{}}
+ projects := make([]string, 0, len(opts.Projects))
+ for _, p := range opts.Projects {
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ continue
+ }
+ projects = append(projects, abs)
+ }
+ sort.Strings(projects)
+ res.Projects = projects
+
+ want := map[string]bool{}
+ for _, t := range opts.Tools {
+ want[strings.ToLower(strings.TrimSpace(t))] = true
+ }
+
+ for _, ad := range Adapters() {
+ if len(want) > 0 && !want[ad.ID] {
+ continue
+ }
+ report := AdapterReport{ID: ad.ID, Name: ad.Name, Docs: ad.Docs, Files: []FileReport{}}
+ for _, loc := range ad.Locations(opts.Home, projects) {
+ paths := resolveLocation(loc, opts.Home, projects)
+ if len(paths) == 0 && strings.ContainsAny(loc, "*?[") {
+ // A glob that matched nothing is still a path we looked at.
+ report.Files = append(report.Files, FileReport{Path: loc})
+ continue
+ }
+ for _, path := range paths {
+ fr := readFile(ad, path, opts.Home, projects, &res.Items)
+ report.Files = append(report.Files, fr)
+ report.Items += fr.Items
+ }
+ }
+ res.Adapters = append(res.Adapters, report)
+ }
+ return res
+}
+
+// resolveLocation turns a location into the files it stands for: one file
+// for an exact path, the matches of a glob for a pattern.
+func resolveLocation(loc, home string, projects []string) []string {
+ if !strings.ContainsAny(loc, "*?[") {
+ return []string{loc}
+ }
+ matches, err := expandGlob(loc)
+ if err != nil {
+ return nil
+ }
+ allowed := allowedRoots(home, projects)
+ out := make([]string, 0, len(matches))
+ for _, m := range matches {
+ if !withinAny(m, allowed) {
+ continue
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// allowedRoots are the directories a file may live in.
+func allowedRoots(home string, projects []string) []string {
+ roots := make([]string, 0, len(projects)+1)
+ if home != "" {
+ roots = append(roots, home)
+ }
+ roots = append(roots, projects...)
+ return roots
+}
+
+// withinAny tells whether a path is inside one of the roots.
+func withinAny(path string, roots []string) bool {
+ for _, root := range roots {
+ if root == "" {
+ continue
+ }
+ if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {
+ return true
+ }
+ }
+ return false
+}
+
+// readFile reads one file and turns it into items.
+func readFile(ad Adapter, path, home string, projects []string, items *[]Item) FileReport {
+ fr := FileReport{Path: path}
+ info, err := os.Lstat(path)
+ if err != nil {
+ return fr
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ // A link is only followed when it stays inside the paths we were given.
+ resolved, err := filepath.EvalSymlinks(path)
+ if err != nil || !withinAny(resolved, allowedRoots(home, projects)) {
+ fr.Note = "skipped: link leaves the scanned paths"
+ return fr
+ }
+ info, err = os.Stat(resolved)
+ if err != nil {
+ fr.Note = "skipped: link cannot be read"
+ return fr
+ }
+ path = resolved
+ }
+ if info.IsDir() {
+ fr.Note = "skipped: a directory"
+ return fr
+ }
+ if info.Size() > MaxFileBytes {
+ fr.Note = "skipped: larger than 256 KB"
+ return fr
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ fr.Note = "skipped: cannot be read"
+ return fr
+ }
+ if isBinary(data) {
+ fr.Note = "skipped: a binary file"
+ return fr
+ }
+ fr.Found = true
+ for _, name := range indexFilesOf(ad) {
+ if strings.EqualFold(filepath.Base(path), name) {
+ fr.Note = "skipped: an index of the memories, not a memory"
+ return fr
+ }
+ }
+ updated := info.ModTime().UTC().Format(time.RFC3339)
+ scope := scopeFor(path, projects)
+ for _, it := range ad.Parse(path, data) {
+ it.Source = ad.ID
+ it.Path = path
+ it.Updated = updated
+ it.Scope = scope
+ if it.Kind == "" {
+ // A file of the person's own is about them; a file of a project
+ // holds that project's rules.
+ if scope == ScopeUser {
+ it.Kind = KindPreference
+ } else {
+ it.Kind = KindProjectRule
+ }
+ }
+ *items = append(*items, it)
+ fr.Items++
+ }
+ return fr
+}
+
+// scopeFor gives the project a file belongs to, or the person when it belongs
+// to none.
+func scopeFor(path string, projects []string) Scope {
+ for _, p := range projects {
+ if path == p || strings.HasPrefix(path, p+string(filepath.Separator)) {
+ return ScopeProject(p)
+ }
+ }
+ return ScopeUser
+}
+
+// isBinary tells whether a file holds binary data.
+func isBinary(data []byte) bool {
+ head := data
+ if len(head) > 8000 {
+ head = head[:8000]
+ }
+ for _, b := range head {
+ if b == 0 {
+ return true
+ }
+ }
+ return false
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+# Codex global
+
+## Preferences
+
+- Answer in plain English, no preamble
+- Never touch the vendor directory
+- The release token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# Agents
+
+## Project rules
+
+- Run the tests before committing
+- Do not edit generated files
+
+## Decision
+
+We keep the ledger in SQLite for now.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+model: gpt-4o
+read:
+ - NOTES.md
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Notes
+
+The person prefers short commit messages.
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+model: gpt-4o
+read:
+ - README.md
+ - docs/architecture.md
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Conventions
+
+- Run `go test ./...` before every commit
+- The database password=Sup3rSecretFakeValue is only for the fixture
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Demo project
+
+A small service for invoices.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Architecture
+
+The service is one binary and one database.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+# Personal setup
+
+- Prefer tabs in Go files and spaces everywhere else
+- The test command is `go test ./...` and it must pass before a commit
+
+## Credentials
+
+The staging API key is sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 and it is only for the sandbox.
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Memory index
+
+- [User role](user_role.md)
+- [Feedback on tests](feedback_testing.md)
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+---
+name: feedback_testing
+description: What the person corrected
+metadata:
+ type: feedback
+---
+
+Do not run the whole suite while iterating; run the one test.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+---
+name: user_role
+description: The person running the project
+metadata:
+ type: user
+---
+
+The person is a staff engineer who prefers short answers.
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+# Sandbox
+
+- The sandbox runs against its own database
+
+```text
+-----BEGIN OPENSSH PRIVATE KEY-----
+
+MIIEowIBAAKCAQEAtESTKEYONLYOPENSSHFAKE000000000000
+-----END OPENSSH PRIVATE KEY-----
+```
+
+- The sandbox environment is written down here
+
+```env
+DATABASE_PASSWORD=fake-db-password-123
+STRIPE_SECRET=fake-stripe-secret-999
+SESSION_SECRET="correct horse battery staple"
+```
+
+- The vault key was pasted in halves
+
+```text
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE0000000000000000
+```
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+---
+paths:
+ - "**/*_test.go"
+---
+
+# Testing rules
+
+- Every change comes with a test
+- Never commit a failing test
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Local Claude notes
+
+- The sandbox URL is http://localhost:4321
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+---
+paths:
+ - "src/api/**"
+---
+
+# API rules
+
+- Validate every input
+- Use the standard error format
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Personal for this project
+
+- Use the fixture database, never the real one
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# Demo project
+
+## Conventions
+
+- Use 2-space indentation
+- Keep the public API small
+
+## Build
+
+Run `npm run build` before pushing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Cline rules
+
+- Always read the memory bank first
+- The database password=Sup3rSecretFakeValue is only for the fixture
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Project brief
+
+The project is a small invoicing service.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Command Code memory
+
+- Keep the answers short
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Command Code project memory
+
+- The build is `make build`
+- The personal token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Style
+
+- Use tabs in Go
+- The personal token=ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB is for the sandbox only
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+# Repository instructions
+
+- Use the shared client in packages/api
+- Keep the changelog updated
+- The session token is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+---
+applyTo: "**/*.test.ts"
+---
+
+# Test instructions
+
+- Use the fixture builder, not raw objects
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+---
+description: Database rules
+globs:
+ - "**/*.sql"
+alwaysApply: false
+---
+
+# Database
+
+- Every migration is reversible
+- No table without an index
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+---
+description: How we write code here
+globs: "**/*.go"
+alwaysApply: true
+---
+
+# Style
+
+- Error messages start lowercase
+- Comments explain why, not what
+- The Slack token for the alerts room is xoxb-0000000000000000FAKE
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+# Global Gemini context
+
+- Write release notes in the past tense
+- Keep summaries under five lines
+- The bucket key is AKIAIOSFODNN7EXAMPLE
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# Project context
+
+## Stack
+
+- Go 1.24 for the services
+- Postgres 16 for the data
+
+## Style
+
+- Errors are wrapped with %w
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Product
+
+An invoicing service for small businesses.
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+# Technology stack
+
+- Go with chi for the HTTP layer
+- Postgres 16
+- The staging URL is postgres://dbuser:[email protected]:5432/app
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# OpenCode global
+
+- Ask before changing the CI
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# OpenCode instructions
+
+- Read the schema before writing a migration
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Conventions
+
+- Every migration has a down step
+- The aws_secret_access_key = wJalrXUtnFEMI00000000000000000000000FAKE is a fixture
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "instructions": ["docs/conventions.md"]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+# Roo rules
+
+- Never edit files outside the workspace
+- The api key is sk_live_0000000000000000FAKE
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Tech context
+
+- Node 22 and TypeScript 5
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+# Preferences
+
+- I like small pull requests
+- I read the diff before the description
+- The deploy key lives here:
+
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE0000000000000000
+-----END RSA PRIVATE KEY-----
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# General rules
+
+- Use pnpm, never npm