mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
feat(path-picker): show hidden files and folders, and harden the secret blocklist
The picker behind Link Existing's "Browse" and the mobile keyboard's Path key refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml` could not be selected and a hidden folder could not be opened at all. It gains the same `.*` toggle as the File Viewer: default OFF, per-device, and applied to both the listing and the preview endpoint, which re-resolves the path independently. That dotfile filter was quietly doing security work. The picker's roots include Home, so with every hidden path unreachable the shared blocklist never had to name the credentials that live in dot-directories. Lifting the filter removes that accident, so `isSensitivePath` now covers them explicitly: SSH keys at any depth rather than only under $HOME, GPG keyrings, AWS/GCloud/Azure/Docker/ Kubernetes credentials, npm, Yarn, git, gh, netrc, PyPI, RubyGems, Cargo and Terraform tokens, .pgpass and .my.cnf, and the Claude and Codeman agent credentials. `~/.codeman/` and `~/.claude/` stay attachable as trees, since the publish skill and the review-card loop read from them; only their secret-bearing members are named. Everything else still applies with the toggle on: blocked trees, sensitive files, root confinement, ownership scoping and symlink-escape checks. A hidden entry whose realpath is a secret is dropped from the listing, and opening it is refused. Follows #221 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,12 @@
|
||||
// Shared Filesystem Path Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Per-device, and deliberately its own key rather than a shared "show hidden"
|
||||
// preference with the File Viewer: that tree is confined to one workspace, while
|
||||
// the picker browses Home and every configured root, so wanting dotfiles in a
|
||||
// project does not imply wanting them in ~.
|
||||
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
@@ -43,6 +49,7 @@ const PathPicker = {
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
_showHidden: false,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
@@ -53,6 +60,7 @@ const PathPicker = {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._showHidden = this._loadShowHidden();
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
@@ -74,6 +82,7 @@ const PathPicker = {
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
@@ -100,6 +109,8 @@ const PathPicker = {
|
||||
if (current) this.select(current);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||
this._syncHiddenButton();
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
@@ -120,6 +131,38 @@ const PathPicker = {
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
_loadShowHidden() {
|
||||
try {
|
||||
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
_syncHiddenButton() {
|
||||
const btn = this.overlay?.querySelector('.path-picker-hidden');
|
||||
if (!btn) return;
|
||||
const label = this._showHidden ? 'Hide hidden files and folders' : 'Show hidden files and folders';
|
||||
btn.classList.toggle('active', this._showHidden);
|
||||
btn.setAttribute('aria-pressed', this._showHidden ? 'true' : 'false');
|
||||
btn.setAttribute('title', label);
|
||||
btn.setAttribute('aria-label', label);
|
||||
},
|
||||
|
||||
toggleHidden() {
|
||||
if (!this.overlay) return;
|
||||
this._showHidden = !this._showHidden;
|
||||
try {
|
||||
localStorage.setItem(PATH_PICKER_SHOW_HIDDEN_KEY, this._showHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncHiddenButton();
|
||||
// Reload where we are rather than resetting to the root. Turning the toggle
|
||||
// OFF inside a hidden folder makes the current path unbrowsable again; the
|
||||
// server answers 403 and load()'s catch falls back to the default root,
|
||||
// which is the only place left to stand.
|
||||
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
@@ -131,6 +174,7 @@ const PathPicker = {
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set('path', path);
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
@@ -248,6 +292,9 @@ const PathPicker = {
|
||||
const requestSequence = ++this._previewRequestSequence;
|
||||
const params = new URLSearchParams({ path: entry.path });
|
||||
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
// A hidden file is only reachable while the toggle is on, and the preview
|
||||
// endpoint re-resolves the path independently, so it needs the flag too.
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
|
||||
@@ -11980,7 +11980,8 @@ body.touch-device.cjk-input-visible .main {
|
||||
}
|
||||
|
||||
.path-picker-up,
|
||||
.path-picker-refresh {
|
||||
.path-picker-refresh,
|
||||
.path-picker-hidden {
|
||||
flex: 0 0 38px;
|
||||
height: 38px;
|
||||
color: var(--text);
|
||||
@@ -11990,6 +11991,20 @@ body.touch-device.cjk-input-visible .main {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
|
||||
* (dot-prefixed files and folders) survives every skin and font stack. */
|
||||
.path-picker-hidden {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.path-picker-hidden.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-up:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
|
||||
Reference in New Issue
Block a user