@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.
- package/README.md +3 -1
- package/dist/assembly/compose.d.ts +40 -3
- package/dist/assembly/compose.js +85 -8
- package/dist/assembly/compose.js.map +1 -1
- package/dist/assembly/delegation-registry.js +5 -2
- package/dist/assembly/delegation-registry.js.map +1 -1
- package/dist/assembly/image-reads.d.ts +53 -0
- package/dist/assembly/image-reads.js +163 -0
- package/dist/assembly/image-reads.js.map +1 -0
- package/dist/assembly/index.d.ts +21 -0
- package/dist/assembly/index.js +7 -1
- package/dist/assembly/index.js.map +1 -1
- package/dist/assembly/plain.js +10 -4
- package/dist/assembly/plain.js.map +1 -1
- package/dist/authoring-skill/archmax-harness/references/backend-integration.md +59 -1
- package/dist/authoring-skill/archmax-harness/references/hook-and-test-scripts.md +27 -1
- package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +71 -22
- package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +24 -5
- package/dist/core/binary-read.d.ts +44 -0
- package/dist/core/binary-read.js +93 -0
- package/dist/core/binary-read.js.map +1 -0
- package/dist/core/binary-types.d.ts +19 -0
- package/dist/core/binary-types.js +54 -0
- package/dist/core/binary-types.js.map +1 -0
- package/dist/core/file-operations.d.ts +43 -0
- package/dist/core/file-operations.js +368 -0
- package/dist/core/file-operations.js.map +1 -0
- package/dist/core/path-mapping.d.ts +17 -3
- package/dist/core/path-mapping.js +32 -1
- package/dist/core/path-mapping.js.map +1 -1
- package/dist/core/platform-prompt.generated.js +1 -1
- package/dist/core/platform-prompt.generated.js.map +1 -1
- package/dist/core/prompt.d.ts +27 -0
- package/dist/core/prompt.js +65 -5
- package/dist/core/prompt.js.map +1 -1
- package/dist/core/raw-download.d.ts +16 -0
- package/dist/core/raw-download.js +56 -0
- package/dist/core/raw-download.js.map +1 -0
- package/dist/core/session-zone.d.ts +9 -1
- package/dist/core/session-zone.js +15 -0
- package/dist/core/session-zone.js.map +1 -1
- package/dist/core/tool-context.d.ts +27 -0
- package/dist/core/tool-context.js +14 -0
- package/dist/core/tool-context.js.map +1 -0
- package/dist/core/tool-telemetry.js +2 -1
- package/dist/core/tool-telemetry.js.map +1 -1
- package/dist/core/workspace-context.js +1 -0
- package/dist/core/workspace-context.js.map +1 -1
- package/dist/core/workspace-router.d.ts +30 -1
- package/dist/core/workspace-router.js +80 -3
- package/dist/core/workspace-router.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel/kernel.js +198 -119
- package/dist/kernel/kernel.js.map +1 -1
- package/dist/machine/allow.d.ts +22 -0
- package/dist/machine/allow.js +47 -15
- package/dist/machine/allow.js.map +1 -1
- package/dist/machine/lint-spec.js +12 -3
- package/dist/machine/lint-spec.js.map +1 -1
- package/dist/machine/machine.d.ts +15 -1
- package/dist/machine/machine.js +43 -8
- package/dist/machine/machine.js.map +1 -1
- package/dist/machine/spec-schema.js +14 -0
- package/dist/machine/spec-schema.js.map +1 -1
- package/dist/machine/tool-names.d.ts +27 -7
- package/dist/machine/tool-names.js +43 -13
- package/dist/machine/tool-names.js.map +1 -1
- package/dist/machine/tool-paths.d.ts +64 -0
- package/dist/machine/tool-paths.js +155 -0
- package/dist/machine/tool-paths.js.map +1 -0
- package/dist/public/spec.d.ts +11 -1
- package/dist/public/spec.js +10 -1
- package/dist/public/spec.js.map +1 -1
- package/dist/validate/validate.js +10 -5
- package/dist/validate/validate.js.map +1 -1
- package/dist/workflow/agent-tools.d.ts +20 -1
- package/dist/workflow/agent-tools.js +35 -1
- package/dist/workflow/agent-tools.js.map +1 -1
- package/dist/workflow/control-tools.d.ts +4 -1
- package/dist/workflow/control-tools.js +16 -9
- package/dist/workflow/control-tools.js.map +1 -1
- package/dist/workflow/governance.d.ts +2 -0
- package/dist/workflow/governance.js +1 -0
- package/dist/workflow/governance.js.map +1 -1
- package/dist/workflow/middleware.d.ts +2 -0
- package/dist/workflow/middleware.js +27 -12
- package/dist/workflow/middleware.js.map +1 -1
- package/dist/workflow/on-error.js +3 -2
- package/dist/workflow/on-error.js.map +1 -1
- package/dist/workflow/parks.js +5 -5
- package/dist/workflow/parks.js.map +1 -1
- package/dist/workflow/render-prompt.d.ts +2 -0
- package/dist/workflow/render-prompt.js +9 -3
- package/dist/workflow/render-prompt.js.map +1 -1
- package/dist/workflow/returns-check.d.ts +35 -0
- package/dist/workflow/returns-check.js +10 -0
- package/dist/workflow/returns-check.js.map +1 -0
- package/dist/workflow/signature-checks.d.ts +26 -1
- package/dist/workflow/signature-checks.js +35 -10
- package/dist/workflow/signature-checks.js.map +1 -1
- package/dist/workflow/state.d.ts +75 -1
- package/dist/workflow/state.js +83 -0
- package/dist/workflow/state.js.map +1 -1
- package/dist/workflow/sub-workflow.d.ts +16 -5
- package/dist/workflow/sub-workflow.js +84 -37
- package/dist/workflow/sub-workflow.js.map +1 -1
- package/dist/workflow/tool-service.js +57 -10
- package/dist/workflow/tool-service.js.map +1 -1
- package/dist/workflow/turn-boundary.d.ts +8 -7
- package/dist/workflow/turn-boundary.js +10 -9
- package/dist/workflow/turn-boundary.js.map +1 -1
- package/dist/workflow/workflow-tools.js +5 -1
- package/dist/workflow/workflow-tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/archmax-harness/references/backend-integration.md +59 -1
- package/skills/archmax-harness/references/hook-and-test-scripts.md +27 -1
- package/skills/archmax-harness/references/workflow-schema.md +71 -22
- package/skills/archmax-harness/references/workflow-yaml.md +24 -5
package/dist/kernel/kernel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ADVANCE_TOOL, HARNESS_CONTROL_TOOLS, EVAL_TOOL,
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
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"
|
|
216
|
+
const runtimeInternalRule = (action, api) => {
|
|
217
|
+
if (action.kind !== "tool-call")
|
|
209
218
|
return null;
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
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:
|
|
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"
|
|
237
|
+
const runtimeManagedRule = (action, api) => {
|
|
238
|
+
if (action.kind !== "tool-call")
|
|
230
239
|
return null;
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
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:
|
|
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
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
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:
|
|
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
|
|
305
|
-
*
|
|
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 {
|
|
331
|
-
|
|
332
|
-
|
|
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 (!
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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:
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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:
|
|
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
|
-
|
|
469
|
-
if (target == null)
|
|
490
|
+
if (!(api.machine ? api.machine.isEssential(action.tool) : ESSENTIAL_TOOLS.has(action.tool)))
|
|
470
491
|
return null;
|
|
471
|
-
const
|
|
472
|
-
if (
|
|
473
|
-
return
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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"
|
|
535
|
-
return null;
|
|
536
|
-
const target = pathArg(action.args);
|
|
537
|
-
if (target == null)
|
|
565
|
+
if (action.kind !== "tool-call")
|
|
538
566
|
return null;
|
|
539
|
-
|
|
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: '${
|
|
545
|
-
`and '${
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
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
|
|
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,
|
|
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(
|
|
640
|
-
if (argMatchers && !
|
|
695
|
+
const guards = argMatchers ? describeGuards(normalized) : null;
|
|
696
|
+
if (argMatchers && !denialMatches(normalized, action, api))
|
|
641
697
|
return null;
|
|
642
|
-
const target =
|
|
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
|
|
712
|
+
* Whether a denial's guards match the call.
|
|
657
713
|
*
|
|
658
|
-
* Resolution is the allow side's own
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
676
|
-
|
|
677
|
-
|
|
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
|
}
|