@crewhaus/tool-task 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-task",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Task(description, prompt, subagent_type?) tool — spawns a sub-agent and returns its final message (Section 13)",
6
6
  "main": "src/index.ts",
@@ -12,24 +12,24 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/agent-context-isolation": "0.1.1",
16
- "@crewhaus/errors": "0.1.1",
17
- "@crewhaus/sub-agent-permission-inheritance": "0.1.1",
18
- "@crewhaus/tool-builder": "0.1.1",
19
- "@crewhaus/tool-catalog": "0.1.1",
15
+ "@crewhaus/agent-context-isolation": "0.1.3",
16
+ "@crewhaus/errors": "0.1.3",
17
+ "@crewhaus/sub-agent-permission-inheritance": "0.1.3",
18
+ "@crewhaus/tool-builder": "0.1.3",
19
+ "@crewhaus/tool-catalog": "0.1.3",
20
20
  "yaml": "^2.6.0",
21
21
  "zod": "^3.23.8"
22
22
  },
23
23
  "devDependencies": {
24
- "@crewhaus/event-log": "0.1.1",
25
- "@crewhaus/permission-engine": "0.1.1",
26
- "@crewhaus/run-context": "0.1.1"
24
+ "@crewhaus/event-log": "0.1.3",
25
+ "@crewhaus/permission-engine": "0.1.3",
26
+ "@crewhaus/run-context": "0.1.3"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "author": {
30
30
  "name": "Max Meier",
31
- "email": "max@studiomax.io",
32
- "url": "https://studiomax.io"
31
+ "email": "max@crewhaus.ai",
32
+ "url": "https://crewhaus.ai"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -41,12 +41,7 @@
41
41
  "url": "https://github.com/crewhaus/factory/issues"
42
42
  },
43
43
  "publishConfig": {
44
- "access": "restricted"
45
- },
46
- "files": [
47
- "src",
48
- "README.md",
49
- "LICENSE",
50
- "NOTICE"
51
- ]
44
+ "access": "public"
45
+ },
46
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
52
47
  }
package/src/index.test.ts CHANGED
@@ -177,6 +177,46 @@ disk instructions`,
177
177
  resolveSubAgentDefinition("nonexistent", { subAgentDir: "/tmp/does-not-exist-xyz" }),
178
178
  ).toThrow(/unknown subagent_type/);
179
179
  });
180
+
181
+ // SECURITY: subagent_type is model-filled and becomes `join(dir, name+".md")`,
182
+ // so an unsanitized `..`/separator escapes the sub-agent dir and loads an
183
+ // arbitrary host .md as a sub-agent definition. Must be rejected.
184
+ test.each([
185
+ "../../../../etc/passwd",
186
+ "../sibling",
187
+ "foo/bar",
188
+ "foo\\bar",
189
+ "..",
190
+ ".",
191
+ "a/../../b",
192
+ ])("rejects path-traversal subagent_type %p", (evil) => {
193
+ expect(() =>
194
+ resolveSubAgentDefinition(evil, { subAgentDir: "/tmp/does-not-exist-xyz" }),
195
+ ).toThrow(/invalid subagent_type/);
196
+ });
197
+
198
+ test("path-traversal is rejected even when an inline map is present (disk path is the sink)", () => {
199
+ expect(() =>
200
+ resolveSubAgentDefinition("../../tmp/evil", {
201
+ subAgents: new Map(),
202
+ subAgentDir: "/tmp/does-not-exist-xyz",
203
+ }),
204
+ ).toThrow(/invalid subagent_type/);
205
+ });
206
+
207
+ test("ordinary names with dots that are not traversal still resolve via disk", () => {
208
+ const dir = newTempDir();
209
+ try {
210
+ writeFileSync(
211
+ join(dir, "my.agent.md"),
212
+ "---\nname: my.agent\ndescription: d\n---\ndo the thing\n",
213
+ );
214
+ const out = resolveSubAgentDefinition("my.agent", { subAgentDir: dir });
215
+ expect(out.name).toBe("my.agent");
216
+ } finally {
217
+ rmSync(dir, { recursive: true, force: true });
218
+ }
219
+ });
180
220
  });
181
221
 
182
222
  describe("createTaskTool — execute round-trip", () => {
package/src/index.ts CHANGED
@@ -143,6 +143,27 @@ function loadSubAgentFromDisk(name: string, dir: string): SubAgentDefinition | n
143
143
  return parseSubAgentFile(content, name);
144
144
  }
145
145
 
146
+ /**
147
+ * `subagent_type` is a model-filled tool argument and model output is
148
+ * attacker-steerable (via any untrusted input). It becomes a filesystem path
149
+ * segment in `loadSubAgentFromDisk` (`join(dir, `${name}.md`)`), and `join`
150
+ * resolves `..` and path separators — so an unsanitized name like
151
+ * `../../../tmp/evil` reads an arbitrary `.md` on the host and loads it as a
152
+ * sub-agent definition. Reject anything that isn't a single safe path
153
+ * component before it reaches the disk lookup. Inline spec-supplied names are
154
+ * exact-key map lookups that never touch the filesystem, so they are resolved
155
+ * first (above) and are unaffected.
156
+ */
157
+ function isUnsafeSubAgentName(name: string): boolean {
158
+ return (
159
+ name.includes("/") ||
160
+ name.includes("\\") ||
161
+ name.includes("\0") ||
162
+ name === "." ||
163
+ name === ".."
164
+ );
165
+ }
166
+
146
167
  export type CreateTaskToolOptions = {
147
168
  /** Inline spec-supplied sub-agent definitions (highest priority). */
148
169
  readonly subAgents?: ReadonlyMap<string, SubAgentDefinition>;
@@ -160,6 +181,13 @@ export function resolveSubAgentDefinition(
160
181
  const inline = opts.subAgents.get(resolveName);
161
182
  if (inline !== undefined) return inline;
162
183
  }
184
+ // Guard the disk lookup against path traversal (the inline map above is an
185
+ // exact-key lookup and is intentionally not gated).
186
+ if (isUnsafeSubAgentName(resolveName)) {
187
+ throw new SubAgentResolutionError(
188
+ `invalid subagent_type "${resolveName}" — must not contain path separators, "..", ".", or NUL bytes`,
189
+ );
190
+ }
163
191
  const dir = opts.subAgentDir ?? join(process.cwd(), ".crewhaus", "sub-agents");
164
192
  const onDisk = loadSubAgentFromDisk(resolveName, dir);
165
193
  if (onDisk !== null) return onDisk;