@sema-agent/core 5.18.1 → 5.20.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/agents/roster-store.js +3 -0
  3. package/dist/brain/circuit-breaker.js +14 -3
  4. package/dist/brain/timeout.d.ts +1 -0
  5. package/dist/brain/timeout.js +11 -0
  6. package/dist/core/background-agent-store.d.ts +1 -0
  7. package/dist/core/background-agent-store.js +5 -0
  8. package/dist/core/fs-write-gate-policy.js +1 -1
  9. package/dist/core/hooks.d.ts +3 -1
  10. package/dist/core/hooks.js +32 -0
  11. package/dist/core/mailbox-store.js +2 -0
  12. package/dist/core/mcp.d.ts +4 -0
  13. package/dist/core/mcp.js +58 -11
  14. package/dist/core/retention-policy.d.ts +7 -0
  15. package/dist/core/retention-policy.js +21 -0
  16. package/dist/core/runner/active-skill-scope.js +1 -1
  17. package/dist/core/runner/prepare-task.d.ts +3 -0
  18. package/dist/core/runner/prepare-task.js +195 -121
  19. package/dist/core/runner/runtask.js +29 -4
  20. package/dist/core/runner/session-rule-policy.js +1 -1
  21. package/dist/core/sensitive-path-policy.js +1 -1
  22. package/dist/core/task-registry-agent.js +2 -0
  23. package/dist/core/tool-policy.d.ts +1 -0
  24. package/dist/core/tool-policy.js +21 -12
  25. package/dist/core/workflow-run-store.js +2 -0
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/orchestration/run-spec.js +5 -1
  29. package/dist/orchestration/workflow.js +13 -2
  30. package/dist/stores/file/background-agent-store.js +2 -1
  31. package/dist/stores/file/mailbox-store.js +2 -0
  32. package/dist/stores/file/workflow-run-store.js +2 -0
  33. package/dist/tools/fs/bash-readonly-classifier.d.ts +5 -2
  34. package/dist/tools/fs/bash-readonly-classifier.js +129 -17
  35. package/dist/tools/web.js +32 -5
  36. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -116,7 +116,7 @@ export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmitti
116
116
  export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, } from "./core/permission-rule-store.js";
117
117
  export { prepareCardApproval, confirmRuleApproval, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, } from "./core/permission-rule-consent.js";
118
118
  export { FilePermissionRuleStoreProvider } from "./stores/file/permission-rule-store.js";
119
- export { formatHookFeedback, runToolGate, createHookEnvCapabilities, } from "./core/hooks.js";
119
+ export { formatHookFeedback, runToolGate, createHookEnvCapabilities, createPreToolUseConstraintPolicy, } from "./core/hooks.js";
120
120
  export { InMemoryMemoryStore, composeMemoryBlock, composeLayeredMemoryBlock, normalizeMemorySpec, supportsConsolidation, supportsPeriodicConsolidation, firstSentence, expandLexicalTerms, lexicalSearchMatch, classifyPromotable, enforcePromotableWriteGate, guardedMemoryStore, MemoryGateError, detectSecret, enforceSecretWriteGate, enforceStructuredNoteSecretGate, } from "./core/memory.js";
121
121
  export { MemoryEngine, FileMemoryEngineBackend, memoryBackendContract, assertMemoryBackendSearchEquivalence, buildMemoryInstruction, truncateIndex, scanEntryFiles, deriveRepoKey, deriveRepoMemoryDir, deriveControlPlaneDir, deriveRepoControlPlaneDir, resolveMemoryEngineRoot, scopeDirName, ControlPlaneCorruptError, parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_INDEX_FILENAME, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_MAX_ENTRY_DEPTH, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE, renderAnnouncements, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, SCOPE_SEGMENT_MAX_ENCODED, PROJECT_MARKER_PATH, encodeScopeSegment, decodeScopeSegment, parseScopeKey, formatUserScope, formatOrgScope, formatProjScope, formatUserProjScope, isPersonalScope, assertScopeContractPlacement, formatProjectMarker, parseProjectMarker, resolveProjectId, PROJECT_ID_REGEX, migrateScope, materializeEntriesToFiles, harvestFilesToPatches, projectionsToWriteBack, screenInboundEntries, REMOTE_HARVEST_PER_FILE_BYTES, REMOTE_MASS_DELETION_FUSE_RATIO, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, reconcileMemoryEntries, nextSyncBaseline, mergeRecallHits, syncMemoryScope, } from "./core/memory-engine/index.js";
122
122
  export { SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_LIST_PAGE_SIZE, SharedMemoryStoreError, } from "./core/shared-memory/types.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
  }
@@ -102,6 +102,9 @@ export async function runSpec(runner, contract, opts) {
102
102
  const userHooks = opts.taskSpec.hooks && deployBaseline?.hooks
103
103
  ? {
104
104
  preToolUse: (opts.taskSpec.hooks.preToolUse ?? deployBaseline.hooks.preToolUse)?.bind(opts.taskSpec.hooks.preToolUse ? opts.taskSpec.hooks : deployBaseline.hooks),
105
+ ...((opts.taskSpec.hooks.preToolUse ? opts.taskSpec.hooks : deployBaseline.hooks).preToolUseObservational === true
106
+ ? { preToolUseObservational: true }
107
+ : {}),
105
108
  postToolUse: (opts.taskSpec.hooks.postToolUse ?? deployBaseline.hooks.postToolUse)?.bind(opts.taskSpec.hooks.postToolUse ? opts.taskSpec.hooks : deployBaseline.hooks),
106
109
  userPromptSubmit: (opts.taskSpec.hooks.userPromptSubmit ?? deployBaseline.hooks.userPromptSubmit)?.bind(opts.taskSpec.hooks.userPromptSubmit ? opts.taskSpec.hooks : deployBaseline.hooks),
107
110
  stop: (opts.taskSpec.hooks.stop ?? deployBaseline.hooks.stop)?.bind(opts.taskSpec.hooks.stop ? opts.taskSpec.hooks : deployBaseline.hooks),
@@ -118,6 +121,7 @@ export async function runSpec(runner, contract, opts) {
118
121
  let gateAbandoned = false;
119
122
  const hooks = {
120
123
  ...(userHooks?.preToolUse && { preToolUse: userHooks.preToolUse.bind(userHooks) }),
124
+ ...(userHooks?.preToolUse && userHooks.preToolUseObservational === true ? { preToolUseObservational: true } : {}),
121
125
  ...(userHooks?.postToolUse && { postToolUse: userHooks.postToolUse.bind(userHooks) }),
122
126
  ...(userHooks?.userPromptSubmit && { userPromptSubmit: userHooks.userPromptSubmit.bind(userHooks) }),
123
127
  ...(userHooks?.postToolUseFailure && { postToolUseFailure: userHooks.postToolUseFailure.bind(userHooks) }),
@@ -245,6 +245,7 @@ function normalizeConcurrency(c) {
245
245
  }
246
246
  return Math.floor(c);
247
247
  }
248
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
248
249
  function normalizeWorkflowHardCap(name, v) {
249
250
  if (v === undefined)
250
251
  return undefined;
@@ -253,12 +254,22 @@ function normalizeWorkflowHardCap(name, v) {
253
254
  }
254
255
  return v;
255
256
  }
257
+ function normalizeWorkflowTimerCap(name, v) {
258
+ const normalized = normalizeWorkflowHardCap(name, v);
259
+ if (normalized !== undefined && normalized > MAX_TIMER_DELAY_MS) {
260
+ throw new Error(`runWorkflow: ${name} must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) (got ${v}) — a host timer truncates a larger delay and fires immediately, cancelling the run at once. Omit it for no deadline.`);
261
+ }
262
+ return normalized;
263
+ }
256
264
  function normalizeWorkflowStallMs(v) {
257
265
  if (v === undefined)
258
266
  return WORKFLOW_AGENT_STALL_MS;
259
267
  if (!Number.isFinite(v)) {
260
268
  throw new Error(`runWorkflow: stallMs must be a finite number of milliseconds (got ${v}); 0 or a negative value is the documented explicit "off", not NaN/Infinity`);
261
269
  }
270
+ if (v > MAX_TIMER_DELAY_MS) {
271
+ throw new Error(`runWorkflow: stallMs must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) (got ${v}) — a host timer truncates a larger delay and fires immediately, so a watchdog stretched that far would trip on every agent instead of tolerating a long one. Pass 0 to turn it off.`);
272
+ }
262
273
  return v;
263
274
  }
264
275
  function createSemaphore(max) {
@@ -346,10 +357,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
346
357
  const maxAgents = normalizeWorkflowHardCap("maxAgents", opts.maxAgents);
347
358
  const maxLogChars = normalizeWorkflowHardCap("maxLogChars", opts.maxLogChars);
348
359
  const maxResultChars = normalizeWorkflowHardCap("maxResultChars", opts.maxResultChars);
349
- const totalTimeoutMs = normalizeWorkflowHardCap("totalTimeoutMs", opts.totalTimeoutMs);
360
+ const totalTimeoutMs = normalizeWorkflowTimerCap("totalTimeoutMs", opts.totalTimeoutMs);
350
361
  const stallMs = normalizeWorkflowStallMs(opts.stallMs);
351
362
  const agentMaxRetries = normalizeWorkflowHardCap("agentMaxRetries", opts.agentMaxRetries) ?? WORKFLOW_AGENT_MAX_RETRIES;
352
- const throttleBackoffMs = normalizeWorkflowHardCap("throttleBackoffMs", opts.throttleBackoffMs) ?? WORKFLOW_AGENT_THROTTLE_BACKOFF_MS;
363
+ const throttleBackoffMs = normalizeWorkflowTimerCap("throttleBackoffMs", opts.throttleBackoffMs) ?? WORKFLOW_AGENT_THROTTLE_BACKOFF_MS;
353
364
  const timers = opts.timers ?? REAL_WORKFLOW_TIMERS;
354
365
  const cancelController = new AbortController();
355
366
  const timeoutController = totalTimeoutMs !== undefined ? new AbortController() : undefined;
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, queryBackgroundAgents, } from "../../core/background-agent-store.js";
2
+ import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, assertBackgroundAgentReapOptions, queryBackgroundAgents, } from "../../core/background-agent-store.js";
3
3
  import { SharedLedgerTable } from "./shared-ledger.js";
4
4
  const agentLedgers = new SharedLedgerTable({
5
5
  keyOf: (r) => FileBackgroundAgentStore.key(r.handle, r.scope),
@@ -113,6 +113,7 @@ export class FileBackgroundAgentStore {
113
113
  });
114
114
  }
115
115
  async reap(scope, now, opts) {
116
+ assertBackgroundAgentReapOptions(opts);
116
117
  if (!opts || (opts.maxAgeMs === undefined && opts.keep === undefined && opts.staleRunningMaxAgeMs === undefined))
117
118
  return 0;
118
119
  const changed = new Set();
@@ -1,4 +1,5 @@
1
1
  import { join, resolve, sep } from "node:path";
2
+ import { assertRetentionPolicy } from "../../core/retention-policy.js";
2
3
  import { existsSync, readdirSync, realpathSync } from "node:fs";
3
4
  import { newestSentAt, } from "../../core/mailbox-store.js";
4
5
  import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
@@ -176,6 +177,7 @@ export class FileMailboxStore {
176
177
  });
177
178
  }
178
179
  async reap(scope, now, opts) {
180
+ assertRetentionPolicy("MailboxStore.reap", opts);
179
181
  if (opts?.maxAgeMs === undefined)
180
182
  return 0;
181
183
  let dropped = 0;
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { assertRetentionPolicy } from "../../core/retention-policy.js";
2
3
  import { WorkflowRunStoreError, isTerminalWorkflowStatus, nextWorkflowRunOnUpdate, queryWorkflowRuns, } from "../../core/workflow-run-store.js";
3
4
  import { SharedLedgerTable } from "./shared-ledger.js";
4
5
  const runLedgers = new SharedLedgerTable({
@@ -64,6 +65,7 @@ export class FileWorkflowRunStore {
64
65
  return queryWorkflowRuns(this.runs.values(), scope, opts);
65
66
  }
66
67
  async reap(scope, now, opts) {
68
+ assertRetentionPolicy("WorkflowRunStore.reap", opts);
67
69
  if (opts?.maxAgeMs === undefined && opts?.keep === undefined)
68
70
  return 0;
69
71
  const terminal = [...this.runs.values()].filter((r) => r.scope === scope && isTerminalWorkflowStatus(r.status));
@@ -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/dist/tools/web.js CHANGED
@@ -25,6 +25,31 @@ function resolveWebMaxBytes(value) {
25
25
  }
26
26
  return value;
27
27
  }
28
+ function resolveWebTimeoutMs(value, knob, fallback) {
29
+ if (value === undefined)
30
+ return fallback;
31
+ if (!Number.isFinite(value) || value <= 0) {
32
+ const e = new Error(`${knob} must be a positive finite number of milliseconds (got ${String(value)}) — a non-finite or non-positive budget is not "no limit", it aborts every request within a millisecond`);
33
+ e.code = "config.web_timeout_invalid";
34
+ throw e;
35
+ }
36
+ if (value > 2_147_483_647) {
37
+ const e = new Error(`${knob} must not exceed 2147483647ms (~24.8 days) (got ${String(value)}) — a host timer truncates a larger delay and fires immediately, so widening the budget past this point aborts every request at once`);
38
+ e.code = "config.web_timeout_invalid";
39
+ throw e;
40
+ }
41
+ return value;
42
+ }
43
+ function resolveSearchMaxResults(value) {
44
+ if (value === undefined)
45
+ return 10;
46
+ if (!Number.isInteger(value) || value < 1) {
47
+ const e = new Error(`WebSearch maxResults must be a whole number of 1 or more (got ${String(value)}) — a zero, negative or unevaluable cap returns an empty result set from a backend that answered`);
48
+ e.code = "config.web_search_max_results_invalid";
49
+ throw e;
50
+ }
51
+ return value;
52
+ }
28
53
  export const WEBFETCH_GROUNDING_MIN_TEXT_CHARS = 200;
29
54
  const GROUNDING_MIN_TEXT_RATIO = 0.01;
30
55
  const GROUNDING_RATIO_MAX_TEXT_CHARS = 2_000;
@@ -230,6 +255,7 @@ async function fetchAllowlisted(doFetch, start, allowHosts, userAgent, signal, o
230
255
  }
231
256
  export function webFetchToolSpec(config = {}) {
232
257
  const maxBytes = resolveWebMaxBytes(config.maxBytes);
258
+ const fetchTimeoutMs = resolveWebTimeoutMs(config.timeoutMs, "WebFetch timeoutMs", DEFAULT_FETCH_TIMEOUT_MS);
233
259
  const doFetch = config.fetchImpl ?? globalThis.fetch;
234
260
  const canSummarize = config.summarize !== undefined;
235
261
  return {
@@ -276,7 +302,7 @@ export function webFetchToolSpec(config = {}) {
276
302
  }
277
303
  if (!doFetch)
278
304
  return failure("Error (WebFetch): no fetch implementation available in this environment.");
279
- const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
305
+ const timeoutMs = fetchTimeoutMs;
280
306
  const ac = new AbortController();
281
307
  const timer = setTimeout(() => ac.abort(), timeoutMs);
282
308
  const onOuterAbort = () => ac.abort();
@@ -794,7 +820,8 @@ function webSearchResultAllowed(url, allowed, blocked) {
794
820
  return true;
795
821
  }
796
822
  export function createWebSearchTool(config) {
797
- const max = config.maxResults ?? 10;
823
+ const max = resolveSearchMaxResults(config.maxResults);
824
+ const searchTimeoutMs = resolveWebTimeoutMs(config.timeoutMs, "WebSearch timeoutMs", DEFAULT_SEARCH_TIMEOUT_MS);
798
825
  const currentMonthYear = new Date().toLocaleString("en-US", { month: "long", year: "numeric" });
799
826
  return {
800
827
  name: "WebSearch",
@@ -831,7 +858,7 @@ export function createWebSearchTool(config) {
831
858
  if (queryChars > SEARCH_QUERY_MAX_CHARS) {
832
859
  return errorResult(`Error (WebSearch): the query is ${queryChars} characters, over the ${SEARCH_QUERY_MAX_CHARS}-character limit. Shorten it to the terms that matter — an identical retry will fail the same way.`, failCard({ retryable: false }));
833
860
  }
834
- const timeoutMs = config.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS;
861
+ const timeoutMs = searchTimeoutMs;
835
862
  const ac = new AbortController();
836
863
  const timer = setTimeout(() => ac.abort(), timeoutMs);
837
864
  const onOuterAbort = () => ac.abort();
@@ -936,7 +963,7 @@ export function createWebSearchTool(config) {
936
963
  }
937
964
  export function createSearxngSearchBackend(baseUrl, options = {}) {
938
965
  const doFetch = options.fetchImpl ?? globalThis.fetch;
939
- const timeoutMs = options.timeoutMs ?? 10_000;
966
+ const timeoutMs = resolveWebTimeoutMs(options.timeoutMs, "createSearxngSearchBackend timeoutMs", 10_000);
940
967
  const parsedBase = new URL(baseUrl);
941
968
  parsedBase.hash = "";
942
969
  parsedBase.pathname = `${parsedBase.pathname.replace(/\/+$/, "")}/search`;
@@ -966,9 +993,9 @@ export function createSearxngSearchBackend(baseUrl, options = {}) {
966
993
  };
967
994
  }
968
995
  export async function probeSearchBackend(search, options) {
969
- const budget = options?.timeoutMs ?? 15_000;
970
996
  let timer;
971
997
  try {
998
+ const budget = resolveWebTimeoutMs(options?.timeoutMs, "probeSearchBackend timeoutMs", 15_000);
972
999
  const results = await Promise.race([
973
1000
  search("connectivity probe", AbortSignal.timeout(budget)),
974
1001
  new Promise((_, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.18.1",
3
+ "version": "5.20.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",