src / scope.ts
src / scope.ts
// Which folders the model may read and edit, and the checks every path goes through: it must
// resolve (after following links) to a place inside a granted folder, secret files are never
// read, and the insides of .git and node_modules are never written.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export const IS_WIN = process.platform === "win32";
const fold = (p: string) => (IS_WIN ? p.toLowerCase() : p);
// The real location of a path: links and junctions followed, and for a path that does not exist
// yet, the real location of its nearest existing parent with the rest appended.
export function realPath(p: string): string {
let current = path.resolve(p);
const rest: string[] = [];
for (;;) {
try {
const real = fs.realpathSync.native(current);
return rest.length ? path.join(real, ...rest.reverse()) : real;
} catch {
const parent = path.dirname(current);
if (parent === current) return path.resolve(p);
rest.push(path.basename(current));
current = parent;
}
}
}
export function samePath(a: string, b: string): boolean {
return fold(path.resolve(a)) === fold(path.resolve(b));
}
export function isInside(child: string, parent: string): boolean {
const rel = path.relative(fold(parent), fold(child));
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
// Files that hold keys, passwords or tokens. Never read or written, even in a granted folder.
const SECRET_FILE =
/^(\.env(\..+)?|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|.+\.(pem|key|p12|pfx|kdbx|keychain|gpg)|credentials(\.json)?|\.netrc|\.npmrc|\.pypirc|\.git-credentials|secrets?\.(json|ya?ml|toml))$/i;
export function isSecretFile(p: string): boolean {
return SECRET_FILE.test(path.basename(p));
}
// Folders whose insides are managed by tools; editing them by hand breaks things.
const NO_WRITE_DIRS = new Set([".git", "node_modules"]);
export function isWriteProtected(p: string): boolean {
return p.split(/[\\/]/).some((part) => NO_WRITE_DIRS.has(part.toLowerCase()));
}
// A folder too broad to grant as a whole: a drive or file system root, or the home folder.
export function tooBroad(folder: string): boolean {
const real = fold(realPath(folder));
if (path.dirname(real) === real) return true;
return real === fold(realPath(os.homedir()));
}
export interface Grant {
folder: string;
from: "settings" | "chat";
}
export class Scope {
readonly grants: Grant[];
readonly excludes: string[];
constructor(grants: Grant[], excludes: string[] = []) {
this.excludes = excludes.map(realPath);
const seen = new Set<string>();
this.grants = [];
for (const g of grants) {
const real = realPath(g.folder);
if (seen.has(fold(real))) continue;
seen.add(fold(real));
this.grants.push({ folder: real, from: g.from });
}
}
grantFor(real: string): Grant | null {
let best: Grant | null = null;
for (const g of this.grants) {
if (isInside(real, g.folder) && (!best || g.folder.length > best.folder.length)) best = g;
}
return best;
}
// Turns a path from the model into a real path inside a granted folder, or explains why not.
// A relative path is taken relative to the only granted folder, if there is exactly one.
resolve(input: string, purpose: "read" | "write"): { path: string; grant: Grant } | { error: string } {
const raw = (input ?? "").trim().replace(/^["']|["']$/g, "");
if (!raw) return { error: "No path was given." };
let absolute: string;
if (path.isAbsolute(raw)) absolute = raw;
else if (this.grants.length === 1) absolute = path.join(this.grants[0].folder, raw);
else if (!this.grants.length) return { error: "No folders are granted yet." };
else return { error: `"${raw}" is a relative path and more than one folder is granted. Give the full path.` };
const real = realPath(absolute);
const grant = this.grantFor(real);
if (!grant) return { error: `${real} is outside the granted folders.` };
if (this.excludes.some((e) => isInside(real, e))) return { error: `${real} is inside a folder the user listed under "Never look in".` };
if (isSecretFile(real)) return { error: `${path.basename(real)} looks like a file with keys or passwords, which simpleFiles never ${purpose === "read" ? "reads" : "changes"}.` };
if (purpose === "write" && isWriteProtected(path.relative(grant.folder, real))) {
return { error: "Files inside .git and node_modules are managed by tools and are never edited by hand." };
}
return { path: real, grant };
}
}
// The folder a command names, when words may follow it ("/files trust C:\\proj run it"): the
// longest leading part of the text that is an existing folder, and the words left over. Null
// when no leading part is a folder.
export function folderInText(text: string): { folder: string; extra: string } | null {
const words = text.trim().replace(/^["']|["']$/g, "").split(/(\s+)/);
for (let end = words.length; end > 0; end--) {
const candidate = words.slice(0, end).join("").trim().replace(/^["']|["']$/g, "");
if (!candidate) continue;
try {
if (fs.statSync(candidate).isDirectory()) return { folder: realPath(candidate), extra: words.slice(end).join("").trim() };
} catch {
// not a folder; try a shorter part
}
}
return null;
}
// Which folders the model may read and edit, and the checks every path goes through: it must
// resolve (after following links) to a place inside a granted folder, secret files are never
// read, and the insides of .git and node_modules are never written.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export const IS_WIN = process.platform === "win32";
const fold = (p: string) => (IS_WIN ? p.toLowerCase() : p);
// The real location of a path: links and junctions followed, and for a path that does not exist
// yet, the real location of its nearest existing parent with the rest appended.
export function realPath(p: string): string {
let current = path.resolve(p);
const rest: string[] = [];
for (;;) {
try {
const real = fs.realpathSync.native(current);
return rest.length ? path.join(real, ...rest.reverse()) : real;
} catch {
const parent = path.dirname(current);
if (parent === current) return path.resolve(p);
rest.push(path.basename(current));
current = parent;
}
}
}
export function samePath(a: string, b: string): boolean {
return fold(path.resolve(a)) === fold(path.resolve(b));
}
export function isInside(child: string, parent: string): boolean {
const rel = path.relative(fold(parent), fold(child));
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
// Files that hold keys, passwords or tokens. Never read or written, even in a granted folder.
const SECRET_FILE =
/^(\.env(\..+)?|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|.+\.(pem|key|p12|pfx|kdbx|keychain|gpg)|credentials(\.json)?|\.netrc|\.npmrc|\.pypirc|\.git-credentials|secrets?\.(json|ya?ml|toml))$/i;
export function isSecretFile(p: string): boolean {
return SECRET_FILE.test(path.basename(p));
}
// Folders whose insides are managed by tools; editing them by hand breaks things.
const NO_WRITE_DIRS = new Set([".git", "node_modules"]);
export function isWriteProtected(p: string): boolean {
return p.split(/[\\/]/).some((part) => NO_WRITE_DIRS.has(part.toLowerCase()));
}
// A folder too broad to grant as a whole: a drive or file system root, or the home folder.
export function tooBroad(folder: string): boolean {
const real = fold(realPath(folder));
if (path.dirname(real) === real) return true;
return real === fold(realPath(os.homedir()));
}
export interface Grant {
folder: string;
from: "settings" | "chat";
}
export class Scope {
readonly grants: Grant[];
readonly excludes: string[];
constructor(grants: Grant[], excludes: string[] = []) {
this.excludes = excludes.map(realPath);
const seen = new Set<string>();
this.grants = [];
for (const g of grants) {
const real = realPath(g.folder);
if (seen.has(fold(real))) continue;
seen.add(fold(real));
this.grants.push({ folder: real, from: g.from });
}
}
grantFor(real: string): Grant | null {
let best: Grant | null = null;
for (const g of this.grants) {
if (isInside(real, g.folder) && (!best || g.folder.length > best.folder.length)) best = g;
}
return best;
}
// Turns a path from the model into a real path inside a granted folder, or explains why not.
// A relative path is taken relative to the only granted folder, if there is exactly one.
resolve(input: string, purpose: "read" | "write"): { path: string; grant: Grant } | { error: string } {
const raw = (input ?? "").trim().replace(/^["']|["']$/g, "");
if (!raw) return { error: "No path was given." };
let absolute: string;
if (path.isAbsolute(raw)) absolute = raw;
else if (this.grants.length === 1) absolute = path.join(this.grants[0].folder, raw);
else if (!this.grants.length) return { error: "No folders are granted yet." };
else return { error: `"${raw}" is a relative path and more than one folder is granted. Give the full path.` };
const real = realPath(absolute);
const grant = this.grantFor(real);
if (!grant) return { error: `${real} is outside the granted folders.` };
if (this.excludes.some((e) => isInside(real, e))) return { error: `${real} is inside a folder the user listed under "Never look in".` };
if (isSecretFile(real)) return { error: `${path.basename(real)} looks like a file with keys or passwords, which simpleFiles never ${purpose === "read" ? "reads" : "changes"}.` };
if (purpose === "write" && isWriteProtected(path.relative(grant.folder, real))) {
return { error: "Files inside .git and node_modules are managed by tools and are never edited by hand." };
}
return { path: real, grant };
}
}
// The folder a command names, when words may follow it ("/files trust C:\\proj run it"): the
// longest leading part of the text that is an existing folder, and the words left over. Null
// when no leading part is a folder.
export function folderInText(text: string): { folder: string; extra: string } | null {
const words = text.trim().replace(/^["']|["']$/g, "").split(/(\s+)/);
for (let end = words.length; end > 0; end--) {
const candidate = words.slice(0, end).join("").trim().replace(/^["']|["']$/g, "");
if (!candidate) continue;
try {
if (fs.statSync(candidate).isDirectory()) return { folder: realPath(candidate), extra: words.slice(end).join("").trim() };
} catch {
// not a folder; try a shorter part
}
}
return null;
}