Skip to content

feat(cache): add cache stats, invalidation and an opt-out flag - #670

Merged
gulshngill merged 9 commits into
mainfrom
feat/cache-stats-and-controls
Sep 23, 2026
Merged

gulshngill merged 9 commits into
mainfrom
feat/cache-stats-and-controls

Conversation

@gulshngill

Copy link
Copy Markdown
Contributor

Summary

The CLI keeps three caches under ~/.nansen and offered no way to look at any of them — no size, no age, no effective TTL, no way to tell which commands cache at all. cache was also missing from src/schema.json entirely, which is why it never showed up in nansen schema or in the generated shell completions.

The caches that exist (all pre-existing; this PR adds no new caching mechanism):

Cache Location Written by TTL
responses ~/.nansen/cache/<digest>.json the API client, only when --cache is passed --cache-ttl, default 300s
cost-map ~/.nansen/cost-map.json credit cost lookup 24h
update-check ~/.nansen/update-check.json background update check 24h

What cache stats shows. Per cache: entry count, bytes on disk, age of the oldest and newest entry, the effective TTL, and how many entries are already past it — plus a total. --json returns the same numbers as an object for scripting. There are no hit/miss counters: hits are decided in memory and nothing records them on disk, so the report says that outright (hit_miss_recorded: false) rather than inventing a hit rate. Adding persistent hit/miss telemetry was deliberately out of scope.

Controls added. nansen cache clear now takes an explicit target — responses (the default, unchanged behaviour), cost-map, update-check, or all — and prints exactly what it removed. An unrecognised target is refused and removes nothing. NANSEN_NO_CACHE=1 is a new flagless equivalent of the existing --no-cache, for wrappers and agent harnesses that cannot change the command line; like --no-cache it is a veto, never an enable.

Which commands cache is now documented as one rule rather than a per-command flag, because caching is a property of the request path, not of the individual command: a per-command cached: true would be the same sentence copied across hundreds of schema entries and would go stale the moment a command lands. Instead there is a caching section in nansen schema (scope, the controls, the TTL, the explicit never-cached list, and the three caches), the same rule in nansen cache help, and a README section. A drift-guard test pins the documented never-cached list to the requests that actually opt out in src/api.js, so the docs cannot silently fall behind.

Privacy guarantee. Stats are built from directory listings and stat(2) alone — no cache file is ever opened — so a cached payload, an API key, a wallet address, an endpoint path or a request parameter cannot reach the output by construction. Cache keys are digests and are not printed either; the report is aggregates only. A test asserts that none of a credential-shaped fixture, an address, an endpoint path, a payload field or any hash-shaped token appears in either rendering.

Safety. Clearing only ever unlinks regular files at the three known paths: .json entries directly inside the response cache directory, and the two named files. Directories and symlinks are skipped rather than followed, so a clear cannot be walked out of the cache it was pointed at. config.json, wallets/, saved quotes and saved auth are never read and never deleted — saved quotes in particular are left alone on purpose, since they expire on their own and an unexecuted quote is still spendable. The cache command also no longer triggers the background update check or the cost-map refresh, which would otherwise repopulate a cache the user had just measured or cleared.

Sample output (fake values):

$ nansen cache stats

Cache stats

  responses       14 entries   47.1 KB TTL 5m    oldest 9m    newest 12s   3 expired
  cost-map         1 entry     18.4 KB TTL 24h   oldest 6h    newest 6h
  update-check     1 entry        64 B TTL 24h   oldest 6h    newest 6h

  Total: 16 entries, 65.6 KB under /home/you/.nansen
  Hits and misses are not recorded on disk, so there is no hit rate to report.

  Caching is off unless --cache is passed. --no-cache (or NANSEN_NO_CACHE=1) bypasses it.
  Clear with: nansen cache clear [responses|cost-map|update-check|all]

$ nansen cache clear all
✓ Cleared 14 entries (47.1 KB) from the response cache
  /home/you/.nansen/cache
✓ Cleared 1 entry (18.4 KB) from the credit cost map
  /home/you/.nansen/cost-map.json
✓ Cleared 1 entry (64 B) from the update check
  /home/you/.nansen/update-check.json
  Total: 16 entries, 65.6 KB

Changes

  • src/cache-inspect.js (new) — reads and clears the three caches. Owns no cache of its own and never writes an entry; paths come from the modules that own them, so there is one source of truth per cache.
  • src/cli.js — cache stats and cache clear <target> subcommands; CACHE_HELP (also what nansen cache --help shows); cache added to the --help banner as stats, clear; NANSEN_NO_CACHE honoured (via an injectable env dep on runCLI); the caching block surfaced in the compact nansen schema output; the cache command no longer schedules the update check or the cost-map refresh.
  • src/schema.json — the cache command group with descriptions, options, returns and examples per subcommand; --cache and --no-cache in globalOptions next to the existing --cache-ttl; a top-level caching section documenting scope, controls, TTLs, the never-cached list and the three caches.
  • src/api.js, src/cost-cache.js, src/update-check.js — export the default TTL, the two cache file paths and their TTLs. No behaviour change.
  • src/commands/completion.js — drop the hand-written cache entry from UNSCHEMA_COMMANDS now that the schema describes it (a test asserts the two never overlap). Completions now offer cache stats as well as cache clear.
  • src/__tests__/cache-stats.test.js (new, 27 tests) — empty cache, populated cache, ages and byte counts, TTL-sensitive expiry, non-entry files ignored, the report rendering, the privacy assertion, clearing per target and all, unknown targets, the symlink guard, the command layer, the --cache/--no-cache/NANSEN_NO_CACHE matrix, and the docs/code drift guards. Every fixture lives in a temp HOME, using the same isolation mechanism as api-cache.test.js.
  • README.md — a Caching section (controls, what caches, how to inspect and clear, the cache table) and the two flags in Key Options.

Validation

npm test
 Test Files  77 passed (77)
      Tests  3225 passed | 2 skipped (3227)

npm run lint
> eslint .        (no findings)

Also smoke-tested end to end against a throwaway HOME: cache stats, cache stats --json --pretty, cache stats --cache-ttl, cache, cache --help, cache stats --help, cache clear, cache clear all, and cache clear <bogus> (exits 1, removes nothing) — with config.json and wallets/ confirmed untouched afterwards.

Expected rebase points

Checklist

  • Tests pass (npm test)
  • src/schema.json updated if new commands or flags were added
  • README.md updated if new top-level commands or categories were added
  • Changeset added (.changeset/) — only required for user-facing changes (new commands, flags, bug fixes, breaking changes)

🤖 Generated with Claude Code

…v var

The CLI kept three caches under ~/.nansen and offered no way to look at any
of them: no size, no age, no effective TTL, and no way to tell which commands
cache at all. `cache` was also missing from src/schema.json entirely, so it
was invisible to `nansen schema` and to the generated shell completions.

- `nansen cache stats` reports entries, bytes on disk, oldest/newest age, the
  effective TTL and the already-expired count for each cache (saved API
  responses, credit cost map, update check), as text or as an object with
  --json. Hits and misses are not recorded anywhere on disk, so the report
  says so instead of inventing a hit rate.
- `nansen cache clear` takes an explicit target — responses (default),
  cost-map, update-check or all — and prints exactly what it removed. It only
  unlinks regular files in the cache it was pointed at; symlinks are skipped
  rather than followed, and credentials, wallets, saved quotes and config are
  never touched.
- NANSEN_NO_CACHE=1 is a flagless equivalent of --no-cache, for wrappers that
  cannot change the command line. Both are a veto, never an enable.
- Caching is documented once as a rule rather than per command: a `caching`
  section in the schema, the cache command's own help, and README.

Stats are built from directory listings and stat(2) alone — no cache file is
ever opened — so a cached payload, an API key, an address, an endpoint path or
a cache key cannot reach the output. A test asserts it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gulshngill

Copy link
Copy Markdown
Contributor Author

Merge position: 9 of 10. Merge after #666, and before #669.

#666 adds a drift-guard test asserting every command group in src/schema.json is named in the help banner. This PR registers cache in the schema for the first time, so it needs that test to be in place and satisfied. Both of its banner rules were checked against this branch and pass.

Expect textual conflicts with #668 in the globalOptions block of src/schema.json and in the README and help option lists. There is no functional overlap: #668 traces a cache hit for a single run, which is not persistent accounting.

Revised order for the batch: #665 → #661 → #662 → #663 → #666 → #664 → #667 → #668 → this → #669.

Comment thread src/cache-inspect.js Resolved
@nansen-pr-reviewer

nansen-pr-reviewer Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

pr-reviewer Summary for #a3a5253

✅ No issues found

The code review completed successfully with no findings.

Review effort: 4/5 (Complex)

Summary

This is a well-considered and carefully hardened PR. cache-inspect.js correctly isolates path resolution per-call (via namespaces()), matches the expiry boundary semantics of each cache's own reader (strictly-greater-than for responses, greater-or-equal for auxiliaries — verified by the alignment tests), and applies thorough symlink guards using O_NOFOLLOW with a documented Windows fallback. The NANSEN_NO_CACHE=1 veto is wired correctly — it cannot act as an enable, only a veto when --cache is also present. The privacy guarantee (aggregate-only output, no file content read for stats, no digest keys printed) is structurally enforced and covered by a dedicated test. Schema, help text, README, changeset, and a drift-guard test tying the neverCached list to cache: false in api.js are all present and coherent.

No actionable findings were identified. The clearCache export in api.js remains but is only consumed by api.test.js and cli.internal.test.js (pre-existing tests that use it for teardown and direct unit tests of the api-layer function) — this is not a regression introduced by this PR. The three changesets are correctly scoped to nansen-cli with appropriate semver levels (minor for the new feature, two patch entries for the validation hardening added during review).


Token usage: 21,303 input, 9,653 output, 2,150,536 cache read, 70,626 cache write | Usage Guide

New pushes are reviewed automatically with a 10-minute cooldown between reviews. To request a review at any time, comment @nansen-pr-reviewer re-review.

@gulshngill

Copy link
Copy Markdown
Contributor Author

@nansen-pr-reviewer re-review

Comment thread src/cache-inspect.js Resolved
Comment thread src/cache-inspect.js
// Windows does not define it. There, only the earlier lstat check applies;
// opening with O_RDONLY does not prevent this race.
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
fd = fs.openSync(file, flags);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix

Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Comment thread src/cache-inspect.js
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
fd = fs.openSync(file, flags);
if (!fs.fstatSync(fd).isFile()) return { raced: true };
raw = fs.readFileSync(fd, 'utf8');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix

Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

@gulshngill
gulshngill merged commit 1faade8 into main Sep 23, 2026
9 checks passed
@gulshngill
gulshngill deleted the feat/cache-stats-and-controls branch September 23, 2026 09:30
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