Skip to content

feat(core): add ctx.dotdir — scoped dot directory API#105

Merged
zrosenbauer merged 6 commits intomainfrom
feat/dotdir
Mar 24, 2026
Merged

feat(core): add ctx.dotdir — scoped dot directory API#105
zrosenbauer merged 6 commits intomainfrom
feat/dotdir

Conversation

@zrosenbauer
Copy link
Member

Summary

  • Adds DotDirectoryClient on ctx.dotdir with scoped filesystem operations for CLI dot directories
  • ctx.dotdir.global() returns a DotDirectory for ~/.myapp/, ctx.dotdir.local() returns Result<DotDirectory, DotDirectoryError> for <project>/.myapp/
  • Each DotDirectory provides: ensure, read, write, readJson, writeJson, exists, remove, path
  • Protection registry allows middleware to guard sensitive files (e.g. auth.json) from accidental handler access
  • Auth middleware now protects auth.json via ctx.dotdir.protect()

Stacked on #104

Test plan

  • pnpm check passes (typecheck + lint + format)
  • pnpm test passes (780 tests — 36 new dotdir tests)
  • Protection registry blocks read/write/remove of protected files
  • dangerouslyAccessProtectedFile opt-in bypasses protection
  • Zod schema validation works in readJson

@vercel
Copy link

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
oss-kidd Ignored Ignored Preview Mar 24, 2026 11:30pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 24, 2026

🦋 Changeset detected

Latest commit: ea39498

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@kidd-cli/core Minor
@kidd-cli/cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Base automatically changed from refactor/context-rename to main March 24, 2026 22:17
…ction

Adds DotDirectoryClient on the command context providing scoped
filesystem operations for CLI dot directories. Includes a protection
registry that middleware can populate to guard sensitive files (e.g.
auth.json) from accidental handler access.

Co-Authored-By: Claude <noreply@anthropic.com>
@codspeed-hq
Copy link

codspeed-hq bot commented Mar 24, 2026

Merging this PR will not alter performance

✅ 2 untouched benchmarks


Comparing feat/dotdir (ea39498) with main (008efc0)

Open in CodSpeed

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a DotDirectory subsystem and integrates it into CommandContext. New public types and exports for dotdir APIs and errors, plus factories: createDotDirectory, createDotDirectoryClient, and createProtectionRegistry. Implements a synchronous filesystem-backed DotDirectoryClient with JSON helpers, path-traversal and symlink checks, protection enforcement, and result-typed error handling. createContext now constructs and attaches ctx.dotdir via createDotDirectory({ dirs: options.meta.dirs }). Auth middleware registers the default auth filename as protected. Unit tests for the client and protection registry were added.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • joggrdocs/kidd PR 87 — Threads meta.dirs / ResolvedDirs through create-context/types, which this PR consumes to construct the dotdir clients.
  • joggrdocs/kidd PR 104 — Modifies CommandContext/createContext surface that this change extends by adding the dotdir property.
  • joggrdocs/kidd PR 69 — Touches createContext implementation; both PRs alter the same initialization flow where ctx.dotdir is attached.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(core): add ctx.dotdir — scoped dot directory API' directly and clearly summarizes the main change: exposing a dot-directory API via ctx.dotdir on the CommandContext.
Description check ✅ Passed The description comprehensively covers the changeset: it details the new ctx.dotdir API, explains global/local directory access, lists all provided operations, documents the protection registry mechanism, and references related test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 94.74% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dotdir

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/lib/dotdir/create-dot-directory-client.test.ts`:
- Around line 53-68: The test currently allows both success and failure by
branching on the Result from client.local(), making it nondeterministic; update
the test to assert success deterministically by keeping the vi.stubEnv('PWD',
tmpDir) setup (which includes creating tmpDir with a .git) and replacing the
conditional with a direct assertion that error is falsy (or null) and that
dotdir.dir contains DIR_NAME—i.e., call createDotDirectoryClient(...), run const
[error, dotdir] = client.local(), expect(error).toBeFalsy() (or
expect(error).toBeNull()) and then expect(dotdir.dir).toContain(DIR_NAME); refer
to createDotDirectoryClient and the client.local() call to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 40a26443-51e2-4fa9-8763-97fd76a6801a

📥 Commits

Reviewing files that changed from the base of the PR and between 008efc0 and e71584a.

⛔ Files ignored due to path filters (1)
  • .changeset/dotdir.md is excluded by !.changeset/**
📒 Files selected for processing (13)
  • packages/core/src/context/create-context.ts
  • packages/core/src/context/types.ts
  • packages/core/src/index.ts
  • packages/core/src/lib/dotdir/create-dot-directory-client.test.ts
  • packages/core/src/lib/dotdir/create-dot-directory-client.ts
  • packages/core/src/lib/dotdir/create-dot-directory.test.ts
  • packages/core/src/lib/dotdir/create-dot-directory.ts
  • packages/core/src/lib/dotdir/index.ts
  • packages/core/src/lib/dotdir/protection.test.ts
  • packages/core/src/lib/dotdir/protection.ts
  • packages/core/src/lib/dotdir/types.ts
  • packages/core/src/middleware/auth/auth.test.ts
  • packages/core/src/middleware/auth/auth.ts

DotDirectory is now the root manager on ctx.dotdir (with .global(),
.local(), .protect()), and DotDirectoryClient is the scoped filesystem
handle returned by .global() and .local().

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/lib/dotdir/create-dot-directory-client.ts`:
- Around line 199-207: The returned error for jsonStringify failures currently
uses type 'fs_error' which is misleading for a serialization failure; update the
error object returned by the jsonStringify branch inside
create-dot-directory-client (the block that checks stringifyError after calling
jsonStringify(data, { pretty: true })) to use a more appropriate error type —
either change type: 'fs_error' to type: 'parse_error' for symmetry with
readJson, or introduce and use a new type like 'serialize_error' (and ensure any
callers handling error.type are updated accordingly); keep the message using
stringifyError.message and filename unchanged.

In `@packages/core/src/lib/dotdir/create-dot-directory.test.ts`:
- Around line 53-68: The test for createDotDirectory::local() is
non-deterministic because resolveLocalPath/findProjectRoot may use process.cwd()
rather than the stubbed PWD; make the test deterministic by explicitly stubbing
the resolver or process.cwd(): either mock resolveLocalPath (or the module that
exports it) to return a known Result pointing at tmpDir before calling
createDotDirectory/local(), or use vi.spyOn(process,
'cwd').mockReturnValue(tmpDir) so findProjectRoot resolves predictably;
reference the createDotDirectory factory and the local() method when applying
the stub.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 75bc2bb6-c6c4-45d5-a2cb-0c861f5345fb

📥 Commits

Reviewing files that changed from the base of the PR and between e71584a and f2f336f.

📒 Files selected for processing (9)
  • packages/core/src/context/create-context.ts
  • packages/core/src/context/types.ts
  • packages/core/src/lib/dotdir/create-dot-directory-client.test.ts
  • packages/core/src/lib/dotdir/create-dot-directory-client.ts
  • packages/core/src/lib/dotdir/create-dot-directory.test.ts
  • packages/core/src/lib/dotdir/create-dot-directory.ts
  • packages/core/src/lib/dotdir/index.ts
  • packages/core/src/lib/dotdir/protection.ts
  • packages/core/src/lib/dotdir/types.ts

- Make local() test deterministic by mocking process.cwd() instead of
  using conditional assertions
- Change writeJson serialization error type from fs_error to parse_error
  for consistency with readJson

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/lib/dotdir/create-dot-directory-client.ts`:
- Around line 66-69: The code is vulnerable to symlink escape because filesystem
calls follow symlinks; fix by validating and resolving paths: at initialization
resolve the configured dir with realpathSync and use lstatSync on each path
component to detect and reject any symlink in the configured dotdir (throw a
DotDirectoryError if found), and for every file operation in ensure(), read(),
write(), remove(), and fileExists() call realpathSync on the target path and
verify the resolved path has the dotdir realpath as its prefix (reject if it
escapes the boundary); use the existing Result/DotDirectoryError flow to return
errors when symlinks or path escapes are detected.
- Around line 42-58: checkProtection currently uses the raw filename while
filesystem ops use join(dir, filename), allowing path traversal like
"../../../etc/passwd" to bypass registry checks; modify the code paths
(checkProtection, readJson, writeJson and any call sites using join(dir,
filename)) to first normalize and resolve the filename to an absolute/canonical
path (e.g., path.resolve or fs.realpath equivalent), compute the target path =
resolve(dir, filename), verify the target path startsWith the directory root to
prevent escaping, then use that canonical target for both the protection
registry lookup (pass the normalized relative/canonical filename used by
registry) and all filesystem operations; ensure checkProtection uses the
normalized identifier that matches what registry.has(location, ...) expects and
reject access if the resolved path is outside dir unless
dangerouslyAccessProtectedFile is true.

In `@packages/core/src/lib/dotdir/create-dot-directory.test.ts`:
- Around line 9-10: The tests use mutable shared state (globalHome and tmpDir)
which is reassigned across hooks and read by the module mock; replace the shared
let bindings with a per-test fixture helper that returns local consts (e.g.,
createFixture() -> { globalHome, tmpDir }) and update each test to call that
helper and use its returned consts; then update the module mock setup to capture
the per-test values (e.g., set the mock implementation inside the test or use
jest.isolateModules/jest.doMock per test) so the mock reads the local consts
instead of the module-scoped variables; remove the top-level let declarations
and any hooks that mutate them so each test is order-independent.
- Around line 53-60: The test currently uses
expect(dotdir.dir).toContain(DIR_NAME) which is too loose; change the assertion
to verify the exact resolved local path by comparing dotdir.dir to the
resolved/joined path built from tmpDir and DIR_NAME (e.g., use path.join or
path.resolve with tmpDir and DIR_NAME) after calling createDotDirectory and
client.local(), so the test asserts that local() resolved against tmpDir rather
than matching any path containing DIR_NAME.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7fa7f9f1-52a1-48ba-b016-845a0a3068e5

📥 Commits

Reviewing files that changed from the base of the PR and between f2f336f and 294fbfd.

📒 Files selected for processing (2)
  • packages/core/src/lib/dotdir/create-dot-directory-client.ts
  • packages/core/src/lib/dotdir/create-dot-directory.test.ts

- Validate filenames resolve within the scoped directory boundary
- Reject symbolic links via lstatSync check
- Add path_traversal error type to DotDirectoryError
- Add 5 path traversal tests covering read, write, remove, readJson, exists
- Tighten local() test assertion to exact path match
- Move test let bindings to module scope with JSDoc explaining the exception

Co-Authored-By: Claude <noreply@anthropic.com>
… path(), seal internals

- Consolidate safePath() to single lstatSync instead of existsSync + lstatSync
- Route path() through safePath() for traversal/symlink protection
- Reuse ensure() in write() instead of duplicating mkdirSync
- Remove ProtectionRegistry/createProtectionRegistry from barrel exports

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
packages/core/src/lib/dotdir/create-dot-directory-client.ts (2)

77-92: ⚠️ Potential issue | 🔴 Critical

Protected files are still bypassable through normalized filenames.

checkProtection() compares the raw caller input, but safePath() canonicalizes the actual target later. With auth.json protected, read('./auth.json') or write('sub/../auth.json', ...) reaches the same file without dangerouslyAccessProtectedFile, because the registry lookup and filesystem target are derived from different names. Normalize first, then run the protection lookup against the canonical relative filename.

Also applies to: 127-136, 164-170, 290-296

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/lib/dotdir/create-dot-directory-client.ts` around lines 77
- 92, checkProtection currently checks protection using the raw caller filename
which allows bypass via inputs like './auth.json' or 'sub/../auth.json'; change
it to first compute the canonical relative path with the same normalization used
by safePath (i.e., resolve/normalize to the repository-relative target) and then
run registry.has against that canonical filename inside checkProtection. Update
all other places that perform protection checks (the other checkProtection-like
calls around the file read/write handlers referenced in the diff) to use the
normalized/canonical filename before calling registry.has so registry lookup and
filesystem target use the same canonical name.

43-66: ⚠️ Potential issue | 🔴 Critical

Parent symlinks still let filesystem operations escape the scoped directory.

Line 55 only inspects the leaf path. If resolvedDir itself, or any segment under it, is a symlink, safePath('cache/config.json') still passes because lstatSync(resolved) follows parent symlinks and a missing target just yields ENOENT. The later readFileSync / writeFileSync / unlinkSync then operate outside the dotdir boundary. This still needs canonical-root validation of each existing path component (or an equivalent realpath-based parent-path check) before any filesystem access.

Does Node.js fs.lstatSync() detect symlinks in parent path components, or only the final path entry? What is the recommended way to prevent readFileSync/writeFileSync/unlinkSync from escaping an allowed base directory through nested symlinks?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/lib/dotdir/create-dot-directory-client.ts` around lines 43
- 66, safePath currently only lstatSync()s the final resolved path which allows
parent-directory symlinks to escape the dotdir; change it to validate canonical
parent paths: compute the realpath (realpathSync) of resolvedDir once and for
each requested filename compute the realpath of the deepest existing parent (or
walk components from resolvedDir to the target calling lstatSync on each
existing segment) and reject if any segment is a symbolic link or if the
realpath of the target/parent does not start with the canonical resolvedDir
path; update safePath to use these checks (replace the single
lstatSync(resolved) check) so subsequent readFileSync/writeFileSync/unlinkSync
cannot escape the allowed base.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/lib/dotdir/types.ts`:
- Around line 88-104: The exported DotDirectoryClient methods (read, write,
readJson, writeJson, remove) currently use positional multi-arg signatures which
will lock API shape; change each to accept a single object parameter (e.g.,
ReadParams, WriteParams, ReadJsonParams<T>, WriteJsonParams, RemoveParams or
Options variants) so callers pass named properties (filename, content/data,
options) and update the method types in types.ts (the readonly read, write,
readJson, writeJson, exists, remove entries) to use those object param types;
ensure generic readJson keeps its <T> and that optional fields remain optional
in the new param types, and update any internal references/call sites to
destructure the new param objects accordingly.
- Around line 71-73: The readJson generic signature allows callers to assert T
without runtime validation; change ReadJsonOptions<T> so schema is required
(remove optional) and add overloads on DotDirectoryClient.readJson so calls
without a schema return Result<unknown, DotDirectoryError> while calls passing
ReadJsonOptions<T> with schema return Result<T, DotDirectoryError>; update the
implementation of readJson to perform zod.parse when schema is present and cast
only after validation. Also refactor multi-parameter methods on
DotDirectoryClient (read, write, readJson, writeJson, remove) to use a single
options object parameter (object destructuring) per the project standard and
update their signatures/consumers accordingly.

---

Duplicate comments:
In `@packages/core/src/lib/dotdir/create-dot-directory-client.ts`:
- Around line 77-92: checkProtection currently checks protection using the raw
caller filename which allows bypass via inputs like './auth.json' or
'sub/../auth.json'; change it to first compute the canonical relative path with
the same normalization used by safePath (i.e., resolve/normalize to the
repository-relative target) and then run registry.has against that canonical
filename inside checkProtection. Update all other places that perform protection
checks (the other checkProtection-like calls around the file read/write handlers
referenced in the diff) to use the normalized/canonical filename before calling
registry.has so registry lookup and filesystem target use the same canonical
name.
- Around line 43-66: safePath currently only lstatSync()s the final resolved
path which allows parent-directory symlinks to escape the dotdir; change it to
validate canonical parent paths: compute the realpath (realpathSync) of
resolvedDir once and for each requested filename compute the realpath of the
deepest existing parent (or walk components from resolvedDir to the target
calling lstatSync on each existing segment) and reject if any segment is a
symbolic link or if the realpath of the target/parent does not start with the
canonical resolvedDir path; update safePath to use these checks (replace the
single lstatSync(resolved) check) so subsequent
readFileSync/writeFileSync/unlinkSync cannot escape the allowed base.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: adfda406-985f-4d4f-a3a9-c5558255068f

📥 Commits

Reviewing files that changed from the base of the PR and between 8132cdf and 0bc652d.

📒 Files selected for processing (4)
  • packages/core/src/lib/dotdir/create-dot-directory-client.test.ts
  • packages/core/src/lib/dotdir/create-dot-directory-client.ts
  • packages/core/src/lib/dotdir/index.ts
  • packages/core/src/lib/dotdir/types.ts

- Replace toBe(true/false) with toBeTruthy/toBeFalsy
- Capitalize section separator comments
- Resolve stale stash conflicts in docs (keep CommandContext naming)

Co-Authored-By: Claude <noreply@anthropic.com>
@zrosenbauer zrosenbauer merged commit d5d83fd into main Mar 24, 2026
7 checks passed
@zrosenbauer zrosenbauer deleted the feat/dotdir branch March 24, 2026 23:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant