DEV Community

Cover image for Three things my Claude Code memory OSS was quietly getting wrong (KIOKU v0.4.0)
megaphone
megaphone

Posted on

Three things my Claude Code memory OSS was quietly getting wrong (KIOKU v0.4.0)

Context

A few days ago I shipped v0.2 and v0.3 of KIOKU, adding PDF and URL ingestion to my Claude Code / Desktop memory system. The features were working.

What I wanted to do next wasn't adding more. It was reading the code I'd already shipped as if someone else had written it.

v0.4.0 is the result. Zero new features. But every fix in it carries the same feeling: glad I caught this before someone else did.

This post is about three of those fixes — the ones that genuinely made me pause. It's not a feature post, it's the "what I found once I started looking" post.

https://github.com/megaphone-tokyo/kioku

Mac mini was silently failing git push for five days

This was the one that made me sweat.

KIOKU syncs your Obsidian vault across machines via Git. I run it on a MacBook and a Mac mini, both doing git commit && git push from auto-ingest.sh. Knowledge written on one machine shows up on the other. That's the whole point.

Except one afternoon I checked the Mac mini's log and realized: push hadn't succeeded in five days.

What was happening

auto-ingest.sh runs git commit followed by git push. The commits were landing. reflog confirmed — there was a wall of commits accumulated locally. The problem was on the push side:

$ cd $OBSIDIAN_VAULT
$ git status
HEAD detached at abc1234
Enter fullscreen mode Exit fullscreen mode

Detached HEAD.

I don't know exactly when it happened. Probably some Obsidian-side operation — an aborted rebase, a half-finished branch switch. HEAD had fallen off its branch and stayed there.

In detached HEAD state, git commit succeeds. It just advances HEAD. But git push can't decide which remote branch to push to, so it fails. Fails silently, too — auto-ingest.sh was swallowing the exit code with || true.

Result: commits piling up in reflog, zero reaching origin, for five days.

The fix: guard on git symbolic-ref

The fix is tiny. In auto-ingest.sh / auto-lint.sh / install-hooks.sh, check HEAD before any git writes:

if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then
  echo "WARNING: Vault is in detached HEAD state. Skipping git commit/push." >&2
  echo "Recovery: run 'git checkout main' in the Vault." >&2
  return 0
fi
Enter fullscreen mode Exit fullscreen mode

git symbolic-ref -q HEAD returns refs/heads/<branch> when attached, fails when detached. One conditional, done.

What it taught me

The nasty thing about this class of bug is that it's an error that doesn't show up as an error. Commit succeeds. Push fails, but fails quietly because of a well-meaning || true. The logs are clean. The process exits zero.

Meanwhile, the premise underneath the whole product is eroding. KIOKU's second-brain promise is "your knowledge follows you between machines." Five days of silent desync breaks that, even if nothing explicitly broke.

"Works" and "works correctly" are not the same thing. Failsafes like || true are useful, but what exactly are you silently swallowing? is a question I now want to be able to answer for every one of them in my code.

The MCP lock was being held for 4+ minutes

Not a correctness bug, but an architectural one that hurt.

KIOKU's MCP server uses a lockfile at $VAULT/.kioku-mcp.lock to serialize writes between auto-ingest.sh (cron-driven) and MCP tools (Claude Desktop / Code driven). Both touch the same vault, so they need to take turns.

withLock is the helper — acquire, run, release. Standard stuff.

The problem was in how kioku_ingest_url handled PDF URLs:

await withLock(vault, async () => {
  // ...fetch URL, save PDF to raw-sources/...

  if (isPdf) {
    await handleIngestPdf(vault, { path: savedPath }, { skipLock: true });
    //                                                  ^^^^^^^^^^^^^^
    //                              "we already own the lock, don't re-acquire"
  }
});
Enter fullscreen mode Exit fullscreen mode

The outer withLock was holding the lock while the inner call did the full PDF pipeline — poppler extraction, chunking, summary via claude -p. On a 50-page PDF, measured hold time: 4.5 minutes.

For those 4.5 minutes, every cron ingest run, every other MCP tool call, was blocked waiting for the lock.

The fix: release the outer lock first

The refactor: scope withLock to just "write the PDF to disk" (seconds), then release before dispatching to the PDF handler:

const phase1Result = await withLock(vault, async () => {
  // fetch URL, save PDF — finishes in seconds
  return { savedPath, needsPdfDispatch: true };
});
// lock is released here

if (phase1Result.needsPdfDispatch) {
  // handleIngestPdf acquires its own withLock when it needs to
  await handleIngestPdf(vault, { path: phase1Result.savedPath });
}
Enter fullscreen mode Exit fullscreen mode

Once I restructured it this way, the skipLock injection was no longer needed for anything. I deleted it from the API entirely.

What it taught me

The existence of skipLock was itself a design smell. "Flag to avoid double-acquiring the lock" only makes sense if the caller knows whether it already holds the lock — and that's coupling I shouldn't have leaked into the API surface.

The correct shape is: every callable that needs the lock takes it itself, locks are reentrant or short-lived enough not to matter, and no one has to reason about "what state is the caller in."

Pre-release, when I knew all the callers personally, skipLock was "good enough." As soon as KIOKU was public, anyone could call handleIngestPdf through a different path. skipLock immediately becomes a trap.

Shortcuts that rely on "I know all my callers" rot on contact with publication.

Hidden bypass holes in the Hook layer

The smallest-feeling but scariest of the three.

KIOKU's Hook captures Claude Code sessions and masks secrets before writing them to disk — sk-ant-... becomes sk-ant-***, same for OpenAI, GitHub, AWS, Slack, the usual cast. MASK_RULES is an array of regexes.

Session logs get committed to GitHub (private repo, but still — commit history is forever). If masking misses something, there's no taking it back.

Bypass hole 1: zero-width space slip-through

During the v0.4 audit of the Hook layer, I noticed this:

sk-ant-​abcdefghijklmnopqrstuvwxyz
       ↑
     U+200B (zero-width space)
Enter fullscreen mode Exit fullscreen mode

Insert a zero-width space between sk-ant- and the body of the token, and the regex /sk-ant-[A-Za-z0-9_-]{20,}/ doesn't match. Visually it's indistinguishable from a normal token. The mask silently does nothing.

Realistic scenarios where this could occur:

  • Someone deliberately injects invisible chars into a prompt
  • A text editor paste drags invisible chars along
  • A Markdown preprocessor inserts zero-width chars

The point is: regex matching alone is insufficient as a masking strategy when the input space includes Unicode.

Bypass hole 2: YAML frontmatter injection

Session logs have YAML frontmatter:

---
session_id: abc123
cwd: /Users/me/project
---
Enter fullscreen mode Exit fullscreen mode

These values come from the hook execution environment — mostly trustworthy, but some like cwd can contain newlines.

If an attacker could engineer cwd to be:

/tmp/x\n---\ntype: injected\nrelated: ["/etc/passwd"]
Enter fullscreen mode Exit fullscreen mode

…the frontmatter gets closed mid-value, and injected type: / related: keys appear as if they were set legitimately. Any downstream tool that trusts frontmatter would act on the forged values.

Bypass hole 3: KIOKU_NO_LOG strict-equality drift

KIOKU's auto-ingest spawns claude -p to process logs. That claude -p is itself a Claude Code session that fires Hooks. Without a guard, you get infinite recursion: ingest → spawn → hook fires → ingest → spawn → ...

The guard is a KIOKU_NO_LOG=1 env var:

if (process.env.KIOKU_NO_LOG === '1') return;
Enter fullscreen mode Exit fullscreen mode

Problem: strict equality with '1'. If anyone — me, a future contributor, a user automating KIOKU — writes KIOKU_NO_LOG=true or KIOKU_NO_LOG=yes thinking the obvious thing, the guard silently stops working. The recursion comes back.

Fix: proper defense in depth

All three now fixed:

  • Masking: strip INVISIBLE_CHARS_RE and NFC-normalize before regex matching
  • Frontmatter: yamlSafeValue() scrubs control characters and YAML structure chars before quoting
  • Env check: envTruthy() accepts 1 / true / yes / on (case-insensitive)

What it taught me

None of these bugs had caused harm, as far as I could tell. No zero-width-space tokens were observed. No YAML injection attempted. Nobody had miswritten KIOKU_NO_LOG=true. Every one of them was "this could happen" territory.

But the asymmetry for security bugs is brutal: "could happen" is cheap to fix, "did happen" is often impossible to undo. A leaked API key is in GitHub history forever.

If KIOKU had stayed private, I'd probably have shrugged these off. I don't smuggle zero-width chars into my own prompts. Nobody is crafting malicious cwd values on my machine.

The act of publication changes the math on "could happen." It stops being theoretical. That's not news, but it's something I'd held as an abstract rule rather than a habit. v0.4.0 is the first time I actually sat down and re-audited my own Hook layer with that mindset, and found three things worth fixing.

Other fixes in v0.4.0

The three above are the ones that gave me pause. v0.4.0 has more:

  • A#1: @mozilla/readability 0.5 → 0.6 (ReDoS GHSA-3p6v-hrg8-8qj7 mitigated; 144 production deps pass npm audit clean)
  • B#2: Formalized cron / setup script env-override conventions via tests/cron-guard-parity.test.sh (17 assertions)
  • B#3: sync-to-app.sh cross-machine race prevented by check_github_side_lock (α guard, 120s default window, configurable via KIOKU_SYNC_LOCK_MAX_AGE)
  • B#8: i18n parity — added §10 MCP / §11 MCPB / Changelog sections to all 8 non-en/ja READMEs (+1,384 lines)
  • Tests: 299 Node tests + 15 Bash suites / 415 assertions, all green

Full details in the v0.4.0 release notes.

The shared lesson: "working" ≠ "working correctly"

If there's a single thread through all three stories, it's that one line.

  • Mac mini was "working" (commits landed) — but not correctly (pushes didn't)
  • The MCP server was "working" (locks were acquired) — but not correctly (everyone else was starved for four minutes)
  • The Hook was "working" (mask ran) — but not correctly (zero-width space breezed through)

Nothing visibly broke. No error logs. The product kept behaving. Meanwhile, the trust underneath the product was being quietly eaten.

Publishing code changes what the baseline for "working" needs to be. Private code can coast on "it runs." Team code gets caught by colleagues pointing out the smell. Open source code has neither privilege — your users are invisible to you, and all you have is the discipline to be harder on your own code than seems reasonable.

This is a different kind of work from feature development. Features have a clear "done." Audit passes don't — "is this really enough?" is a question you have to actively choose to keep asking. But the code after a few passes of this is noticeably more restful to read.

What's next

With v0.4.0 in place, the next cycle goes back to features:

  • Pluggable LLM backend: swap claude -p in auto-ingest for OpenAI / Ollama — drops the Max plan prerequisite for most of the pipeline
  • Morning Briefing: a single summary in the morning of what got ingested overnight
  • Team Wiki: session-logs stay local per user; wiki/ syncs via Git across a team

But each feature I add will probably surface its own v0.4-equivalent set of "didn't see this until I shipped it" findings. That's just how this works. You ship, you notice something, you fix it, you ship the next thing.

OSS isn't done when you publish. That's when a different kind of work starts — the ongoing work of finding the problems and fixing them, in public. v0.4.0 was the first release where I really felt that shift.

Summary

  • v0.4.0 ships zero new features — just re-audit and operational fixes
  • Three sweat-inducing finds: 5-day detached HEAD, 4-minute MCP lock, Hook layer bypasses
  • All three were "working" on the surface but not correctly underneath
  • Publication is what shifts "could happen" from theoretical to stakes-you-care-about
  • MIT licensed, feedback very welcome

https://github.com/megaphone-tokyo/kioku

Read alongside the first, second, and third posts for the full KIOKU arc so far.

Questions I'd love feedback on:

  • For || true-style failsafes, do you have patterns for making "silent swallow" at least loud in logs without abandoning fail-safety?
  • For Hook layer auditing, what are bypass patterns you've seen that I should also be thinking about beyond Unicode tricks and frontmatter injection?
  • For the Mac-mini-style silent desync problem, any good end-to-end "did sync actually happen" health check patterns?

Other projects

hello from the seasons.

A gallery of seasonal photos I take, with a small twist: you can upload your own image and compose yourself into one of the season shots using AI. Cherry blossoms, autumn leaves, wherever. Built it for fun — photography is a long-running hobby, and mixing AI into the workflow felt right.


Built by @megaphone_tokyo — building things with code and AI. Freelance engineer, 10 years in. Tokyo, Japan.

Top comments (0)