v1.11.7: secret-file deny list for codebase tools
Ports continue.dev's DEFAULT_SECURITY_IGNORE_FILETYPES + ignored-dir lists into apps/server/src/services/secret_guard.ts plus a small BooCode additions block (id_rsa*, *credentials*, .netrc, *.kdbx). Tiny glob-to- regex matcher; no new prod dep. view_file hard-refuses via SecretBlockedError. list_dir / grep / find_files filter their results and surface a pathguard_note string field with the hidden count — never list the offending paths back. Named secret_guard.ts (not safety/pathGuard.ts) to avoid collision with the existing path_guard.ts which already exports a pathGuard() function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
apps/server/src/services/__tests__/secret_guard.test.ts
Normal file
198
apps/server/src/services/__tests__/secret_guard.test.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isSecretPath,
|
||||||
|
filterSecretEntries,
|
||||||
|
SecretBlockedError,
|
||||||
|
DEFAULT_SECURITY_IGNORE_FILETYPES,
|
||||||
|
} from '../secret_guard.js';
|
||||||
|
|
||||||
|
// ---- env / config patterns -------------------------------------------------
|
||||||
|
|
||||||
|
describe('isSecretPath — env / config files', () => {
|
||||||
|
it('matches .env (literal via .env*)', () => {
|
||||||
|
expect(isSecretPath('.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches .env.local (via .env*)', () => {
|
||||||
|
expect(isSecretPath('.env.local')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches .env.production.local (via .env*)', () => {
|
||||||
|
expect(isSecretPath('.env.production.local')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches .envrc (via .env*, common direnv config holding secrets)', () => {
|
||||||
|
expect(isSecretPath('.envrc')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches nested .env (apps/server/.env via basename test)', () => {
|
||||||
|
expect(isSecretPath('apps/server/.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('case-insensitive: .ENV matches .env*', () => {
|
||||||
|
expect(isSecretPath('.ENV')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- SSH / cert / key patterns --------------------------------------------
|
||||||
|
|
||||||
|
describe('isSecretPath — SSH / certs / keys', () => {
|
||||||
|
it('matches id_rsa (continue.dev literal)', () => {
|
||||||
|
expect(isSecretPath('id_rsa')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches id_rsa.pub (BooCode addition id_rsa*)', () => {
|
||||||
|
// continue.dev's literal id_rsa wouldn't match this; BooCode broadens
|
||||||
|
// because .pub files leak hostnames/usernames and authorized_keys hints.
|
||||||
|
expect(isSecretPath('id_rsa.pub')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches cert.pem (*.pem)', () => {
|
||||||
|
expect(isSecretPath('cert.pem')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches private.key (*.key)', () => {
|
||||||
|
expect(isSecretPath('private.key')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- credential patterns ---------------------------------------------------
|
||||||
|
|
||||||
|
describe('isSecretPath — credential files (BooCode additions)', () => {
|
||||||
|
it('matches credentials.json (BooCode *credentials*)', () => {
|
||||||
|
expect(isSecretPath('credentials.json')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches aws_credentials (BooCode *credentials* — substring match)', () => {
|
||||||
|
// continue.dev has no `credentials*` pattern. BooCode adds `*credentials*`
|
||||||
|
// to catch the common `aws_credentials`, `gcp-credentials.yml`, etc.
|
||||||
|
expect(isSecretPath('aws_credentials')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches .netrc (BooCode addition)', () => {
|
||||||
|
expect(isSecretPath('.netrc')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches keystore.kdbx (BooCode addition *.kdbx)', () => {
|
||||||
|
expect(isSecretPath('keystore.kdbx')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- directory patterns ----------------------------------------------------
|
||||||
|
|
||||||
|
describe('isSecretPath — directory segments (trailing-slash patterns)', () => {
|
||||||
|
it('matches files under .aws/ via segment test', () => {
|
||||||
|
expect(isSecretPath('home/user/.aws/credentials')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches files under .ssh/', () => {
|
||||||
|
expect(isSecretPath('home/user/.ssh/known_hosts')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches files inside any path segment named secrets/', () => {
|
||||||
|
expect(isSecretPath('apps/server/secrets/api.key')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- negatives -------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('isSecretPath — negatives', () => {
|
||||||
|
it('package.json is allowed', () => {
|
||||||
|
expect(isSecretPath('package.json')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('README.md is allowed', () => {
|
||||||
|
expect(isSecretPath('README.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Login.tsx is allowed (substring "login" doesn\'t trigger anything)', () => {
|
||||||
|
expect(isSecretPath('src/components/Login.tsx')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty string returns false (defensive)', () => {
|
||||||
|
expect(isSecretPath('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a directory NAMED "credentials" alone does NOT trigger — only file basenames do', () => {
|
||||||
|
// Worth pinning: BooCode's `*credentials*` is a basename pattern (no
|
||||||
|
// trailing `/`), so it tests the leaf filename only. A directory
|
||||||
|
// literally called "credentials" containing innocuous files (e.g.
|
||||||
|
// Login.tsx) is fine. This is a deliberate trade-off vs. continue.dev's
|
||||||
|
// dir-pattern approach — adding `credentials/` as a dir pattern would
|
||||||
|
// block legitimate code like `src/auth/credentials/Login.tsx`.
|
||||||
|
expect(isSecretPath('src/auth/credentials/Login.tsx')).toBe(false);
|
||||||
|
// ...but a file INSIDE that dir whose name includes "credentials" still
|
||||||
|
// blocks via the basename match:
|
||||||
|
expect(isSecretPath('src/auth/credentials/credentials.ts')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- filterSecretEntries (listing-tools helper) ----------------------------
|
||||||
|
|
||||||
|
describe('filterSecretEntries', () => {
|
||||||
|
it('removes secret entries and reports the count via note string', () => {
|
||||||
|
const entries = [
|
||||||
|
{ path: 'src/index.ts' },
|
||||||
|
{ path: '.env' },
|
||||||
|
{ path: 'README.md' },
|
||||||
|
{ path: 'id_rsa' },
|
||||||
|
{ path: 'apps/server/package.json' },
|
||||||
|
];
|
||||||
|
const result = filterSecretEntries(entries, (e) => e.path);
|
||||||
|
expect(result.kept.map((e) => e.path)).toEqual([
|
||||||
|
'src/index.ts',
|
||||||
|
'README.md',
|
||||||
|
'apps/server/package.json',
|
||||||
|
]);
|
||||||
|
expect(result.hidden).toBe(2);
|
||||||
|
expect(result.note).toBe('[pathGuard: 2 entries hidden by secret-file filter]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined note when nothing was filtered', () => {
|
||||||
|
const result = filterSecretEntries(
|
||||||
|
[{ path: 'a.ts' }, { path: 'b.ts' }],
|
||||||
|
(e) => e.path,
|
||||||
|
);
|
||||||
|
expect(result.kept).toHaveLength(2);
|
||||||
|
expect(result.hidden).toBe(0);
|
||||||
|
expect(result.note).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses singular "entry" for a 1-hit filter (cosmetic but worth pinning)', () => {
|
||||||
|
const result = filterSecretEntries(
|
||||||
|
[{ path: 'index.ts' }, { path: '.env' }],
|
||||||
|
(e) => e.path,
|
||||||
|
);
|
||||||
|
expect(result.note).toBe('[pathGuard: 1 entry hidden by secret-file filter]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- SecretBlockedError ----------------------------------------------------
|
||||||
|
|
||||||
|
describe('SecretBlockedError', () => {
|
||||||
|
it('carries the offending path on .path and in the message', () => {
|
||||||
|
const err = new SecretBlockedError('apps/server/.env');
|
||||||
|
expect(err.name).toBe('SecretBlockedError');
|
||||||
|
expect(err.path).toBe('apps/server/.env');
|
||||||
|
expect(err.message).toContain('apps/server/.env');
|
||||||
|
expect(err.message).toContain('pathGuard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- contract sanity check -------------------------------------------------
|
||||||
|
|
||||||
|
describe('DEFAULT_SECURITY_IGNORE_FILETYPES', () => {
|
||||||
|
it('exports at least 40 patterns (continue.dev base) and is non-empty', () => {
|
||||||
|
expect(DEFAULT_SECURITY_IGNORE_FILETYPES.length).toBeGreaterThanOrEqual(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes all the headline continue.dev entries we tested above', () => {
|
||||||
|
// Spot-check that the list still carries the patterns whose behavior
|
||||||
|
// the tests depend on. Catches an accidental list edit that would
|
||||||
|
// silently degrade coverage.
|
||||||
|
const set = new Set(DEFAULT_SECURITY_IGNORE_FILETYPES);
|
||||||
|
for (const pat of ['*.env', '.env*', '*.pem', '*.key', 'id_rsa', '.aws/', '.ssh/']) {
|
||||||
|
expect(set.has(pat), `missing pattern: ${pat}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
226
apps/server/src/services/secret_guard.ts
Normal file
226
apps/server/src/services/secret_guard.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// v1.11.7: secret-file guard. Filters paths that commonly contain secrets
|
||||||
|
// (env files, key/cert files, credential stores) out of tool results, and
|
||||||
|
// hard-refuses single-path reads of the same. Composes with path_guard.ts:
|
||||||
|
// pathGuard() proves the path is inside the project root; isSecretPath()
|
||||||
|
// then proves it's not a known-sensitive filename. Patterns ported from
|
||||||
|
// continuedev/continue/core/indexing/ignore.ts plus a small BooCode
|
||||||
|
// additions block (see below).
|
||||||
|
|
||||||
|
// Verbatim from continuedev/continue/core/indexing/ignore.ts
|
||||||
|
// DEFAULT_SECURITY_IGNORE_FILETYPES export. 40 patterns.
|
||||||
|
const CONTINUE_FILETYPES: ReadonlyArray<string> = [
|
||||||
|
// Environment and configuration files with secrets
|
||||||
|
'*.env',
|
||||||
|
'*.env.*',
|
||||||
|
'.env*',
|
||||||
|
'config.json',
|
||||||
|
'config.yaml',
|
||||||
|
'config.yml',
|
||||||
|
'settings.json',
|
||||||
|
'appsettings.json',
|
||||||
|
'appsettings.*.json',
|
||||||
|
|
||||||
|
// Certificate and key files
|
||||||
|
'*.key',
|
||||||
|
'*.pem',
|
||||||
|
'*.p12',
|
||||||
|
'*.pfx',
|
||||||
|
'*.crt',
|
||||||
|
'*.cer',
|
||||||
|
'*.jks',
|
||||||
|
'*.keystore',
|
||||||
|
'*.truststore',
|
||||||
|
|
||||||
|
// Database files that may contain sensitive data
|
||||||
|
'*.db',
|
||||||
|
'*.sqlite',
|
||||||
|
'*.sqlite3',
|
||||||
|
'*.mdb',
|
||||||
|
'*.accdb',
|
||||||
|
|
||||||
|
// Credential and secret files
|
||||||
|
'*.secret',
|
||||||
|
'*.secrets',
|
||||||
|
'auth.json',
|
||||||
|
'*.token',
|
||||||
|
|
||||||
|
// Backup files that might contain sensitive data
|
||||||
|
'*.bak',
|
||||||
|
'*.backup',
|
||||||
|
'*.old',
|
||||||
|
'*.orig',
|
||||||
|
|
||||||
|
// Docker secrets
|
||||||
|
'docker-compose.override.yml',
|
||||||
|
'docker-compose.override.yaml',
|
||||||
|
|
||||||
|
// SSH and GPG
|
||||||
|
'id_rsa',
|
||||||
|
'id_dsa',
|
||||||
|
'id_ecdsa',
|
||||||
|
'id_ed25519',
|
||||||
|
'*.ppk',
|
||||||
|
'*.gpg',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verbatim from continuedev/continue/core/indexing/ignore.ts
|
||||||
|
// DEFAULT_SECURITY_IGNORE_DIRS export. Trailing "/" semantics: match
|
||||||
|
// against any path segment that equals the dir name (so files INSIDE the
|
||||||
|
// dir get blocked even if their leaf name is innocuous, e.g.
|
||||||
|
// `home/user/.aws/credentials` blocks via the `.aws` segment).
|
||||||
|
const CONTINUE_DIRS: ReadonlyArray<string> = [
|
||||||
|
// Environment and configuration directories
|
||||||
|
'.env/',
|
||||||
|
'env/',
|
||||||
|
|
||||||
|
// Cloud provider credential directories
|
||||||
|
'.aws/',
|
||||||
|
'.gcp/',
|
||||||
|
'.azure/',
|
||||||
|
'.kube/',
|
||||||
|
'.docker/',
|
||||||
|
|
||||||
|
// Secret directories
|
||||||
|
'secrets/',
|
||||||
|
'.secrets/',
|
||||||
|
'private/',
|
||||||
|
'.private/',
|
||||||
|
'certs/',
|
||||||
|
'certificates/',
|
||||||
|
'keys/',
|
||||||
|
'.ssh/',
|
||||||
|
'.gnupg/',
|
||||||
|
'.gpg/',
|
||||||
|
|
||||||
|
// Temporary directories that might contain sensitive data
|
||||||
|
'tmp/secrets/',
|
||||||
|
'temp/secrets/',
|
||||||
|
'.tmp/',
|
||||||
|
];
|
||||||
|
|
||||||
|
// BooCode additions. continue.dev's list omits some classics — closing the
|
||||||
|
// gaps below. Each entry has a one-line justification so future audits know
|
||||||
|
// why it's here and not in the upstream port.
|
||||||
|
const BOOCODE_ADDITIONS: ReadonlyArray<string> = [
|
||||||
|
// SSH public keys leak hostnames + usernames. continue.dev's `id_rsa`
|
||||||
|
// is a literal that doesn't match `id_rsa.pub`; broadening to a glob.
|
||||||
|
'id_rsa*',
|
||||||
|
'id_dsa*',
|
||||||
|
'id_ecdsa*',
|
||||||
|
'id_ed25519*',
|
||||||
|
// Wide-net credential pattern. `*credentials*` (not `credentials*`)
|
||||||
|
// because the leak shape varies: credentials.json, aws_credentials,
|
||||||
|
// gcp-credentials.yml, etc. Trade-off: also catches files named
|
||||||
|
// "Credentials.tsx" → those go through view_file's hard-refuse path,
|
||||||
|
// which is the right outcome (the LLM gets a clear "blocked" signal
|
||||||
|
// and can ask the user to whitelist if it was a false-positive).
|
||||||
|
'*credentials*',
|
||||||
|
// .netrc holds plaintext FTP/HTTP credentials. Standard tooling target.
|
||||||
|
'.netrc',
|
||||||
|
// KeePass database. Encrypted at rest but contents are 1:1 secret
|
||||||
|
// material; never want to feed even ciphertext to a model.
|
||||||
|
'*.kdbx',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string> = [
|
||||||
|
...CONTINUE_FILETYPES,
|
||||||
|
...CONTINUE_DIRS,
|
||||||
|
...BOOCODE_ADDITIONS,
|
||||||
|
];
|
||||||
|
|
||||||
|
// === glob compilation ======================================================
|
||||||
|
// Tiny glob-to-regex. No new prod dep — the patterns we ship are simple
|
||||||
|
// (literal | name* | *.ext | dir/). Covers ~95% of glob spec, which is
|
||||||
|
// 100% of what this list uses. If patterns ever grow to need `**`, `[]`,
|
||||||
|
// `{a,b}`, or negation, swap in picomatch.
|
||||||
|
|
||||||
|
interface CompiledPattern {
|
||||||
|
regex: RegExp;
|
||||||
|
// 'basename' = test against the trailing path component only.
|
||||||
|
// 'segment' = test against ANY path component (used for `dir/` patterns
|
||||||
|
// so `home/user/.aws/credentials` blocks via the `.aws` seg).
|
||||||
|
mode: 'basename' | 'segment';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compile(pattern: string): CompiledPattern {
|
||||||
|
const isDir = pattern.endsWith('/');
|
||||||
|
const body = isDir ? pattern.slice(0, -1) : pattern;
|
||||||
|
// Escape regex specials except * and ?. Don't escape `/` — the patterns
|
||||||
|
// we accept don't contain it, but if a future pattern does, splitting on
|
||||||
|
// `/` in the matcher already handles it.
|
||||||
|
const escaped = body.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regexBody = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
return {
|
||||||
|
regex: new RegExp(`^${regexBody}$`, 'i'),
|
||||||
|
mode: isDir ? 'segment' : 'basename',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYPES.map(compile);
|
||||||
|
|
||||||
|
// === public API ============================================================
|
||||||
|
|
||||||
|
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
||||||
|
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
||||||
|
// paths match the same patterns. Empty or root-only paths return false.
|
||||||
|
export function isSecretPath(relPath: string): boolean {
|
||||||
|
if (!relPath) return false;
|
||||||
|
const normalized = relPath.replace(/\\/g, '/');
|
||||||
|
const segments = normalized.split('/').filter((s) => s.length > 0);
|
||||||
|
if (segments.length === 0) return false;
|
||||||
|
const base = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
for (const compiled of COMPILED) {
|
||||||
|
if (compiled.mode === 'basename') {
|
||||||
|
if (compiled.regex.test(base)) return true;
|
||||||
|
} else {
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (compiled.regex.test(seg)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error thrown by view_file (or any single-path read) when the resolved
|
||||||
|
// path matches a secret pattern. Caught by inference.ts executeToolCall
|
||||||
|
// alongside PathScopeError; the message reaches the LLM verbatim so it
|
||||||
|
// knows the file was deliberately blocked rather than missing/broken.
|
||||||
|
export class SecretBlockedError extends Error {
|
||||||
|
readonly path: string;
|
||||||
|
constructor(relPath: string) {
|
||||||
|
super(
|
||||||
|
`Refused: ${relPath} matches a secret-file pattern and was blocked by pathGuard.`,
|
||||||
|
);
|
||||||
|
this.name = 'SecretBlockedError';
|
||||||
|
this.path = relPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for listing tools (list_dir / grep / find_files). Filters entries
|
||||||
|
// by their `.path` (or computed path), returns the filtered list plus a
|
||||||
|
// note string when anything was hidden. Callers attach the note to a
|
||||||
|
// `pathguard_note` field on their output shape so the LLM sees it.
|
||||||
|
//
|
||||||
|
// Generic over the entry type so each tool can pass its own row shape and
|
||||||
|
// a `pathOf` extractor. The caller-supplied path is what gets tested —
|
||||||
|
// usually the project-relative path the tool already computes for output.
|
||||||
|
export function filterSecretEntries<T>(
|
||||||
|
entries: ReadonlyArray<T>,
|
||||||
|
pathOf: (entry: T) => string,
|
||||||
|
): { kept: T[]; hidden: number; note: string | undefined } {
|
||||||
|
const kept: T[] = [];
|
||||||
|
let hidden = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (isSecretPath(pathOf(e))) {
|
||||||
|
hidden += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(e);
|
||||||
|
}
|
||||||
|
const note =
|
||||||
|
hidden > 0
|
||||||
|
? `[pathGuard: ${hidden} ${hidden === 1 ? 'entry' : 'entries'} hidden by secret-file filter]`
|
||||||
|
: undefined;
|
||||||
|
return { kept, hidden, note };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|||||||
import { resolve, basename, relative } from 'node:path';
|
import { resolve, basename, relative } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
|
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
||||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||||
import { getGitMeta } from './git_meta.js';
|
import { getGitMeta } from './git_meta.js';
|
||||||
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
||||||
@@ -63,6 +64,15 @@ export const viewFile: ToolDef<ViewFileInputT> = {
|
|||||||
},
|
},
|
||||||
async execute(input, projectRoot) {
|
async execute(input, projectRoot) {
|
||||||
const real = await pathGuard(projectRoot, input.path);
|
const real = await pathGuard(projectRoot, input.path);
|
||||||
|
// v1.11.7: secret-file deny check. Test the project-relative path
|
||||||
|
// (matches the form continue.dev's patterns expect: basenames + dir
|
||||||
|
// segments). Throw a typed error so executeToolCall in inference.ts
|
||||||
|
// surfaces a clear "blocked" message to the LLM instead of silently
|
||||||
|
// returning content the user wanted hidden.
|
||||||
|
const relPath = relative(projectRoot, real) || basename(real);
|
||||||
|
if (isSecretPath(relPath)) {
|
||||||
|
throw new SecretBlockedError(relPath);
|
||||||
|
}
|
||||||
const s = await stat(real);
|
const s = await stat(real);
|
||||||
if (!s.isFile()) {
|
if (!s.isFile()) {
|
||||||
throw new PathScopeError(`not a file: ${input.path}`);
|
throw new PathScopeError(`not a file: ${input.path}`);
|
||||||
@@ -152,11 +162,21 @@ export const listDir: ToolDef<ListDirInputT> = {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
// v1.11.7: filter entries whose project-relative path matches a secret
|
||||||
|
// pattern. Each entry is tested using the project-rel dir + its name
|
||||||
|
// so the pattern's path/segment semantics work for nested dirs like
|
||||||
|
// `.aws/`. The count is surfaced via `pathguard_note` — we never list
|
||||||
|
// the hidden paths (defeats the purpose).
|
||||||
|
const relDir = relative(projectRoot, real) || '.';
|
||||||
|
const secretFilter = filterSecretEntries(out, (e) =>
|
||||||
|
relDir === '.' ? e.name : `${relDir}/${e.name}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
path: relative(projectRoot, real) || '.',
|
path: relDir,
|
||||||
entries: out,
|
entries: secretFilter.kept,
|
||||||
total,
|
total: secretFilter.kept.length,
|
||||||
truncated: total > MAX_DIR_ENTRIES,
|
truncated: total > MAX_DIR_ENTRIES,
|
||||||
|
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -208,14 +228,21 @@ export const grep: ToolDef<GrepInputT> = {
|
|||||||
case_sensitive: input.case_sensitive,
|
case_sensitive: input.case_sensitive,
|
||||||
hidden: input.hidden,
|
hidden: input.hidden,
|
||||||
});
|
});
|
||||||
return {
|
const reshaped = result.matches.map((m) => ({
|
||||||
matches: result.matches.map((m) => ({
|
|
||||||
path: m.path,
|
path: m.path,
|
||||||
line: m.line,
|
line: m.line,
|
||||||
content: m.text,
|
content: m.text,
|
||||||
})),
|
}));
|
||||||
total: result.matches.length,
|
// v1.11.7: drop matches whose source file is a known-secret pattern.
|
||||||
|
// file_ops.grep returns project-relative paths, so we feed them straight
|
||||||
|
// into isSecretPath. Multiple matches in the same secret file each get
|
||||||
|
// dropped individually — they all count in the hidden tally.
|
||||||
|
const secretFilter = filterSecretEntries(reshaped, (m) => m.path);
|
||||||
|
return {
|
||||||
|
matches: secretFilter.kept,
|
||||||
|
total: secretFilter.kept.length,
|
||||||
truncated: result.truncated,
|
truncated: result.truncated,
|
||||||
|
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -260,10 +287,15 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
|||||||
path: input.path,
|
path: input.path,
|
||||||
max_results: limit,
|
max_results: limit,
|
||||||
});
|
});
|
||||||
|
// v1.11.7: drop paths matching secret patterns. The original `total`
|
||||||
|
// from file_ops includes pre-truncation count; we report the visible
|
||||||
|
// count post-filter so the LLM can't infer hidden-count by subtraction.
|
||||||
|
const secretFilter = filterSecretEntries(result.files, (p) => p);
|
||||||
return {
|
return {
|
||||||
paths: result.files,
|
paths: secretFilter.kept,
|
||||||
total: result.total,
|
total: secretFilter.kept.length,
|
||||||
truncated: result.truncated,
|
truncated: result.truncated,
|
||||||
|
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user