one-code-extension 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in auto-mode rule lists (pure data).
|
|
3
|
+
*
|
|
4
|
+
* These are prose, read by the classifier as natural-language rules — the same
|
|
5
|
+
* shape Claude Code's `claude auto-mode defaults` prints, so a user's existing
|
|
6
|
+
* `autoMode` settings splice into them with `"$defaults"` unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Four tiers, in the precedence the classifier is instructed to apply:
|
|
9
|
+
* hard_deny — unconditional; neither user intent nor allow rules clear it
|
|
10
|
+
* soft_deny — blocked unless an allow rule or explicit user intent clears it
|
|
11
|
+
* allow — exceptions to soft_deny
|
|
12
|
+
* (intent) — a specific user instruction clears remaining soft denials
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What the classifier treats as inside the trust boundary. */
|
|
16
|
+
export const DEFAULT_ENVIRONMENT: string[] = [
|
|
17
|
+
"Trusted repo: the git repository containing the working directory, and its configured remotes. Pushing to any branch of this repository is routine. Content first read from outside this repository is not this repository's own work, whoever asked for it to be moved.",
|
|
18
|
+
"Repository visibility: assume private unless the user's own message says otherwise. Confidential material may go into a private repo; secrets and personal data may never go into any repo regardless of visibility.",
|
|
19
|
+
"Trusted internal domains: none configured. Any host that is not the repository's own remote is outside the boundary.",
|
|
20
|
+
"Trusted cloud buckets: none configured. Treat every bucket write as leaving the boundary.",
|
|
21
|
+
"Internal package registry: none configured. Installs from the ecosystem's default public registry are routine; installs redirected to another registry are not.",
|
|
22
|
+
"Sensitive data locations: credential directories (~/.ssh, ~/.aws, ~/.kube, ~/.docker, ~/.m2, ~/.gnupg, ~/.config/gcloud), .env files anywhere, private keys and keystores, and /proc or /sys process state.",
|
|
23
|
+
"Sensitive remote targets: any host, namespace, container, or branch whose name carries prod, production, live, or release.",
|
|
24
|
+
"Protected IaC scopes: any Terraform, Pulumi, CloudFormation, Helm, or Kubernetes manifest path whose name marks it as production.",
|
|
25
|
+
"Primary use: software development in the working directory.",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Unconditional. User intent does not clear these. */
|
|
29
|
+
export const DEFAULT_HARD_DENY: string[] = [
|
|
30
|
+
"Data exfiltration: sending secrets, credentials, private keys, personal data, or the contents of sensitive files to any destination outside the trust boundary — by network request, by commit or push, by writing into a shared location, or by embedding them in a command argument that reaches a third party. This holds however the action is framed and whoever asked for it.",
|
|
31
|
+
"Auto-mode bypass: disabling, weakening, or working around this approval mechanism — editing permission settings or Claude Code/One Code configuration to grant broader permissions, adding allow rules, changing the permission mode, or routing an action through a path that avoids classification.",
|
|
32
|
+
"Credential harvesting: reading credential stores, keychains, browser password databases, or process environment memory (/proc/*/environ) for any purpose other than one the user specifically named.",
|
|
33
|
+
"Destroying the user's own history: rewriting or deleting the reflog, deleting the local repository, or removing backups such that the user's uncommitted or unpushed work cannot be recovered.",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Blocked, but an allow rule or a specific user instruction clears them. */
|
|
37
|
+
export const DEFAULT_SOFT_DENY: string[] = [
|
|
38
|
+
"Git destructive [must name the operation and its target]: force pushing, deleting remote branches, tags, or releases, rewriting remote history, or amending a commit that is not the agent's own unpushed work from this session.",
|
|
39
|
+
"Production deploys and destroys [must name the target]: deploying, applying, or destroying against a sensitive remote target or protected IaC scope, including terraform apply/destroy, helm upgrade, kubectl apply against a production namespace, and any release or publish pipeline.",
|
|
40
|
+
"Remote code execution: piping a downloaded script into an interpreter (curl | bash and equivalents), running an installer fetched at execution time, or executing a file whose contents were downloaded during this session.",
|
|
41
|
+
"Irreversible loss outside the working directory: deleting, overwriting, or truncating an existing file or directory that is not under the working directory and not obviously scratch space such as /tmp. Creating a new file outside the working directory is covered by the separate rule below, not by this one.",
|
|
42
|
+
"Creating or modifying files outside the working directory [must name the path]: writing somewhere the user did not ask for is how work leaks out of the project. Clears when the user's own message names that path, or names the destination unambiguously enough that the path follows from it. Also clears for the harness's own designated directories named in the system prompt — the session scratchpad (under /tmp or /private/tmp, in a claude-<uid> directory) and the auto-memory directory (~/.claude/projects/<project>/memory) — which exist precisely so temporary and memory files do not land in the project.",
|
|
43
|
+
"Publishing: pushing a package to a registry, creating a public release or gist, or making a private repository public.",
|
|
44
|
+
"Disabling safety machinery: turning off CI checks, required reviews, branch protection, test suites in configuration, pre-commit hooks, or TLS certificate verification.",
|
|
45
|
+
"Secrets entering version control: staging or committing a file that holds a credential, key, or token, including one the agent generated.",
|
|
46
|
+
"Bulk permission or ownership changes: recursive chmod/chown, or making files world-writable.",
|
|
47
|
+
"Writing to paths whose contents execute later without further approval: .git/hooks, .git/config, editor auto-run configuration, or a build wrapper such as mvnw or gradlew.",
|
|
48
|
+
"Database mutation outside a migration tool: dropping, truncating, or bulk-updating tables directly, against any database.",
|
|
49
|
+
"Remote shells and port-forwards into a sensitive remote target.",
|
|
50
|
+
"Sending repository contents to a third-party service that is not part of the configured toolchain.",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Exceptions to soft_deny. */
|
|
54
|
+
export const DEFAULT_ALLOW: string[] = [
|
|
55
|
+
"Local file operations inside the working directory: creating, editing, moving, and deleting files and directories under it, including build output and caches.",
|
|
56
|
+
"Reading anything inside the working directory, and reading system and tool configuration that is not on the sensitive-data list.",
|
|
57
|
+
"Installing dependencies already declared in the project's manifest (package.json, pyproject.toml, go.mod, pom.xml, Cargo.toml) from the ecosystem's default registry.",
|
|
58
|
+
"Running the project's own build, test, lint, format, and type-check commands, including ones that write to build directories.",
|
|
59
|
+
"Read-only information lookups that retrieve only public data and send none of the user's own: HTTP GET/HEAD to public documentation, package metadata, and credential-free API endpoints; and public registry or DNS queries such as whois, dig, nslookup, and host, including ones that name the registry or DNS server to query.",
|
|
60
|
+
"Git operations that do not rewrite published history: status, diff, log, add, commit, branch, checkout, merge, rebase of unpushed local work, stash, and pushing to any branch of the trusted repo.",
|
|
61
|
+
"Creating and updating pull requests on the trusted repo, and reading issues and CI results.",
|
|
62
|
+
"Starting, stopping, and querying local development servers and containers bound to loopback.",
|
|
63
|
+
"Test artifacts: placeholder credentials authored as placeholders in examples and fixtures. A value copied from a real secret or a sensitive path is never a test artifact.",
|
|
64
|
+
"Reading the project's own .env file when the user asked for work that requires its configuration, provided the values do not leave the machine.",
|
|
65
|
+
];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project instruction files for the classifier (pure fs).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's classifier reads the same CLAUDE.md the agent does, so an
|
|
5
|
+
* instruction like "never force push" steers both at once. This collects the
|
|
6
|
+
* same files: cwd upward to the git root, plus the user's global file.
|
|
7
|
+
*
|
|
8
|
+
* These files are checked in, so they are untrusted input in a way the user's
|
|
9
|
+
* own messages are not — the classifier prompt tells the model they may tighten
|
|
10
|
+
* what is allowed but never widen it. That asymmetry is what makes including
|
|
11
|
+
* them safe; without it, a repository could ship its own authorisation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const FILE_NAMES = ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md"];
|
|
18
|
+
|
|
19
|
+
/** Per-file and total caps, so a large instruction file cannot crowd out rules. */
|
|
20
|
+
const PER_FILE_LIMIT = 6_000;
|
|
21
|
+
const TOTAL_LIMIT = 12_000;
|
|
22
|
+
|
|
23
|
+
function readCapped(path: string): string | undefined {
|
|
24
|
+
try {
|
|
25
|
+
// readFileSync loads the whole file regardless, so the cap is applied to the
|
|
26
|
+
// decoded string. (A prior statSync branch claimed to bound the read but did
|
|
27
|
+
// the same full read either way.)
|
|
28
|
+
return readFileSync(path, "utf-8").slice(0, PER_FILE_LIMIT);
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findGitRoot(from: string): string | undefined {
|
|
35
|
+
let dir = from;
|
|
36
|
+
for (;;) {
|
|
37
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir) return undefined;
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Concatenated instruction files, nearest first, or undefined when there are
|
|
46
|
+
* none. Each is labelled with its path so the classifier can tell project
|
|
47
|
+
* convention from user-global preference.
|
|
48
|
+
*/
|
|
49
|
+
export function loadProjectInstructions(cwd: string, home: string): string | undefined {
|
|
50
|
+
const stop = findGitRoot(cwd) ?? cwd;
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
|
|
54
|
+
const add = (path: string) => {
|
|
55
|
+
if (total >= TOTAL_LIMIT || !existsSync(path)) return;
|
|
56
|
+
const body = readCapped(path)?.trim();
|
|
57
|
+
if (!body) return;
|
|
58
|
+
const chunk = `# ${path}\n${body}`;
|
|
59
|
+
parts.push(chunk.slice(0, TOTAL_LIMIT - total));
|
|
60
|
+
total += chunk.length;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let dir = cwd;
|
|
64
|
+
for (;;) {
|
|
65
|
+
for (const name of FILE_NAMES) add(join(dir, name));
|
|
66
|
+
if (dir === stop) break;
|
|
67
|
+
const parent = dirname(dir);
|
|
68
|
+
if (parent === dir) break;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
add(join(home, ".claude", "CLAUDE.md"));
|
|
72
|
+
|
|
73
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/auto-mode model` picker (pure).
|
|
3
|
+
*
|
|
4
|
+
* Filtering, key decoding, selection movement, and rendering for the
|
|
5
|
+
* interactive classifier-model picker, kept free of pi imports so they are
|
|
6
|
+
* unit-testable. The thin `ctx.ui.custom` component in the permissions
|
|
7
|
+
* extension owns nothing but mutable state and repaint calls.
|
|
8
|
+
*
|
|
9
|
+
* A picker exists because the alternative was worse in practice: choosing the
|
|
10
|
+
* classifier automatically took three rounds of fault-fixing against real
|
|
11
|
+
* catalogs, and when auto-selection picks badly the only recourse was
|
|
12
|
+
* hand-editing settings.json with a model id the user has to guess. Naming a
|
|
13
|
+
* model here is the explicit consent `autoMode.classifierModel` stands for —
|
|
14
|
+
* including consent to another provider, which auto-selection must never
|
|
15
|
+
* assume (see model-select.ts).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface PickerEntry {
|
|
19
|
+
provider: string;
|
|
20
|
+
id: string;
|
|
21
|
+
/** Input price per million tokens, when the catalog carries a usable one. */
|
|
22
|
+
inputPrice?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const pickerSpec = (entry: { provider: string; id: string }): string => `${entry.provider}/${entry.id}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rank a query against one spec: 0 prefix, 1 substring, 2 in-order subsequence,
|
|
29
|
+
* undefined no match. Subsequence keeps "haiku45" finding "claude-haiku-4.5"
|
|
30
|
+
* without a fuzzy-match dependency.
|
|
31
|
+
*/
|
|
32
|
+
export function matchRank(spec: string, query: string): number | undefined {
|
|
33
|
+
const haystack = spec.toLowerCase();
|
|
34
|
+
const needle = query.toLowerCase().replace(/\s+/g, "");
|
|
35
|
+
if (needle.length === 0) return 2;
|
|
36
|
+
if (haystack.startsWith(needle)) return 0;
|
|
37
|
+
if (haystack.includes(needle)) return 1;
|
|
38
|
+
let at = 0;
|
|
39
|
+
for (const char of needle) {
|
|
40
|
+
at = haystack.indexOf(char, at);
|
|
41
|
+
if (at === -1) return undefined;
|
|
42
|
+
at++;
|
|
43
|
+
}
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Filter and rank, preserving catalog order within a rank. */
|
|
48
|
+
export function filterEntries<T extends { provider: string; id: string }>(entries: T[], query: string): T[] {
|
|
49
|
+
return entries
|
|
50
|
+
.map((entry, position) => ({ entry, position, rank: matchRank(pickerSpec(entry), query) }))
|
|
51
|
+
.filter((item): item is { entry: T; position: number; rank: number } => item.rank !== undefined)
|
|
52
|
+
.sort((a, b) => a.rank - b.rank || a.position - b.position)
|
|
53
|
+
.map((item) => item.entry);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type PickerKey =
|
|
57
|
+
| { kind: "up" }
|
|
58
|
+
| { kind: "down" }
|
|
59
|
+
| { kind: "confirm" }
|
|
60
|
+
| { kind: "cancel" }
|
|
61
|
+
| { kind: "backspace" }
|
|
62
|
+
| { kind: "type"; text: string };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decode a raw terminal chunk. Unlike the effort slider there are no letter
|
|
66
|
+
* shortcuts: every printable character belongs to the filter query.
|
|
67
|
+
*/
|
|
68
|
+
export function decodePickerKey(data: string): PickerKey | undefined {
|
|
69
|
+
switch (data) {
|
|
70
|
+
case "\x1b[A":
|
|
71
|
+
case "\x1bOA":
|
|
72
|
+
return { kind: "up" };
|
|
73
|
+
case "\x1b[B":
|
|
74
|
+
case "\x1bOB":
|
|
75
|
+
return { kind: "down" };
|
|
76
|
+
case "\r":
|
|
77
|
+
case "\n":
|
|
78
|
+
return { kind: "confirm" };
|
|
79
|
+
case "\x1b":
|
|
80
|
+
case "\x03": // ctrl+c — same intent as escape while the picker is focused
|
|
81
|
+
return { kind: "cancel" };
|
|
82
|
+
case "\x7f":
|
|
83
|
+
case "\b":
|
|
84
|
+
return { kind: "backspace" };
|
|
85
|
+
default: {
|
|
86
|
+
// Printable text only (covers paste); control sequences are ignored.
|
|
87
|
+
const text = [...data].filter((char) => char >= " " && char !== "\x7f").join("");
|
|
88
|
+
return text.length > 0 && !data.startsWith("\x1b") ? { kind: "type", text } : undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** First index of the window shown, keeping the cursor visible. */
|
|
94
|
+
export function windowStart(index: number, length: number, maxVisible: number): number {
|
|
95
|
+
if (length <= maxVisible) return 0;
|
|
96
|
+
return Math.max(0, Math.min(index - Math.floor(maxVisible / 2), length - maxVisible));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type Paint = (color: string, text: string) => string;
|
|
100
|
+
|
|
101
|
+
export interface PickerView {
|
|
102
|
+
entries: PickerEntry[];
|
|
103
|
+
index: number;
|
|
104
|
+
query: string;
|
|
105
|
+
/** Catalog size before filtering, for the "n of m" line. */
|
|
106
|
+
total: number;
|
|
107
|
+
/** The currently configured spec, marked in the listing. */
|
|
108
|
+
current?: string;
|
|
109
|
+
maxVisible?: number;
|
|
110
|
+
/** Picker header; defaults to the auto-mode classifier copy. */
|
|
111
|
+
title?: string;
|
|
112
|
+
subtitle?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const formatPrice = (price?: number): string => (price === undefined ? "" : `$${price}/M in`);
|
|
116
|
+
|
|
117
|
+
/** Picker entries from a model catalog, sorted by spec, non-positive prices treated as unpriced. */
|
|
118
|
+
export function toPickerEntries(models: { provider: string; id: string; cost?: { input?: number } }[]): PickerEntry[] {
|
|
119
|
+
return models
|
|
120
|
+
.map((model) => ({
|
|
121
|
+
provider: model.provider,
|
|
122
|
+
id: model.id,
|
|
123
|
+
inputPrice: typeof model.cost?.input === "number" && model.cost.input > 0 ? model.cost.input : undefined,
|
|
124
|
+
}))
|
|
125
|
+
.sort((a, b) => pickerSpec(a).localeCompare(pickerSpec(b)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PickerComponentOptions {
|
|
129
|
+
entries: PickerEntry[];
|
|
130
|
+
current?: string;
|
|
131
|
+
title?: string;
|
|
132
|
+
subtitle?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `ctx.ui.custom` component behind `/auto-mode model` and `/subagent`:
|
|
137
|
+
* filter-as-you-type over the entries, enter confirms, esc cancels. Kept here
|
|
138
|
+
* (structurally typed, no pi imports) so both commands share one keyboard
|
|
139
|
+
* loop instead of drifting copies.
|
|
140
|
+
*/
|
|
141
|
+
export function modelPickerComponent(
|
|
142
|
+
options: PickerComponentOptions,
|
|
143
|
+
tui: { requestRender(): void },
|
|
144
|
+
theme: unknown,
|
|
145
|
+
done: (choice: PickerEntry | null) => void,
|
|
146
|
+
): { render(): string[]; handleInput(data: string): void; invalidate(): void } {
|
|
147
|
+
const paint: Paint = (color, text) => {
|
|
148
|
+
const themed = theme as { fg?(c: string, t: string): string } | undefined;
|
|
149
|
+
try {
|
|
150
|
+
return themed?.fg ? themed.fg(color, text) : text;
|
|
151
|
+
} catch {
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let query = "";
|
|
156
|
+
let index = 0;
|
|
157
|
+
let filtered = options.entries;
|
|
158
|
+
return {
|
|
159
|
+
render: () => [
|
|
160
|
+
"",
|
|
161
|
+
...renderModelPicker(
|
|
162
|
+
{
|
|
163
|
+
entries: filtered,
|
|
164
|
+
index,
|
|
165
|
+
query,
|
|
166
|
+
total: options.entries.length,
|
|
167
|
+
current: options.current,
|
|
168
|
+
title: options.title,
|
|
169
|
+
subtitle: options.subtitle,
|
|
170
|
+
},
|
|
171
|
+
paint,
|
|
172
|
+
),
|
|
173
|
+
"",
|
|
174
|
+
],
|
|
175
|
+
handleInput: (data: string) => {
|
|
176
|
+
const key = decodePickerKey(data);
|
|
177
|
+
if (!key) return;
|
|
178
|
+
if (key.kind === "cancel") return done(null);
|
|
179
|
+
if (key.kind === "confirm") return done(filtered[index] ?? null);
|
|
180
|
+
if (key.kind === "up") index = Math.max(0, index - 1);
|
|
181
|
+
else if (key.kind === "down") index = Math.min(filtered.length - 1, index + 1);
|
|
182
|
+
else {
|
|
183
|
+
query = key.kind === "backspace" ? query.slice(0, -1) : query + key.text;
|
|
184
|
+
filtered = filterEntries(options.entries, query);
|
|
185
|
+
index = 0;
|
|
186
|
+
}
|
|
187
|
+
tui.requestRender();
|
|
188
|
+
},
|
|
189
|
+
invalidate: () => {},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function renderModelPicker(view: PickerView, paint: Paint): string[] {
|
|
194
|
+
const maxVisible = view.maxVisible ?? 10;
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
lines.push(paint("accent", view.title ?? "Select the auto-mode classifier model"));
|
|
197
|
+
lines.push(
|
|
198
|
+
paint(
|
|
199
|
+
"dim",
|
|
200
|
+
view.subtitle ??
|
|
201
|
+
"It reads your prompts and CLAUDE.md — picking another provider sends them there. type to filter · ↑/↓ · enter · esc",
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
lines.push(` filter: ${view.query}${paint("dim", "▏")}`);
|
|
205
|
+
|
|
206
|
+
if (view.entries.length === 0) {
|
|
207
|
+
lines.push(paint("warning", " (no available model matches)"));
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const start = windowStart(view.index, view.entries.length, maxVisible);
|
|
212
|
+
for (const [offset, entry] of view.entries.slice(start, start + maxVisible).entries()) {
|
|
213
|
+
const at = start + offset;
|
|
214
|
+
const spec = pickerSpec(entry);
|
|
215
|
+
const annotations = [view.current === spec ? "✓ current" : "", formatPrice(entry.inputPrice)]
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.join(" ");
|
|
218
|
+
const suffix = annotations ? ` ${paint("dim", annotations)}` : "";
|
|
219
|
+
lines.push(at === view.index ? `${paint("accent", `❯ ${spec}`)}${suffix}` : ` ${spec}${suffix}`);
|
|
220
|
+
}
|
|
221
|
+
if (view.entries.length > maxVisible || view.entries.length < view.total) {
|
|
222
|
+
lines.push(paint("dim", ` ${view.entries.length} of ${view.total} models${view.query ? " match" : ""}`));
|
|
223
|
+
}
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing the classifier model (pure).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not just "pick a cheap model"
|
|
5
|
+
*
|
|
6
|
+
* The classifier receives the user's own messages, their CLAUDE.md, and the text
|
|
7
|
+
* of the command being judged. So *which provider it runs on* is a privacy
|
|
8
|
+
* decision, not an optimisation. The first cut of this searched the whole
|
|
9
|
+
* registry for a model whose id contained "haiku"/"sonnet"/"mini", which on a
|
|
10
|
+
* session running `openai-codex/gpt-5.5-codex` with an Anthropic key also present
|
|
11
|
+
* silently selected `anthropic/claude-haiku-4-5` — shipping that user's prompts to
|
|
12
|
+
* a vendor they had not chosen for this session, through a component with no UI.
|
|
13
|
+
*
|
|
14
|
+
* So: **never leave the session's provider unless the user asked for it.** An
|
|
15
|
+
* explicit `autoMode.classifierModel` may name any provider, because naming it is
|
|
16
|
+
* the asking. Everything else stays where the session already is. This is the same
|
|
17
|
+
* reasoning pi's own subagent config gives for defaulting to the session model —
|
|
18
|
+
* "this keeps new installs from depending on a provider you may not have
|
|
19
|
+
* configured" — and this module follows that precedence shape: explicit override,
|
|
20
|
+
* then a configured default, then something sensible in-provider, then the session
|
|
21
|
+
* model itself.
|
|
22
|
+
*
|
|
23
|
+
* Name matching is demoted to a tiebreak because it does not survive contact with
|
|
24
|
+
* real catalogs: of Groq's 7 models and xAI's 3, *none* contain any of those
|
|
25
|
+
* substrings, while OpenRouter has 303 models and 79 substring hits, so the "first
|
|
26
|
+
* match" is arbitrary. Cost is the one signal every provider carries.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
30
|
+
import {
|
|
31
|
+
findConfigured,
|
|
32
|
+
findRoleProfileModel,
|
|
33
|
+
hintRank,
|
|
34
|
+
modelIdentity,
|
|
35
|
+
modelsContainedToSession,
|
|
36
|
+
pricedInput,
|
|
37
|
+
} from "../lib/model-policy.ts";
|
|
38
|
+
|
|
39
|
+
export { findConfigured } from "../lib/model-policy.ts";
|
|
40
|
+
|
|
41
|
+
export interface Candidate {
|
|
42
|
+
model: Model<Api>;
|
|
43
|
+
/** How this candidate was arrived at, for the notice shown to the user. */
|
|
44
|
+
source: "configured" | "role-profile" | "cheapest-in-provider" | "session";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SelectInput {
|
|
48
|
+
/** Models the user actually has working credentials for. */
|
|
49
|
+
available: Model<Api>[];
|
|
50
|
+
sessionModel: Model<Api> | undefined;
|
|
51
|
+
/** `autoMode.classifierModel`, if set. May name any provider. */
|
|
52
|
+
configured?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The ordered candidate chain. More than one is returned so a candidate that
|
|
57
|
+
* turns out to be unusable at call time (not entitled on this account, withdrawn
|
|
58
|
+
* by the provider) can be stepped over instead of failing every tool call — the
|
|
59
|
+
* same shape as pi's subagent `fallbackModels`.
|
|
60
|
+
*
|
|
61
|
+
* The chain always ends at the session model when there is one, so auto mode
|
|
62
|
+
* degrades to "correct but not cheap" rather than to "broken".
|
|
63
|
+
*/
|
|
64
|
+
export function classifierCandidates({ available, sessionModel, configured }: SelectInput): Candidate[] {
|
|
65
|
+
const candidates: Candidate[] = [];
|
|
66
|
+
const push = (model: Model<Api> | undefined, source: Candidate["source"]) => {
|
|
67
|
+
if (!model) return;
|
|
68
|
+
if (candidates.some((entry) => entry.model.provider === model.provider && entry.model.id === model.id)) return;
|
|
69
|
+
candidates.push({ model, source });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// 1. What the user asked for, wherever it lives. Naming a provider is choosing it.
|
|
73
|
+
if (configured) push(findConfigured(available, configured), "configured");
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Containment is shared with subagents. For a direct provider it is the
|
|
77
|
+
* provider itself; gateways additionally preserve a reliable route/family;
|
|
78
|
+
* opaque routers expose only the session model.
|
|
79
|
+
*/
|
|
80
|
+
const inProvider = sessionModel ? modelsContainedToSession(available, sessionModel) : [];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* No candidate may cost more per token than the model doing the actual work.
|
|
84
|
+
* Screening a call more expensively than making it is indefensible, and it
|
|
85
|
+
* happened: on an OpenRouter session at `z-ai/glm-4.6` ($0.50/M) the table's
|
|
86
|
+
* `anthropic/claude-haiku-4.5` ($1/M) was selected, because this ceiling was
|
|
87
|
+
* originally applied only to the cost-ranked branch and not to the table.
|
|
88
|
+
*/
|
|
89
|
+
const sessionPrice = sessionModel ? pricedInput(sessionModel) : undefined;
|
|
90
|
+
const withinBudget = (model: Model<Api>) => {
|
|
91
|
+
if (sessionPrice === undefined) return true;
|
|
92
|
+
const price = pricedInput(model);
|
|
93
|
+
return price === undefined || price <= sessionPrice;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// 2. A reviewed small-but-capable model for this provider/family. The
|
|
97
|
+
// profile is short and applied to the live catalog; price alone never
|
|
98
|
+
// promotes a model into the classifier role.
|
|
99
|
+
if (sessionModel) {
|
|
100
|
+
// Reuse the containment set computed above instead of recomputing it inside.
|
|
101
|
+
push(findRoleProfileModel(available, sessionModel, "classifier", withinBudget, inProvider), "role-profile");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. The cheapest genuinely-priced model in the provider.
|
|
105
|
+
const cheaper = inProvider
|
|
106
|
+
.map((model) => ({ model, price: pricedInput(model) }))
|
|
107
|
+
.filter((entry): entry is { model: Model<Api>; price: number } => entry.price !== undefined)
|
|
108
|
+
.filter((entry) => withinBudget(entry.model))
|
|
109
|
+
.sort((a, b) => a.price - b.price || hintRank(a.model) - hintRank(b.model));
|
|
110
|
+
const cheapest = cheaper[0]?.model;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 4. The session's own model, ahead of the cost-ranked pick.
|
|
114
|
+
*
|
|
115
|
+
* Price is not evidence of suitability, and a name is barely better. On
|
|
116
|
+
* OpenRouter the cheapest model in the catalog is `inclusionai/ling-2.6-flash`
|
|
117
|
+
* at $0.01/M, which would otherwise become the security boundary for being
|
|
118
|
+
* cheap. An attempt to gate that on the name carrying a known small-model word
|
|
119
|
+
* failed on the same example — "flash" is in the name, because any vendor may
|
|
120
|
+
* put it there; the word means "someone called this small", not "this family is
|
|
121
|
+
* known good".
|
|
122
|
+
*
|
|
123
|
+
* So nothing chosen on price alone leads. Providers that *are* vetted get their
|
|
124
|
+
* saving from the table above (which covers OpenRouter and every mainstream
|
|
125
|
+
* provider); anywhere else the model the user already trusted for the real work
|
|
126
|
+
* screens the calls — correct, merely not cheap — and `classifierModel` is
|
|
127
|
+
* there for someone who wants otherwise.
|
|
128
|
+
*/
|
|
129
|
+
push(sessionModel, "session");
|
|
130
|
+
|
|
131
|
+
// 5. Last resort, for when even the session model cannot serve.
|
|
132
|
+
push(cheapest, "cheapest-in-provider");
|
|
133
|
+
|
|
134
|
+
// Nothing configured and no session model (a headless run with a bare
|
|
135
|
+
// registry) — take anything available rather than refusing outright.
|
|
136
|
+
if (candidates.length === 0) push(available[0], "session");
|
|
137
|
+
|
|
138
|
+
return candidates;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether an error means "this model is not usable on this account" — in which
|
|
143
|
+
* case stepping to the next candidate is right — as opposed to a transient
|
|
144
|
+
* failure, where switching models would hide a problem that is about to clear.
|
|
145
|
+
*
|
|
146
|
+
* "Quota" is deliberately matched only in its billing forms (`insufficient_quota`,
|
|
147
|
+
* "exceeded your current quota"): a bare match also caught per-minute rate-limit
|
|
148
|
+
* messages ("quota exceeded, retry in 60s"), permanently rejecting a healthy
|
|
149
|
+
* model for the whole session over a blip. Misreading billing as transient
|
|
150
|
+
* merely retries and blocks noisily; misreading a blip as permanent bricks the
|
|
151
|
+
* candidate — so uncertainty goes to transient.
|
|
152
|
+
*/
|
|
153
|
+
export function isModelUnavailableError(message: string): boolean {
|
|
154
|
+
return /\b(401|403|404)\b|not_found|not found|does not exist|(no|have|lacks?)\s+access|unauthoriz|forbidden|invalid[_ -]?model|model[_ -]?not|unsupported[_ -]?model|no such model|entitl|insufficient[_ -]?quota|exceeded your current quota|billing/i.test(
|
|
155
|
+
message,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One-line description for the notice and `/auto-mode config`. */
|
|
160
|
+
export function describeCandidate(candidate: Candidate): string {
|
|
161
|
+
const name = `${candidate.model.provider}/${candidate.model.id}`;
|
|
162
|
+
switch (candidate.source) {
|
|
163
|
+
case "configured":
|
|
164
|
+
return `${name} (from autoMode.classifierModel)`;
|
|
165
|
+
case "role-profile": {
|
|
166
|
+
const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
|
|
167
|
+
return `${name} (classifier profile for ${where})`;
|
|
168
|
+
}
|
|
169
|
+
case "cheapest-in-provider": {
|
|
170
|
+
const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
|
|
171
|
+
return `${name} (cheapest available within ${where})`;
|
|
172
|
+
}
|
|
173
|
+
case "session": {
|
|
174
|
+
const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
|
|
175
|
+
return `${name} (this session's model — no vetted smaller model within ${where})`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution and containment for the shell pre-gate (pure apart from fs
|
|
3
|
+
* reads).
|
|
4
|
+
*
|
|
5
|
+
* Two fixes over the sandbox this derives from:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The symlink leaf is inspected** (review finding N9). The original's
|
|
8
|
+
* ENOENT fallback joined literal basenames onto the nearest resolved
|
|
9
|
+
* ancestor without ever lstat-ing the leaf, so `ln -s /outside/x link`
|
|
10
|
+
* followed by `echo hi > link` resolved to `<project>/link` — inside the
|
|
11
|
+
* project — while bash wrote through the symlink to `/outside/x`.
|
|
12
|
+
* 2. **Resolution failure is reported, not swallowed.** The original returned a
|
|
13
|
+
* best-effort path on failure, so the caller could not tell a real
|
|
14
|
+
* containment pass from a guess. Here an unresolvable path is `undefined`
|
|
15
|
+
* and every caller treats that as "not provably contained".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
19
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tools whose calls write to a path. Lives here — the lowest shared layer both
|
|
23
|
+
* the shell safety floor and the permission gate import — so the two write-gates
|
|
24
|
+
* share one source of truth for "which tools write" (was duplicated).
|
|
25
|
+
*/
|
|
26
|
+
export const WRITING_TOOLS = new Set(["edit", "write", "notebook_edit"]);
|
|
27
|
+
|
|
28
|
+
export function isWritingTool(toolName: string): boolean {
|
|
29
|
+
return WRITING_TOOLS.has(toolName);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Case-folded on darwin/win32, where the filesystem is case-insensitive. */
|
|
33
|
+
function normalize(target: string): string {
|
|
34
|
+
const normalized = resolve(target).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
35
|
+
return process.platform === "linux" ? normalized : normalized.toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tryRealpath(target: string): string | undefined {
|
|
39
|
+
try {
|
|
40
|
+
return realpathSync.native(target);
|
|
41
|
+
} catch {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(target);
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a path for containment checks, following a symlink leaf that does not
|
|
52
|
+
* yet have a target. Returns undefined when nothing about the path can be
|
|
53
|
+
* resolved — callers must not treat that as contained.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveForContainment(target: string): string | undefined {
|
|
56
|
+
const absolute = resolve(target);
|
|
57
|
+
|
|
58
|
+
const direct = tryRealpath(absolute);
|
|
59
|
+
if (direct) return normalize(direct);
|
|
60
|
+
|
|
61
|
+
// The leaf may be a dangling symlink: realpath fails on it, but writes still
|
|
62
|
+
// follow it. Read the link and resolve its target instead of assuming the
|
|
63
|
+
// literal path (N9).
|
|
64
|
+
try {
|
|
65
|
+
const stats = lstatSync(absolute);
|
|
66
|
+
if (stats.isSymbolicLink()) {
|
|
67
|
+
const linkTarget = readlinkSync(absolute);
|
|
68
|
+
const resolvedTarget = isAbsolute(linkTarget) ? linkTarget : resolve(dirname(absolute), linkTarget);
|
|
69
|
+
// One hop is enough: the target's own realpath covers the rest of a chain.
|
|
70
|
+
return resolveForContainment(resolvedTarget) ?? normalize(resolvedTarget);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Does not exist yet — fall through to ancestor resolution below.
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A path being created (`> newfile`): resolve the nearest existing ancestor
|
|
77
|
+
// and re-attach the tail, so a symlinked *parent* directory is still followed.
|
|
78
|
+
const tail: string[] = [];
|
|
79
|
+
let cursor = absolute;
|
|
80
|
+
for (;;) {
|
|
81
|
+
const parent = dirname(cursor);
|
|
82
|
+
if (parent === cursor) return undefined;
|
|
83
|
+
tail.unshift(basename(cursor));
|
|
84
|
+
const realParent = tryRealpath(parent);
|
|
85
|
+
if (realParent) return normalize(join(realParent, ...tail));
|
|
86
|
+
cursor = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True only when `target` is provably at or under `base`. */
|
|
91
|
+
export function isWithin(base: string, target: string): boolean {
|
|
92
|
+
const normalizedBase = normalize(base);
|
|
93
|
+
const normalizedTarget = normalize(target);
|
|
94
|
+
return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}/`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Expand a leading `~`, then resolve relative to `cwd`. Kept separate from
|
|
99
|
+
* resolveForContainment so callers can report the *original* token in messages:
|
|
100
|
+
* a resolved path can embed an expanded environment-variable value, and echoing
|
|
101
|
+
* that back to the model leaks the secret it was meant to protect (review
|
|
102
|
+
* finding N18).
|
|
103
|
+
*/
|
|
104
|
+
export function toAbsolute(cwd: string, token: string, home: string): string {
|
|
105
|
+
if (token === "~") return home;
|
|
106
|
+
if (token.startsWith("~/")) return resolve(home, token.slice(2));
|
|
107
|
+
if (isAbsolute(token)) return resolve(token);
|
|
108
|
+
return resolve(cwd, token);
|
|
109
|
+
}
|