Skip to content

fix: data-guard coverage, interrupted-flow cleanup, log-field bounds - #372

Merged
KrasimirKralev merged 4 commits into
betafrom
fix/data-guard-coverage
Aug 11, 2026
Merged

fix: data-guard coverage, interrupted-flow cleanup, log-field bounds#372
KrasimirKralev merged 4 commits into
betafrom
fix/data-guard-coverage

Conversation

@KrasimirKralev

@KrasimirKralev KrasimirKralev commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Coverage, correctness and cleanup across the file guard, the OAuth sign-in flow, and two log/accumulator details. No behaviour change for anything that was already working.

1. Guard the data directory by containment rather than an enumerated list

src/lib/file-guard.ts kept a hand-maintained list of filenames under DATA_DIR. It had fallen behind the code — the OAuth flow files, the login and credentials-change state, the tunnel state and the cloudflared/ directory were all missing from it. A list also cannot describe this directory in principle: an atomic write stages <name>.tmp.<hex> beside its target, so some entries are named at runtime.

Inverted to containment. Everything under DATA_DIR is covered except an explicit set of user-facing subtrees — webapps, icons, catalog-cache, code-projects, llamacpp — each checked against the module that owns it. DATA_DIR itself stays listable, because the Files API filters a listing entry by entry and that is what surfaces the public subtrees.

Two implementation details worth a look:

  • a prefix test rather than path.relative. This runs once per entry in a directory listing (a file search scans up to 20k), and path.relative measured at roughly six times the cost of the rest of the guard put together.
  • the first segment is found with indexOf(path.sep) rather than a split on a character class of both separators. On POSIX a backslash is a legal filename character, so the class form would read a single entry named webapps\x as a path into the webapps subtree.

2. Include the backup tool's directory in the protected set

~/.clawkeep (CLAWKEEP_DATA_DIR) holds the portal token and the device's backup passphrase, and sits inside $HOME. It had no entry in PROTECTED_DIR_RES and no clawkeep alternative in the MCP SECRET_NAME_RE. middleware.ts already classes /setup-api/clawkeep as sensitive — this is the same rule applied to the store behind the route.

3. Remove interrupted sign-in state instead of leaving it

Three gaps in the OAuth handoff file's lifecycle:

  • device-poll removed the flow state on its two provider-failure branches but not the tokens.
  • configure guarded its age comparison on createdAt being present, so a file without one never expired. A file whose age cannot be established is now refused and removed, like an expired one.
  • nothing removed a handoff file by age; device-poll's POST now sweeps one past the TTL.

The file had five call sites across five routes with no owner — two writers, one reader, one clear-on-start, and now a sweep — with the TTL spelled twice and a comment in one of them asserting the two windows were the same. Path, TTL and the clear/sweep helpers now live in src/lib/oauth-handoff.ts, and the authorization-code flow gets the same clear-on-start the device-code flow already had, so the handoff file always belongs to the sign-in in progress.

4. Bound and sanitise a log field

New src/lib/log-safe.ts: replace control characters so one value stays one line, and cap the length with a count of what was dropped so a record's size does not follow its input's. Applied at both wifi/update sinks (the second one's scrubbed nmcli text re-embeds the SSID) and at the preferences rejected-write line, whose reason string is built from the caller-supplied key.

normalizedSsid is also capped at 32 octets — 802.11 defines the SSID element as at most 32 octets, so counting octets rather than characters makes the check mean what it says, and it is what actually bounds how much reaches the log.

5. Null-prototype accumulators

preferences/route.ts and sanitizePreferences. The last of the three is the one that builds the object reaching the response, so fixing only the callers would have moved the question one hop rather than settled it.

Testing

  • bun run test: 2261 passing. 29 failures are pre-existing on the dev machine (Windows: chmod/symlink/spawn-env semantics) — the failing set is byte-for-byte identical before and after this branch, verified by running the suite against a stash of the changes.
  • tsc --noEmit clean apart from two pre-existing errors in untouched test files; eslint reports no errors on every changed file.
  • The new guard form was checked against the previous path.relative implementation over a 66-path corpus: zero divergence, plus the backslash-name case it fixes.
  • Hardware verification was not done — no Jetson was available. Everything here is unit-level and CI; e2e and e2e-install cover the routes on this PR.

Scope

Deliberately left alone, noted for follow-up:

  • system/hotspot/route.ts enforces the same 32-SSID rule in characters rather than octets, with its own error string. Unifying them changes hotspot validation, so it is a separate change.
  • persistTokensAndAck is still duplicated between device-poll and exchange; the new module owns the path and lifetime but not the writer.
  • searchTree in files/route.ts runs the guard for entries whose result it then discards; reordering two lines would skip roughly 19.7k of 20k calls per search.
  • preferences GET does one full config read and parse per key where the all=1 branch above it already shows the one-read form.

Summary by CodeRabbit

  • Security

    • Expanded protection for credential stores and sensitive application data while preserving access to approved public files.
    • Improved handling of invalid, expired, and stale OAuth sign-in handoffs.
  • Bug Fixes

    • OAuth sign-in cleanup is more reliable after failed or repeated attempts.
    • Wi-Fi updates now reject SSIDs exceeding the 32-byte limit, including multi-byte characters.
    • Sensitive error details and control characters are safer in application logs.

…d list

The protected set for DATA_DIR was a hand-maintained list of filenames, and it
had fallen behind the code: the OAuth flow files, the login and credentials-change
state, the tunnel state, and the cloudflared directory were all absent from it.
A list also cannot describe this directory in principle — an atomic write stages
`<name>.tmp.<hex>` beside its target, so some entries are named at runtime.

Invert it. Everything under DATA_DIR is covered except an explicit set of
user-facing subtrees (webapps, icons, catalog-cache, code-projects, llamacpp),
each verified against the module that owns it. DATA_DIR itself stays listable,
since the Files API filters a listing entry by entry and that is what surfaces
the public subtrees.

Also include the backup tool's own directory (~/.clawkeep, holding its portal
token and the device's backup passphrase) in the named set, and add `clawkeep`
to the MCP name pattern. The route in front of it was already classed as
sensitive in middleware; the store behind it was not.

Implementation notes:
- a prefix test rather than path.relative, which costs about six times the rest
  of the guard put together on a per-entry path (a file search scans up to 20k)
- the first segment is found with indexOf rather than a split on a class of both
  separators, which on POSIX would read a backslash — a legal filename
  character — as a path separator

Tests cover the full inventory, a runtime-named sidecar, a name invented for the
test, over-block guards for each public subtree and for DATA_DIR itself, and the
backslash-in-a-name case (POSIX only).
Three gaps in the OAuth token handoff file's lifecycle:

- device-poll removed the flow state on its two provider-failure branches but
  not the handoff tokens, so a failed exchange left them on disk
- configure treated a file with no `createdAt` as fresh: the age comparison was
  guarded by the field's presence, so a file without one never expired. A file
  whose age cannot be established is now refused and removed, like an expired one
- nothing removed a handoff file by age. device-poll's POST now sweeps one past
  the TTL, alongside the flow-state check it already ran

The file had five call sites across five routes with no owner: two writers, one
reader, one clear-on-start, and now a sweep — with the TTL spelled twice and a
comment in one of them asserting the two windows were the same. Move the path,
the TTL and the clear/sweep helpers into src/lib/oauth-handoff.ts so they cannot
drift apart, and give the authorization-code flow the same clear-on-start the
device-code flow already had, so a handoff file always belongs to the sign-in in
progress rather than to whichever one was abandoned last.

Tests: an exchange failure leaves no token file, a file without `createdAt` is
rejected and removed, an over-age file is swept, and one inside the TTL is kept.
The two console.warn sinks in the wifi update route interpolated the SSID and an
nmcli error message straight into a log line. Neither was bounded, and neither
had its control characters removed, so one value could span several journal
records. The second sink's already-scrubbed text carries the SSID too, since
nmcli echoes it back — so it needs the same treatment, and the first now logs a
string rather than handing console.warn a raw error object.

Add src/lib/log-safe.ts: replace control characters (so one value stays one line
and renders as text in a terminal) and cap the length with a count of what was
dropped (so the size of a record does not follow the size of its input). It cuts
before sanitising, which gives the same string without walking a caller-sized
value to produce a bounded line — an execFile message is bounded only by its
1 MB maxBuffer.

Also cap the SSID at 32 octets. 802.11 defines the SSID element as at most 32
octets, so a longer value cannot name a network nmcli could act on; counting
octets rather than characters is what makes that check mean what it says. It is
also what actually bounds how much reaches the log, which the sanitiser alone
does not do.
Both accumulators in the preferences GET handler, and the one in
sanitizePreferences that they are handed to, are built from key names that come
from outside the function. Assigning into a plain object literal can reach an
inherited name rather than defining an own property; Object.create(null) means
an assignment always defines one.

sanitizePreferences matters most of the three — it builds the object that
actually reaches the response, so fixing only the callers would move the
question one hop rather than settle it.

Also run the rejected-write log line through logSafe. The reason string is built
from the rejected key, which is caller-supplied and only prefix-checked, so it
is a request-derived log field like any other.
@KrasimirKralev
KrasimirKralev requested a review from a team as a code owner August 11, 2026 19:54
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The changes centralize OAuth handoff storage and cleanup, expand credential and ClawBox data-path protection, add bounded log sanitization, enforce Wi-Fi SSID byte limits, and add regression coverage.

Changes

Security and OAuth hardening

Layer / File(s) Summary
OAuth handoff lifecycle
src/lib/oauth-handoff.ts, src/app/setup-api/ai-models/oauth/*, src/app/setup-api/ai-models/configure/route.ts, src/tests/routes/ai-models/*
OAuth routes share the handoff path and TTL. Invalid, expired, missing-timestamp, and failed-flow handoffs are removed.
Credential and data path protection
src/lib/file-guard.ts, mcp/lib/guard.ts, src/tests/unit/file-guard.test.ts, src/tests/unit/mcp-path-guard.test.ts, src/tests/routes/files/path.test.ts
Credential stores and ClawBox data descendants are protected, while designated public subtrees remain accessible.
Bounded log and preference handling
src/lib/log-safe.ts, src/app/setup-api/preferences/route.ts, src/lib/preference-schema.ts, src/tests/unit/log-safe.test.ts
logSafe sanitizes and bounds untrusted strings. Preference accumulators use null-prototype objects.
Wi-Fi input validation and logging
src/app/setup-api/wifi/update/route.ts, src/tests/routes/wifi-saved-update.test.ts
The route validates SSIDs by UTF-8 octet length and sanitizes failure logs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OAuthStart
  participant DevicePoll
  participant OAuthHandoff
  participant FileSystem
  OAuthStart->>OAuthHandoff: Clear previous handoff tokens
  DevicePoll->>OAuthHandoff: Sweep stale handoff tokens
  OAuthHandoff->>FileSystem: Stat and remove stale file
  DevicePoll->>FileSystem: Atomically persist exchanged tokens
  DevicePoll->>OAuthHandoff: Clear tokens after failed flow
Loading

Possibly related PRs

Suggested reviewers: georgik77, yalexx

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: data-guard coverage, interrupted OAuth-flow cleanup, and bounded log fields.
Description check ✅ Passed The description provides a detailed summary, testing results, scope, and limitations, although it omits the template checkboxes and screenshots section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/data-guard-coverage

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.

❤️ Share

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

@github-actions

Copy link
Copy Markdown

🦀 ClawReview

Poked my eyestalks out for this one. Quick tour:

Tightens the file-access guard, patches three lifecycle gaps in the OAuth sign-in flow, adds a log-sanitization helper, and fixes a handful of small correctness issues (null-prototype accumulators, SSID octet-length validation). No new runtime behaviour for anything that was already working correctly — this is a coverage and correctness sweep.

At a glance

  • 🔧 Fix · touches file guard + MCP path guard, OAuth handoff flow, WiFi update route, preferences route, log output
  • Base branch: beta · +246 source / +434 tests across 19 files
  • ✅ base beta matches the beta-first convention
  • ✅ conventional PR title
  • ✅ source changes come with test changes

Good to know

  • 🟡 The file-guard rule for DATA_DIR flips from an enumerated list to containment — a significant model change that covers runtime-named atomic-write sidecars and future files the list could never describe.
  • 🟡 mcp/lib/guard.ts is updated (SECRET_NAME_RE + data-dir delegation), so the change ships to the MCP server running on customer Jetson devices.
  • ℹ️ Two new lib files (oauth-handoff.ts, log-safe.ts) with no new package dependencies — pure Node/fs additions.
  • ℹ️ Substantial test additions across six test files; 2261 passing, 29 pre-existing failures attributed to Windows chmod/symlink/spawn-env semantics.

— ClawReview 🦀, your resident reef crab. Just orientation — CodeRabbit does the line-by-line, humans do the merge. Conventions: docs.

@github-actions github-actions Bot added area: gateway Auto-triage area area: ui Auto-triage area labels Aug 11, 2026
Comment thread src/app/setup-api/wifi/update/route.ts Dismissed
Comment thread src/app/setup-api/wifi/update/route.ts Dismissed
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

CI Summary

✅ Tests

  • Result: passed
  • View run
  • Coverage: statements 64.75%, branches 53.74%, functions 62.84%, lines 66.87%

✅ E2E

✅ E2E Install

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/setup-api/ai-models/configure/route.ts`:
- Around line 496-502: Separate the HANDOFF_TOKENS_PATH file read from
JSON.parse in the handoff-loading flow, preserving the existing 400 response for
missing or unreadable files. When parsing fails, remove the malformed handoff
file before returning the 400 response, and add a regression test covering
malformed JSON cleanup and the returned error.
- Around line 505-511: Strengthen the credential validation around
handoff.createdAt so it must be a finite number no later than the current time
before applying HANDOFF_TTL_MS; reject and unlink the handoff for malformed,
NaN, or future timestamps. Add tests covering non-numeric and future createdAt
values while preserving existing stale-token cleanup behavior.

In `@src/app/setup-api/ai-models/oauth/device-poll/route.ts`:
- Around line 32-36: Update discardFlow() in
src/app/setup-api/ai-models/oauth/device-poll/route.ts:32-36 to remove only
STATE_PATH and stop calling clearHandoffTokens(). Update the failure test in
src/tests/routes/ai-models/device-poll.test.ts:322-355 to assert STATE_PATH is
removed while TOKENS_PATH is retained.

In `@src/app/setup-api/ai-models/oauth/start/route.ts`:
- Around line 19-22: Move clearHandoffTokens in
src/app/setup-api/ai-models/oauth/start/route.ts#L19-L22 to run only after the
new OAuth state has been validated and persisted. Apply the same ordering in
src/app/setup-api/ai-models/oauth/device-start/route.ts#L15-L18: validate the
provider response and persist the new device state before clearing the
superseded handoff; both sites require direct changes.
🪄 Autofix

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: ASSERTIVE

Plan: Pro Plus

Run ID: 77816d97-c695-45c5-b605-886dbbe7161e

📥 Commits

Reviewing files that changed from the base of the PR and between 5932848 and 1d27938.

📒 Files selected for processing (19)
  • mcp/lib/guard.ts
  • src/app/setup-api/ai-models/configure/route.ts
  • src/app/setup-api/ai-models/oauth/device-poll/route.ts
  • src/app/setup-api/ai-models/oauth/device-start/route.ts
  • src/app/setup-api/ai-models/oauth/exchange/route.ts
  • src/app/setup-api/ai-models/oauth/start/route.ts
  • src/app/setup-api/preferences/route.ts
  • src/app/setup-api/wifi/update/route.ts
  • src/lib/file-guard.ts
  • src/lib/log-safe.ts
  • src/lib/oauth-handoff.ts
  • src/lib/preference-schema.ts
  • src/tests/routes/ai-models/configure.test.ts
  • src/tests/routes/ai-models/device-poll.test.ts
  • src/tests/routes/files/path.test.ts
  • src/tests/routes/wifi-saved-update.test.ts
  • src/tests/unit/file-guard.test.ts
  • src/tests/unit/log-safe.test.ts
  • src/tests/unit/mcp-path-guard.test.ts

Comment thread src/app/setup-api/ai-models/configure/route.ts
Comment on lines 505 to +511
if (
!handoff.access_token ||
(handoff.createdAt && Date.now() - handoff.createdAt > 15 * 60 * 1000)
!handoff.createdAt ||
Date.now() - handoff.createdAt > HANDOFF_TTL_MS
) {
// Stale/invalid credential material — consume it so it can't linger.
await fs.unlink(tokensPath).catch(() => {});
await fs.unlink(HANDOFF_TOKENS_PATH).catch(() => {});

@coderabbitai coderabbitai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate the timestamp value at runtime.

A truthy non-number value produces NaN during subtraction. A future timestamp produces a negative age. Both values bypass this expiry check and let the handoff proceed.

Require a finite numeric createdAt that is not later than the current time before applying HANDOFF_TTL_MS. Add tests for malformed and future timestamps.

🤖 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/app/setup-api/ai-models/configure/route.ts` around lines 505 - 511,
Strengthen the credential validation around handoff.createdAt so it must be a
finite number no later than the current time before applying HANDOFF_TTL_MS;
reject and unlink the handoff for malformed, NaN, or future timestamps. Add
tests covering non-numeric and future createdAt values while preserving existing
stale-token cleanup behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken — this was a real hole in the line the branch had just changed.

Both cases you name slip through for the same reason: Date.now() - createdAt is NaN for a non-numeric value and negative for a future one, and neither is > TTL, so the file was accepted no matter how old it was. Replacing !handoff.createdAt with a presence check alone would not have covered either.

The age is now computed only from a createdAt that is a finite number, and a null age or a negative one is refused and removed exactly like an over-age file. Tests cover a string, NaN and a future timestamp.

Fixed in 6162632.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline 6162632
git show --format=fuller --find-renames --find-copies -- \
  src/app/setup-api/ai-models/configure/route.ts \
  src/tests/routes/ai-models/configure.test.ts \
  6162632

printf '\n--- Current validation hunk ---\n'
rg -n -A22 -B8 'createdAt|HANDOFF_TTL_MS|handoffAge' \
  src/app/setup-api/ai-models/configure/route.ts

printf '\n--- Relevant tests ---\n'
rg -n -A15 -B8 'createdAt|future|NaN|non-numeric|handoff' \
  src/tests/routes/ai-models/configure.test.ts

Length of output: 2147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Current OAuth handoff validation ---'
rg -n -A30 -B10 'createdAt|HANDOFF_TTL_MS|handoffAge' \
  src/app/setup-api/ai-models/configure/route.ts || true

printf '%s\n' '--- Configure route test structure ---'
ast-grep outline src/tests/routes/ai-models/configure.test.ts --items all --type function

printf '%s\n' '--- Timestamp-related regression tests ---'
rg -n -A20 -B10 'createdAt|future|NaN|non-numeric|handoff|expired' \
  src/tests/routes/ai-models/configure.test.ts || true

Length of output: 12933


@KrasimirKralev, verification is inconclusive for commit 6162632 because that commit is not available in the checked-out repository.

The current configure/route.ts still uses:

!handoff.createdAt ||
Date.now() - handoff.createdAt > HANDOFF_TTL_MS

This code still accepts truthy non-numeric timestamps and future timestamps. The current tests cover missing and expired timestamps only. They do not include the stated string, NaN, or future-timestamp cases.

Please retry after the PR branch contains 6162632. The finding remains unresolved.


🧬 Code Graph Analysis Results

src/lib/oauth-handoff.ts:18,24

export const HANDOFF_TOKENS_PATH = path.join(DATA_DIR, "oauth-device-tokens.json");
export const HANDOFF_TTL_MS = 15 * 60 * 1000;

Defines the OAuth handoff file location and its 15-minute expiration interval.

src/app/setup-api/ai-models/oauth/device-start/route.ts:85-106

await fs.writeFile(
  tmpPath,
  JSON.stringify({
    provider: providerName,
    device_id: deviceId,
    user_code: userCode,
    interval,
    createdAt: Date.now(),
  }),
  { mode: 0o600 }
);
await fs.rename(tmpPath, STATE_PATH);

Creates device-auth state with createdAt as a numeric millisecond timestamp and writes it atomically.

src/app/setup-api/ai-models/oauth/device-poll/route.ts:195-210

// 15-minute expiry
if (Date.now() - stored.createdAt > HANDOFF_TTL_MS) {
  await fs.unlink(STATE_PATH).catch(() => {});
  return NextResponse.json(
    { error: "Device auth session expired. Please start again." },
    { status: 400 }
  );
}

Provides the analogous device-auth expiry check and removes expired state files.

You are interacting with an AI system.

Comment thread src/app/setup-api/ai-models/oauth/device-poll/route.ts
Comment thread src/app/setup-api/ai-models/oauth/start/route.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: gateway Auto-triage area area: ui Auto-triage area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants