@namzu/sdk 5.0.0 → 5.2.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.
Files changed (181) hide show
  1. package/CHANGELOG.md +463 -0
  2. package/README.md +2 -2
  3. package/dist/agents/__tests__/run-agent-forwarding.test.d.ts +2 -0
  4. package/dist/agents/__tests__/run-agent-forwarding.test.d.ts.map +1 -0
  5. package/dist/agents/__tests__/run-agent-forwarding.test.js +76 -0
  6. package/dist/agents/__tests__/run-agent-forwarding.test.js.map +1 -0
  7. package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts +2 -0
  8. package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts.map +1 -0
  9. package/dist/agents/__tests__/run-completed-stop-reason.test.js +50 -0
  10. package/dist/agents/__tests__/run-completed-stop-reason.test.js.map +1 -0
  11. package/dist/agents/index.d.ts +1 -0
  12. package/dist/agents/index.d.ts.map +1 -1
  13. package/dist/agents/index.js +4 -0
  14. package/dist/agents/index.js.map +1 -1
  15. package/dist/agents/runAgent.d.ts +34 -0
  16. package/dist/agents/runAgent.d.ts.map +1 -1
  17. package/dist/agents/runAgent.js +5 -3
  18. package/dist/agents/runAgent.js.map +1 -1
  19. package/dist/config/runtime.d.ts +30 -30
  20. package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts +2 -0
  21. package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts.map +1 -0
  22. package/dist/constants/tools/__tests__/dangerous-patterns.test.js +54 -0
  23. package/dist/constants/tools/__tests__/dangerous-patterns.test.js.map +1 -0
  24. package/dist/constants/tools/index.d.ts.map +1 -1
  25. package/dist/constants/tools/index.js +28 -1
  26. package/dist/constants/tools/index.js.map +1 -1
  27. package/dist/contracts/a2a.d.ts +44 -44
  28. package/dist/directory/__tests__/derive.test.d.ts +2 -0
  29. package/dist/directory/__tests__/derive.test.d.ts.map +1 -0
  30. package/dist/directory/__tests__/derive.test.js +111 -0
  31. package/dist/directory/__tests__/derive.test.js.map +1 -0
  32. package/dist/directory/__tests__/load-diagnostics.test.d.ts +2 -0
  33. package/dist/directory/__tests__/load-diagnostics.test.d.ts.map +1 -0
  34. package/dist/directory/__tests__/load-diagnostics.test.js +106 -0
  35. package/dist/directory/__tests__/load-diagnostics.test.js.map +1 -0
  36. package/dist/directory/__tests__/load-directory.test.d.ts +2 -0
  37. package/dist/directory/__tests__/load-directory.test.d.ts.map +1 -0
  38. package/dist/directory/__tests__/load-directory.test.js +196 -0
  39. package/dist/directory/__tests__/load-directory.test.js.map +1 -0
  40. package/dist/directory/__tests__/subagents.test.d.ts +2 -0
  41. package/dist/directory/__tests__/subagents.test.d.ts.map +1 -0
  42. package/dist/directory/__tests__/subagents.test.js +117 -0
  43. package/dist/directory/__tests__/subagents.test.js.map +1 -0
  44. package/dist/directory/__tests__/supervisor-budget.test.d.ts +2 -0
  45. package/dist/directory/__tests__/supervisor-budget.test.d.ts.map +1 -0
  46. package/dist/directory/__tests__/supervisor-budget.test.js +97 -0
  47. package/dist/directory/__tests__/supervisor-budget.test.js.map +1 -0
  48. package/dist/directory/derive-supervisor.d.ts +58 -0
  49. package/dist/directory/derive-supervisor.d.ts.map +1 -0
  50. package/dist/directory/derive-supervisor.js +88 -0
  51. package/dist/directory/derive-supervisor.js.map +1 -0
  52. package/dist/directory/derive.d.ts +12 -0
  53. package/dist/directory/derive.d.ts.map +1 -0
  54. package/dist/directory/derive.js +58 -0
  55. package/dist/directory/derive.js.map +1 -0
  56. package/dist/directory/index.d.ts +7 -0
  57. package/dist/directory/index.d.ts.map +1 -0
  58. package/dist/directory/index.js +5 -0
  59. package/dist/directory/index.js.map +1 -0
  60. package/dist/directory/load.d.ts +14 -0
  61. package/dist/directory/load.d.ts.map +1 -0
  62. package/dist/directory/load.js +488 -0
  63. package/dist/directory/load.js.map +1 -0
  64. package/dist/directory/scan.d.ts +36 -0
  65. package/dist/directory/scan.d.ts.map +1 -0
  66. package/dist/directory/scan.js +136 -0
  67. package/dist/directory/scan.js.map +1 -0
  68. package/dist/directory/types.d.ts +215 -0
  69. package/dist/directory/types.d.ts.map +1 -0
  70. package/dist/directory/types.js +8 -0
  71. package/dist/directory/types.js.map +1 -0
  72. package/dist/plugin/__tests__/path-containment.test.d.ts +2 -0
  73. package/dist/plugin/__tests__/path-containment.test.d.ts.map +1 -0
  74. package/dist/plugin/__tests__/path-containment.test.js +91 -0
  75. package/dist/plugin/__tests__/path-containment.test.js.map +1 -0
  76. package/dist/plugin/lifecycle.d.ts.map +1 -1
  77. package/dist/plugin/lifecycle.js +3 -3
  78. package/dist/plugin/lifecycle.js.map +1 -1
  79. package/dist/plugin/loader.d.ts.map +1 -1
  80. package/dist/plugin/loader.js +12 -2
  81. package/dist/plugin/loader.js.map +1 -1
  82. package/dist/provider/__tests__/model-version.test.d.ts +2 -0
  83. package/dist/provider/__tests__/model-version.test.d.ts.map +1 -0
  84. package/dist/provider/__tests__/model-version.test.js +80 -0
  85. package/dist/provider/__tests__/model-version.test.js.map +1 -0
  86. package/dist/provider/__tests__/strict-schema.test.d.ts +2 -0
  87. package/dist/provider/__tests__/strict-schema.test.d.ts.map +1 -0
  88. package/dist/provider/__tests__/strict-schema.test.js +109 -0
  89. package/dist/provider/__tests__/strict-schema.test.js.map +1 -0
  90. package/dist/provider/__tests__/typed-error-classification.test.d.ts +2 -0
  91. package/dist/provider/__tests__/typed-error-classification.test.d.ts.map +1 -0
  92. package/dist/provider/__tests__/typed-error-classification.test.js +123 -0
  93. package/dist/provider/__tests__/typed-error-classification.test.js.map +1 -0
  94. package/dist/provider/model-version.d.ts +60 -0
  95. package/dist/provider/model-version.d.ts.map +1 -0
  96. package/dist/provider/model-version.js +64 -0
  97. package/dist/provider/model-version.js.map +1 -0
  98. package/dist/provider/retry.d.ts.map +1 -1
  99. package/dist/provider/retry.js +23 -7
  100. package/dist/provider/retry.js.map +1 -1
  101. package/dist/provider/strict-schema.d.ts +49 -0
  102. package/dist/provider/strict-schema.d.ts.map +1 -0
  103. package/dist/provider/strict-schema.js +112 -0
  104. package/dist/provider/strict-schema.js.map +1 -0
  105. package/dist/public-runtime.d.ts +6 -1
  106. package/dist/public-runtime.d.ts.map +1 -1
  107. package/dist/public-runtime.js +21 -1
  108. package/dist/public-runtime.js.map +1 -1
  109. package/dist/public-tools.d.ts +1 -0
  110. package/dist/public-tools.d.ts.map +1 -1
  111. package/dist/public-tools.js +4 -0
  112. package/dist/public-tools.js.map +1 -1
  113. package/dist/public-types.d.ts +2 -0
  114. package/dist/public-types.d.ts.map +1 -1
  115. package/dist/registry/tool/__tests__/strict-registration.test.d.ts +2 -0
  116. package/dist/registry/tool/__tests__/strict-registration.test.d.ts.map +1 -0
  117. package/dist/registry/tool/__tests__/strict-registration.test.js +82 -0
  118. package/dist/registry/tool/__tests__/strict-registration.test.js.map +1 -0
  119. package/dist/registry/tool/execute.d.ts.map +1 -1
  120. package/dist/registry/tool/execute.js +16 -0
  121. package/dist/registry/tool/execute.js.map +1 -1
  122. package/dist/runtime/query/__tests__/stream-recovery.test.js +5 -0
  123. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  124. package/dist/runtime/query/result.d.ts.map +1 -1
  125. package/dist/runtime/query/result.js +5 -0
  126. package/dist/runtime/query/result.js.map +1 -1
  127. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +7 -1
  128. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -1
  129. package/dist/tools/builtins/__tests__/edit.test.js +1 -1
  130. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  131. package/dist/tools/builtins/edit.d.ts.map +1 -1
  132. package/dist/tools/builtins/edit.js +20 -2
  133. package/dist/tools/builtins/edit.js.map +1 -1
  134. package/dist/types/plugin/index.d.ts +4 -4
  135. package/dist/types/provider/errors.d.ts +0 -17
  136. package/dist/types/provider/errors.d.ts.map +1 -1
  137. package/dist/types/provider/errors.js +60 -0
  138. package/dist/types/provider/errors.js.map +1 -1
  139. package/dist/types/run/events.d.ts +17 -2
  140. package/dist/types/run/events.d.ts.map +1 -1
  141. package/dist/types/run/events.js.map +1 -1
  142. package/dist/types/verification/index.d.ts +6 -6
  143. package/package.json +1 -1
  144. package/src/agents/__tests__/run-agent-forwarding.test.ts +85 -0
  145. package/src/agents/__tests__/run-completed-stop-reason.test.ts +59 -0
  146. package/src/agents/index.ts +8 -0
  147. package/src/agents/runAgent.ts +30 -4
  148. package/src/constants/tools/__tests__/dangerous-patterns.test.ts +62 -0
  149. package/src/constants/tools/index.ts +28 -1
  150. package/src/directory/__tests__/derive.test.ts +135 -0
  151. package/src/directory/__tests__/load-diagnostics.test.ts +126 -0
  152. package/src/directory/__tests__/load-directory.test.ts +243 -0
  153. package/src/directory/__tests__/subagents.test.ts +155 -0
  154. package/src/directory/__tests__/supervisor-budget.test.ts +117 -0
  155. package/src/directory/derive-supervisor.ts +149 -0
  156. package/src/directory/derive.ts +70 -0
  157. package/src/directory/index.ts +27 -0
  158. package/src/directory/load.ts +556 -0
  159. package/src/directory/scan.ts +163 -0
  160. package/src/directory/types.ts +254 -0
  161. package/src/plugin/__tests__/path-containment.test.ts +107 -0
  162. package/src/plugin/lifecycle.ts +3 -3
  163. package/src/plugin/loader.ts +12 -2
  164. package/src/provider/__tests__/model-version.test.ts +93 -0
  165. package/src/provider/__tests__/strict-schema.test.ts +133 -0
  166. package/src/provider/__tests__/typed-error-classification.test.ts +143 -0
  167. package/src/provider/model-version.ts +98 -0
  168. package/src/provider/retry.ts +23 -7
  169. package/src/provider/strict-schema.ts +125 -0
  170. package/src/public-runtime.ts +31 -0
  171. package/src/public-tools.ts +4 -0
  172. package/src/public-types.ts +8 -0
  173. package/src/registry/tool/__tests__/strict-registration.test.ts +115 -0
  174. package/src/registry/tool/execute.ts +16 -0
  175. package/src/runtime/query/__tests__/stream-recovery.test.ts +5 -0
  176. package/src/runtime/query/result.ts +5 -0
  177. package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +8 -2
  178. package/src/tools/builtins/__tests__/edit.test.ts +1 -1
  179. package/src/tools/builtins/edit.ts +20 -2
  180. package/src/types/provider/errors.ts +71 -0
  181. package/src/types/run/events.ts +15 -2
@@ -0,0 +1,163 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import { lstat, readdir, realpath } from 'node:fs/promises'
3
+ import { basename, extname, join, relative, sep } from 'node:path'
4
+
5
+ import { resolveWithinReal } from '../tools/paths.js'
6
+
7
+ import type { DirectoryDiagnostic, DirectorySlot } from './types.js'
8
+
9
+ /** Extensions Node can import directly, given its own type stripping. */
10
+ const CODE_EXTENSIONS = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'])
11
+
12
+ export interface ScannedFile {
13
+ readonly path: string
14
+ readonly relativePath: string
15
+ readonly id: string
16
+ }
17
+
18
+ export interface ScanResult {
19
+ readonly files: readonly ScannedFile[]
20
+ readonly diagnostics: readonly DirectoryDiagnostic[]
21
+ }
22
+
23
+ /**
24
+ * List the code files directly inside one slot directory.
25
+ *
26
+ * **Symlinks are refused, not followed.** A directory handed to this loader is
27
+ * caller-supplied, and a link inside it pointing anywhere else is the
28
+ * link-following weakness (CWE-59) with the loader as the vehicle: the file
29
+ * that gets imported is not the file that was listed. The estate already
30
+ * decided this for its filesystem tools; the same rule applies to a directory
31
+ * whose author may not be the host.
32
+ *
33
+ * That is stricter than the SDK's own `discoverSkills`, and deliberately so —
34
+ * that function walks a host's own trusted tree, where following a link is a
35
+ * convenience rather than a hole. Reusing it here would have inherited a
36
+ * threat model chosen for a different caller.
37
+ *
38
+ * Only the top level is read. A nested directory is reported so an author
39
+ * learns their file was not picked up, except when its name starts with `.`
40
+ * or `_`, which is the conventional way to say "not for you" and is honoured
41
+ * silently.
42
+ */
43
+ export async function scanSlot(
44
+ root: string,
45
+ slot: DirectorySlot,
46
+ dirName: string,
47
+ ): Promise<ScanResult> {
48
+ const dir = join(root, dirName)
49
+ const files: ScannedFile[] = []
50
+ const diagnostics: DirectoryDiagnostic[] = []
51
+
52
+ let entries: Dirent[]
53
+ try {
54
+ entries = await readdir(dir, { withFileTypes: true })
55
+ } catch {
56
+ // An absent optional directory is not news.
57
+ return { files, diagnostics }
58
+ }
59
+
60
+ for (const entry of entries) {
61
+ const name = entry.name
62
+ if (name.startsWith('.') || name.startsWith('_')) continue
63
+
64
+ const candidate = join(dir, name)
65
+
66
+ // `readdir(withFileTypes)` reports the link itself, so this catches a
67
+ // symlink before anything resolves it.
68
+ let stats: Awaited<ReturnType<typeof lstat>>
69
+ try {
70
+ stats = await lstat(candidate)
71
+ } catch {
72
+ continue
73
+ }
74
+
75
+ if (stats.isSymbolicLink()) {
76
+ diagnostics.push({
77
+ code: 'symlink_refused',
78
+ severity: 'warning',
79
+ message: `${dirName}/${name} is a symbolic link and was not loaded. The file that would be imported is not the file listed here, so links are refused rather than followed.`,
80
+ path: candidate,
81
+ })
82
+ continue
83
+ }
84
+
85
+ // Two slots hold DIRECTORIES rather than files: a skill is a folder
86
+ // with a SKILL.md, and a delegate is a whole project directory.
87
+ const directorySlot = slot === 'skills' || slot === 'agents'
88
+
89
+ if (stats.isDirectory()) {
90
+ if (directorySlot) {
91
+ files.push({
92
+ path: candidate,
93
+ relativePath: `${dirName}/${name}`,
94
+ id: name,
95
+ })
96
+ continue
97
+ }
98
+ diagnostics.push({
99
+ code: 'unscanned_directory',
100
+ severity: 'warning',
101
+ message: `${dirName}/${name}/ is a directory and was not scanned. Only files directly inside ${dirName}/ are loaded; prefix a directory with "." or "_" to declare it private and silence this.`,
102
+ path: candidate,
103
+ })
104
+ continue
105
+ }
106
+
107
+ if (!stats.isFile()) continue
108
+ // A loose file in a directory slot is not an error worth a diagnostic —
109
+ // a README beside the skill folders is normal.
110
+ if (directorySlot) continue
111
+
112
+ if (!CODE_EXTENSIONS.has(extname(name))) {
113
+ diagnostics.push({
114
+ code: 'unscanned_directory',
115
+ severity: 'warning',
116
+ message: `${dirName}/${name} is not a module this loader can import (expected one of ${[...CODE_EXTENSIONS].join(', ')}).`,
117
+ path: candidate,
118
+ })
119
+ continue
120
+ }
121
+
122
+ // Containment even though the path was built from a directory listing:
123
+ // the check costs nothing and it is the one place a future change to
124
+ // how `dir` is derived would otherwise escape unnoticed.
125
+ let contained: string
126
+ try {
127
+ contained = await resolveWithinReal(root, relative(root, candidate))
128
+ } catch {
129
+ diagnostics.push({
130
+ code: 'path_escapes_root',
131
+ severity: 'warning',
132
+ message: `${dirName}/${name} resolves outside the project root and was not loaded.`,
133
+ path: candidate,
134
+ })
135
+ continue
136
+ }
137
+
138
+ files.push({
139
+ path: contained,
140
+ relativePath: `${dirName}/${name}`,
141
+ id: basename(name, extname(name)),
142
+ })
143
+ }
144
+
145
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
146
+ return { files, diagnostics }
147
+ }
148
+
149
+ /** Canonical absolute root, or undefined when the directory does not exist. */
150
+ export async function canonicalRoot(dir: string): Promise<string | undefined> {
151
+ try {
152
+ const resolved = await realpath(dir)
153
+ const stats = await lstat(resolved)
154
+ return stats.isDirectory() ? resolved : undefined
155
+ } catch {
156
+ return undefined
157
+ }
158
+ }
159
+
160
+ /** Posix-normalised, for a `relativePath` that reads the same on every platform. */
161
+ export function toPosix(value: string): string {
162
+ return value.split(sep).join('/')
163
+ }
@@ -0,0 +1,254 @@
1
+ import type { AgentIdentity, RunAgentOptions } from '../agents/runAgent.js'
2
+ import type { Message } from '../types/message/index.js'
3
+ import type { LLMProvider } from '../types/provider/index.js'
4
+ import type { Skill } from '../types/skills/index.js'
5
+ import type { ToolDefinition } from '../types/tool/index.js'
6
+
7
+ /**
8
+ * A directory in the convention this package reads.
9
+ *
10
+ * `channels/` and `schedules/` are deliberately absent. They were scoped in
11
+ * and then cut: a trigger definition of `{id, handler}` cannot express a
12
+ * signed webhook, because signature verification needs the RAW body and a
13
+ * handler that receives a parsed one can never check an HMAC. It carries no
14
+ * idempotency key either, while webhooks retry and schedules double-fire, and
15
+ * a cron field with no timezone story is a declaration nothing drives. Each of
16
+ * those is a `major` waiting to happen on a published type. The layering
17
+ * question — does a convention belong outside the kernel — was answered yes
18
+ * and stands; the shape question had simply never been asked.
19
+ */
20
+ export type DirectorySlot = 'agent' | 'instructions' | 'tools' | 'skills' | 'agents'
21
+
22
+ export const ALL_SLOTS: readonly DirectorySlot[] = [
23
+ 'agent',
24
+ 'instructions',
25
+ 'tools',
26
+ 'skills',
27
+ 'agents',
28
+ ]
29
+
30
+ /**
31
+ * Whether the loader executes the project's code.
32
+ *
33
+ * `'evaluate'` imports every module-backed file, and importing a module RUNS
34
+ * it — a top-level side effect in `tools/search.ts` happens during
35
+ * `loadDirectory`, in this process, with this process's privileges.
36
+ *
37
+ * `'skip'` imports nothing. The manifest still carries the full structural
38
+ * truth — every path, the instructions, the skills, duplicate and ambiguity
39
+ * detection — and module-backed entries report `'not_loaded'`. This is what a
40
+ * CI gate, a UI file tree, and triage of a directory you did not write all
41
+ * actually want, and it is the only mode that is safe against a directory
42
+ * whose author you are not.
43
+ */
44
+ export type ModuleMode = 'evaluate' | 'skip'
45
+
46
+ export type ModuleImporter = (fileUrl: string) => Promise<unknown>
47
+
48
+ export type SourceOutcome =
49
+ | 'loaded'
50
+ | 'failed'
51
+ /** Refused, or not a code file. Carries a diagnostic unless `.`/`_`-prefixed. */
52
+ | 'skipped'
53
+ /** `modules: 'skip'` — never imported, so nothing is known about its exports. */
54
+ | 'not_loaded'
55
+ /**
56
+ * The import outran `moduleTimeoutMs`.
57
+ *
58
+ * NOT a synonym for `'failed'`. `import()` cannot be cancelled: the module
59
+ * is still executing, may still complete, and its top-level side effects
60
+ * may land after `loadDirectory` has returned. Node then caches it, so a
61
+ * second load in the same process can see the same file succeed instantly.
62
+ * A distinct outcome because "we stopped waiting" and "it did not work"
63
+ * lead a reader to different places.
64
+ */
65
+ | 'abandoned'
66
+
67
+ export interface SourceRef {
68
+ /** Absolute and canonical — symlinks already resolved. */
69
+ readonly path: string
70
+ /** Posix, relative to the project root: `tools/search.ts`. */
71
+ readonly relativePath: string
72
+ readonly slot: DirectorySlot
73
+ /** Path-derived, extension stripped. `tools/search.ts` → `search`. */
74
+ readonly id: string
75
+ readonly outcome: SourceOutcome
76
+ }
77
+
78
+ export type DirectoryDiagnosticSeverity = 'error' | 'warning'
79
+
80
+ export type DirectoryDiagnosticCode =
81
+ | 'instructions_missing'
82
+ | 'instructions_empty'
83
+ | 'module_load_failed'
84
+ | 'module_load_abandoned'
85
+ | 'no_default_export'
86
+ | 'not_a_tool'
87
+ | 'duplicate_tool_name'
88
+ | 'invalid_config'
89
+ | 'skill_load_failed'
90
+ | 'symlink_refused'
91
+ | 'path_escapes_root'
92
+ | 'unscanned_directory'
93
+ | 'root_missing'
94
+ | 'subagent_load_failed'
95
+ | 'subagent_too_deep'
96
+
97
+ /**
98
+ * Something the loader could not do, reported rather than dropped.
99
+ *
100
+ * A loader that quietly skips a broken tool produces a project running with
101
+ * half its capabilities and nothing to say so. Every refusal names its file
102
+ * and its reason, and an inspector can render the list without re-reading the
103
+ * directory.
104
+ */
105
+ export interface DirectoryDiagnostic {
106
+ readonly code: DirectoryDiagnosticCode
107
+ readonly severity: DirectoryDiagnosticSeverity
108
+ readonly message: string
109
+ /** Absolute path, when the diagnostic is about a file. */
110
+ readonly path?: string
111
+ /**
112
+ * The underlying error's message, when there was one.
113
+ *
114
+ * This package never reads file CONTENTS into a diagnostic. What appears
115
+ * here is text produced by Node or thrown by the authored module, and is
116
+ * exactly as trustworthy as that module — a `throw new Error(secret)` puts
117
+ * the secret here, and no amount of redaction on our side changes that.
118
+ */
119
+ readonly cause?: string
120
+ }
121
+
122
+ export interface ToolEntry {
123
+ readonly source: SourceRef
124
+ readonly definition: ToolDefinition
125
+ }
126
+
127
+ /**
128
+ * A delegate this project declares, loaded as a project in its own right.
129
+ *
130
+ * Recursion rather than a new concept: a sub-agent directory has the same
131
+ * shape as its parent, so `agents/researcher/` is read by the same loader
132
+ * that read the root. One level only — a delegate may not declare delegates of its
133
+ * own in this version. Unbounded nesting is a topology decision (who may
134
+ * spawn whom, and how deep) that belongs to whoever composes the system, and
135
+ * shipping it before that decision means shipping a default nobody chose.
136
+ */
137
+ export interface SubAgentEntry {
138
+ readonly source: SourceRef
139
+ /** The delegate's own manifest — its instructions, tools and config. */
140
+ readonly manifest: DirectoryManifest
141
+ /** Directory-derived, and the id a supervisor delegates to. */
142
+ readonly id: string
143
+ }
144
+
145
+ export interface SkillEntry {
146
+ readonly source: SourceRef
147
+ readonly skill: Skill
148
+ }
149
+
150
+ /**
151
+ * What `agent.ts` may declare.
152
+ *
153
+ * Deliberately a plain object, default-exported, with no authoring helper. A
154
+ * `defineAgent` here would collide with the SDK's existing export of that
155
+ * name, and `export default { model: 'x' } satisfies DirectoryConfig` already
156
+ * gets the type checking a helper would provide.
157
+ *
158
+ * There is no factory form. It would buy environment-conditioned config that
159
+ * `model: process.env.MODEL` already does inside a module that is being
160
+ * evaluated anyway, and it would cost a user-function-invocation phase with a
161
+ * hang mode outside what `moduleTimeoutMs` promises to bound.
162
+ */
163
+ export interface DirectoryConfig {
164
+ readonly model?: string
165
+ readonly temperature?: number
166
+ readonly maxIterations?: number
167
+ readonly tokenBudget?: number
168
+ readonly timeoutMs?: number
169
+ /** Names the agent in traces and events when declared. */
170
+ readonly name?: string
171
+ /** Free-form labels for an inspector. Never interpreted by this package. */
172
+ readonly metadata?: Readonly<Record<string, string>>
173
+ }
174
+
175
+ export interface DirectoryManifest {
176
+ /** Absolute, canonical path to the `agent/` directory. */
177
+ readonly root: string
178
+ /**
179
+ * A DISPLAY LABEL — `config.name`, else the directory's basename, else
180
+ * `'agent'`.
181
+ *
182
+ * Not a trace key, and `deriveRunOptions` forwards it to `runAgent` only
183
+ * when `agent.ts` declared one. A name guessed from a directory basename
184
+ * would collide across sibling projects and silently merge their traces,
185
+ * which is worse than the SDK's own default.
186
+ */
187
+ readonly name: string
188
+ /** Verbatim `instructions.md`, trailing whitespace removed. */
189
+ readonly instructions: string
190
+ readonly config: DirectoryConfig
191
+ readonly tools: readonly ToolEntry[]
192
+ readonly skills: readonly SkillEntry[]
193
+ /** Delegates this project declares. Empty unless it has an `agents/` slot. */
194
+ readonly agents: readonly SubAgentEntry[]
195
+ /** Every file considered, with its outcome. An inspector's ground truth. */
196
+ readonly sources: readonly SourceRef[]
197
+ /** Which slots were scanned. What `ok` is scoped to. */
198
+ readonly included: readonly DirectorySlot[]
199
+ readonly modules: ModuleMode
200
+ }
201
+
202
+ export interface DirectoryLoadResult {
203
+ readonly manifest: DirectoryManifest
204
+ readonly diagnostics: readonly DirectoryDiagnostic[]
205
+ /**
206
+ * True when no diagnostic is an error.
207
+ *
208
+ * Scoped to `manifest.included`: it means "every slot you asked for is
209
+ * sound", never "this directory is complete". A caller that scanned only
210
+ * `tools` learns nothing here about `instructions`.
211
+ */
212
+ readonly ok: boolean
213
+ }
214
+
215
+ export interface LoadDirectoryOptions {
216
+ /** See {@link ModuleMode}. Default `'evaluate'`. */
217
+ readonly modules?: ModuleMode
218
+ /**
219
+ * Replace the import.
220
+ *
221
+ * The exit for a project Node's own type stripping cannot read — enums,
222
+ * decorators, tsconfig path aliases — where a host passes `jiti.import` or
223
+ * a `tsx`-registered importer. Three lines in the host, and no bundler
224
+ * dependency in this tree; the same rule `LLMProvider` follows.
225
+ *
226
+ * Ignored under `modules: 'skip'`, where nothing is imported at all.
227
+ */
228
+ readonly importModule?: ModuleImporter
229
+ /**
230
+ * Slots to scan. Default: all of them.
231
+ *
232
+ * The tuple type makes an empty array unrepresentable in TypeScript on
233
+ * purpose. This is an allow-list, and an allow-list that admits everything
234
+ * when empty is the fail-open shape this estate removed elsewhere; here an
235
+ * empty list scans nothing, which is the closed reading.
236
+ */
237
+ readonly include?: readonly [DirectorySlot, ...DirectorySlot[]]
238
+ /**
239
+ * Per-module deadline. Default 10_000ms.
240
+ *
241
+ * Bounds THIS LOADER, not the module. An import that expires is not
242
+ * cancelled — see {@link SourceOutcome} `'abandoned'`.
243
+ */
244
+ readonly moduleTimeoutMs?: number
245
+ }
246
+
247
+ export interface DeriveRunOptionsInput {
248
+ readonly provider: LLMProvider
249
+ readonly prompt: string | Message[]
250
+ /** Wins over `agent.ts`. Required when `agent.ts` names no model. */
251
+ readonly model?: string
252
+ readonly identity?: AgentIdentity
253
+ readonly overrides?: Partial<Omit<RunAgentOptions, 'provider' | 'prompt'>>
254
+ }
@@ -0,0 +1,107 @@
1
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { PluginRegistry } from '../../registry/plugin/index.js'
7
+ import { ToolRegistry } from '../../registry/tool/execute.js'
8
+ import { getRootLogger } from '../../utils/logger.js'
9
+ import { PluginLifecycleManager } from '../lifecycle.js'
10
+ import { discoverPlugins } from '../loader.js'
11
+
12
+ /**
13
+ * The plugin subsystem loads THIRD-PARTY code, which makes it the worst place
14
+ * in this repo to be missing the containment its filesystem tools got this
15
+ * week. It had none: zero `lstat`, zero `isSymbolicLink`, zero `resolveWithin`
16
+ * across the loader and the lifecycle manager.
17
+ *
18
+ * Two holes, and the second is the serious one.
19
+ *
20
+ * `discoverPlugins` used `stat`, which follows a link and reports on its
21
+ * TARGET — so a symlinked entry pointing anywhere on disk was admitted as a
22
+ * plugin directory. The directory listed was not the directory loaded.
23
+ *
24
+ * And `PluginLifecycleManager` built its import path with
25
+ * `join(plugin.rootDir, toolPath)` where `toolPath` comes out of the
26
+ * MANIFEST — a file the plugin author writes. `"tools": ["../../../evil.js"]`
27
+ * left the plugin directory entirely and was imported, which runs it.
28
+ */
29
+
30
+ const CAN_SYMLINK = (() => {
31
+ try {
32
+ const probe = mkdtempSync(join(tmpdir(), 'namzu-plug-sym-'))
33
+ symlinkSync(probe, join(probe, 'self'), 'dir')
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ })()
39
+
40
+ function pluginsDir(): string {
41
+ return mkdtempSync(join(tmpdir(), 'namzu-plugins-'))
42
+ }
43
+
44
+ function writePlugin(parent: string, name: string, manifest: Record<string, unknown>): string {
45
+ const dir = join(parent, name)
46
+ mkdirSync(dir, { recursive: true })
47
+ writeFileSync(
48
+ join(dir, 'plugin.json'),
49
+ JSON.stringify({ name, version: '1.0.0', description: 'test', ...manifest }),
50
+ )
51
+ return dir
52
+ }
53
+
54
+ describe('plugin discovery refuses a symlinked directory', () => {
55
+ it.skipIf(!CAN_SYMLINK)('does not admit a link as a plugin directory', async () => {
56
+ const outside = mkdtempSync(join(tmpdir(), 'namzu-outside-'))
57
+ writePlugin(outside, 'evil', { tools: [] })
58
+
59
+ const parent = pluginsDir()
60
+ writePlugin(parent, 'honest', { tools: [] })
61
+ symlinkSync(join(outside, 'evil'), join(parent, 'linked'), 'dir')
62
+
63
+ const found = await discoverPlugins(parent)
64
+
65
+ expect(found.map((p) => p.split(/[\\/]/).pop())).toEqual(['honest'])
66
+ })
67
+
68
+ it('still finds an ordinary plugin directory', async () => {
69
+ const parent = pluginsDir()
70
+ writePlugin(parent, 'ordinary', { tools: [] })
71
+
72
+ expect(await discoverPlugins(parent)).toHaveLength(1)
73
+ })
74
+ })
75
+
76
+ function managerFor(def: unknown): PluginLifecycleManager {
77
+ const pluginRegistry = new PluginRegistry()
78
+ pluginRegistry.register(def as never)
79
+ return new PluginLifecycleManager({
80
+ pluginRegistry,
81
+ toolRegistry: new ToolRegistry(),
82
+ log: getRootLogger(),
83
+ } as never)
84
+ }
85
+
86
+ describe('a manifest cannot name a file outside its own plugin', () => {
87
+ it('refuses a tool path that climbs out', async () => {
88
+ // The manifest is written by the plugin author. Without containment the
89
+ // named file was imported — which is to say, executed.
90
+ const outside = mkdtempSync(join(tmpdir(), 'namzu-outside-'))
91
+ writeFileSync(join(outside, 'evil.js'), 'export const tools = []')
92
+ const parent = pluginsDir()
93
+ const escaping = join('..', outside.split(/[\/]/).pop() as string, 'evil.js')
94
+ const dir = writePlugin(parent, 'sneaky', { tools: [escaping] })
95
+
96
+ const manager = managerFor({
97
+ id: 'sneaky',
98
+ manifest: { name: 'sneaky', version: '1.0.0', description: 't', tools: [escaping] },
99
+ scope: 'project',
100
+ status: 'installed',
101
+ rootDir: dir,
102
+ installedAt: 0,
103
+ })
104
+
105
+ await expect(manager.enable('sneaky' as never)).rejects.toThrow(/escapes the working directory/)
106
+ })
107
+ })
@@ -1,4 +1,3 @@
1
- import { join } from 'node:path'
2
1
  import { pathToFileURL } from 'node:url'
3
2
  import { mcpToolToToolDefinition } from '../connector/mcp/adapter.js'
4
3
  import { MCPClient } from '../connector/mcp/client.js'
@@ -12,6 +11,7 @@ import {
12
11
  PLUGIN_NAMESPACE_SEPARATOR,
13
12
  } from '../constants/plugin/index.js'
14
13
  import type { PluginRegistry } from '../registry/plugin/index.js'
14
+ import { resolveWithinReal } from '../tools/paths.js'
15
15
  import type { PluginId } from '../types/ids/index.js'
16
16
  import type {
17
17
  PluginDefinition,
@@ -218,7 +218,7 @@ export class PluginLifecycleManager {
218
218
  // Load tools
219
219
  if (manifest.tools && manifest.tools.length > 0) {
220
220
  for (const toolPath of manifest.tools) {
221
- const absolutePath = join(plugin.rootDir, toolPath)
221
+ const absolutePath = await resolveWithinReal(plugin.rootDir, toolPath)
222
222
  const fileUrl = pathToFileURL(absolutePath).href
223
223
  const mod = (await import(fileUrl)) as { tools?: ToolDefinition[] }
224
224
 
@@ -240,7 +240,7 @@ export class PluginLifecycleManager {
240
240
  // Load hooks
241
241
  if (manifest.hooks && manifest.hooks.length > 0) {
242
242
  for (const hookPath of manifest.hooks) {
243
- const absolutePath = join(plugin.rootDir, hookPath)
243
+ const absolutePath = await resolveWithinReal(plugin.rootDir, hookPath)
244
244
  const fileUrl = pathToFileURL(absolutePath).href
245
245
  const mod = (await import(fileUrl)) as { hooks?: PluginHookDefinition[] }
246
246
 
@@ -1,4 +1,4 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises'
1
+ import { lstat, readFile, readdir, stat } from 'node:fs/promises'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import {
@@ -23,7 +23,17 @@ export async function discoverPlugins(parentDir: string): Promise<string[]> {
23
23
  for (const entry of entries) {
24
24
  if (entry.startsWith('.') || entry.startsWith('_')) continue
25
25
  const fullPath = join(parentDir, entry)
26
- const s = await stat(fullPath)
26
+ // `lstat`, not `stat`: `stat` follows the link and reports on its
27
+ // TARGET, so a symlinked entry pointing anywhere on disk was
28
+ // admitted as a plugin directory and its manifest read from there.
29
+ // A plugins directory holds third-party code by definition, which
30
+ // makes following a link out of it the worse half of CWE-59 — the
31
+ // directory listed is not the directory loaded.
32
+ const s = await lstat(fullPath)
33
+ if (s.isSymbolicLink()) {
34
+ logger.warn('Refusing a symlinked plugin directory', { path: fullPath })
35
+ continue
36
+ }
27
37
  if (!s.isDirectory()) continue
28
38
 
29
39
  const manifestPath = join(fullPath, PLUGIN_MANIFEST_FILENAME)
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { modelVersionAtLeast, parseVersionedModelId } from '../model-version.js'
4
+ import type { ModelIdGrammar } from '../model-version.js'
5
+
6
+ /**
7
+ * The defect this module exists to end: the minor-version group was `\d+`, so
8
+ * it swallowed the 8-digit release date. An id naming no minor therefore
9
+ * parsed as `major.<the date>` and compared as enormously NEWER than one that
10
+ * does, inverting every capability gate keyed on `minor >= n` — a model was
11
+ * told it supported features it does not.
12
+ *
13
+ * Three drivers had each written the same matcher, and all three had it. The
14
+ * vocabulary below is deliberately invented: the shape is what lives here, and
15
+ * a test that needed real product names would be testing the wrong layer.
16
+ */
17
+
18
+ const GRAMMAR: ModelIdGrammar = {
19
+ product: 'widget',
20
+ families: ['small', 'large'],
21
+ routingPrefix: 'vendor/',
22
+ }
23
+
24
+ describe('a date suffix is not a minor version', () => {
25
+ it.each([
26
+ ['widget-small-4-20250514', 'small', 4, 0],
27
+ ['widget-large-4-20250514', 'large', 4, 0],
28
+ ['widget-small-3-20240307', 'small', 3, 0],
29
+ ])('%s parses as %s %d.%d', (id, family, major, minor) => {
30
+ expect(parseVersionedModelId(id, GRAMMAR)).toEqual({ family, major, minor })
31
+ })
32
+
33
+ it('still reads a real minor that is followed by a date', () => {
34
+ expect(parseVersionedModelId('widget-large-4-1-20250805', GRAMMAR)).toEqual({
35
+ family: 'large',
36
+ major: 4,
37
+ minor: 1,
38
+ })
39
+ expect(parseVersionedModelId('widget-small-4-5-20250929', GRAMMAR)?.minor).toBe(5)
40
+ })
41
+
42
+ it('treats a missing minor as .0 rather than as absent', () => {
43
+ expect(parseVersionedModelId('widget-large-5', GRAMMAR)).toEqual({
44
+ family: 'large',
45
+ major: 5,
46
+ minor: 0,
47
+ })
48
+ })
49
+
50
+ it('tolerates the routing prefix, the other separators, and case', () => {
51
+ expect(parseVersionedModelId('vendor/widget-large-4-5', GRAMMAR)?.minor).toBe(5)
52
+ expect(parseVersionedModelId('widget-large-4.5', GRAMMAR)?.minor).toBe(5)
53
+ expect(parseVersionedModelId('WIDGET-LARGE-4-5', GRAMMAR)?.major).toBe(4)
54
+ })
55
+
56
+ it('returns undefined for what the grammar does not describe', () => {
57
+ for (const id of ['other-large-4', 'widget', 'widget-large', 'widget-medium-4', '']) {
58
+ expect(parseVersionedModelId(id, GRAMMAR)).toBeUndefined()
59
+ }
60
+ })
61
+
62
+ it('escapes regex metacharacters in the grammar', () => {
63
+ // A product or prefix containing `.` or `+` must match literally, not
64
+ // as a wildcard. `.map(escape)` — the global, deprecated one — would
65
+ // percent-encode instead, and pass every other test in this file
66
+ // because no ordinary name contains a character it changes.
67
+ const odd: ModelIdGrammar = { product: 'a.b', families: ['c+d'] }
68
+
69
+ expect(parseVersionedModelId('a.b-c+d-4', odd)?.major).toBe(4)
70
+ expect(parseVersionedModelId('axb-cxd-4', odd)).toBeUndefined()
71
+ })
72
+ })
73
+
74
+ describe('modelVersionAtLeast', () => {
75
+ it('does not let a dated id clear a gate it is below', () => {
76
+ // The whole point. With the old `\d+` minor this was `true`, because
77
+ // 4.20250514 >= 4.5.
78
+ expect(modelVersionAtLeast('widget-small-4-20250514', GRAMMAR, 4, 5)).toBe(false)
79
+ expect(modelVersionAtLeast('widget-large-4-20250514', GRAMMAR, 4, 7)).toBe(false)
80
+ })
81
+
82
+ it('admits versions that genuinely clear it', () => {
83
+ expect(modelVersionAtLeast('widget-small-4-5-20250929', GRAMMAR, 4, 5)).toBe(true)
84
+ expect(modelVersionAtLeast('widget-large-5', GRAMMAR, 4, 7)).toBe(true)
85
+ expect(modelVersionAtLeast('widget-large-4-1', GRAMMAR, 4, 5)).toBe(false)
86
+ })
87
+
88
+ it('refuses a name it cannot parse rather than opening the gate', () => {
89
+ // Fail-safe: a capability gate must not open for an id it does not
90
+ // understand.
91
+ expect(modelVersionAtLeast('something-else', GRAMMAR, 4, 5)).toBe(false)
92
+ })
93
+ })