Notes Module
August 30, 2026 · View on GitHub
Overview
Notes is a builtin App Store app (kiro_crew/apps/builtins/md_notebook/) for keeping a
markdown notebook inside a git repository. It runs as a managed app backend SUBPROCESS: an
aiohttp server on the backend-assigned port, reached only through the gateway proxy. Every
proxied request carries an HMAC signature (X-KiroCrew-Proxy: <ts>:<hmac> over
<ts>:<METHOD>:<path>[?q]:<sha256(body)>, +/-60s window) verified fail-closed by
proxy_auth.verify_proxy_request in the backend's middleware. A failed verification is
SEL-audited before the 401 goes out (operation=proxy_auth_failed, outcome=denied,
path only — no query string, which can carry note names; emitted off-loop via
asyncio.to_thread, following the file-explorer builtin's convention). The bare /health path is
the single exemption, because the gateway's own liveness poll hits it unsigned. Gateway
session auth gates the proxy entrance as with all builtin apps.
The app id is md-notebook; the display name is "Notes". defaultEnabled is false, so it
appears in the Apps library ready to be switched on rather than enabling itself.
Responsibilities
- Vaults — clone a remote repo into
<home>/vaults/<id>/, or attach an existing local working tree in place (no second copy on disk) - Notes — list, read, save, create, move and delete markdown files within a vault
- Links — parse
[[wikilinks]], resolve them by title then filename, and build the reverse map so a note can show what links back to it - Search — full-text index over titles and bodies, with a title boost
- Sync — commit, fetch, merge and push in one call, reporting conflicts without overwriting anything
- Knowledge — persist a per-vault flag recording that the folder is registered as a Knowledge source (registration itself happens in the UI, which holds the user session)
State Layout
Rooted at MD_NOTEBOOK_HOME, defaulting to ~/.kiro/crew/workspace/md-notebook/:
| Path | Contents |
|---|---|
vaults.json | Vault descriptors. No secrets, but localPath is what sync runs git against, so it is in _SENSITIVE_HOME_DIRS. Written via a temp file + os.replace. |
pat | GitHub token, chmod 0600, never echoed back to the UI (only a boolean is). Also listed in _SENSITIVE_HOME_DIRS, so agent file tools cannot read it through the shared gate — 0600 alone does not isolate another process running as the same user. |
vaults/<id>/ | Vaults this app cloned itself. Attached vaults stay where the user has them. |
A vault descriptor carries id, name, repo, localPath, branch, readOnly, an
optional subfolder scope, plus knowledge and knowledgeSourceId. The external field
returned by GET /api/vaults is COMPUTED on read (localPath is outside vaults/) and
never persisted.
Routes
The gateway proxy preserves the /api/ prefix, so the backend sees exactly the paths the
UI calls. All vault-scoped routes accept ?vault=<id> and fall back to the first vault.
Read (GET)
| Route | Returns |
|---|---|
/health, /api/health | {ok, features[]} — the capability probe |
/api/vaults | {vaults[], hasPat, hasGhAuth} |
/api/notes | {notes[]} with title, modifiedAt, createdAt, syncStatus |
/api/note?path= | {path, content, mtime, meta, backlinks[]} |
/api/search?q= | {results[]}; an empty query returns nothing, not everything |
/api/changes?since= | {rev, changed[], watching} — external-edit poll |
Write (POST/PUT/DELETE)
| Route | Effect |
|---|---|
POST /api/vaults | Clone a remote vault |
POST /api/vaults/attach | Adopt an existing checkout, with or without a git remote (no remote → a localOnly vault); 409 if already attached; 403 if the folder resolves into a protected location OR contains one (e.g. the home directory — sync's git add -A from such a root would stage ~/.ssh/~/.aws wholesale; checked list-based via security.path_contains_sensitive, no tree walk) |
DELETE /api/vaults | Forget the descriptor. FILES ARE NEVER DELETED. |
PUT /api/vaults/knowledge | Persist the knowledge flag and source id |
PUT /api/pat | Store or clear the token |
PUT /api/note | Save, guarded by baseMtime |
DELETE /api/note?path= | Move a note into the vault's local .trash (never unlinked) |
POST /api/note/new | Create a uniquely named note |
POST /api/note/duplicate | Copy a note beside itself as <name> copy[.n].md |
POST /api/note/move | Move or rename; 409 rather than overwrite |
POST /api/sync | Commit, fetch, merge, push |
POST /api/commit | Commit to LOCAL history only — the periodic autosave. Never pushes, so it skips the remote-identity checks (require_writable and the trusted-gitdir check still apply) |
POST /api/pick-folder | Native folder chooser; 501 when unsupported |
Capability probe
Both health routes return a features list: createdAt, attach, changes,
saveGuard, forget, pat, newNote, move, duplicate, trash, localOnly,
autoCommit, trashOpen, knowledge, pickFolder.
The gateway keeps an app's backend alive across UI reloads, so a process running older code than
the page would otherwise surface as confusing "no route" errors; the UI compares this list
and names the missing capabilities instead. trash is listed even though it adds no route,
because the delete dialog's copy promises the note is recoverable — an older backend would
hard-unlink while the UI said otherwise, so the capability has to be detectable.
Detectable is not sufficient, because the stale-backend banner only warns: trash is also
enforced. The row's onDelete is passed only when health positively reports trash
(canTrash), and NoteRow omits the delete button when it is absent — not disabled, absent,
since a disabled control still advertises an action the backend cannot honour. trash and
trashOpen are in REQUIRED_FEATURES too, so the banner names them as the reason the button
is gone.
Local Trash
DELETE /api/note moves the note into <content root>/.trash/ and never unlinks it. A hard
delete is unrecoverable for a note the user never committed — the common case for something
written and deleted the same day — and git can only restore what it already has.
- The folder is dotted, so
_list_note_files_sync's dotted-directory prune keeps trashed notes out of the listing and out of search for free. - The destination name is the note's basename, with
2,3, … on collision, so two same-named notes from different folders both survive. The name is reserved with anO_EXCLcreate and thenos.replaced onto —replacealone silently overwrites, and a bareexists()check would be a TOCTOU window. - No git ignore rule is written for the trash — per-path staging is the whole mechanism.
Nothing touches
.git/info/excludeor the vault's.gitignore;status()filters.trashpaths and staging names only whatstatus()reported, so the folder cannot enter a commit. Two consequences to keep in mind:git statusin the user's own terminal does list.trash/as untracked, and a hand-rungit add -Athere would stage it. Writing a per-clone exclude rule is deferred to a follow-up PR (its full hardening —O_NOFOLLOW, the hardlink refusal, the symlinked-parent refusal, the Windows fallback and the bytes-not-text read — is preserved on thewip/notes-trash-excludebranch); it is defence in depth for third-party git use, not part of this app's guarantee, and it was the single largest source of review findings on this PR. An earlier revision instead passed:(exclude,literal).trashtogit add, which broke sync: naming the folder in a pathspec makes git treat it as an EXPLICITLY named ignored path and fail the whole add (use -f if you really want to add them, exit 1) in any vault that already ignores it — which is Obsidian's own convention, so the guard broke the common case while passing a fixture repo that has no ignore file. Do not reintroduce a pathspec exclusion. - The cache is rebuilt rather than having one index entry dropped: the deleted note's own
[[wikilinks]]go with it, so its targets would otherwise keep a backlink to a note that no longer exists. - Nothing empties it. There is no retention policy, no purge on start, no age limit — a trashed note stays until the user removes it (Obsidian behaves the same). Time-based retention is deliberately NOT in this module: it is the only behaviour that would delete user data unattended, so it is being landed separately, where a reviewer can look at it in isolation. That is also why the delete dialog's copy promises only recoverability, never a deadline — copy that states a window a backend does not enforce is worse than no copy.
POST /api/trash/openreveals it in the OS file manager, surfaced as an underlined link in the delete dialog (whose copy promises the note is restorable from there) and per vault in Settings. Without it the promise was unactionable: the folder is dotted, so it is hidden in this app's listing AND in Finder's default view. The route takes no path — the directory comes from the vault descriptor viavault_mutation_path, so a caller cannot aim it elsewhere — and the binary is an absolute platform constant (/usr/bin/open,/usr/bin/xdg-open,os.startfileon Windows), existence-checked, never resolved from the agent-writable front ofPATH. A trusted binary is not sufficient on Linux, becausexdg-openis a shell script that dispatches to whichever helper it finds onPATH(gio,gvfs-open,exo-open, …) — so every POSIX spawn inserver.pypasses_trusted_env(), which replacesPATHwithgit_ops.TRUSTED_PATHand keeps the rest (the desktop session needsDISPLAY/DBUS_SESSION_BUS_ADDRESS/XDG_*). Otherwise a planted~/.local/bin/giowould run on a click the user has every reason to trust. The same pin coversgh auth tokenand theosascriptfolder chooser, and a test asserts the count ofsubprocess.run(calls equals the count ofenv=_trusted_env(),so a new spawn cannot silently omit it. An absent trash returns{empty: true}rather than being created; a host with no file manager returns 501folder_open_unsupported, which the UI explains. Allowlisted intest_spawn_audit.py.
Saving: three layers
The word "save" means three different things here, and only the last needs a button.
- Disk. A note edit is written to the file after
SAVE_DEBOUNCE_MS(1s) of quiet, and flushed unconditionally before anything that could lose it — opening another note, switching vaults, moving/renaming/deleting, syncing, andbeforeunload. The user never saves the file by hand. Disk saves are single-flight: a mutation that reaches its flush barrier while the debounced save is active joins that request, so a failed save cannot be hidden by a competing write that lets the mutation proceed. - Local git history (autosave).
POST /api/commitruns everyAUTO_COMMIT_MINS(5) whileLS.autoCommitis on — default ON (DEFAULT_AUTO_COMMIT). It commits and stops; it cannot reach a remote. It stages exactly the changed.mdpaths it saves, named individually, and excludes any path the user has staged in git — a staged path is a commit they are composing by hand, and an unattendedgit addwould overwrite a partialadd -pboundary, whilegit commit -- <path>would lift a fully staged file out of the multi-file commit being assembled. A deliberate Sync stages everything, because the user chose the moment. Skipped for a read-only vault, while the block editor holds an uncommitted draft, and while a save is unreconciled (dirtyafter a failed write), and it is silent by design: a tick with nothing pending makes no commit, touches no listing, and a failure raises no banner over the user's writing — an explicit sync reports errors normally. - The remote (push). The Sync button, the sync shortcut, and auto-sync
(
LS.autoSync, default OFF). This is the only layer that leaves the machine.
The row's pending badge belongs to layer 3, not layer 1: it is git status
reporting the file differs from the last commit, so it appears AFTER the disk write
that caused it. rowBadge() therefore suppresses it on a local-only vault
(showSyncBadge false) — with no remote there is nowhere to be pending to, autosave
clears it within AUTO_COMMIT_MINS, and left visible it reads as "not saved", the
opposite of what it means. An in-flight deleting still wins the slot either way.
Autosave defaults on and pushing does not, because a local commit is private and reversible while a push is neither. That asymmetry is the whole design: doing layer 2 unasked costs the user nothing, and doing layer 3 unasked would be a decision made on their behalf.
Save Guard
PUT /api/note accepts the baseMtime the client received from its read. If the file's
current mtime differs by more than 1ms, the write is refused with 409 and
{code: "ESTALE", mtime, disk} — the response carries what is actually on disk so the UI
can offer a merge. This exists because an attached vault is a folder the user also edits
with Obsidian, an editor, or the git CLI, and a blind write would silently clobber that
work. The 1ms tolerance absorbs filesystem mtime rounding.
External Change Detection
GET /api/changes compares an mtime snapshot of the vault's markdown files against the
previous snapshot, bumping a monotonic revision when anything differs and accumulating the
changed paths. A write the app made itself is suppressed for
SELF_WRITE_GRACE_SEC (1.5s) so saving a note does not report itself as an external edit.
Detecting a change also drops the search/backlink cache, so the next read rebuilds from
disk.
This replaces the Node original's recursive fs.watch. Snapshot comparison needs no extra
dependency and no background thread, and since the UI is the only consumer and it polls,
the observable behaviour is the same.
Git Behavior
Git runs as the real git binary via asyncio.create_subprocess_exec, never a shell.
- Local remotes need no special handling — real git speaks
file://and bare paths natively. (The TypeScript original carried a hand-written transport module purely because isomorphic-git's HTTP client could not.) - Clone is FULL, not shallow. The original defaulted to
depth: 1, but most servers refuse a push from a shallow clone, which would break the app's own sync. Note vaults are text, so full history is cheap. - Status compares the working tree directly against HEAD, treating untracked files as additions and reporting a rename as a delete plus an add. A repo with no commits reports everything as added.
- Staging is always per-path, never directory-wide.
auto_commitnames each changed path fromstatus()as a:(literal)pathspec (capped atMAX_STAGED_PATHS), for the explicit Sync as well as the autosave.status()filters.trash, and that filter is the ONLY thing keeping the trash — including a PRE-EXISTING Obsidian one in a freshly attached vault — out of history, since no git ignore rule is written for it. A scope-wideadd -Awould sweep it up and push notes the user deleted elsewhere.notes_only(autosave) narrows the same list further, to.mdonly. - Only the autosave truncates at
MAX_STAGED_PATHS(500); an explicit Sync refuses. The cap exists so a first sweep over a large vault cannot build an argv past the OS limit, and for the autosave the remainder is a delay rather than a loss — it pushes nothing, and the next tick picks the rest up. A user-initiated Sync cannot truncate, becausestatus()reports a rename as two entries (old path deleted, new path added) and the slice sorts by path: a cutoff falling between them would push the deletion half alone, so the note reads as deleted in every other clone while the UI reported success. Sync therefore raises with the changed-file count and tells the user the autosave is draining them. - Sync commits pending work, fetches, and merges. On conflict the merge is ABORTED so the working tree keeps local content, and the result lists each conflicted path with both the local and remote versions. Nothing is overwritten.
- Local-only vaults. Attaching a repo with no
remote.origin.urlis supported: the descriptor recordslocalOnly: truewithremoteUrl: null, and sync stops after the commit ({localOnly: true, pushed: false}) instead of failing on a remote that does not exist. Everything else — listing, search, backlinks, trash, the save guard — needs no remote. The UI labels the button "Save locally" and shows "No remote · <branch>" in Settings rather than a repo slug.localOnlyis stored EXPLICITLY rather than inferred from a nullremoteUrl, and sync REFUSES a local-only vault that has since gained an origin:.git/configis agent-writable, so an origin appearing after attach is not a user decision and pushing note history to it is the same exfiltration the trusted-remote check prevents.
Credential handling
A token reaches git through GIT_CONFIG_COUNT/KEY/VALUE carrying an
http.extraHeader: Authorization: Basic <b64> for that invocation only. It is deliberately
NOT interpolated into the remote URL, which would persist it in .git/config and leak it
into any error that echoes the remote, and NOT passed as a command-line argument, which
would expose it in the process table. Unlike git -c, these environment variables are not
copied into a newly cloned repository's config. GIT_TERMINAL_PROMPT=0 keeps a credential
prompt from hanging the request.
Auth resolution order: the stored PAT, else a token minted on demand from the user's gh
CLI login (cached 300s, never written to disk).
Input Validation
- Note paths resolve through
safe_join, which rejects anything landing outside the vault root after symlink resolution (400). .trashis refused when it is a symlink at all (trash_dir_path), not only when it escapes the vault. A clone can carry.trash -> public, whose target is contained, so the escape check passes — thenmkdir(exist_ok=True)follows the link and the note lands atpublic/One.md, a pathstatus()does not filter (it filters the.trash/prefix), so the next sync pushes the deleted note.is_symlink()(lstat) is the only test that sees this;exists()andis_dir()both follow. Both the delete route and the reveal route go through the same resolver.- Containment is not sufficient, so every caller-supplied path also passes
require_note_path()(require_folder_path()for the new-notefolder): each component must be undotted and the file must end in.md. A vault holds far more than notes, and.git/configis contained — an unvalidated delete moved it to.trash/config.md, breaking the vault and its remote binding, and save / move / duplicate reach the same places. The two rules are exactly the ones the note walk applies, so the addressable surface equals the listed surface:.git/,.trash/and dotfiles are excluded by construction rather than by being named, and a future dotted directory needs no further edit. Applied at all six entry points — read, save, delete, duplicate, new-notefolder, and both ends of a move (the message namesfrom/to). Server-derived paths (the.trashreveal) do not go through it; they were never caller input. readOnlyvaults refuse every mutating note route (403).POST /api/note/newdecides the name server-side and creates the file withO_EXCL, so two quick clicks cannot collide or overwrite a file the UI's cached listing did not know about.POST /api/note/duplicatefollows the same rule for the copy's name, reads the source through the central sensitive-path gate (read_note_text) rather thanopen(), and resolves the destination directory from the validated source folder — so a copy can only ever land beside its source inside the vault, and a.mdsymlink aimed at a private key cannot be laundered into vault content that search would serve and sync would push.POST /api/note/moverefuses to overwrite an existing file (409).- Request bodies are capped by the Application's
client_max_size.
Folder Picker
POST /api/pick-folder opens the macOS folder chooser via osascript and returns the
POSIX path. The UI cannot produce an absolute path itself — browser file APIs
(showDirectoryPicker, input[webkitdirectory]) deliberately withhold real filesystem
paths, and the attach flow needs one. activate makes the dialog frontmost rather than
leaving it behind the browser; cancelling raises AppleScript error -128 and is reported as
a plain cancellation. Non-macOS hosts get 501 so the UI falls back to a typed path.
MD_NOTEBOOK_NO_PICKER suppresses it, which is how the test suite guarantees no GUI dialog
can open during a run.
Frontend
The UI is a compiled builtin surface at website/src/apps/md-notebook/, routed at
/md-notebook. It is NOT a dynamic ui.entry bundle: the gateway serves app UI bundles
only from apps_dir()/<name>/ui/, and builtin registration writes metadata without
copying files there, so a package-resident builtin must compile into the frontend.
Knowledge-sync calls go to the HOST API (/api/knowledge/*) rather than the app namespace,
because registration needs the user's dashboard session. /api/knowledge is therefore
declared in the manifest's permissions.api.
Notes panel affordances
- New note lives in the panel header (top right, beside the vault selector) and creates
the note at the vault's top level — outside every folder. It is there rather than beside
the document controls because "outside all folders" only reads as a location next to the
tree it applies to. The file is created empty:
note_titlealready falls back to the basename, so a seeded# Untitledheading was a duplicate of the title the user then had to delete, and it did not follow a later rename. Preview's trailing click-to-append region (80px) is what keeps an empty note clickable. - Drag to file. A note row is draggable; the drop targets are a folder row (files into
that folder), another note row (files into that note's folder, so a drop inside a folder
does not fall through), and the list background (files at the vault root). All of it goes
through the same
POST /api/note/move, which refuses to overwrite.
Delete flow
Delete is the one destructive row action, and it is staged rather than immediate:
- Confirmation is an in-app dialog (
ConfirmDialog.tsx), notwindow.confirm. The native dialog rendered as an OS sheet in the desktop app — the only surface that left the app's own design language, and unthemeable by construction. The dialog scrims the Notes pane (the app root isposition: relative, so the dashboard chrome stays visible), focuses the destructive button so Enter confirms, and closes on Escape or a scrim click. The scrim carriesrole="presentation"before itsonClick— theaccessible-interactive-elementsAUTOSDE rule matches<div … onClickonly when norole=precedes it. - The row goes 50% opacity while the request is in flight, with the sync-badge slot showing
deletinginstead ofpending— the badge slot already means "state of this file", so a second indicator would be noise. The row stops responding to clicks and drags, and its action bar is hidden, for the duration. - On success the panel lands on the adjacent note — the next one DOWN in the visible order,
falling back to the one above when the deleted note was last, and to the empty state when it
was the only one. "Visible order" is resolved from
visibleNotePaths, which mirrors whichever mode is rendering (search results / flat list /flattenVisibleNotesfor the tree) and omits collapsed folders, because a note the user cannot see is not a place to land. - On failure nothing is lost. The clear + neighbour hand-off run only after the server confirms, so a read-only vault (403) or a network error leaves the note open, in the list, with any unsaved text intact. This is why the editor is not cleared optimistically.
- Hover action bar — pin/unpin, duplicate, rename, delete — revealed on row hover,
using the chat session list's recipe (card surface, thin border, only the hovered row's
bar visible). The keyboard path is
.mdnb-row-actions:has(:focus-visible)— on the BAR and scoped to keyboard focus, and both halves are load-bearing..mdnb-row:focus-withinalso matches the row holding focus itself (Clickableis tabbable), so it left the bar lit on the note you last selected; and plain:focus-withinon the bar keeps matching after a MOUSE click, so it left the bar lit on the note you last pinned. Delete is the sole destructive action, tinted--dangerand gated on the confirmation dialog above; it also cancels the debounced save first, because a pending flush would write the open note straight back and resurrect the deleted file. - Rename is inline on the row, seeded from the displayed title. It reuses the same
relocatepath as the inline document title, so the folder is preserved and separators / filename-illegal characters are stripped — a rename can never move the note. - Pins are local and per-vault (
mdnb-pinned-<vaultId>in localStorage). Deliberately NOT frontmatter: a pin is a per-device reading aid for this sidebar, and writing it into the note would commit a UI preference into the user's git history on the next sync. A pinned note sorts first within its own container — it is not hoisted out of its folder, which would misreport where the file lives. Pins follow a move/rename and are dropped on delete.
Tests
test/test_md_notebook.py drives the aiohttp app through a signed test client, so the
proxy-HMAC middleware is exercised on every call rather than bypassed. Coverage includes
the save guard, path traversal, unique note naming, duplicate-note naming and containment,
move-without-overwrite, external-change
detection, self-write suppression, token file permissions, the knowledge flag round-trip,
and a real sync against git fixtures including the conflict path.