@archmax-ai/harness 0.3.2 → 0.4.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 (120) hide show
  1. package/README.md +3 -1
  2. package/dist/assembly/compose.d.ts +40 -3
  3. package/dist/assembly/compose.js +85 -8
  4. package/dist/assembly/compose.js.map +1 -1
  5. package/dist/assembly/delegation-registry.js +5 -2
  6. package/dist/assembly/delegation-registry.js.map +1 -1
  7. package/dist/assembly/image-reads.d.ts +53 -0
  8. package/dist/assembly/image-reads.js +163 -0
  9. package/dist/assembly/image-reads.js.map +1 -0
  10. package/dist/assembly/index.d.ts +21 -0
  11. package/dist/assembly/index.js +7 -1
  12. package/dist/assembly/index.js.map +1 -1
  13. package/dist/assembly/plain.js +10 -4
  14. package/dist/assembly/plain.js.map +1 -1
  15. package/dist/authoring-skill/archmax-harness/references/backend-integration.md +59 -1
  16. package/dist/authoring-skill/archmax-harness/references/hook-and-test-scripts.md +27 -1
  17. package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +71 -22
  18. package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +24 -5
  19. package/dist/core/binary-read.d.ts +44 -0
  20. package/dist/core/binary-read.js +93 -0
  21. package/dist/core/binary-read.js.map +1 -0
  22. package/dist/core/binary-types.d.ts +19 -0
  23. package/dist/core/binary-types.js +54 -0
  24. package/dist/core/binary-types.js.map +1 -0
  25. package/dist/core/file-operations.d.ts +43 -0
  26. package/dist/core/file-operations.js +368 -0
  27. package/dist/core/file-operations.js.map +1 -0
  28. package/dist/core/path-mapping.d.ts +17 -3
  29. package/dist/core/path-mapping.js +32 -1
  30. package/dist/core/path-mapping.js.map +1 -1
  31. package/dist/core/platform-prompt.generated.js +1 -1
  32. package/dist/core/platform-prompt.generated.js.map +1 -1
  33. package/dist/core/prompt.d.ts +27 -0
  34. package/dist/core/prompt.js +65 -5
  35. package/dist/core/prompt.js.map +1 -1
  36. package/dist/core/raw-download.d.ts +16 -0
  37. package/dist/core/raw-download.js +56 -0
  38. package/dist/core/raw-download.js.map +1 -0
  39. package/dist/core/session-zone.d.ts +9 -1
  40. package/dist/core/session-zone.js +15 -0
  41. package/dist/core/session-zone.js.map +1 -1
  42. package/dist/core/tool-context.d.ts +27 -0
  43. package/dist/core/tool-context.js +14 -0
  44. package/dist/core/tool-context.js.map +1 -0
  45. package/dist/core/tool-telemetry.js +2 -1
  46. package/dist/core/tool-telemetry.js.map +1 -1
  47. package/dist/core/workspace-context.js +1 -0
  48. package/dist/core/workspace-context.js.map +1 -1
  49. package/dist/core/workspace-router.d.ts +30 -1
  50. package/dist/core/workspace-router.js +80 -3
  51. package/dist/core/workspace-router.js.map +1 -1
  52. package/dist/index.d.ts +11 -1
  53. package/dist/index.js +3 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/kernel/kernel.js +198 -119
  56. package/dist/kernel/kernel.js.map +1 -1
  57. package/dist/machine/allow.d.ts +22 -0
  58. package/dist/machine/allow.js +47 -15
  59. package/dist/machine/allow.js.map +1 -1
  60. package/dist/machine/lint-spec.js +12 -3
  61. package/dist/machine/lint-spec.js.map +1 -1
  62. package/dist/machine/machine.d.ts +15 -1
  63. package/dist/machine/machine.js +43 -8
  64. package/dist/machine/machine.js.map +1 -1
  65. package/dist/machine/spec-schema.js +14 -0
  66. package/dist/machine/spec-schema.js.map +1 -1
  67. package/dist/machine/tool-names.d.ts +27 -7
  68. package/dist/machine/tool-names.js +43 -13
  69. package/dist/machine/tool-names.js.map +1 -1
  70. package/dist/machine/tool-paths.d.ts +64 -0
  71. package/dist/machine/tool-paths.js +155 -0
  72. package/dist/machine/tool-paths.js.map +1 -0
  73. package/dist/public/spec.d.ts +11 -1
  74. package/dist/public/spec.js +10 -1
  75. package/dist/public/spec.js.map +1 -1
  76. package/dist/validate/validate.js +10 -5
  77. package/dist/validate/validate.js.map +1 -1
  78. package/dist/workflow/agent-tools.d.ts +20 -1
  79. package/dist/workflow/agent-tools.js +35 -1
  80. package/dist/workflow/agent-tools.js.map +1 -1
  81. package/dist/workflow/control-tools.d.ts +4 -1
  82. package/dist/workflow/control-tools.js +16 -9
  83. package/dist/workflow/control-tools.js.map +1 -1
  84. package/dist/workflow/governance.d.ts +2 -0
  85. package/dist/workflow/governance.js +1 -0
  86. package/dist/workflow/governance.js.map +1 -1
  87. package/dist/workflow/middleware.d.ts +2 -0
  88. package/dist/workflow/middleware.js +27 -12
  89. package/dist/workflow/middleware.js.map +1 -1
  90. package/dist/workflow/on-error.js +3 -2
  91. package/dist/workflow/on-error.js.map +1 -1
  92. package/dist/workflow/parks.js +5 -5
  93. package/dist/workflow/parks.js.map +1 -1
  94. package/dist/workflow/render-prompt.d.ts +2 -0
  95. package/dist/workflow/render-prompt.js +9 -3
  96. package/dist/workflow/render-prompt.js.map +1 -1
  97. package/dist/workflow/returns-check.d.ts +35 -0
  98. package/dist/workflow/returns-check.js +10 -0
  99. package/dist/workflow/returns-check.js.map +1 -0
  100. package/dist/workflow/signature-checks.d.ts +26 -1
  101. package/dist/workflow/signature-checks.js +35 -10
  102. package/dist/workflow/signature-checks.js.map +1 -1
  103. package/dist/workflow/state.d.ts +75 -1
  104. package/dist/workflow/state.js +83 -0
  105. package/dist/workflow/state.js.map +1 -1
  106. package/dist/workflow/sub-workflow.d.ts +16 -5
  107. package/dist/workflow/sub-workflow.js +84 -37
  108. package/dist/workflow/sub-workflow.js.map +1 -1
  109. package/dist/workflow/tool-service.js +57 -10
  110. package/dist/workflow/tool-service.js.map +1 -1
  111. package/dist/workflow/turn-boundary.d.ts +8 -7
  112. package/dist/workflow/turn-boundary.js +10 -9
  113. package/dist/workflow/turn-boundary.js.map +1 -1
  114. package/dist/workflow/workflow-tools.js +5 -1
  115. package/dist/workflow/workflow-tools.js.map +1 -1
  116. package/package.json +1 -1
  117. package/skills/archmax-harness/references/backend-integration.md +59 -1
  118. package/skills/archmax-harness/references/hook-and-test-scripts.md +27 -1
  119. package/skills/archmax-harness/references/workflow-schema.md +71 -22
  120. package/skills/archmax-harness/references/workflow-yaml.md +24 -5
@@ -1,6 +1,7 @@
1
- import { ADVANCE_TOOL, HARNESS_CONTROL_TOOLS, EVAL_TOOL, RUN_TOOL, SET_VARIABLES_TOOL, UNGRANTABLE_TOOLS, } from "../machine/tool-names.js";
1
+ import { ADVANCE_TOOL, ESSENTIAL_TOOLS, HARNESS_CONTROL_TOOLS, EVAL_TOOL, SET_VARIABLES_TOOL, UNGRANTABLE_TOOLS, } from "../machine/tool-names.js";
2
2
  import { FORBID_ANY_TOOL } from "../machine/spec-schema.js";
3
- import { argsSatisfy, normalizeAllowEntry } from "../machine/allow.js";
3
+ import { argsSatisfy, normalizeAllowEntry, pathGuardMatches, } from "../machine/allow.js";
4
+ import { BUILT_IN_TOOL_PATH_TABLE, declaredPathsOf, malformedPathArgs, MUTATING_ACCESSES, OFFLOAD_ACCESSES, pathValuesOf, SCRATCHPAD_ACCESSES, } from "../machine/tool-paths.js";
4
5
  import { unmetRequirements } from "../workflow/control-tools.js";
5
6
  import { canonicalizeRelPath } from "../core/workspace.js";
6
7
  import { classifyWorkspacePath, mountNameOf, SESSION_INTERNAL_DIRS, SESSION_OFFLOAD_DIRS, SESSION_OPEN_DIR, } from "../core/zones.js";
@@ -27,21 +28,30 @@ const TASK_BLOCK_MESSAGE = `[workflow] BLOCKED: \`task\` is the runtime's own di
27
28
  `a state may call. A rubric grades your work; you do not call it. To delegate work to another ` +
28
29
  `process, use the workflow's declared \`archmax_workflow_<slug>\` tool.`;
29
30
  const ALLOW = { decision: "allow", ruleId: "allow" };
30
- /** File-writing built-in tools whose `file_path` argument the kernel governs. */
31
- const WRITE_TOOLS = new Set(["write_file", "edit_file"]);
32
31
  /**
33
- * File tools whose access to the always-open run areas is permitted in every
34
- * state, independent of the state's `allow` list.
32
+ * The workspace paths a call names, from the tool's declared path arguments
33
+ * (`machine/tool-paths.ts`). Every path rule reads this, so a tool is governed
34
+ * by what it declares — a built-in, a runtime file operation and a host tool
35
+ * alike — and a call naming several paths is refused when any one is.
35
36
  */
36
- const SESSION_OPEN_TOOLS = new Set(["read_file", "write_file", "edit_file", "ls"]);
37
- /** Read-shaped file tools (offload areas are readable but never writable). */
38
- const READ_TOOLS = new Set(["read_file", "ls"]);
39
- /** Every file tool whose path argument the zone rules classify. */
40
- const PATH_TOOLS = new Set(["read_file", "write_file", "edit_file", "ls", "glob", "grep"]);
41
- /** The path argument a file tool carries, whatever it is named. */
42
- function pathArg(args) {
43
- const target = args.file_path ?? args.path;
44
- return typeof target === "string" ? target : null;
37
+ function pathsOf(action, api) {
38
+ return declaredPathsOf(action.tool, toolPathsOf(action.tool, api), action.args);
39
+ }
40
+ /** A tool's declaration: the machine's table, else the built-ins (a rule run without a machine). */
41
+ function toolPathsOf(tool, api) {
42
+ return api.machine ? api.machine.toolPaths(tool) : BUILT_IN_TOOL_PATH_TABLE.get(tool);
43
+ }
44
+ /**
45
+ * How a refusal names the call: `'write_file' on 'x'`, and for a tool declaring
46
+ * several paths, which argument it was — `'copy_file' on 'x' (destination)`.
47
+ */
48
+ function subject(action, path, api) {
49
+ return `'${action.tool}' on ${pathLabel(action, path, api)}`;
50
+ }
51
+ /** One path of a call, named with its argument when the tool declares several. */
52
+ function pathLabel(action, path, api) {
53
+ const several = Object.keys(toolPathsOf(action.tool, api) ?? {}).length > 1;
54
+ return `'${path.path}'${several ? ` (${path.arg})` : ""}`;
45
55
  }
46
56
  /**
47
57
  * Canonicalize a tool `file_path` argument to the single workspace-relative
@@ -180,18 +190,17 @@ const taskBlockRule = (action) => {
180
190
  const readOnlyZoneRule = (action, api) => {
181
191
  if (action.kind !== "tool-call")
182
192
  return null;
183
- const { tool, args } = action;
184
- if (WRITE_TOOLS.has(tool) &&
185
- typeof args.file_path === "string" &&
186
- isReadOnlyZonePath(args.file_path, api.mountPrefixes)) {
187
- const rel = normalizeZonePath(args.file_path);
193
+ for (const path of pathsOf(action, api)) {
194
+ if (!MUTATING_ACCESSES.has(path.access) || !isReadOnlyZonePath(path.path, api.mountPrefixes))
195
+ continue;
196
+ const rel = normalizeZonePath(path.path);
188
197
  // The mount itself, so a nested key is named as authored (`catalogs/eu`)
189
198
  // rather than by the segment it happens to sit under.
190
199
  const mount = mountNameOf(rel, api.mountPrefixes) ?? rel.split("/")[0];
191
200
  return {
192
201
  decision: "block",
193
202
  ruleId: "zone.read-only",
194
- reason: `[workflow] BLOCKED: '${tool}' on '${args.file_path}' targets a read-only ` +
203
+ reason: `[workflow] BLOCKED: ${subject(action, path, api)} targets a read-only ` +
195
204
  `mount ('${mount}'). Authored content is mounted read-only — write your files under ` +
196
205
  `'${SESSION_OPEN_DIR}/…' (persisted per-session in the session store).`,
197
206
  warn: true,
@@ -204,16 +213,16 @@ const readOnlyZoneRule = (action, api) => {
204
213
  * `_specs/`) are not addressable by agent tools at all — not read, not written.
205
214
  * They hold the runtime's own bookkeeping for the run being executed.
206
215
  */
207
- const runtimeInternalRule = (action) => {
208
- if (action.kind !== "tool-call" || !PATH_TOOLS.has(action.tool))
216
+ const runtimeInternalRule = (action, api) => {
217
+ if (action.kind !== "tool-call")
209
218
  return null;
210
- const target = pathArg(action.args);
211
- if (target == null || classifyWorkspacePath(target) !== "run-internal")
219
+ const path = pathsOf(action, api).find((p) => classifyWorkspacePath(p.path) === "run-internal");
220
+ if (!path)
212
221
  return null;
213
222
  return {
214
223
  decision: "block",
215
224
  ruleId: "zone.runtime-internal",
216
- reason: `[workflow] BLOCKED: '${action.tool}' on '${target}' targets a harness-internal run ` +
225
+ reason: `[workflow] BLOCKED: ${subject(action, path, api)} targets a harness-internal run ` +
217
226
  `area (${SESSION_INTERNAL_DIRS.map((d) => `'${d}/'`).join(", ")}). These hold the runtime's ` +
218
227
  `own bookkeeping and are not part of your workspace — use '${SESSION_OPEN_DIR}/…' for ` +
219
228
  `your own files.`,
@@ -225,16 +234,16 @@ const runtimeInternalRule = (action) => {
225
234
  * evicted content there and hands the model the path, so reads are permitted
226
235
  * (see {@link runOpenAccessRule}) but an agent-initiated write is refused.
227
236
  */
228
- const runtimeManagedRule = (action) => {
229
- if (action.kind !== "tool-call" || !WRITE_TOOLS.has(action.tool))
237
+ const runtimeManagedRule = (action, api) => {
238
+ if (action.kind !== "tool-call")
230
239
  return null;
231
- const filePath = action.args.file_path;
232
- if (typeof filePath !== "string" || classifyWorkspacePath(filePath) !== "run-offload")
240
+ const path = pathsOf(action, api).find((p) => MUTATING_ACCESSES.has(p.access) && classifyWorkspacePath(p.path) === "run-offload");
241
+ if (!path)
233
242
  return null;
234
243
  return {
235
244
  decision: "block",
236
245
  ruleId: "zone.runtime-managed",
237
- reason: `[workflow] BLOCKED: '${action.tool}' on '${filePath}' targets a runtime-owned area ` +
246
+ reason: `[workflow] BLOCKED: ${subject(action, path, api)} targets a runtime-owned area ` +
238
247
  `(${SESSION_OFFLOAD_DIRS.map((d) => `'${d}/'`).join(", ")}), where the runtime offloads ` +
239
248
  `oversized context. You may read those files; write your own under '${SESSION_OPEN_DIR}/…'.`,
240
249
  warn: true,
@@ -243,9 +252,9 @@ const runtimeManagedRule = (action) => {
243
252
  /**
244
253
  * Safety rule: a skill bundle the acting state does not enable is unreachable.
245
254
  * The enforcement half of skill governance (declared by `skills.allow` at the
246
- * spec root and on a state): binds every path-classified tool plus
247
- * {@link RUN_TOOL}, so a disabled capability can be neither read, listed,
248
- * searched, written, nor executed.
255
+ * spec root and on a state): binds every declared path of every call, whatever
256
+ * its access, so a disabled capability can be neither read, listed, searched,
257
+ * written, removed, nor executed.
249
258
  *
250
259
  * Non-overridable: evaluated with the safety rules, ahead of `policy`, consumer
251
260
  * rules, and the per-state `allow` default, so no declaration can reach into a
@@ -259,13 +268,18 @@ const skillRule = (action, api) => {
259
268
  const skills = api.skills ?? NO_SKILLS;
260
269
  if (skills.length === 0)
261
270
  return null;
262
- const { tool, args, state } = action;
263
- if (!PATH_TOOLS.has(tool) && tool !== RUN_TOOL)
264
- return null;
265
- const target = pathArg(args);
266
- if (target == null)
267
- return null;
268
- const slug = skillOfPath(target, skills);
271
+ for (const path of pathsOf(action, api)) {
272
+ const verdict = skillVerdict(action, path, api, skills);
273
+ if (verdict)
274
+ return verdict;
275
+ }
276
+ return null;
277
+ };
278
+ /** {@link skillRule} for one of a call's paths. */
279
+ function skillVerdict(action, path, api, skills) {
280
+ const { state } = action;
281
+ const on = subject(action, path, api);
282
+ const slug = skillOfPath(path.path, skills);
269
283
  if (slug == null)
270
284
  return null;
271
285
  const enabled = api.machine.enabledSkills(state, skills.map((skill) => skill.slug));
@@ -281,7 +295,7 @@ const skillRule = (action, api) => {
281
295
  return {
282
296
  decision: "block",
283
297
  ruleId: "skill.forbidden",
284
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' belongs to the skill '${slug}', which ` +
298
+ reason: `[workflow] BLOCKED: ${on} belongs to the skill '${slug}', which ` +
285
299
  `${denial === "workflow" ? "'skills.forbid_always' denies in every state" : `state '${state}' forbids`}. ` +
286
300
  `A denial beats every grant. ${enabledHere}`,
287
301
  warn: true,
@@ -290,19 +304,19 @@ const skillRule = (action, api) => {
290
304
  return {
291
305
  decision: "block",
292
306
  ruleId: "skill.not-allowed",
293
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' belongs to the skill '${slug}', which is ` +
307
+ reason: `[workflow] BLOCKED: ${on} belongs to the skill '${slug}', which is ` +
294
308
  `not enabled in state '${state}'. ` +
295
309
  enabledHere,
296
310
  warn: true,
297
311
  };
298
- };
312
+ }
299
313
  /**
300
314
  * Safety rule: a mount the acting state does not have is unreachable.
301
315
  *
302
316
  * The enforcement half of mount governance (declared by `mounts.allow` at the
303
317
  * spec root and on a state), and the mirror of {@link skillRule}: it binds every
304
- * path-classified tool plus {@link RUN_TOOL}, so a mount the state cannot see
305
- * can be neither read, listed, searched, written, nor executed from.
318
+ * declared path of every call, so a mount the state cannot see can be neither
319
+ * read, listed, searched, written, removed, nor executed from.
306
320
  *
307
321
  * Two kinds of mount, one rule. A **governed** mount (`MountSpec.governed`) is
308
322
  * closed by default: reachable only where `enabledMounts` names it. An
@@ -326,14 +340,19 @@ const skillRule = (action, api) => {
326
340
  const mountRule = (action, api) => {
327
341
  if (action.kind !== "tool-call" || action.origin === "lifecycle")
328
342
  return null;
343
+ for (const path of pathsOf(action, api)) {
344
+ const verdict = mountVerdict(action, path, api);
345
+ if (verdict)
346
+ return verdict;
347
+ }
348
+ return null;
349
+ };
350
+ /** {@link mountRule} for one of a call's paths. */
351
+ function mountVerdict(action, path, api) {
329
352
  const mounts = api.mountPrefixes ?? NO_MOUNTS;
330
- const { tool, args, state } = action;
331
- if (!PATH_TOOLS.has(tool) && tool !== RUN_TOOL)
332
- return null;
333
- const target = pathArg(args);
334
- if (target == null)
335
- return null;
336
- const name = mountNameOf(normalizeZonePath(target), mounts);
353
+ const { state } = action;
354
+ const on = subject(action, path, api);
355
+ const name = mountNameOf(normalizeZonePath(path.path), mounts);
337
356
  if (name == null)
338
357
  return null;
339
358
  const denial = api.machine.mountDenial(state, name);
@@ -345,12 +364,12 @@ const mountRule = (action, api) => {
345
364
  // mount to reads without hiding it. The zone rule has already refused a
346
365
  // write into a mount the host serves read-only, so this only ever fires on
347
366
  // one the host declared writable.
348
- if (!WRITE_TOOLS.has(tool) || api.machine.mountWritable(state, name, mounts))
367
+ if (!MUTATING_ACCESSES.has(path.access) || api.machine.mountWritable(state, name, mounts))
349
368
  return null;
350
369
  return {
351
370
  decision: "block",
352
371
  ruleId: "mount.read-only",
353
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' targets the mount '${name}', which state ` +
372
+ reason: `[workflow] BLOCKED: ${on} targets the mount '${name}', which state ` +
354
373
  `'${state}' may read but not write ('access: read'). Write your files under ` +
355
374
  `'${SESSION_OPEN_DIR}/…' instead.`,
356
375
  warn: true,
@@ -363,7 +382,7 @@ const mountRule = (action, api) => {
363
382
  return {
364
383
  decision: "block",
365
384
  ruleId: "mount.forbidden",
366
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' is under the mount '${name}', which ` +
385
+ reason: `[workflow] BLOCKED: ${on} is under the mount '${name}', which ` +
367
386
  `${denial === "workflow" ? "'mounts.forbid_always' denies in every state" : `state '${state}' forbids`}. ` +
368
387
  `A denial beats every grant. ${enabledHere}`,
369
388
  warn: true,
@@ -372,12 +391,12 @@ const mountRule = (action, api) => {
372
391
  return {
373
392
  decision: "block",
374
393
  ruleId: "mount.not-allowed",
375
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' is under the mount '${name}', which state ` +
394
+ reason: `[workflow] BLOCKED: ${on} is under the mount '${name}', which state ` +
376
395
  `'${state}' does not have. The path exists; this state was not given it. ` +
377
396
  enabledHere,
378
397
  warn: true,
379
398
  };
380
- };
399
+ }
381
400
  /**
382
401
  * An **ancestor's** `mounts.forbid_always`, as rules the descendant inherits.
383
402
  * The mount twin of {@link compileForbiddenSkillRules}: a child machine resolves
@@ -397,19 +416,17 @@ export function compileForbiddenMountRules(names, source) {
397
416
  if (action.kind !== "tool-call")
398
417
  return null;
399
418
  const mounts = api.mountPrefixes ?? NO_MOUNTS;
400
- const { tool, args } = action;
401
- if (!PATH_TOOLS.has(tool) && tool !== RUN_TOOL)
402
- return null;
403
- const target = pathArg(args);
404
- if (target == null)
405
- return null;
406
- const name = mountNameOf(normalizeZonePath(target), mounts);
407
- if (name == null || !denied.has(name))
419
+ let name = null;
420
+ const path = pathsOf(action, api).find((p) => {
421
+ name = mountNameOf(normalizeZonePath(p.path), mounts);
422
+ return name != null && denied.has(name);
423
+ });
424
+ if (!path || name == null)
408
425
  return null;
409
426
  return {
410
427
  decision: "block",
411
428
  ruleId: "mount.forbidden",
412
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' is under the mount '${name}', which ` +
429
+ reason: `[workflow] BLOCKED: ${subject(action, path, api)} is under the mount '${name}', which ` +
413
430
  `workflow '${source}' denies in every state it delegates to ` +
414
431
  `(mounts.forbid_always). A denial beats every grant.`,
415
432
  warn: true,
@@ -434,19 +451,17 @@ export function compileForbiddenSkillRules(slugs, source) {
434
451
  const skills = api.skills ?? NO_SKILLS;
435
452
  if (skills.length === 0)
436
453
  return null;
437
- const { tool, args } = action;
438
- if (!PATH_TOOLS.has(tool) && tool !== RUN_TOOL)
439
- return null;
440
- const target = pathArg(args);
441
- if (target == null)
442
- return null;
443
- const slug = skillOfPath(target, skills);
444
- if (slug == null || !denied.has(slug))
454
+ let slug = null;
455
+ const path = pathsOf(action, api).find((p) => {
456
+ slug = skillOfPath(p.path, skills);
457
+ return slug != null && denied.has(slug);
458
+ });
459
+ if (!path || slug == null)
445
460
  return null;
446
461
  return {
447
462
  decision: "block",
448
463
  ruleId: "skill.forbidden",
449
- reason: `[workflow] BLOCKED: '${tool}' on '${target}' belongs to the skill '${slug}', which ` +
464
+ reason: `[workflow] BLOCKED: ${subject(action, path, api)} belongs to the skill '${slug}', which ` +
450
465
  `workflow '${source}' denies in every state it delegates to ` +
451
466
  `(skills.forbid_always). A denial beats every grant.`,
452
467
  warn: true,
@@ -461,21 +476,37 @@ export function compileForbiddenSkillRules(slugs, source) {
461
476
  * Evaluated after the safety, policy, and consumer rules (any of which may
462
477
  * still block it), just ahead of the per-state defaults — so it loosens only
463
478
  * the state `allow` list, never the governed run paths outside it.
479
+ *
480
+ * It opens these areas only to an **essential** tool — one every state already
481
+ * permits (the file tools, the file operations, the host's `essentialTools`) —
482
+ * sparing it a grant it does not need where a state narrows it. Any other tool
483
+ * is left to the state's grant: declaring a host tool's path arguments only ever
484
+ * narrows what it may do, and never makes it callable in a state that does not
485
+ * grant it.
464
486
  */
465
- const runOpenAccessRule = (action) => {
487
+ const runOpenAccessRule = (action, api) => {
466
488
  if (action.kind !== "tool-call")
467
489
  return null;
468
- const target = pathArg(action.args);
469
- if (target == null)
490
+ if (!(api.machine ? api.machine.isEssential(action.tool) : ESSENTIAL_TOOLS.has(action.tool)))
470
491
  return null;
471
- const zone = classifyWorkspacePath(target);
472
- if (zone === "run-open" && SESSION_OPEN_TOOLS.has(action.tool)) {
473
- return { decision: "allow", ruleId: "tool.scratchpad" };
474
- }
475
- if (zone === "run-offload" && READ_TOOLS.has(action.tool)) {
476
- return { decision: "allow", ruleId: "tool.offload-read" };
477
- }
478
- return null;
492
+ const paths = pathsOf(action, api);
493
+ if (paths.length === 0)
494
+ return null;
495
+ // Open only when every path the call names is: a copy from a mount into the
496
+ // scratchpad is still decided by the state's grant for the tool.
497
+ const zones = paths.map((p) => {
498
+ const zone = classifyWorkspacePath(p.path);
499
+ if (zone === "run-open" && SCRATCHPAD_ACCESSES.has(p.access))
500
+ return "open";
501
+ if (zone === "run-offload" && OFFLOAD_ACCESSES.has(p.access))
502
+ return "offload";
503
+ return null;
504
+ });
505
+ if (zones.some((zone) => zone === null))
506
+ return null;
507
+ return zones.every((zone) => zone === "offload")
508
+ ? { decision: "allow", ruleId: "tool.offload-read" }
509
+ : { decision: "allow", ruleId: "tool.scratchpad" };
479
510
  };
480
511
  /**
481
512
  * Default rule: the active state's `tools` governance (allow-list matching
@@ -531,23 +562,41 @@ function unresolvedGuardVerdict(state, tool, failure) {
531
562
  * widen it; a state's entry may still *narrow* execution within the bundles.
532
563
  */
533
564
  const scriptSkillOnlyRule = (action, api) => {
534
- if (action.kind !== "tool-call" || action.tool !== RUN_TOOL)
535
- return null;
536
- const target = pathArg(action.args);
537
- if (target == null)
565
+ if (action.kind !== "tool-call")
538
566
  return null;
539
- if (skillOfPath(target, api.skills ?? NO_SKILLS) != null)
567
+ const path = pathsOf(action, api).find((p) => p.access === "execute" && skillOfPath(p.path, api.skills ?? NO_SKILLS) == null);
568
+ if (!path)
540
569
  return null;
541
570
  return {
542
571
  decision: "block",
543
572
  ruleId: "script.skill-only",
544
- reason: `[workflow] BLOCKED: '${RUN_TOOL}' may only execute scripts that live in a skill bundle, ` +
545
- `and '${target}' is not in one. Scripts the agent runs belong in ` +
573
+ reason: `[workflow] BLOCKED: '${action.tool}' may only execute scripts that live in a skill bundle, ` +
574
+ `and '${path.path}' is not in one. Scripts the agent runs belong in ` +
546
575
  `'skills/<capability>/scripts/'; lifecycle hook scripts live on the authoring plane and are ` +
547
576
  `run by the harness, never by the agent.`,
548
577
  warn: true,
549
578
  };
550
579
  };
580
+ /**
581
+ * Safety rule: a declared path argument holds a path or a list of paths. Any
582
+ * other value — a number, an object, a list holding one — is a shape no path
583
+ * rule can read, and a path inside it would pass every rule unseen, so the call
584
+ * is refused before them (`machine/tool-paths.ts`, `malformedPathArgs`).
585
+ */
586
+ const pathArgumentRule = (action, api) => {
587
+ if (action.kind !== "tool-call")
588
+ return null;
589
+ const [arg] = malformedPathArgs(action.tool, toolPathsOf(action.tool, api), action.args);
590
+ if (arg === undefined)
591
+ return null;
592
+ return {
593
+ decision: "block",
594
+ ruleId: "tool.path-argument",
595
+ reason: `[workflow] BLOCKED: '${action.tool}' argument '${arg}' must be a path or a list of paths ` +
596
+ `(strings); it holds something else, so the path rules cannot read it.`,
597
+ warn: true,
598
+ };
599
+ };
551
600
  /**
552
601
  * Safety rule: the authoring plane is not addressable. Every
553
602
  * {@link AUTHORING_PREFIXES} prefix (`workflows/**`) has no route
@@ -556,19 +605,23 @@ const scriptSkillOnlyRule = (action, api) => {
556
605
  * every state whatever it allows — the plane's isolation is structural, not
557
606
  * granted.
558
607
  */
559
- const governancePlaneRule = (action) => {
608
+ const governancePlaneRule = (action, api) => {
560
609
  if (action.kind !== "tool-call")
561
610
  return null;
562
611
  if (action.origin !== "script" && action.origin !== "lifecycle")
563
612
  return null;
564
- const target = pathArg(action.args);
565
- if (target == null)
566
- return null;
567
- const { path: rel, escapes } = canonicalizeRelPath(target);
568
- if (escapes)
569
- return null;
570
- const prefix = authoringPlanePrefix(rel);
571
- if (!prefix)
613
+ // A tool that declares no paths is still read by its conventional path
614
+ // argument here: the plane's isolation does not wait on a declaration.
615
+ const declared = pathsOf(action, api).map((p) => p.path);
616
+ const fallback = action.args.file_path ?? action.args.path;
617
+ const targets = declared.length > 0 ? declared : typeof fallback === "string" ? [fallback] : [];
618
+ let prefix = null;
619
+ const target = targets.find((candidate) => {
620
+ const { path: rel, escapes } = canonicalizeRelPath(candidate);
621
+ prefix = escapes ? null : authoringPlanePrefix(rel);
622
+ return prefix != null;
623
+ });
624
+ if (target == null || prefix == null)
572
625
  return null;
573
626
  return {
574
627
  decision: "block",
@@ -584,6 +637,8 @@ const governancePlaneRule = (action) => {
584
637
  * none of those can loosen them.
585
638
  */
586
639
  const SAFETY_RULES = [
640
+ // First: every path rule below reads the declared paths, which this vouches for.
641
+ pathArgumentRule,
587
642
  governancePlaneRule,
588
643
  scriptSkillOnlyRule,
589
644
  replyOnlyRule,
@@ -623,23 +678,24 @@ export function compileForbidRules(entries, options) {
623
678
  const from = source ? ` inherited from workflow '${source}'` : "";
624
679
  const rules = [];
625
680
  for (const entry of entries) {
626
- const { tool, argMatchers } = normalizeAllowEntry(entry);
681
+ const normalized = normalizeAllowEntry(entry);
682
+ const { tool, argMatchers } = normalized;
627
683
  if (!tool)
628
684
  continue; // reported by the schema
629
685
  const anyTool = tool === FORBID_ANY_TOOL;
630
686
  const key = scope === "workflow" ? "tools.forbid_always" : `states.${state}.tools.forbid`;
631
687
  const where = scope === "workflow" ? `the workflow${from}` : `state '${state}'`;
632
- rules.push((action, { variables }) => {
688
+ rules.push((action, api) => {
633
689
  if (action.kind !== "tool-call")
634
690
  return null;
635
691
  if (!anyTool && action.tool !== tool)
636
692
  return null;
637
693
  if (scope === "state" && action.state !== state)
638
694
  return null;
639
- const guards = argMatchers ? describeGuards(argMatchers) : null;
640
- if (argMatchers && !matchesEveryGuard(action.args, argMatchers, variables ?? {}))
695
+ const guards = argMatchers ? describeGuards(normalized) : null;
696
+ if (argMatchers && !denialMatches(normalized, action, api))
641
697
  return null;
642
- const target = typeof action.args.file_path === "string" ? ` on '${action.args.file_path}'` : "";
698
+ const target = describeTargets(action, api);
643
699
  return {
644
700
  decision: "block",
645
701
  ruleId: scope === "workflow" ? "tool.forbidden" : "tool.forbidden-here",
@@ -653,28 +709,51 @@ export function compileForbidRules(entries, options) {
653
709
  return rules;
654
710
  }
655
711
  /**
656
- * Whether every one of an entry's argument guards matches the call's arguments.
712
+ * Whether a denial's guards match the call.
657
713
  *
658
- * Resolution is the allow side's own (`argsSatisfy`), so a guard binds the same
659
- * calls whichever list it is written in: `${{…}}` resolves against the run's
660
- * variables and a dotted name traverses nested arguments. A denial that
661
- * understood fewer guard forms than the grant beside it would silently match
662
- * nothing, which is the shape of issue #156.
714
+ * Resolution is the allow side's own, so a guard binds the same calls whichever
715
+ * list it is written in: `${{…}}` resolves against the run's variables and a
716
+ * dotted name traverses nested arguments. A denial that understood fewer guard
717
+ * forms than the grant beside it would silently match nothing, which is the
718
+ * shape of issue #156. A `paths:` guard matches when **any** path the call names
719
+ * does (the grant side needs every one).
663
720
  *
664
721
  * The one asymmetry is deliberate. An allow entry whose reference cannot be
665
722
  * resolved grants nothing; a forbid entry in the same position must **match**,
666
723
  * so an unresolvable guard closes the call rather than opening it.
667
724
  */
668
- function matchesEveryGuard(args, argMatchers, variables) {
725
+ function denialMatches(entry, action, api) {
726
+ const variables = api.variables ?? {};
727
+ if (entry.fromPaths) {
728
+ const values = pathValuesOf(action.tool, toolPathsOf(action.tool, api), action.args);
729
+ return pathGuardMatches(entry, values, "any", variables) ?? true;
730
+ }
669
731
  let unresolved = false;
670
- const satisfied = argsSatisfy(argMatchers, args, variables, () => {
732
+ const satisfied = argsSatisfy(entry.argMatchers, action.args, variables, () => {
671
733
  unresolved = true;
672
734
  });
673
735
  return satisfied || unresolved;
674
736
  }
675
- /** An entry's guards, for the refusal reason: `file_path=logs/**`. */
676
- function describeGuards(argMatchers) {
677
- return Object.entries(argMatchers)
737
+ /**
738
+ * The paths a denied call named, for the refusal reason: ` on 'x'`, or for a
739
+ * tool declaring several, ` on 'a' (source), 'b' (destination)`. A tool that
740
+ * declares none is named by its `file_path`, as before the path table.
741
+ */
742
+ function describeTargets(action, api) {
743
+ const paths = pathsOf(action, api);
744
+ if (paths.length === 0) {
745
+ return typeof action.args.file_path === "string" ? ` on '${action.args.file_path}'` : "";
746
+ }
747
+ return ` on ${paths.map((p) => pathLabel(action, p, api)).join(", ")}`;
748
+ }
749
+ /**
750
+ * An entry's guards, for the refusal reason: `file_path=logs/**`, or for the
751
+ * `paths:` shorthand — which guards every path argument — `paths=logs/**`.
752
+ */
753
+ function describeGuards(entry) {
754
+ if (entry.fromPaths)
755
+ return `paths=${(entry.argMatchers?.file_path ?? []).join("|")}`;
756
+ return Object.entries(entry.argMatchers ?? {})
678
757
  .map(([name, globs]) => `${name}=${globs.join("|")}`)
679
758
  .join(", ");
680
759
  }