@sema-agent/core 5.18.0 → 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.
@@ -1,10 +1,14 @@
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
+ export const APPROVAL_SETTLED_BY_VALUES = ["human", "timeout", "aborted"];
9
+ export function isApprovalSettledBy(v) {
10
+ return typeof v === "string" && APPROVAL_SETTLED_BY_VALUES.includes(v);
11
+ }
8
12
  export function decisionText(d) {
9
13
  return d.message;
10
14
  }
@@ -19,6 +23,7 @@ export function refuseOutOfContractDecision(d) {
19
23
  return d;
20
24
  return { action: "deny", message: RETIRED_TEXT_FIELD_DENY_MESSAGE, decisionReason: "rule" };
21
25
  }
26
+ const DEADLINE_ELAPSED = Symbol("approval.deadline_elapsed");
22
27
  function withTimeout(p, ms, onTimeout) {
23
28
  if (ms === undefined) {
24
29
  return p;
@@ -51,6 +56,18 @@ export function createAllowDenyPolicy(opts) {
51
56
  return undefined;
52
57
  const kept = [];
53
58
  for (const entry of entries) {
59
+ if (entry.startsWith("mcp__")) {
60
+ const segments = entry.slice("mcp__".length).split("__");
61
+ if (segments.some((seg) => seg.length === 0)) {
62
+ invalid.push({
63
+ entry,
64
+ list,
65
+ message: `"${entry}" is a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
66
+ `Use \`mcp__<server>\` for every tool of a server, or \`mcp__<server>__<tool>\` for one tool.`,
67
+ });
68
+ continue;
69
+ }
70
+ }
54
71
  if (parsePermissionRule(entry).ruleContent === undefined) {
55
72
  kept.push(entry);
56
73
  continue;
@@ -118,25 +135,37 @@ export function createApprovalPolicy(opts) {
118
135
  }
119
136
  if (need.has(toolName)) {
120
137
  if (signal?.aborted) {
121
- return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)` };
138
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" };
122
139
  }
123
140
  let ok;
124
141
  try {
125
- ok = await withTimeout(Promise.resolve(opts.approve(req, signal)), opts.approvalTimeoutMs, () => false);
142
+ ok = await withTimeout(Promise.resolve(opts.approve(req, signal)), opts.approvalTimeoutMs, () => DEADLINE_ELAPSED);
126
143
  }
127
144
  catch (err) {
128
145
  return {
129
146
  action: "deny",
130
147
  message: `approval errored for "${req.toolName}": ${describeThrown(err)}`,
148
+ settledBy: "aborted",
149
+ };
150
+ }
151
+ if (ok === DEADLINE_ELAPSED) {
152
+ return {
153
+ action: "deny",
154
+ message: `no one answered the approval request for "${req.toolName}" — the approval window elapsed ` +
155
+ `(${opts.approvalTimeoutMs}ms) with no answer; denied fail-closed`,
156
+ settledBy: "timeout",
131
157
  };
132
158
  }
159
+ if (signal?.aborted) {
160
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" };
161
+ }
133
162
  const okRaw = ok;
134
163
  if (okRaw === true)
135
- return ALLOW;
164
+ return { action: "allow", settledBy: "human" };
136
165
  if (okRaw !== false) {
137
- return { action: "deny", message: `approval callback for "${req.toolName}" returned an out-of-contract value (${typeof ok}) — denied fail-closed (this policy's \`approve\` returns a boolean: return true or false)` };
166
+ return { action: "deny", message: `approval callback for "${req.toolName}" returned an out-of-contract value (${typeof ok}) — denied fail-closed (this policy's \`approve\` returns a boolean: return true or false)`, settledBy: "aborted" };
138
167
  }
139
- return { action: "deny", message: `approval denied for "${req.toolName}"` };
168
+ return { action: "deny", message: `approval denied for "${req.toolName}"`, settledBy: "human" };
140
169
  }
141
170
  if (opts.denyByDefault && !auto.has(toolName)) {
142
171
  return { action: "deny", message: `tool "${req.toolName}" requires explicit allow` };
@@ -153,6 +182,7 @@ export function combinePolicies(...policies) {
153
182
  let asked;
154
183
  let current = req;
155
184
  let rewrite;
185
+ let settled;
156
186
  for (const p of policies) {
157
187
  const d = refuseOutOfContractDecision(await p.check(current, signal));
158
188
  if (d.action === "deny") {
@@ -162,6 +192,11 @@ export function combinePolicies(...policies) {
162
192
  current = { ...current, args: d.updatedInput };
163
193
  rewrite = d;
164
194
  }
195
+ if (d.action === "allow") {
196
+ const supplied = d.settledBy;
197
+ if (supplied !== undefined)
198
+ settled = supplied;
199
+ }
165
200
  if (d.action === "ask" && (asked === undefined || (d.requiresRealApproval === true && asked.requiresRealApproval !== true))) {
166
201
  asked = d;
167
202
  }
@@ -170,7 +205,8 @@ export function combinePolicies(...policies) {
170
205
  const merged = rewrite?.updatedInput;
171
206
  return merged !== undefined ? { ...asked, updatedInput: merged } : asked;
172
207
  }
173
- return rewrite ?? ALLOW;
208
+ const allowed = rewrite ?? ALLOW;
209
+ return settled !== undefined ? { ...allowed, settledBy: settled } : allowed;
174
210
  },
175
211
  };
176
212
  }
@@ -490,13 +526,16 @@ export function createTranscriptIntegrityPolicy(opts) {
490
526
  const readAllow = new Set(opts?.readAllow ?? BASH_READONLY_DEFAULT_ALLOW);
491
527
  const shellTools = canonicalToolNameSet(opts?.tools);
492
528
  const dirSegs = dirs.map((d) => pathSegments(d).map(foldPathCase));
493
- const inProtectedDir = (p) => {
494
- 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);
495
533
  const segs = pathSegments(abs).map(foldPathCase);
496
- if (isAbsolute(abs)) {
497
- return dirSegs.some((d) => d.length <= segs.length && d.every((s, i) => s === segs[i]));
498
- }
499
- 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));
500
539
  };
501
540
  const referencesProtectedDir = (cmd) => {
502
541
  const hay = foldPathCase(cmd);
@@ -535,18 +574,21 @@ export function createTranscriptIntegrityPolicy(opts) {
535
574
  if (typeof command !== "string")
536
575
  return ALLOW;
537
576
  const cmd = command.normalize("NFC");
538
- if (!referencesProtectedDir(cmd))
577
+ const cwdInsideProtected = req.cwd !== undefined && inProtectedDir(req.cwd);
578
+ if (!referencesProtectedDir(cmd) && !cwdInsideProtected)
539
579
  return ALLOW;
540
580
  const parsed = parseLeadingCommandName(command);
541
581
  if ("name" in parsed && readAllow.has(parsed.name))
542
582
  return ALLOW;
543
583
  return askReason("name" in parsed
544
- ? `\`${parsed.name}\` is not a read-only command`
545
- : "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)");
546
588
  }
547
589
  if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
548
590
  const p = writeTargetPath(toolName, req.args);
549
- if (typeof p === "string" && inProtectedDir(p)) {
591
+ if (typeof p === "string" && inProtectedDir(p, req.cwd)) {
550
592
  return askReason(`"${p}" is inside the session-transcript directory`);
551
593
  }
552
594
  }
@@ -643,7 +685,7 @@ export async function resolveAsk(req, onAsk, signal) {
643
685
  };
644
686
  }
645
687
  if (signal?.aborted) {
646
- return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, decisionReason: "mode" };
688
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, decisionReason: "mode", settledBy: "aborted" };
647
689
  }
648
690
  const presented = tryCloneArgs(req.args);
649
691
  if (!presented.ok) {
@@ -651,6 +693,7 @@ export async function resolveAsk(req, onAsk, signal) {
651
693
  action: "deny",
652
694
  message: `approval for "${req.toolName}" could not present the args safely (unclonable value: ${presented.reason}) — denied fail-closed`,
653
695
  decisionReason: "mode",
696
+ settledBy: "aborted",
654
697
  };
655
698
  }
656
699
  let ok;
@@ -661,6 +704,7 @@ export async function resolveAsk(req, onAsk, signal) {
661
704
  action: "deny",
662
705
  message: `approval for "${req.toolName}" could not present the args safely (unclonable value: ${approverView.reason}) — denied fail-closed`,
663
706
  decisionReason: "mode",
707
+ settledBy: "aborted",
664
708
  };
665
709
  }
666
710
  ok = await onAsk({ ...req, boundInputHash: boundInputHashOf(presented.value), args: approverView.value }, signal);
@@ -670,42 +714,49 @@ export async function resolveAsk(req, onAsk, signal) {
670
714
  action: "deny",
671
715
  message: `approval errored for "${req.toolName}": ${describeThrown(err)}`,
672
716
  decisionReason: "mode",
717
+ settledBy: "aborted",
673
718
  };
674
719
  }
720
+ if (signal?.aborted) {
721
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, decisionReason: "mode", settledBy: "aborted" };
722
+ }
675
723
  if (ok === "unavailable") {
676
724
  return {
677
725
  action: "deny",
678
726
  message: `approval required for "${req.toolName}" but the approver reported unavailable (no reachable ` +
679
727
  `operator for this ask) and no durable approval gate is armed — denied fail-closed: ${req.message}`,
680
728
  decisionReason: "mode",
729
+ settledBy: "aborted",
681
730
  approverUnavailable: true,
682
731
  };
683
732
  }
684
733
  if (typeof ok === "object" && ok !== null) {
685
734
  if (ok.allow !== true) {
686
- return { action: "deny", message: `approval denied for "${req.toolName}": ${req.message}`, decisionReason: "mode" };
735
+ return { action: "deny", message: `approval denied for "${req.toolName}": ${req.message}`, decisionReason: "mode", settledBy: "human" };
687
736
  }
688
737
  if (ok.updatedInput === undefined)
689
- return { action: "allow", decisionReason: "mode", presentedInput: presented.value };
738
+ return { action: "allow", decisionReason: "mode", presentedInput: presented.value, settledBy: "human" };
690
739
  const edit = tryCloneArgs(ok.updatedInput);
691
740
  if (!edit.ok) {
692
741
  return {
693
742
  action: "deny",
694
743
  message: `the approved edit for "${req.toolName}" is not safely clonable (${edit.reason}) — denied fail-closed`,
695
744
  decisionReason: "mode",
745
+ settledBy: "aborted",
696
746
  };
697
747
  }
698
- return { action: "allow", updatedInput: edit.value, decisionReason: "mode" };
748
+ return { action: "allow", updatedInput: edit.value, decisionReason: "mode", settledBy: "human" };
699
749
  }
700
750
  const okRaw = ok;
701
751
  if (okRaw === true)
702
- return { action: "allow", decisionReason: "mode", presentedInput: presented.value };
752
+ return { action: "allow", decisionReason: "mode", presentedInput: presented.value, settledBy: "human" };
703
753
  if (okRaw !== false) {
704
754
  return {
705
755
  action: "deny",
706
756
  message: `the approver for "${req.toolName}" returned an out-of-contract value (${typeof ok}) — denied fail-closed (return true, false, "unavailable", or the {allow} object)`,
707
757
  decisionReason: "mode",
758
+ settledBy: "aborted",
708
759
  };
709
760
  }
710
- return { action: "deny", message: `approval denied for "${req.toolName}": ${req.message}`, decisionReason: "mode" };
761
+ return { action: "deny", message: `approval denied for "${req.toolName}": ${req.message}`, decisionReason: "mode", settledBy: "human" };
711
762
  }
@@ -568,6 +568,7 @@ export type TaskEvent = ({
568
568
  output?: unknown;
569
569
  structured?: unknown;
570
570
  errorCode?: string;
571
+ settledBy?: import("./tool-policy.js").ApprovalSettledBy;
571
572
  truncated?: boolean;
572
573
  totalChars?: number;
573
574
  } & TaskEventIdentity) | ({
package/dist/index.d.ts CHANGED
@@ -122,14 +122,14 @@ export type { SchedulerCapability, SchedulerErrorCode, ScheduledIntent, Schedule
122
122
  export { createSchedulerTools, type SchedulerToolContext, SCHEDULE_WAKEUP_TOOL_NAME, AUTONOMOUS_LOOP_SENTINEL, AUTONOMOUS_LOOP_DYNAMIC_SENTINEL, } from "./tools/scheduler-tools.js";
123
123
  export { resolveAutonomousLoopPrompt, AUTONOMOUS_LOOP_PREAMBLE, AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT, type AutonomousLoopPromptOptions, } from "./tools/loop-tick.js";
124
124
  export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.js";
125
- export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, } from "./core/tool-policy.js";
125
+ export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, type ApprovalSettledBy, APPROVAL_SETTLED_BY_VALUES, isApprovalSettledBy, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, } from "./core/tool-policy.js";
126
126
  export { parseAutoModeResponse, createAutoModeDecider, type AutoModeVerdict, type AutoModeDecider, type AutoModeDeciderOptions, type AutoModeClassifyFn, type AutoModeClassifyInput, } from "./core/auto-mode.js";
127
127
  export { buildAutoModePrompt, renderAutoModeWindow, renderAutoModeAction, AUTO_MODE_DEFAULTS_SENTINEL, type AutoModeRules, type BuildAutoModePromptOptions, type AutoModeWindowOptions, } from "./core/auto-mode-prompt.js";
128
128
  export { AUTO_MODE_BASE_PROMPT, AUTO_MODE_PERMISSIONS_EXTERNAL } from "./core/auto-mode-prompt-assets.js";
129
129
  export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRule, wildcardMatch, type PermissionRule, type ParsedPermissionRule, type PermissionRuleIssue, type PermissionRuleCaps, type PermissionRulePolicyOptions, } from "./core/permission-rules.js";
130
130
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, type PersistedAllowRule, type RuleTombstone, type RuleScope, type RuleDot, type RuleAdd, type RuleAddOrigin, type RuleSuggestion, type RuleReject, type RuleRejectCode, type ParsedAllowRule, type PersistedRuleTool, type PersistedRuleMatch, } from "./core/permission-rule-model.js";
131
131
  export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, } from "./core/permission-rule-store.js";
132
- export { prepareCardApproval, confirmRuleApproval, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, type RuleTicket, type RuleCandidate, type RuleApprovalKind, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleConsentDeps, type RedeemResult, type CcImportLayer, type ImportedSettingsLayer, type ImportPreview, type ImportResult, } from "./core/permission-rule-consent.js";
132
+ export { prepareCardApproval, confirmRuleApproval, type ConfirmResult, type ConfirmRefusalReason, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, type RuleTicket, type RuleCandidate, type RuleApprovalKind, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleConsentDeps, type RedeemResult, type CcImportLayer, type ImportedSettingsLayer, type ImportPreview, type ImportResult, } from "./core/permission-rule-consent.js";
133
133
  export { FilePermissionRuleStoreProvider } from "./stores/file/permission-rule-store.js";
134
134
  export { formatHookFeedback, runToolGate, createHookEnvCapabilities, type Hooks, type HookToolContext, type HookEnvCapabilities, type HookToolOutput, type PreToolUseResult, type PostToolUseResult, type UserPromptSubmitResult, type HookToolFailure, type PostToolUseFailureResult, type PostToolBatchCall, type PostToolBatchResult, type PreCompactContext, type PreCompactResult, type PostCompactContext, type StopFailureContext, type PermissionDeniedPayload, type PermissionDeniedSource, } from "./core/hooks.js";
135
135
  export { InMemoryMemoryStore, composeMemoryBlock, composeLayeredMemoryBlock, normalizeMemorySpec, type NormalizedMemorySpec, type MemorySpecInput, supportsConsolidation, supportsPeriodicConsolidation, firstSentence, expandLexicalTerms, lexicalSearchMatch, type Embedder, classifyPromotable, enforcePromotableWriteGate, guardedMemoryStore, MemoryGateError, detectSecret, enforceSecretWriteGate, enforceStructuredNoteSecretGate, type UtilityGate, type MemoryStore, type MemoryVectorMode, type ScoredMemory, type MemoryNoteHeader, type MemoryNoteRecord, type MemoryNoteType, type StructuredNoteInput, } from "./core/memory.js";
package/dist/index.js CHANGED
@@ -107,7 +107,7 @@ export { hasScheduler, isValidCronExpr, SchedulerError } from "./core/scheduler.
107
107
  export { createSchedulerTools, SCHEDULE_WAKEUP_TOOL_NAME, AUTONOMOUS_LOOP_SENTINEL, AUTONOMOUS_LOOP_DYNAMIC_SENTINEL, } from "./tools/scheduler-tools.js";
108
108
  export { resolveAutonomousLoopPrompt, AUTONOMOUS_LOOP_PREAMBLE, AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT, } from "./tools/loop-tick.js";
109
109
  export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.js";
110
- export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, } from "./core/tool-policy.js";
110
+ export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, decisionText, resolveAsk, toolPolicyNameSets, APPROVAL_SETTLED_BY_VALUES, isApprovalSettledBy, } from "./core/tool-policy.js";
111
111
  export { parseAutoModeResponse, createAutoModeDecider, } from "./core/auto-mode.js";
112
112
  export { buildAutoModePrompt, renderAutoModeWindow, renderAutoModeAction, AUTO_MODE_DEFAULTS_SENTINEL, } from "./core/auto-mode-prompt.js";
113
113
  export { AUTO_MODE_BASE_PROMPT, AUTO_MODE_PERMISSIONS_EXTERNAL } from "./core/auto-mode-prompt-assets.js";
@@ -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.0",
3
+ "version": "5.19.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",