fix(project): guard credential/auto-auth secret file reads (#282)#283
fix(project): guard credential/auto-auth secret file reads (#282)#283OkeyAmy wants to merge 1 commit into
Conversation
…e#282) The v0.4.0 `project credential` and `project auto-auth` commands read their `--*-file` flags with a raw `readFileSync(path).trim()`. A missing file, a directory, or an oversized file escaped as an unwrapped Node error (exit 1) that also broke the `--output json` envelope (bare `{"error":"ENOENT…"}` instead of the structured `{code,message,nextAction}`) and leaked fs internals. Generalize the guard into `readSecretFileGuarded(path, flagName)` — mirroring the `--code-file` guard in test.ts and PR TestSprite#248's `readPasswordFileGuarded` — and apply it to all six project.ts file-read sites: - project create/update --password-file - project credential --credential-file - project auto-auth --password-file / --client-secret-file / --refresh-token-file Missing/non-regular files now return VALIDATION_ERROR (exit 5) and oversized files return PAYLOAD_TOO_LARGE (exit 5), each carrying the flag name — the same contract every other file flag already honors. Adds 11 tests covering missing/directory/oversized inputs across all four new flags plus the two password-file sites, and the trimmed happy path. Closes TestSprite#282
WalkthroughProject secret-file flags now use a shared guarded reader that validates paths, rejects oversized files, and returns structured errors. The guard is wired into create, update, credential, and auto-auth commands, with tests covering failures, network suppression, size limits, and successful trimmed reads. ChangesProject secret-file validation
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/commands/project.ts (1)
467-472: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winGuarded credential-file read runs before the
--dry-runcheck — violates the dry-run filesystem contract.
readSecretFileGuardedis invoked at Line 471, butopts.dryRunisn't checked until Line 487. UnlikerunCreate/runUpdate(which explicitly defer file reads until after the dry-run early return),runCredential --dry-run --credential-file <path>willstatSync/readFileSyncthe file for real, and will throw aVALIDATION_ERROR/PAYLOAD_TOO_LARGEif the file is missing/oversized — even though dry-run is documented to be a pure offline preview.🔧 Proposed fix — presence-check only, defer the real read past dry-run
- // Resolve the credential value (flag or file). Required for every type - // except `public` (which clears it). - let credential = opts.credential; - if (credential === undefined && opts.credentialFile !== undefined) { - credential = readSecretFileGuarded(opts.credentialFile, 'credential-file'); - } - if (opts.authType !== 'public' && (credential === undefined || credential === '')) { + // Presence-only check here — dry-run must not touch the filesystem. + // The actual file read happens below, after the dry-run early return. + const credentialSupplied = + (opts.credential !== undefined && opts.credential !== '') || + opts.credentialFile !== undefined; + if (opts.authType !== 'public' && !credentialSupplied) { throw localValidationError( '--credential (or --credential-file) is required unless --type is "public"', ); }Then, after the
if (opts.dryRun) { ... return sample; }block:+ let credential = opts.credential; + if (credential === undefined && opts.credentialFile !== undefined) { + credential = readSecretFileGuarded(opts.credentialFile, 'credential-file'); + } + const body: Record<string, string> = { authType: opts.authType }; if (opts.authType !== 'public' && credential !== undefined) body.credential = credential;As per path instructions, "the CLI must skip network, credentials, and local filesystem work" during
--dry-run, so this ordering needs to change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/commands/project.ts` around lines 467 - 472, Move the guarded credential-file read in runCredential so it occurs only after the opts.dryRun early-return block. During dry-run, retain only the credential-file presence information needed to build the preview and avoid invoking readSecretFileGuarded or any filesystem access; preserve the existing real credential resolution for non-dry-run execution.Source: Path instructions
🧹 Nitpick comments (4)
src/commands/project.test.ts (2)
1516-1516: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStray review-tooling comment left in the test body.
// blindfold: manual — the on-disk fixture written above is " tok-from-file\n"; the guard trims itreads like a leftover annotation rather than an intentional code comment. Consider removing it or rewording as a normal explanatory comment.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/commands/project.test.ts` at line 1516, Remove the stray “blindfold: manual” annotation from the test body, or replace it with a concise normal comment explaining the fixture’s whitespace and trimming behavior without review-tooling terminology.
1428-1608: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNo test exercises
--dry-runcombined with a guarded--*-fileflag forrunCredential/runAutoAuth.All new cases here call
runCredential/runAutoAuth/runCreate/runUpdatewithoutdryRun: true. Given the ordering bug flagged insrc/commands/project.ts(credential/secret-file resolution currently runs before the dry-run check inrunCredentialandrunAutoAuth), a test asserting that--dry-run --credential-file <missing-path>returns the canned sample without touching the filesystem would have caught this regression class.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/commands/project.test.ts` around lines 1428 - 1608, Add coverage in the “#282 — secret --*-file flags are guarded” suite for runCredential and runAutoAuth with dryRun: true and missing credential/secret-file paths. Assert each returns its canned dry-run sample successfully without reading the filesystem or invoking the network, preserving the existing guarded-file validation cases for non-dry-run execution.Source: Path instructions
src/commands/project.ts (2)
1101-1147: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winRaw filesystem error text is embedded in the structured envelope's
details.error.
secretFileError(flagName, 'must point to a readable file', { path: absolute, error: message })forwards the rawerr.messagefromstatSync(e.g. anENOENT/OS-formatted string) straight into the responsedetails. The top-levelmessage/nextActionare clean, but automation or UI code that surfacesdetails.errorverbatim would still expose the underlying OS error text and absolute path, which is what the doc's "rather than leaking raw filesystem errors" guidance is trying to avoid.♻️ Suggested tweak — drop the raw OS message, keep a generic reason
} catch (err) { - const message = err instanceof Error ? err.message : String(err); throw secretFileError(flagName, 'must point to a readable file', { path: absolute, - error: message, }); }As per path instructions, "the CLI should validate these flags locally and return structured validation errors rather than leaking raw filesystem errors."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/commands/project.ts` around lines 1101 - 1147, Update readSecretFileGuarded and secretFileError usage so statSync failures do not include the raw filesystem error message or absolute path in the structured details. Keep the structured validation error and generic “must point to a readable file” reason, but remove the error text and path fields from this failure response.Source: Path instructions
1088-1147: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the guarded file-read helpers into a shared module.
test.tshas multiple stat/read guards for--code-file, steps, plan files, and output paths, whileproject.tsnow adds a guarded secret-file reader. Lifting these intosrc/libwill keep file validation consistent and make future--*-fileguards easier to add and test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/commands/project.ts` around lines 1088 - 1147, Extract readSecretFileGuarded, secretFileError, and MAX_SECRET_FILE_BYTES from project.ts into a shared src/lib module, then update project.ts to import and use them. Consolidate the existing guarded file stat/read helpers from test.ts into the same module where applicable, preserving their validation behavior, ApiError envelopes, size limits, and flag-specific details.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/commands/project.ts`:
- Around line 1101-1131: Update readSecretFileGuarded so the final readFileSync
operation is wrapped in the same structured error handling as statSync. Convert
read failures into secretFileError(flagName, 'must point to a readable file',
including the absolute path and underlying error message), while preserving the
existing validation and trimmed successful-read behavior.
- Around line 577-592: Move the password, clientSecret, and refreshToken
resolution using readSecretFileGuarded out of the initial setup in runAutoAuth
and place it after the opts.dryRun early return, ensuring dry-run performs no
filesystem access. Restructure the subsequent request-body construction to use
the resolved values without duplicate maybe calls.
---
Outside diff comments:
In `@src/commands/project.ts`:
- Around line 467-472: Move the guarded credential-file read in runCredential so
it occurs only after the opts.dryRun early-return block. During dry-run, retain
only the credential-file presence information needed to build the preview and
avoid invoking readSecretFileGuarded or any filesystem access; preserve the
existing real credential resolution for non-dry-run execution.
---
Nitpick comments:
In `@src/commands/project.test.ts`:
- Line 1516: Remove the stray “blindfold: manual” annotation from the test body,
or replace it with a concise normal comment explaining the fixture’s whitespace
and trimming behavior without review-tooling terminology.
- Around line 1428-1608: Add coverage in the “#282 — secret --*-file flags are
guarded” suite for runCredential and runAutoAuth with dryRun: true and missing
credential/secret-file paths. Assert each returns its canned dry-run sample
successfully without reading the filesystem or invoking the network, preserving
the existing guarded-file validation cases for non-dry-run execution.
In `@src/commands/project.ts`:
- Around line 1101-1147: Update readSecretFileGuarded and secretFileError usage
so statSync failures do not include the raw filesystem error message or absolute
path in the structured details. Keep the structured validation error and generic
“must point to a readable file” reason, but remove the error text and path
fields from this failure response.
- Around line 1088-1147: Extract readSecretFileGuarded, secretFileError, and
MAX_SECRET_FILE_BYTES from project.ts into a shared src/lib module, then update
project.ts to import and use them. Consolidate the existing guarded file
stat/read helpers from test.ts into the same module where applicable, preserving
their validation behavior, ApiError envelopes, size limits, and flag-specific
details.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 3d200ba6-1cf0-4890-a8ec-64fd0e287a0f
📒 Files selected for processing (2)
src/commands/project.test.tssrc/commands/project.ts
| // Resolve secrets from --*-file variants so they stay out of shell history. | ||
| const password = | ||
| opts.password ?? | ||
| (opts.passwordFile !== undefined ? readFileSync(opts.passwordFile, 'utf8').trim() : undefined); | ||
| (opts.passwordFile !== undefined | ||
| ? readSecretFileGuarded(opts.passwordFile, 'password-file') | ||
| : undefined); | ||
| const clientSecret = | ||
| opts.clientSecret ?? | ||
| (opts.clientSecretFile !== undefined | ||
| ? readFileSync(opts.clientSecretFile, 'utf8').trim() | ||
| ? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file') | ||
| : undefined); | ||
| const refreshToken = | ||
| opts.refreshToken ?? | ||
| (opts.refreshTokenFile !== undefined | ||
| ? readFileSync(opts.refreshTokenFile, 'utf8').trim() | ||
| ? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file') | ||
| : undefined); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Same dry-run bypass: secret-file resolution runs before the --dry-run check in runAutoAuth.
password/clientSecret/refreshToken are resolved (potentially reading files via readSecretFileGuarded) at Lines 578-592, but opts.dryRun isn't checked until Line 619. Any of --password-file, --client-secret-file, or --refresh-token-file will hit the real filesystem during --dry-run, contradicting the documented offline guarantee.
🔧 Proposed fix — move secret-file resolution after the dry-run early return
- // Resolve secrets from --*-file variants so they stay out of shell history.
- const password =
- opts.password ??
- (opts.passwordFile !== undefined
- ? readSecretFileGuarded(opts.passwordFile, 'password-file')
- : undefined);
- const clientSecret =
- opts.clientSecret ??
- (opts.clientSecretFile !== undefined
- ? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file')
- : undefined);
- const refreshToken =
- opts.refreshToken ??
- (opts.refreshTokenFile !== undefined
- ? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file')
- : undefined);
-
const enabled = opts.disable !== true;
const body: Record<string, unknown> = { enabled, method: opts.method, inject: opts.inject };Then, immediately after if (opts.dryRun) { ... return sample; }:
+ // Resolve secrets from --*-file variants so they stay out of shell history.
+ // Deferred until after the dry-run return so preview mode never touches disk.
+ const password =
+ opts.password ??
+ (opts.passwordFile !== undefined
+ ? readSecretFileGuarded(opts.passwordFile, 'password-file')
+ : undefined);
+ const clientSecret =
+ opts.clientSecret ??
+ (opts.clientSecretFile !== undefined
+ ? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file')
+ : undefined);
+ const refreshToken =
+ opts.refreshToken ??
+ (opts.refreshTokenFile !== undefined
+ ? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file')
+ : undefined);
+ maybe('password', password);
+ maybe('clientSecret', clientSecret);
+ maybe('refreshToken', refreshToken);(then remove the now-duplicate maybe('password', password) etc. calls further down, or restructure to build body in one place.)
As per path instructions, dry-run must skip local filesystem work.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/commands/project.ts` around lines 577 - 592, Move the password,
clientSecret, and refreshToken resolution using readSecretFileGuarded out of the
initial setup in runAutoAuth and place it after the opts.dryRun early return,
ensuring dry-run performs no filesystem access. Restructure the subsequent
request-body construction to use the resolved values without duplicate maybe
calls.
Source: Path instructions
| function readSecretFileGuarded(path: string, flagName: string): string { | ||
| const absolute = resolve(path); | ||
| let stat; | ||
| try { | ||
| stat = statSync(absolute); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| throw secretFileError(flagName, 'must point to a readable file', { | ||
| path: absolute, | ||
| error: message, | ||
| }); | ||
| } | ||
|
|
||
| if (!stat.isFile()) { | ||
| throw secretFileError(flagName, 'must point to a regular file', { path: absolute }); | ||
| } | ||
|
|
||
| if (stat.size > MAX_SECRET_FILE_BYTES) { | ||
| throw ApiError.fromEnvelope({ | ||
| error: { | ||
| code: 'PAYLOAD_TOO_LARGE', | ||
| message: 'Secret file is too large.', | ||
| nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`, | ||
| requestId: 'local', | ||
| details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES }, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| return readFileSync(absolute, 'utf8').trim(); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does Node.js fs.statSync succeed on a file the process lacks read permission for, while fs.readFileSync fails with EACCES?
💡 Result:
No, fs.statSync does not necessarily succeed if the process lacks read permission for the file. The success of both fs.statSync and fs.readFileSync depends on the operating system's permission model, and both may fail with EACCES if the process does not have the required access rights [1][2]. In POSIX-compliant systems, fs.statSync requires the process to have permission to traverse (execute/search) all directories in the path leading to the file [3]. If the process lacks the necessary permissions to traverse the path or access the file's metadata, fs.statSync will throw an EACCES error [1][3]. Similarly, fs.readFileSync requires read permission for the file itself, and will fail with EACCES if that permission is missing [1][2]. While some users might observe scenarios where metadata is accessible while file content is not, this is determined by the underlying file system permissions and the kernel's enforcement of them, not by a difference in how Node.js handles these specific operations [1]. If an EACCES error occurs, it indicates that the operating system has explicitly denied the request based on the current process's user/group identity and the file's security settings [4][1]. Node.js merely surfaces the error provided by the operating system [1].
Citations:
- 1: https://www.thenodebook.com/file-system/permissions-metadata-edge-cases
- 2: https://bugstack.ai/errors/nodejs-permission-denied
- 3: Making files/directories world writable (and readable) does matter isaacs/rimraf#14
- 4: https://oneuptime.com/blog/post/2026-01-22-nodejs-eacces-permission-denied/view
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate project.ts =="
fd -a 'project\.ts$' . | sed 's#^\./##'
echo "== inspect relevant helper and usages =="
file="$(fd 'project\.ts$' . | head -n 1)"
wc -l "$file"
sed -n '1060,1145p' "$file"
echo "== find readSecretFileGuarded usages =="
rg -n "readSecretFileGuarded|secretFileError|MAX_SECRET_FILE_BYTES|--dry-run|readFileSync|statSync" "$file" DOCUMENTATION.md 2>/dev/null || true
echo "== behavioral Node.fs stat/read permission probe =="
node - <<'JS'
const fs = require('fs');
const path = require('path');
const os = require('os');
const tmp = require('os').tmpdir();
let fn;
try {
fn = path.join(tmp, `secret-mode-probe-${process.pid}-${process.hrtime.bigint().toString()}`);
fs.writeFileSync(fn, 'secret');
const before = fs.statSync(fn);
fs.chmodSync(fn, 0);
console.log('node', process.version);
console.log('platform', process.platform);
console.log('effectiveUid', process.geteuid());
console.log('initial stat ok, isFile=' + before.isFile());
try {
const data = fs.readFileSync(fn, 'utf8').trim();
console.log('readFileSync result:', data);
} catch (err) {
console.log('readFileSync error code=' + err.code + ' msg=' + err.message);
}
console.log('final stat after chmod 0');
try {
const post = fs.statSync(fn);
console.log('stat ok, isFile=' + post.isFile());
} catch (err) {
console.log('stat error code=' + err.code + ' msg=' + err.message);
}
} finally {
try { fs.chmodSync(fn, 0o600); fs.unlinkSync(fn); } catch {}
}
JSRepository: TestSprite/testsprite-cli
Length of output: 9913
Wrap the final secret-file read in this helper
statSync can succeed while readFileSync fails with EACCES when the file becomes unreadable after stat (for example, root running against a mode-0 file). Those failures currently surface as a raw filesystem error instead of the documented structured validation envelope.
🔧 Proposed fix — guard the final read too
- return readFileSync(absolute, 'utf8').trim();
+ try {
+ return readFileSync(absolute, 'utf8').trim();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw secretFileError(flagName, 'must point to a readable file', {
+ path: absolute,
+ error: message,
+ });
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function readSecretFileGuarded(path: string, flagName: string): string { | |
| const absolute = resolve(path); | |
| let stat; | |
| try { | |
| stat = statSync(absolute); | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| throw secretFileError(flagName, 'must point to a readable file', { | |
| path: absolute, | |
| error: message, | |
| }); | |
| } | |
| if (!stat.isFile()) { | |
| throw secretFileError(flagName, 'must point to a regular file', { path: absolute }); | |
| } | |
| if (stat.size > MAX_SECRET_FILE_BYTES) { | |
| throw ApiError.fromEnvelope({ | |
| error: { | |
| code: 'PAYLOAD_TOO_LARGE', | |
| message: 'Secret file is too large.', | |
| nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`, | |
| requestId: 'local', | |
| details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES }, | |
| }, | |
| }); | |
| } | |
| return readFileSync(absolute, 'utf8').trim(); | |
| } | |
| function readSecretFileGuarded(path: string, flagName: string): string { | |
| const absolute = resolve(path); | |
| let stat; | |
| try { | |
| stat = statSync(absolute); | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| throw secretFileError(flagName, 'must point to a readable file', { | |
| path: absolute, | |
| error: message, | |
| }); | |
| } | |
| if (!stat.isFile()) { | |
| throw secretFileError(flagName, 'must point to a regular file', { path: absolute }); | |
| } | |
| if (stat.size > MAX_SECRET_FILE_BYTES) { | |
| throw ApiError.fromEnvelope({ | |
| error: { | |
| code: 'PAYLOAD_TOO_LARGE', | |
| message: 'Secret file is too large.', | |
| nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`, | |
| requestId: 'local', | |
| details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES }, | |
| }, | |
| }); | |
| } | |
| try { | |
| return readFileSync(absolute, 'utf8').trim(); | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| throw secretFileError(flagName, 'must point to a readable file', { | |
| path: absolute, | |
| error: message, | |
| }); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/commands/project.ts` around lines 1101 - 1131, Update
readSecretFileGuarded so the final readFileSync operation is wrapped in the same
structured error handling as statSync. Convert read failures into
secretFileError(flagName, 'must point to a readable file', including the
absolute path and underlying error message), while preserving the existing
validation and trimmed successful-read behavior.
Source: Path instructions
What
project credentialandproject auto-auth(both v0.4.0) read their--*-fileflags with a rawreadFileSync(path, 'utf8').trim()and no guard. A missing file, a directory, or an oversized file escaped as an unwrapped Node error:1(generic) instead of5(validation)--output jsonenvelope —errorwas a bare string, not the{ code, message, nextAction }object the rest of the CLI emitstest.tsalready guards all of its file flags (--code-file,--plan-from,--plans,--steps), and #248 addsreadPasswordFileGuardedfor--password-file— but only oncreate/update. The fourcredential/auto-authflags were left unguarded because those commands didn't exist when that fix was written.Change
Generalize the guard into
readSecretFileGuarded(path, flagName)and apply it to all six project.ts file-read sites:project create/project update--password-fileproject credential--credential-fileproject auto-auth--password-file,--client-secret-file,--refresh-token-fileMissing / non-regular files now return
VALIDATION_ERROR(exit 5); oversized files (> 64 KiB) returnPAYLOAD_TOO_LARGE(exit 5). Each error carries the flag name, matching the existing--code-filecontract.Before / after
Relationship to #248
This absorbs #248's
--password-fileguard into one shared helper covering all six sites, so the two don't need separate implementations. Happy to rebase around whichever lands first — if #248 merges, I'll drop the two overlapping password-file sites; if this merges first, #248 becomes redundant.Notes / scope
create/updatestill skip the file read under--dry-run;credential/auto-authvalidate the path under--dry-run(now a clean exit-5 error instead of a raw crash). Unifying the dry-run read ordering across all four commands is a reasonable follow-up but is out of scope here to keep the change focused.Testing
npm run typecheck,npm run lint,npm run format:check— cleanmain(v0.4.0)Closes #282
Summary by CodeRabbit