@sema-agent/core 7.9.0 → 7.9.2

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 (61) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/agents/child-model-seat.d.ts +51 -0
  3. package/dist/agents/child-model-seat.js +34 -0
  4. package/dist/agents/subagent.d.ts +4 -2
  5. package/dist/agents/subagent.js +16 -12
  6. package/dist/core/ask-origin.d.ts +7 -3
  7. package/dist/core/checkpoint-store.d.ts +2 -7
  8. package/dist/core/effective-path-target.d.ts +43 -0
  9. package/dist/core/effective-path-target.js +56 -0
  10. package/dist/core/engine-notice.d.ts +8 -0
  11. package/dist/core/fs-write-gate-policy.js +2 -1
  12. package/dist/core/gate-lanes.d.ts +1 -0
  13. package/dist/core/gate-lanes.js +11 -5
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +2 -0
  16. package/dist/core/hooks.d.ts +10 -1
  17. package/dist/core/mcp-injection-drop.d.ts +74 -0
  18. package/dist/core/mcp-injection-drop.js +27 -0
  19. package/dist/core/permission-rule-model.d.ts +91 -75
  20. package/dist/core/permission-rule-model.js +90 -111
  21. package/dist/core/permission-rule-org.d.ts +12 -6
  22. package/dist/core/permission-rule-org.js +9 -3
  23. package/dist/core/permission-rules.d.ts +3 -2
  24. package/dist/core/permission-rules.js +38 -15
  25. package/dist/core/persisted-rule-arms.d.ts +7 -2
  26. package/dist/core/persisted-rule-arms.js +3 -1
  27. package/dist/core/roles.d.ts +8 -0
  28. package/dist/core/runner/active-skill-scope.js +2 -1
  29. package/dist/core/runner/contracts.d.ts +3 -2
  30. package/dist/core/runner/permission-rule-lanes.d.ts +12 -4
  31. package/dist/core/runner/permission-rule-lanes.js +13 -15
  32. package/dist/core/runner/prepare-caps-and-workflow.js +2 -2
  33. package/dist/core/runner/runtask.d.ts +2 -1
  34. package/dist/core/runner/runtask.js +1 -1
  35. package/dist/core/runner/session-rule-policy.js +2 -1
  36. package/dist/core/runner/tool-face-overlay.js +22 -3
  37. package/dist/core/sensitive-path-policy.js +5 -3
  38. package/dist/core/shell-lexer.d.ts +47 -0
  39. package/dist/core/shell-lexer.js +478 -0
  40. package/dist/core/shell-scan.d.ts +60 -0
  41. package/dist/core/shell-scan.js +183 -0
  42. package/dist/core/shell-wrapper-table.d.ts +297 -0
  43. package/dist/core/shell-wrapper-table.js +58 -0
  44. package/dist/core/tool-catalog-entries.js +6 -6
  45. package/dist/core/tool-face.d.ts +80 -4
  46. package/dist/core/tool-face.js +10 -0
  47. package/dist/core/tool-policy.d.ts +1 -6
  48. package/dist/core/tool-registry.d.ts +8 -11
  49. package/dist/core/tool-registry.js +5 -2
  50. package/dist/core/tool-roster.d.ts +11 -2
  51. package/dist/core/tool-roster.js +21 -3
  52. package/dist/index.d.ts +6 -3
  53. package/dist/index.js +5 -2
  54. package/dist/orchestration/workflow.js +21 -4
  55. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  56. package/dist/tools/fs/fs-search-tools.js +17 -9
  57. package/dist/tools/fs/index.js +1 -1
  58. package/dist/tools/fs/search.d.ts +0 -8
  59. package/dist/tools/fs/search.js +0 -23
  60. package/package.json +1 -1
  61. package/test/export-surface.snapshot.json +57 -1
@@ -1,4 +1,4 @@
1
- import { escapeForDisclosure, parseRuleText, ruleReachesProgramRun } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseRuleText, programRunReachOf, ruleReachesProgramRun } from "./permission-rule-model.js";
2
2
  import { writerOf } from "./permission-rule-store.js";
3
3
  export const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
4
4
  export const ORG_ADJUDICATION_TIMEOUT_MS = 15_000;
@@ -244,19 +244,25 @@ function validateOrgSnapshot(s, nowMs) {
244
244
  }
245
245
  export function orgRuleVerdictFor(rules, call) {
246
246
  let ask;
247
+ let unreadable;
247
248
  for (const r of rules) {
248
249
  const parsed = parseRuleText(r.rule, r.behavior);
249
250
  if ("reject" in parsed)
250
251
  continue;
251
252
  if (parsed.rule.tool !== call.tool)
252
253
  continue;
253
- if (!ruleReachesProgramRun(parsed.rule, call.command))
254
+ const outcome = programRunReachOf(parsed.rule, call.command);
255
+ if (outcome.reach === "not_reached")
254
256
  continue;
257
+ if (outcome.reach === "unreadable") {
258
+ unreadable ??= { behavior: "ask", rule: r.rule, unreadable: outcome.reason };
259
+ continue;
260
+ }
255
261
  if (r.behavior === "deny")
256
262
  return { behavior: "deny", rule: r.rule };
257
263
  ask ??= { behavior: "ask", rule: r.rule };
258
264
  }
259
- return ask;
265
+ return unreadable ?? ask;
260
266
  }
261
267
  export function orgRuleShadows(orgRules, rule) {
262
268
  return compileOrgShadowPredicate(orgRules)(rule);
@@ -16,8 +16,9 @@
16
16
  * this compiler now compiles it AS SUCH for the two tightening behaviors: the text goes through the ONE
17
17
  * validator (`parseRuleText(text, behavior)` — the tightening floor, so `Bash(node:*)` as a deny is a
18
18
  * legitimately wide tightening) and the call is judged with the ONE tightening reach
19
- * (`ruleReachesProgramRun`: the whole command line or any program run in a connector chain — upstream's
20
- * per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use).
19
+ * (`programRunReachOf`: the whole command line or any program run in a connector chain — upstream's
20
+ * per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use;
21
+ * a call the lexer cannot read against a rule is a fail-closed ask, never silence).
21
22
  * It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
22
23
  * is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
23
24
  * ask → `AN(…,"ask")` field-level ask → allow). A content-form ALLOW is refused (`unsupported.bash_prefix`):
@@ -1,8 +1,9 @@
1
1
  import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
3
  import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
4
- import { isUsablePathBase, lexicalNormalAbsolutePathOf, parseRuleText, pathRuleReaches, ruleReachesProgramRun } from "./permission-rule-model.js";
5
- import { pathTargetValue, protectivePathTargetOf } from "./tool-registry.js";
4
+ import { isUsablePathBase, parseRuleText, pathRuleReaches, programRunReachOf } from "./permission-rule-model.js";
5
+ import { effectivePathTargetOf } from "./effective-path-target.js";
6
+ import { protectivePathTargetOf } from "./tool-registry.js";
6
7
  export { parsePermissionRule };
7
8
  export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
8
9
  const DEFAULT_CAPS = {
@@ -317,18 +318,34 @@ export function createPermissionRulePolicy(rules, opts) {
317
318
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
318
319
  const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
319
320
  const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
320
- const cwdBase = (pathBases.cwd ?? pathBases.root ?? "").replace(/\/+$/, "");
321
321
  const contentHit = (lane, req) => {
322
322
  if (lane.length === 0)
323
323
  return undefined;
324
- const pt = protectivePathTargetOf(req);
325
- const spelled = pt !== undefined ? pathTargetValue(req.args, pt) : undefined;
326
- const target = spelled === undefined ? undefined : lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${cwdBase}/${spelled}`);
324
+ const target = effectivePathTargetOf(req, protectivePathTargetOf(req), pathBases);
327
325
  const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined;
328
- return lane.find((r) => r.parsed.match === "subpath" || r.parsed.match === "path"
329
- ? target !== undefined && pathRuleReaches(r.parsed, target, pathBases)
330
- : typeof command === "string" && ruleReachesProgramRun(r.parsed, command));
326
+ let unreadable;
327
+ for (const r of lane) {
328
+ if (r.parsed.match === "subpath" || r.parsed.match === "path") {
329
+ if (target !== undefined && pathRuleReaches(r.parsed, target, pathBases))
330
+ return { rule: r };
331
+ continue;
332
+ }
333
+ if (typeof command !== "string")
334
+ continue;
335
+ const outcome = programRunReachOf(r.parsed, command);
336
+ if (outcome.reach === "reached")
337
+ return { rule: r };
338
+ if (outcome.reach === "unreadable")
339
+ unreadable ??= { rule: r, unreadable: outcome.reason };
340
+ }
341
+ return unreadable;
331
342
  };
343
+ const unreadableAsk = (hit, req) => ({
344
+ action: "ask",
345
+ message: `tool "${req.toolName}" needs approval: the command could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
346
+ matchedAskRule: hit.rule.ruleText,
347
+ requiresRealApproval: true,
348
+ });
332
349
  return {
333
350
  nameSets: [nameSets],
334
351
  check(req) {
@@ -342,15 +359,19 @@ export function createPermissionRulePolicy(rules, opts) {
342
359
  if (coveringDeny)
343
360
  return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
344
361
  }
362
+ const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
363
+ if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
364
+ return { action: "deny", message: ruleMessage("denied", contentDeny.rule.ruleText, contentDeny.rule.source) };
365
+ }
345
366
  if (entry) {
346
- const contentDeny = contentHit(entry.content.deny, req);
347
- if (contentDeny) {
348
- return { action: "deny", message: ruleMessage("denied", contentDeny.ruleText, contentDeny.source) };
349
- }
350
367
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
351
368
  if (paramDeny) {
352
369
  return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
353
370
  }
371
+ }
372
+ if (contentDeny?.unreadable !== undefined)
373
+ return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
374
+ if (entry) {
354
375
  if (entry.bare.ask) {
355
376
  return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
356
377
  }
@@ -362,8 +383,10 @@ export function createPermissionRulePolicy(rules, opts) {
362
383
  }
363
384
  if (entry) {
364
385
  const contentAsk = contentHit(entry.content.ask, req);
365
- if (contentAsk) {
366
- return { action: "ask", message: ruleMessage("flagged", contentAsk.ruleText, contentAsk.source), matchedAskRule: contentAsk.ruleText };
386
+ if (contentAsk !== undefined) {
387
+ if (contentAsk.unreadable !== undefined)
388
+ return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
389
+ return { action: "ask", message: ruleMessage("flagged", contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
367
390
  }
368
391
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
369
392
  if (paramAsk) {
@@ -18,6 +18,9 @@ import type { PersistedRuleHit, PersistedRuleHitRule } from "./hooks.js";
18
18
  export interface PersistedRuleRead {
19
19
  readonly hit?: PersistedRuleHit;
20
20
  readonly unreadable?: true;
21
+ /** The lexer's reason when the CALL could not be read against the person's deny/ask rows (absent for
22
+ * a store read failure) — the message says which it was; the decision is the same. */
23
+ readonly reason?: string;
21
24
  }
22
25
  /** Display form of a rule set (design/375 §5.1): every rule text is a DISPLAY value on a human trust
23
26
  * boundary, and the set must stay readable however large a lane makes it — each member rides
@@ -42,8 +45,10 @@ export declare function disclosedRuleSet(rules: readonly PersistedRuleHitRule[])
42
45
  * ask, the marker is stamped if absent and the message names the rule — the standing question
43
46
  * outranks a remembered yes across lanes, exactly as the DSL's own order says within one; a deny
44
47
  * stays a deny.
45
- * · UNREADABLE — the wired store could not be read (a read failure, a timeout): the person's deny/ask
46
- * rows cannot be enforced, so the call fails CLOSED the way an unreadable org snapshot does — an
48
+ * · UNREADABLE — the wired store could not be read (a read failure, a timeout), or the CALL could not be
49
+ * read against the person's standing deny/ask rows (`reason` carries the lexer's word: an expansion
50
+ * where a rule reads a word, an unterminated quote, a syntax error): the person's deny/ask rows
51
+ * cannot be enforced for this call, so it fails CLOSED the way an unreadable org snapshot does — an
47
52
  * allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
48
53
  * derives the origin `rule_store_unavailable` from its own record of the read), an ask acquires the
49
54
  * bit, a deny stays a deny. Reading an unreadable store as "no rule" would erase every deny the
@@ -8,7 +8,9 @@ export function applyPersistedTightening(decision, read) {
8
8
  if (read?.unreadable === true) {
9
9
  if (decision.action === "deny")
10
10
  return { decision };
11
- const message = "the persisted permission-rule store could not be read for this call — the person's own deny/ask rules cannot be enforced, so this call needs a real approval until the store is readable";
11
+ const message = read.reason !== undefined
12
+ ? `this call could not be read against the person's standing deny/ask rules (${read.reason}) — whether one of them forbids it is unknown, so it needs a real approval`
13
+ : "the persisted permission-rule store could not be read for this call — the person's own deny/ask rules cannot be enforced, so this call needs a real approval until the store is readable";
12
14
  return {
13
15
  decision: decision.action === "allow"
14
16
  ? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
@@ -98,6 +98,14 @@ export interface ResolvedRole {
98
98
  * Resolve the model (and default thinking) for a task. Explicit `spec.model` wins; otherwise walk the
99
99
  * `modelRole`'s fallback chain over the merged roles (task overrides runner) and use the first role
100
100
  * that supplies a model. Throws a helpful error if nothing resolves.
101
+ *
102
+ * @contract roles.side_channels_deployment_only — an engine SIDE CHANNEL (the auto-mode classifier,
103
+ * the compaction/summarize lane, memory consolidation, next-prompt suggestion) resolves here with a
104
+ * `modelRole` and NO run model: it runs on the deployment's roles alone — the boot default at the end of
105
+ * its chain — and never follows the model a run was switched to. Only a run's CHILDREN follow the parent:
106
+ * the delegation seat (`childModelSeat`) passes the parent's `Model` as `spec.model` with the role kept
107
+ * on `modelRole`, so the role's presets still apply. A side channel that must follow a run passes that
108
+ * run's model explicitly; nothing here infers it.
101
109
  */
102
110
  export declare function resolveTaskModel(spec: {
103
111
  model?: ModelRef;
@@ -1,5 +1,6 @@
1
1
  import { canonicalizeTarget } from "../../tools/fs/safety.js";
2
2
  import { pathTargetValue } from "../tool-registry.js";
3
+ import { pathTargetBaseOf } from "../effective-path-target.js";
3
4
  import { PATH_WRITE_TOOLS, isWithin } from "./session-rule-policy.js";
4
5
  import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
5
6
  export class ActiveSkillScope {
@@ -99,7 +100,7 @@ export function createActiveSkillScopePolicy(opts) {
99
100
  decisionReason: "safety",
100
101
  };
101
102
  }
102
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
103
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declaredTarget, { root: rootPath, cwd: req.cwd }));
103
104
  if (!canon.ok) {
104
105
  return {
105
106
  action: "deny",
@@ -43,7 +43,7 @@ import type { SystemInjectionPriority, TaskNotificationPayload } from "../task-n
43
43
  import type { OnAsk, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "../tool-policy.js";
44
44
  import type { RemoteExecutionEnv, SnapshotId, WorkspaceHandle } from "../remote-env.js";
45
45
  import type { ToolDisclosureManifest } from "../trace.js";
46
- import type { AgentDefinition, Brain, CompactOutcome, NestedUsageAccum, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream, ToolActivity, ToolEffect } from "../types.js";
46
+ import type { AgentDefinition, Brain, CompactOutcome, ModelRoles, NestedUsageAccum, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream, ToolActivity, ToolEffect } from "../types.js";
47
47
  import type { WiringManifest } from "../wiring-manifest.js";
48
48
  import type { ActiveSkillFrame } from "./active-skill-scope.js";
49
49
  import type { BudgetAxis } from "./assemble-result.js";
@@ -2377,11 +2377,12 @@ export interface RunnerDepsSeat {
2377
2377
  export interface RunnerSelfSeat {
2378
2378
  /** The session store this Runner was constructed over — the delegation tools acquire / release / fork through it. */
2379
2379
  readonly sessions: SessionStore;
2380
- /** The deployment agent + model catalog, READ-ONLY (a fresh copy per read; the definitions are the shared objects). */
2380
+ /** The deployment agent + model catalog, READ-ONLY (a fresh copy per read; the definitions are the shared objects); `roles` = `RunnerDeps.roles` (the delegation seat's `childModelSeat` reads `roles.subagent`). */
2381
2381
  readonly agentCatalog: {
2382
2382
  agents?: AgentDefinition[];
2383
2383
  builtinAgents?: boolean;
2384
2384
  models?: Record<string, Model>;
2385
+ roles?: ModelRoles;
2385
2386
  };
2386
2387
  /** The deployment-level gating baseline (deps toolPolicy / hooks) as delegate wrappers — for assemblers that compose with it. */
2387
2388
  readonly gateBaseline: {
@@ -26,7 +26,7 @@
26
26
  * silence the ungated-write warning for deployments that wired no policy at all.
27
27
  */
28
28
  import type { AskRuleEvidence, ToolCallRequest } from "../tool-policy.js";
29
- import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit } from "../hooks.js";
29
+ import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit, type PersistedRuleUnreadable } from "../hooks.js";
30
30
  import type { PersistedRule, PersistedRuleVerdict, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
31
31
  import { type OrgRuleResolution } from "../permission-rule-org.js";
32
32
  import type { PermissionRuleStoreProvider } from "../permission-rule-provider.js";
@@ -79,11 +79,19 @@ export declare function orgRevisionEvidenceOf(resolution: OrgRuleResolution, onD
79
79
  * aliased: the evidence must not change under a store that reuses its row objects.
80
80
  */
81
81
  export declare function persistedRuleHitOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | undefined;
82
+ /** The COMMAND arm's projection of a verdict: a call the lexer could not read against a standing deny/ask
83
+ * row is the lane's UNREADABLE answer — the same fail-closed shape an unreadable store takes (a
84
+ * real-approval ask no automatic lane clears), with the lexer's reason in place of the store's — never a
85
+ * matched ask rule, which would let a blanket `onAsk: "allow"` clear a question nobody can answer. Every
86
+ * other verdict is {@link persistedRuleHitOf}'s hit. */
87
+ export declare function persistedRuleAnswerOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | PersistedRuleUnreadable | undefined;
82
88
  /**
83
89
  * design/382 §2.5-1, widened to every path-targeting tool — the persisted-rule lane's PATH arm: the call's
84
- * target path in the SAME lexical identity the rule family is defined over absolute as spelled, relative
85
- * resolved against the LIVE tracked working directory (the very base the file tools themselves resolve
86
- * against; task root when no tracker moved), then normalized lexically, zero IO.
90
+ * target path in the SAME lexical identity the rule family is defined over, resolved by the ONE reader every
91
+ * fence and every tool goes through (`effectivePathTargetOf`), so the lane judges the path the tool will
92
+ * really open. The tool's own declaration decides which base a relative spelling takes and what an ABSENT
93
+ * slot means — the lane no longer guesses `liveCwd ?? root` for every tool, which was wrong for the two
94
+ * search tools in three separate shapes (see effective-path-target.ts). Still zero IO.
87
95
  *
88
96
  * TWO readings of the call's path slot, one per direction (design/388 B6/B17): the tightening arms judge
89
97
  * the PROTECTIVE target (the object face's declaration, the catalog's for the name as the floor — reading
@@ -1,6 +1,7 @@
1
1
  import { persistedRuleMandateOf } from "../hooks.js";
2
- import { adjudicatePersistedPathRules, adjudicatePersistedRules, lexicalNormalAbsolutePathOf, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
- import { declaredPathTargetOf, pathTargetValue, protectivePathTargetOf } from "../tool-registry.js";
2
+ import { adjudicatePersistedPathRules, adjudicatePersistedRules, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
+ import { effectivePathTargetOf } from "../effective-path-target.js";
4
+ import { declaredPathTargetOf, protectivePathTargetOf } from "../tool-registry.js";
4
5
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
5
6
  export const PERSISTED_RULE_TOOL = "Bash";
6
7
  const HANDOFF_BOUND = 256;
@@ -24,21 +25,18 @@ export function persistedRuleHitOf(verdict) {
24
25
  ? undefined
25
26
  : { behavior: verdict.behavior, rules: verdict.rules.map((r) => ({ rule: r.rule, dots: r.adds.map((a) => ({ actor: a.dot.actor, counter: a.dot.counter })) })) };
26
27
  }
28
+ export function persistedRuleAnswerOf(verdict) {
29
+ if (verdict?.unreadable !== undefined)
30
+ return { unreadable: true, reason: verdict.unreadable };
31
+ return persistedRuleHitOf(verdict);
32
+ }
27
33
  export function pathRuleLaneAnswer(table, req, ctx) {
28
- const base = (ctx.liveCwd ?? ctx.root ?? "").replace(/\/+$/, "");
29
- const resolve = (pt) => {
30
- if (pt === undefined)
31
- return undefined;
32
- const spelled = pathTargetValue(req.args, pt);
33
- if (spelled === undefined)
34
- return undefined;
35
- return lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${base}/${spelled}`);
36
- };
37
- const tighten = resolve(protectivePathTargetOf(req));
38
- const allow = resolve(declaredPathTargetOf(req));
34
+ const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) };
35
+ const tighten = effectivePathTargetOf(req, protectivePathTargetOf(req), bases);
36
+ const allow = effectivePathTargetOf(req, declaredPathTargetOf(req), bases);
39
37
  if (tighten === undefined && allow === undefined)
40
38
  return undefined;
41
- return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) }));
39
+ return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
42
40
  }
43
41
  export function createPermissionRuleLanes(cfg) {
44
42
  const provider = cfg.provider;
@@ -137,7 +135,7 @@ export function createPermissionRuleLanes(cfg) {
137
135
  const execCwd = liveCwd !== undefined ? { execCwd: liveCwd } : {};
138
136
  const verdict = adjudicatePersistedRules(table, { tool: req.toolName, command, cwd: cfg.root, sessionId: cfg.sessionId, ...execCwd });
139
137
  if (verdict !== undefined)
140
- return persistedRuleHitOf(verdict);
138
+ return persistedRuleAnswerOf(verdict);
141
139
  const coverage = segmentCoverageOf(command, { persisted: table }, { tool: req.toolName, cwd: cfg.root, sessionId: cfg.sessionId, ...execCwd });
142
140
  return coverage !== undefined ? { segmentCoverage: coverage } : undefined;
143
141
  },
@@ -1,6 +1,6 @@
1
1
  import { isSyntheticApiErrorMessage } from "../../internal/harness.js";
2
2
  import { createHash } from "node:crypto";
3
- import { forkGovernanceDenial } from "../../agents/subagent.js";
3
+ import { deploymentSubagentTierModel, forkGovernanceDenial } from "../../agents/subagent.js";
4
4
  import { CROSS_SESSION_CLASSIFIER_RULE } from "../../agents/cross-session-envelope.js";
5
5
  import { createRunWorkflowTool } from "../../orchestration/run-workflow-tool.js";
6
6
  import { isSelfOrchestrationActive } from "../../orchestration/workflow-script-runner.js";
@@ -243,7 +243,7 @@ export async function prepareCapsAndWorkflow(input) {
243
243
  taskOwner: hostTaskId,
244
244
  limits: deps.workflowLimits,
245
245
  sourceTaskId: hostTaskId,
246
- parentModel: () => harnessRef.current?.getModel(),
246
+ parentModel: () => deploymentSubagentTierModel(runnerSelf.agentCatalog) ?? harnessRef.current?.getModel(),
247
247
  ...(spec.getApiKeyAndHeaders !== undefined ? { parentGetApiKeyAndHeaders: spec.getApiKeyAndHeaders } : {}),
248
248
  ...(frozenOnAsk !== undefined ? { parentOnAsk: frozenOnAsk } : {}),
249
249
  principal: spec.principal,
@@ -3,7 +3,7 @@ import { type CheckpointToken, type ResumeOutcome } from "../checkpoint-store.js
3
3
  import { type TaskOutcome } from "../task-outcome.js";
4
4
  import { type SideQuerySpec, type SideQueryResult } from "../side-query.js";
5
5
  import type { SessionStore } from "../session.js";
6
- import type { AgentDefinition, ModelRef, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js";
6
+ import type { AgentDefinition, ModelRef, ModelRoles, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream } from "../types.js";
7
7
  import type { ResumeRun, ResumeTaskConfig, RunInternals, RunnerSelfSeat } from "./contracts.js";
8
8
  export type { ResumeTaskConfig } from "./contracts.js";
9
9
  /**
@@ -74,6 +74,7 @@ export declare class Runner implements RunnerSelfSeat {
74
74
  agents?: AgentDefinition[];
75
75
  builtinAgents?: boolean;
76
76
  models?: Record<string, Model>;
77
+ roles?: ModelRoles;
77
78
  };
78
79
  /** design/125 (实现审 B1) — the deployment-level gating baseline, exposed READ-ONLY so thin
79
80
  * assemblers over the Runner (`runSpec`) can COMPOSE with it. The task-level engine semantic is a
@@ -138,7 +138,7 @@ export class Runner {
138
138
  return {
139
139
  ...(this.deps.agents ? { agents: [...this.deps.agents] } : {}),
140
140
  ...(this.deps.builtinAgents !== undefined ? { builtinAgents: this.deps.builtinAgents } : {}),
141
- ...(this.deps.models ? { models: { ...this.deps.models } } : {}),
141
+ ...(this.deps.models ? { models: { ...this.deps.models } } : {}), ...(this.deps.roles ? { roles: { ...this.deps.roles } } : {}),
142
142
  };
143
143
  }
144
144
  get gateBaseline() {
@@ -2,6 +2,7 @@ import { canonicalizeTarget, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { isWinFormPath } from "../../tools/fs/safety.js";
3
3
  import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
4
4
  import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames, skillScopeWriteToolNames } from "../tool-registry.js";
5
+ import { pathTargetBaseOf } from "../effective-path-target.js";
5
6
  export const PATH_WRITE_TOOLS = skillScopeWriteToolNames();
6
7
  export const PATH_CONFINABLE_WRITE_TOOLS = pathConfinableWriteToolNames();
7
8
  export function isWithin(root, p) {
@@ -65,7 +66,7 @@ export function createSessionRulePolicy(rules, opts) {
65
66
  if (typeof path !== "string" || path.length === 0) {
66
67
  return deny(`write tool "${req.toolName}" denied: session rule confines writes to allowDirs but the call has no resolvable path`);
67
68
  }
68
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
69
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
69
70
  if (!canon.ok) {
70
71
  return deny(`write to "${path}" denied: its real target could not be resolved against the session-rule allowDirs`);
71
72
  }
@@ -1,6 +1,6 @@
1
1
  import { MCP_NAMESPACE } from "../protocol-table.js";
2
2
  import { mintNamespacePrefix } from "../protocol-naming.js";
3
- import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS } from "../tool-face.js";
3
+ import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, TOOL_PATH_ABSENCES, TOOL_PATH_ACCESSES, TOOL_PATH_BASES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS, } from "../tool-face.js";
4
4
  import { inputKeysOf } from "../tool-roster.js";
5
5
  import { deliverEngineNotice } from "../engine-notice.js";
6
6
  export const APPROVAL_CARD_REQUIRED_KEYS = Object.freeze({
@@ -26,9 +26,28 @@ export function toolFaceProblem(face, schema) {
26
26
  if (face.pathTarget !== undefined) {
27
27
  if (typeof face.pathTarget.param !== "string" || !top.has(face.pathTarget.param))
28
28
  return `pathTarget.param ${JSON.stringify(face.pathTarget.param)} is not a top-level property of the tool's schema`;
29
- if (!["read", "create", "edit"].includes(face.pathTarget.access))
29
+ if (!TOOL_PATH_ACCESSES.includes(face.pathTarget.access))
30
30
  return `pathTarget.access ${JSON.stringify(face.pathTarget.access)} is outside the closed set`;
31
- const long = overlong("pathTarget.param", [face.pathTarget.param]) ?? overlong("pathTarget.aliases", face.pathTarget.aliases ?? []);
31
+ const base = face.pathTarget.base;
32
+ if (base !== undefined && !TOOL_PATH_BASES.includes(base))
33
+ return `pathTarget.base ${JSON.stringify(base)} is outside the closed set`;
34
+ if (base === "root" && face.pathTarget.access !== "read")
35
+ return `pathTarget.base "root" needs pathTarget.access "read" — two write guards (the session-transcript directory guard and the frozen-spec deny) resolve a write target against the call's working directory only, so a write face declaring a root base would be judged on two different files (#638)`;
36
+ const absent = face.pathTarget.absent;
37
+ if (absent !== undefined && !TOOL_PATH_ABSENCES.includes(absent))
38
+ return `pathTarget.absent ${JSON.stringify(absent)} is outside the closed set`;
39
+ if (absent === "base" && face.pathTarget.access !== "read")
40
+ return `pathTarget.absent "base" needs pathTarget.access "read" — it says the call is about the whole base directory when the slot is empty, which is a search SCOPE; a write with no path names no file, and the containment fences judge a write on its own target`;
41
+ const patternParam = face.pathTarget.patternParam;
42
+ if (patternParam !== undefined) {
43
+ if (typeof patternParam !== "string" || !top.has(patternParam))
44
+ return `pathTarget.patternParam ${JSON.stringify(patternParam)} is not a top-level property of the tool's schema`;
45
+ if (absent !== "base")
46
+ return `pathTarget.patternParam ${JSON.stringify(patternParam)} is declared but pathTarget.absent is ${JSON.stringify(absent ?? "none")} — the pattern base is only ever read for an absent slot, so this declaration would be inert`;
47
+ }
48
+ const long = overlong("pathTarget.param", [face.pathTarget.param]) ??
49
+ overlong("pathTarget.aliases", face.pathTarget.aliases ?? []) ??
50
+ overlong("pathTarget.patternParam", patternParam === undefined ? [] : [patternParam]);
32
51
  if (long !== undefined)
33
52
  return long;
34
53
  vocabulary.add(face.pathTarget.param);
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { canonicalizeTarget, expandHomeTilde, isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
4
4
  import { compileSegmentPattern, matchSegmentPatterns } from "../tools/fs/read-deny.js";
5
- import { isProtectedWrite } from "./tool-registry.js";
5
+ import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
6
+ import { pathTargetBaseOf } from "./effective-path-target.js";
6
7
  export const RECOMMENDED_SENSITIVE_PATTERNS = [
7
8
  ".env",
8
9
  ".env.*",
@@ -97,10 +98,11 @@ export function createSensitivePathPolicy(opts) {
97
98
  return { action: "allow" };
98
99
  if (guardedByName !== undefined ? !guardedByName.has(req.toolName) : !isProtectedWrite(req))
99
100
  return { action: "allow" };
100
- const path = writeTargetPath(req);
101
+ const protective = protectivePathTargetOf(req);
102
+ const path = writeTargetPath(req, protective);
101
103
  if (typeof path !== "string" || path.length === 0)
102
104
  return { action: "allow" };
103
- const canon = await canonicalizeTarget(opts.env, path, signal, req.cwd ?? opts.rootPath);
105
+ const canon = await canonicalizeTarget(opts.env, path, signal, pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }));
104
106
  if (!canon.ok) {
105
107
  if (canon.unresolvedSymlink) {
106
108
  return {
@@ -0,0 +1,47 @@
1
+ /** One argv word as the lexer read it. */
2
+ export interface ShellWord {
3
+ /** The word's value with quotes and escapes removed — meaningful only when `expands` is `false`. */
4
+ readonly text: string;
5
+ /** The unquoted source spelling (assignment and wrapper-flag tests read this, never `text`). */
6
+ readonly raw: string;
7
+ /** `false`: a literal word. `"one"`: carries an expansion but yields exactly ONE word (it was double-quoted).
8
+ * `"many"`: an unquoted expansion, glob or brace pattern, or a quoted `"$@"` / array expansion — may
9
+ * yield zero, one or several words. */
10
+ readonly expands: false | "one" | "many";
11
+ }
12
+ /** One program run the lexer found, after keyword stripping. */
13
+ export interface ShellSegment {
14
+ /** The run as SPELLED (a leading keyword removed): the first candidate a rule is compared against. */
15
+ readonly argv: readonly ShellWord[];
16
+ /** The deeper candidates, one per peeled layer (leading assignments, then each wrapper of the table):
17
+ * `sudo -u root rm -r x` ⇒ `[[rm, -r, x]]`. Empty when nothing peeled. */
18
+ readonly peeled: readonly (readonly ShellWord[])[];
19
+ /** Present when this segment's program runs cannot be known from the text at all; `argv` then holds
20
+ * whatever was read before the failure and must not be matched as a run. */
21
+ readonly unreadable?: string;
22
+ /** Present when the segment's program runs are NOT fully known although its candidates could be read:
23
+ * a peel that stopped before the wrapped program could be named (its options carry an expansion, a
24
+ * command string, an option of unknown arity), or a command/process substitution anywhere in the
25
+ * segment (it runs a program the lexer does not read). The candidates stand — a rule they reach is
26
+ * reached — and every other rule reads `unreadable`. */
27
+ readonly peelUnreadable?: string;
28
+ }
29
+ /** A command line as the tightening reader sees it. */
30
+ export interface ShellCommandShape {
31
+ readonly segments: readonly ShellSegment[];
32
+ /** The connector BETWEEN each consecutive pair of segments (`;` `&&` `||` `|` `|&` `&` `\n`). */
33
+ readonly connectors: readonly string[];
34
+ /** A subshell, group, control-structure keyword or dropped empty piece bounded the segments: the
35
+ * connector list is not one flat chain a compound rule could spell. */
36
+ readonly grouped: boolean;
37
+ }
38
+ export { SHELL_WRAPPER_TABLE, type ShellWrapperName } from "./shell-wrapper-table.js";
39
+ /** Longer than this and the command is not read at all (one unreadable segment): every pass is linear,
40
+ * but a substitution nested inside quoting re-enters the quote reader once per nesting level, and the
41
+ * cap is what bounds that on model-supplied text. Upstream's own reader stops at the same size. */
42
+ export declare const MAX_SHELL_READ_CHARS = 10000;
43
+ /** Read `source` as the tightening reader must: every program run the text could perform, or why it cannot be read. */
44
+ export declare function readShellCommand(source: string): ShellCommandShape;
45
+ /** Every program run of `shape` can be NAMED from the text: no segment is unreadable, no peel stopped,
46
+ * and every run candidate's program word is literal. The offer/mint side's "judgeable" question. */
47
+ export declare function isFullyReadable(shape: ShellCommandShape): boolean;