@cruxy/cli 0.29.2 → 0.29.4
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/dist/agent/loop.js +6 -0
- package/dist/agent/prompts.d.ts +8 -0
- package/dist/agent/prompts.js +26 -0
- package/dist/cli/commands/mcp.js +1 -1
- package/dist/cli/commands/test.js +1 -0
- package/dist/config/credentials.d.ts +3 -4
- package/dist/config/credentials.js +74 -21
- package/dist/config/owner-only.d.ts +19 -0
- package/dist/config/owner-only.js +114 -0
- package/dist/config/schema.d.ts +77 -32
- package/dist/config/schema.js +16 -0
- package/dist/errors/constructors.d.ts +9 -0
- package/dist/errors/constructors.js +27 -0
- package/dist/errors/types.d.ts +12 -0
- package/dist/errors/types.js +18 -0
- package/dist/indexing/service.js +4 -1
- package/dist/lsp/transport.js +5 -5
- package/dist/mcp/transport.js +6 -6
- package/dist/memory/types.d.ts +2 -2
- package/dist/sandbox/docker-runtime.js +7 -15
- package/dist/testing/run-tests-tool.js +1 -0
- package/dist/testing/runner.js +7 -17
- package/dist/testing/types.d.ts +6 -1
- package/dist/tools/shell/exec.js +8 -19
- package/dist/tools/shell/resolve-shell.d.ts +81 -0
- package/dist/tools/shell/resolve-shell.js +158 -0
- package/dist/utils/child-tree.d.ts +9 -11
- package/dist/utils/child-tree.js +11 -25
- package/dist/utils/process-tree.d.ts +16 -0
- package/dist/utils/process-tree.js +84 -0
- package/package.json +4 -2
package/dist/agent/loop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CruxyError, providerUnsupported } from "../errors/index.js";
|
|
2
2
|
import { resolveTaskModel, } from "../routing/index.js";
|
|
3
3
|
import { buildSystemPrompt } from "./prompts.js";
|
|
4
|
+
import { resolveShell } from "../tools/shell/resolve-shell.js";
|
|
4
5
|
/** Tools whose successful call is a file change (drives the `on-file-change`
|
|
5
6
|
* hook). Kept in sync with the file-mutating tool set. */
|
|
6
7
|
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch"]);
|
|
@@ -45,6 +46,10 @@ async function driveLoop(args, renderer, routed) {
|
|
|
45
46
|
let messages = [...args.messages];
|
|
46
47
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
47
48
|
const maxIterations = config.agent.maxIterations;
|
|
49
|
+
// Resolve the host shell up front (JC#8) so a Windows box with no usable shell
|
|
50
|
+
// fails loud NOW (CRUXY_E_NO_SHELL) rather than mid-task, and the model gets the
|
|
51
|
+
// dialect directive from turn 1. Memoized, so run_command / run_tests reuse it.
|
|
52
|
+
const shellPlan = resolveShell(config.shell);
|
|
48
53
|
// The tool catalogue and environment are stable across the loop, so build the
|
|
49
54
|
// system prompt once. The builder degrades gracefully when git is null.
|
|
50
55
|
const system = buildSystemPrompt({
|
|
@@ -60,6 +65,7 @@ async function driveLoop(args, renderer, routed) {
|
|
|
60
65
|
recalledMemory: args.recalledMemory ?? null,
|
|
61
66
|
planMode: args.planMode ?? false,
|
|
62
67
|
subagent: args.subagent ?? false,
|
|
68
|
+
shellDialect: shellPlan.dialect,
|
|
63
69
|
});
|
|
64
70
|
let iterations = 0;
|
|
65
71
|
for (let i = 0; i < maxIterations; i++) {
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* dynamic environment block, so the model always knows where it is, what it
|
|
7
7
|
* can do, and how it's expected to behave.
|
|
8
8
|
*/
|
|
9
|
+
import type { ShellDialect } from "../tools/shell/resolve-shell.js";
|
|
9
10
|
export interface ToolSummary {
|
|
10
11
|
name: string;
|
|
11
12
|
}
|
|
@@ -38,6 +39,13 @@ export interface PromptContext {
|
|
|
38
39
|
planMode?: boolean;
|
|
39
40
|
/** Subagent run (C.14): inject the bounded-subtask directive. */
|
|
40
41
|
subagent?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The resolved shell's command dialect (from resolve-shell.ts). When it is not
|
|
44
|
+
* a POSIX shell — e.g. PowerShell, because no Git Bash was found on Windows —
|
|
45
|
+
* a directive tells the model to emit that dialect instead of bash. Absent /
|
|
46
|
+
* "posix" → no directive (bash-isms are correct there; a nudge would be wrong).
|
|
47
|
+
*/
|
|
48
|
+
shellDialect?: ShellDialect;
|
|
41
49
|
}
|
|
42
50
|
/** Assemble the full system prompt for a session. */
|
|
43
51
|
export declare function buildSystemPrompt(ctx: PromptContext): string;
|
package/dist/agent/prompts.js
CHANGED
|
@@ -70,6 +70,29 @@ function renderEnvironment(ctx) {
|
|
|
70
70
|
}
|
|
71
71
|
return `## Environment\n${lines.join("\n")}`;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The command-dialect directive (JC#4). Emitted ONLY for a non-POSIX shell:
|
|
75
|
+
* under bash / Git Bash the model's default bash syntax is correct, so a "use
|
|
76
|
+
* PowerShell" nudge would be actively wrong. Keeps the model from emitting
|
|
77
|
+
* bash-isms that a Windows PowerShell/cmd shell would silently mis-run.
|
|
78
|
+
*/
|
|
79
|
+
function renderShellDialect(dialect) {
|
|
80
|
+
if (!dialect || dialect === "posix")
|
|
81
|
+
return null;
|
|
82
|
+
if (dialect === "powershell") {
|
|
83
|
+
return `## Shell dialect
|
|
84
|
+
\`run_command\` and \`run_tests\` execute through **PowerShell**, not a POSIX/bash shell. Emit native PowerShell:
|
|
85
|
+
- Sequence with \`;\`; run-on-success with \`if ($?) { ... }\` — NOT bash \`&&\` / \`||\`.
|
|
86
|
+
- Environment variables are \`$env:NAME\` (read) and \`$env:NAME = "v"\` (set) — never \`$NAME\` or \`export NAME=\`.
|
|
87
|
+
- Discard output with \`2>$null\` / \`| Out-Null\`, not \`2>/dev/null\`; no heredocs or single-quote-escaping tricks.
|
|
88
|
+
Cross-platform tools (npm, pnpm, git, cargo, node, python) take the same arguments here as anywhere.`;
|
|
89
|
+
}
|
|
90
|
+
return `## Shell dialect
|
|
91
|
+
\`run_command\` and \`run_tests\` execute through **cmd.exe**, not a POSIX/bash shell. Emit native cmd:
|
|
92
|
+
- Sequence with \`&\`; environment variables are \`%NAME%\`; set them with \`set NAME=value\`.
|
|
93
|
+
- No bash constructs (\`export\`, single-quoted strings, \`2>/dev/null\`, heredocs).
|
|
94
|
+
Cross-platform tools (npm, git, cargo, node) take the same arguments here as anywhere.`;
|
|
95
|
+
}
|
|
73
96
|
function renderTools(tools) {
|
|
74
97
|
if (tools.length === 0) {
|
|
75
98
|
return "## Tools\nNo tools are available this session; respond in text only.";
|
|
@@ -89,6 +112,9 @@ export function buildSystemPrompt(ctx) {
|
|
|
89
112
|
const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
|
|
90
113
|
const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
|
|
91
114
|
const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
|
|
115
|
+
const dialect = renderShellDialect(ctx.shellDialect);
|
|
116
|
+
if (dialect)
|
|
117
|
+
sections.push(dialect);
|
|
92
118
|
if (ctx.planMode)
|
|
93
119
|
sections.push(PLAN_MODE_SECTION);
|
|
94
120
|
if (ctx.subagent)
|
package/dist/cli/commands/mcp.js
CHANGED
|
@@ -131,7 +131,7 @@ export function mcpCommand() {
|
|
|
131
131
|
]);
|
|
132
132
|
}
|
|
133
133
|
const io = defaultOnboardingIO(shouldUseColor(process.stderr));
|
|
134
|
-
io.write(`${t.strong(`credential for "${server}"`)} ${t.muted(`(stored as "${ref}" in ~/.cruxy/credentials.json,
|
|
134
|
+
io.write(`${t.strong(`credential for "${server}"`)} ${t.muted(`(stored as "${ref}" in ~/.cruxy/credentials.json, owner-only)`)}\n`);
|
|
135
135
|
io.write(`${t.muted("paste the bearer token (input hidden): ")}`);
|
|
136
136
|
const token = (await io.readSecret()).trim();
|
|
137
137
|
if (!token) {
|
|
@@ -9,11 +9,10 @@ export declare function readCredential(provider: string, file?: string): string
|
|
|
9
9
|
* secret is sourced from. Never throws.
|
|
10
10
|
*/
|
|
11
11
|
export declare function readMcpCredential(ref: string, file?: string): string | undefined;
|
|
12
|
-
/** Persist MCP bearer token for credential name `ref`. Same
|
|
12
|
+
/** Persist MCP bearer token for credential name `ref`. Same owner-only guarantee. */
|
|
13
13
|
export declare function writeMcpCredential(ref: string, token: string, file?: string): void;
|
|
14
14
|
/**
|
|
15
|
-
* Persist `key` for `provider`, merging into any existing store.
|
|
16
|
-
*
|
|
17
|
-
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
15
|
+
* Persist `key` for `provider`, merging into any existing store. Written
|
|
16
|
+
* owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
|
|
18
17
|
*/
|
|
19
18
|
export declare function writeCredential(provider: string, key: string, file?: string): void;
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CREDENTIALS_FILE_NAME } from "../constants.js";
|
|
4
|
+
import { credentialsUnprotected } from "../errors/index.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { enforceOwnerOnly, isOwnerOnly } from "./owner-only.js";
|
|
4
7
|
import { globalDir } from "./paths.js";
|
|
5
8
|
/**
|
|
6
9
|
* The credentials store (U.6) — the one place a provider API key is persisted.
|
|
7
10
|
* It lives **outside** `config.json` on purpose: config is secret-free by design
|
|
8
11
|
* ("the API key comes from env"), so secrets get their own file with restrictive
|
|
9
|
-
* permissions
|
|
10
|
-
*
|
|
12
|
+
* permissions, the same shape as gh/aws/npm. `resolveApiKey` reads it as a
|
|
13
|
+
* fallback after the environment.
|
|
14
|
+
*
|
|
15
|
+
* "Restrictive" means **owner-only**: POSIX `0600` (dir `0700`), and on Windows
|
|
16
|
+
* an NTFS ACL granting only the current user (POSIX modes are meaningless there —
|
|
17
|
+
* see {@link enforceOwnerOnly}). The guarantee is enforced AND verified on every
|
|
18
|
+
* write; if it cannot be established the write is refused loudly
|
|
19
|
+
* (`CRUXY_E_CREDENTIALS_UNPROTECTED`) rather than persisting a secret at
|
|
20
|
+
* permissions we could not secure.
|
|
11
21
|
*/
|
|
12
22
|
/** Bumped if the on-disk shape ever changes. */
|
|
13
23
|
const CREDENTIALS_VERSION = 1;
|
|
@@ -15,6 +25,26 @@ const CREDENTIALS_VERSION = 1;
|
|
|
15
25
|
export function credentialsPath() {
|
|
16
26
|
return join(globalDir(), CREDENTIALS_FILE_NAME);
|
|
17
27
|
}
|
|
28
|
+
/** Paths already warned about this process, so a loose-perms warning fires once. */
|
|
29
|
+
const warnedLoosePerms = new Set();
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort: warn (never refuse) if an existing store is readable beyond its
|
|
32
|
+
* owner, so an upgrade from a build that couldn't secure it isn't silently
|
|
33
|
+
* insecure — and isn't bricked either. Fires at most once per path per process.
|
|
34
|
+
*/
|
|
35
|
+
function warnIfLoosePerms(file) {
|
|
36
|
+
if (warnedLoosePerms.has(file))
|
|
37
|
+
return;
|
|
38
|
+
warnedLoosePerms.add(file);
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(file) && !isOwnerOnly(file)) {
|
|
41
|
+
logger.warn(`credentials store ${file} is not owner-only — other users on this machine may be able to read it. Re-run \`cruxy login\` (or re-store the MCP credential) to repair its permissions.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Perms could not be inspected — stay silent rather than cry wolf.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
18
48
|
/** Parse the store at `file`, or `null` if absent/unreadable/malformed. */
|
|
19
49
|
function readStore(file) {
|
|
20
50
|
if (!existsSync(file))
|
|
@@ -35,6 +65,7 @@ function readStore(file) {
|
|
|
35
65
|
}
|
|
36
66
|
/** The stored key for `provider`, or `undefined`. Never throws. */
|
|
37
67
|
export function readCredential(provider, file = credentialsPath()) {
|
|
68
|
+
warnIfLoosePerms(file);
|
|
38
69
|
const store = readStore(file);
|
|
39
70
|
const key = store?.keys[provider];
|
|
40
71
|
return typeof key === "string" && key !== "" ? key : undefined;
|
|
@@ -46,52 +77,74 @@ export function readCredential(provider, file = credentialsPath()) {
|
|
|
46
77
|
* secret is sourced from. Never throws.
|
|
47
78
|
*/
|
|
48
79
|
export function readMcpCredential(ref, file = credentialsPath()) {
|
|
80
|
+
warnIfLoosePerms(file);
|
|
49
81
|
const store = readStore(file);
|
|
50
82
|
const token = store?.mcp?.[ref];
|
|
51
83
|
return typeof token === "string" && token !== "" ? token : undefined;
|
|
52
84
|
}
|
|
53
|
-
/** Persist MCP bearer token for credential name `ref`. Same
|
|
85
|
+
/** Persist MCP bearer token for credential name `ref`. Same owner-only guarantee. */
|
|
54
86
|
export function writeMcpCredential(ref, token, file = credentialsPath()) {
|
|
55
87
|
writeInto(file, (store) => {
|
|
56
88
|
store.mcp ??= {};
|
|
57
89
|
store.mcp[ref] = token;
|
|
58
|
-
});
|
|
90
|
+
}, "mcp");
|
|
59
91
|
}
|
|
60
92
|
/**
|
|
61
|
-
* Persist `key` for `provider`, merging into any existing store.
|
|
62
|
-
*
|
|
63
|
-
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
93
|
+
* Persist `key` for `provider`, merging into any existing store. Written
|
|
94
|
+
* owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
|
|
64
95
|
*/
|
|
65
96
|
export function writeCredential(provider, key, file = credentialsPath()) {
|
|
66
97
|
writeInto(file, (store) => {
|
|
67
98
|
store.keys[provider] = key;
|
|
68
|
-
});
|
|
99
|
+
}, "provider");
|
|
69
100
|
}
|
|
70
101
|
/**
|
|
71
|
-
* Merge `mutate` into the store and persist it owner-only
|
|
72
|
-
*
|
|
73
|
-
*
|
|
102
|
+
* Merge `mutate` into the store and persist it owner-only. The one write path
|
|
103
|
+
* shared by every credential namespace, and the single place the owner-only
|
|
104
|
+
* guarantee is enforced:
|
|
105
|
+
*
|
|
106
|
+
* 1. The DIRECTORY is made owner-only FIRST, before any secret is written — so
|
|
107
|
+
* the file inherits owner-only at creation (no window where it exists under
|
|
108
|
+
* inherited permissions), and if the directory can't be secured we refuse
|
|
109
|
+
* before a secret ever touches disk.
|
|
110
|
+
* 2. The new content is written to a temp file, made owner-only + VERIFIED,
|
|
111
|
+
* then atomically renamed over the target. A failure removes the temp and
|
|
112
|
+
* leaves any existing store untouched — never a torn or clobbered write.
|
|
113
|
+
*
|
|
114
|
+
* Enforcement runs on every write, so a pre-existing store with loose
|
|
115
|
+
* permissions is repaired in place. If owner-only cannot be established the write
|
|
116
|
+
* is refused with `CRUXY_E_CREDENTIALS_UNPROTECTED` (`kind` tailors the
|
|
117
|
+
* remediation: provider keys → env var; MCP tokens → no env fallback).
|
|
74
118
|
*/
|
|
75
|
-
function writeInto(file, mutate) {
|
|
119
|
+
function writeInto(file, mutate, kind) {
|
|
76
120
|
const dir = dirname(file);
|
|
77
|
-
mkdirSync(dir, { recursive: true
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
78
122
|
try {
|
|
79
|
-
|
|
123
|
+
enforceOwnerOnly(dir, { directory: true });
|
|
80
124
|
}
|
|
81
|
-
catch {
|
|
82
|
-
//
|
|
125
|
+
catch (err) {
|
|
126
|
+
// Refuse BEFORE writing any secret — nothing sensitive has hit disk yet.
|
|
127
|
+
throw credentialsUnprotected(kind, dir, err);
|
|
83
128
|
}
|
|
84
129
|
const store = readStore(file) ?? { version: CREDENTIALS_VERSION, keys: {} };
|
|
85
130
|
store.version = CREDENTIALS_VERSION;
|
|
86
131
|
mutate(store);
|
|
87
|
-
|
|
132
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
133
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", {
|
|
88
134
|
encoding: "utf8",
|
|
89
135
|
mode: 0o600,
|
|
90
136
|
});
|
|
91
137
|
try {
|
|
92
|
-
|
|
138
|
+
enforceOwnerOnly(tmp, { directory: false }); // sets + verifies, or throws
|
|
139
|
+
renameSync(tmp, file); // atomic replace; the owner-only ACL moves with it
|
|
93
140
|
}
|
|
94
|
-
catch {
|
|
95
|
-
|
|
141
|
+
catch (err) {
|
|
142
|
+
try {
|
|
143
|
+
rmSync(tmp, { force: true });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// The temp may already be gone; the target store is untouched regardless.
|
|
147
|
+
}
|
|
148
|
+
throw credentialsUnprotected(kind, file, err);
|
|
96
149
|
}
|
|
97
150
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make `path` owner-only, or throw. On Windows the ACL is verified after the
|
|
3
|
+
* edit (both the `icacls` exit code AND a read-back), so a returned call is a
|
|
4
|
+
* genuine guarantee, never a best-effort attempt.
|
|
5
|
+
*
|
|
6
|
+
* @throws if owner-only permissions cannot be established (non-zero `icacls`,
|
|
7
|
+
* an unresolvable SID, a filesystem without ACLs/modes, …).
|
|
8
|
+
*/
|
|
9
|
+
export declare function enforceOwnerOnly(path: string, opts?: {
|
|
10
|
+
directory?: boolean;
|
|
11
|
+
}): void;
|
|
12
|
+
/**
|
|
13
|
+
* Whether `path` is currently owner-only. POSIX: no group/other bits. Windows:
|
|
14
|
+
* best-effort — the ACL names no broad principal (Everyone / Authenticated
|
|
15
|
+
* Users / Users). Used both to verify {@link enforceOwnerOnly} and to warn on a
|
|
16
|
+
* pre-existing store with loose permissions. Returns `true` when it genuinely
|
|
17
|
+
* cannot tell (a missing tool), so a warning path never cries wolf.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isOwnerOnly(path: string): boolean;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, statSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform "owner-only" file/dir permissions — the one place that knows
|
|
5
|
+
* how to make a path readable by nobody but its owner, and how to check whether
|
|
6
|
+
* it already is.
|
|
7
|
+
*
|
|
8
|
+
* POSIX is `chmod` (`0600` file / `0700` dir). Windows has no POSIX modes —
|
|
9
|
+
* `fs.chmod` there only toggles the read-only attribute and never touches the
|
|
10
|
+
* NTFS ACL (a libuv limitation), so it is worthless for confidentiality. The
|
|
11
|
+
* Windows equivalent is an ACL edit via `icacls`: strip the inherited ACEs (the
|
|
12
|
+
* only source of "others" access on a fresh file) and grant Full to exactly the
|
|
13
|
+
* current user's SID.
|
|
14
|
+
*
|
|
15
|
+
* Every function here THROWS rather than swallowing a failure — the caller (the
|
|
16
|
+
* credential store) turns that into a loud refusal to persist a secret it can't
|
|
17
|
+
* secure. Nothing here silently claims success.
|
|
18
|
+
*/
|
|
19
|
+
const isWindows = process.platform === "win32";
|
|
20
|
+
/** `undefined` = not yet resolved, `null` = resolution failed this process. */
|
|
21
|
+
let cachedSid;
|
|
22
|
+
/**
|
|
23
|
+
* The current user's SID (e.g. `S-1-5-21-…`) via `whoami /user`, resolved once
|
|
24
|
+
* and cached. We grant to the SID — never a name — so the ACL is correct
|
|
25
|
+
* regardless of the machine's display language (`BUILTIN\Users` etc. localise).
|
|
26
|
+
* Returns `null` if it cannot be determined.
|
|
27
|
+
*/
|
|
28
|
+
function currentUserSid() {
|
|
29
|
+
if (cachedSid !== undefined)
|
|
30
|
+
return cachedSid;
|
|
31
|
+
try {
|
|
32
|
+
const out = execFileSync("whoami", ["/user", "/fo", "csv", "/nh"], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
});
|
|
35
|
+
const m = out.match(/S-1-[0-9-]+/);
|
|
36
|
+
cachedSid = m ? m[0] : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
cachedSid = null;
|
|
40
|
+
}
|
|
41
|
+
return cachedSid;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Make `path` owner-only, or throw. On Windows the ACL is verified after the
|
|
45
|
+
* edit (both the `icacls` exit code AND a read-back), so a returned call is a
|
|
46
|
+
* genuine guarantee, never a best-effort attempt.
|
|
47
|
+
*
|
|
48
|
+
* @throws if owner-only permissions cannot be established (non-zero `icacls`,
|
|
49
|
+
* an unresolvable SID, a filesystem without ACLs/modes, …).
|
|
50
|
+
*/
|
|
51
|
+
export function enforceOwnerOnly(path, opts = {}) {
|
|
52
|
+
const directory = opts.directory ?? false;
|
|
53
|
+
if (isWindows) {
|
|
54
|
+
const sid = currentUserSid();
|
|
55
|
+
if (!sid) {
|
|
56
|
+
throw new Error("could not resolve the current user's SID (whoami failed) — cannot set an owner-only ACL");
|
|
57
|
+
}
|
|
58
|
+
// /inheritance:r removes inherited ACEs (where any "others" access comes
|
|
59
|
+
// from); /grant:r replaces the DACL with exactly this one grant. A directory
|
|
60
|
+
// grant carries (OI)(CI) so files created inside inherit owner-only at birth.
|
|
61
|
+
// The SID must be written `*S-1-…` — a bare token is read as an account NAME
|
|
62
|
+
// ("No mapping between account names and security IDs" otherwise).
|
|
63
|
+
const principal = `*${sid}`;
|
|
64
|
+
const grant = directory ? `${principal}:(OI)(CI)F` : `${principal}:F`;
|
|
65
|
+
try {
|
|
66
|
+
execFileSync("icacls", [path, "/inheritance:r", "/grant:r", grant], {
|
|
67
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const stderr = err.stderr?.toString().trim();
|
|
72
|
+
throw new Error(`icacls could not restrict "${path}"${stderr ? `: ${stderr}` : ""}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
chmodSync(path, directory ? 0o700 : 0o600);
|
|
77
|
+
}
|
|
78
|
+
// Verify the end state rather than trust the set — the guarantee is the point.
|
|
79
|
+
if (!isOwnerOnly(path)) {
|
|
80
|
+
throw new Error(`"${path}" is not owner-only after attempting to restrict it`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether `path` is currently owner-only. POSIX: no group/other bits. Windows:
|
|
85
|
+
* best-effort — the ACL names no broad principal (Everyone / Authenticated
|
|
86
|
+
* Users / Users). Used both to verify {@link enforceOwnerOnly} and to warn on a
|
|
87
|
+
* pre-existing store with loose permissions. Returns `true` when it genuinely
|
|
88
|
+
* cannot tell (a missing tool), so a warning path never cries wolf.
|
|
89
|
+
*/
|
|
90
|
+
export function isOwnerOnly(path) {
|
|
91
|
+
if (!isWindows) {
|
|
92
|
+
return (statSync(path).mode & 0o077) === 0;
|
|
93
|
+
}
|
|
94
|
+
let out;
|
|
95
|
+
try {
|
|
96
|
+
out = execFileSync("icacls", [path], { encoding: "utf8" });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return true; // can't inspect → don't raise a false alarm
|
|
100
|
+
}
|
|
101
|
+
// `icacls <path>` resolves SIDs to names (locale-dependent on non-English
|
|
102
|
+
// Windows, hence best-effort): flag the well-known broad principals by name
|
|
103
|
+
// and by SID for the cases icacls leaves a SID unresolved.
|
|
104
|
+
const broad = [
|
|
105
|
+
/\bEveryone\b/i,
|
|
106
|
+
/\bAuthenticated Users\b/i,
|
|
107
|
+
/\bBUILTIN\\Users\b/i,
|
|
108
|
+
/\\Users:/i,
|
|
109
|
+
/S-1-1-0/, // Everyone
|
|
110
|
+
/S-1-5-11/, // Authenticated Users
|
|
111
|
+
/S-1-5-32-545/, // BUILTIN\Users
|
|
112
|
+
];
|
|
113
|
+
return !broad.some((re) => re.test(out));
|
|
114
|
+
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -105,13 +105,34 @@ export declare const ShellConfigSchema: z.ZodObject<{
|
|
|
105
105
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
106
106
|
/** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
|
|
107
107
|
maxOutputBytes: z.ZodDefault<z.ZodNumber>;
|
|
108
|
+
/**
|
|
109
|
+
* Explicit shell to run commands through, overriding platform detection. A
|
|
110
|
+
* path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
|
|
111
|
+
* set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
|
|
112
|
+
* the user owns the choice. Unset → POSIX uses the default shell; Windows
|
|
113
|
+
* probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
|
|
114
|
+
*/
|
|
115
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
116
|
+
/**
|
|
117
|
+
* The command dialect of `executable`, so the model is told which syntax to
|
|
118
|
+
* emit. Only sniffed automatically for unambiguous basenames (bash/sh →
|
|
119
|
+
* posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
|
|
120
|
+
* or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
|
|
121
|
+
* `executable`.
|
|
122
|
+
*/
|
|
123
|
+
dialect: z.ZodOptional<z.ZodEnum<["posix", "powershell", "cmd"]>>;
|
|
108
124
|
}, "strict", z.ZodTypeAny, {
|
|
109
125
|
timeoutMs: number;
|
|
110
126
|
maxOutputBytes: number;
|
|
127
|
+
executable?: string | undefined;
|
|
128
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
111
129
|
}, {
|
|
112
130
|
timeoutMs?: number | undefined;
|
|
113
131
|
maxOutputBytes?: number | undefined;
|
|
132
|
+
executable?: string | undefined;
|
|
133
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
114
134
|
}>;
|
|
135
|
+
export type ShellConfig = z.infer<typeof ShellConfigSchema>;
|
|
115
136
|
/** Context-window management: when to compact the running conversation. */
|
|
116
137
|
export declare const ContextConfigSchema: z.ZodObject<{
|
|
117
138
|
/** Approximate model context budget, in tokens (heuristic estimate). */
|
|
@@ -1096,12 +1117,32 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1096
1117
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
1097
1118
|
/** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
|
|
1098
1119
|
maxOutputBytes: z.ZodDefault<z.ZodNumber>;
|
|
1120
|
+
/**
|
|
1121
|
+
* Explicit shell to run commands through, overriding platform detection. A
|
|
1122
|
+
* path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
|
|
1123
|
+
* set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
|
|
1124
|
+
* the user owns the choice. Unset → POSIX uses the default shell; Windows
|
|
1125
|
+
* probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
|
|
1126
|
+
*/
|
|
1127
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
1128
|
+
/**
|
|
1129
|
+
* The command dialect of `executable`, so the model is told which syntax to
|
|
1130
|
+
* emit. Only sniffed automatically for unambiguous basenames (bash/sh →
|
|
1131
|
+
* posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
|
|
1132
|
+
* or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
|
|
1133
|
+
* `executable`.
|
|
1134
|
+
*/
|
|
1135
|
+
dialect: z.ZodOptional<z.ZodEnum<["posix", "powershell", "cmd"]>>;
|
|
1099
1136
|
}, "strict", z.ZodTypeAny, {
|
|
1100
1137
|
timeoutMs: number;
|
|
1101
1138
|
maxOutputBytes: number;
|
|
1139
|
+
executable?: string | undefined;
|
|
1140
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1102
1141
|
}, {
|
|
1103
1142
|
timeoutMs?: number | undefined;
|
|
1104
1143
|
maxOutputBytes?: number | undefined;
|
|
1144
|
+
executable?: string | undefined;
|
|
1145
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1105
1146
|
}>>;
|
|
1106
1147
|
context: z.ZodDefault<z.ZodObject<{
|
|
1107
1148
|
/** Approximate model context budget, in tokens (heuristic estimate). */
|
|
@@ -1796,6 +1837,22 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1796
1837
|
approval: {
|
|
1797
1838
|
mode: "prompt";
|
|
1798
1839
|
};
|
|
1840
|
+
mcp: {
|
|
1841
|
+
startupTimeout: number;
|
|
1842
|
+
requestTimeout: number;
|
|
1843
|
+
servers: Record<string, {
|
|
1844
|
+
args: string[];
|
|
1845
|
+
env: Record<string, string>;
|
|
1846
|
+
command?: string | undefined;
|
|
1847
|
+
credentialRef?: string | undefined;
|
|
1848
|
+
url?: string | undefined;
|
|
1849
|
+
headers?: Record<string, string> | undefined;
|
|
1850
|
+
}>;
|
|
1851
|
+
enabled: boolean;
|
|
1852
|
+
maxToolsPerServer: number;
|
|
1853
|
+
maxDescriptionChars: number;
|
|
1854
|
+
maxSchemaBytes: number;
|
|
1855
|
+
};
|
|
1799
1856
|
subagent: {
|
|
1800
1857
|
maxDepth: number;
|
|
1801
1858
|
maxConcurrency: number;
|
|
@@ -1814,6 +1871,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1814
1871
|
shell: {
|
|
1815
1872
|
timeoutMs: number;
|
|
1816
1873
|
maxOutputBytes: number;
|
|
1874
|
+
executable?: string | undefined;
|
|
1875
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1817
1876
|
};
|
|
1818
1877
|
agent: {
|
|
1819
1878
|
maxIterations: number;
|
|
@@ -1879,22 +1938,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1879
1938
|
map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
|
|
1880
1939
|
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
1881
1940
|
};
|
|
1882
|
-
mcp: {
|
|
1883
|
-
startupTimeout: number;
|
|
1884
|
-
requestTimeout: number;
|
|
1885
|
-
servers: Record<string, {
|
|
1886
|
-
args: string[];
|
|
1887
|
-
env: Record<string, string>;
|
|
1888
|
-
command?: string | undefined;
|
|
1889
|
-
credentialRef?: string | undefined;
|
|
1890
|
-
url?: string | undefined;
|
|
1891
|
-
headers?: Record<string, string> | undefined;
|
|
1892
|
-
}>;
|
|
1893
|
-
enabled: boolean;
|
|
1894
|
-
maxToolsPerServer: number;
|
|
1895
|
-
maxDescriptionChars: number;
|
|
1896
|
-
maxSchemaBytes: number;
|
|
1897
|
-
};
|
|
1898
1941
|
web: {
|
|
1899
1942
|
provider: "tavily";
|
|
1900
1943
|
timeoutMs: number;
|
|
@@ -1951,6 +1994,22 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1951
1994
|
approval?: {
|
|
1952
1995
|
mode?: "prompt" | undefined;
|
|
1953
1996
|
} | undefined;
|
|
1997
|
+
mcp?: {
|
|
1998
|
+
startupTimeout?: number | undefined;
|
|
1999
|
+
requestTimeout?: number | undefined;
|
|
2000
|
+
servers?: Record<string, {
|
|
2001
|
+
command?: string | undefined;
|
|
2002
|
+
credentialRef?: string | undefined;
|
|
2003
|
+
url?: string | undefined;
|
|
2004
|
+
args?: string[] | undefined;
|
|
2005
|
+
env?: Record<string, string> | undefined;
|
|
2006
|
+
headers?: Record<string, string> | undefined;
|
|
2007
|
+
}> | undefined;
|
|
2008
|
+
enabled?: boolean | undefined;
|
|
2009
|
+
maxToolsPerServer?: number | undefined;
|
|
2010
|
+
maxDescriptionChars?: number | undefined;
|
|
2011
|
+
maxSchemaBytes?: number | undefined;
|
|
2012
|
+
} | undefined;
|
|
1954
2013
|
subagent?: {
|
|
1955
2014
|
maxDepth?: number | undefined;
|
|
1956
2015
|
maxConcurrency?: number | undefined;
|
|
@@ -1969,6 +2028,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1969
2028
|
shell?: {
|
|
1970
2029
|
timeoutMs?: number | undefined;
|
|
1971
2030
|
maxOutputBytes?: number | undefined;
|
|
2031
|
+
executable?: string | undefined;
|
|
2032
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1972
2033
|
} | undefined;
|
|
1973
2034
|
agent?: {
|
|
1974
2035
|
maxIterations?: number | undefined;
|
|
@@ -2034,22 +2095,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
2034
2095
|
map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
|
|
2035
2096
|
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
2036
2097
|
} | undefined;
|
|
2037
|
-
mcp?: {
|
|
2038
|
-
startupTimeout?: number | undefined;
|
|
2039
|
-
requestTimeout?: number | undefined;
|
|
2040
|
-
servers?: Record<string, {
|
|
2041
|
-
command?: string | undefined;
|
|
2042
|
-
credentialRef?: string | undefined;
|
|
2043
|
-
url?: string | undefined;
|
|
2044
|
-
args?: string[] | undefined;
|
|
2045
|
-
env?: Record<string, string> | undefined;
|
|
2046
|
-
headers?: Record<string, string> | undefined;
|
|
2047
|
-
}> | undefined;
|
|
2048
|
-
enabled?: boolean | undefined;
|
|
2049
|
-
maxToolsPerServer?: number | undefined;
|
|
2050
|
-
maxDescriptionChars?: number | undefined;
|
|
2051
|
-
maxSchemaBytes?: number | undefined;
|
|
2052
|
-
} | undefined;
|
|
2053
2098
|
web?: {
|
|
2054
2099
|
provider?: "tavily" | undefined;
|
|
2055
2100
|
timeoutMs?: number | undefined;
|
package/dist/config/schema.js
CHANGED
|
@@ -85,6 +85,22 @@ export const ShellConfigSchema = z
|
|
|
85
85
|
timeoutMs: z.number().int().positive().default(120000),
|
|
86
86
|
/** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
|
|
87
87
|
maxOutputBytes: z.number().int().positive().default(102400),
|
|
88
|
+
/**
|
|
89
|
+
* Explicit shell to run commands through, overriding platform detection. A
|
|
90
|
+
* path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
|
|
91
|
+
* set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
|
|
92
|
+
* the user owns the choice. Unset → POSIX uses the default shell; Windows
|
|
93
|
+
* probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
|
|
94
|
+
*/
|
|
95
|
+
executable: z.string().min(1).optional(),
|
|
96
|
+
/**
|
|
97
|
+
* The command dialect of `executable`, so the model is told which syntax to
|
|
98
|
+
* emit. Only sniffed automatically for unambiguous basenames (bash/sh →
|
|
99
|
+
* posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
|
|
100
|
+
* or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
|
|
101
|
+
* `executable`.
|
|
102
|
+
*/
|
|
103
|
+
dialect: z.enum(["posix", "powershell", "cmd"]).optional(),
|
|
88
104
|
})
|
|
89
105
|
.strict();
|
|
90
106
|
/** Context-window management: when to compact the running conversation. */
|
|
@@ -24,6 +24,15 @@ export declare function configParse(path: string, underlying?: unknown): CruxyEr
|
|
|
24
24
|
export declare function configInvalid(issues: string, path?: string): CruxyError;
|
|
25
25
|
export declare function authMissingKey(provider: string, envVar: string): CruxyError;
|
|
26
26
|
export declare function authInvalid(underlying?: unknown): CruxyError;
|
|
27
|
+
/**
|
|
28
|
+
* A credential could not be persisted with owner-only permissions, so it was
|
|
29
|
+
* NOT written (C.27c). Chiefly a Windows case: the store's ACL could not be
|
|
30
|
+
* restricted to the current user (non-NTFS filesystem, `icacls`/SID
|
|
31
|
+
* unavailable), and we refuse to leave a secret at inheritable permissions while
|
|
32
|
+
* claiming otherwise. Provider keys point at the env-var fallback; MCP tokens
|
|
33
|
+
* have no such fallback, so the message says so plainly.
|
|
34
|
+
*/
|
|
35
|
+
export declare function credentialsUnprotected(kind: "provider" | "mcp", path: string, underlying?: unknown): CruxyError;
|
|
27
36
|
export declare function gatewayUnreachable(underlying?: unknown): CruxyError;
|
|
28
37
|
export declare function apiError(underlying?: unknown): CruxyError;
|
|
29
38
|
export declare function apiRateLimit(underlying?: unknown): CruxyError;
|