@molecule/api-ai-tools 1.0.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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Core types for the shared AI agent tool system.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * Abstraction over the execution environment.
8
+ * Implemented by SandboxBackend (Docker) and LocalBackend (host filesystem).
9
+ */
10
+ export interface ExecutionBackend {
11
+ /** The root directory for all operations (e.g. '/workspace' or '/Users/.../project'). */
12
+ readonly projectRoot: string;
13
+ /** Read a file's content as UTF-8 string. */
14
+ readFile(path: string): Promise<string>;
15
+ /** Write content to a file. Creates parent directories as needed. */
16
+ writeFile(path: string, content: string): Promise<void>;
17
+ /** Delete a file. */
18
+ deleteFile(path: string): Promise<void>;
19
+ /** List entries in a directory. */
20
+ readDir(path: string): Promise<Array<{
21
+ name: string;
22
+ type: 'file' | 'directory';
23
+ }>>;
24
+ /**
25
+ * Run a shell command. Returns stdout, stderr, and exit code.
26
+ * Backends implement this safely (sandbox.exec for Docker, execFile for local).
27
+ */
28
+ run(command: string, opts?: {
29
+ cwd?: string;
30
+ timeout?: number;
31
+ }): Promise<{
32
+ stdout: string;
33
+ stderr: string;
34
+ exitCode: number;
35
+ }>;
36
+ }
37
+ /**
38
+ * Configuration for building the tool set.
39
+ * Allows consumers to customize security, callbacks, and tool selection.
40
+ */
41
+ export interface ToolBuildConfig {
42
+ /** Which tools to include. Defaults to all. */
43
+ include?: string[];
44
+ /** Which tools to exclude. Applied after include. */
45
+ exclude?: string[];
46
+ /** Whether to validate paths stay within projectRoot. Default: true. */
47
+ pathGuards?: boolean;
48
+ /** Whether to check symlinks resolve within projectRoot. Default: false (sandbox-only). */
49
+ symlinkGuards?: boolean;
50
+ /** Whether to redact secrets in file reads and command output. Default: true. */
51
+ redactSecrets?: boolean;
52
+ /** Whether to block dangerous shell commands (env dumps, /proc access). Default: false. */
53
+ blockDangerousCommands?: boolean;
54
+ /**
55
+ * Consumer-specific command guard for `exec_command`, checked BEFORE execution (after
56
+ * the built-in dangerous-command check). Return an error string to block the command —
57
+ * it is returned to the model verbatim, so make it actionable (say what to do instead) —
58
+ * or `null`/`undefined` to allow it. Keeps environment-specific rules (e.g. an IDE
59
+ * sandbox forbidding installs that would break its preinstalled library) out of this
60
+ * shared package.
61
+ *
62
+ * @param command - The shell command the model asked to run.
63
+ * @param cwd - The resolved working directory it would run in.
64
+ * @returns An error string to block, or null/undefined to allow.
65
+ */
66
+ blockCommand?: (command: string, cwd: string) => string | null | undefined;
67
+ /**
68
+ * Timeout (ms) for a single `exec_command` run. `exec_command` legitimately
69
+ * runs LONG — `npm install`, a production build, a test suite — so the default
70
+ * is generous (2 min); the old 30 s hardcap killed those spuriously. A consumer
71
+ * that wraps tool calls in its own outer timeout should set this to match (or
72
+ * slightly exceed) that budget so its own timeout is the effective bound and
73
+ * produces the nicer "tool timed out" message. Quick commands are unaffected —
74
+ * this is only the ceiling before a wedged command is killed.
75
+ */
76
+ execTimeoutMs?: number;
77
+ /**
78
+ * Directory names `search_files` and `find_files` skip (VS Code
79
+ * `search.exclude` semantics). Defaults to `DEFAULT_SEARCH_EXCLUDED_DIRS`
80
+ * (node_modules, VCS dirs, build output). Pass the consumer's per-project
81
+ * setting so every search surface shares ONE synchronized set.
82
+ */
83
+ searchExcludedDirs?: string[];
84
+ /** Post-write hook (e.g. auto-format via Prettier/ESLint). Called after every write_file/edit_file. */
85
+ onAfterWrite?: (path: string) => Promise<void>;
86
+ /** Diff tracking callback. Called before writes with old/new content. */
87
+ onFileDiff?: (event: FileDiffEvent) => void;
88
+ /** Structural change callback. Called on create_directory, delete_file, rename_file. */
89
+ onFileChange?: (event: FileChangeEvent) => void;
90
+ }
91
+ /**
92
+ * Payload emitted when a tracked file changes contents.
93
+ */
94
+ export interface FileDiffEvent {
95
+ path: string;
96
+ oldContent: string | null;
97
+ newContent: string;
98
+ }
99
+ /**
100
+ * Payload emitted when a file is created, modified, or deleted structurally.
101
+ */
102
+ export interface FileChangeEvent {
103
+ type: 'created' | 'modified' | 'deleted';
104
+ path: string;
105
+ }
106
+ /** Metadata for a discovered skill (used in PromptContext). */
107
+ export interface SkillEntry {
108
+ /** Skill name. */
109
+ name: string;
110
+ /** Short description. */
111
+ description: string;
112
+ /** Relative path to the SKILL.md file. */
113
+ path: string;
114
+ }
115
+ /**
116
+ * Context for building a composable system prompt.
117
+ */
118
+ export interface PromptContext {
119
+ /** Agent identity (e.g. 'Synthase', 'Polish Agent'). */
120
+ agentName: string;
121
+ /** Project root path. */
122
+ projectRoot: string;
123
+ /** Names of available tools (for the tool listing section). */
124
+ tools: string[];
125
+ /** Project-specific rules (AGENTS.md or CLAUDE.md content). */
126
+ projectDocs?: string;
127
+ /** Additional skill/reference content to inject. */
128
+ skills?: string[];
129
+ /** Discovered skills to list in the prompt (use load_skill to read on demand). */
130
+ discoveredSkills?: SkillEntry[];
131
+ /** Custom sections to append to the prompt. */
132
+ customSections?: string[];
133
+ }
134
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Core types for the shared AI agent tool system.
3
+ *
4
+ * @module
5
+ */
6
+ export {};
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared utilities for AI agent tools.
3
+ * Extracted from molecule-dev/api/src/ai/tools.ts for reuse.
4
+ *
5
+ * @module
6
+ */
7
+ /**
8
+ * Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes),
9
+ * single-quoted strings prevent command substitution ($(), backticks) and variable expansion.
10
+ *
11
+ * @param s - Raw string to wrap for POSIX shell single-quoted context.
12
+ * @returns A single-quoted shell literal representing `s`.
13
+ */
14
+ export declare function shellQuote(s: string): string;
15
+ /**
16
+ * Strip C0 control chars (except tab, newline, CR) that break PostgreSQL JSONB
17
+ * and can cause rendering issues.
18
+ *
19
+ * @param s - Arbitrary text that may contain disallowed control characters.
20
+ * @returns A copy of `s` with unsafe control characters removed.
21
+ */
22
+ export declare const stripControlChars: (s: string) => string;
23
+ /**
24
+ * Redact values of common secret/credential patterns in text output.
25
+ *
26
+ * @param s - Log or command output that may contain `.env`-style secrets.
27
+ * @returns A redacted copy safe to surface to end users or models.
28
+ */
29
+ export declare function redactSecrets(s: string): string;
30
+ /**
31
+ * Check if a command is blocked for security reasons. Returns error message or null if allowed.
32
+ *
33
+ * @param command - Shell command string proposed for execution.
34
+ * @returns A human-readable block reason, or `null` when the command is allowed.
35
+ */
36
+ export declare function checkBlockedCommand(command: string): string | null;
37
+ /**
38
+ * Normalize a path to be absolute within the project root.
39
+ * Empty string and '/' both resolve to projectRoot.
40
+ * Rejects paths that escape via traversal or absolute paths outside root.
41
+ *
42
+ * @param path - Relative or absolute path inside the workspace.
43
+ * @param projectRoot - Absolute filesystem root for the active project.
44
+ * @returns A normalized absolute path confined to `projectRoot`.
45
+ */
46
+ export declare function resolvePath(path: string, projectRoot: string): string;
47
+ /**
48
+ * Validate that a glob/include pattern is safe (no shell metacharacters that could
49
+ * inject). Allows alphanumeric, `* ? . _ - /` and the bracket/paren glob chars `[] ()`.
50
+ *
51
+ * The brackets/parens matter for real frameworks: Next.js App Router names route
52
+ * directories `[id]`, `[...slug]`, `(group)`, `[[...optional]]`, so without them the
53
+ * executor cannot `find_files`/`search_files` its own routes on any Next.js project — a
54
+ * hard block observed on live imports. They are injection-safe here because every caller
55
+ * passes the pattern through `shellQuote` before it reaches `find -name`/`grep --include`,
56
+ * where inside single quotes `[]()` are literal (a subshell `(...)` only starts UNquoted);
57
+ * to the glob engine `[abc]` is a normal character class. The genuinely dangerous
58
+ * metacharacters (`; | & $ \` > < \n` space) remain disallowed.
59
+ *
60
+ * @param pattern - User-supplied glob fragment for search/list operations.
61
+ * @returns `true` when the pattern contains only allowed characters.
62
+ */
63
+ export declare function isValidGlob(pattern: string): boolean;
64
+ /**
65
+ * Validate a file tool's `path` argument is a non-empty string. A weak model
66
+ * sometimes omits it or passes a non-string, which would otherwise crash
67
+ * `resolvePath` (`path.replace` on undefined) with the cryptic, unactionable
68
+ * "Cannot read properties of undefined (reading 'replace')" — wasting executor
69
+ * turns. Returns an actionable message, or null when the path is usable.
70
+ *
71
+ * @param path - The raw `path` argument from the tool input.
72
+ * @param tool - The tool name, for the error message (e.g. 'read_file').
73
+ * @returns An actionable error string, or null when `path` is a non-empty string.
74
+ */
75
+ export declare function pathArgError(path: unknown, tool: string): string | null;
76
+ /**
77
+ * Detect a "read/edit targeted a directory, not a file" failure from a backend
78
+ * error message (local fs `EISDIR` or the sandbox's `cat: X: Is a directory`),
79
+ * and return an actionable message steering the model to `list_files`. Returns
80
+ * null when the error is not a directory error.
81
+ *
82
+ * @param message - The backend error message.
83
+ * @param path - The resolved path that was targeted.
84
+ * @returns An actionable directory-error string, or null.
85
+ */
86
+ export declare function directoryReadHint(message: string, path: string): string | null;
87
+ /** Max file size for read_file (5MB). */
88
+ export declare const MAX_READ_SIZE: number;
89
+ /** Max content size for write_file (10MB). */
90
+ export declare const MAX_WRITE_SIZE: number;
91
+ /** Max command output size (100KB per stream). */
92
+ export declare const MAX_OUTPUT_SIZE: number;
93
+ /** Max search results. */
94
+ export declare const MAX_SEARCH_RESULTS = 50;
95
+ /** Max find results. */
96
+ export declare const MAX_FIND_RESULTS = 100;
97
+ /**
98
+ * Default directory names `search_files`/`find_files` skip — VS Code's
99
+ * `search.exclude` + `files.exclude` defaults (node_modules, bower_components,
100
+ * VCS dirs) plus the platform's vendored/build dirs. Overridable per consumer
101
+ * via `ToolBuildConfig.searchExcludedDirs` (a per-project, user-editable
102
+ * setting in molecule.dev — keep the APP-SIDE copy in
103
+ * `@molecule/app-ide-react`'s search types in sync with this list).
104
+ */
105
+ export declare const DEFAULT_SEARCH_EXCLUDED_DIRS: readonly ["node_modules", "bower_components", ".git", ".svn", ".hg", "CVS", "dist", ".next", ".vite", "molecule"];
106
+ /**
107
+ * Truncate a string to a max length with a truncation notice.
108
+ *
109
+ * @param s - Arbitrary text to bound in size.
110
+ * @param maxLength - Maximum number of characters to retain before truncating.
111
+ * @returns Either the original string or a shortened copy with a trailing notice.
112
+ */
113
+ export declare function truncate(s: string, maxLength: number): string;
114
+ /**
115
+ * Truncate keeping BOTH the head and the tail, eliding the middle — for command
116
+ * output (build / test / migration / install logs). Plain head truncation
117
+ * ({@link truncate}) drops the TAIL, which is exactly where a failing command puts
118
+ * the reason: the `npm ERR!` line, the test-failure summary (`1 failed, 240
119
+ * passed`), the migration stack trace. When that is cut, the executor sees only
120
+ * passing progress and can't tell WHY the command failed — a self-inflicted error
121
+ * then survives every fix round. The head still shows what ran and the first
122
+ * errors; the split is weighted toward the tail since the summary lives there.
123
+ * No-op when `s` already fits.
124
+ *
125
+ * @param s - Arbitrary text (typically stdout/stderr) to bound in size.
126
+ * @param maxLength - Maximum characters to retain (excluding the elision notice).
127
+ * @returns The original string, or head + an elision notice + tail.
128
+ */
129
+ export declare function truncateMiddle(s: string, maxLength: number): string;
130
+ /**
131
+ * Attempt a whitespace-tolerant replacement when an exact `old_string` match
132
+ * failed. Finds a contiguous run of lines in `content` whose per-line
133
+ * whitespace-normalized form (runs of whitespace collapsed to one space, then
134
+ * trimmed) equals the normalized `oldString` lines, and replaces that run with
135
+ * `newString` verbatim. Applies ONLY when exactly one such run exists —
136
+ * uniqueness keeps it safe; an ambiguous (or zero) match is refused (returns
137
+ * null) so the caller falls back to its existing error path.
138
+ *
139
+ * This rescues the most common edit_file failure: a (weak) executor reproduces
140
+ * the target text correctly but with different indentation or trailing
141
+ * whitespace, which would otherwise bounce it into a re-read/retry loop — the
142
+ * single biggest source of wasted edit turns.
143
+ *
144
+ * @param content - Current file content.
145
+ * @param oldString - The search text (an exact match has already failed).
146
+ * @param newString - The replacement text, applied verbatim.
147
+ * @returns The new content if a unique fuzzy run matched, else null.
148
+ */
149
+ export declare function whitespaceTolerantReplace(content: string, oldString: string, newString: string): string | null;
150
+ //# sourceMappingURL=utilities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,GAAG,MAAM,KAAG,MAEE,CAAA;AAyDhD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK/C;AAaD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgBlE;AAID;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAQrE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIvE;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI9E;AAID,yCAAyC;AACzC,eAAO,MAAM,aAAa,QAAkB,CAAA;AAC5C,8CAA8C;AAC9C,eAAO,MAAM,cAAc,QAAmB,CAAA;AAC9C,kDAAkD;AAClD,eAAO,MAAM,eAAe,QAAa,CAAA;AACzC,0BAA0B;AAC1B,eAAO,MAAM,kBAAkB,KAAK,CAAA;AACpC,wBAAwB;AACxB,eAAO,MAAM,gBAAgB,MAAM,CAAA;AAEnC;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,mHAW/B,CAAA;AAEV;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAmBnE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAyBf"}
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Shared utilities for AI agent tools.
3
+ * Extracted from molecule-dev/api/src/ai/tools.ts for reuse.
4
+ *
5
+ * @module
6
+ */
7
+ import { posix } from 'path';
8
+ /**
9
+ * Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes),
10
+ * single-quoted strings prevent command substitution ($(), backticks) and variable expansion.
11
+ *
12
+ * @param s - Raw string to wrap for POSIX shell single-quoted context.
13
+ * @returns A single-quoted shell literal representing `s`.
14
+ */
15
+ export function shellQuote(s) {
16
+ // Defensive: a tool handler passing a missing arg (undefined) would otherwise throw
17
+ // the cryptic "Cannot read properties of undefined (reading 'replace')". Fail with a
18
+ // clear message so the surrounding handler's catch reports something actionable.
19
+ if (typeof s !== 'string')
20
+ throw new Error(`shellQuote expected a string, received ${s === undefined ? 'undefined' : typeof s}`);
21
+ return "'" + s.replace(/'/g, "'\\''") + "'";
22
+ }
23
+ /**
24
+ * Strip C0 control chars (except tab, newline, CR) that break PostgreSQL JSONB
25
+ * and can cause rendering issues.
26
+ *
27
+ * @param s - Arbitrary text that may contain disallowed control characters.
28
+ * @returns A copy of `s` with unsafe control characters removed.
29
+ */
30
+ export const stripControlChars = (s) =>
31
+ // eslint-disable-next-line no-control-regex -- strip C0 controls except tab/LF/CR
32
+ s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
33
+ // ── Secret redaction ──────────────────────────────────────────────────────────
34
+ /** Keywords that indicate a secret/credential when part of an env var name. */
35
+ // Keep aligned with the vault's secret classifier (isSecretKey): a key the vault
36
+ // encrypts must also be masked here, or a decrypted secret read from a project's
37
+ // .env egresses UNMASKED into stored AI transcripts (a store with different
38
+ // access controls than the vault). PWD (DB_PWD/MYSQL_PWD), APIKEY (no underscore,
39
+ // e.g. MAILGUN_APIKEY), and SERVICE_ACCOUNT were the gaps; `_KEY` already covers
40
+ // OPENAI_KEY / *_ROLE_KEY.
41
+ const SECRET_KEYWORDS = 'SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_KEY|APIKEY|PRIVATE_KEY|DATABASE_URL|REDIS_URL|AUTH|CREDENTIAL|ACCESS_KEY|SIGNING_KEY|ENCRYPTION_KEY|CONNECTION_STRING|SERVICE_ACCOUNT|DSN|SMTP_PASS|_KEY';
42
+ const SECRET_KEY_PATTERN = new RegExp(`^(.*(?:${SECRET_KEYWORDS})[A-Z0-9_]*)=(.+)$`, 'gim');
43
+ /** Catch JSON-formatted env dumps like { KEY: 'value' } from node/python. */
44
+ const SECRET_JSON_DQ = new RegExp(`(['"]?(?:\\w*(?:${SECRET_KEYWORDS})\\w*)['"]?\\s*[:=]\\s*)"(?:[^"\\\\]|\\\\.)*"`, 'gi');
45
+ const SECRET_JSON_SQ = new RegExp(`(['"]?(?:\\w*(?:${SECRET_KEYWORDS})\\w*)['"]?\\s*[:=]\\s*)'(?:[^'\\\\]|\\\\.)*'`, 'gi');
46
+ /**
47
+ * Exact VALUES that are UI vocabulary, never credentials — kept verbatim even when they
48
+ * sit next to a keyword-looking name. Frontend auth code is full of these: a JSX ternary
49
+ * `autoComplete={mode === "signup" ? "new-password" : "current-password"}` parses to the
50
+ * JSON patterns as name `…password"` : value `"current-password"` and was redacted to
51
+ * `"[REDACTED]"` — corrupting what the model reads back from its own auth files (and any
52
+ * edit_file old_string built from that read can never match).
53
+ */
54
+ const NON_SECRET_VALUES = new Set([
55
+ 'current-password',
56
+ 'new-password',
57
+ 'one-time-code',
58
+ 'webauthn',
59
+ 'password',
60
+ 'username',
61
+ ]);
62
+ /**
63
+ * Replacer for the JSON-style keyword patterns: masks the quoted value unless it is a
64
+ * known non-secret UI token ({@link NON_SECRET_VALUES}).
65
+ *
66
+ * @param quote - The quote character the value uses (`"` or `'`).
67
+ * @returns A String.replace replacer preserving allowlisted values.
68
+ */
69
+ const jsonValueReplacer = (quote) => (match, prefix) => {
70
+ const value = match.slice(prefix.length);
71
+ const inner = value.slice(1, -1);
72
+ if (NON_SECRET_VALUES.has(inner.toLowerCase()))
73
+ return match;
74
+ return `${prefix}${quote}[REDACTED]${quote}`;
75
+ };
76
+ /**
77
+ * Redact values of common secret/credential patterns in text output.
78
+ *
79
+ * @param s - Log or command output that may contain `.env`-style secrets.
80
+ * @returns A redacted copy safe to surface to end users or models.
81
+ */
82
+ export function redactSecrets(s) {
83
+ return s
84
+ .replace(SECRET_KEY_PATTERN, '$1=[REDACTED]')
85
+ .replace(SECRET_JSON_DQ, jsonValueReplacer('"'))
86
+ .replace(SECRET_JSON_SQ, jsonValueReplacer("'"));
87
+ }
88
+ // ── Command blocking ──────────────────────────────────────────────────────────
89
+ /** Commands that dump environment variables — blocked to prevent secret leakage. */
90
+ const BLOCKED_COMMANDS = /(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv\b|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ)/i;
91
+ /** Block shell redirects from /proc environ. */
92
+ const BLOCKED_PROC_REDIRECT = /(?:<\s*\/proc\/(?:\d+|self)\/environ)/i;
93
+ /** Interpreter-based env dumping (python, node, ruby, perl). */
94
+ const BLOCKED_INTERPRETER_ENV = /(?:python[23]?|node|ruby|perl)\s+(?:-e|-c)\s+[^\n]*(?:os\.environ|process\.env|ENV\[|%ENV|ENVIRON)/i;
95
+ /**
96
+ * Check if a command is blocked for security reasons. Returns error message or null if allowed.
97
+ *
98
+ * @param command - Shell command string proposed for execution.
99
+ * @returns A human-readable block reason, or `null` when the command is allowed.
100
+ */
101
+ export function checkBlockedCommand(command) {
102
+ // These blocks fire when the executor tries to DUMP the environment (usually to discover
103
+ // what managed services are configured). Don't just refuse — redirect: the values are
104
+ // already in the process environment, so it never needs to print them.
105
+ const envDumpSteer = ' You do NOT need to dump it: the managed service values (DATABASE_URL, etc.) are already ' +
106
+ 'in the process environment — read them IN CODE via process.env / your config loader, or ' +
107
+ "check the project's provisioned env file to see WHICH services exist. Printing them only " +
108
+ 'leaks secrets into the transcript.';
109
+ if (BLOCKED_COMMANDS.test(command))
110
+ return `Command blocked: dumping environment variables is not allowed.${envDumpSteer}`;
111
+ if (BLOCKED_PROC_REDIRECT.test(command))
112
+ return 'Command blocked: /proc/environ access is not allowed.' + envDumpSteer;
113
+ if (BLOCKED_INTERPRETER_ENV.test(command))
114
+ return `Command blocked: dumping the environment from an interpreter is not allowed.${envDumpSteer}`;
115
+ return null;
116
+ }
117
+ // ── Path resolution ───────────────────────────────────────────────────────────
118
+ /**
119
+ * Normalize a path to be absolute within the project root.
120
+ * Empty string and '/' both resolve to projectRoot.
121
+ * Rejects paths that escape via traversal or absolute paths outside root.
122
+ *
123
+ * @param path - Relative or absolute path inside the workspace.
124
+ * @param projectRoot - Absolute filesystem root for the active project.
125
+ * @returns A normalized absolute path confined to `projectRoot`.
126
+ */
127
+ export function resolvePath(path, projectRoot) {
128
+ if (path === '' || path === '/')
129
+ return projectRoot;
130
+ const clean = path.replace(/\0/g, '');
131
+ const resolved = clean.startsWith('/')
132
+ ? posix.normalize(clean)
133
+ : posix.normalize(`${projectRoot}/${clean}`);
134
+ if (resolved !== projectRoot && !resolved.startsWith(projectRoot + '/'))
135
+ return projectRoot;
136
+ return resolved;
137
+ }
138
+ /**
139
+ * Validate that a glob/include pattern is safe (no shell metacharacters that could
140
+ * inject). Allows alphanumeric, `* ? . _ - /` and the bracket/paren glob chars `[] ()`.
141
+ *
142
+ * The brackets/parens matter for real frameworks: Next.js App Router names route
143
+ * directories `[id]`, `[...slug]`, `(group)`, `[[...optional]]`, so without them the
144
+ * executor cannot `find_files`/`search_files` its own routes on any Next.js project — a
145
+ * hard block observed on live imports. They are injection-safe here because every caller
146
+ * passes the pattern through `shellQuote` before it reaches `find -name`/`grep --include`,
147
+ * where inside single quotes `[]()` are literal (a subshell `(...)` only starts UNquoted);
148
+ * to the glob engine `[abc]` is a normal character class. The genuinely dangerous
149
+ * metacharacters (`; | & $ \` > < \n` space) remain disallowed.
150
+ *
151
+ * @param pattern - User-supplied glob fragment for search/list operations.
152
+ * @returns `true` when the pattern contains only allowed characters.
153
+ */
154
+ export function isValidGlob(pattern) {
155
+ return /^[A-Za-z0-9*?._/()[\]-]+$/.test(pattern);
156
+ }
157
+ /**
158
+ * Validate a file tool's `path` argument is a non-empty string. A weak model
159
+ * sometimes omits it or passes a non-string, which would otherwise crash
160
+ * `resolvePath` (`path.replace` on undefined) with the cryptic, unactionable
161
+ * "Cannot read properties of undefined (reading 'replace')" — wasting executor
162
+ * turns. Returns an actionable message, or null when the path is usable.
163
+ *
164
+ * @param path - The raw `path` argument from the tool input.
165
+ * @param tool - The tool name, for the error message (e.g. 'read_file').
166
+ * @returns An actionable error string, or null when `path` is a non-empty string.
167
+ */
168
+ export function pathArgError(path, tool) {
169
+ if (typeof path !== 'string' || path.trim() === '')
170
+ return `${tool} requires a non-empty "path" argument (a file path relative to the project root, e.g. "api/src/handlers/index.ts").`;
171
+ return null;
172
+ }
173
+ /**
174
+ * Detect a "read/edit targeted a directory, not a file" failure from a backend
175
+ * error message (local fs `EISDIR` or the sandbox's `cat: X: Is a directory`),
176
+ * and return an actionable message steering the model to `list_files`. Returns
177
+ * null when the error is not a directory error.
178
+ *
179
+ * @param message - The backend error message.
180
+ * @param path - The resolved path that was targeted.
181
+ * @returns An actionable directory-error string, or null.
182
+ */
183
+ export function directoryReadHint(message, path) {
184
+ if (/EISDIR|is a directory/i.test(message))
185
+ return `${path} is a directory, not a file. Use list_files to see its contents, then read_file a specific file inside it.`;
186
+ return null;
187
+ }
188
+ // ── Output truncation ─────────────────────────────────────────────────────────
189
+ /** Max file size for read_file (5MB). */
190
+ export const MAX_READ_SIZE = 5 * 1024 * 1024;
191
+ /** Max content size for write_file (10MB). */
192
+ export const MAX_WRITE_SIZE = 10 * 1024 * 1024;
193
+ /** Max command output size (100KB per stream). */
194
+ export const MAX_OUTPUT_SIZE = 100 * 1024;
195
+ /** Max search results. */
196
+ export const MAX_SEARCH_RESULTS = 50;
197
+ /** Max find results. */
198
+ export const MAX_FIND_RESULTS = 100;
199
+ /**
200
+ * Default directory names `search_files`/`find_files` skip — VS Code's
201
+ * `search.exclude` + `files.exclude` defaults (node_modules, bower_components,
202
+ * VCS dirs) plus the platform's vendored/build dirs. Overridable per consumer
203
+ * via `ToolBuildConfig.searchExcludedDirs` (a per-project, user-editable
204
+ * setting in molecule.dev — keep the APP-SIDE copy in
205
+ * `@molecule/app-ide-react`'s search types in sync with this list).
206
+ */
207
+ export const DEFAULT_SEARCH_EXCLUDED_DIRS = [
208
+ 'node_modules',
209
+ 'bower_components',
210
+ '.git',
211
+ '.svn',
212
+ '.hg',
213
+ 'CVS',
214
+ 'dist',
215
+ '.next',
216
+ '.vite',
217
+ 'molecule',
218
+ ];
219
+ /**
220
+ * Truncate a string to a max length with a truncation notice.
221
+ *
222
+ * @param s - Arbitrary text to bound in size.
223
+ * @param maxLength - Maximum number of characters to retain before truncating.
224
+ * @returns Either the original string or a shortened copy with a trailing notice.
225
+ */
226
+ export function truncate(s, maxLength) {
227
+ if (s.length <= maxLength)
228
+ return s;
229
+ return s.substring(0, maxLength) + '\n\n... (truncated)';
230
+ }
231
+ /**
232
+ * Truncate keeping BOTH the head and the tail, eliding the middle — for command
233
+ * output (build / test / migration / install logs). Plain head truncation
234
+ * ({@link truncate}) drops the TAIL, which is exactly where a failing command puts
235
+ * the reason: the `npm ERR!` line, the test-failure summary (`1 failed, 240
236
+ * passed`), the migration stack trace. When that is cut, the executor sees only
237
+ * passing progress and can't tell WHY the command failed — a self-inflicted error
238
+ * then survives every fix round. The head still shows what ran and the first
239
+ * errors; the split is weighted toward the tail since the summary lives there.
240
+ * No-op when `s` already fits.
241
+ *
242
+ * @param s - Arbitrary text (typically stdout/stderr) to bound in size.
243
+ * @param maxLength - Maximum characters to retain (excluding the elision notice).
244
+ * @returns The original string, or head + an elision notice + tail.
245
+ */
246
+ export function truncateMiddle(s, maxLength) {
247
+ if (s.length <= maxLength)
248
+ return s;
249
+ // Reserve room for the elision notice so head + notice + tail stays WITHIN
250
+ // maxLength — the cap is a real token budget the caller relies on (a head-only
251
+ // truncate that overshoots by its suffix length breaks that contract). 140 =
252
+ // the fixed notice text (~124) + up to ~12 digits for the omitted count.
253
+ const noticeReserve = 140;
254
+ // Cap too small to fit the notice + any head/tail → plain head slice (still
255
+ // within maxLength; the middle-preserving form only helps at real sizes).
256
+ if (maxLength <= noticeReserve)
257
+ return s.slice(0, maxLength);
258
+ const budget = maxLength - noticeReserve;
259
+ const headLen = Math.floor(budget * 0.4);
260
+ const tailLen = budget - headLen;
261
+ const omitted = s.length - headLen - tailLen;
262
+ return (s.slice(0, headLen) +
263
+ `\n\n... [middle truncated — ${omitted} chars omitted; the head AND the tail are shown, a failing command's error is usually near the end] ...\n\n` +
264
+ s.slice(s.length - tailLen));
265
+ }
266
+ /**
267
+ * Attempt a whitespace-tolerant replacement when an exact `old_string` match
268
+ * failed. Finds a contiguous run of lines in `content` whose per-line
269
+ * whitespace-normalized form (runs of whitespace collapsed to one space, then
270
+ * trimmed) equals the normalized `oldString` lines, and replaces that run with
271
+ * `newString` verbatim. Applies ONLY when exactly one such run exists —
272
+ * uniqueness keeps it safe; an ambiguous (or zero) match is refused (returns
273
+ * null) so the caller falls back to its existing error path.
274
+ *
275
+ * This rescues the most common edit_file failure: a (weak) executor reproduces
276
+ * the target text correctly but with different indentation or trailing
277
+ * whitespace, which would otherwise bounce it into a re-read/retry loop — the
278
+ * single biggest source of wasted edit turns.
279
+ *
280
+ * @param content - Current file content.
281
+ * @param oldString - The search text (an exact match has already failed).
282
+ * @param newString - The replacement text, applied verbatim.
283
+ * @returns The new content if a unique fuzzy run matched, else null.
284
+ */
285
+ export function whitespaceTolerantReplace(content, oldString, newString) {
286
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
287
+ const fileLines = content.split('\n');
288
+ const normOld = oldString.split('\n').map(norm);
289
+ // Refuse a degenerate all-blank search block (would match any blank run).
290
+ if (normOld.length === 0 || normOld.every((l) => l === ''))
291
+ return null;
292
+ const matches = [];
293
+ for (let i = 0; i + normOld.length <= fileLines.length; i++) {
294
+ let ok = true;
295
+ for (let j = 0; j < normOld.length; j++) {
296
+ if (norm(fileLines[i + j]) !== normOld[j]) {
297
+ ok = false;
298
+ break;
299
+ }
300
+ }
301
+ if (ok) {
302
+ matches.push(i);
303
+ if (matches.length > 1)
304
+ return null; // ambiguous — refuse
305
+ }
306
+ }
307
+ if (matches.length !== 1)
308
+ return null;
309
+ const start = matches[0];
310
+ return [...fileLines.slice(0, start), newString, ...fileLines.slice(start + normOld.length)].join('\n');
311
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@molecule/api-ai-tools",
3
+ "version": "1.0.0",
4
+ "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "keywords": [
23
+ "molecule",
24
+ "ai",
25
+ "tools",
26
+ "agent"
27
+ ],
28
+ "license": "Apache-2.0",
29
+ "peerDependencies": {
30
+ "@molecule/api-ai": "^1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@molecule/api-ai": "1.0.0",
34
+ "@types/node": "26.1.2",
35
+ "typescript": "6.0.3",
36
+ "vitest": "4.1.10"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/molecule-dev/molecule.git",
41
+ "directory": "packages/api/core/ai-tools"
42
+ },
43
+ "homepage": "https://github.com/molecule-dev/molecule/tree/main/packages/api/core/ai-tools",
44
+ "bugs": "https://github.com/molecule-dev/molecule/issues",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }