@sema-agent/core 7.28.1 → 7.30.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 (162) hide show
  1. package/CHANGELOG.md +473 -1
  2. package/dist/agents/child-model-seat.d.ts +59 -7
  3. package/dist/agents/child-model-seat.js +33 -7
  4. package/dist/agents/cross-session-judge.d.ts +27 -5
  5. package/dist/agents/cross-session-judge.js +23 -12
  6. package/dist/agents/list-agents-tool.d.ts +31 -6
  7. package/dist/agents/list-agents-tool.js +66 -14
  8. package/dist/agents/peer-admission.d.ts +20 -8
  9. package/dist/agents/peer-admission.js +21 -12
  10. package/dist/agents/peer-directory.d.ts +29 -0
  11. package/dist/agents/peer-directory.js +20 -0
  12. package/dist/agents/peer-held-queue.d.ts +31 -4
  13. package/dist/agents/peer-held-queue.js +32 -1
  14. package/dist/agents/peer-session-drain.d.ts +91 -13
  15. package/dist/agents/peer-session-drain.js +409 -102
  16. package/dist/agents/read-inbox-tool.d.ts +22 -0
  17. package/dist/agents/read-inbox-tool.js +39 -0
  18. package/dist/agents/roster-store.d.ts +2 -4
  19. package/dist/agents/roster-store.js +1 -1
  20. package/dist/agents/send-message-tool.d.ts +4 -4
  21. package/dist/agents/send-message-tool.js +22 -2
  22. package/dist/agents/subagent.js +62 -44
  23. package/dist/agents/team-role.d.ts +34 -0
  24. package/dist/agents/team-role.js +17 -0
  25. package/dist/agents/verify.js +2 -2
  26. package/dist/brain/anthropic.js +4 -2
  27. package/dist/brain/open-responses.js +4 -3
  28. package/dist/brain/openai.js +2 -1
  29. package/dist/brain/stream-engine.js +6 -0
  30. package/dist/brain/stream-shared.d.ts +8 -0
  31. package/dist/brain/stream-shared.js +8 -0
  32. package/dist/core/auto-compaction.d.ts +42 -0
  33. package/dist/core/auto-compaction.js +35 -2
  34. package/dist/core/child-clamp-seats.d.ts +11 -0
  35. package/dist/core/child-clamp-seats.js +3 -0
  36. package/dist/core/context-edit.d.ts +3 -2
  37. package/dist/core/context-edit.js +6 -5
  38. package/dist/core/context-guard.d.ts +2 -1
  39. package/dist/core/context-guard.js +3 -2
  40. package/dist/core/context-settings.d.ts +103 -0
  41. package/dist/core/context-settings.js +60 -0
  42. package/dist/core/engine-notice.d.ts +18 -1
  43. package/dist/core/frame-settlement.d.ts +97 -0
  44. package/dist/core/frame-settlement.js +36 -0
  45. package/dist/core/governance-codes.d.ts +1 -1
  46. package/dist/core/governance-codes.js +4 -0
  47. package/dist/core/hooks.d.ts +10 -10
  48. package/dist/core/mailbox-store.d.ts +41 -7
  49. package/dist/core/mailbox-store.js +9 -3
  50. package/dist/core/message-utils.d.ts +13 -0
  51. package/dist/core/message-utils.js +6 -0
  52. package/dist/core/parent-spec-seat.d.ts +14 -1
  53. package/dist/core/parent-spec-seat.js +6 -0
  54. package/dist/core/permission-rule-model.js +1 -1
  55. package/dist/core/roles.d.ts +20 -0
  56. package/dist/core/roles.js +35 -16
  57. package/dist/core/runner/assemble-result.d.ts +2 -0
  58. package/dist/core/runner/assemble-result.js +3 -1
  59. package/dist/core/runner/compaction-call-options.d.ts +26 -2
  60. package/dist/core/runner/compaction-call-options.js +9 -0
  61. package/dist/core/runner/compaction-seams.d.ts +4 -3
  62. package/dist/core/runner/compaction-seams.js +13 -6
  63. package/dist/core/runner/contracts.d.ts +61 -7
  64. package/dist/core/runner/decide-continuation.d.ts +2 -1
  65. package/dist/core/runner/decide-continuation.js +11 -10
  66. package/dist/core/runner/initial-run-state.js +2 -2
  67. package/dist/core/runner/prepare-caps-and-workflow.js +9 -1
  68. package/dist/core/runner/prepare-config-doors.js +4 -1
  69. package/dist/core/runner/prepare-context-lane.d.ts +9 -2
  70. package/dist/core/runner/prepare-context-lane.js +15 -10
  71. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -2
  72. package/dist/core/runner/prepare-delegation-surface.js +9 -3
  73. package/dist/core/runner/prepare-file-history.js +10 -8
  74. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  75. package/dist/core/runner/prepare-gate-stations.js +3 -1
  76. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  77. package/dist/core/runner/prepare-prompt-inputs.d.ts +6 -0
  78. package/dist/core/runner/prepare-prompt-inputs.js +2 -1
  79. package/dist/core/runner/prepare-task.js +22 -9
  80. package/dist/core/runner/prepare-wiring-manifest.d.ts +6 -1
  81. package/dist/core/runner/prepare-wiring-manifest.js +6 -1
  82. package/dist/core/runner/response-model-seat.d.ts +16 -0
  83. package/dist/core/runner/response-model-seat.js +27 -0
  84. package/dist/core/runner/retired-runner-deps.d.ts +3 -1
  85. package/dist/core/runner/retired-runner-deps.js +1 -0
  86. package/dist/core/runner/run-compaction-machinery.d.ts +4 -2
  87. package/dist/core/runner/run-compaction-machinery.js +5 -3
  88. package/dist/core/runner/run-harness-handlers.js +9 -2
  89. package/dist/core/runner/run-identity-wiring.js +19 -1
  90. package/dist/core/runner/run-leg.js +1 -1
  91. package/dist/core/runner/run-notification-lane.js +8 -2
  92. package/dist/core/runner/run-recovery-lanes.js +9 -5
  93. package/dist/core/runner/run-settle-and-teardown.d.ts +4 -22
  94. package/dist/core/runner/run-settle-and-teardown.js +6 -10
  95. package/dist/core/runner/run-stop-and-final-verify.js +1 -0
  96. package/dist/core/runner/run-terminal-adoption.js +5 -5
  97. package/dist/core/runner/run-turn-boundary.js +32 -5
  98. package/dist/core/runner/runtask.d.ts +8 -1
  99. package/dist/core/runner/runtask.js +33 -2
  100. package/dist/core/runner-deps.d.ts +44 -13
  101. package/dist/core/side-query.d.ts +6 -5
  102. package/dist/core/side-query.js +3 -1
  103. package/dist/core/swappable-deps.d.ts +20 -4
  104. package/dist/core/swappable-deps.js +6 -1
  105. package/dist/core/task-event.d.ts +13 -0
  106. package/dist/core/task-notification.d.ts +144 -2
  107. package/dist/core/task-notification.js +154 -5
  108. package/dist/core/task-result.d.ts +27 -3
  109. package/dist/core/task-spec.d.ts +7 -0
  110. package/dist/core/tool-catalog-entries.js +2 -1
  111. package/dist/core/tool-conformance.js +1 -0
  112. package/dist/core/tool-policy.d.ts +9 -4
  113. package/dist/core/tool-spec.d.ts +14 -0
  114. package/dist/core/wiring-manifest.d.ts +12 -0
  115. package/dist/core/wiring-manifest.js +5 -1
  116. package/dist/engine/harness/agent-harness.d.ts +14 -1
  117. package/dist/engine/harness/agent-harness.js +28 -3
  118. package/dist/engine/harness/types.d.ts +3 -1
  119. package/dist/engine/llm/types.d.ts +8 -7
  120. package/dist/engine/llm/types.js +1 -1
  121. package/dist/engine/session/import-validate.js +4 -0
  122. package/dist/index.d.ts +10 -7
  123. package/dist/index.js +7 -5
  124. package/dist/orchestration/run-workflow-tool.d.ts +4 -4
  125. package/dist/orchestration/workflow.d.ts +1 -1
  126. package/dist/orchestration/workflow.js +8 -5
  127. package/dist/shell/lex.d.ts +30 -51
  128. package/dist/shell/lex.js +237 -284
  129. package/dist/stores/cc/mailbox-store.js +3 -2
  130. package/dist/stores/file/corrupt-read-sink.d.ts +28 -2
  131. package/dist/stores/file/file-history-store.d.ts +2 -4
  132. package/dist/stores/file/file-history-store.js +1 -1
  133. package/dist/stores/file/fs-atomic.d.ts +16 -11
  134. package/dist/stores/file/fs-atomic.js +17 -6
  135. package/dist/stores/file/index.d.ts +5 -6
  136. package/dist/stores/file/index.js +1 -1
  137. package/dist/stores/file/mailbox-store.d.ts +4 -9
  138. package/dist/stores/file/mailbox-store.js +29 -30
  139. package/dist/stores/file/session-policy-store.d.ts +2 -0
  140. package/dist/stores/file/session-policy-store.js +1 -1
  141. package/dist/stores/file/session-store.d.ts +11 -7
  142. package/dist/stores/file/session-store.js +23 -16
  143. package/dist/stores/file/shared-ledger.js +3 -14
  144. package/dist/stores/file/tool-result-store.d.ts +2 -0
  145. package/dist/stores/file/tool-result-store.js +5 -2
  146. package/dist/stores/file/workflow-journal-store.js +14 -9
  147. package/dist/tools/fs/bash-program-position.d.ts +15 -24
  148. package/dist/tools/fs/bash-program-position.js +11 -29
  149. package/dist/tools/fs/bash-readonly-classifier.d.ts +125 -117
  150. package/dist/tools/fs/bash-readonly-classifier.js +400 -272
  151. package/dist/tools/fs/shell-read-check.js +3 -3
  152. package/dist/tools/fs/shell-read-establish.js +1 -1
  153. package/dist/tools/fs/shell-read-plan.d.ts +11 -6
  154. package/dist/tools/fs/shell-read-plan.js +167 -183
  155. package/dist/tools/fs/shell-read-word.d.ts +50 -0
  156. package/dist/tools/fs/shell-read-word.js +59 -0
  157. package/dist/tools/fs/shell-reentry-text.d.ts +10 -0
  158. package/dist/tools/fs/shell-reentry-text.js +73 -0
  159. package/package.json +1 -1
  160. package/test/export-surface.snapshot.json +58 -8
  161. package/wire-changes/core@7.29.0.json +185 -0
  162. package/wire-changes/core@7.30.0.json +1821 -0
@@ -1,6 +1,6 @@
1
1
  import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf } from "./safety.js";
2
- import { lexShellText, literalTildeSpelling, ansiCQuoteClose, dollarOpensQuote, quoteMask, wordsOf, } from "../../shell/lex.js";
3
- import { COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, ASSIGNMENT_PREFIX, readProgramPosition, writtenAssignmentPrefix, } from "./bash-program-position.js";
2
+ import { PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, argvOf, lexShell, literalTildeSpelling, } from "../../shell/lex.js";
3
+ import { COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
4
4
  export const BASH_READONLY_DEFAULT_ALLOW = [
5
5
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
6
6
  "grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
@@ -15,20 +15,33 @@ const SHELL_OPERATORS = /[;&|<>$()`\n\r\\]/;
15
15
  function hasBareShellOperator(s, quotedOperatorsAreText) {
16
16
  if (!quotedOperatorsAreText)
17
17
  return SHELL_OPERATORS.test(s);
18
- const mask = quoteMask(s);
19
- if (!mask.balanced)
18
+ const lexed = lexShell(s);
19
+ if ("parseFailure" in lexed)
20
20
  return SHELL_OPERATORS.test(s);
21
- for (let i = 0; i < s.length; i++) {
22
- const ch = s[i];
23
- if (!SHELL_OPERATORS.test(ch))
24
- continue;
25
- const literal = SHELL_EXPANDS_IN_DOUBLE_QUOTES.test(ch) ? mask.openedBy[i] === "'" : mask.quoted[i];
26
- if (!literal)
21
+ if (lexed.segments.length > 1)
22
+ return true;
23
+ for (const t of lexed.segments[0].tokens) {
24
+ if (t.kind !== "word")
25
+ return true;
26
+ if (expandsDollar(t) || t.assignment?.form === "compound")
27
+ return true;
28
+ if (t.pieces.some((p) => p.quotedBy === "\\" || (p.quotedBy === undefined && p.literal !== undefined && /[\r\\]/.test(p.literal))))
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+ function expandsDollar(w) {
34
+ if (w.quotedSpans.some((q) => q.by === '$"'))
35
+ return true;
36
+ for (const p of w.pieces) {
37
+ const k = p.expansion?.kind;
38
+ if (k === "parameter" || k === "command" || k === "process" || k === "arithmetic")
39
+ return true;
40
+ if (p.literal !== undefined && (p.quotedBy === undefined || p.quotedBy === '"') && /[$`]/.test(p.literal))
27
41
  return true;
28
42
  }
29
43
  return false;
30
44
  }
31
- const SHELL_EXPANDS_IN_DOUBLE_QUOTES = /[$`]/;
32
45
  export function parseLeadingCommandName(command, options) {
33
46
  const trimmed = command.trim();
34
47
  if (!trimmed)
@@ -38,12 +51,14 @@ export function parseLeadingCommandName(command, options) {
38
51
  reject: "shell operators (pipes, redirects, ;, &&, command substitution, subshells, variable expansion) are not allowed — run a single simple command",
39
52
  };
40
53
  }
41
- const first = trimmed.split(/\s+/)[0];
54
+ const first = trimmed.split(/[ \t]+/)[0];
42
55
  if (first.includes("/") && options?.pathPrefixedNameIsText !== true) {
43
56
  return { reject: "the command must be a bare name resolved via PATH (no path prefix)" };
44
57
  }
45
58
  if (first.includes("="))
46
59
  return { reject: "leading environment-variable assignments are not allowed" };
60
+ if (first.startsWith("#"))
61
+ return { reject: "empty command" };
47
62
  if (/["'{}*?[\]~]/.test(first))
48
63
  return { reject: "the command name must be a bare token (no quotes, braces, globs, or ~)" };
49
64
  return { name: first };
@@ -62,96 +77,81 @@ export function carriesShellRedirection(text) {
62
77
  return SHELL_REDIRECTION_OPERATORS.test(text);
63
78
  }
64
79
  const SHELL_REDIRECTION_OPERATORS = /[<>]/;
65
- const SHELL_CONNECTOR_CHARS = /[|&;]/;
66
- function redirectionPrecedesConnector(source) {
67
- const mask = quoteMask(source);
68
- const quoted = (i) => mask.balanced && mask.quoted[i] === true;
69
- const isSpace = (c) => c === " " || c === "\t";
70
- for (let i = 0; i < source.length; i++) {
71
- if (quoted(i) || !SHELL_REDIRECTION_OPERATORS.test(source[i]))
72
- continue;
73
- let j = i + 1;
74
- while (j < source.length && !quoted(j) && (SHELL_REDIRECTION_OPERATORS.test(source[j]) || isSpace(source[j])))
75
- j++;
76
- if (j < source.length && !quoted(j) && SHELL_CONNECTOR_CHARS.test(source[j]))
77
- return true;
78
- i = j - 1;
79
- }
80
- return false;
81
- }
82
- function rejectedSansRedirection(source) {
83
- if (redirectionPrecedesConnector(source)) {
84
- return "a redirection followed by `|`, `&` or `;` is not allowed — the operator's own characters cannot be told apart from a command boundary";
85
- }
86
- return hardRejectReason(source, true);
80
+ function isDataFreeRedirection(r) {
81
+ if (r.op === ">" && r.fd === 2 && r.target !== undefined && r.target.raw === "/dev/null")
82
+ return true;
83
+ return r.op === ">&" && r.dup !== undefined && /^[12]$/.test(r.dup) && (r.fd === undefined || r.fd === 1 || r.fd === 2);
87
84
  }
88
- function hardRejectReason(source, tolerateRedirection) {
89
- const reason = tolerateRedirection
85
+ const BACKGROUND_REFUSAL = "`&` backgrounding is not allowed — a backgrounded process outlives the command";
86
+ const PIPE_BOTH_REFUSAL = "`|&` pipes standard error as well as standard output — not a connector this face reads";
87
+ export function strictResidualRefusal(lexed, source, opts = {}) {
88
+ const tolerate = opts.tolerateRedirection === true;
89
+ const reason = tolerate
90
90
  ? "command/variable substitution, subshells, escapes, and line breaks are not allowed"
91
91
  : "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed";
92
- for (const ch of source) {
93
- if (SHELL_SEGMENT_QUOTE_BLIND_REJECT.test(ch) && !(tolerateRedirection && SHELL_REDIRECTION_OPERATORS.test(ch)))
92
+ const inClass = (text) => {
93
+ for (const ch of text)
94
+ if (SHELL_SEGMENT_QUOTE_BLIND_REJECT.test(ch) && !(tolerate && SHELL_REDIRECTION_OPERATORS.test(ch)))
95
+ return true;
96
+ return false;
97
+ };
98
+ if ("parseFailure" in lexed)
99
+ return inClass(source) || SHELL_EXPANSION_CHARS.test(source) ? reason : PARSE_FAILURE_SENTENCE[lexed.parseFailure.reason];
100
+ const words = [];
101
+ for (const s of lexed.segments) {
102
+ if (s.connector === "\n")
94
103
  return reason;
104
+ for (const t of s.tokens) {
105
+ if (t.kind === "word") {
106
+ if (inClass(t.raw))
107
+ return reason;
108
+ words.push(t);
109
+ continue;
110
+ }
111
+ if (t.kind !== "redirect")
112
+ return reason;
113
+ if (opts.dataFreeRedirections === true && isDataFreeRedirection(t))
114
+ continue;
115
+ if (!tolerate)
116
+ return reason;
117
+ if (t.target !== undefined) {
118
+ if (inClass(t.target.raw))
119
+ return reason;
120
+ words.push(t.target);
121
+ }
122
+ }
95
123
  }
96
- if (!SHELL_EXPANSION_CHARS.test(source))
97
- return undefined;
98
- const mask = quoteMask(source);
99
- for (let i = 0; i < source.length; i++) {
100
- if (!SHELL_EXPANSION_CHARS.test(source[i]))
101
- continue;
102
- if (mask.balanced && mask.openedBy[i] === "'")
103
- continue;
104
- return reason;
124
+ const home = opts.homeDir !== undefined && !homeMayBeRebound(source) ? opts.homeDir : undefined;
125
+ for (const w of words) {
126
+ if (expandsDollar(w) && !(home !== undefined && homeSpelledForClassify(w, home) !== undefined))
127
+ return reason;
105
128
  }
129
+ for (const s of lexed.segments) {
130
+ if (s.connector === "&")
131
+ return BACKGROUND_REFUSAL;
132
+ if (s.pipesStderr)
133
+ return PIPE_BOTH_REFUSAL;
134
+ }
135
+ for (const w of words)
136
+ if (w.unresolved !== undefined)
137
+ return UNRESOLVED_SENTENCE[w.unresolved];
106
138
  return undefined;
107
139
  }
108
140
  export function splitShellCompoundSegments(source, options) {
109
- if (options?.redirection === "tolerate") {
110
- const refused = rejectedSansRedirection(source);
111
- if (refused !== undefined)
112
- return { reject: refused };
113
- }
114
- else {
115
- const refused = hardRejectReason(source, false);
116
- if (refused !== undefined)
117
- return { reject: refused };
118
- }
119
141
  const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
120
- const lexed = lexShellText(scanned, { mode: "strict" });
121
- if ("reject" in lexed)
122
- return lexed;
123
- const segments = lexed.segments.map((s) => s.text);
124
- const connectors = lexed.segments.map((s) => s.connector);
125
- return { segments, pipeFed: connectors.map((k) => k === "|"), connectors };
126
- }
127
- const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
128
- function stripExemptRedirections(command) {
129
- if (!command.includes(">"))
130
- return command;
131
- const mask = quoteMask(command);
132
- if (!mask.balanced)
133
- return command;
134
- let out = "";
135
- let word = "";
136
- let wordQuoted = false;
137
- const flush = () => {
138
- out += wordQuoted || !EXEMPT_REDIRECTION.test(word) ? word : "";
139
- word = "";
140
- wordQuoted = false;
141
- };
142
- for (let i = 0; i < command.length; i++) {
143
- const ch = command[i];
144
- if (!mask.quoted[i] && (ch === " " || ch === "\t")) {
145
- flush();
146
- out += ch;
147
- continue;
148
- }
149
- if (mask.quoted[i])
150
- wordQuoted = true;
151
- word += ch;
152
- }
153
- flush();
154
- return out;
142
+ if (/[\n\r]/.test(source.slice(scanned.length)))
143
+ return { reject: options?.redirection === "tolerate" ? "command/variable substitution, subshells, escapes, and line breaks are not allowed" : "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
144
+ const lexed = lexShell(scanned);
145
+ const refused = strictResidualRefusal(lexed, scanned, {
146
+ tolerateRedirection: options?.redirection === "tolerate",
147
+ dataFreeRedirections: options?.redirection === "data-free",
148
+ ...(options?.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
149
+ });
150
+ if (refused !== undefined)
151
+ return { reject: refused };
152
+ const command = lexed;
153
+ const connectors = command.segments.map((s) => s.connector);
154
+ return { segments: command.segments.map((s) => s.text), pipeFed: connectors.map((k) => k === "|"), connectors, lexed: command.segments };
155
155
  }
156
156
  export function formatOutOfRootReadApprovalOption(directory) {
157
157
  const trimmed = directory.replace(/[/\\]+$/, "");
@@ -200,56 +200,36 @@ function reheadSegment(toks, head, from = 0) {
200
200
  return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
201
201
  }
202
202
  export const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi|a|mk|ya|pdk|c|tc)?sh|eval|source|\.|parallel)$/;
203
- function tokenizeSegment(segment) {
204
- const lexed = lexShellText(segment, { mode: "strict", connectors: "text" });
205
- if ("reject" in lexed)
206
- return { folded: [], raw: [] };
207
- return wordsOf(lexed.segments[0]);
208
- }
209
- export function hasUnquotedExpansionMetachar(rawToken) {
210
- let open;
211
- for (let i = 0; i < rawToken.length; i++) {
212
- const ch = rawToken[i];
213
- const opener = rawToken[i + 1];
214
- if (open === undefined && ch === "$" && dollarOpensQuote(rawToken, i)) {
215
- if (opener === '"') {
216
- open = opener;
217
- i += 1;
218
- continue;
219
- }
220
- const close = ansiCQuoteClose(rawToken, i + 1);
221
- if (close === undefined)
222
- return true;
223
- i = close;
224
- continue;
225
- }
226
- if (open === undefined && (ch === '"' || ch === "'")) {
227
- open = ch;
228
- continue;
229
- }
230
- if (open === ch) {
231
- open = undefined;
203
+ function segmentSpellings(words, homeDir, homeRebound) {
204
+ const folded = [];
205
+ const raw = [];
206
+ for (const w of words) {
207
+ const spelled = homeDir !== undefined && !homeRebound ? homeSpelledForClassify(w, homeDir) : undefined;
208
+ folded.push(spelled ?? w.folded);
209
+ raw.push(spelled ?? w.raw);
210
+ }
211
+ return { folded, raw };
212
+ }
213
+ export function expansionInWord(w) {
214
+ for (const p of w.pieces) {
215
+ const k = p.expansion?.kind;
216
+ if (k === "parameter" || k === "command" || k === "process" || k === "arithmetic" || k === "brace")
217
+ return true;
218
+ if (p.literal === undefined)
232
219
  continue;
233
- }
234
- if (open === undefined && (ch === "{" || ch === "}" || ch === "$" || ch === "`"))
220
+ if (p.quotedBy === undefined && /[{}$`]/.test(p.literal))
235
221
  return true;
236
- if (open === '"' && (ch === "$" || ch === "`"))
222
+ if (p.quotedBy === '"' && /[$`]/.test(p.literal))
237
223
  return true;
238
224
  }
239
225
  return false;
240
226
  }
241
- export function hasUnquotedGlobMetachar(rawToken) {
242
- let open;
243
- for (const ch of rawToken) {
244
- if (open === undefined && (ch === '"' || ch === "'")) {
245
- open = ch;
246
- continue;
247
- }
248
- if (open === ch) {
249
- open = undefined;
250
- continue;
251
- }
252
- if (open === undefined && (ch === "*" || ch === "?" || ch === "["))
227
+ const isGlobPiece = (p) => p.expansion?.kind === "glob";
228
+ export function globInWord(w) {
229
+ for (const p of w.pieces) {
230
+ if (isGlobPiece(p))
231
+ return true;
232
+ if (p.literal !== undefined && p.quotedBy === undefined && /[*?[]/.test(p.literal))
253
233
  return true;
254
234
  }
255
235
  return false;
@@ -1298,24 +1278,24 @@ function argumentAsProgramRefusal(written, name, args) {
1298
1278
  const clause = inlineProgramClause(interpreter, args);
1299
1279
  return clause === undefined ? undefined : unjudgedProgramTextRefusal(written, clause);
1300
1280
  }
1301
- function guardedVerbFormReason(name, toks, raw) {
1281
+ function guardedVerbFormReason(name, toks, args) {
1302
1282
  if (name === "find") {
1303
1283
  for (const t of toks) {
1304
1284
  if (FIND_DANGEROUS_PREDICATES.has(t)) {
1305
1285
  return `\`find ${t}\` executes a command or writes a file — not a read ${NOT_AUTO_ALLOWED}`;
1306
1286
  }
1307
1287
  }
1308
- for (const rt of raw.slice(1)) {
1309
- if (hasUnquotedGlobMetachar(rt) || hasUnquotedExpansionMetachar(rt)) {
1310
- return `\`find\` is given the unquoted pattern "${rt}", which the shell may expand into other arguments before find runs ${NOT_AUTO_ALLOWED}`;
1288
+ for (const w of args) {
1289
+ if (globInWord(w) || expansionInWord(w)) {
1290
+ return `\`find\` is given the unquoted pattern "${w.raw}", which the shell may expand into other arguments before find runs ${NOT_AUTO_ALLOWED}`;
1311
1291
  }
1312
1292
  }
1313
1293
  return undefined;
1314
1294
  }
1315
1295
  if (name === "sed") {
1316
- for (const rt of raw.slice(1)) {
1317
- if (hasUnquotedGlobMetachar(rt) || hasUnquotedExpansionMetachar(rt)) {
1318
- return `\`sed\` is given the unquoted pattern "${rt}", which the shell may expand into other arguments (a file named like a flag becomes a live option) before sed runs ${NOT_AUTO_ALLOWED}`;
1296
+ for (const w of args) {
1297
+ if (globInWord(w) || expansionInWord(w)) {
1298
+ return `\`sed\` is given the unquoted pattern "${w.raw}", which the shell may expand into other arguments (a file named like a flag becomes a live option) before sed runs ${NOT_AUTO_ALLOWED}`;
1319
1299
  }
1320
1300
  }
1321
1301
  const flags = [];
@@ -1361,7 +1341,7 @@ function guardedVerbFormReason(name, toks, raw) {
1361
1341
  return undefined;
1362
1342
  }
1363
1343
  if (name === "sleep") {
1364
- return pollLoopSleepReason(toks.join(" "));
1344
+ return pollLoopSleepReason(toks);
1365
1345
  }
1366
1346
  if (name === "seq") {
1367
1347
  for (const t of toks.slice(1)) {
@@ -1458,51 +1438,13 @@ function isGrepFileStdinLongOption(tok) {
1458
1438
  return name !== undefined && isLongOptionAbbrevOf(name, "file");
1459
1439
  }
1460
1440
  const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
1461
- function tildeExpandsInValue(rawValue) {
1462
- if (rawValue === undefined)
1463
- return false;
1464
- for (let at = 0; at <= rawValue.length;) {
1465
- if (rawValue[at] === "~") {
1466
- let quoted = false;
1467
- for (let k = at + 1; k < rawValue.length && rawValue[k] !== "/" && rawValue[k] !== ":"; k++) {
1468
- if (rawValue[k] === "'" || rawValue[k] === '"' || rawValue[k] === "\\") {
1469
- quoted = true;
1470
- break;
1471
- }
1472
- }
1473
- if (!quoted)
1474
- return true;
1475
- }
1476
- const next = rawValue.indexOf(":", at);
1477
- if (next < 0)
1478
- return false;
1479
- at = next + 1;
1480
- }
1481
- return false;
1482
- }
1483
- export function assignmentWordPayload(word, raw = word) {
1484
- const written = writtenAssignmentPrefix(word);
1485
- if (written === undefined)
1486
- return undefined;
1487
- const eq = written.prefix.length - 1;
1488
- const spelled = word.slice(eq + 1);
1489
- const expands = tildeExpandsInValue(raw.startsWith(written.prefix) ? raw.slice(written.prefix.length) : undefined);
1490
- const value = expands ? spelled : literalTildeSpelling(spelled);
1491
- if (written.appends) {
1492
- const reason = `"${word}" appends to a value the text does not contain`;
1493
- return spelled === "" || expands ? { kind: "unresolved", reason } : { kind: "unresolved", reason, value };
1494
- }
1495
- if (expands)
1496
- return { kind: "unresolved", reason: `"${word}" carries a tilde whose expansion the text does not settle` };
1497
- return spelled === "" ? undefined : { kind: "value", value };
1498
- }
1499
1441
  export const ASSIGNMENT_VALUE_NOT_PATHS = new Set(["make", "gmake", "bmake", "cmake", "ninja", "meson", "docker", "podman"]);
1500
- export function argvProgramOf(tokens) {
1501
- const pos = readProgramPosition(tokens);
1442
+ export function argvProgramOf(words) {
1443
+ const pos = readProgramPosition(words);
1502
1444
  if (pos.program === undefined)
1503
1445
  return undefined;
1504
1446
  const at = pos.program.index;
1505
- return ASSIGNMENT_PREFIX.test(tokens.folded[at] ?? "") ? undefined : { program: pos.program.basename, from: at };
1447
+ return words[at]?.assignment !== undefined ? undefined : { program: pos.program.basename, from: at };
1506
1448
  }
1507
1449
  export function attachedOptionPayloads(tok) {
1508
1450
  const literal = literalTildeSpelling;
@@ -1525,83 +1467,239 @@ export function attachedOptionPayloads(tok) {
1525
1467
  }
1526
1468
  return out;
1527
1469
  }
1528
- export function foldHomeVariableSpelling(command, homeDir) {
1529
- if (homeDir === undefined || !command.includes("$") || /[\\`]/.test(command))
1530
- return command;
1531
- const unquotedIsExact = /^[A-Za-z0-9_./+@:%=,-]+$/.test(homeDir);
1532
- const quotedIsExact = !/["$`\\\n\r]/.test(homeDir);
1533
- const UNQUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/\s;|&"]|$)/;
1534
- const DOUBLE_QUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/"]|$)/;
1535
- let open;
1536
- let wordStart = true;
1537
- let quoteHadContent = false;
1538
- let out = "";
1539
- for (let i = 0; i < command.length; i++) {
1540
- const ch = command[i];
1541
- if (open === undefined && (ch === '"' || ch === "'")) {
1542
- open = ch;
1543
- quoteHadContent = false;
1544
- out += ch;
1470
+ const HOME_EXPANDS_TO_ONE_WORD = /^[A-Za-z0-9_./+@:%=,-]+$/;
1471
+ export function homeExpansionOf(w, home) {
1472
+ const [head, ...rest] = w.pieces;
1473
+ const e = head?.expansion;
1474
+ if (head === undefined || e === undefined || e.kind !== "parameter" || e.name !== "HOME" || (e.body !== undefined && e.body !== "HOME"))
1475
+ return undefined;
1476
+ if (head.quotedBy !== undefined && head.quotedBy !== '"')
1477
+ return undefined;
1478
+ if (head.quotedBy === undefined && !HOME_EXPANDS_TO_ONE_WORD.test(home))
1479
+ return undefined;
1480
+ if (rest.some((p) => p.expansion !== undefined || p.literal === undefined))
1481
+ return undefined;
1482
+ const next = rest[0];
1483
+ if (next !== undefined) {
1484
+ if (head.quotedBy === undefined && !(next.literal.startsWith("/") || next.quotedBy === '"'))
1485
+ return undefined;
1486
+ if (head.quotedBy === '"' && next.quotedBy === '"' && !next.literal.startsWith("/"))
1487
+ return undefined;
1488
+ }
1489
+ return { spelled: `${home}${rest.map((p) => p.literal).join("")}` };
1490
+ }
1491
+ const CLASSIFY_HOME_REREADABLE = /["$`\\\n\r]/;
1492
+ function homeSpelledForClassify(w, home) {
1493
+ return CLASSIFY_HOME_REREADABLE.test(home) ? undefined : homeExpansionOf(w, home)?.spelled;
1494
+ }
1495
+ const HOME_PLAIN_READ = /\$HOME(?![A-Za-z0-9_])|\$\{HOME\}/g;
1496
+ const HOME_NAME = /HOME(?![A-Za-z0-9_])/;
1497
+ export function homeMayBeRebound(text) {
1498
+ const unquoted = text.replace(/\\\r?\n/g, "").replace(HOME_PLAIN_READ, " ").replace(/\$?["']|\\/g, "");
1499
+ if (HOME_NAME.test(unquoted))
1500
+ return true;
1501
+ if (hasUnquotedExtglobOpener(text))
1502
+ return true;
1503
+ if (!unquoted.includes("printf") && !/[*?[{]/.test(unquoted))
1504
+ return false;
1505
+ const lexed = lexShell(text);
1506
+ return !("parseFailure" in lexed) && lexed.segments.some((seg) => {
1507
+ const words = argvOf(seg);
1508
+ return printfNamesComputed(words) || words.some((w) => globMayMatchHome(w) || optionWordCarriesGlob(w) || wordHasBraceList(w));
1509
+ });
1510
+ }
1511
+ function hasUnquotedExtglobOpener(text) {
1512
+ const lexed = lexShell(text);
1513
+ if ("parseFailure" in lexed)
1514
+ return true;
1515
+ for (const seg of lexed.segments) {
1516
+ for (const tok of seg.tokens) {
1517
+ const t = tok.kind === "word" ? tok : tok.kind === "redirect" ? tok.target : undefined;
1518
+ if (t === undefined)
1519
+ continue;
1520
+ const last = t.pieces[t.pieces.length - 1];
1521
+ if (last === undefined || last.quotedBy !== undefined)
1522
+ continue;
1523
+ if (!/[?*+@!]$/.test(text.slice(last.span[0], last.span[1])))
1524
+ continue;
1525
+ let j = last.span[1];
1526
+ while (text[j] === "\\" && (text[j + 1] === "\n" || (text[j + 1] === "\r" && text[j + 2] === "\n")))
1527
+ j += text[j + 1] === "\r" ? 3 : 2;
1528
+ if (text[j] === "(")
1529
+ return true;
1530
+ }
1531
+ }
1532
+ return false;
1533
+ }
1534
+ function wordHasBraceList(w) {
1535
+ return w.pieces.some((p) => p.expansion?.kind === "brace");
1536
+ }
1537
+ function optionWordCarriesGlob(w) {
1538
+ if (!w.pieces.some(isGlobPiece))
1539
+ return false;
1540
+ const atoms = patternAtoms(w.pieces);
1541
+ if (atoms === undefined)
1542
+ return UNREADABLE_GLOB_IS_A_REBINDING;
1543
+ try {
1544
+ const reversed = atoms.slice().reverse();
1545
+ return closureReaches(atoms, "-") && (closureReaches(reversed, "EMOH") || closureReaches(reversed, "]"));
1546
+ }
1547
+ catch {
1548
+ return UNREADABLE_GLOB_IS_A_REBINDING;
1549
+ }
1550
+ }
1551
+ const UNREADABLE_GLOB_IS_A_REBINDING = true;
1552
+ function escapeRegExp(s) {
1553
+ return s.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
1554
+ }
1555
+ function globMayMatchHome(w) {
1556
+ if (!w.pieces.some(isGlobPiece))
1557
+ return false;
1558
+ const atoms = patternAtoms(w.pieces);
1559
+ if (atoms === undefined)
1560
+ return UNREADABLE_GLOB_IS_A_REBINDING;
1561
+ try {
1562
+ if (new RegExp(`^${atoms.join("")}$`).test("HOME"))
1563
+ return true;
1564
+ return closureReaches(atoms, "HOME[") && closureReaches(atoms.slice().reverse(), "]");
1565
+ }
1566
+ catch {
1567
+ return UNREADABLE_GLOB_IS_A_REBINDING;
1568
+ }
1569
+ }
1570
+ function patternAtoms(pieces) {
1571
+ const atoms = [];
1572
+ for (const p of pieces) {
1573
+ if (p.literal !== undefined && p.expansion === undefined) {
1574
+ for (const ch of p.literal)
1575
+ atoms.push(caseFoldedChar(ch));
1545
1576
  continue;
1546
1577
  }
1547
- if (open !== undefined && open === ch) {
1548
- open = undefined;
1549
- wordStart = wordStart && !quoteHadContent;
1550
- out += ch;
1578
+ if (isGlobPiece(p)) {
1579
+ const body = p.expansion.body;
1580
+ if (body === undefined) {
1581
+ atoms.push(RUN_ATOM);
1582
+ continue;
1583
+ }
1584
+ const cls = translateBracket(body);
1585
+ if (cls === undefined)
1586
+ return undefined;
1587
+ atoms.push(cls);
1551
1588
  continue;
1552
1589
  }
1553
- if (open !== undefined)
1554
- quoteHadContent = true;
1555
- if (open === undefined && /[\s;|&]/.test(ch)) {
1556
- wordStart = true;
1557
- out += ch;
1590
+ atoms.push(RUN_ATOM);
1591
+ }
1592
+ return atoms;
1593
+ }
1594
+ const RUN_ATOM = ".*";
1595
+ function caseFoldedChar(ch) {
1596
+ const lower = ch.toLowerCase();
1597
+ const upper = ch.toUpperCase();
1598
+ return lower !== upper ? `[${escapeRegExp(lower)}${escapeRegExp(upper)}]` : escapeRegExp(ch);
1599
+ }
1600
+ function closureReaches(atoms, target) {
1601
+ let re = "";
1602
+ for (let i = atoms.length - 1; i >= 0; i--)
1603
+ re = `(?:${atoms[i]}${re})?`;
1604
+ return new RegExp(`^${re}$`).test(target);
1605
+ }
1606
+ function scanBracketBody(body) {
1607
+ const members = [];
1608
+ let computed = false;
1609
+ for (let i = 0; i < body.length; i++) {
1610
+ const c = body[i];
1611
+ if (c === "\\") {
1612
+ if (i + 1 < body.length)
1613
+ members.push({ ch: body[++i], unquoted: false });
1558
1614
  continue;
1559
1615
  }
1560
- if (ch === "$" && wordStart && ((open === '"' && quotedIsExact) || (open === undefined && unquotedIsExact))) {
1561
- const m = (open === '"' ? DOUBLE_QUOTED_HOME_WORD : UNQUOTED_HOME_WORD).exec(command.slice(i));
1562
- if (m !== null) {
1563
- wordStart = false;
1564
- if (open === '"')
1565
- quoteHadContent = true;
1566
- out += homeDir;
1567
- i += m[0].length - 1;
1568
- continue;
1569
- }
1616
+ if (c === "$" || c === "`" || c === "{")
1617
+ computed = true;
1618
+ members.push({ ch: c, unquoted: true });
1619
+ }
1620
+ return { members, computed };
1621
+ }
1622
+ function membersOpenClassExpression(members) {
1623
+ return members.some((m, i) => m.unquoted && m.ch === "[" && members[i + 1]?.unquoted === true && (members[i + 1].ch === ":" || members[i + 1].ch === "=" || members[i + 1].ch === "."));
1624
+ }
1625
+ function translateBracket(body) {
1626
+ if (body.includes('"') || body.includes("'"))
1627
+ return undefined;
1628
+ const { members, computed } = scanBracketBody(body);
1629
+ if (computed || membersOpenClassExpression(members))
1630
+ return undefined;
1631
+ let i = 0;
1632
+ let negated = false;
1633
+ if (members[0]?.unquoted === true && (members[0].ch === "!" || members[0].ch === "^")) {
1634
+ negated = true;
1635
+ i = 1;
1636
+ }
1637
+ const first = i;
1638
+ let cls = "";
1639
+ for (; i < members.length; i++) {
1640
+ const { ch, unquoted } = members[i];
1641
+ if (ch === "-" && unquoted && i > first && i < members.length - 1) {
1642
+ if (!negated)
1643
+ return undefined;
1644
+ cls += "-";
1645
+ continue;
1570
1646
  }
1571
- wordStart = false;
1572
- out += ch;
1647
+ const escaped = ch.replace(/[\\\]\[^-]/g, "\\$&");
1648
+ cls += !negated && ch.toLowerCase() !== ch.toUpperCase() ? `${escapeRegExp(ch.toLowerCase())}${escapeRegExp(ch.toUpperCase())}` : escaped;
1573
1649
  }
1574
- return open === undefined ? out : command;
1650
+ return `[${negated ? "^" : ""}${cls || "\\]"}]`;
1651
+ }
1652
+ const literalWord = (w) => (w !== undefined && w.pieces.every((p) => p.literal !== undefined && p.expansion === undefined) ? w.pieces.map((p) => p.literal).join("") : undefined);
1653
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
1654
+ function printfNamesComputed(words) {
1655
+ for (let i = 0; i < words.length; i++) {
1656
+ const head = literalWord(words[i]);
1657
+ if (head === undefined || programBasename(head) !== "printf")
1658
+ continue;
1659
+ for (let j = i + 1; j < words.length; j++) {
1660
+ const lit = literalWord(words[j]);
1661
+ const opt = lit ?? words[j].pieces[0]?.literal ?? "";
1662
+ if (lit === "--" || !opt.startsWith("-v"))
1663
+ break;
1664
+ if (lit === undefined)
1665
+ return true;
1666
+ const name = lit === "-v" ? words[++j] : undefined;
1667
+ if (lit === "-v" && name === undefined)
1668
+ break;
1669
+ if (!IDENTIFIER.test(lit === "-v" ? (literalWord(name) ?? "") : lit.slice(2)))
1670
+ return true;
1671
+ }
1672
+ }
1673
+ return false;
1575
1674
  }
1576
1675
  function segmentCompoundForReadonly(command, homeDir) {
1577
- const trimmed = foldHomeVariableSpelling(command, homeDir).trim();
1676
+ const trimmed = command.trim();
1578
1677
  if (!trimmed)
1579
1678
  return { reject: "empty command" };
1580
- const redirectionStripped = stripExemptRedirections(trimmed);
1581
- const terminatorStripped = redirectionStripped.endsWith(";")
1582
- ? redirectionStripped.slice(0, -1)
1583
- : redirectionStripped;
1584
- return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
1679
+ const terminatorStripped = trimmed.endsWith(";") ? trimmed.slice(0, -1) : trimmed;
1680
+ return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep", redirection: "data-free", ...(homeDir !== undefined ? { homeDir } : {}) });
1585
1681
  }
1586
1682
  export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
1587
1683
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
1588
1684
  if ("reject" in split)
1589
1685
  return { reason: split.reject };
1590
1686
  const { segments, pipeFed } = split;
1687
+ const homeRebound = boundary?.homeDir !== undefined && homeMayBeRebound(command);
1591
1688
  let nameRefusal;
1592
1689
  const unlistedSegments = new Set();
1593
1690
  const reheaded = new Map();
1594
1691
  for (let si = 0; si < segments.length; si++) {
1595
- const toks = tokenizeSegment(segments[si]);
1692
+ const words = argvOf(split.lexed[si]);
1693
+ const toks = segmentSpellings(words, boundary?.homeDir, homeRebound);
1596
1694
  const folded = toks.folded;
1597
- const pos = readProgramPosition(toks);
1695
+ const pos = readProgramPosition(words);
1598
1696
  const from = pos.written ?? folded.length;
1599
1697
  const written = folded[from];
1600
1698
  if (written === undefined)
1601
1699
  return { reason: "empty command" };
1602
1700
  if (written === "coproc")
1603
1701
  return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command" };
1604
- if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
1702
+ if (isBareControlWord(words, from, SHELL_BLOCK_WORDS)) {
1605
1703
  unlistedSegments.add(si);
1606
1704
  nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
1607
1705
  continue;
@@ -1682,13 +1780,16 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
1682
1780
  if (unlistedSegments.has(si))
1683
1781
  continue;
1684
1782
  const rehead = reheaded.get(si);
1685
- const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
1783
+ const words = argvOf(split.lexed[si]);
1784
+ const spelled = segmentSpellings(words, boundary?.homeDir, homeRebound);
1785
+ const segmentTokens = rehead === undefined ? spelled : reheadSegment(spelled, rehead.head, rehead.from);
1786
+ const argWords = words.slice((rehead?.from ?? 0) + 1);
1686
1787
  const toks = segmentTokens.folded;
1687
1788
  if (toks.length === 0)
1688
1789
  continue;
1689
1790
  foldedSegments.push(segmentTokens);
1690
1791
  const name = toks[0];
1691
- const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
1792
+ const guardReason = guardedVerbFormReason(name, toks, argWords);
1692
1793
  if (guardReason !== undefined) {
1693
1794
  shapeRefusal ??= guardReason;
1694
1795
  continue;
@@ -1776,31 +1877,53 @@ export const POLL_LOOP_MAX_BEATS = 120;
1776
1877
  export const POLL_LOOP_MAX_SLEEP_SECONDS = 600;
1777
1878
  const POLL_BODY_HARD_REJECT = /[<>$()`|&{}\n\r\\]/;
1778
1879
  const NON_ASCII_WHITESPACE = /[^\S \t]/;
1779
- const POLL_LOOP_SHAPE = /^for[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]+in[ \t]+(.+?)[ \t]*;[ \t]*do[ \t]+(.+?)[ \t]*;[ \t]*done[ \t]*;?$/;
1780
- function pollLoopQuotesBalanced(s) {
1781
- let open;
1782
- for (const ch of s) {
1783
- if (open === undefined && (ch === '"' || ch === "'"))
1784
- open = ch;
1785
- else if (open === ch)
1786
- open = undefined;
1787
- }
1788
- return open === undefined;
1880
+ const NOT_A_POLL_LOOP = "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)";
1881
+ function pollLoopShapeOf(lexed) {
1882
+ const segments = [...lexed.segments];
1883
+ const last = segments[segments.length - 1];
1884
+ if (last !== undefined && last.tokens.length === 0 && last.connector === ";" && segments.length > 1)
1885
+ segments.pop();
1886
+ if (segments.length < 3)
1887
+ return undefined;
1888
+ const bare = (w, text) => w !== undefined && w.raw === w.folded && w.folded === text;
1889
+ const onlyWords = (s) => s.tokens.every((t) => t.kind === "word");
1890
+ const head = segments[0];
1891
+ if (!onlyWords(head))
1892
+ return undefined;
1893
+ const hw = argvOf(head);
1894
+ if (!bare(hw[0], "for") || !bare(hw[2], "in") || hw.length < 4)
1895
+ return undefined;
1896
+ const name = hw[1];
1897
+ if (name.raw !== name.folded || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.folded))
1898
+ return undefined;
1899
+ const done = segments[segments.length - 1];
1900
+ const dw = argvOf(done);
1901
+ if (done.connector !== ";" || !onlyWords(done) || dw.length !== 1 || !bare(dw[0], "done"))
1902
+ return undefined;
1903
+ const first = segments[1];
1904
+ if (first.connector !== ";")
1905
+ return undefined;
1906
+ const fw = argvOf(first);
1907
+ if (!bare(fw[0], "do") || fw.length < 2)
1908
+ return undefined;
1909
+ const body = [{ words: fw.slice(1), segment: first }];
1910
+ for (const s of segments.slice(2, -1))
1911
+ body.push({ words: argvOf(s), segment: s });
1912
+ return { varName: name.folded, iterable: hw.slice(3), body };
1789
1913
  }
1790
1914
  function pollLoopBeatsOf(iterable) {
1791
- const seq = /^\$\(seq[ \t]+(\d{1,7})[ \t]+(\d{1,7})\)$/.exec(iterable);
1792
- const brace = seq === null ? /^\{(\d{1,7})\.\.(\d{1,7})\}$/.exec(iterable) : null;
1915
+ const one = iterable.length === 1 && iterable[0].pieces.length === 1 ? iterable[0].pieces[0].expansion : undefined;
1916
+ const seq = one?.kind === "command" ? /^seq[ \t]+(\d{1,7})[ \t]+(\d{1,7})$/.exec(one.body ?? "") : null;
1917
+ const brace = seq === null && one?.kind === "brace" ? /^(\d{1,7})\.\.(\d{1,7})$/.exec(one.body ?? "") : null;
1793
1918
  const range = seq ?? brace;
1794
1919
  if (range !== null) {
1795
1920
  const lo = Number(range[1]);
1796
1921
  const hi = Number(range[2]);
1797
1922
  return hi >= lo ? hi - lo + 1 : undefined;
1798
1923
  }
1799
- const items = iterable.split(/[ \t]+/);
1800
- return items.every((t) => /^[A-Za-z0-9_.-]{1,64}$/.test(t)) ? items.length : undefined;
1924
+ return iterable.every((w) => w.raw === w.folded && w.expansions.length === 0 && /^[A-Za-z0-9_.-]{1,64}$/.test(w.raw)) ? iterable.length : undefined;
1801
1925
  }
1802
- function pollLoopSleepReason(segment) {
1803
- const toks = segment.split(/[ \t]+/);
1926
+ function pollLoopSleepReason(toks) {
1804
1927
  if (toks.length !== 2)
1805
1928
  return "`sleep` in a poll loop must take exactly one literal numeric argument";
1806
1929
  const v = toks[1];
@@ -1816,42 +1939,47 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
1816
1939
  if (NON_ASCII_WHITESPACE.test(command)) {
1817
1940
  return { reason: "the command contains a non-ASCII or control whitespace character (only space and tab are allowed) — its word boundaries cannot be read the way bash would split them, so it is not auto-allowed" };
1818
1941
  }
1819
- const trimmed = foldHomeVariableSpelling(command, boundary?.homeDir).trim();
1942
+ const trimmed = command.trim();
1820
1943
  if (!trimmed)
1821
1944
  return { reason: "empty command" };
1822
- if (!pollLoopQuotesBalanced(trimmed)) {
1823
- return { reason: "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed" };
1824
- }
1825
- const m = POLL_LOOP_SHAPE.exec(trimmed);
1826
- if (m === null) {
1827
- return { reason: "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)" };
1945
+ const lexed = lexShell(trimmed);
1946
+ if ("parseFailure" in lexed) {
1947
+ const r = lexed.parseFailure.reason;
1948
+ if (r === "unterminated_single_quote" || r === "unterminated_double_quote" || r === "unterminated_ansi_c") {
1949
+ return { reason: "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed" };
1950
+ }
1951
+ return { reason: NOT_A_POLL_LOOP };
1828
1952
  }
1829
- const varName = m[1];
1830
- const iterable = m[2];
1831
- const body = m[3];
1832
- if (!/^[a-z]$/.test(varName)) {
1833
- return { reason: `the loop variable "${varName}" must be a single lowercase letter — a name like PATH/IFS/LD_PRELOAD would change how the body's commands resolve` };
1953
+ const shape = pollLoopShapeOf(lexed);
1954
+ if (shape === undefined)
1955
+ return { reason: NOT_A_POLL_LOOP };
1956
+ if (!/^[a-z]$/.test(shape.varName)) {
1957
+ return { reason: `the loop variable "${shape.varName}" must be a single lowercase letter — a name like PATH/IFS/LD_PRELOAD would change how the body's commands resolve` };
1834
1958
  }
1835
- const beats = pollLoopBeatsOf(iterable);
1959
+ const beats = pollLoopBeatsOf(shape.iterable);
1836
1960
  if (beats === undefined) {
1837
- return { reason: `the loop bound "${iterable}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted` };
1961
+ return { reason: `the loop bound "${shape.iterable.map((w) => w.raw).join(" ")}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted` };
1838
1962
  }
1839
1963
  if (beats > POLL_LOOP_MAX_BEATS) {
1840
1964
  return { reason: `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow` };
1841
1965
  }
1842
- if (POLL_BODY_HARD_REJECT.test(body)) {
1843
- return { reason: "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks" };
1966
+ const homeRebound = boundary?.homeDir !== undefined && homeMayBeRebound(command);
1967
+ const bodies = shape.body.map((b) => ({ ...b, spelled: segmentSpellings(b.words, boundary?.homeDir, homeRebound).raw }));
1968
+ for (const b of bodies) {
1969
+ if (b.segment.connector !== ";" || b.segment.tokens.some((t) => t.kind !== "word") || b.spelled.some((raw) => POLL_BODY_HARD_REJECT.test(raw))) {
1970
+ return { reason: "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks" };
1971
+ }
1844
1972
  }
1845
1973
  const readSegments = [];
1846
- for (const rawSegment of body.split(";")) {
1847
- const segment = rawSegment.trim();
1848
- if (segment.length === 0)
1974
+ for (const b of bodies) {
1975
+ if (b.words.length === 0)
1849
1976
  return { reason: "empty command in the loop body" };
1850
- const parsed = parseLeadingCommandName(segment);
1977
+ const segment = b.spelled.join(" ");
1978
+ const parsed = parseLeadingCommandName(segment, { quotedOperatorsAreText: true });
1851
1979
  if ("reject" in parsed)
1852
1980
  return { reason: parsed.reject };
1853
1981
  if (parsed.name === "sleep") {
1854
- const sleepReason = pollLoopSleepReason(segment);
1982
+ const sleepReason = pollLoopSleepReason(b.spelled);
1855
1983
  if (sleepReason !== undefined)
1856
1984
  return { reason: sleepReason };
1857
1985
  continue;