@sema-agent/core 5.18.1 → 5.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.19.0 — 2026-08-10
4
+
5
+ > Three bundles, each pre-verified two-way by the evaluation line against the 5.18.1 artifact
6
+ > before this release (criteria [3134], green-lit [3146]).
7
+
8
+ ### BREAKING
9
+
10
+ - **A task's PreToolUse screening face now screens its DELEGATED CHILDREN** (issue #33). The
11
+ face used to run only in the gate of the task that installed it, so a call it denied executed
12
+ unrefused one level down — sync, background and workflow legs alike, at any depth. It now
13
+ folds into a ToolPolicy on the existing ancestor-constraint chain (full-installation identity,
14
+ four-axis dedup so a deps-level hook is consulted once per call at any depth). A screening
15
+ `ask` resolves at the installing task's frozen approver; a hook-wired parent with no approver
16
+ denies its children's screened calls fail-closed.
17
+ **Operational**: `parentConstraintCount` gains a screening entry for hook-wired trees — a
18
+ pending approval checkpoint minted before this change and resumed after (or the reverse)
19
+ fails pre-CAS with `resume.parent_constraint_mismatch`. **Drain pending checkpoints across
20
+ the upgrade.** A delegated child of a hook-wired parent also sets
21
+ `requiresParentConstraint`, so its durable resume must re-supply the chain via
22
+ `resumeStream(..., internals)`.
23
+ - **Write guards judge the call's live cwd** (P1, host-lane proven bypass). A resident shell's
24
+ `cd` moved what a relative Write/Edit/NotebookEdit target meant, while the guards kept
25
+ judging the wiring-time root — `cd .git/hooks` + `Write("pre-commit")` landed unrefused.
26
+ Every path-resolving guard (sensitive-path, fs-write-gate, session-rule allowDirs,
27
+ active-skill allowPaths, transcript-integrity, run-spec frozen deny) now resolves the target
28
+ against `ToolCallRequest.cwd`, stamped per call from the same tracked cwd the tools resolve
29
+ with. **The asymmetry is deliberate**: the target follows the live cwd; each guard's own
30
+ configured directories stay anchored to the static root, so a `cd` cannot relocate the fence
31
+ itself. Absent the stamp (direct invocation), every guard falls back to its configured root —
32
+ byte-identical prior behavior. Also newly asked: a non-read-only Bash whose cwd sits inside
33
+ the transcript directory, and a POSIX filename containing a backslash (fail-closed).
34
+ Registered, not fixed: bash builtin writes, hook `touchedPaths`, the shell-operand axis
35
+ (backlog #108 notes).
36
+
37
+ ### Fixed
38
+
39
+ - **Read-only shell classification (`shellGate:"classify"`) prompts less and is more accurate
40
+ in both directions.** The compound splitter carries quote state, so a quoted connector —
41
+ `grep -E "a|b"`, the ordinary spelling of a read-only monitoring pipeline — is argument text
42
+ rather than a phantom command boundary; `2>/dev/null` and output-descriptor fd duplication
43
+ are recognized as moving no data into any file. No widening: every other redirection form
44
+ still rejects, unbalanced quoting falls back to the stricter blind scan, and phantom segments
45
+ could only ever add a rejection. Also tightened in the demoting direction: operand counts now
46
+ match what bash passes, so stdin-reading forms that would hang (`grep "a | b"`,
47
+ `cut -d , -f1`, `grep -if-`, `grep -f/dev/zero`) no longer auto-allow.
48
+
49
+ ### Guards / tooling (no behavior surface)
50
+
51
+ - `scripts/ship-post-lint.mjs`: release-post commit references are ancestry-verified against
52
+ the release, and negative claims get the real two-version dist delta to be written against.
53
+ - A brain silent-recovery-arm ratchet freezes the 26 existing tell-nobody arms so a new one has
54
+ to be argued for; the delegation carriage surfaces (live chain, constraint entry, checkpoint
55
+ projection) are exhaustively registered so a new field stops compiling until its travel is
56
+ recorded.
57
+
3
58
  ## 5.18.1 — 2026-08-10
4
59
 
5
60
  > The 5.18.0 post-release verification round, folded back in. Every surface below was
@@ -15,7 +15,7 @@ export function createFsWriteGatePolicy(opts) {
15
15
  if (path === undefined) {
16
16
  return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine`);
17
17
  }
18
- const canon = await canonicalizeTarget(env, path, signal, rootPath);
18
+ const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
19
19
  if (!canon.ok) {
20
20
  return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})`);
21
21
  }
@@ -1,6 +1,6 @@
1
1
  import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
- import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "./tool-policy.js";
3
+ import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  export interface Hooks {
5
5
  preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
6
6
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
@@ -165,4 +165,5 @@ export interface ToolGateInput {
165
165
  }) => void;
166
166
  };
167
167
  }
168
+ export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
168
169
  export declare function runToolGate(input: ToolGateInput): Promise<ToolGateResult>;
@@ -98,6 +98,38 @@ function screenPreToolUseResult(r) {
98
98
  return undefined;
99
99
  return refuseOutOfContractDecision(r);
100
100
  }
101
+ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
102
+ return {
103
+ check: async (req) => {
104
+ let r;
105
+ try {
106
+ r = screenPreToolUseResult(await preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, ...(env !== undefined ? { env } : {}) }));
107
+ }
108
+ catch (err) {
109
+ try {
110
+ onCrash?.(err);
111
+ }
112
+ catch {
113
+ }
114
+ return { action: "deny", message: preToolUseCrashReason(`this call to "${req.toolName}"`, err), decisionReason: "hook" };
115
+ }
116
+ if (r === undefined)
117
+ return { action: "allow" };
118
+ if (r.action === "deny") {
119
+ return { action: "deny", message: decisionText(r) ?? `tool "${req.toolName}" blocked by a PreToolUse hook`, decisionReason: "hook" };
120
+ }
121
+ if (r.action === "ask") {
122
+ return {
123
+ action: "ask",
124
+ message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`,
125
+ decisionReason: "hook",
126
+ ...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}),
127
+ };
128
+ }
129
+ return r.updatedInput !== undefined ? { action: "allow", updatedInput: r.updatedInput } : { action: "allow" };
130
+ },
131
+ };
132
+ }
101
133
  function traceHookCrash(input, err, notifier) {
102
134
  notifier.notify(() => input.onHookError?.(err), "toolGate.onHookError");
103
135
  }
@@ -96,7 +96,7 @@ export function createActiveSkillScopePolicy(opts) {
96
96
  decisionReason: "safety",
97
97
  };
98
98
  }
99
- const canon = await canonicalizeTarget(env, path, signal, rootPath);
99
+ const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
100
100
  if (!canon.ok) {
101
101
  return {
102
102
  action: "deny",
@@ -10,6 +10,7 @@ import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
10
10
  import type { OnAsk, ToolPolicy } from "../tool-policy.js";
11
11
  import { type ActiveSkillFrame } from "./active-skill-scope.js";
12
12
  import type { SessionPermissionRules } from "../session-policy-store.js";
13
+ import { type Hooks } from "../hooks.js";
13
14
  import { type RecoveredOrphan } from "../session-reconcile.js";
14
15
  import { CacheBreakDetector, type ToolFingerprintInput } from "../cache-break-detector.js";
15
16
  import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
@@ -330,6 +331,8 @@ export interface InheritedGate {
330
331
  policy: ToolPolicy;
331
332
  onAsk?: OnAsk;
332
333
  durableMandate?: boolean;
334
+ preToolUse?: Hooks["preToolUse"];
335
+ hookEnv?: unknown;
333
336
  }>;
334
337
  }
335
338
  export declare function isFableFamilyModelId(id: string): boolean;
@@ -28,7 +28,7 @@ import { CHANGED_FILES_MTIME_EPS_MS, fenceMcpServerInstructions, renderAgentList
28
28
  import { inlineUntrusted } from "../untrusted-text.js";
29
29
  import { emitTrace } from "../trace.js";
30
30
  import { createSessionRulePolicy } from "./session-rule-policy.js";
31
- import { cloneObserverInput, createHookEnvCapabilities, formatHookFeedback, runToolGate } from "../hooks.js";
31
+ import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, runToolGate } from "../hooks.js";
32
32
  import { reconcileInterruptedSession } from "../session-reconcile.js";
33
33
  import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
34
34
  import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
@@ -1057,6 +1057,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1057
1057
  const ownOrgVerdictRef = { current: undefined };
1058
1058
  const orgAdmissionCheckpointState = () => inheritedAdmittedOrgScopes !== undefined || ownOrgVerdictRef.current !== undefined || orgGovernedProvenance;
1059
1059
  const frozenOnAsk = spec.onAsk ?? deps.onAsk;
1060
+ const hookEnvSource = (ownedEnv ?? deps.executionEnv) != null ? executionEnv : undefined;
1061
+ const notifyOwnHookCrash = (err) => {
1062
+ try {
1063
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
1064
+ }
1065
+ catch {
1066
+ }
1067
+ };
1060
1068
  const frozenOnQuestion = spec.onQuestion ?? deps.onQuestion;
1061
1069
  const inheritedGateForChildren = () => {
1062
1070
  const ancestorRules = [
@@ -1066,8 +1074,25 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1066
1074
  const ownCallerPolicy = lockedPreflight.toolPolicy;
1067
1075
  const durableMandate = runtimeCaps?.forceDurableGate === true ||
1068
1076
  (spec.durableApproval !== undefined && !isLiveApproverSeat(frozenOnAsk));
1077
+ const ownPreToolUse = hooks?.preToolUse;
1078
+ const hookConstraint = ownPreToolUse !== undefined &&
1079
+ !(inheritedParentConstraints ?? []).some((pc) => pc.preToolUse === ownPreToolUse &&
1080
+ askApproverIdentity(pc.onAsk) === askApproverIdentity(frozenOnAsk) &&
1081
+ (pc.durableMandate === true) === durableMandate &&
1082
+ pc.hookEnv === hookEnvSource)
1083
+ ? [
1084
+ {
1085
+ policy: createPreToolUseConstraintPolicy(ownPreToolUse, hookEnvFace, notifyOwnHookCrash),
1086
+ preToolUse: ownPreToolUse,
1087
+ ...(hookEnvSource !== undefined ? { hookEnv: hookEnvSource } : {}),
1088
+ ...(frozenOnAsk !== undefined ? { onAsk: frozenOnAsk } : {}),
1089
+ ...(durableMandate ? { durableMandate: true } : {}),
1090
+ },
1091
+ ]
1092
+ : [];
1069
1093
  const parentConstraints = [
1070
1094
  ...(inheritedParentConstraints ?? []),
1095
+ ...hookConstraint,
1071
1096
  ...(ownCallerPolicy !== undefined
1072
1097
  ? [
1073
1098
  {
@@ -2920,6 +2945,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2920
2945
  auditPolicyNames(layer);
2921
2946
  const denyNarrowingPolicy = narrowingLayers.length === 0 ? undefined : narrowingLayers.length === 1 ? narrowingLayers[0] : combinePolicies(...narrowingLayers);
2922
2947
  const basePolicyForResumeEdit = lockedPreflight.toolPolicy;
2948
+ const hooks = spec.hooks ?? deps.hooks;
2923
2949
  const sameInstanceAncestorCount = policy === undefined ? 0 : (inheritedParentConstraints ?? []).reduce((n, pc) => (pc.policy === policy ? n + 1 : n), 0);
2924
2950
  const sharedFirstDecision = new Map();
2925
2951
  const SHARED_FIRST_DECISION_CAP = 256;
@@ -3067,125 +3093,133 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3067
3093
  editArgs = rr.updatedInput;
3068
3094
  }
3069
3095
  };
3070
- const parentConstraintWrappers = (inheritedParentConstraints ?? []).map((pc) => policy !== undefined && pc.policy === policy
3071
- ? {
3072
- check: async (creq, csignal) => {
3073
- const first = sharedFirstDecision.get(creq.toolCallId);
3074
- if (first === undefined) {
3075
- return {
3076
- action: "deny",
3077
- message: `inherited parent policy could not be arbitrated for "${creq.toolName}" ` +
3078
- `(shared-instance first decision unavailable); denied fail-closed`,
3079
- };
3080
- }
3081
- if (first.action === "deny")
3082
- return first;
3083
- if (first.action === "allow")
3084
- return { action: "allow" };
3085
- if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
3086
- return first;
3087
- }
3088
- if (pc.durableMandate === true) {
3089
- if (resolveCheckpointStore(spec, deps) !== undefined &&
3090
- (spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
3091
- markInheritedUnavailable(creq.toolCallId)) {
3092
- return first;
3096
+ const parentConstraintWrappers = (inheritedParentConstraints ?? []).map((pc) => pc.preToolUse !== undefined &&
3097
+ pc.preToolUse === hooks?.preToolUse &&
3098
+ pc.durableMandate !== true &&
3099
+ askApproverIdentity(pc.onAsk) === askApproverIdentity(frozenOnAsk) &&
3100
+ pc.hookEnv === hookEnvSource
3101
+ ? { check: () => ({ action: "allow" }) }
3102
+ : policy !== undefined && pc.policy === policy
3103
+ ? {
3104
+ check: async (creq, csignal) => {
3105
+ const first = sharedFirstDecision.get(creq.toolCallId);
3106
+ if (first === undefined) {
3107
+ return {
3108
+ action: "deny",
3109
+ message: `inherited parent policy could not be arbitrated for "${creq.toolName}" ` +
3110
+ `(shared-instance first decision unavailable); denied fail-closed`,
3111
+ };
3093
3112
  }
3094
- return {
3095
- action: "deny",
3096
- message: `inherited parent policy requires durable approval for "${creq.toolName}" — the parent's durable ` +
3097
- `ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
3098
- };
3099
- }
3100
- const presentedArgs = creq.args;
3101
- const askT0 = now();
3102
- const resolved = await resolveAsk({
3103
- toolName: creq.toolName,
3104
- toolCallId: creq.toolCallId,
3105
- args: presentedArgs,
3106
- ...ruleSuggestionsOf(creq.toolName, presentedArgs),
3107
- message: first.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3108
- ...askSourceIdentity(),
3109
- ...riskAxesOf(creq.toolName),
3110
- ...(first.action === "ask" && first.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3111
- }, pc.onAsk, csignal ?? abortController.signal);
3112
- const askWaitMs = Math.max(0, now() - askT0);
3113
- if (resolved.action === "deny" && resolved.approverUnavailable === true) {
3114
- if (markInheritedUnavailable(creq.toolCallId))
3113
+ if (first.action === "deny")
3114
+ return first;
3115
+ if (first.action === "allow")
3116
+ return { action: "allow" };
3117
+ if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
3115
3118
  return first;
3116
- return {
3117
- action: "deny",
3118
- message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
3119
- };
3120
- }
3121
- if (resolved.action !== "allow")
3122
- return resolved;
3123
- if (resolved.updatedInput !== undefined) {
3124
- return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
3125
- }
3126
- recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
3127
- return { action: "allow" };
3128
- },
3129
- }
3130
- : {
3131
- check: async (creq, csignal) => {
3132
- let decision;
3133
- try {
3134
- decision = await pc.policy.check(creq, csignal ?? abortController.signal);
3135
- }
3136
- catch (err) {
3137
- return {
3138
- action: "deny",
3139
- message: `inherited parent policy errored for "${creq.toolName}": ${err instanceof Error ? err.message : String(err)}`,
3140
- };
3141
- }
3142
- if (decision.action !== "ask")
3143
- return decision;
3144
- if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
3145
- return decision;
3146
- }
3147
- if (pc.durableMandate === true) {
3148
- if (resolveCheckpointStore(spec, deps) !== undefined &&
3149
- (spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
3150
- markInheritedUnavailable(creq.toolCallId)) {
3151
- return decision;
3152
3119
  }
3153
- return {
3154
- action: "deny",
3155
- message: `inherited parent policy requires durable approval for "${creq.toolName}" — the parent's durable ` +
3156
- `ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
3157
- };
3158
- }
3159
- const presentedArgs = decision.updatedInput !== undefined ? decision.updatedInput : creq.args;
3160
- const askT0 = now();
3161
- const resolved = await resolveAsk({
3162
- toolName: creq.toolName,
3163
- toolCallId: creq.toolCallId,
3164
- args: presentedArgs,
3165
- ...ruleSuggestionsOf(creq.toolName, presentedArgs),
3166
- message: decision.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3167
- ...askSourceIdentity(),
3168
- ...riskAxesOf(creq.toolName),
3169
- ...(decision.action === "ask" && decision.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3170
- }, pc.onAsk, csignal ?? abortController.signal);
3171
- const askWaitMs = Math.max(0, now() - askT0);
3172
- if (resolved.action === "deny" && resolved.approverUnavailable === true) {
3173
- if (markInheritedUnavailable(creq.toolCallId))
3120
+ if (pc.durableMandate === true) {
3121
+ if (resolveCheckpointStore(spec, deps) !== undefined &&
3122
+ (spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
3123
+ markInheritedUnavailable(creq.toolCallId)) {
3124
+ return first;
3125
+ }
3126
+ return {
3127
+ action: "deny",
3128
+ message: `inherited parent policy requires durable approval for "${creq.toolName}" — the parent's durable ` +
3129
+ `ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
3130
+ };
3131
+ }
3132
+ const presentedArgs = creq.args;
3133
+ const askT0 = now();
3134
+ const resolved = await resolveAsk({
3135
+ toolName: creq.toolName,
3136
+ toolCallId: creq.toolCallId,
3137
+ args: presentedArgs,
3138
+ ...ruleSuggestionsOf(creq.toolName, presentedArgs),
3139
+ message: first.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3140
+ ...askSourceIdentity(),
3141
+ ...riskAxesOf(creq.toolName),
3142
+ ...(first.action === "ask" && first.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3143
+ }, pc.onAsk, csignal ?? abortController.signal);
3144
+ const askWaitMs = Math.max(0, now() - askT0);
3145
+ if (resolved.action === "deny" && resolved.approverUnavailable === true) {
3146
+ if (markInheritedUnavailable(creq.toolCallId))
3147
+ return first;
3148
+ return {
3149
+ action: "deny",
3150
+ message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
3151
+ };
3152
+ }
3153
+ if (resolved.action !== "allow")
3154
+ return resolved;
3155
+ if (resolved.updatedInput !== undefined) {
3156
+ return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
3157
+ }
3158
+ recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
3159
+ return { action: "allow" };
3160
+ },
3161
+ }
3162
+ : {
3163
+ check: async (creq, csignal) => {
3164
+ let decision;
3165
+ try {
3166
+ decision = await pc.policy.check(creq, csignal ?? abortController.signal);
3167
+ }
3168
+ catch (err) {
3169
+ return {
3170
+ action: "deny",
3171
+ message: `inherited parent policy errored for "${creq.toolName}": ${err instanceof Error ? err.message : String(err)}`,
3172
+ };
3173
+ }
3174
+ if (decision.action !== "ask")
3174
3175
  return decision;
3175
- return {
3176
- action: "deny",
3177
- message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
3178
- };
3179
- }
3180
- if (resolved.action !== "allow")
3181
- return resolved;
3182
- if (resolved.updatedInput !== undefined) {
3183
- return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
3184
- }
3185
- recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
3186
- return decision.updatedInput !== undefined ? { ...resolved, updatedInput: decision.updatedInput } : resolved;
3187
- },
3188
- });
3176
+ if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
3177
+ return decision;
3178
+ }
3179
+ if (pc.durableMandate === true) {
3180
+ if (resolveCheckpointStore(spec, deps) !== undefined &&
3181
+ (spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
3182
+ markInheritedUnavailable(creq.toolCallId)) {
3183
+ return decision;
3184
+ }
3185
+ return {
3186
+ action: "deny",
3187
+ message: `inherited parent policy requires durable approval for "${creq.toolName}" the parent's durable ` +
3188
+ `ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
3189
+ };
3190
+ }
3191
+ const presentedArgs = decision.updatedInput !== undefined ? decision.updatedInput : creq.args;
3192
+ const askT0 = now();
3193
+ const resolved = await resolveAsk({
3194
+ toolName: creq.toolName,
3195
+ toolCallId: creq.toolCallId,
3196
+ args: presentedArgs,
3197
+ ...ruleSuggestionsOf(creq.toolName, presentedArgs),
3198
+ message: decision.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3199
+ ...askSourceIdentity(),
3200
+ ...riskAxesOf(creq.toolName),
3201
+ ...(decision.action === "ask" && decision.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3202
+ }, pc.onAsk, csignal ?? abortController.signal);
3203
+ const askWaitMs = Math.max(0, now() - askT0);
3204
+ if (resolved.action === "deny" && resolved.approverUnavailable === true) {
3205
+ if (markInheritedUnavailable(creq.toolCallId))
3206
+ return decision;
3207
+ return {
3208
+ action: "deny",
3209
+ message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
3210
+ };
3211
+ }
3212
+ if (resolved.action !== "allow")
3213
+ return resolved;
3214
+ if (resolved.updatedInput !== undefined) {
3215
+ return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
3216
+ }
3217
+ if (pc.preToolUse === undefined) {
3218
+ recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
3219
+ }
3220
+ return decision.updatedInput !== undefined ? { ...resolved, updatedInput: decision.updatedInput } : resolved;
3221
+ },
3222
+ });
3189
3223
  const rewriteCapableLayers = policy !== undefined || parentConstraintWrappers.length > 0;
3190
3224
  const rewriteEmitterIndex = new Map();
3191
3225
  const rewriteCapableChain = [
@@ -3269,7 +3303,6 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3269
3303
  },
3270
3304
  }
3271
3305
  : foldedPolicy;
3272
- const hooks = spec.hooks ?? deps.hooks;
3273
3306
  const onAsk = spec.onAsk ?? deps.onAsk;
3274
3307
  const handWriteTools = handsEnabled && spec.handsReadOnly !== true
3275
3308
  ? Object.keys(HAND_TOOL_EFFECTS).filter((name) => {
@@ -3391,7 +3424,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3391
3424
  const hookEnvFace = hookContextConsumerWired && (ownedEnv ?? deps.executionEnv) != null ? createHookEnvCapabilities(executionEnv) : undefined;
3392
3425
  if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || resourceSuspendEligible || platformSuspendArmed || spec.enablePlanMode === true) {
3393
3426
  const adjudicate = effectivePolicy
3394
- ? (req) => raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot }, abortController.signal)), abortController.signal, () => ({
3427
+ ? (req) => raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, abortController.signal)), abortController.signal, () => ({
3395
3428
  action: "deny",
3396
3429
  message: "policy check aborted (task timed out or cancelled)",
3397
3430
  }))
@@ -3918,7 +3951,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3918
3951
  if (!presented.ok) {
3919
3952
  throw new ParkRefusal(`the stored form of "${req.toolName}"'s arguments could not be presented for re-adjudication (${describeThrown(presented.cause)})`, { cause: presented.cause });
3920
3953
  }
3921
- const reprojected = refuseOutOfContractDecision(await basePolicyForResumeEdit.check({ toolName: req.toolName, args: presented.value, toolCallId: req.toolCallId }, abortController.signal));
3954
+ const reprojected = refuseOutOfContractDecision(await basePolicyForResumeEdit.check({
3955
+ toolName: req.toolName,
3956
+ args: presented.value,
3957
+ toolCallId: req.toolCallId,
3958
+ ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}),
3959
+ }, abortController.signal));
3922
3960
  const demanded = reprojected.updatedInput !== undefined ? tryCloneArgs(reprojected.updatedInput) : undefined;
3923
3961
  const rewroteTheFiledValue = reprojected.updatedInput !== undefined &&
3924
3962
  !(demanded?.ok === true &&
@@ -3853,7 +3853,12 @@ export class Runner {
3853
3853
  throw new CheckpointError("checkpoint.invalid_outcome", `pending action reached the resolver with decision "${String(outcome.decision)}" — only "allow" executes and only "deny" injects a denial; refusing to execute`);
3854
3854
  }
3855
3855
  if (outcome.decision === "allow" && outcome.updatedInput !== undefined && prepared.basePolicyForResumeEdit) {
3856
- const rechecked = refuseOutOfContractDecision(await prepared.basePolicyForResumeEdit.check({ toolName: pendingAction.toolName, args: resolvedArgs, toolCallId: pendingAction.toolCallId }, prepared.abortController.signal));
3856
+ const rechecked = refuseOutOfContractDecision(await prepared.basePolicyForResumeEdit.check({
3857
+ toolName: pendingAction.toolName,
3858
+ args: resolvedArgs,
3859
+ toolCallId: pendingAction.toolCallId,
3860
+ ...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
3861
+ }, prepared.abortController.signal));
3857
3862
  if (rechecked.action === "deny") {
3858
3863
  const editedDenial = formatHookFeedback(`The approver EDITED this call's input; the edited call is denied by the deployment's tool policy and was not executed${rechecked.message ? `: ${rechecked.message}` : ""}.`);
3859
3864
  emitEnd(true, { content: editedDenial });
@@ -3863,7 +3868,12 @@ export class Runner {
3863
3868
  }
3864
3869
  }
3865
3870
  if (prepared.denyNarrowingPolicy) {
3866
- const narrowed = refuseOutOfContractDecision(await prepared.denyNarrowingPolicy.check({ toolName: pendingAction.toolName, args: resolvedArgs, toolCallId: pendingAction.toolCallId }, prepared.abortController.signal));
3871
+ const narrowed = refuseOutOfContractDecision(await prepared.denyNarrowingPolicy.check({
3872
+ toolName: pendingAction.toolName,
3873
+ args: resolvedArgs,
3874
+ toolCallId: pendingAction.toolCallId,
3875
+ ...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
3876
+ }, prepared.abortController.signal));
3867
3877
  if (narrowed.action === "deny") {
3868
3878
  const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by a session rule and was not executed${narrowed.message ? `: ${narrowed.message}` : ""}.`);
3869
3879
  emitEnd(true, { content: narrowedDenial });
@@ -58,7 +58,7 @@ export function createSessionRulePolicy(rules, opts) {
58
58
  if (typeof path !== "string" || path.length === 0) {
59
59
  return deny(`write tool "${req.toolName}" denied: session rule confines writes to allowDirs but the call has no resolvable path`);
60
60
  }
61
- const canon = await canonicalizeTarget(env, path, signal, rootPath);
61
+ const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
62
62
  if (!canon.ok) {
63
63
  return deny(`write to "${path}" denied: its real target could not be resolved against the session-rule allowDirs`);
64
64
  }
@@ -64,7 +64,7 @@ export function createSensitivePathPolicy(opts) {
64
64
  const path = writeTargetPath(canonical, req.args);
65
65
  if (typeof path !== "string" || path.length === 0)
66
66
  return { action: "allow" };
67
- const canon = await canonicalizeTarget(opts.env, path, signal, opts.rootPath);
67
+ const canon = await canonicalizeTarget(opts.env, path, signal, req.cwd ?? opts.rootPath);
68
68
  if (!canon.ok) {
69
69
  if (canon.unresolvedSymlink) {
70
70
  return {
@@ -2,6 +2,7 @@ export interface ToolCallRequest {
2
2
  toolName: string;
3
3
  args: unknown;
4
4
  toolCallId: string;
5
+ cwd?: string;
5
6
  budget?: {
6
7
  resourceRemainingMicroUsd?: number;
7
8
  resourceSpentMicroUsd: number;
@@ -1,10 +1,10 @@
1
1
  import { homedir } from "node:os";
2
- import { isAbsolute, join, normalize as normalizePath, sep } from "node:path";
2
+ import { join, normalize as normalizePath, posix as posixPath, sep, win32 as winPath } from "node:path";
3
3
  import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
4
4
  import { boundInputHashOf } from "./canonical-json.js";
5
5
  import { inlineUntrusted } from "./untrusted-text.js";
6
6
  import { parsePermissionRule } from "./permission-rules.js";
7
- import { writeTargetPath } from "../tools/fs/safety.js";
7
+ import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
8
8
  export const APPROVAL_SETTLED_BY_VALUES = ["human", "timeout", "aborted"];
9
9
  export function isApprovalSettledBy(v) {
10
10
  return typeof v === "string" && APPROVAL_SETTLED_BY_VALUES.includes(v);
@@ -526,13 +526,16 @@ export function createTranscriptIntegrityPolicy(opts) {
526
526
  const readAllow = new Set(opts?.readAllow ?? BASH_READONLY_DEFAULT_ALLOW);
527
527
  const shellTools = canonicalToolNameSet(opts?.tools);
528
528
  const dirSegs = dirs.map((d) => pathSegments(d).map(foldPathCase));
529
- const inProtectedDir = (p) => {
530
- const abs = lexicalPath(p, home);
529
+ const inProtectedDir = (p, liveCwd) => {
530
+ const rawRelative = !isAbsolutePathForm(p);
531
+ const joinInFamily = (base, rel) => isWinFormPath(base) ? winPath.join(base, rel) : posixPath.join(base, rel);
532
+ const abs = liveCwd !== undefined && rawRelative ? lexicalPath(joinInFamily(liveCwd, p), home) : lexicalPath(p, home);
531
533
  const segs = pathSegments(abs).map(foldPathCase);
532
- if (isAbsolute(abs)) {
533
- return dirSegs.some((d) => d.length <= segs.length && d.every((s, i) => s === segs[i]));
534
- }
535
- return dirSegs.some((d) => relativeContinuesDirTail(segs, d));
534
+ if (isAbsolutePathForm(abs) && dirSegs.some((d) => d.length <= segs.length && d.every((s, i) => s === segs[i])))
535
+ return true;
536
+ if (!rawRelative)
537
+ return false;
538
+ return dirSegs.some((d) => relativeContinuesDirTail(pathSegments(lexicalPath(p, home)).map(foldPathCase), d));
536
539
  };
537
540
  const referencesProtectedDir = (cmd) => {
538
541
  const hay = foldPathCase(cmd);
@@ -571,18 +574,21 @@ export function createTranscriptIntegrityPolicy(opts) {
571
574
  if (typeof command !== "string")
572
575
  return ALLOW;
573
576
  const cmd = command.normalize("NFC");
574
- if (!referencesProtectedDir(cmd))
577
+ const cwdInsideProtected = req.cwd !== undefined && inProtectedDir(req.cwd);
578
+ if (!referencesProtectedDir(cmd) && !cwdInsideProtected)
575
579
  return ALLOW;
576
580
  const parsed = parseLeadingCommandName(command);
577
581
  if ("name" in parsed && readAllow.has(parsed.name))
578
582
  return ALLOW;
579
583
  return askReason("name" in parsed
580
- ? `\`${parsed.name}\` is not a read-only command`
581
- : "the command references the transcript directory and is not a single read-only command (fail-closed)");
584
+ ? `\`${parsed.name}\` is not a read-only command${cwdInsideProtected ? " and the shell's working directory is inside the transcript directory" : ""}`
585
+ : cwdInsideProtected
586
+ ? "the shell's working directory is inside the transcript directory and this is not a single read-only command (fail-closed)"
587
+ : "the command references the transcript directory and is not a single read-only command (fail-closed)");
582
588
  }
583
589
  if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
584
590
  const p = writeTargetPath(toolName, req.args);
585
- if (typeof p === "string" && inProtectedDir(p)) {
591
+ if (typeof p === "string" && inProtectedDir(p, req.cwd)) {
586
592
  return askReason(`"${p}" is inside the session-transcript directory`);
587
593
  }
588
594
  }
@@ -26,7 +26,7 @@ function frozenDenyPolicy(rootDir, frozenResolved) {
26
26
  if (typeof raw !== "string" || raw.length === 0)
27
27
  return { action: "allow" };
28
28
  const absForm = isAbsolutePathForm(raw);
29
- const abs = pathKey(absForm ? raw : resolve(rootDir, raw));
29
+ const abs = pathKey(absForm ? raw : resolve(req.cwd || rootDir, raw));
30
30
  if (frozen.has(abs)) {
31
31
  return { action: "deny", message: `${raw} is part of the frozen specification surface (read-only).` };
32
32
  }
@@ -1,11 +1,14 @@
1
1
  export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
2
2
  export declare const BASH_READONLY_DEFAULT_ALLOW: readonly string[];
3
- export declare function parseLeadingCommandName(command: string): {
3
+ export interface LeadingCommandNameOptions {
4
+ quotedOperatorsAreText?: boolean;
5
+ }
6
+ export declare function parseLeadingCommandName(command: string, options?: LeadingCommandNameOptions): {
4
7
  name: string;
5
8
  } | {
6
9
  reject: string;
7
10
  };
8
- export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>): string | undefined;
11
+ export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>, options?: LeadingCommandNameOptions): string | undefined;
9
12
  export interface BashReadonlyRootBoundary {
10
13
  roots: readonly string[];
11
14
  cwd?: string;
@@ -5,11 +5,41 @@ export const BASH_READONLY_DEFAULT_ALLOW = [
5
5
  "grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
6
6
  ];
7
7
  const SHELL_OPERATORS = /[;&|<>$()`\n\r\\]/;
8
- export function parseLeadingCommandName(command) {
8
+ function quoteMask(s) {
9
+ const quoted = new Array(s.length).fill(false);
10
+ let open;
11
+ for (let i = 0; i < s.length; i++) {
12
+ const ch = s[i];
13
+ if (open === undefined && (ch === '"' || ch === "'")) {
14
+ open = ch;
15
+ quoted[i] = true;
16
+ continue;
17
+ }
18
+ if (open !== undefined) {
19
+ quoted[i] = true;
20
+ if (open === ch)
21
+ open = undefined;
22
+ }
23
+ }
24
+ return { quoted, balanced: open === undefined };
25
+ }
26
+ function hasBareShellOperator(s, quotedOperatorsAreText) {
27
+ if (!quotedOperatorsAreText)
28
+ return SHELL_OPERATORS.test(s);
29
+ const mask = quoteMask(s);
30
+ if (!mask.balanced)
31
+ return SHELL_OPERATORS.test(s);
32
+ for (let i = 0; i < s.length; i++) {
33
+ if (!mask.quoted[i] && SHELL_OPERATORS.test(s[i]))
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ export function parseLeadingCommandName(command, options) {
9
39
  const trimmed = command.trim();
10
40
  if (!trimmed)
11
41
  return { reject: "empty command" };
12
- if (SHELL_OPERATORS.test(trimmed)) {
42
+ if (hasBareShellOperator(trimmed, options?.quotedOperatorsAreText === true)) {
13
43
  return {
14
44
  reject: "shell operators (pipes, redirects, ;, &&, command substitution, subshells, variable expansion) are not allowed — run a single simple command",
15
45
  };
@@ -23,8 +53,8 @@ export function parseLeadingCommandName(command) {
23
53
  return { reject: "the command name must be a bare token (no quotes, braces, globs, or ~)" };
24
54
  return { name: first };
25
55
  }
26
- export function coarseReadonlyCheck(command, allow) {
27
- const parsed = parseLeadingCommandName(command);
56
+ export function coarseReadonlyCheck(command, allow, options) {
57
+ const parsed = parseLeadingCommandName(command, options);
28
58
  if ("reject" in parsed)
29
59
  return parsed.reject;
30
60
  if (!allow.has(parsed.name))
@@ -32,6 +62,35 @@ export function coarseReadonlyCheck(command, allow) {
32
62
  return undefined;
33
63
  }
34
64
  const SHELL_SEGMENT_HARD_REJECT = /[<>$()`\n\r\\]/;
65
+ const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
66
+ function stripExemptRedirections(command) {
67
+ if (!command.includes(">"))
68
+ return command;
69
+ const mask = quoteMask(command);
70
+ if (!mask.balanced)
71
+ return command;
72
+ let out = "";
73
+ let word = "";
74
+ let wordQuoted = false;
75
+ const flush = () => {
76
+ out += wordQuoted || !EXEMPT_REDIRECTION.test(word) ? word : "";
77
+ word = "";
78
+ wordQuoted = false;
79
+ };
80
+ for (let i = 0; i < command.length; i++) {
81
+ const ch = command[i];
82
+ if (!mask.quoted[i] && (ch === " " || ch === "\t")) {
83
+ flush();
84
+ out += ch;
85
+ continue;
86
+ }
87
+ if (mask.quoted[i])
88
+ wordQuoted = true;
89
+ word += ch;
90
+ }
91
+ flush();
92
+ return out;
93
+ }
35
94
  function foldQuoteRemovalToken(tok) {
36
95
  if (!tok.includes('"') && !tok.includes("'"))
37
96
  return tok;
@@ -77,12 +136,30 @@ function resolveOperandLexically(base, operand, homeDir) {
77
136
  return normalizeAbsPathLexically(`${base.replace(/[/\\]+$/, "")}/${raw}`);
78
137
  }
79
138
  function tokenizeSegment(segment) {
80
- const raw = segment
81
- .trim()
82
- .split(/\s+/)
83
- .filter((t) => t.length > 0);
139
+ const raw = splitWordsQuoteAware(segment);
84
140
  return { folded: raw.map(foldQuoteRemovalToken), raw };
85
141
  }
142
+ function splitWordsQuoteAware(segment) {
143
+ const s = segment.trim();
144
+ const mask = quoteMask(s);
145
+ if (!mask.balanced)
146
+ return s.split(/\s+/).filter((t) => t.length > 0);
147
+ const out = [];
148
+ let cur = "";
149
+ for (let i = 0; i < s.length; i++) {
150
+ const ch = s[i];
151
+ if (!mask.quoted[i] && /\s/.test(ch)) {
152
+ if (cur.length > 0)
153
+ out.push(cur);
154
+ cur = "";
155
+ continue;
156
+ }
157
+ cur += ch;
158
+ }
159
+ if (cur.length > 0)
160
+ out.push(cur);
161
+ return out;
162
+ }
86
163
  function hasUnquotedExpansionMetachar(rawToken) {
87
164
  let open;
88
165
  for (const ch of rawToken) {
@@ -147,6 +224,26 @@ function isCutDelimiterPayloadLongOption(tok) {
147
224
  const name = longOptionNameOf(tok);
148
225
  return name !== undefined && (isLongOptionAbbrevOf(name, "delimiter") || isLongOptionAbbrevOf(name, "output-delimiter"));
149
226
  }
227
+ function isGrepClusterFileStdin(tok) {
228
+ if (tok.startsWith("--") || !/^-[A-Za-z0-9]/.test(tok))
229
+ return false;
230
+ for (let i = 1; i < tok.length; i++) {
231
+ const ch = tok[i];
232
+ if (ch === "e")
233
+ return false;
234
+ if (ch === "f")
235
+ return tok.slice(i + 1) === "-";
236
+ if (!/[A-Za-z0-9]/.test(ch))
237
+ return false;
238
+ }
239
+ return false;
240
+ }
241
+ function isCutSeparatedDelimiterOption(tok) {
242
+ if (tok.includes("="))
243
+ return false;
244
+ const name = longOptionNameOf(tok);
245
+ return name !== undefined && (isLongOptionAbbrevOf(name, "delimiter") || isLongOptionAbbrevOf(name, "output-delimiter"));
246
+ }
150
247
  function isGrepFileStdinLongOption(tok) {
151
248
  const eq = tok.indexOf("=");
152
249
  if (eq < 0 || tok.slice(eq + 1) !== "-")
@@ -285,16 +382,22 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
285
382
  const trimmed = command.trim();
286
383
  if (!trimmed)
287
384
  return { reason: "empty command" };
288
- if (SHELL_SEGMENT_HARD_REJECT.test(trimmed)) {
385
+ const redirectionStripped = stripExemptRedirections(trimmed);
386
+ if (SHELL_SEGMENT_HARD_REJECT.test(redirectionStripped)) {
289
387
  return { reason: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
290
388
  }
291
- const source = trimmed.endsWith(";") ? trimmed.slice(0, -1) : trimmed;
389
+ const source = redirectionStripped.endsWith(";") ? redirectionStripped.slice(0, -1) : redirectionStripped;
292
390
  const segments = [];
293
391
  const pipeFed = [false];
392
+ const mask = quoteMask(source);
393
+ const quoted = (i) => mask.balanced && mask.quoted[i] === true;
294
394
  let cur = "";
295
395
  for (let i = 0; i < source.length; i++) {
296
396
  const c = source[i];
297
- if (c === "&") {
397
+ if (quoted(i)) {
398
+ cur += c;
399
+ }
400
+ else if (c === "&") {
298
401
  if (source[i + 1] === "&") {
299
402
  segments.push(cur);
300
403
  cur = "";
@@ -327,7 +430,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
327
430
  }
328
431
  segments.push(cur);
329
432
  for (const segment of segments) {
330
- const reason = coarseReadonlyCheck(segment, allow);
433
+ const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
331
434
  if (reason !== undefined)
332
435
  return { reason };
333
436
  }
@@ -368,21 +471,21 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
368
471
  continue;
369
472
  foldedSegments.push(segmentTokens);
370
473
  const name = toks[0];
371
- const nonOption = toks.slice(1).filter((t) => !t.startsWith("-"));
372
474
  if (!(pipeFed[si] ?? false)) {
373
475
  const floor = STDIN_FILE_FLOOR[name];
374
476
  const restArgs = toks.slice(1);
375
477
  const grepPatternSuppliedByFlag = name === "grep" && restArgs.some(isGrepPatternFlagToken);
376
478
  let sawNonFlagOperand = false;
377
479
  let hasStdinDash = false;
480
+ let operandCount = 0;
378
481
  if (name !== "tr") {
379
482
  for (let k = 0; k < restArgs.length; k++) {
380
483
  const t = restArgs[k];
381
- if (name === "cut" && (t === "-d" || t === "--delimiter")) {
484
+ if (name === "cut" && (t === "-d" || isCutSeparatedDelimiterOption(t))) {
382
485
  k++;
383
486
  continue;
384
487
  }
385
- if (name === "grep" && (t === "-f-" || isGrepFileStdinLongOption(t))) {
488
+ if (name === "grep" && (isGrepClusterFileStdin(t) || isGrepFileStdinLongOption(t))) {
386
489
  hasStdinDash = true;
387
490
  break;
388
491
  }
@@ -390,6 +493,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
390
493
  continue;
391
494
  const isGrepPatternPosition = name === "grep" && !grepPatternSuppliedByFlag && !sawNonFlagOperand;
392
495
  sawNonFlagOperand = true;
496
+ operandCount++;
393
497
  if (isGrepPatternPosition)
394
498
  continue;
395
499
  if (t === "-") {
@@ -401,7 +505,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
401
505
  if (floor !== undefined && hasStdinDash) {
402
506
  return { reason: `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
403
507
  }
404
- if (floor !== undefined && nonOption.length < floor) {
508
+ if (floor !== undefined && operandCount < floor) {
405
509
  return { reason: `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
406
510
  }
407
511
  }
@@ -409,7 +513,15 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
409
513
  return { reason: "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED };
410
514
  }
411
515
  const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
412
- const deviceArgs = toks.slice(1).filter((t) => !t.startsWith("-")).map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
516
+ const deviceCandidates = [];
517
+ for (const t of toks.slice(1)) {
518
+ if (t.startsWith("-") && t !== "-") {
519
+ deviceCandidates.push(...attachedOptionPayloads(t));
520
+ continue;
521
+ }
522
+ deviceCandidates.push(t);
523
+ }
524
+ const deviceArgs = deviceCandidates.map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
413
525
  const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
414
526
  if (!rescuedByHead && deviceArgs.length > 0) {
415
527
  return { reason: "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) " + NOT_AUTO_ALLOWED };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.18.1",
3
+ "version": "5.19.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",