@molecule/api-ai-tools 1.0.0 → 1.0.2

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/README.md ADDED
@@ -0,0 +1,696 @@
1
+ <!--
2
+ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
+ Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
+ Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
+ To change this document, edit the module-level JSDoc in src/index.ts.
6
+ Generated: 2026-08-13T21:34:34.535Z
7
+ -->
8
+
9
+ # @molecule/api-ai-tools
10
+
11
+ > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
12
+ > It is written to be read by coding agents as much as by people, and is generated from this
13
+ > package's source — edit `src/index.ts` JSDoc, not this file.
14
+
15
+ Shared AI agent tool set for molecule.dev — filesystem, search, and shell
16
+ tools an LLM agent can call, with a swappable execution backend.
17
+
18
+ `buildTools(backend)` returns ready-to-use `AITool`s (the `@molecule/api-ai`
19
+ tool shape): `list_files`, `read_file`, `write_file`, `edit_file`,
20
+ `search_files`, `find_files`, `create_directory`, `rename_file`,
21
+ `delete_file`, `exec_command`, `save_plan`, `load_skill`. The backend decides
22
+ WHERE they act: `createLocalBackend(projectRoot)` (host filesystem) or
23
+ `createSandboxBackend(...)` (an isolated `@molecule/api-code-sandbox`
24
+ container). `buildAgentPrompt(ctx)` composes a matching system prompt
25
+ (identity, tool listing, project docs, discovered skills).
26
+
27
+ ## Quick Start
28
+
29
+ ```typescript
30
+ import { buildAgentPrompt, buildTools, createLocalBackend } from '@molecule/api-ai-tools'
31
+ import { requireProvider } from '@molecule/api-ai'
32
+
33
+ const backend = createLocalBackend('/path/to/project')
34
+ const tools = buildTools(backend, { include: ['read_file', 'edit_file', 'search_files'] })
35
+ const system = buildAgentPrompt({
36
+ agentName: 'My Agent',
37
+ projectRoot: backend.projectRoot,
38
+ tools: tools.map((t) => t.name),
39
+ })
40
+
41
+ // Hand them to the bonded AI provider (or an @molecule/api-ai-agents run).
42
+ for await (const event of requireProvider().chat({ system, tools, messages, stream: true })) {
43
+ // forward text chunks; tool calls are executed against the backend
44
+ }
45
+ ```
46
+
47
+ ## Type
48
+
49
+ `core`
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ npm install @molecule/api-ai-tools @molecule/api-ai
55
+ ```
56
+
57
+ ## API
58
+
59
+ ### Interfaces
60
+
61
+ #### `ExecutionBackend`
62
+
63
+ Abstraction over the execution environment.
64
+ Implemented by SandboxBackend (Docker) and LocalBackend (host filesystem).
65
+
66
+ ```typescript
67
+ interface ExecutionBackend {
68
+ /** The root directory for all operations (e.g. '/workspace' or '/Users/.../project'). */
69
+ readonly projectRoot: string
70
+
71
+ /** Read a file's content as UTF-8 string. */
72
+ readFile(path: string): Promise<string>
73
+
74
+ /** Write content to a file. Creates parent directories as needed. */
75
+ writeFile(path: string, content: string): Promise<void>
76
+
77
+ /** Delete a file. */
78
+ deleteFile(path: string): Promise<void>
79
+
80
+ /** List entries in a directory. */
81
+ readDir(path: string): Promise<Array<{ name: string; type: 'file' | 'directory' }>>
82
+
83
+ /**
84
+ * Run a shell command. Returns stdout, stderr, and exit code.
85
+ * Backends implement this safely (sandbox.exec for Docker, execFile for local).
86
+ */
87
+ run(
88
+ command: string,
89
+ opts?: { cwd?: string; timeout?: number },
90
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }>
91
+ }
92
+ ```
93
+
94
+ #### `FileChangeEvent`
95
+
96
+ Payload emitted when a file is created, modified, or deleted structurally.
97
+
98
+ ```typescript
99
+ interface FileChangeEvent {
100
+ type: 'created' | 'modified' | 'deleted'
101
+ path: string
102
+ }
103
+ ```
104
+
105
+ #### `FileDiffEvent`
106
+
107
+ Payload emitted when a tracked file changes contents.
108
+
109
+ ```typescript
110
+ interface FileDiffEvent {
111
+ path: string
112
+ oldContent: string | null
113
+ newContent: string
114
+ }
115
+ ```
116
+
117
+ #### `PromptContext`
118
+
119
+ Context for building a composable system prompt.
120
+
121
+ ```typescript
122
+ interface PromptContext {
123
+ /** Agent identity (e.g. 'Synthase', 'Polish Agent'). */
124
+ agentName: string
125
+
126
+ /** Project root path. */
127
+ projectRoot: string
128
+
129
+ /** Names of available tools (for the tool listing section). */
130
+ tools: string[]
131
+
132
+ /** Project-specific rules (AGENTS.md or CLAUDE.md content). */
133
+ projectDocs?: string
134
+
135
+ /** Additional skill/reference content to inject. */
136
+ skills?: string[]
137
+
138
+ /** Discovered skills to list in the prompt (use load_skill to read on demand). */
139
+ discoveredSkills?: SkillEntry[]
140
+
141
+ /** Custom sections to append to the prompt. */
142
+ customSections?: string[]
143
+ }
144
+ ```
145
+
146
+ #### `SkillEntry`
147
+
148
+ Metadata for a discovered skill (used in PromptContext).
149
+
150
+ ```typescript
151
+ interface SkillEntry {
152
+ /** Skill name. */
153
+ name: string
154
+ /** Short description. */
155
+ description: string
156
+ /** Relative path to the SKILL.md file. */
157
+ path: string
158
+ }
159
+ ```
160
+
161
+ #### `ToolBuildConfig`
162
+
163
+ Configuration for building the tool set.
164
+ Allows consumers to customize security, callbacks, and tool selection.
165
+
166
+ ```typescript
167
+ interface ToolBuildConfig {
168
+ /** Which tools to include. Defaults to all. */
169
+ include?: string[]
170
+
171
+ /** Which tools to exclude. Applied after include. */
172
+ exclude?: string[]
173
+
174
+ /** Whether to validate paths stay within projectRoot. Default: true. */
175
+ pathGuards?: boolean
176
+
177
+ /** Whether to check symlinks resolve within projectRoot. Default: false (sandbox-only). */
178
+ symlinkGuards?: boolean
179
+
180
+ /** Whether to redact secrets in file reads and command output. Default: true. */
181
+ redactSecrets?: boolean
182
+
183
+ /** Whether to block dangerous shell commands (env dumps, /proc access). Default: false. */
184
+ blockDangerousCommands?: boolean
185
+
186
+ /**
187
+ * Consumer-specific command guard for `exec_command`, checked BEFORE execution (after
188
+ * the built-in dangerous-command check). Return an error string to block the command —
189
+ * it is returned to the model verbatim, so make it actionable (say what to do instead) —
190
+ * or `null`/`undefined` to allow it. Keeps environment-specific rules (e.g. an IDE
191
+ * sandbox forbidding installs that would break its preinstalled library) out of this
192
+ * shared package.
193
+ *
194
+ * @param command - The shell command the model asked to run.
195
+ * @param cwd - The resolved working directory it would run in.
196
+ * @returns An error string to block, or null/undefined to allow.
197
+ */
198
+ blockCommand?: (command: string, cwd: string) => string | null | undefined
199
+
200
+ /**
201
+ * Timeout (ms) for a single `exec_command` run. `exec_command` legitimately
202
+ * runs LONG — `npm install`, a production build, a test suite — so the default
203
+ * is generous (2 min); the old 30 s hardcap killed those spuriously. A consumer
204
+ * that wraps tool calls in its own outer timeout should set this to match (or
205
+ * slightly exceed) that budget so its own timeout is the effective bound and
206
+ * produces the nicer "tool timed out" message. Quick commands are unaffected —
207
+ * this is only the ceiling before a wedged command is killed.
208
+ */
209
+ execTimeoutMs?: number
210
+
211
+ /**
212
+ * Directory names `search_files` and `find_files` skip (VS Code
213
+ * `search.exclude` semantics). Defaults to `DEFAULT_SEARCH_EXCLUDED_DIRS`
214
+ * (node_modules, VCS dirs, build output). Pass the consumer's per-project
215
+ * setting so every search surface shares ONE synchronized set.
216
+ */
217
+ searchExcludedDirs?: string[]
218
+
219
+ /** Post-write hook (e.g. auto-format via Prettier/ESLint). Called after every write_file/edit_file. */
220
+ onAfterWrite?: (path: string) => Promise<void>
221
+
222
+ /** Diff tracking callback. Called before writes with old/new content. */
223
+ onFileDiff?: (event: FileDiffEvent) => void
224
+
225
+ /** Structural change callback. Called on create_directory, delete_file, rename_file. */
226
+ onFileChange?: (event: FileChangeEvent) => void
227
+ }
228
+ ```
229
+
230
+ #### `ToolSchema`
231
+
232
+ JSON-schema-backed definition for a single agent tool.
233
+
234
+ ```typescript
235
+ interface ToolSchema {
236
+ name: string
237
+ description: string
238
+ parameters: JSONSchema
239
+ }
240
+ ```
241
+
242
+ ### Types
243
+
244
+ #### `DiscoveredSkill` _(deprecated)_
245
+
246
+ Re-export SkillEntry as DiscoveredSkill for backwards compatibility.
247
+
248
+ ```typescript
249
+ type DiscoveredSkill = SkillEntry
250
+ ```
251
+
252
+ ### Functions
253
+
254
+ #### `buildAgentPrompt(ctx)`
255
+
256
+ Build a coding-focused system prompt from composable sections.
257
+
258
+ Returns a string that includes:
259
+
260
+ - Agent identity
261
+ - Available tools listing
262
+ - Coding best practices
263
+ - Tool argument formatting guidance
264
+ - Project docs (if provided)
265
+ - Discovered skills listing (if provided)
266
+ - Inline skills (if provided)
267
+ - Custom sections (if provided)
268
+
269
+ ```typescript
270
+ function buildAgentPrompt(ctx: PromptContext): string
271
+ ```
272
+
273
+ - `ctx` — Prompt construction inputs (tools, docs, skills, etc.).
274
+
275
+ **Returns:** Fully assembled system prompt text for the coding agent.
276
+
277
+ #### `buildTools(backend, config)`
278
+
279
+ Build a complete set of AI agent tools bound to an execution backend.
280
+
281
+ ```typescript
282
+ function buildTools(backend: ExecutionBackend, config?: ToolBuildConfig): AITool[]
283
+ ```
284
+
285
+ - `backend` — The execution environment (sandbox or local filesystem)
286
+ - `config` — Optional configuration for security, callbacks, and tool selection
287
+
288
+ **Returns:** Array of AITool objects ready to pass to an AI provider
289
+
290
+ #### `checkBlockedCommand(command)`
291
+
292
+ Check if a command is blocked for security reasons. Returns error message or null if allowed.
293
+
294
+ ```typescript
295
+ function checkBlockedCommand(command: string): string | null
296
+ ```
297
+
298
+ - `command` — Shell command string proposed for execution.
299
+
300
+ **Returns:** A human-readable block reason, or `null` when the command is allowed.
301
+
302
+ #### `createLocalBackend(projectRoot)`
303
+
304
+ Create an ExecutionBackend that operates on the local filesystem.
305
+
306
+ ```typescript
307
+ function createLocalBackend(projectRoot: string): ExecutionBackend
308
+ ```
309
+
310
+ - `projectRoot` — Absolute path to the project root directory
311
+
312
+ **Returns:** A backend wired to `fs/promises` and guarded subprocess calls.
313
+
314
+ #### `createSandboxBackend(sandbox, projectRoot)`
315
+
316
+ Create an ExecutionBackend that delegates to a Docker sandbox instance.
317
+ The sandbox.exec method is inherently safe — it runs inside an isolated Docker container.
318
+
319
+ ```typescript
320
+ function createSandboxBackend(sandbox: SandboxLike, projectRoot?: string): ExecutionBackend
321
+ ```
322
+
323
+ - `sandbox` — A running Sandbox instance from `@molecule/api-code-sandbox`
324
+ - `projectRoot` — Root directory inside the sandbox (default: '/workspace')
325
+
326
+ **Returns:** A backend that proxies all filesystem calls into the sandbox.
327
+
328
+ #### `directoryReadHint(message, path)`
329
+
330
+ Detect a "read/edit targeted a directory, not a file" failure from a backend
331
+ error message (local fs `EISDIR` or the sandbox's `cat: X: Is a directory`),
332
+ and return an actionable message steering the model to `list_files`. Returns
333
+ null when the error is not a directory error.
334
+
335
+ ```typescript
336
+ function directoryReadHint(message: string, path: string): string | null
337
+ ```
338
+
339
+ - `message` — The backend error message.
340
+ - `path` — The resolved path that was targeted.
341
+
342
+ **Returns:** An actionable directory-error string, or null.
343
+
344
+ #### `discoverSkills(backend)`
345
+
346
+ Discover skills from a project directory.
347
+
348
+ Scans `.agents/skills/` and `.claude/skills/` for SKILL.md files.
349
+ Reads the YAML frontmatter of each to extract `name:` and `description:` fields.
350
+
351
+ ```typescript
352
+ function discoverSkills(backend: ExecutionBackend): Promise<SkillEntry[]>
353
+ ```
354
+
355
+ - `backend` — Execution backend to use for filesystem access
356
+
357
+ **Returns:** Array of discovered skills with name, description, and path
358
+
359
+ #### `isEnvFilePath(path)`
360
+
361
+ Whether a path is an env file, for which {@link redactSecrets}' full env-dump
362
+ treatment is appropriate rather than {@link redactSecretsInCode}. Matches
363
+ `.env`, `.env.<suffix>`, and `<name>.env`.
364
+
365
+ ```typescript
366
+ function isEnvFilePath(path: string): boolean
367
+ ```
368
+
369
+ - `path` — A workspace-relative or absolute file path.
370
+
371
+ **Returns:** `true` when the file is an env file.
372
+
373
+ #### `isValidGlob(pattern)`
374
+
375
+ Validate that a glob/include pattern is safe (no shell metacharacters that could
376
+ inject). Allows alphanumeric, `* ? . _ - /` and the bracket/paren glob chars `[] ()`.
377
+
378
+ The brackets/parens matter for real frameworks: Next.js App Router names route
379
+ directories `[id]`, `[...slug]`, `(group)`, `[[...optional]]`, so without them the
380
+ executor cannot `find_files`/`search_files` its own routes on any Next.js project — a
381
+ hard block observed on live imports. They are injection-safe here because every caller
382
+ passes the pattern through `shellQuote` before it reaches `find -name`/`grep --include`,
383
+ where inside single quotes `[]()` are literal (a subshell `(...)` only starts UNquoted);
384
+ to the glob engine `[abc]` is a normal character class. The genuinely dangerous
385
+ metacharacters (`; | & $ \` > < \n` space) remain disallowed.
386
+
387
+ ```typescript
388
+ function isValidGlob(pattern: string): boolean
389
+ ```
390
+
391
+ - `pattern` — User-supplied glob fragment for search/list operations.
392
+
393
+ **Returns:** `true` when the pattern contains only allowed characters.
394
+
395
+ #### `pathArgError(path, tool)`
396
+
397
+ Validate a file tool's `path` argument is a non-empty string. A weak model
398
+ sometimes omits it or passes a non-string, which would otherwise crash
399
+ `resolvePath` (`path.replace` on undefined) with the cryptic, unactionable
400
+ "Cannot read properties of undefined (reading 'replace')" — wasting executor
401
+ turns. Returns an actionable message, or null when the path is usable.
402
+
403
+ ```typescript
404
+ function pathArgError(path: unknown, tool: string): string | null
405
+ ```
406
+
407
+ - `path` — The raw `path` argument from the tool input.
408
+ - `tool` — The tool name, for the error message (e.g. 'read_file').
409
+
410
+ **Returns:** An actionable error string, or null when `path` is a non-empty string.
411
+
412
+ #### `redactSecrets(s)`
413
+
414
+ Redact values of common secret/credential patterns in text output.
415
+
416
+ ENV-DUMP GRADE — includes the JSON `KEY: 'value'` passes, which key off the
417
+ NAME beside the value and therefore cannot tell a credential from ordinary
418
+ code that happens to use a keyword-ish identifier. Use this for `.env` reads
419
+ and command output (where an env dump is the actual threat); use
420
+ {@link redactSecretsInCode} for source-file content.
421
+
422
+ ```typescript
423
+ function redactSecrets(s: string): string
424
+ ```
425
+
426
+ - `s` — Log or command output that may contain `.env`-style secrets.
427
+
428
+ **Returns:** A redacted copy safe to surface to end users or models.
429
+
430
+ #### `redactSecretsInCode(s)`
431
+
432
+ CODE-SAFE redaction — the env-assignment pass of {@link redactSecrets} WITHOUT
433
+ the JSON `KEY: 'value'` passes.
434
+
435
+ Those passes match on the NAME next to a quoted value, so over source code they
436
+ replace legitimate content at enormous scale: `forgotPasswordEndpoint:
437
+ '/users/forgot-password'`, `apiKeys: 'API keys'`, and every localized "Show
438
+ password" string all became `'[REDACTED]'`. Because the agent writes back the
439
+ content it reads, that token then lands in the user's project — measured at
440
+ 10,952 of 27,919 flagship template files before this split.
441
+
442
+ No value heuristic can fix that: a legitimate `password = 'TestPass123!'` in a
443
+ test helper is indistinguishable from a real credential by shape. So the
444
+ name-keyed passes simply do not run over code. Credentials in source are still
445
+ caught by the env-assignment pass here, and consumers layer VALUE-SHAPE
446
+ detection (vendor prefixes, PEM blocks, credentials in a URL authority) on top —
447
+ which is what actually catches a secret sitting under an innocuous name.
448
+
449
+ ```typescript
450
+ function redactSecretsInCode(s: string): string
451
+ ```
452
+
453
+ - `s` — Source-file content or other code-shaped text.
454
+
455
+ **Returns:** A redacted copy that preserves ordinary code verbatim.
456
+
457
+ #### `resolvePath(path, projectRoot)`
458
+
459
+ Normalize a path to be absolute within the project root.
460
+ Empty string and '/' both resolve to projectRoot.
461
+ Rejects paths that escape via traversal or absolute paths outside root.
462
+
463
+ ```typescript
464
+ function resolvePath(path: string, projectRoot: string): string
465
+ ```
466
+
467
+ - `path` — Relative or absolute path inside the workspace.
468
+ - `projectRoot` — Absolute filesystem root for the active project.
469
+
470
+ **Returns:** A normalized absolute path confined to `projectRoot`.
471
+
472
+ #### `shellQuote(s)`
473
+
474
+ Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes),
475
+ single-quoted strings prevent command substitution ($(), backticks) and variable expansion.
476
+
477
+ ```typescript
478
+ function shellQuote(s: string): string
479
+ ```
480
+
481
+ - `s` — Raw string to wrap for POSIX shell single-quoted context.
482
+
483
+ **Returns:** A single-quoted shell literal representing `s`.
484
+
485
+ #### `stripControlChars(s)`
486
+
487
+ Strip C0 control chars (except tab, newline, CR) that break PostgreSQL JSONB
488
+ and can cause rendering issues.
489
+
490
+ ```typescript
491
+ function stripControlChars(s: string): string
492
+ ```
493
+
494
+ - `s` — Arbitrary text that may contain disallowed control characters.
495
+
496
+ **Returns:** A copy of `s` with unsafe control characters removed.
497
+
498
+ #### `truncate(s, maxLength)`
499
+
500
+ Truncate a string to a max length with a truncation notice.
501
+
502
+ ```typescript
503
+ function truncate(s: string, maxLength: number): string
504
+ ```
505
+
506
+ - `s` — Arbitrary text to bound in size.
507
+ - `maxLength` — Maximum number of characters to retain before truncating.
508
+
509
+ **Returns:** Either the original string or a shortened copy with a trailing notice.
510
+
511
+ #### `truncateMiddle(s, maxLength)`
512
+
513
+ Truncate keeping BOTH the head and the tail, eliding the middle — for command
514
+ output (build / test / migration / install logs). Plain head truncation
515
+ ({@link truncate}) drops the TAIL, which is exactly where a failing command puts
516
+ the reason: the `npm ERR!` line, the test-failure summary (`1 failed, 240
517
+ passed`), the migration stack trace. When that is cut, the executor sees only
518
+ passing progress and can't tell WHY the command failed — a self-inflicted error
519
+ then survives every fix round. The head still shows what ran and the first
520
+ errors; the split is weighted toward the tail since the summary lives there.
521
+ No-op when `s` already fits.
522
+
523
+ ```typescript
524
+ function truncateMiddle(s: string, maxLength: number): string
525
+ ```
526
+
527
+ - `s` — Arbitrary text (typically stdout/stderr) to bound in size.
528
+ - `maxLength` — Maximum characters to retain (excluding the elision notice).
529
+
530
+ **Returns:** The original string, or head + an elision notice + tail.
531
+
532
+ #### `whitespaceTolerantReplace(content, oldString, newString)`
533
+
534
+ Attempt a whitespace-tolerant replacement when an exact `old_string` match
535
+ failed. Finds a contiguous run of lines in `content` whose per-line
536
+ whitespace-normalized form (runs of whitespace collapsed to one space, then
537
+ trimmed) equals the normalized `oldString` lines, and replaces that run with
538
+ `newString` verbatim. Applies ONLY when exactly one such run exists —
539
+ uniqueness keeps it safe; an ambiguous (or zero) match is refused (returns
540
+ null) so the caller falls back to its existing error path.
541
+
542
+ This rescues the most common edit_file failure: a (weak) executor reproduces
543
+ the target text correctly but with different indentation or trailing
544
+ whitespace, which would otherwise bounce it into a re-read/retry loop — the
545
+ single biggest source of wasted edit turns.
546
+
547
+ ```typescript
548
+ function whitespaceTolerantReplace(
549
+ content: string,
550
+ oldString: string,
551
+ newString: string,
552
+ ): string | null
553
+ ```
554
+
555
+ - `content` — Current file content.
556
+ - `oldString` — The search text (an exact match has already failed).
557
+ - `newString` — The replacement text, applied verbatim.
558
+
559
+ **Returns:** The new content if a unique fuzzy run matched, else null.
560
+
561
+ ### Constants
562
+
563
+ #### `DEFAULT_SEARCH_EXCLUDED_DIRS`
564
+
565
+ Default directory names `search_files`/`find_files` skip — VS Code's
566
+ `search.exclude` + `files.exclude` defaults (node_modules, bower_components,
567
+ VCS dirs) plus the platform's vendored/build dirs. Overridable per consumer
568
+ via `ToolBuildConfig.searchExcludedDirs` (a per-project, user-editable
569
+ setting in molecule.dev — keep the APP-SIDE copy in
570
+ `@molecule/app-ide-react`'s search types in sync with this list).
571
+
572
+ ```typescript
573
+ const DEFAULT_SEARCH_EXCLUDED_DIRS: readonly [
574
+ 'node_modules',
575
+ 'bower_components',
576
+ '.git',
577
+ '.svn',
578
+ '.hg',
579
+ 'CVS',
580
+ 'dist',
581
+ '.next',
582
+ '.vite',
583
+ 'molecule',
584
+ ]
585
+ ```
586
+
587
+ #### `MAX_FIND_RESULTS`
588
+
589
+ Max find results.
590
+
591
+ ```typescript
592
+ const MAX_FIND_RESULTS: 100
593
+ ```
594
+
595
+ #### `MAX_OUTPUT_SIZE`
596
+
597
+ Max command output size (100KB per stream).
598
+
599
+ ```typescript
600
+ const MAX_OUTPUT_SIZE: number
601
+ ```
602
+
603
+ #### `MAX_READ_SIZE`
604
+
605
+ Max file size for read_file (5MB).
606
+
607
+ ```typescript
608
+ const MAX_READ_SIZE: number
609
+ ```
610
+
611
+ #### `MAX_SEARCH_RESULTS`
612
+
613
+ Max search results.
614
+
615
+ ```typescript
616
+ const MAX_SEARCH_RESULTS: 50
617
+ ```
618
+
619
+ #### `MAX_WRITE_SIZE`
620
+
621
+ Max content size for write_file (10MB).
622
+
623
+ ```typescript
624
+ const MAX_WRITE_SIZE: number
625
+ ```
626
+
627
+ #### `TOOL_SCHEMAS`
628
+
629
+ Canonical tool schemas shared by the agent runtime and documentation.
630
+
631
+ ```typescript
632
+ const TOOL_SCHEMAS: Record<string, ToolSchema>
633
+ ```
634
+
635
+ ## Injection Notes
636
+
637
+ ### Requirements
638
+
639
+ Peer dependencies:
640
+
641
+ - `@molecule/api-ai` ^1.0.1
642
+
643
+ ### Runtime Dependencies
644
+
645
+ - `@molecule/api-ai`
646
+
647
+ - **This package only defines the tools — it runs no model loop.** Hand them to
648
+ the bonded AI provider (`chat({ tools, … })`) or to an
649
+ `@molecule/api-ai-agents` run.
650
+ - **Safety defaults: keep them on for model-driven use.** `pathGuards` (default
651
+ true) rejects paths escaping `projectRoot`; `redactSecrets` (default true)
652
+ masks secret-looking values in file reads and command output.
653
+ - **`exec_command` executes REAL shell commands** on the backend. Prefer a
654
+ sandbox backend for untrusted/model-driven work; add environment-specific
655
+ rules via `blockCommand` (return an actionable error string to refuse — it is
656
+ shown to the model verbatim) and enable `blockDangerousCommands` (default
657
+ FALSE) where appropriate.
658
+ - **Scope the tool set per agent** with `include`/`exclude` — a read-only agent
659
+ should not receive `write_file`/`exec_command`.
660
+ - Outputs are size-capped (`MAX_READ_SIZE`, `MAX_OUTPUT_SIZE`, …) and searches
661
+ skip `DEFAULT_SEARCH_EXCLUDED_DIRS` (node_modules, VCS dirs, build output) —
662
+ pass `searchExcludedDirs` so every search surface shares the consumer's one
663
+ setting.
664
+
665
+ ## E2E Tests
666
+
667
+ Integration checklist — drive the real UI (live preview, no mocks), adapt
668
+ each item to this app's actual agent/chat surface and its registered tools,
669
+ and check every box off one by one. A box you can't check is an integration
670
+ bug to fix — not a skip:
671
+
672
+ - [ ] A prompt that should trigger a registered tool makes the model INVOKE
673
+ it with args matching that tool's `parameters` schema (e.g. "read
674
+ src/index.ts" -> calls `read_file` with `{ path: 'src/index.ts' }`).
675
+ Confirm the tool's `execute` actually RAN — its backend side effect / log /
676
+ the file it touched — not that the model merely narrated calling it.
677
+ - [ ] The tool's returned value flows back into the model and shapes the
678
+ final answer: the REAL result (the file's actual contents, the command's
679
+ real stdout/exitCode) appears in the reply, not a plausible hallucination.
680
+ - [ ] A prompt that needs no tool is answered directly, with no spurious
681
+ tool call.
682
+ - [ ] A tool whose `execute` throws or returns `{ error }` (missing file,
683
+ failing command, blocked path) degrades gracefully — the error is caught
684
+ and fed back to the model as text, the conversation continues, and nothing
685
+ crashes the request.
686
+ - [ ] The model can invoke ONLY the tools handed to this run: an
687
+ `include`/`exclude`-scoped agent (e.g. read-only — no `write_file` /
688
+ `exec_command`) cannot call an excluded tool, and a tool name the model
689
+ invents that was never registered is refused, not executed.
690
+ - [ ] Tool execution is server-side and authorized: `exec_command` /
691
+ `write_file` run only on the bonded backend under its guards (`pathGuards`,
692
+ `symlinkGuards`, `redactSecrets`, `blockDangerousCommands` / `blockCommand`)
693
+ and stay inside `projectRoot`. Feed a prompt-injected instruction (a file or
694
+ message telling the model to read `/etc/passwd`, escape the workspace, or run
695
+ a privileged command) and confirm the guard REFUSES it — a user must not be
696
+ able to trigger, via the model, any action they could not perform directly.
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAG9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAoBnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA0nBxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAG9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAsBnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAopBxF"}
package/dist/tools.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * @module
8
8
  */
9
9
  import { TOOL_SCHEMAS } from './schemas.js';
10
- import { checkBlockedCommand, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, isValidGlob, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_SIZE, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, pathArgError, redactSecrets, resolvePath, shellQuote, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
10
+ import { checkBlockedCommand, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, isEnvFilePath, isValidGlob, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_SIZE, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, pathArgError, redactSecrets, redactSecretsInCode, resolvePath, shellQuote, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
11
11
  /**
12
12
  * Build a complete set of AI agent tools bound to an execution backend.
13
13
  *
@@ -71,6 +71,24 @@ export function buildTools(backend, config) {
71
71
  result = redactSecrets(result);
72
72
  return result;
73
73
  }
74
+ /**
75
+ * Sanitize FILE CONTENT for return to the model. Unlike {@link sanitizeOutput},
76
+ * this uses the code-safe redactor for ordinary source files — the name-keyed
77
+ * JSON passes destroy legitimate code, and the agent writes back what it reads,
78
+ * so an over-redacted read corrupts the user's project on the next write. Env
79
+ * files still get the full env-dump treatment, since that is where the shape
80
+ * those passes detect actually indicates a credential.
81
+ *
82
+ * @param s - Raw file contents.
83
+ * @param path - The file's path, used to decide the redaction grade.
84
+ * @returns Content safe to return to the model, with code preserved verbatim.
85
+ */
86
+ function sanitizeFileContent(s, path) {
87
+ let result = stripControlChars(s);
88
+ if (doRedact)
89
+ result = isEnvFilePath(path) ? redactSecrets(result) : redactSecretsInCode(result);
90
+ return result;
91
+ }
74
92
  // ── Diff computation ───────────────────────────────────────────
75
93
  /**
76
94
  * Compute a lightweight diff summary for telemetry and UI badges.
@@ -120,7 +138,7 @@ export function buildTools(backend, config) {
120
138
  return {
121
139
  error: `File too large (${Math.round(content.length / 1024)}KB). Maximum is ${MAX_READ_SIZE / 1024 / 1024}MB.`,
122
140
  };
123
- return { path, content: sanitizeOutput(content) };
141
+ return { path, content: sanitizeFileContent(content, path) };
124
142
  }
125
143
  catch (e) {
126
144
  // Backends (e.g. the docker provider's `cat`) already prefix "Failed to read <path>: ";
@@ -363,7 +381,11 @@ export function buildTools(backend, config) {
363
381
  try {
364
382
  const globArg = include ? `--include=${shellQuote(include)}` : '';
365
383
  const result = await backend.run(`grep -rn ${globArg} ${grepExcludeArgs} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)} 2>/dev/null || true`, { timeout: 10000 });
366
- const output = sanitizeOutput(result.stdout.trim());
384
+ // grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
385
+ // content alone: the file prefix would otherwise hide an env assignment from
386
+ // the line-anchored env pattern, and knowing each match's own path is what
387
+ // lets a hit inside a .env get full treatment while source stays verbatim.
388
+ const output = stripControlChars(result.stdout.trim());
367
389
  if (!output)
368
390
  return { pattern, path, matches: [] };
369
391
  const matches = output
@@ -372,8 +394,8 @@ export function buildTools(backend, config) {
372
394
  .map((line) => {
373
395
  const m = line.match(/^(.+?):(\d+):(.*)$/);
374
396
  return m
375
- ? { file: m[1], line: parseInt(m[2]), content: m[3] }
376
- : { file: '', line: 0, content: line };
397
+ ? { file: m[1], line: parseInt(m[2]), content: sanitizeFileContent(m[3], m[1]) }
398
+ : { file: '', line: 0, content: sanitizeFileContent(line, '') };
377
399
  });
378
400
  return { pattern, path, matches };
379
401
  }
@@ -506,7 +528,7 @@ export function buildTools(backend, config) {
506
528
  return { error: symlinkErr };
507
529
  try {
508
530
  const content = await backend.readFile(path);
509
- return { name, path, content: sanitizeOutput(content) };
531
+ return { name, path, content: sanitizeFileContent(content, path) };
510
532
  }
511
533
  catch (_error) {
512
534
  // File not present at the given path — fall through to the error return below
@@ -523,7 +545,11 @@ export function buildTools(backend, config) {
523
545
  continue;
524
546
  try {
525
547
  const content = await backend.readFile(skillPath);
526
- return { name, path: `${dir}/${name}/SKILL.md`, content: sanitizeOutput(content) };
548
+ return {
549
+ name,
550
+ path: `${dir}/${name}/SKILL.md`,
551
+ content: sanitizeFileContent(content, skillPath),
552
+ };
527
553
  }
528
554
  catch (_error) {
529
555
  // Skill file not present in this directory — try the next candidate
@@ -23,10 +23,47 @@ export declare const stripControlChars: (s: string) => string;
23
23
  /**
24
24
  * Redact values of common secret/credential patterns in text output.
25
25
  *
26
+ * ENV-DUMP GRADE — includes the JSON `KEY: 'value'` passes, which key off the
27
+ * NAME beside the value and therefore cannot tell a credential from ordinary
28
+ * code that happens to use a keyword-ish identifier. Use this for `.env` reads
29
+ * and command output (where an env dump is the actual threat); use
30
+ * {@link redactSecretsInCode} for source-file content.
31
+ *
26
32
  * @param s - Log or command output that may contain `.env`-style secrets.
27
33
  * @returns A redacted copy safe to surface to end users or models.
28
34
  */
29
35
  export declare function redactSecrets(s: string): string;
36
+ /**
37
+ * CODE-SAFE redaction — the env-assignment pass of {@link redactSecrets} WITHOUT
38
+ * the JSON `KEY: 'value'` passes.
39
+ *
40
+ * Those passes match on the NAME next to a quoted value, so over source code they
41
+ * replace legitimate content at enormous scale: `forgotPasswordEndpoint:
42
+ * '/users/forgot-password'`, `apiKeys: 'API keys'`, and every localized "Show
43
+ * password" string all became `'[REDACTED]'`. Because the agent writes back the
44
+ * content it reads, that token then lands in the user's project — measured at
45
+ * 10,952 of 27,919 flagship template files before this split.
46
+ *
47
+ * No value heuristic can fix that: a legitimate `password = 'TestPass123!'` in a
48
+ * test helper is indistinguishable from a real credential by shape. So the
49
+ * name-keyed passes simply do not run over code. Credentials in source are still
50
+ * caught by the env-assignment pass here, and consumers layer VALUE-SHAPE
51
+ * detection (vendor prefixes, PEM blocks, credentials in a URL authority) on top —
52
+ * which is what actually catches a secret sitting under an innocuous name.
53
+ *
54
+ * @param s - Source-file content or other code-shaped text.
55
+ * @returns A redacted copy that preserves ordinary code verbatim.
56
+ */
57
+ export declare function redactSecretsInCode(s: string): string;
58
+ /**
59
+ * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
60
+ * treatment is appropriate rather than {@link redactSecretsInCode}. Matches
61
+ * `.env`, `.env.<suffix>`, and `<name>.env`.
62
+ *
63
+ * @param path - A workspace-relative or absolute file path.
64
+ * @returns `true` when the file is an env file.
65
+ */
66
+ export declare function isEnvFilePath(path: string): boolean;
30
67
  /**
31
68
  * Check if a command is blocked for security reasons. Returns error message or null if allowed.
32
69
  *
@@ -1 +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"}
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;AAyFhD;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK/C;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGnD;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"}
package/dist/utilities.js CHANGED
@@ -39,7 +39,33 @@ s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
39
39
  // e.g. MAILGUN_APIKEY), and SERVICE_ACCOUNT were the gaps; `_KEY` already covers
40
40
  // OPENAI_KEY / *_ROLE_KEY.
41
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');
42
+ /**
43
+ * A keyword-named `NAME=value` anywhere on a line — the ENV-DUMP grade pattern,
44
+ * used by {@link redactSecrets} for command output and `.env` reads. The permissive
45
+ * `.*` prefix is deliberate there: a leaked env var routinely arrives mid-line
46
+ * (`Error: DATABASE_URL=postgres://…` on stderr), and that output is shown to the
47
+ * model but never written back to a file, so over-matching costs nothing.
48
+ *
49
+ * The value must not open with `{` or `<`: an env value never does, but a JSX
50
+ * expression container or element always does. Without that guard this pattern ate
51
+ * `auth={authClient}` down to `auth=[REDACTED]`.
52
+ */
53
+ const SECRET_KEY_PATTERN = new RegExp(`^(.*(?:${SECRET_KEYWORDS})[A-Za-z0-9_]*)=(?![{<])(.+)$`, 'gim');
54
+ /**
55
+ * The same assignment anchored to a REAL env-assignment shape — line start (or
56
+ * `export `), a bare identifier, then `=` with no surrounding spaces. This is the
57
+ * CODE-SAFE form used by {@link redactSecretsInCode}.
58
+ *
59
+ * Anchoring matters because the loose form above matches any line with a keyword
60
+ * anywhere before an `=` and replaces the whole line tail. Over source code that
61
+ * destroyed ordinary lines, and since the executor writes back what it reads, the
62
+ * literal token landed in users' projects.
63
+ */
64
+ const SECRET_ENV_ASSIGNMENT = new RegExp(
65
+ // The leading name run allows EMPTY: a name that IS the keyword (`DATABASE_URL=`,
66
+ // `SECRET=`) has nothing before it, and requiring a character there silently
67
+ // un-masked exactly the plainest env vars.
68
+ `^((?:export[ \\t]+)?[A-Za-z0-9_]*(?:${SECRET_KEYWORDS})[A-Za-z0-9_]*)=(?![{<])(.+)$`, 'gim');
43
69
  /** Catch JSON-formatted env dumps like { KEY: 'value' } from node/python. */
44
70
  const SECRET_JSON_DQ = new RegExp(`(['"]?(?:\\w*(?:${SECRET_KEYWORDS})\\w*)['"]?\\s*[:=]\\s*)"(?:[^"\\\\]|\\\\.)*"`, 'gi');
45
71
  const SECRET_JSON_SQ = new RegExp(`(['"]?(?:\\w*(?:${SECRET_KEYWORDS})\\w*)['"]?\\s*[:=]\\s*)'(?:[^'\\\\]|\\\\.)*'`, 'gi');
@@ -76,6 +102,12 @@ const jsonValueReplacer = (quote) => (match, prefix) => {
76
102
  /**
77
103
  * Redact values of common secret/credential patterns in text output.
78
104
  *
105
+ * ENV-DUMP GRADE — includes the JSON `KEY: 'value'` passes, which key off the
106
+ * NAME beside the value and therefore cannot tell a credential from ordinary
107
+ * code that happens to use a keyword-ish identifier. Use this for `.env` reads
108
+ * and command output (where an env dump is the actual threat); use
109
+ * {@link redactSecretsInCode} for source-file content.
110
+ *
79
111
  * @param s - Log or command output that may contain `.env`-style secrets.
80
112
  * @returns A redacted copy safe to surface to end users or models.
81
113
  */
@@ -85,6 +117,42 @@ export function redactSecrets(s) {
85
117
  .replace(SECRET_JSON_DQ, jsonValueReplacer('"'))
86
118
  .replace(SECRET_JSON_SQ, jsonValueReplacer("'"));
87
119
  }
120
+ /**
121
+ * CODE-SAFE redaction — the env-assignment pass of {@link redactSecrets} WITHOUT
122
+ * the JSON `KEY: 'value'` passes.
123
+ *
124
+ * Those passes match on the NAME next to a quoted value, so over source code they
125
+ * replace legitimate content at enormous scale: `forgotPasswordEndpoint:
126
+ * '/users/forgot-password'`, `apiKeys: 'API keys'`, and every localized "Show
127
+ * password" string all became `'[REDACTED]'`. Because the agent writes back the
128
+ * content it reads, that token then lands in the user's project — measured at
129
+ * 10,952 of 27,919 flagship template files before this split.
130
+ *
131
+ * No value heuristic can fix that: a legitimate `password = 'TestPass123!'` in a
132
+ * test helper is indistinguishable from a real credential by shape. So the
133
+ * name-keyed passes simply do not run over code. Credentials in source are still
134
+ * caught by the env-assignment pass here, and consumers layer VALUE-SHAPE
135
+ * detection (vendor prefixes, PEM blocks, credentials in a URL authority) on top —
136
+ * which is what actually catches a secret sitting under an innocuous name.
137
+ *
138
+ * @param s - Source-file content or other code-shaped text.
139
+ * @returns A redacted copy that preserves ordinary code verbatim.
140
+ */
141
+ export function redactSecretsInCode(s) {
142
+ return s.replace(SECRET_ENV_ASSIGNMENT, '$1=[REDACTED]');
143
+ }
144
+ /**
145
+ * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
146
+ * treatment is appropriate rather than {@link redactSecretsInCode}. Matches
147
+ * `.env`, `.env.<suffix>`, and `<name>.env`.
148
+ *
149
+ * @param path - A workspace-relative or absolute file path.
150
+ * @returns `true` when the file is an env file.
151
+ */
152
+ export function isEnvFilePath(path) {
153
+ const base = path.split('/').pop() ?? '';
154
+ return base === '.env' || base.startsWith('.env.') || base.endsWith('.env');
155
+ }
88
156
  // ── Command blocking ──────────────────────────────────────────────────────────
89
157
  /** Commands that dump environment variables — blocked to prevent secret leakage. */
90
158
  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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,8 @@
17
17
  }
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "README.md"
21
22
  ],
22
23
  "keywords": [
23
24
  "molecule",
@@ -27,10 +28,10 @@
27
28
  ],
28
29
  "license": "Apache-2.0",
29
30
  "peerDependencies": {
30
- "@molecule/api-ai": "^1.0.0"
31
+ "@molecule/api-ai": "^1.0.1"
31
32
  },
32
33
  "devDependencies": {
33
- "@molecule/api-ai": "1.0.0",
34
+ "@molecule/api-ai": "1.0.1",
34
35
  "@types/node": "26.1.2",
35
36
  "typescript": "6.0.3",
36
37
  "vitest": "4.1.10"