@anthropic-ai/sdk 0.116.0 → 0.117.1

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 (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/lib/BetaMessageStream.d.mts.map +1 -1
  3. package/lib/BetaMessageStream.d.ts.map +1 -1
  4. package/lib/BetaMessageStream.js +12 -5
  5. package/lib/BetaMessageStream.js.map +1 -1
  6. package/lib/BetaMessageStream.mjs +12 -5
  7. package/lib/BetaMessageStream.mjs.map +1 -1
  8. package/lib/MessageStream.d.mts.map +1 -1
  9. package/lib/MessageStream.d.ts.map +1 -1
  10. package/lib/MessageStream.js +9 -4
  11. package/lib/MessageStream.js.map +1 -1
  12. package/lib/MessageStream.mjs +9 -4
  13. package/lib/MessageStream.mjs.map +1 -1
  14. package/lib/environments/worker.d.mts.map +1 -1
  15. package/lib/environments/worker.d.ts.map +1 -1
  16. package/lib/environments/worker.js +29 -3
  17. package/lib/environments/worker.js.map +1 -1
  18. package/lib/environments/worker.mjs +29 -3
  19. package/lib/environments/worker.mjs.map +1 -1
  20. package/lib/tools/BetaToolRunner.d.mts.map +1 -1
  21. package/lib/tools/BetaToolRunner.d.ts.map +1 -1
  22. package/lib/tools/BetaToolRunner.js +11 -0
  23. package/lib/tools/BetaToolRunner.js.map +1 -1
  24. package/lib/tools/BetaToolRunner.mjs +11 -0
  25. package/lib/tools/BetaToolRunner.mjs.map +1 -1
  26. package/package.json +9 -15
  27. package/resources/beta/beta.d.mts +2 -2
  28. package/resources/beta/beta.d.mts.map +1 -1
  29. package/resources/beta/beta.d.ts +2 -2
  30. package/resources/beta/beta.d.ts.map +1 -1
  31. package/resources/beta/beta.js.map +1 -1
  32. package/resources/beta/beta.mjs.map +1 -1
  33. package/resources/beta/dreams.d.mts +46 -6
  34. package/resources/beta/dreams.d.mts.map +1 -1
  35. package/resources/beta/dreams.d.ts +46 -6
  36. package/resources/beta/dreams.d.ts.map +1 -1
  37. package/resources/beta/index.d.mts +1 -1
  38. package/resources/beta/index.d.mts.map +1 -1
  39. package/resources/beta/index.d.ts +1 -1
  40. package/resources/beta/index.d.ts.map +1 -1
  41. package/resources/beta/index.js.map +1 -1
  42. package/resources/beta/index.mjs.map +1 -1
  43. package/resources/beta/messages/messages.js +1 -1
  44. package/resources/beta/messages/messages.js.map +1 -1
  45. package/resources/beta/messages/messages.mjs +1 -1
  46. package/resources/beta/messages/messages.mjs.map +1 -1
  47. package/resources/beta/user-profiles.d.mts +5 -5
  48. package/resources/beta/user-profiles.d.ts +5 -5
  49. package/resources/messages/messages.js +1 -1
  50. package/resources/messages/messages.js.map +1 -1
  51. package/resources/messages/messages.mjs +1 -1
  52. package/resources/messages/messages.mjs.map +1 -1
  53. package/src/lib/BetaMessageStream.ts +15 -5
  54. package/src/lib/MessageStream.ts +11 -4
  55. package/src/lib/environments/worker.ts +28 -3
  56. package/src/lib/tools/BetaToolRunner.ts +11 -0
  57. package/src/resources/beta/beta.ts +6 -0
  58. package/src/resources/beta/dreams.ts +54 -5
  59. package/src/resources/beta/index.ts +3 -0
  60. package/src/resources/beta/messages/messages.ts +1 -1
  61. package/src/resources/beta/user-profiles.ts +5 -5
  62. package/src/resources/messages/messages.ts +1 -1
  63. package/src/tools/agent-toolset/fs-util.ts +43 -23
  64. package/src/tools/agent-toolset/node.ts +1 -1
  65. package/src/tools/agent-toolset/skills.ts +127 -31
  66. package/src/version.ts +1 -1
  67. package/tools/agent-toolset/fs-util.d.mts +14 -2
  68. package/tools/agent-toolset/fs-util.d.mts.map +1 -1
  69. package/tools/agent-toolset/fs-util.d.ts +14 -2
  70. package/tools/agent-toolset/fs-util.d.ts.map +1 -1
  71. package/tools/agent-toolset/fs-util.js +46 -24
  72. package/tools/agent-toolset/fs-util.js.map +1 -1
  73. package/tools/agent-toolset/fs-util.mjs +45 -24
  74. package/tools/agent-toolset/fs-util.mjs.map +1 -1
  75. package/tools/agent-toolset/node.js +1 -1
  76. package/tools/agent-toolset/node.js.map +1 -1
  77. package/tools/agent-toolset/node.mjs +1 -1
  78. package/tools/agent-toolset/node.mjs.map +1 -1
  79. package/tools/agent-toolset/skills.d.mts +26 -3
  80. package/tools/agent-toolset/skills.d.mts.map +1 -1
  81. package/tools/agent-toolset/skills.d.ts +26 -3
  82. package/tools/agent-toolset/skills.d.ts.map +1 -1
  83. package/tools/agent-toolset/skills.js +114 -29
  84. package/tools/agent-toolset/skills.js.map +1 -1
  85. package/tools/agent-toolset/skills.mjs +113 -30
  86. package/tools/agent-toolset/skills.mjs.map +1 -1
  87. package/version.d.mts +1 -1
  88. package/version.d.ts +1 -1
  89. package/version.js +1 -1
  90. package/version.mjs +1 -1
@@ -184,8 +184,8 @@ export interface BetaUserProfile {
184
184
  external_id?: string | null;
185
185
 
186
186
  /**
187
- * Display name of the entity this profile represents. For `resold` this is the
188
- * resold-to company's name.
187
+ * Real-world name of the entity this profile represents (company or individual).
188
+ * For `resold` this is the resold-to company's name.
189
189
  */
190
190
  name?: string | null;
191
191
  }
@@ -229,9 +229,9 @@ export interface UserProfileCreateParams {
229
229
  metadata?: { [key: string]: string };
230
230
 
231
231
  /**
232
- * Body param: Display name of the entity this profile represents. Required when
233
- * relationship is `resold` (the resold-to company's name); optional otherwise.
234
- * Maximum 255 characters.
232
+ * Body param: Optional for all profiles. Real-world name of the entity this
233
+ * profile represents (company or individual); for `resold` profiles, the resold-to
234
+ * company's name where known. Maximum 255 characters.
235
235
  */
236
236
  name?: string | null;
237
237
 
@@ -86,7 +86,7 @@ export class Messages extends APIResource {
86
86
  );
87
87
  }
88
88
 
89
- let timeout = (this._client as any)._options.timeout as number | null;
89
+ let timeout = options?.timeout ?? ((this._client as any)._options.timeout as number | null);
90
90
  if (!body.stream && timeout == null) {
91
91
  const maxNonstreamingTokens = MODEL_NONSTREAMING_TOKENS[body.model] ?? undefined;
92
92
  timeout = this._client.calculateNonstreamingTimeout(body.max_tokens, maxNonstreamingTokens);
@@ -24,12 +24,27 @@ async function realpathOrSelf(p: string): Promise<string> {
24
24
  }
25
25
  }
26
26
 
27
+ /** Matches Linux MAXSYMLINKS, the threshold at which `realpath` itself reports ELOOP. */
28
+ const MAX_SYMLINK_HOPS = 40;
29
+
30
+ /** The `code` of a Node system error, or `undefined` for anything else. */
31
+ export function errnoCode(err: unknown): string | undefined {
32
+ const code = (err as { code?: unknown } | null)?.code;
33
+ return typeof code === 'string' ? code : undefined;
34
+ }
35
+
27
36
  /**
28
37
  * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append
29
38
  * the rest, but never re-append a component that is itself a symlink — read the
30
39
  * link and continue from its target instead. This handles paths being created
31
40
  * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing
32
41
  * outside a confinement root) slip through unresolved.
42
+ *
43
+ * Returns a symlink-free path or throws an errno-carrying error (`ELOOP` for a
44
+ * cycle or more than {@link MAX_SYMLINK_HOPS} links, the `lstat`/`realpath`
45
+ * error for an unreadable component); it never returns `abs` unresolved. Only
46
+ * symlink hops count against the cap, so any depth of not-yet-existing
47
+ * directories still resolves.
33
48
  */
34
49
  export async function canonicalize(abs: string): Promise<string> {
35
50
  const tail: string[] = [];
@@ -39,28 +54,24 @@ export async function canonicalize(abs: string): Promise<string> {
39
54
  let real: string;
40
55
  try {
41
56
  real = await fs.realpath(prefix);
42
- } catch {
43
- let isLink = false;
57
+ } catch (realpathErr) {
58
+ let isLink: boolean;
44
59
  try {
45
60
  isLink = (await fs.lstat(prefix)).isSymbolicLink();
46
- } catch {
47
- /* prefix truly doesn't exist (ENOENT) — fall through and walk up */
48
- }
49
- if (isLink) {
50
- // Resolve the symlink ourselves and retry; `tail` (the part below it)
51
- // still applies to the link's target. The hop cap matches Linux
52
- // MAXSYMLINKS — the same threshold at which `realpath` itself would
53
- // have returned ELOOP — so a cycle of unresolvable links terminates.
54
- if (++hops > 40) {
55
- throw new ToolError(`path ${JSON.stringify(abs)} has too many levels of symbolic links`);
56
- }
57
- prefix = path.resolve(path.dirname(prefix), await fs.readlink(prefix));
61
+ } catch (lstatErr) {
62
+ const code = errnoCode(lstatErr);
63
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') throw lstatErr;
64
+ const parent = path.dirname(prefix);
65
+ if (parent === prefix) throw lstatErr;
66
+ tail.push(path.basename(prefix));
67
+ prefix = parent;
58
68
  continue;
59
69
  }
60
- const parent = path.dirname(prefix);
61
- if (parent === prefix) return abs; // walked past the FS root without a hit
62
- tail.push(path.basename(prefix));
63
- prefix = parent;
70
+ if (!isLink) throw realpathErr;
71
+ if (++hops > MAX_SYMLINK_HOPS) {
72
+ throw Object.assign(new Error('too many levels of symbolic links'), { code: 'ELOOP' });
73
+ }
74
+ prefix = path.resolve(path.dirname(prefix), await fs.readlink(prefix));
64
75
  continue;
65
76
  }
66
77
  return tail.length ? path.join(real, ...tail.reverse()) : real;
@@ -76,6 +87,9 @@ export async function canonicalize(abs: string): Promise<string> {
76
87
  * leaf, even a dangling one) is resolved before the confinement check, and the
77
88
  * resolved path is what the caller then operates on, so a symlink inside `root`
78
89
  * that points outside it can neither pass the check nor be followed afterwards.
90
+ * `..` is collapsed lexically before any symlink is followed. A path that cannot
91
+ * be resolved (symlink loop, unreadable component) is rejected with a
92
+ * `ToolError` naming `p`, never the host's absolute path.
79
93
  *
80
94
  * Residual TOCTOU: a component could still be swapped for a symlink between this
81
95
  * call and the eventual `fs` operation. Closing that fully needs per-component
@@ -91,7 +105,12 @@ export async function confineToRoot(
91
105
  const realRoot = await realpathOrSelf(path.resolve(root));
92
106
  const abs = path.resolve(realRoot, p);
93
107
  if (allowOutside) return abs;
94
- const real = await canonicalize(abs);
108
+ let real: string;
109
+ try {
110
+ real = await canonicalize(abs);
111
+ } catch (err) {
112
+ throw new ToolError(fsErrorMessage(err, `path ${JSON.stringify(p)}`));
113
+ }
95
114
  if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
96
115
  throw new ToolError(`path ${JSON.stringify(p)} escapes workdir`);
97
116
  }
@@ -124,11 +143,12 @@ export async function atomicWriteFile(targetPath: string, content: string): Prom
124
143
  /**
125
144
  * Map a thrown filesystem error to a consistent, language-independent message,
126
145
  * so the model sees the same wording regardless of the runtime (Node's raw
127
- * `ENOENT: no such file...` text would otherwise leak through). Falls back to
128
- * the raw error message for codes we don't special-case.
146
+ * `ENOENT: no such file...` text would otherwise leak through). Codes we don't
147
+ * special-case render as the bare code, never Node's message, which embeds the
148
+ * host's absolute path.
129
149
  */
130
150
  export function fsErrorMessage(err: unknown, file: string): string {
131
- const code = (err as { code?: string } | null)?.code;
151
+ const code = errnoCode(err);
132
152
  switch (code) {
133
153
  case 'ENOENT':
134
154
  return `${file}: no such file or directory`;
@@ -149,6 +169,6 @@ export function fsErrorMessage(err: unknown, file: string): string {
149
169
  case 'ENFILE':
150
170
  return `${file}: too many open files`;
151
171
  default:
152
- return `${file}: ${err instanceof Error ? err.message : String(err)}`;
172
+ return `${file}: ${code !== undefined ? `i/o error (${code})` : 'i/o error'}`;
153
173
  }
154
174
  }
@@ -470,7 +470,7 @@ export function betaReadTool(ctx: AgentToolContext): BetaRunnableTool {
470
470
  if (e instanceof ToolError) throw e;
471
471
  throw new ToolError(`read: ${fsErrorMessage(e, file_path)}`);
472
472
  }
473
- if (!view_range) return data;
473
+ if (!view_range?.length) return data;
474
474
  if (view_range.length !== 2) throw new ToolError('read: view_range must be [start_line, end_line]');
475
475
  const [startLine, endLine] = view_range as [number, number];
476
476
  const lines = data.split('\n');
@@ -15,7 +15,7 @@ import { pipeline } from 'node:stream/promises';
15
15
  import type { Anthropic } from '../../client';
16
16
  import { AnthropicError } from '../../core/error';
17
17
  import { loggerFor } from '../../internal/utils/log';
18
- import { DIR_CREATE_MODE } from './fs-util';
18
+ import { DIR_CREATE_MODE, errnoCode } from './fs-util';
19
19
  import type { AgentToolContext } from './node';
20
20
 
21
21
  const execFileAsync = promisify(execFile);
@@ -115,8 +115,8 @@ export async function resolveSkillVersion(
115
115
  }
116
116
 
117
117
  /** Reject archive members that are absolute or contain a `..` component. */
118
- function assertSafeMemberNames(names: string): void {
119
- for (const raw of names.split('\n')) {
118
+ function assertSafeMemberNames(names: string[]): void {
119
+ for (const raw of names) {
120
120
  const entry = raw.trim();
121
121
  if (!entry) continue;
122
122
  if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
@@ -125,19 +125,77 @@ function assertSafeMemberNames(names: string): void {
125
125
  }
126
126
  }
127
127
 
128
+ const INCONSISTENT_LISTING = 'skill archive listing is inconsistent; refusing to extract';
129
+
130
+ /**
131
+ * Type chars (first byte of each `ls`-style line from `unzip -Z` / `tar -tvf`)
132
+ * that denote a regular file or directory. `zipinfo` prints `?` for entries
133
+ * with no Unix type bits, which `unzip` extracts as regular files; GNU tar
134
+ * prints `C` for contiguous files. Everything else — `l` symlink, `h`
135
+ * hardlink, `b`/`c` device, `p` fifo, `s` socket, unknown tar types — is a
136
+ * special member.
137
+ */
138
+ const PLAIN_TYPE_CHARS = { unzip: new Set(['-', 'd', '?']), tar: new Set(['-', 'd', 'C']) };
139
+
140
+ function listingLines(listing: string): string[] {
141
+ const lines = listing.split('\n');
142
+ if (lines[lines.length - 1] === '') lines.pop();
143
+ return lines;
144
+ }
145
+
128
146
  /**
129
- * Reject archives that contain anything other than regular files and
130
- * directories. The type char is the first byte of each `ls`-style line emitted
131
- * by `tar -tvf` / `unzip -Z`: `-` file, `d` dir, `l` symlink, `h` hardlink,
132
- * `b`/`c` device, `p` fifo, `s` socket. A symlink/hardlink member is how an
133
- * archive escapes its extraction dir even when no name contains `..`.
147
+ * A special member is excluded by handing its listed name back to the CLI as
148
+ * a pattern, so the name must be byte-identical to what is stored. `tar`,
149
+ * `bsdtar` and `unzip` print bytes they cannot show literally as `\ooo`, `^X`
150
+ * or `#U` escapes, or as raw non-ASCII; any such name cannot be excluded
151
+ * reliably. A leading `-` would let `unzip` parse the pattern as an option.
134
152
  */
135
- function assertNoSpecialMembers(verboseListing: string): void {
136
- for (const line of verboseListing.split('\n')) {
137
- const type = line.trimStart()[0];
138
- if (type === 'l' || type === 'h' || type === 'b' || type === 'c' || type === 'p' || type === 's') {
139
- throw new AnthropicError('refusing to extract archive with symlink/hardlink/device member');
153
+ function canExcludeVerbatim(cmd: 'unzip' | 'tar', name: string): boolean {
154
+ return /^[\x20-\x7E]+$/.test(name) && !/[\\^#]/.test(name) && !(cmd === 'unzip' && name.startsWith('-'));
155
+ }
156
+
157
+ /**
158
+ * Pair an archive's name listing (`unzip -Z1` / `tar -tf`) with its typed
159
+ * listing (`unzip -Z --h --t` / `tar -tvf`) and split the members into plain
160
+ * (regular file or directory) and special (everything else). Special members
161
+ * are excluded from extraction rather than rejected; the archive is refused
162
+ * only when the two listings disagree in length or a special member's name
163
+ * cannot be passed back to the CLI verbatim (see {@link canExcludeVerbatim}).
164
+ */
165
+ export function classifyArchiveListing(
166
+ cmd: 'unzip' | 'tar',
167
+ names: string,
168
+ typed: string,
169
+ ): { plain: string[]; special: string[] } {
170
+ const nameLines = listingLines(names);
171
+ const typedLines = listingLines(typed);
172
+ if (nameLines.length !== typedLines.length) throw new AnthropicError(INCONSISTENT_LISTING);
173
+ const plain: string[] = [];
174
+ const special: string[] = [];
175
+ nameLines.forEach((name, i) => {
176
+ if (PLAIN_TYPE_CHARS[cmd].has(typedLines[i]!.charAt(0))) {
177
+ plain.push(name);
178
+ return;
140
179
  }
180
+ if (!canExcludeVerbatim(cmd, name)) {
181
+ throw new AnthropicError(
182
+ `refusing to extract archive: cannot safely exclude member ${JSON.stringify(name)}`,
183
+ );
184
+ }
185
+ special.push(name);
186
+ });
187
+ return { plain, special };
188
+ }
189
+
190
+ /**
191
+ * Walk `dir` with `lstat` semantics and reject anything that is not a regular
192
+ * file or directory. Never follows a link and never descends into anything
193
+ * but a real directory.
194
+ */
195
+ export async function assertOnlyPlainEntries(dir: string): Promise<void> {
196
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
197
+ if (entry.isDirectory()) await assertOnlyPlainEntries(path.join(dir, entry.name));
198
+ else if (!entry.isFile()) throw new AnthropicError(INCONSISTENT_LISTING);
141
199
  }
142
200
  }
143
201
 
@@ -152,7 +210,7 @@ async function runArchiveTool(cmd: 'unzip' | 'tar', args: string[]): Promise<str
152
210
  const { stdout } = await execFileAsync(cmd, args);
153
211
  return stdout;
154
212
  } catch (e) {
155
- if (e != null && typeof e === 'object' && (e as { code?: unknown }).code === 'ENOENT') {
213
+ if (errnoCode(e) === 'ENOENT') {
156
214
  throw new AnthropicError(
157
215
  `skill extraction requires the \`${cmd}\` command, but it was not found on PATH`,
158
216
  );
@@ -162,17 +220,17 @@ async function runArchiveTool(cmd: 'unzip' | 'tar', args: string[]): Promise<str
162
220
  }
163
221
 
164
222
  /**
165
- * The single top-level directory shared by every entry in a newline-separated
166
- * archive listing, or `''` if entries don't all live under one common
167
- * directory. Skill bundles are packaged wrapped in one directory named after
168
- * the skill (e.g. `pdf/SKILL.md`, `pdf/scripts/...`); the extractor strips it
169
- * so contents land directly in the skill's dir instead of a redundant nested
170
- * `<skill>/<skill>/` level. A flat or multi-root archive yields `''`.
223
+ * The single top-level directory shared by every entry in an archive listing,
224
+ * or `''` if entries don't all live under one common directory. Skill bundles
225
+ * are packaged wrapped in one directory named after the skill (e.g.
226
+ * `pdf/SKILL.md`, `pdf/scripts/...`); the extractor strips it so contents land
227
+ * directly in the skill's dir instead of a redundant nested `<skill>/<skill>/`
228
+ * level. A flat or multi-root archive yields `''`.
171
229
  */
172
- function archiveTopDir(listing: string): string {
230
+ function archiveTopDir(names: string[]): string {
173
231
  let top: string | undefined;
174
232
  let nested = false;
175
- for (const raw of listing.split('\n')) {
233
+ for (const raw of names) {
176
234
  // Drop `.` / empty segments so a `./pdf/...`-style listing (e.g. from
177
235
  // `tar -C dir .`) is treated the same as `pdf/...`.
178
236
  const parts = raw
@@ -195,9 +253,14 @@ function archiveTopDir(listing: string): string {
195
253
  * to `unzip`/`tar` — consistent with the rest of the toolset, which already
196
254
  * invokes `bash` and `rg`. Both `unzip` and `tar` must be available on `PATH`; a
197
255
  * missing binary surfaces as a clear error (see {@link runArchiveTool}). Refuses
198
- * any member that would escape `dest` (zip-slip / tar-slip), including
199
- * symlink/hardlink members: skill archives come from the API, but skills can be
200
- * third-party.
256
+ * any member that would escape `dest` (zip-slip / tar-slip): skill archives
257
+ * come from the API, but skills can be third-party. Members that are not a
258
+ * regular file or directory (symlink, hardlink, device, fifo) are excluded
259
+ * from extraction rather than rejected; an archive whose special members
260
+ * cannot be excluded reliably is refused (see {@link classifyArchiveListing}).
261
+ * `tar` matches exclusions unanchored, so a plain member sharing a special
262
+ * member's name may be dropped too. The staging tree is verified to hold only
263
+ * regular files and directories before anything is promoted into `dest`.
201
264
  *
202
265
  * The skill bundle's single wrapper directory is stripped: the archive is
203
266
  * extracted into a staging dir and the wrapper's contents are promoted into
@@ -215,6 +278,7 @@ export async function extractSkillArchive(resp: Response, dest: string): Promise
215
278
  fssync.createWriteStream(tmp),
216
279
  );
217
280
  const stage = path.join(path.dirname(dest), `.skill-stage-${process.pid}-${Date.now()}`);
281
+ const excludeFile = path.join(path.dirname(dest), `.skill-exclude-${process.pid}-${Date.now()}`);
218
282
  try {
219
283
  // Sniff the first bytes: zip archives start with "PK\x03\x04"; treat
220
284
  // anything else as a tar.* archive (`tar -xf` autodetects gzip/bzip2/xz).
@@ -224,25 +288,57 @@ export async function extractSkillArchive(resp: Response, dest: string): Promise
224
288
  const archiveCmd = isZip ? 'unzip' : 'tar';
225
289
  // List first, validate, then extract — `tar`/`unzip` will happily write a
226
290
  // `../` member (or follow a symlink member) outside `-C`/`-d` otherwise.
227
- const listing = await runArchiveTool(archiveCmd, isZip ? ['-Z1', tmp] : ['-tf', tmp]);
228
- assertSafeMemberNames(listing);
229
- assertNoSpecialMembers(await runArchiveTool(archiveCmd, isZip ? ['-Z', tmp] : ['-tvf', tmp]));
230
- const top = archiveTopDir(listing);
291
+ const names = await runArchiveTool(archiveCmd, isZip ? ['-Z1', tmp] : ['-tf', tmp]);
292
+ const typed = await runArchiveTool(archiveCmd, isZip ? ['-Z', '--h', '--t', tmp] : ['-tvf', tmp]);
293
+ const { plain, special } = classifyArchiveListing(archiveCmd, names, typed);
294
+ assertSafeMemberNames([...plain, ...special]);
295
+ const top = archiveTopDir(plain);
231
296
  await fs.mkdir(stage, { recursive: true, mode: DIR_CREATE_MODE });
232
- await runArchiveTool(archiveCmd, isZip ? ['-oq', tmp, '-d', stage] : ['-xf', tmp, '-C', stage]);
297
+ // `unzip` exits non-zero when every member is excluded, so only run the
298
+ // extractor when there is something to extract.
299
+ if (plain.length > 0) {
300
+ await runArchiveTool(archiveCmd, await extractArgs(archiveCmd, tmp, stage, special, excludeFile));
301
+ }
302
+ await assertOnlyPlainEntries(stage);
233
303
  // Promote the wrapper's contents (or the staged tree itself, if the
234
304
  // archive wasn't wrapped) into the already-created empty `dest`. `stage`
235
305
  // is a sibling of `dest`, so each rename stays on one filesystem.
236
306
  const srcRoot = top ? path.join(stage, top) : stage;
237
- for (const entry of await fs.readdir(srcRoot)) {
307
+ const entries = await fs.readdir(srcRoot).catch((e: unknown) => {
308
+ throw errnoCode(e) === 'ENOENT' ? new AnthropicError(INCONSISTENT_LISTING) : e;
309
+ });
310
+ for (const entry of entries) {
238
311
  await fs.rename(path.join(srcRoot, entry), path.join(dest, entry));
239
312
  }
240
313
  } finally {
241
314
  await fs.rm(tmp, { force: true });
315
+ await fs.rm(excludeFile, { force: true });
242
316
  await fs.rm(stage, { recursive: true, force: true });
243
317
  }
244
318
  }
245
319
 
320
+ /**
321
+ * Arguments that extract `archive` into `stage` while excluding every member
322
+ * in `special`. Names are glob-escaped because both CLIs treat exclusions as
323
+ * patterns; `tar` reads them from `excludeFile`, `unzip` takes them after
324
+ * `-x`, which must follow `-d` so no pattern is parsed as an option.
325
+ */
326
+ async function extractArgs(
327
+ cmd: 'unzip' | 'tar',
328
+ archive: string,
329
+ stage: string,
330
+ special: string[],
331
+ excludeFile: string,
332
+ ): Promise<string[]> {
333
+ const patterns = special.map((name) => name.replace(/[*?[\\]/g, '\\$&'));
334
+ if (cmd === 'unzip') {
335
+ return ['-oq', archive, '-d', stage, ...(patterns.length > 0 ? ['-x', ...patterns] : [])];
336
+ }
337
+ if (patterns.length === 0) return ['-xf', archive, '-C', stage];
338
+ await fs.writeFile(excludeFile, patterns.join('\n') + '\n', { flag: 'wx', mode: 0o600 });
339
+ return ['-xf', archive, '-C', stage, '-X', excludeFile];
340
+ }
341
+
246
342
  /** Read the first `n` bytes of `file`. */
247
343
  async function readHead(file: string, n: number): Promise<Buffer> {
248
344
  const handle = await fs.open(file, 'r');
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.116.0'; // x-release-please-version
1
+ export const VERSION = '0.117.1'; // x-release-please-version
@@ -8,12 +8,20 @@
8
8
  export declare const DIR_CREATE_MODE = 493;
9
9
  /** Mode for files the file tools create. */
10
10
  export declare const FILE_CREATE_MODE = 420;
11
+ /** The `code` of a Node system error, or `undefined` for anything else. */
12
+ export declare function errnoCode(err: unknown): string | undefined;
11
13
  /**
12
14
  * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append
13
15
  * the rest, but never re-append a component that is itself a symlink — read the
14
16
  * link and continue from its target instead. This handles paths being created
15
17
  * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing
16
18
  * outside a confinement root) slip through unresolved.
19
+ *
20
+ * Returns a symlink-free path or throws an errno-carrying error (`ELOOP` for a
21
+ * cycle or more than {@link MAX_SYMLINK_HOPS} links, the `lstat`/`realpath`
22
+ * error for an unreadable component); it never returns `abs` unresolved. Only
23
+ * symlink hops count against the cap, so any depth of not-yet-existing
24
+ * directories still resolves.
17
25
  */
18
26
  export declare function canonicalize(abs: string): Promise<string>;
19
27
  /**
@@ -25,6 +33,9 @@ export declare function canonicalize(abs: string): Promise<string>;
25
33
  * leaf, even a dangling one) is resolved before the confinement check, and the
26
34
  * resolved path is what the caller then operates on, so a symlink inside `root`
27
35
  * that points outside it can neither pass the check nor be followed afterwards.
36
+ * `..` is collapsed lexically before any symlink is followed. A path that cannot
37
+ * be resolved (symlink loop, unreadable component) is rejected with a
38
+ * `ToolError` naming `p`, never the host's absolute path.
28
39
  *
29
40
  * Residual TOCTOU: a component could still be swapped for a symlink between this
30
41
  * call and the eventual `fs` operation. Closing that fully needs per-component
@@ -43,8 +54,9 @@ export declare function atomicWriteFile(targetPath: string, content: string): Pr
43
54
  /**
44
55
  * Map a thrown filesystem error to a consistent, language-independent message,
45
56
  * so the model sees the same wording regardless of the runtime (Node's raw
46
- * `ENOENT: no such file...` text would otherwise leak through). Falls back to
47
- * the raw error message for codes we don't special-case.
57
+ * `ENOENT: no such file...` text would otherwise leak through). Codes we don't
58
+ * special-case render as the bare code, never Node's message, which embeds the
59
+ * host's absolute path.
48
60
  */
49
61
  export declare function fsErrorMessage(err: unknown, file: string): string;
50
62
  //# sourceMappingURL=fs-util.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fs-util.d.mts","sourceRoot":"","sources":["../../src/tools/agent-toolset/fs-util.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,uFAAuF;AACvF,eAAO,MAAM,eAAe,MAAQ,CAAC;AACrC,4CAA4C;AAC5C,eAAO,MAAM,gBAAgB,MAAQ,CAAC;AAWtC;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkC/D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,CAAC,EAAE,MAAM,EACT,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GAChC,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBxF;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAwBjE"}
1
+ {"version":3,"file":"fs-util.d.mts","sourceRoot":"","sources":["../../src/tools/agent-toolset/fs-util.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,uFAAuF;AACvF,eAAO,MAAM,eAAe,MAAQ,CAAC;AACrC,4CAA4C;AAC5C,eAAO,MAAM,gBAAgB,MAAQ,CAAC;AActC,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAG1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA8B/D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,CAAC,EAAE,MAAM,EACT,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GAChC,OAAO,CAAC,MAAM,CAAC,CAejB;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBxF;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAwBjE"}
@@ -8,12 +8,20 @@
8
8
  export declare const DIR_CREATE_MODE = 493;
9
9
  /** Mode for files the file tools create. */
10
10
  export declare const FILE_CREATE_MODE = 420;
11
+ /** The `code` of a Node system error, or `undefined` for anything else. */
12
+ export declare function errnoCode(err: unknown): string | undefined;
11
13
  /**
12
14
  * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append
13
15
  * the rest, but never re-append a component that is itself a symlink — read the
14
16
  * link and continue from its target instead. This handles paths being created
15
17
  * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing
16
18
  * outside a confinement root) slip through unresolved.
19
+ *
20
+ * Returns a symlink-free path or throws an errno-carrying error (`ELOOP` for a
21
+ * cycle or more than {@link MAX_SYMLINK_HOPS} links, the `lstat`/`realpath`
22
+ * error for an unreadable component); it never returns `abs` unresolved. Only
23
+ * symlink hops count against the cap, so any depth of not-yet-existing
24
+ * directories still resolves.
17
25
  */
18
26
  export declare function canonicalize(abs: string): Promise<string>;
19
27
  /**
@@ -25,6 +33,9 @@ export declare function canonicalize(abs: string): Promise<string>;
25
33
  * leaf, even a dangling one) is resolved before the confinement check, and the
26
34
  * resolved path is what the caller then operates on, so a symlink inside `root`
27
35
  * that points outside it can neither pass the check nor be followed afterwards.
36
+ * `..` is collapsed lexically before any symlink is followed. A path that cannot
37
+ * be resolved (symlink loop, unreadable component) is rejected with a
38
+ * `ToolError` naming `p`, never the host's absolute path.
28
39
  *
29
40
  * Residual TOCTOU: a component could still be swapped for a symlink between this
30
41
  * call and the eventual `fs` operation. Closing that fully needs per-component
@@ -43,8 +54,9 @@ export declare function atomicWriteFile(targetPath: string, content: string): Pr
43
54
  /**
44
55
  * Map a thrown filesystem error to a consistent, language-independent message,
45
56
  * so the model sees the same wording regardless of the runtime (Node's raw
46
- * `ENOENT: no such file...` text would otherwise leak through). Falls back to
47
- * the raw error message for codes we don't special-case.
57
+ * `ENOENT: no such file...` text would otherwise leak through). Codes we don't
58
+ * special-case render as the bare code, never Node's message, which embeds the
59
+ * host's absolute path.
48
60
  */
49
61
  export declare function fsErrorMessage(err: unknown, file: string): string;
50
62
  //# sourceMappingURL=fs-util.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fs-util.d.ts","sourceRoot":"","sources":["../../src/tools/agent-toolset/fs-util.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,uFAAuF;AACvF,eAAO,MAAM,eAAe,MAAQ,CAAC;AACrC,4CAA4C;AAC5C,eAAO,MAAM,gBAAgB,MAAQ,CAAC;AAWtC;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkC/D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,CAAC,EAAE,MAAM,EACT,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GAChC,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBxF;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAwBjE"}
1
+ {"version":3,"file":"fs-util.d.ts","sourceRoot":"","sources":["../../src/tools/agent-toolset/fs-util.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,uFAAuF;AACvF,eAAO,MAAM,eAAe,MAAQ,CAAC;AACrC,4CAA4C;AAC5C,eAAO,MAAM,gBAAgB,MAAQ,CAAC;AActC,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAG1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA8B/D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,CAAC,EAAE,MAAM,EACT,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GAChC,OAAO,CAAC,MAAM,CAAC,CAejB;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBxF;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAwBjE"}
@@ -7,6 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.FILE_CREATE_MODE = exports.DIR_CREATE_MODE = void 0;
10
+ exports.errnoCode = errnoCode;
10
11
  exports.canonicalize = canonicalize;
11
12
  exports.confineToRoot = confineToRoot;
12
13
  exports.atomicWriteFile = atomicWriteFile;
@@ -29,12 +30,25 @@ async function realpathOrSelf(p) {
29
30
  return p;
30
31
  }
31
32
  }
33
+ /** Matches Linux MAXSYMLINKS, the threshold at which `realpath` itself reports ELOOP. */
34
+ const MAX_SYMLINK_HOPS = 40;
35
+ /** The `code` of a Node system error, or `undefined` for anything else. */
36
+ function errnoCode(err) {
37
+ const code = err?.code;
38
+ return typeof code === 'string' ? code : undefined;
39
+ }
32
40
  /**
33
41
  * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append
34
42
  * the rest, but never re-append a component that is itself a symlink — read the
35
43
  * link and continue from its target instead. This handles paths being created
36
44
  * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing
37
45
  * outside a confinement root) slip through unresolved.
46
+ *
47
+ * Returns a symlink-free path or throws an errno-carrying error (`ELOOP` for a
48
+ * cycle or more than {@link MAX_SYMLINK_HOPS} links, the `lstat`/`realpath`
49
+ * error for an unreadable component); it never returns `abs` unresolved. Only
50
+ * symlink hops count against the cap, so any depth of not-yet-existing
51
+ * directories still resolves.
38
52
  */
39
53
  async function canonicalize(abs) {
40
54
  const tail = [];
@@ -45,30 +59,28 @@ async function canonicalize(abs) {
45
59
  try {
46
60
  real = await fs.realpath(prefix);
47
61
  }
48
- catch {
49
- let isLink = false;
62
+ catch (realpathErr) {
63
+ let isLink;
50
64
  try {
51
65
  isLink = (await fs.lstat(prefix)).isSymbolicLink();
52
66
  }
53
- catch {
54
- /* prefix truly doesn't exist (ENOENT) — fall through and walk up */
55
- }
56
- if (isLink) {
57
- // Resolve the symlink ourselves and retry; `tail` (the part below it)
58
- // still applies to the link's target. The hop cap matches Linux
59
- // MAXSYMLINKS — the same threshold at which `realpath` itself would
60
- // have returned ELOOP — so a cycle of unresolvable links terminates.
61
- if (++hops > 40) {
62
- throw new ToolError_1.ToolError(`path ${JSON.stringify(abs)} has too many levels of symbolic links`);
63
- }
64
- prefix = path.resolve(path.dirname(prefix), await fs.readlink(prefix));
67
+ catch (lstatErr) {
68
+ const code = errnoCode(lstatErr);
69
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
70
+ throw lstatErr;
71
+ const parent = path.dirname(prefix);
72
+ if (parent === prefix)
73
+ throw lstatErr;
74
+ tail.push(path.basename(prefix));
75
+ prefix = parent;
65
76
  continue;
66
77
  }
67
- const parent = path.dirname(prefix);
68
- if (parent === prefix)
69
- return abs; // walked past the FS root without a hit
70
- tail.push(path.basename(prefix));
71
- prefix = parent;
78
+ if (!isLink)
79
+ throw realpathErr;
80
+ if (++hops > MAX_SYMLINK_HOPS) {
81
+ throw Object.assign(new Error('too many levels of symbolic links'), { code: 'ELOOP' });
82
+ }
83
+ prefix = path.resolve(path.dirname(prefix), await fs.readlink(prefix));
72
84
  continue;
73
85
  }
74
86
  return tail.length ? path.join(real, ...tail.reverse()) : real;
@@ -83,6 +95,9 @@ async function canonicalize(abs) {
83
95
  * leaf, even a dangling one) is resolved before the confinement check, and the
84
96
  * resolved path is what the caller then operates on, so a symlink inside `root`
85
97
  * that points outside it can neither pass the check nor be followed afterwards.
98
+ * `..` is collapsed lexically before any symlink is followed. A path that cannot
99
+ * be resolved (symlink loop, unreadable component) is rejected with a
100
+ * `ToolError` naming `p`, never the host's absolute path.
86
101
  *
87
102
  * Residual TOCTOU: a component could still be swapped for a symlink between this
88
103
  * call and the eventual `fs` operation. Closing that fully needs per-component
@@ -95,7 +110,13 @@ async function confineToRoot(root, p, opts) {
95
110
  const abs = path.resolve(realRoot, p);
96
111
  if (allowOutside)
97
112
  return abs;
98
- const real = await canonicalize(abs);
113
+ let real;
114
+ try {
115
+ real = await canonicalize(abs);
116
+ }
117
+ catch (err) {
118
+ throw new ToolError_1.ToolError(fsErrorMessage(err, `path ${JSON.stringify(p)}`));
119
+ }
99
120
  if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
100
121
  throw new ToolError_1.ToolError(`path ${JSON.stringify(p)} escapes workdir`);
101
122
  }
@@ -128,11 +149,12 @@ async function atomicWriteFile(targetPath, content) {
128
149
  /**
129
150
  * Map a thrown filesystem error to a consistent, language-independent message,
130
151
  * so the model sees the same wording regardless of the runtime (Node's raw
131
- * `ENOENT: no such file...` text would otherwise leak through). Falls back to
132
- * the raw error message for codes we don't special-case.
152
+ * `ENOENT: no such file...` text would otherwise leak through). Codes we don't
153
+ * special-case render as the bare code, never Node's message, which embeds the
154
+ * host's absolute path.
133
155
  */
134
156
  function fsErrorMessage(err, file) {
135
- const code = err?.code;
157
+ const code = errnoCode(err);
136
158
  switch (code) {
137
159
  case 'ENOENT':
138
160
  return `${file}: no such file or directory`;
@@ -153,7 +175,7 @@ function fsErrorMessage(err, file) {
153
175
  case 'ENFILE':
154
176
  return `${file}: too many open files`;
155
177
  default:
156
- return `${file}: ${err instanceof Error ? err.message : String(err)}`;
178
+ return `${file}: ${code !== undefined ? `i/o error (${code})` : 'i/o error'}`;
157
179
  }
158
180
  }
159
181
  //# sourceMappingURL=fs-util.js.map