@kontextmind/kxm 0.7.168 → 0.7.169

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.
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.168",
14
+ "version": "0.7.169",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -181,6 +181,18 @@ All notable user-facing changes are documented here. The project follows [Semant
181
181
 
182
182
  ### Changed
183
183
 
184
+ - **Tool policy is enforced at load and before dispatch.**
185
+ `coordinator` and `read-only` select the read-only one-shot profile.
186
+ `workspace-writer` and `tests-writer` select the edit profile. Dispatch
187
+ uses the narrowest of the role `permission`, that preset profile, and the
188
+ step's repository access. An agent preset wider than its role is
189
+ `tool_preset_exceeds_role` at load, and `step_unsupported` before a
190
+ producer starts if those files were already loaded. A write step whose
191
+ resolved profile is read-only is the same handoff. The detail names the
192
+ role permission and the step access. `tools.allow` and `tools.deny` stay
193
+ on the MCP surface through `enforceToolPolicy`. See
194
+ [agent files](docs/reference/config-reference.md#kxmagentsidyaml-kxmagentv1).
195
+
184
196
  - **The checked-in price catalog and model inventory match the live OpenRouter and Nous feeds.**
185
197
  `.kxm/prices.yaml` is dated 2026-09-28. It adds `xai/grok-4.7` (alias
186
198
  `x-ai/grok-4.7`) at $1.60 input and $4.80 output per 1M tokens, with cache
@@ -337,7 +337,7 @@ with `retired_agent_routing_fields`.
337
337
  | `skills` | Unique identifiers, at most 64 | Optional | Skill names granted to the agent |
338
338
  | `instructions` | String, at most 16,000 characters | Optional | Not read by any code path yet. Step `instructions` are what reach the prompt. |
339
339
  | `executor` | `local`, `ssh`, or `exe-dev` | Optional | Loader (`executor_unknown`); recorded in the executor-policy revision; no dispatch path selects an executor from it yet |
340
- | `tools.preset` | `coordinator`, `read-only`, `workspace-writer`, or `tests-writer` | Optional | Loader (`tool_preset_unknown`): the preset must be registered. An agent `tools.preset` may only narrow the role preset. Enforcement of that rule is pending (P3). |
340
+ | `tools.preset` | `coordinator`, `read-only`, `workspace-writer`, or `tests-writer` | Optional | Loader (`tool_preset_unknown`): the preset must be registered. `coordinator` and `read-only` select the read-only one-shot profile. `workspace-writer` and `tests-writer` select the edit profile. An agent preset may only narrow the role. A wider preset is `tool_preset_exceeds_role` at load. |
341
341
  | `tools.allow`, `tools.deny` | Unique identifiers, at most 128 each | Optional | A tool in both lists is `tool_policy_contradiction`; steps may only narrow the ceiling |
342
342
  | `defaultRepositoryAccess` | `none`, `read`, or `write` | Optional; the ceiling is `none` when absent | Loader: access ceiling for repositories not listed in `repositories` |
343
343
  | `repositories` | Map of repository ID to `none`, `read`, or `write`; at most 64 | Optional | Loader: per-repository access ceiling; IDs must be declared (`repository_unknown`) |
@@ -349,18 +349,30 @@ with `retired_agent_routing_fields`.
349
349
  | `session.reuse` | `compatible-run-scope` | Optional | Not read by any code path yet |
350
350
  | `session.maxIdleMs` | Integer, 0 to 31,536,000,000 | Optional | Not read by any code path yet |
351
351
 
352
- Tool presets are names checked against a registered list. The live one-shot
353
- producer launches the harness named by the selected model file, with a fixed
354
- argument set, and does not translate `tools` into harness flags. A read-only
355
- step uses the read-only set (`READ_ONLY_ONESHOT_ARGS` in
356
- `plugins/kxm/src/harness.ts`). A step with `write` access uses the audited
357
- writer set (`WRITER_ONESHOT_ARGS`), which exists only for `pi` (`-a` with
358
- extensions, skills, prompt templates and sessions off) and `grok`
359
- (`--always-approve` with subagents and web search off). Both approve every
360
- tool call, shell commands included, and neither confines the process to the
361
- checkout. A live write step on any other harness is handed off; see
352
+ Tool presets are names checked against a registered list, then mapped onto
353
+ the harness one-shot profiles in `plugins/kxm/src/harness.ts`. `coordinator`
354
+ and `read-only` use the read-only argument set (`READ_ONLY_ONESHOT_ARGS`).
355
+ `workspace-writer` and `tests-writer` use the audited writer set
356
+ (`WRITER_ONESHOT_ARGS`), which exists only for `pi` (`-a` with extensions,
357
+ skills, prompt templates and sessions off) and `grok` (`--always-approve`
358
+ with subagents and web search off). Both approve every tool call, shell
359
+ commands included, and neither confines the process to the checkout.
360
+
361
+ Dispatch permission is the narrowest of the role `permission`, the preset
362
+ profile (the role preset, then the agent preset), and the step's repository
363
+ access (`write` is edit; `read` and `none` are read-only). A preset cannot
364
+ widen a role. An agent preset that is wider fails load with
365
+ `tool_preset_exceeds_role`, and dispatch refuses that file again with
366
+ `step_unsupported` (field `tools.preset`) when the process already loaded
367
+ it. A write step whose resolved profile is read-only is `step_unsupported`
368
+ (field `permission`) before the producer starts. The detail names the role
369
+ permission and the step access. A live write that does resolve to edit
370
+ still hands off on a harness with no audited writer set; see
362
371
  [Steps the Runtime does not execute yet](#steps-the-runtime-does-not-execute-yet).
363
372
 
373
+ `tools.allow` and `tools.deny` apply to the MCP surface through
374
+ `enforceToolPolicy` in `plugins/kxm/src/commands.ts`.
375
+
364
376
  Example (the shape `kxm init` writes for `writer`):
365
377
 
366
378
  ```yaml
@@ -394,7 +406,7 @@ Runtime cannot reach the `antigravity` Pi provider yet. `kxm run` and the
394
406
  Runtime read the agents. `kxm trust` diffs them.
395
407
 
396
408
  Error codes: `retired_agent_routing_fields`, `executor_unknown`,
397
- `tool_preset_unknown`, `tool_policy_contradiction`, `repository_unknown`, and
409
+ `tool_preset_unknown`, `tool_preset_exceeds_role`, `tool_policy_contradiction`, `repository_unknown`, and
398
410
  the path codes under
399
411
  [Rules shared by the project bundle](#rules-shared-by-the-project-bundle).
400
412
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.168",
3
+ "version": "0.7.169",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.168",
5
+ "version": "0.7.169",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -20122,6 +20122,21 @@ import { pathToFileURL } from "node:url";
20122
20122
  function defaultKxmSchemaDir() {
20123
20123
  return join9(findKxmRepoRoot(import.meta.url), "schemas");
20124
20124
  }
20125
+ function toolPresetProfile(preset) {
20126
+ if (!Object.hasOwn(TOOL_PRESET_PROFILES, preset)) return void 0;
20127
+ return TOOL_PRESET_PROFILES[preset];
20128
+ }
20129
+ function toolPresetExceedsRoleMessage(preset, roleId, rolePermission, rolePreset) {
20130
+ const profile = toolPresetProfile(preset);
20131
+ if (profile !== "edit") return void 0;
20132
+ if (rolePermission === "read-only") {
20133
+ return `tools.preset ${preset} exceeds role ${roleId} permission read-only`;
20134
+ }
20135
+ if (rolePreset !== void 0 && toolPresetProfile(rolePreset) === "read-only") {
20136
+ return `tools.preset ${preset} exceeds role ${roleId} preset ${rolePreset}`;
20137
+ }
20138
+ return void 0;
20139
+ }
20125
20140
  function compareCodeUnits4(left, right) {
20126
20141
  return left < right ? -1 : left > right ? 1 : 0;
20127
20142
  }
@@ -20407,6 +20422,15 @@ function objectValue(value) {
20407
20422
  function stringValue(value) {
20408
20423
  return typeof value === "string" ? value : void 0;
20409
20424
  }
20425
+ function roleById(roles, roleId) {
20426
+ const direct = roles.get(roleId);
20427
+ if (direct) return direct;
20428
+ const folded = roleId.toLocaleLowerCase("en-US");
20429
+ for (const role of roles.values()) {
20430
+ if ((role.id ?? "").toLocaleLowerCase("en-US") === folded) return role;
20431
+ }
20432
+ return void 0;
20433
+ }
20410
20434
  function numberValue(value, fallback) {
20411
20435
  return typeof value === "number" ? value : fallback;
20412
20436
  }
@@ -20917,10 +20941,29 @@ function validateBundle(project, repositories, agents, models, workflows, enviro
20917
20941
  if (executor && !executors.has(executor)) issues.push(issue3("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
20918
20942
  const preset = stringValue(objectValue(agent.value.tools)?.preset);
20919
20943
  if (preset && !presets.has(preset)) issues.push(issue3("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
20944
+ const roleId = stringValue(agent.value.role);
20945
+ const role = roleId ? roleById(roles, roleId) : void 0;
20946
+ const rolePermission = stringValue(role?.value.permission);
20947
+ if (preset && role && (rolePermission === "edit" || rolePermission === "read-only")) {
20948
+ const message = toolPresetExceedsRoleMessage(
20949
+ preset,
20950
+ role.id ?? roleId ?? "role",
20951
+ rolePermission,
20952
+ stringValue(objectValue(role.value.tools)?.preset)
20953
+ );
20954
+ if (message) issues.push(issue3("semantic", "tool_preset_exceeds_role", agent.logicalPath, message));
20955
+ }
20920
20956
  for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
20921
20957
  if (!repositoryIds.has(repositoryId)) issues.push(issue3("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
20922
20958
  }
20923
20959
  }
20960
+ for (const role of roles.values()) {
20961
+ const permission = stringValue(role.value.permission);
20962
+ const rolePreset = stringValue(objectValue(role.value.tools)?.preset);
20963
+ if (permission === "read-only" && rolePreset && toolPresetProfile(rolePreset) === "edit") {
20964
+ issues.push(issue3("semantic", "tool_preset_exceeds_role", role.logicalPath, `tools.preset ${rolePreset} exceeds permission read-only`));
20965
+ }
20966
+ }
20924
20967
  validateModelReferences(models, issues);
20925
20968
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
20926
20969
  if (!lookupKxmResource(workflows.values(), defaultWorkflow, "workflow")) issues.push(issue3("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
@@ -21398,7 +21441,7 @@ function planKxmInitialization(start = process.cwd(), options = {}) {
21398
21441
  }
21399
21442
  return { mode: "create", inspectedFrom, projectRoot: candidateRoot, changesRequired: true, issues: [], legacyInputs: [] };
21400
21443
  }
21401
- var import__, KxmConfigError, DEFAULT_SCHEMA_DIR, RESOURCE_SCHEMA, IDENTIFIER2, WINDOWS_RESERVED, BUILTIN_EXECUTORS, BUILTIN_TOOL_PRESETS, SECRET_VALUE_PATTERNS, KxmSchemaRegistry, cachedRunEventRegistry, GATE_STEP_OUTCOMES, LEGACY_CONFIG_FILES;
21444
+ var import__, KxmConfigError, DEFAULT_SCHEMA_DIR, RESOURCE_SCHEMA, IDENTIFIER2, WINDOWS_RESERVED, BUILTIN_EXECUTORS, TOOL_PRESET_PROFILES, BUILTIN_TOOL_PRESETS, SECRET_VALUE_PATTERNS, KxmSchemaRegistry, cachedRunEventRegistry, GATE_STEP_OUTCOMES, LEGACY_CONFIG_FILES;
21402
21445
  var init_project_config = __esm({
21403
21446
  "plugins/kxm/src/project-config.ts"() {
21404
21447
  "use strict";
@@ -21432,7 +21475,13 @@ var init_project_config = __esm({
21432
21475
  IDENTIFIER2 = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
21433
21476
  WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$|clock\$)$/i;
21434
21477
  BUILTIN_EXECUTORS = ["local", "ssh", "exe-dev"];
21435
- BUILTIN_TOOL_PRESETS = ["coordinator", "read-only", "workspace-writer", "tests-writer"];
21478
+ TOOL_PRESET_PROFILES = Object.freeze({
21479
+ coordinator: "read-only",
21480
+ "read-only": "read-only",
21481
+ "workspace-writer": "edit",
21482
+ "tests-writer": "edit"
21483
+ });
21484
+ BUILTIN_TOOL_PRESETS = Object.keys(TOOL_PRESET_PROFILES);
21436
21485
  SECRET_VALUE_PATTERNS = [
21437
21486
  /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/,
21438
21487
  /\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/,
@@ -27709,6 +27758,68 @@ function unsupportedAgentStepTimeout(step, limitMs) {
27709
27758
  detail: "step timeoutMs is wider than project limits.agentStepTimeoutMs"
27710
27759
  };
27711
27760
  }
27761
+ function narrowerPermission(left, right) {
27762
+ return left === "read-only" || right === "read-only" ? "read-only" : "edit";
27763
+ }
27764
+ function stepRepositoryPermission(step) {
27765
+ return Object.values(step.repositories).some((access) => access === "write") ? "edit" : "read-only";
27766
+ }
27767
+ function stepAccessDetail(step) {
27768
+ const entries = Object.entries(step.repositories);
27769
+ if (entries.length === 0) return "none";
27770
+ return entries.map(([id, access]) => `${id} ${access}`).sort((left, right) => left < right ? -1 : left > right ? 1 : 0).join(", ");
27771
+ }
27772
+ function yamlToolsPreset(doc) {
27773
+ const tools = doc?.tools;
27774
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) return void 0;
27775
+ const preset = tools.preset;
27776
+ return typeof preset === "string" && preset.length > 0 ? preset : void 0;
27777
+ }
27778
+ function resolveToolDispatch(projectRoot, step, agentId) {
27779
+ const stepPermission = stepRepositoryPermission(step);
27780
+ if (step.kind !== "agent" && step.kind !== "moa") return { permission: stepPermission };
27781
+ const agentFile = findYamlBasename(join19(projectRoot, ".kxm", "agents"), agentId, "agent") ?? agentId;
27782
+ const agent = readYamlFile(join19(projectRoot, ".kxm", "agents", `${agentFile}.yaml`));
27783
+ const roleId = typeof agent?.role === "string" ? agent.role : "";
27784
+ if (!roleId) return { permission: stepPermission };
27785
+ const roleDoc = readYamlFile(join19(projectRoot, ".kxm", "roles", `${roleId}.yaml`));
27786
+ const rawPermission = roleDoc?.permission;
27787
+ const rolePermission = rawPermission === "edit" || rawPermission === "read-only" ? rawPermission : void 0;
27788
+ if (!rolePermission) return { permission: stepPermission };
27789
+ const rolePreset = yamlToolsPreset(roleDoc);
27790
+ const agentPreset = yamlToolsPreset(agent);
27791
+ const rolePresetProfile = rolePreset ? toolPresetProfile(rolePreset) : void 0;
27792
+ if (rolePreset && rolePresetProfile === "edit" && rolePermission === "read-only") {
27793
+ return {
27794
+ error: {
27795
+ reason: "step_unsupported",
27796
+ field: "tools.preset",
27797
+ detail: `tools.preset ${rolePreset} exceeds role ${roleId} permission read-only`
27798
+ }
27799
+ };
27800
+ }
27801
+ if (agentPreset) {
27802
+ const exceeds = toolPresetExceedsRoleMessage(agentPreset, roleId, rolePermission, rolePreset);
27803
+ if (exceeds) {
27804
+ return { error: { reason: "step_unsupported", field: "tools.preset", detail: exceeds } };
27805
+ }
27806
+ }
27807
+ let chain = rolePermission;
27808
+ if (rolePresetProfile) chain = narrowerPermission(chain, rolePresetProfile);
27809
+ const agentProfile = agentPreset ? toolPresetProfile(agentPreset) : void 0;
27810
+ if (agentProfile) chain = narrowerPermission(chain, agentProfile);
27811
+ const permission = narrowerPermission(chain, stepPermission);
27812
+ if (stepPermission === "edit" && permission === "read-only") {
27813
+ return {
27814
+ error: {
27815
+ reason: "step_unsupported",
27816
+ field: "permission",
27817
+ detail: `role permission ${rolePermission} resolves to ${chain}; step access ${stepAccessDetail(step)}`
27818
+ }
27819
+ };
27820
+ }
27821
+ return { permission };
27822
+ }
27712
27823
  function kxmLiveRunPrerequisites(bundle, workflowId, projectRoot) {
27713
27824
  const workflow = lookupKxmResource(bundle.workflows.values(), workflowId, "workflow");
27714
27825
  if (!workflow) throw runtimeError("run_workflow_unknown", workflowId, `workflow ${workflowId} does not exist in this project`);
@@ -27736,6 +27847,11 @@ function kxmLiveRunPrerequisites(bundle, workflowId, projectRoot) {
27736
27847
  if (step.kind !== "agent" && step.kind !== "moa") continue;
27737
27848
  const agentIds = step.assignments.allowedAgents.length > 0 ? step.assignments.allowedAgents : [step.agent];
27738
27849
  for (const agentId of agentIds) {
27850
+ const toolDispatch = resolveToolDispatch(projectRoot, step, agentId);
27851
+ if ("error" in toolDispatch) {
27852
+ prerequisites.push({ ...toolDispatch.error, stepId });
27853
+ continue;
27854
+ }
27739
27855
  const route = resolveProducerRoute(projectRoot, step, agentId);
27740
27856
  if ("error" in route) {
27741
27857
  const detail = route.error.detail === "producer_route_unsupported: agent step model is not honored" ? `${agentId}: agent step model is not honored; remove model from the step` : `${agentId}: ${route.error.detail}; set role in .kxm/agents/${agentId}.yaml and admit the route model with kxm routes admit --model <provider/model>`;
@@ -27743,7 +27859,7 @@ function kxmLiveRunPrerequisites(bundle, workflowId, projectRoot) {
27743
27859
  continue;
27744
27860
  }
27745
27861
  const harness = route.harness;
27746
- const permission = Object.values(step.repositories).includes("write") ? "edit" : "read-only";
27862
+ const permission = toolDispatch.permission;
27747
27863
  if (!BUILTIN_HARNESSES.some((entry) => entry.id === harness && entry.oneShot) || !oneShotPermissionArgs(harness, permission)) {
27748
27864
  prerequisites.push({ reason: "step_unsupported", stepId, field: "harness", detail: `${agentId}: ${harness} has no audited ${permission} one-shot profile; use a supported workflow or execute this work directly in ${harness}, without substituting another harness` });
27749
27865
  continue;
@@ -25438,7 +25438,7 @@ function sessionTokenFixHint(policy) {
25438
25438
  }
25439
25439
 
25440
25440
  // plugins/kxm/src/mcp-server.ts
25441
- var VERSION = "0.7.168";
25441
+ var VERSION = "0.7.169";
25442
25442
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
25443
25443
  var inbox = /* @__PURE__ */ new Map();
25444
25444
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -17261,7 +17261,28 @@ var RESOURCE_SCHEMA = Object.freeze({
17261
17261
  var IDENTIFIER2 = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
17262
17262
  var WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$|clock\$)$/i;
17263
17263
  var BUILTIN_EXECUTORS = ["local", "ssh", "exe-dev"];
17264
- var BUILTIN_TOOL_PRESETS = ["coordinator", "read-only", "workspace-writer", "tests-writer"];
17264
+ var TOOL_PRESET_PROFILES = Object.freeze({
17265
+ coordinator: "read-only",
17266
+ "read-only": "read-only",
17267
+ "workspace-writer": "edit",
17268
+ "tests-writer": "edit"
17269
+ });
17270
+ var BUILTIN_TOOL_PRESETS = Object.keys(TOOL_PRESET_PROFILES);
17271
+ function toolPresetProfile(preset) {
17272
+ if (!Object.hasOwn(TOOL_PRESET_PROFILES, preset)) return void 0;
17273
+ return TOOL_PRESET_PROFILES[preset];
17274
+ }
17275
+ function toolPresetExceedsRoleMessage(preset, roleId, rolePermission, rolePreset) {
17276
+ const profile = toolPresetProfile(preset);
17277
+ if (profile !== "edit") return void 0;
17278
+ if (rolePermission === "read-only") {
17279
+ return `tools.preset ${preset} exceeds role ${roleId} permission read-only`;
17280
+ }
17281
+ if (rolePreset !== void 0 && toolPresetProfile(rolePreset) === "read-only") {
17282
+ return `tools.preset ${preset} exceeds role ${roleId} preset ${rolePreset}`;
17283
+ }
17284
+ return void 0;
17285
+ }
17265
17286
  var SECRET_VALUE_PATTERNS = [
17266
17287
  /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/,
17267
17288
  /\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/,
@@ -17661,6 +17682,15 @@ function objectValue(value) {
17661
17682
  function stringValue(value) {
17662
17683
  return typeof value === "string" ? value : void 0;
17663
17684
  }
17685
+ function roleById(roles, roleId) {
17686
+ const direct = roles.get(roleId);
17687
+ if (direct) return direct;
17688
+ const folded = roleId.toLocaleLowerCase("en-US");
17689
+ for (const role of roles.values()) {
17690
+ if ((role.id ?? "").toLocaleLowerCase("en-US") === folded) return role;
17691
+ }
17692
+ return void 0;
17693
+ }
17664
17694
  function numberValue(value, fallback) {
17665
17695
  return typeof value === "number" ? value : fallback;
17666
17696
  }
@@ -18175,10 +18205,29 @@ function validateBundle(project, repositories, agents, models, workflows, enviro
18175
18205
  if (executor && !executors.has(executor)) issues.push(issue3("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
18176
18206
  const preset = stringValue(objectValue(agent.value.tools)?.preset);
18177
18207
  if (preset && !presets.has(preset)) issues.push(issue3("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
18208
+ const roleId = stringValue(agent.value.role);
18209
+ const role = roleId ? roleById(roles, roleId) : void 0;
18210
+ const rolePermission = stringValue(role?.value.permission);
18211
+ if (preset && role && (rolePermission === "edit" || rolePermission === "read-only")) {
18212
+ const message = toolPresetExceedsRoleMessage(
18213
+ preset,
18214
+ role.id ?? roleId ?? "role",
18215
+ rolePermission,
18216
+ stringValue(objectValue(role.value.tools)?.preset)
18217
+ );
18218
+ if (message) issues.push(issue3("semantic", "tool_preset_exceeds_role", agent.logicalPath, message));
18219
+ }
18178
18220
  for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
18179
18221
  if (!repositoryIds.has(repositoryId)) issues.push(issue3("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
18180
18222
  }
18181
18223
  }
18224
+ for (const role of roles.values()) {
18225
+ const permission = stringValue(role.value.permission);
18226
+ const rolePreset = stringValue(objectValue(role.value.tools)?.preset);
18227
+ if (permission === "read-only" && rolePreset && toolPresetProfile(rolePreset) === "edit") {
18228
+ issues.push(issue3("semantic", "tool_preset_exceeds_role", role.logicalPath, `tools.preset ${rolePreset} exceeds permission read-only`));
18229
+ }
18230
+ }
18182
18231
  validateModelReferences(models, issues);
18183
18232
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
18184
18233
  if (!lookupKxmResource(workflows.values(), defaultWorkflow, "workflow")) issues.push(issue3("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
@@ -29377,6 +29426,68 @@ function unsupportedAgentStepTimeout(step, limitMs) {
29377
29426
  detail: "step timeoutMs is wider than project limits.agentStepTimeoutMs"
29378
29427
  };
29379
29428
  }
29429
+ function narrowerPermission(left, right) {
29430
+ return left === "read-only" || right === "read-only" ? "read-only" : "edit";
29431
+ }
29432
+ function stepRepositoryPermission(step) {
29433
+ return Object.values(step.repositories).some((access) => access === "write") ? "edit" : "read-only";
29434
+ }
29435
+ function stepAccessDetail(step) {
29436
+ const entries = Object.entries(step.repositories);
29437
+ if (entries.length === 0) return "none";
29438
+ return entries.map(([id, access]) => `${id} ${access}`).sort((left, right) => left < right ? -1 : left > right ? 1 : 0).join(", ");
29439
+ }
29440
+ function yamlToolsPreset(doc) {
29441
+ const tools = doc?.tools;
29442
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) return void 0;
29443
+ const preset = tools.preset;
29444
+ return typeof preset === "string" && preset.length > 0 ? preset : void 0;
29445
+ }
29446
+ function resolveToolDispatch(projectRoot, step, agentId) {
29447
+ const stepPermission = stepRepositoryPermission(step);
29448
+ if (step.kind !== "agent" && step.kind !== "moa") return { permission: stepPermission };
29449
+ const agentFile = findYamlBasename(join18(projectRoot, ".kxm", "agents"), agentId, "agent") ?? agentId;
29450
+ const agent = readYamlFile(join18(projectRoot, ".kxm", "agents", `${agentFile}.yaml`));
29451
+ const roleId = typeof agent?.role === "string" ? agent.role : "";
29452
+ if (!roleId) return { permission: stepPermission };
29453
+ const roleDoc = readYamlFile(join18(projectRoot, ".kxm", "roles", `${roleId}.yaml`));
29454
+ const rawPermission = roleDoc?.permission;
29455
+ const rolePermission = rawPermission === "edit" || rawPermission === "read-only" ? rawPermission : void 0;
29456
+ if (!rolePermission) return { permission: stepPermission };
29457
+ const rolePreset = yamlToolsPreset(roleDoc);
29458
+ const agentPreset = yamlToolsPreset(agent);
29459
+ const rolePresetProfile = rolePreset ? toolPresetProfile(rolePreset) : void 0;
29460
+ if (rolePreset && rolePresetProfile === "edit" && rolePermission === "read-only") {
29461
+ return {
29462
+ error: {
29463
+ reason: "step_unsupported",
29464
+ field: "tools.preset",
29465
+ detail: `tools.preset ${rolePreset} exceeds role ${roleId} permission read-only`
29466
+ }
29467
+ };
29468
+ }
29469
+ if (agentPreset) {
29470
+ const exceeds = toolPresetExceedsRoleMessage(agentPreset, roleId, rolePermission, rolePreset);
29471
+ if (exceeds) {
29472
+ return { error: { reason: "step_unsupported", field: "tools.preset", detail: exceeds } };
29473
+ }
29474
+ }
29475
+ let chain = rolePermission;
29476
+ if (rolePresetProfile) chain = narrowerPermission(chain, rolePresetProfile);
29477
+ const agentProfile = agentPreset ? toolPresetProfile(agentPreset) : void 0;
29478
+ if (agentProfile) chain = narrowerPermission(chain, agentProfile);
29479
+ const permission = narrowerPermission(chain, stepPermission);
29480
+ if (stepPermission === "edit" && permission === "read-only") {
29481
+ return {
29482
+ error: {
29483
+ reason: "step_unsupported",
29484
+ field: "permission",
29485
+ detail: `role permission ${rolePermission} resolves to ${chain}; step access ${stepAccessDetail(step)}`
29486
+ }
29487
+ };
29488
+ }
29489
+ return { permission };
29490
+ }
29380
29491
  function executingSingletonAttempt(plan, state) {
29381
29492
  const current = state.currentStep;
29382
29493
  if (!current || current.panel.order.length !== 1) return void 0;
@@ -29569,10 +29680,17 @@ function prepareDispatch(context, runId, producerId, dispatchSources) {
29569
29680
  if (unsupported) return { kind: "return", state, handoff: { ...unsupported, stepId } };
29570
29681
  const wideTimeout = unsupportedAgentStepTimeout(step, projectAgentStepTimeoutMs(context, run));
29571
29682
  if (wideTimeout) return { kind: "return", state, handoff: { ...wideTimeout, stepId } };
29683
+ const firstListed = step.assignments.allowedAgents[0];
29684
+ const firstAgentId = step.kind === "agent" || step.kind === "moa" ? firstListed ? firstListed : step.agent : void 0;
29685
+ if (firstAgentId) {
29686
+ const toolDispatch = resolveToolDispatch(context.projectRoot, step, firstAgentId);
29687
+ if ("error" in toolDispatch) {
29688
+ return { kind: "return", state, handoff: { ...toolDispatch.error, stepId } };
29689
+ }
29690
+ }
29572
29691
  let resolvedRoute;
29573
29692
  if (producerId !== "driver-simulated") {
29574
- const allowed = step.assignments.allowedAgents;
29575
- const agentId = allowed && allowed.length > 0 && allowed[0] ? allowed[0] : step.kind === "agent" || step.kind === "moa" ? step.agent : "coordinator";
29693
+ const agentId = firstAgentId ?? (step.kind === "agent" || step.kind === "moa" ? step.agent : "coordinator");
29576
29694
  const routeResult = resolveProducerRoute(context.projectRoot, step, agentId);
29577
29695
  if ("error" in routeResult) {
29578
29696
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
@@ -29692,6 +29810,10 @@ function birthMember(context, input) {
29692
29810
  const born = folded.currentStep?.panel.order.length ?? 0;
29693
29811
  const allowed = input.step.assignments.allowedAgents;
29694
29812
  const agentId = allowed && allowed.length > born && allowed[born] ? allowed[born] : input.step.kind === "agent" || input.step.kind === "moa" ? input.step.agent : "coordinator";
29813
+ const toolDispatch = resolveToolDispatch(context.projectRoot, input.step, agentId);
29814
+ if ("error" in toolDispatch) {
29815
+ return { handoff: { ...toolDispatch.error, stepId: input.stepId } };
29816
+ }
29695
29817
  let resolvedRoute = input.resolvedRoute;
29696
29818
  if (!resolvedRoute && input.producerId && input.producerId !== "driver-simulated") {
29697
29819
  const routeResult = resolveProducerRoute(context.projectRoot, input.step, agentId);
@@ -29781,7 +29903,7 @@ function birthMember(context, input) {
29781
29903
  stepAttempt: input.stepAttempt,
29782
29904
  objective: promptText,
29783
29905
  allowedOutcomes: [...input.step.outcomes],
29784
- permissionCeiling: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only"
29906
+ permissionCeiling: toolDispatch.permission
29785
29907
  },
29786
29908
  acceptanceCriteria: input.step.requiredEvidence.map((ev) => ({
29787
29909
  id: ev.key,
@@ -29826,7 +29948,7 @@ function birthMember(context, input) {
29826
29948
  prompt: input.step.instructions ? `${input.step.instructions}
29827
29949
 
29828
29950
  ${generatedPrompt}` : generatedPrompt,
29829
- permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
29951
+ permission: toolDispatch.permission,
29830
29952
  ...(input.step.kind === "agent" || input.step.kind === "moa") && input.step.timeoutMs !== void 0 ? { timeoutMs: input.step.timeoutMs } : {},
29831
29953
  contextPacket,
29832
29954
  ...resolvedRoute?.effort !== void 0 ? { thinking: resolvedRoute.effort } : {},
@@ -17431,7 +17431,28 @@ var RESOURCE_SCHEMA = Object.freeze({
17431
17431
  var IDENTIFIER2 = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
17432
17432
  var WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$|clock\$)$/i;
17433
17433
  var BUILTIN_EXECUTORS = ["local", "ssh", "exe-dev"];
17434
- var BUILTIN_TOOL_PRESETS = ["coordinator", "read-only", "workspace-writer", "tests-writer"];
17434
+ var TOOL_PRESET_PROFILES = Object.freeze({
17435
+ coordinator: "read-only",
17436
+ "read-only": "read-only",
17437
+ "workspace-writer": "edit",
17438
+ "tests-writer": "edit"
17439
+ });
17440
+ var BUILTIN_TOOL_PRESETS = Object.keys(TOOL_PRESET_PROFILES);
17441
+ function toolPresetProfile(preset) {
17442
+ if (!Object.hasOwn(TOOL_PRESET_PROFILES, preset)) return void 0;
17443
+ return TOOL_PRESET_PROFILES[preset];
17444
+ }
17445
+ function toolPresetExceedsRoleMessage(preset, roleId, rolePermission, rolePreset) {
17446
+ const profile = toolPresetProfile(preset);
17447
+ if (profile !== "edit") return void 0;
17448
+ if (rolePermission === "read-only") {
17449
+ return `tools.preset ${preset} exceeds role ${roleId} permission read-only`;
17450
+ }
17451
+ if (rolePreset !== void 0 && toolPresetProfile(rolePreset) === "read-only") {
17452
+ return `tools.preset ${preset} exceeds role ${roleId} preset ${rolePreset}`;
17453
+ }
17454
+ return void 0;
17455
+ }
17435
17456
  var SECRET_VALUE_PATTERNS = [
17436
17457
  /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/,
17437
17458
  /\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/,
@@ -17831,6 +17852,15 @@ function objectValue(value) {
17831
17852
  function stringValue(value) {
17832
17853
  return typeof value === "string" ? value : void 0;
17833
17854
  }
17855
+ function roleById(roles, roleId) {
17856
+ const direct = roles.get(roleId);
17857
+ if (direct) return direct;
17858
+ const folded = roleId.toLocaleLowerCase("en-US");
17859
+ for (const role of roles.values()) {
17860
+ if ((role.id ?? "").toLocaleLowerCase("en-US") === folded) return role;
17861
+ }
17862
+ return void 0;
17863
+ }
17834
17864
  function numberValue(value, fallback) {
17835
17865
  return typeof value === "number" ? value : fallback;
17836
17866
  }
@@ -18345,10 +18375,29 @@ function validateBundle(project, repositories, agents, models, workflows, enviro
18345
18375
  if (executor && !executors.has(executor)) issues.push(issue3("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
18346
18376
  const preset = stringValue(objectValue(agent.value.tools)?.preset);
18347
18377
  if (preset && !presets.has(preset)) issues.push(issue3("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
18378
+ const roleId = stringValue(agent.value.role);
18379
+ const role = roleId ? roleById(roles, roleId) : void 0;
18380
+ const rolePermission = stringValue(role?.value.permission);
18381
+ if (preset && role && (rolePermission === "edit" || rolePermission === "read-only")) {
18382
+ const message = toolPresetExceedsRoleMessage(
18383
+ preset,
18384
+ role.id ?? roleId ?? "role",
18385
+ rolePermission,
18386
+ stringValue(objectValue(role.value.tools)?.preset)
18387
+ );
18388
+ if (message) issues.push(issue3("semantic", "tool_preset_exceeds_role", agent.logicalPath, message));
18389
+ }
18348
18390
  for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
18349
18391
  if (!repositoryIds.has(repositoryId)) issues.push(issue3("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
18350
18392
  }
18351
18393
  }
18394
+ for (const role of roles.values()) {
18395
+ const permission = stringValue(role.value.permission);
18396
+ const rolePreset = stringValue(objectValue(role.value.tools)?.preset);
18397
+ if (permission === "read-only" && rolePreset && toolPresetProfile(rolePreset) === "edit") {
18398
+ issues.push(issue3("semantic", "tool_preset_exceeds_role", role.logicalPath, `tools.preset ${rolePreset} exceeds permission read-only`));
18399
+ }
18400
+ }
18352
18401
  validateModelReferences(models, issues);
18353
18402
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
18354
18403
  if (!lookupKxmResource(workflows.values(), defaultWorkflow, "workflow")) issues.push(issue3("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
@@ -30125,6 +30174,68 @@ function unsupportedAgentStepTimeout(step, limitMs) {
30125
30174
  detail: "step timeoutMs is wider than project limits.agentStepTimeoutMs"
30126
30175
  };
30127
30176
  }
30177
+ function narrowerPermission(left, right) {
30178
+ return left === "read-only" || right === "read-only" ? "read-only" : "edit";
30179
+ }
30180
+ function stepRepositoryPermission(step) {
30181
+ return Object.values(step.repositories).some((access) => access === "write") ? "edit" : "read-only";
30182
+ }
30183
+ function stepAccessDetail(step) {
30184
+ const entries = Object.entries(step.repositories);
30185
+ if (entries.length === 0) return "none";
30186
+ return entries.map(([id, access]) => `${id} ${access}`).sort((left, right) => left < right ? -1 : left > right ? 1 : 0).join(", ");
30187
+ }
30188
+ function yamlToolsPreset(doc) {
30189
+ const tools = doc?.tools;
30190
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) return void 0;
30191
+ const preset = tools.preset;
30192
+ return typeof preset === "string" && preset.length > 0 ? preset : void 0;
30193
+ }
30194
+ function resolveToolDispatch(projectRoot, step, agentId) {
30195
+ const stepPermission = stepRepositoryPermission(step);
30196
+ if (step.kind !== "agent" && step.kind !== "moa") return { permission: stepPermission };
30197
+ const agentFile = findYamlBasename(join18(projectRoot, ".kxm", "agents"), agentId, "agent") ?? agentId;
30198
+ const agent = readYamlFile(join18(projectRoot, ".kxm", "agents", `${agentFile}.yaml`));
30199
+ const roleId = typeof agent?.role === "string" ? agent.role : "";
30200
+ if (!roleId) return { permission: stepPermission };
30201
+ const roleDoc = readYamlFile(join18(projectRoot, ".kxm", "roles", `${roleId}.yaml`));
30202
+ const rawPermission = roleDoc?.permission;
30203
+ const rolePermission = rawPermission === "edit" || rawPermission === "read-only" ? rawPermission : void 0;
30204
+ if (!rolePermission) return { permission: stepPermission };
30205
+ const rolePreset = yamlToolsPreset(roleDoc);
30206
+ const agentPreset = yamlToolsPreset(agent);
30207
+ const rolePresetProfile = rolePreset ? toolPresetProfile(rolePreset) : void 0;
30208
+ if (rolePreset && rolePresetProfile === "edit" && rolePermission === "read-only") {
30209
+ return {
30210
+ error: {
30211
+ reason: "step_unsupported",
30212
+ field: "tools.preset",
30213
+ detail: `tools.preset ${rolePreset} exceeds role ${roleId} permission read-only`
30214
+ }
30215
+ };
30216
+ }
30217
+ if (agentPreset) {
30218
+ const exceeds = toolPresetExceedsRoleMessage(agentPreset, roleId, rolePermission, rolePreset);
30219
+ if (exceeds) {
30220
+ return { error: { reason: "step_unsupported", field: "tools.preset", detail: exceeds } };
30221
+ }
30222
+ }
30223
+ let chain = rolePermission;
30224
+ if (rolePresetProfile) chain = narrowerPermission(chain, rolePresetProfile);
30225
+ const agentProfile = agentPreset ? toolPresetProfile(agentPreset) : void 0;
30226
+ if (agentProfile) chain = narrowerPermission(chain, agentProfile);
30227
+ const permission = narrowerPermission(chain, stepPermission);
30228
+ if (stepPermission === "edit" && permission === "read-only") {
30229
+ return {
30230
+ error: {
30231
+ reason: "step_unsupported",
30232
+ field: "permission",
30233
+ detail: `role permission ${rolePermission} resolves to ${chain}; step access ${stepAccessDetail(step)}`
30234
+ }
30235
+ };
30236
+ }
30237
+ return { permission };
30238
+ }
30128
30239
  function executingSingletonAttempt(plan, state) {
30129
30240
  const current = state.currentStep;
30130
30241
  if (!current || current.panel.order.length !== 1) return void 0;
@@ -30317,10 +30428,17 @@ function prepareDispatch(context, runId, producerId, dispatchSources) {
30317
30428
  if (unsupported) return { kind: "return", state, handoff: { ...unsupported, stepId } };
30318
30429
  const wideTimeout = unsupportedAgentStepTimeout(step, projectAgentStepTimeoutMs(context, run));
30319
30430
  if (wideTimeout) return { kind: "return", state, handoff: { ...wideTimeout, stepId } };
30431
+ const firstListed = step.assignments.allowedAgents[0];
30432
+ const firstAgentId = step.kind === "agent" || step.kind === "moa" ? firstListed ? firstListed : step.agent : void 0;
30433
+ if (firstAgentId) {
30434
+ const toolDispatch = resolveToolDispatch(context.projectRoot, step, firstAgentId);
30435
+ if ("error" in toolDispatch) {
30436
+ return { kind: "return", state, handoff: { ...toolDispatch.error, stepId } };
30437
+ }
30438
+ }
30320
30439
  let resolvedRoute;
30321
30440
  if (producerId !== "driver-simulated") {
30322
- const allowed = step.assignments.allowedAgents;
30323
- const agentId = allowed && allowed.length > 0 && allowed[0] ? allowed[0] : step.kind === "agent" || step.kind === "moa" ? step.agent : "coordinator";
30441
+ const agentId = firstAgentId ?? (step.kind === "agent" || step.kind === "moa" ? step.agent : "coordinator");
30324
30442
  const routeResult = resolveProducerRoute(context.projectRoot, step, agentId);
30325
30443
  if ("error" in routeResult) {
30326
30444
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
@@ -30440,6 +30558,10 @@ function birthMember(context, input) {
30440
30558
  const born = folded.currentStep?.panel.order.length ?? 0;
30441
30559
  const allowed = input.step.assignments.allowedAgents;
30442
30560
  const agentId = allowed && allowed.length > born && allowed[born] ? allowed[born] : input.step.kind === "agent" || input.step.kind === "moa" ? input.step.agent : "coordinator";
30561
+ const toolDispatch = resolveToolDispatch(context.projectRoot, input.step, agentId);
30562
+ if ("error" in toolDispatch) {
30563
+ return { handoff: { ...toolDispatch.error, stepId: input.stepId } };
30564
+ }
30443
30565
  let resolvedRoute = input.resolvedRoute;
30444
30566
  if (!resolvedRoute && input.producerId && input.producerId !== "driver-simulated") {
30445
30567
  const routeResult = resolveProducerRoute(context.projectRoot, input.step, agentId);
@@ -30529,7 +30651,7 @@ function birthMember(context, input) {
30529
30651
  stepAttempt: input.stepAttempt,
30530
30652
  objective: promptText,
30531
30653
  allowedOutcomes: [...input.step.outcomes],
30532
- permissionCeiling: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only"
30654
+ permissionCeiling: toolDispatch.permission
30533
30655
  },
30534
30656
  acceptanceCriteria: input.step.requiredEvidence.map((ev) => ({
30535
30657
  id: ev.key,
@@ -30574,7 +30696,7 @@ function birthMember(context, input) {
30574
30696
  prompt: input.step.instructions ? `${input.step.instructions}
30575
30697
 
30576
30698
  ${generatedPrompt}` : generatedPrompt,
30577
- permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
30699
+ permission: toolDispatch.permission,
30578
30700
  ...(input.step.kind === "agent" || input.step.kind === "moa") && input.step.timeoutMs !== void 0 ? { timeoutMs: input.step.timeoutMs } : {},
30579
30701
  contextPacket,
30580
30702
  ...resolvedRoute?.effort !== void 0 ? { thinking: resolvedRoute.effort } : {},
@@ -15542,6 +15542,13 @@ var RESOURCE_SCHEMA = Object.freeze({
15542
15542
  workflow: { identity: "kxm.workflow.v1", file: "workflow.schema.json" },
15543
15543
  "gate-registry": { identity: "kxm.gate-registry.v1", file: "gate-registry.schema.json" }
15544
15544
  });
15545
+ var TOOL_PRESET_PROFILES = Object.freeze({
15546
+ coordinator: "read-only",
15547
+ "read-only": "read-only",
15548
+ "workspace-writer": "edit",
15549
+ "tests-writer": "edit"
15550
+ });
15551
+ var BUILTIN_TOOL_PRESETS = Object.keys(TOOL_PRESET_PROFILES);
15545
15552
  function compareCodeUnits(left, right) {
15546
15553
  return left < right ? -1 : left > right ? 1 : 0;
15547
15554
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.168",
3
+ "version": "0.7.169",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,8 +12,9 @@ or permission to execute an assignment.
12
12
 
13
13
  Dispatch reads `.kxm/roles/*.yaml` and `.kxm/models/*.yaml`. An agent binds
14
14
  a role with `role:` and does not pin `harness` or `model`. An agent
15
- `tools.preset` must be a registered preset. Narrowing that preset against
16
- the role preset is not validated yet (P3). `kxm routes` prints
15
+ `tools.preset` must be a registered preset and may only narrow the role.
16
+ A wider preset is refused at load (`tool_preset_exceeds_role`) and again
17
+ at dispatch (`step_unsupported`). `kxm routes` prints
17
18
  admission from `.kxm/routes.yaml` and membership from the role files.
18
19
  The developer runner builds its policy object from those same files at
19
20
  `refs/remotes/origin/main`.
@@ -113,7 +113,7 @@ import {
113
113
  type KxmRunRecord,
114
114
  type KxmRunStatus,
115
115
  } from "./runtime-store.ts";
116
- import { KxmConfigError, kxmCanonicalJson, lookupKxmResource, type JsonValue, type KxmProjectBundle } from "./project-config.ts";
116
+ import { KxmConfigError, kxmCanonicalJson, lookupKxmResource, toolPresetExceedsRoleMessage, toolPresetProfile, type JsonValue, type KxmProjectBundle } from "./project-config.ts";
117
117
  import { evaluateArtifactsGate } from "./engine-artifacts.ts";
118
118
  import { createCommandObserver } from "./engine-command.ts";
119
119
  import {
@@ -1726,6 +1726,85 @@ function unsupportedAgentStepTimeout(step: KxmCompiledStep, limitMs: number): Om
1726
1726
  };
1727
1727
  }
1728
1728
 
1729
+ function narrowerPermission(left: "read-only" | "edit", right: "read-only" | "edit"): "read-only" | "edit" {
1730
+ return left === "read-only" || right === "read-only" ? "read-only" : "edit";
1731
+ }
1732
+
1733
+ function stepRepositoryPermission(step: KxmCompiledStep): "read-only" | "edit" {
1734
+ return Object.values(step.repositories).some((access) => access === "write") ? "edit" : "read-only";
1735
+ }
1736
+
1737
+ function stepAccessDetail(step: KxmCompiledStep): string {
1738
+ const entries = Object.entries(step.repositories);
1739
+ if (entries.length === 0) return "none";
1740
+ return entries
1741
+ .map(([id, access]) => `${id} ${access}`)
1742
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
1743
+ .join(", ");
1744
+ }
1745
+
1746
+ function yamlToolsPreset(doc: Record<string, unknown> | undefined): string | undefined {
1747
+ const tools = doc?.tools;
1748
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) return undefined;
1749
+ const preset = (tools as { preset?: unknown }).preset;
1750
+ return typeof preset === "string" && preset.length > 0 ? preset : undefined;
1751
+ }
1752
+
1753
+ /**
1754
+ * Dispatch permission is the narrowest of the role permission, the preset
1755
+ * profile, and the step repository access. A wider agent preset, or a write
1756
+ * step on a read-only chain, is step_unsupported.
1757
+ */
1758
+ function resolveToolDispatch(
1759
+ projectRoot: string,
1760
+ step: KxmCompiledStep,
1761
+ agentId: string,
1762
+ ): { permission: "read-only" | "edit" } | { error: Omit<KxmRunHandoff, "stepId"> } {
1763
+ const stepPermission = stepRepositoryPermission(step);
1764
+ if (step.kind !== "agent" && step.kind !== "moa") return { permission: stepPermission };
1765
+ const agentFile = findYamlBasename(join(projectRoot, ".kxm", "agents"), agentId, "agent") ?? agentId;
1766
+ const agent = readYamlFile(join(projectRoot, ".kxm", "agents", `${agentFile}.yaml`));
1767
+ const roleId = typeof agent?.role === "string" ? agent.role : "";
1768
+ if (!roleId) return { permission: stepPermission };
1769
+ const roleDoc = readYamlFile(join(projectRoot, ".kxm", "roles", `${roleId}.yaml`));
1770
+ const rawPermission = roleDoc?.permission;
1771
+ const rolePermission: "edit" | "read-only" | undefined = rawPermission === "edit" || rawPermission === "read-only" ? rawPermission : undefined;
1772
+ if (!rolePermission) return { permission: stepPermission };
1773
+ const rolePreset = yamlToolsPreset(roleDoc);
1774
+ const agentPreset = yamlToolsPreset(agent);
1775
+ const rolePresetProfile = rolePreset ? toolPresetProfile(rolePreset) : undefined;
1776
+ if (rolePreset && rolePresetProfile === "edit" && rolePermission === "read-only") {
1777
+ return {
1778
+ error: {
1779
+ reason: "step_unsupported",
1780
+ field: "tools.preset",
1781
+ detail: `tools.preset ${rolePreset} exceeds role ${roleId} permission read-only`,
1782
+ },
1783
+ };
1784
+ }
1785
+ if (agentPreset) {
1786
+ const exceeds = toolPresetExceedsRoleMessage(agentPreset, roleId, rolePermission, rolePreset);
1787
+ if (exceeds) {
1788
+ return { error: { reason: "step_unsupported", field: "tools.preset", detail: exceeds } };
1789
+ }
1790
+ }
1791
+ let chain: "read-only" | "edit" = rolePermission;
1792
+ if (rolePresetProfile) chain = narrowerPermission(chain, rolePresetProfile);
1793
+ const agentProfile = agentPreset ? toolPresetProfile(agentPreset) : undefined;
1794
+ if (agentProfile) chain = narrowerPermission(chain, agentProfile);
1795
+ const permission = narrowerPermission(chain, stepPermission);
1796
+ if (stepPermission === "edit" && permission === "read-only") {
1797
+ return {
1798
+ error: {
1799
+ reason: "step_unsupported",
1800
+ field: "permission",
1801
+ detail: `role permission ${rolePermission} resolves to ${chain}; step access ${stepAccessDetail(step)}`,
1802
+ },
1803
+ };
1804
+ }
1805
+ return { permission };
1806
+ }
1807
+
1729
1808
  function executingSingletonAttempt(
1730
1809
  plan: KxmCompiledPlan,
1731
1810
  state: KxmRunState,
@@ -1944,12 +2023,21 @@ function prepareDispatch(
1944
2023
  const wideTimeout = unsupportedAgentStepTimeout(step, projectAgentStepTimeoutMs(context, run));
1945
2024
  if (wideTimeout) return { kind: "return", state, handoff: { ...wideTimeout, stepId } };
1946
2025
 
2026
+ const firstListed = step.assignments.allowedAgents[0];
2027
+ const firstAgentId = (step.kind === "agent" || step.kind === "moa")
2028
+ ? (firstListed ? firstListed : step.agent)
2029
+ : undefined;
2030
+ if (firstAgentId) {
2031
+ const toolDispatch = resolveToolDispatch(context.projectRoot, step, firstAgentId);
2032
+ if ("error" in toolDispatch) {
2033
+ return { kind: "return", state, handoff: { ...toolDispatch.error, stepId } };
2034
+ }
2035
+ }
2036
+
1947
2037
  let resolvedRoute: ResolvedProducerRoute | undefined;
1948
2038
  if (producerId !== "driver-simulated") {
1949
- const allowed = step.assignments.allowedAgents;
1950
- const agentId = (allowed && allowed.length > 0 && allowed[0])
1951
- ? allowed[0]!
1952
- : (step.kind === "agent" || step.kind === "moa" ? step.agent : "coordinator");
2039
+ const agentId = firstAgentId
2040
+ ?? ((step.kind === "agent" || step.kind === "moa") ? step.agent : "coordinator");
1953
2041
  const routeResult = resolveProducerRoute(context.projectRoot, step, agentId);
1954
2042
  if ("error" in routeResult) {
1955
2043
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
@@ -2092,6 +2180,10 @@ function birthMember(
2092
2180
  const agentId = (allowed && allowed.length > born && allowed[born])
2093
2181
  ? allowed[born]!
2094
2182
  : (input.step.kind === "agent" || input.step.kind === "moa" ? input.step.agent : "coordinator");
2183
+ const toolDispatch = resolveToolDispatch(context.projectRoot, input.step, agentId);
2184
+ if ("error" in toolDispatch) {
2185
+ return { handoff: { ...toolDispatch.error, stepId: input.stepId } };
2186
+ }
2095
2187
  let resolvedRoute = input.resolvedRoute;
2096
2188
  if (!resolvedRoute && input.producerId && input.producerId !== "driver-simulated") {
2097
2189
  const routeResult = resolveProducerRoute(context.projectRoot, input.step, agentId);
@@ -2183,7 +2275,7 @@ function birthMember(
2183
2275
  stepAttempt: input.stepAttempt,
2184
2276
  objective: promptText,
2185
2277
  allowedOutcomes: [...input.step.outcomes],
2186
- permissionCeiling: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
2278
+ permissionCeiling: toolDispatch.permission,
2187
2279
  },
2188
2280
  acceptanceCriteria: input.step.requiredEvidence.map((ev) => ({
2189
2281
  id: ev.key,
@@ -2227,7 +2319,7 @@ function birthMember(
2227
2319
  allowedOutcomes: input.step.outcomes,
2228
2320
  signal: controller.signal,
2229
2321
  prompt: input.step.instructions ? `${input.step.instructions}\n\n${generatedPrompt}` : generatedPrompt,
2230
- permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
2322
+ permission: toolDispatch.permission,
2231
2323
  ...((input.step.kind === "agent" || input.step.kind === "moa") && input.step.timeoutMs !== undefined
2232
2324
  ? { timeoutMs: input.step.timeoutMs }
2233
2325
  : {}),
@@ -3144,6 +3236,11 @@ export function kxmLiveRunPrerequisites(
3144
3236
  if (step.kind !== "agent" && step.kind !== "moa") continue;
3145
3237
  const agentIds = step.assignments.allowedAgents.length > 0 ? step.assignments.allowedAgents : [step.agent];
3146
3238
  for (const agentId of agentIds) {
3239
+ const toolDispatch = resolveToolDispatch(projectRoot, step, agentId);
3240
+ if ("error" in toolDispatch) {
3241
+ prerequisites.push({ ...toolDispatch.error, stepId });
3242
+ continue;
3243
+ }
3147
3244
  const route = resolveProducerRoute(projectRoot, step, agentId);
3148
3245
  if ("error" in route) {
3149
3246
  const detail = route.error.detail === "producer_route_unsupported: agent step model is not honored"
@@ -3153,7 +3250,7 @@ export function kxmLiveRunPrerequisites(
3153
3250
  continue;
3154
3251
  }
3155
3252
  const harness = route.harness;
3156
- const permission = Object.values(step.repositories).includes("write") ? "edit" : "read-only";
3253
+ const permission = toolDispatch.permission;
3157
3254
  if (!BUILTIN_HARNESSES.some((entry) => entry.id === harness && entry.oneShot) || !oneShotPermissionArgs(harness, permission)) {
3158
3255
  prerequisites.push({ reason: "step_unsupported", stepId, field: "harness", detail: `${agentId}: ${harness} has no audited ${permission} one-shot profile; use a supported workflow or execute this work directly in ${harness}, without substituting another harness` });
3159
3256
  continue;
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.168";
14
+ const VERSION = "0.7.169";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -104,7 +104,40 @@ const RESOURCE_SCHEMA: Readonly<Record<KxmResourceKind, { identity: string; file
104
104
  const IDENTIFIER = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
105
105
  const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$|clock\$)$/i;
106
106
  const BUILTIN_EXECUTORS = ["local", "ssh", "exe-dev"];
107
- const BUILTIN_TOOL_PRESETS = ["coordinator", "read-only", "workspace-writer", "tests-writer"];
107
+ /** Builtin preset to the harness one-shot profile. An agent preset may only narrow. */
108
+ export const TOOL_PRESET_PROFILES = Object.freeze({
109
+ coordinator: "read-only",
110
+ "read-only": "read-only",
111
+ "workspace-writer": "edit",
112
+ "tests-writer": "edit",
113
+ } as const);
114
+ const BUILTIN_TOOL_PRESETS = Object.keys(TOOL_PRESET_PROFILES);
115
+
116
+ export function toolPresetProfile(preset: string): "read-only" | "edit" | undefined {
117
+ if (!Object.hasOwn(TOOL_PRESET_PROFILES, preset)) return undefined;
118
+ return TOOL_PRESET_PROFILES[preset as keyof typeof TOOL_PRESET_PROFILES];
119
+ }
120
+
121
+ /**
122
+ * Set when `preset` is wider than the role permission, or wider than a
123
+ * registered role preset. Same text at load and at dispatch.
124
+ */
125
+ export function toolPresetExceedsRoleMessage(
126
+ preset: string,
127
+ roleId: string,
128
+ rolePermission: "edit" | "read-only",
129
+ rolePreset: string | undefined,
130
+ ): string | undefined {
131
+ const profile = toolPresetProfile(preset);
132
+ if (profile !== "edit") return undefined;
133
+ if (rolePermission === "read-only") {
134
+ return `tools.preset ${preset} exceeds role ${roleId} permission read-only`;
135
+ }
136
+ if (rolePreset !== undefined && toolPresetProfile(rolePreset) === "read-only") {
137
+ return `tools.preset ${preset} exceeds role ${roleId} preset ${rolePreset}`;
138
+ }
139
+ return undefined;
140
+ }
108
141
  const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
109
142
  /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/,
110
143
  /\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/,
@@ -583,6 +616,16 @@ function stringValue(value: JsonValue | undefined): string | undefined {
583
616
  return typeof value === "string" ? value : undefined;
584
617
  }
585
618
 
619
+ function roleById(roles: ReadonlyMap<string, KxmResource>, roleId: string): KxmResource | undefined {
620
+ const direct = roles.get(roleId);
621
+ if (direct) return direct;
622
+ const folded = roleId.toLocaleLowerCase("en-US");
623
+ for (const role of roles.values()) {
624
+ if ((role.id ?? "").toLocaleLowerCase("en-US") === folded) return role;
625
+ }
626
+ return undefined;
627
+ }
628
+
586
629
  function numberValue(value: JsonValue | undefined, fallback: number): number {
587
630
  return typeof value === "number" ? value : fallback;
588
631
  }
@@ -1224,10 +1267,29 @@ function validateBundle(
1224
1267
  if (executor && !executors.has(executor)) issues.push(issue("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
1225
1268
  const preset = stringValue(objectValue(agent.value.tools)?.preset);
1226
1269
  if (preset && !presets.has(preset)) issues.push(issue("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
1270
+ const roleId = stringValue(agent.value.role);
1271
+ const role = roleId ? roleById(roles, roleId) : undefined;
1272
+ const rolePermission = stringValue(role?.value.permission);
1273
+ if (preset && role && (rolePermission === "edit" || rolePermission === "read-only")) {
1274
+ const message = toolPresetExceedsRoleMessage(
1275
+ preset,
1276
+ role.id ?? roleId ?? "role",
1277
+ rolePermission,
1278
+ stringValue(objectValue(role.value.tools)?.preset),
1279
+ );
1280
+ if (message) issues.push(issue("semantic", "tool_preset_exceeds_role", agent.logicalPath, message));
1281
+ }
1227
1282
  for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
1228
1283
  if (!repositoryIds.has(repositoryId)) issues.push(issue("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
1229
1284
  }
1230
1285
  }
1286
+ for (const role of roles.values()) {
1287
+ const permission = stringValue(role.value.permission);
1288
+ const rolePreset = stringValue(objectValue(role.value.tools)?.preset);
1289
+ if (permission === "read-only" && rolePreset && toolPresetProfile(rolePreset) === "edit") {
1290
+ issues.push(issue("semantic", "tool_preset_exceeds_role", role.logicalPath, `tools.preset ${rolePreset} exceeds permission read-only`));
1291
+ }
1292
+ }
1231
1293
  validateModelReferences(models, issues);
1232
1294
 
1233
1295
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";