@sema-agent/core 7.16.0 → 7.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +209 -0
- package/dist/agents/builtin-agents.d.ts +3 -3
- package/dist/agents/cross-session-envelope.d.ts +0 -1
- package/dist/agents/cross-session-envelope.js +0 -1
- package/dist/agents/peer-notices.d.ts +0 -9
- package/dist/agents/peer-notices.js +0 -63
- package/dist/agents/subagent.js +7 -15
- package/dist/brain/request-params.d.ts +2 -2
- package/dist/config/defaults.d.ts +0 -7
- package/dist/config/defaults.js +0 -2
- package/dist/core/artifact-host.d.ts +218 -0
- package/dist/core/artifact-host.js +92 -0
- package/dist/core/ask-origin.d.ts +29 -3
- package/dist/core/ask-origin.js +17 -9
- package/dist/core/ask-question.d.ts +25 -0
- package/dist/core/ask-question.js +4 -0
- package/dist/core/ask-unresolvable-notice.d.ts +30 -21
- package/dist/core/ask-unresolvable-notice.js +16 -8
- package/dist/core/auto-compaction.d.ts +10 -10
- package/dist/core/auto-mode-arming.d.ts +2 -2
- package/dist/core/background-agent-store.d.ts +5 -5
- package/dist/core/background-shell.d.ts +1 -1
- package/dist/core/checkpoint-execution-record.d.ts +0 -3
- package/dist/core/checkpoint-execution-record.js +0 -4
- package/dist/core/checkpoint-store.d.ts +71 -99
- package/dist/core/checkpoint-store.js +9 -16
- package/dist/core/context-edit.d.ts +36 -104
- package/dist/core/context-edit.js +37 -60
- package/dist/core/effective-path-target.d.ts +68 -8
- package/dist/core/effective-path-target.js +42 -30
- package/dist/core/engine-notice.d.ts +46 -32
- package/dist/core/env-budget.d.ts +31 -0
- package/dist/core/env-budget.js +39 -0
- package/dist/core/exec-output-tail.d.ts +5 -5
- package/dist/core/file-history-store.js +3 -6
- package/dist/core/fs-write-gate-policy.d.ts +1 -1
- package/dist/core/fs-write-gate-policy.js +3 -3
- package/dist/core/gate-lanes.js +9 -15
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hands-band.d.ts +1 -1
- package/dist/core/hooks.d.ts +18 -17
- package/dist/core/lsp-session.d.ts +1 -1
- package/dist/core/lsp.d.ts +1 -1
- package/dist/core/lsp.js +5 -4
- package/dist/core/mcp.d.ts +5 -5
- package/dist/core/mcp.js +4 -48
- package/dist/core/memory-engine/engine.js +2 -1
- package/dist/core/memory-engine/layout.d.ts +18 -6
- package/dist/core/memory-engine/layout.js +40 -21
- package/dist/core/memory-recall.d.ts +0 -8
- package/dist/core/memory.d.ts +1 -1
- package/dist/core/oracle-isolation.js +2 -2
- package/dist/core/parent-spec-seat.d.ts +66 -0
- package/dist/core/parent-spec-seat.js +47 -0
- package/dist/core/permission-rule-model.d.ts +11 -6
- package/dist/core/permission-rule-model.js +53 -37
- package/dist/core/permission-rules.js +8 -3
- package/dist/core/physical-path.d.ts +37 -0
- package/dist/core/physical-path.js +30 -0
- package/dist/core/protocol-naming.d.ts +2 -2
- package/dist/core/remote-env.d.ts +1 -1
- package/dist/core/retired-keys.d.ts +19 -0
- package/dist/core/retired-keys.js +15 -0
- package/dist/core/runner/active-skill-scope.js +3 -3
- package/dist/core/runner/assemble-result.d.ts +15 -13
- package/dist/core/runner/assemble-result.js +5 -4
- package/dist/core/runner/compaction-call-options.d.ts +25 -89
- package/dist/core/runner/contracts.d.ts +11 -10
- package/dist/core/runner/gate-exit.js +15 -1
- package/dist/core/runner/halt-attribution.d.ts +35 -0
- package/dist/core/runner/halt-attribution.js +16 -0
- package/dist/core/runner/memory-consolidation.d.ts +0 -2
- package/dist/core/runner/memory-consolidation.js +0 -1
- package/dist/core/runner/prepare-artifact.d.ts +52 -0
- package/dist/core/runner/prepare-artifact.js +63 -0
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
- package/dist/core/runner/prepare-caps-and-workflow.js +2 -3
- package/dist/core/runner/prepare-config-doors.d.ts +3 -3
- package/dist/core/runner/prepare-config-doors.js +5 -2
- package/dist/core/runner/prepare-context-lane.d.ts +2 -2
- package/dist/core/runner/prepare-context-lane.js +2 -13
- package/dist/core/runner/prepare-file-history.d.ts +1 -1
- package/dist/core/runner/prepare-file-history.js +3 -52
- package/dist/core/runner/prepare-hands-readface.d.ts +4 -7
- package/dist/core/runner/prepare-hands-readface.js +3 -2
- package/dist/core/runner/prepare-inherited-gate.d.ts +5 -2
- package/dist/core/runner/prepare-inherited-gate.js +7 -8
- package/dist/core/runner/prepare-memory.d.ts +4 -6
- package/dist/core/runner/prepare-memory.js +4 -2
- package/dist/core/runner/prepare-policy-chain.d.ts +3 -3
- package/dist/core/runner/prepare-policy-chain.js +15 -30
- package/dist/core/runner/prepare-prompt-assembly.js +1 -1
- package/dist/core/runner/prepare-question-face.js +2 -1
- package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
- package/dist/core/runner/prepare-suspend-saga.js +0 -2
- package/dist/core/runner/prepare-task.js +12 -11
- package/dist/core/runner/prepare-wiring-manifest.js +9 -7
- package/dist/core/runner/resume-admission.js +2 -1
- package/dist/core/runner/resume-internals-and-config.js +4 -0
- package/dist/core/runner/retired-runner-deps.d.ts +3 -0
- package/dist/core/runner/retired-runner-deps.js +7 -0
- package/dist/core/runner/run-harness-handlers.js +5 -3
- package/dist/core/runner/run-settle-and-teardown.d.ts +1 -1
- package/dist/core/runner/run-settle-and-teardown.js +3 -1
- package/dist/core/runner/session-file-state-replay.js +3 -3
- package/dist/core/runner/session-rule-policy.js +3 -4
- package/dist/core/runner/stream-halt-verbs.js +1 -1
- package/dist/core/runner/stream-steer-verb.js +1 -4
- package/dist/core/runner/workspace-path.js +3 -0
- package/dist/core/runner-deps.d.ts +30 -49
- package/dist/core/safe-notify.d.ts +1 -1
- package/dist/core/sensitive-path-policy.d.ts +1 -1
- package/dist/core/sensitive-path-policy.js +11 -12
- package/dist/core/session.d.ts +2 -2
- package/dist/core/skill-tool-specifier.d.ts +1 -1
- package/dist/core/skills-directory.d.ts +1 -1
- package/dist/core/skills-directory.js +4 -3
- package/dist/core/spec-contract.js +5 -4
- package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
- package/dist/core/strategy-store.d.ts +8 -11
- package/dist/core/task-event.d.ts +20 -17
- package/dist/core/task-limits.d.ts +1 -1
- package/dist/core/task-notification.d.ts +5 -5
- package/dist/core/task-registry-agent.d.ts +5 -5
- package/dist/core/task-registry-monitor.d.ts +2 -2
- package/dist/core/task-registry-shared.d.ts +32 -28
- package/dist/core/task-registry-shared.js +4 -13
- package/dist/core/task-registry.d.ts +8 -8
- package/dist/core/task-result.d.ts +37 -37
- package/dist/core/task-spec.d.ts +5 -5
- package/dist/core/task-stream.d.ts +16 -11
- package/dist/core/tool-catalog-entries.d.ts +1 -0
- package/dist/core/tool-catalog-entries.js +6 -1
- package/dist/core/tool-conformance.d.ts +0 -2
- package/dist/core/tool-conformance.js +1 -3
- package/dist/core/tool-errors.d.ts +5 -0
- package/dist/core/tool-errors.js +1 -0
- package/dist/core/tool-policy.d.ts +66 -22
- package/dist/core/tool-policy.js +31 -5
- package/dist/core/tool-result-budget.d.ts +1 -1
- package/dist/core/tool-result-store.d.ts +11 -11
- package/dist/core/tool-spec.d.ts +34 -38
- package/dist/core/tools.d.ts +2 -2
- package/dist/core/trace.d.ts +12 -15
- package/dist/core/untrusted-text.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +4 -3
- package/dist/core/workflow-journal-store.d.ts +11 -11
- package/dist/engine/execution-env/node-execution-env.d.ts +3 -2
- package/dist/engine/execution-env/node-execution-env.js +8 -5
- package/dist/engine/harness/agent-harness.d.ts +47 -4
- package/dist/engine/harness/agent-harness.js +52 -9
- package/dist/engine/loop/agent-loop.js +15 -8
- package/dist/engine/loop/types.d.ts +5 -0
- package/dist/index.d.ts +20 -14
- package/dist/index.js +16 -10
- package/dist/internal/harness.d.ts +1 -0
- package/dist/orchestration/run-spec.d.ts +3 -0
- package/dist/orchestration/run-spec.js +3 -3
- package/dist/orchestration/run-workflow-tool.d.ts +12 -6
- package/dist/orchestration/run-workflow-tool.js +23 -5
- package/dist/orchestration/workflow-primitives.d.ts +4 -2
- package/dist/orchestration/workflow-primitives.js +1 -4
- package/dist/orchestration/workflow-script-store.js +9 -25
- package/dist/orchestration/workflow-types.d.ts +34 -4
- package/dist/orchestration/workflow-types.js +2 -0
- package/dist/orchestration/workflow.d.ts +39 -7
- package/dist/orchestration/workflow.js +237 -113
- package/dist/prompt-assembly/assemble.d.ts +2 -2
- package/dist/prompt-assembly/assemble.js +9 -9
- package/dist/prompt-assembly/composer.d.ts +6 -7
- package/dist/prompt-assembly/composer.js +10 -10
- package/dist/prompt-assembly/epoch.js +1 -1
- package/dist/prompt-assembly/event-registry.d.ts +0 -21
- package/dist/prompt-assembly/packs/sema-default.js +30 -30
- package/dist/prompt-assembly/types.d.ts +14 -14
- package/dist/prompts/coordinator.d.ts +0 -13
- package/dist/prompts/coordinator.js +0 -3
- package/dist/stores/cc/task-list-store.js +2 -10
- package/dist/stores/file/checkpoint-store.js +1 -5
- package/dist/stores/file/file-history-store.js +2 -2
- package/dist/stores/file/fs-atomic.d.ts +15 -18
- package/dist/stores/file/fs-atomic.js +4 -14
- package/dist/stores/file/mailbox-store.d.ts +7 -11
- package/dist/stores/file/mailbox-store.js +4 -11
- package/dist/stores/file/permission-rule-store.d.ts +4 -35
- package/dist/stores/file/permission-rule-store.js +4 -148
- package/dist/tools/artifact/artifact-text.d.ts +62 -0
- package/dist/tools/artifact/artifact-text.js +68 -0
- package/dist/tools/artifact/artifact-tool.d.ts +52 -0
- package/dist/tools/artifact/artifact-tool.js +351 -0
- package/dist/tools/artifact/local-stub.d.ts +26 -0
- package/dist/tools/artifact/local-stub.js +297 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +90 -8
- package/dist/tools/fs/bash-readonly-classifier.js +294 -70
- package/dist/tools/fs/fs-bash.d.ts +14 -10
- package/dist/tools/fs/fs-bash.js +50 -33
- package/dist/tools/fs/fs-search-tools.js +5 -2
- package/dist/tools/fs/fs-shared.d.ts +2 -0
- package/dist/tools/fs/fs-shared.js +3 -11
- package/dist/tools/fs/index.d.ts +4 -0
- package/dist/tools/fs/index.js +4 -1
- package/dist/tools/fs/safety.d.ts +117 -1
- package/dist/tools/fs/safety.js +131 -31
- package/dist/tools/fs/search.js +8 -6
- package/package.json +2 -1
- package/test/export-surface.snapshot.json +119 -39
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isAbsolutePathForm, isBlockedDevicePath, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
|
|
1
|
+
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
2
2
|
export const NOT_AUTO_ALLOWED = "— not auto-allowed";
|
|
3
3
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
4
4
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
@@ -213,7 +213,7 @@ const NO_PATH_OPERAND_COMMANDS = new Set([
|
|
|
213
213
|
function isPathShapedToken(tok) {
|
|
214
214
|
return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
|
|
215
215
|
}
|
|
216
|
-
function resolveOperandLexically(base, operand, homeDir) {
|
|
216
|
+
export function resolveOperandLexically(base, operand, homeDir) {
|
|
217
217
|
let raw = operand;
|
|
218
218
|
if (raw === "~" || raw.startsWith("~/")) {
|
|
219
219
|
if (homeDir === undefined || !isAbsolutePathForm(homeDir))
|
|
@@ -223,15 +223,107 @@ function resolveOperandLexically(base, operand, homeDir) {
|
|
|
223
223
|
else if (raw.startsWith("~")) {
|
|
224
224
|
return undefined;
|
|
225
225
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (base === undefined || !isAbsolutePathForm(base))
|
|
226
|
+
const family = base === undefined ? undefined : pathFamilyOf({ root: base });
|
|
227
|
+
if (!win32NamespaceScreen(raw, family).ok)
|
|
229
228
|
return undefined;
|
|
230
|
-
|
|
229
|
+
if (isAbsoluteForFamily(family, raw))
|
|
230
|
+
return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
|
|
231
|
+
if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
|
|
232
|
+
return undefined;
|
|
233
|
+
if (isShellRootedSpellingUnmapped(family, raw))
|
|
234
|
+
return undefined;
|
|
235
|
+
return normalizeAbsPathLexically(joinForFamily(family, base, raw));
|
|
236
|
+
}
|
|
237
|
+
function reheadSegment(toks, head, from = 0) {
|
|
238
|
+
return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
|
|
239
|
+
}
|
|
240
|
+
const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
|
|
241
|
+
const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
|
|
242
|
+
function isBareControlWord(toks, i, words) {
|
|
243
|
+
const word = toks.folded[i];
|
|
244
|
+
return word !== undefined && toks.raw[i] === word && words.has(word);
|
|
245
|
+
}
|
|
246
|
+
function tokensMoveBase(toks, allow) {
|
|
247
|
+
let from = 0;
|
|
248
|
+
while (from < toks.folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
|
|
249
|
+
from++;
|
|
250
|
+
const head = toks.folded[from];
|
|
251
|
+
if (head === undefined)
|
|
252
|
+
return false;
|
|
253
|
+
if (toks.raw[from] !== head)
|
|
254
|
+
return true;
|
|
255
|
+
if (head === "cd")
|
|
256
|
+
return true;
|
|
257
|
+
if (!COMMAND_LAUNCHERS.has(head))
|
|
258
|
+
return false;
|
|
259
|
+
const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
|
|
260
|
+
return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
|
|
261
|
+
}
|
|
262
|
+
const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
|
|
263
|
+
export function refusedCommandMayRead(command, allow) {
|
|
264
|
+
for (const m of command.matchAll(/(?<![<>&\d])<(?![<&])\s*(\S*)/g)) {
|
|
265
|
+
const target = (m[1] ?? "").replace(/^["']+|["']+$/g, "");
|
|
266
|
+
if (target === "" || /^[$`(~]/.test(target))
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
|
|
270
|
+
const programOf = (w) => {
|
|
271
|
+
const inner = fold(w).replace(/^[$`(]+|[)`]+$/g, "");
|
|
272
|
+
return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
|
|
273
|
+
};
|
|
274
|
+
for (const word of command.split(/[\s<>|;&()]+/)) {
|
|
275
|
+
const base = programOf(word);
|
|
276
|
+
if (base === "")
|
|
277
|
+
continue;
|
|
278
|
+
if ((allow.has(base) && !NO_PATH_OPERAND_COMMANDS.has(base)) || SHELL_REENTRY_PROGRAM_RE.test(base))
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
for (const segment of command.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/)) {
|
|
282
|
+
for (const raw of segment.trim().split(/\s+/)) {
|
|
283
|
+
const word = fold(raw);
|
|
284
|
+
if (word === "")
|
|
285
|
+
continue;
|
|
286
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || SHELL_CONTROL_PREFIXES.has(word) || SHELL_BLOCK_WORDS.has(word) || word === "{" || word === "}" || COMMAND_LAUNCHERS.has(programOf(word)) || word.startsWith("-"))
|
|
287
|
+
continue;
|
|
288
|
+
if (/^[$`(]/.test(word))
|
|
289
|
+
return true;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function unwrapLauncher(folded, allow) {
|
|
296
|
+
for (let i = 1; i < folded.length; i++) {
|
|
297
|
+
const t = folded[i];
|
|
298
|
+
if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
|
|
299
|
+
continue;
|
|
300
|
+
if (t.startsWith("-"))
|
|
301
|
+
return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
|
|
302
|
+
const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
|
|
303
|
+
if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
|
|
304
|
+
return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
|
|
305
|
+
return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted" };
|
|
306
|
+
}
|
|
307
|
+
return { kind: "unlisted" };
|
|
231
308
|
}
|
|
232
309
|
function tokenizeSegment(segment) {
|
|
233
310
|
const raw = splitWordsQuoteAware(segment);
|
|
234
|
-
return { folded: raw.map(foldQuoteRemovalToken), raw };
|
|
311
|
+
return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
|
|
312
|
+
}
|
|
313
|
+
function literalTildeSpelling(word) {
|
|
314
|
+
return word.startsWith("~") ? `./${word}` : word;
|
|
315
|
+
}
|
|
316
|
+
function tildeIsLiteral(raw) {
|
|
317
|
+
if (!raw.startsWith("~"))
|
|
318
|
+
return true;
|
|
319
|
+
for (let i = 1; i < raw.length; i++) {
|
|
320
|
+
const ch = raw[i];
|
|
321
|
+
if (ch === '"' || ch === "'")
|
|
322
|
+
return true;
|
|
323
|
+
if (ch === "/")
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
235
327
|
}
|
|
236
328
|
function splitWordsQuoteAware(segment) {
|
|
237
329
|
const s = segment.trim();
|
|
@@ -575,11 +667,12 @@ function grepClusterValueOwner(tok) {
|
|
|
575
667
|
return undefined;
|
|
576
668
|
}
|
|
577
669
|
function attachedOptionPayloads(tok) {
|
|
670
|
+
const literal = literalTildeSpelling;
|
|
578
671
|
const out = [];
|
|
579
672
|
if (tok.startsWith("--")) {
|
|
580
673
|
const eq = tok.indexOf("=");
|
|
581
674
|
if (eq > 0 && eq + 1 < tok.length)
|
|
582
|
-
out.push(tok.slice(eq + 1));
|
|
675
|
+
out.push(literal(tok.slice(eq + 1)));
|
|
583
676
|
return out;
|
|
584
677
|
}
|
|
585
678
|
for (let k = 1; k <= tok.length; k++) {
|
|
@@ -587,7 +680,7 @@ function attachedOptionPayloads(tok) {
|
|
|
587
680
|
break;
|
|
588
681
|
const payload = tok.slice(k);
|
|
589
682
|
if (payload.length > 0)
|
|
590
|
-
out.push(payload);
|
|
683
|
+
out.push(literal(payload));
|
|
591
684
|
}
|
|
592
685
|
return out;
|
|
593
686
|
}
|
|
@@ -600,31 +693,35 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
600
693
|
const candidates = [];
|
|
601
694
|
const bareWordOperands = [];
|
|
602
695
|
const positionalOperands = [];
|
|
696
|
+
let stdinOperand = false;
|
|
603
697
|
const argGlobs = (k) => {
|
|
604
698
|
const raw = tokens.raw[k + 1];
|
|
605
699
|
return raw !== undefined && hasUnquotedGlobMetachar(raw);
|
|
606
700
|
};
|
|
607
|
-
|
|
701
|
+
const expandingArgs = new Set();
|
|
702
|
+
tokens.raw.slice(1).forEach((rawArg, k) => {
|
|
608
703
|
if (!hasUnquotedExpansionMetachar(rawArg))
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
},
|
|
615
|
-
|
|
616
|
-
}
|
|
704
|
+
return;
|
|
705
|
+
expandingArgs.add(k);
|
|
706
|
+
findings.push({
|
|
707
|
+
kind: "unresolvable",
|
|
708
|
+
operand: rawArg,
|
|
709
|
+
reason: `"${name}" is given the argument "${rawArg}", which the shell expands (brace/variable/command expansion) before the command runs — the path it would actually read cannot be resolved statically, so it is not auto-allowed`,
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
if (name === "cd" && expandingArgs.size > 0)
|
|
713
|
+
return findings;
|
|
617
714
|
if (name === "cd") {
|
|
618
715
|
const targetIdx = args.findIndex((t) => !t.startsWith("-") || t === "-");
|
|
619
716
|
const target = targetIdx === -1 ? undefined : args[targetIdx];
|
|
620
717
|
if (target !== undefined && argGlobs(targetIdx)) {
|
|
621
|
-
return [{ kind: "unresolvable", reason: `\`cd\` is given the pattern "${target}", which the shell expands to a directory this check cannot know ${NOT_AUTO_ALLOWED}` }];
|
|
718
|
+
return [{ kind: "unresolvable", operand: target, reason: `\`cd\` is given the pattern "${target}", which the shell expands to a directory this check cannot know ${NOT_AUTO_ALLOWED}` }];
|
|
622
719
|
}
|
|
623
720
|
if (target === undefined) {
|
|
624
|
-
return [{ kind: "unresolvable", reason: '`cd` with no argument targets the home directory, which cannot be checked against the allowed directories ' + NOT_AUTO_ALLOWED }];
|
|
721
|
+
return [{ kind: "unresolvable", operand: "cd", reason: '`cd` with no argument targets the home directory, which cannot be checked against the allowed directories ' + NOT_AUTO_ALLOWED }];
|
|
625
722
|
}
|
|
626
723
|
if (target === "-") {
|
|
627
|
-
return [{ kind: "unresolvable", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
|
|
724
|
+
return [{ kind: "unresolvable", operand: "-", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
|
|
628
725
|
}
|
|
629
726
|
candidates.push({ text: target, globbed: false });
|
|
630
727
|
}
|
|
@@ -635,6 +732,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
635
732
|
let endOfOptions = false;
|
|
636
733
|
for (let k = 0; k < args.length; k++) {
|
|
637
734
|
const t = args[k];
|
|
735
|
+
if (expandingArgs.has(k))
|
|
736
|
+
continue;
|
|
638
737
|
if (!endOfOptions) {
|
|
639
738
|
if (t === "--") {
|
|
640
739
|
endOfOptions = true;
|
|
@@ -662,8 +761,10 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
662
761
|
if (isGrepPatternSlot)
|
|
663
762
|
continue;
|
|
664
763
|
const m = RECURSIVE_READ_FORMS[name];
|
|
665
|
-
if (m === undefined || m.dashIsStdin === true)
|
|
764
|
+
if (m === undefined || m.dashIsStdin === true) {
|
|
765
|
+
stdinOperand = true;
|
|
666
766
|
continue;
|
|
767
|
+
}
|
|
667
768
|
positionalOperands.push(t);
|
|
668
769
|
continue;
|
|
669
770
|
}
|
|
@@ -677,7 +778,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
677
778
|
}
|
|
678
779
|
}
|
|
679
780
|
for (let k = 0; k < args.length; k++) {
|
|
680
|
-
if (!argGlobs(k))
|
|
781
|
+
if (!argGlobs(k) || expandingArgs.has(k))
|
|
681
782
|
continue;
|
|
682
783
|
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
|
|
683
784
|
findings.push({ kind: "undecided", path: resolved ?? args[k] });
|
|
@@ -687,6 +788,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
687
788
|
if (resolved === undefined) {
|
|
688
789
|
findings.push({
|
|
689
790
|
kind: "unresolvable",
|
|
791
|
+
operand: candidate,
|
|
690
792
|
reason: `"${name}" names the path "${candidate}", which cannot be resolved statically ${NOT_AUTO_ALLOWED}`,
|
|
691
793
|
});
|
|
692
794
|
continue;
|
|
@@ -706,7 +808,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
706
808
|
findings.push({ kind: "inside", path: resolved });
|
|
707
809
|
}
|
|
708
810
|
if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
|
|
709
|
-
const roots = positionalOperands.length > 0 ? positionalOperands : ["."];
|
|
811
|
+
const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
|
|
710
812
|
for (const operand of roots) {
|
|
711
813
|
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
|
|
712
814
|
findings.push({ kind: "recursive", path: resolved ?? operand });
|
|
@@ -714,8 +816,56 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
|
|
|
714
816
|
}
|
|
715
817
|
return findings;
|
|
716
818
|
}
|
|
717
|
-
function
|
|
718
|
-
|
|
819
|
+
export function foldHomeVariableSpelling(command, homeDir) {
|
|
820
|
+
if (homeDir === undefined || !command.includes("$") || /[\\`]/.test(command))
|
|
821
|
+
return command;
|
|
822
|
+
const unquotedIsExact = /^[A-Za-z0-9_./+@:%=,-]+$/.test(homeDir);
|
|
823
|
+
const quotedIsExact = !/["$`\\\n\r]/.test(homeDir);
|
|
824
|
+
const UNQUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/\s;|&"]|$)/;
|
|
825
|
+
const DOUBLE_QUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/"]|$)/;
|
|
826
|
+
let open;
|
|
827
|
+
let wordStart = true;
|
|
828
|
+
let quoteHadContent = false;
|
|
829
|
+
let out = "";
|
|
830
|
+
for (let i = 0; i < command.length; i++) {
|
|
831
|
+
const ch = command[i];
|
|
832
|
+
if (open === undefined && (ch === '"' || ch === "'")) {
|
|
833
|
+
open = ch;
|
|
834
|
+
quoteHadContent = false;
|
|
835
|
+
out += ch;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (open !== undefined && open === ch) {
|
|
839
|
+
open = undefined;
|
|
840
|
+
wordStart = wordStart && !quoteHadContent;
|
|
841
|
+
out += ch;
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (open !== undefined)
|
|
845
|
+
quoteHadContent = true;
|
|
846
|
+
if (open === undefined && /[\s;|&]/.test(ch)) {
|
|
847
|
+
wordStart = true;
|
|
848
|
+
out += ch;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (ch === "$" && wordStart && ((open === '"' && quotedIsExact) || (open === undefined && unquotedIsExact))) {
|
|
852
|
+
const m = (open === '"' ? DOUBLE_QUOTED_HOME_WORD : UNQUOTED_HOME_WORD).exec(command.slice(i));
|
|
853
|
+
if (m !== null) {
|
|
854
|
+
wordStart = false;
|
|
855
|
+
if (open === '"')
|
|
856
|
+
quoteHadContent = true;
|
|
857
|
+
out += homeDir;
|
|
858
|
+
i += m[0].length - 1;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
wordStart = false;
|
|
863
|
+
out += ch;
|
|
864
|
+
}
|
|
865
|
+
return open === undefined ? out : command;
|
|
866
|
+
}
|
|
867
|
+
function segmentCompoundForReadonly(command, homeDir) {
|
|
868
|
+
const trimmed = foldHomeVariableSpelling(command, homeDir).trim();
|
|
719
869
|
if (!trimmed)
|
|
720
870
|
return { reject: "empty command" };
|
|
721
871
|
const redirectionStripped = stripExemptRedirections(trimmed);
|
|
@@ -725,14 +875,53 @@ function segmentCompoundForReadonly(command) {
|
|
|
725
875
|
return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
|
|
726
876
|
}
|
|
727
877
|
export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
|
|
728
|
-
const split = segmentCompoundForReadonly(command);
|
|
878
|
+
const split = segmentCompoundForReadonly(command, boundary?.homeDir);
|
|
729
879
|
if ("reject" in split)
|
|
730
|
-
return { reason: split.reject };
|
|
880
|
+
return { reason: split.reject, refusedWhole: true };
|
|
731
881
|
const { segments, pipeFed } = split;
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
882
|
+
let nameRefusal;
|
|
883
|
+
const unlistedSegments = new Set();
|
|
884
|
+
const reheaded = new Map();
|
|
885
|
+
for (let si = 0; si < segments.length; si++) {
|
|
886
|
+
const toks = tokenizeSegment(segments[si]);
|
|
887
|
+
const folded = toks.folded;
|
|
888
|
+
let from = 0;
|
|
889
|
+
while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
|
|
890
|
+
from++;
|
|
891
|
+
const written = folded[from];
|
|
892
|
+
if (written === undefined)
|
|
893
|
+
return { reason: "empty command" };
|
|
894
|
+
if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
|
|
895
|
+
unlistedSegments.add(si);
|
|
896
|
+
nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
|
|
900
|
+
if ("reject" in parsed)
|
|
901
|
+
return { reason: parsed.reject, refusedWhole: true };
|
|
902
|
+
const pathPrefixed = parsed.name.includes("/");
|
|
903
|
+
const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
|
|
904
|
+
if (pathPrefixed)
|
|
905
|
+
nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
|
|
906
|
+
if (allow.has(name)) {
|
|
907
|
+
if (pathPrefixed || from > 0)
|
|
908
|
+
reheaded.set(si, { from, head: name });
|
|
909
|
+
if (from > 0)
|
|
910
|
+
nameRefusal ??= `command "${folded[0]}" is not in the read-only allowlist`;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
|
|
914
|
+
if (COMMAND_LAUNCHERS.has(name)) {
|
|
915
|
+
const unwrapped = unwrapLauncher(folded.slice(from), allow);
|
|
916
|
+
if (unwrapped.kind === "refused")
|
|
917
|
+
return { reason: unwrapped.reason, refusedWhole: true };
|
|
918
|
+
if (unwrapped.kind === "unlisted")
|
|
919
|
+
unlistedSegments.add(si);
|
|
920
|
+
else
|
|
921
|
+
reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
unlistedSegments.add(si);
|
|
736
925
|
}
|
|
737
926
|
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
|
|
738
927
|
const headBoundIsSmall = (name, toks) => {
|
|
@@ -764,16 +953,22 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
764
953
|
};
|
|
765
954
|
const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
|
|
766
955
|
const foldedSegments = [];
|
|
956
|
+
let shapeRefusal = nameRefusal;
|
|
767
957
|
for (let si = 0; si < segments.length; si++) {
|
|
768
|
-
|
|
958
|
+
if (unlistedSegments.has(si))
|
|
959
|
+
continue;
|
|
960
|
+
const rehead = reheaded.get(si);
|
|
961
|
+
const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
|
|
769
962
|
const toks = segmentTokens.folded;
|
|
770
963
|
if (toks.length === 0)
|
|
771
964
|
continue;
|
|
772
965
|
foldedSegments.push(segmentTokens);
|
|
773
966
|
const name = toks[0];
|
|
774
967
|
const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
|
|
775
|
-
if (guardReason !== undefined)
|
|
776
|
-
|
|
968
|
+
if (guardReason !== undefined) {
|
|
969
|
+
shapeRefusal ??= guardReason;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
777
972
|
if (!(pipeFed[si] ?? false)) {
|
|
778
973
|
const floor = STDIN_FILE_FLOOR[name];
|
|
779
974
|
const restArgs = toks.slice(1);
|
|
@@ -815,14 +1010,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
815
1010
|
}
|
|
816
1011
|
}
|
|
817
1012
|
if (floor !== undefined && hasStdinDash) {
|
|
818
|
-
|
|
1013
|
+
shapeRefusal ??= `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
|
|
1014
|
+
continue;
|
|
819
1015
|
}
|
|
820
1016
|
if (floor !== undefined && operandCount < floor) {
|
|
821
|
-
|
|
1017
|
+
shapeRefusal ??= `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
|
|
1018
|
+
continue;
|
|
822
1019
|
}
|
|
823
1020
|
}
|
|
824
1021
|
if (name === "tail" && toks.slice(1).some((t) => t === "--follow" || t.startsWith("--follow=") || /^[-+][^\s]*[fF]/.test(t))) {
|
|
825
|
-
|
|
1022
|
+
shapeRefusal ??= "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED;
|
|
1023
|
+
continue;
|
|
826
1024
|
}
|
|
827
1025
|
const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
|
|
828
1026
|
const deviceCandidates = [];
|
|
@@ -833,20 +1031,26 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
833
1031
|
}
|
|
834
1032
|
deviceCandidates.push(t);
|
|
835
1033
|
}
|
|
836
|
-
const deviceArgs = deviceCandidates.map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
|
|
1034
|
+
const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
|
|
837
1035
|
const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
838
1036
|
if (!rescuedByHead && deviceArgs.length > 0) {
|
|
839
|
-
|
|
1037
|
+
shapeRefusal ??= "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;
|
|
840
1038
|
}
|
|
841
1039
|
}
|
|
842
1040
|
if (opts?.iterated !== true) {
|
|
843
1041
|
const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
|
|
844
|
-
if (cdSegments.length > 1)
|
|
845
|
-
|
|
846
|
-
}
|
|
1042
|
+
if (cdSegments.length > 1)
|
|
1043
|
+
shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
|
|
847
1044
|
}
|
|
848
|
-
if (boundary !== undefined)
|
|
849
|
-
|
|
1045
|
+
if (boundary !== undefined) {
|
|
1046
|
+
const walk = evaluateReadBoundary(foldedSegments, boundary);
|
|
1047
|
+
if (shapeRefusal === undefined)
|
|
1048
|
+
return walk;
|
|
1049
|
+
const { reason: _walkSentence, ...structure } = walk;
|
|
1050
|
+
return { ...structure, reason: shapeRefusal };
|
|
1051
|
+
}
|
|
1052
|
+
if (shapeRefusal !== undefined)
|
|
1053
|
+
return { reason: shapeRefusal };
|
|
850
1054
|
if (foldedSegments.some((st) => st.folded[0] === "cd")) {
|
|
851
1055
|
return { reason: `\`cd\` cannot be judged without a read boundary (no base to resolve the directory change against) ${NOT_AUTO_ALLOWED}` };
|
|
852
1056
|
}
|
|
@@ -855,14 +1059,25 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
855
1059
|
function evaluateReadBoundary(foldedSegments, boundary) {
|
|
856
1060
|
const outside = [];
|
|
857
1061
|
const inside = [];
|
|
1062
|
+
let unresolvable;
|
|
1063
|
+
const unresolved = [];
|
|
1064
|
+
let baseLost = false;
|
|
858
1065
|
const undecided = [];
|
|
859
1066
|
const recursive = [];
|
|
860
1067
|
let currentBase = boundary.cwd ?? boundary.roots[0];
|
|
861
1068
|
for (const toks of foldedSegments) {
|
|
1069
|
+
if (baseLost)
|
|
1070
|
+
break;
|
|
862
1071
|
const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
|
|
863
1072
|
for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
|
|
864
|
-
if (finding.kind === "unresolvable")
|
|
865
|
-
|
|
1073
|
+
if (finding.kind === "unresolvable") {
|
|
1074
|
+
unresolvable ??= finding.reason;
|
|
1075
|
+
if (!unresolved.includes(finding.operand))
|
|
1076
|
+
unresolved.push(finding.operand);
|
|
1077
|
+
if (toks.folded[0] === "cd")
|
|
1078
|
+
baseLost = true;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
866
1081
|
const denied = boundary.denyMatch?.(finding.path);
|
|
867
1082
|
if (denied != null) {
|
|
868
1083
|
return {
|
|
@@ -893,7 +1108,7 @@ function evaluateReadBoundary(foldedSegments, boundary) {
|
|
|
893
1108
|
if (!outside.some((o) => o.path === finding.path))
|
|
894
1109
|
outside.push(finding);
|
|
895
1110
|
}
|
|
896
|
-
if (toks.folded[0] === "cd") {
|
|
1111
|
+
if (!baseLost && toks.folded[0] === "cd") {
|
|
897
1112
|
const cdArgs = toks.folded.slice(1);
|
|
898
1113
|
const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
|
|
899
1114
|
const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
|
|
@@ -907,6 +1122,15 @@ function evaluateReadBoundary(foldedSegments, boundary) {
|
|
|
907
1122
|
...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
|
|
908
1123
|
...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
|
|
909
1124
|
};
|
|
1125
|
+
if (unresolvable !== undefined) {
|
|
1126
|
+
return {
|
|
1127
|
+
reason: unresolvable,
|
|
1128
|
+
unresolvedOperands: unresolved,
|
|
1129
|
+
...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path) } : {}),
|
|
1130
|
+
...(inside.length > 0 ? { checkedPaths: inside } : {}),
|
|
1131
|
+
...undecidedField,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
910
1134
|
if (outside.length === 0)
|
|
911
1135
|
return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
|
|
912
1136
|
const paths = outside.map((o) => `"${o.path}"`).join(", ");
|
|
@@ -948,7 +1172,7 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
|
|
|
948
1172
|
...(boundary.face !== undefined ? { face: boundary.face } : {}),
|
|
949
1173
|
};
|
|
950
1174
|
const judge = (text) => {
|
|
951
|
-
const split = segmentCompoundForReadonly(text);
|
|
1175
|
+
const split = segmentCompoundForReadonly(text, containmentOnly.homeDir);
|
|
952
1176
|
if ("reject" in split)
|
|
953
1177
|
return NOT_GATED;
|
|
954
1178
|
const readers = [];
|
|
@@ -1028,69 +1252,69 @@ function pollLoopSleepReason(segment) {
|
|
|
1028
1252
|
return undefined;
|
|
1029
1253
|
}
|
|
1030
1254
|
export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
|
|
1255
|
+
return classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary).reason;
|
|
1256
|
+
}
|
|
1257
|
+
export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary) {
|
|
1031
1258
|
if (NON_ASCII_WHITESPACE.test(command)) {
|
|
1032
|
-
return "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";
|
|
1259
|
+
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" };
|
|
1033
1260
|
}
|
|
1034
|
-
const trimmed = command.trim();
|
|
1261
|
+
const trimmed = foldHomeVariableSpelling(command, boundary?.homeDir).trim();
|
|
1035
1262
|
if (!trimmed)
|
|
1036
|
-
return "empty command";
|
|
1263
|
+
return { reason: "empty command" };
|
|
1037
1264
|
if (!pollLoopQuotesBalanced(trimmed)) {
|
|
1038
|
-
return "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed";
|
|
1265
|
+
return { reason: "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed" };
|
|
1039
1266
|
}
|
|
1040
1267
|
const m = POLL_LOOP_SHAPE.exec(trimmed);
|
|
1041
1268
|
if (m === null) {
|
|
1042
|
-
return "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)";
|
|
1269
|
+
return { reason: "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)" };
|
|
1043
1270
|
}
|
|
1044
1271
|
const varName = m[1];
|
|
1045
1272
|
const iterable = m[2];
|
|
1046
1273
|
const body = m[3];
|
|
1047
1274
|
if (!/^[a-z]$/.test(varName)) {
|
|
1048
|
-
return `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
|
|
1275
|
+
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` };
|
|
1049
1276
|
}
|
|
1050
1277
|
const beats = pollLoopBeatsOf(iterable);
|
|
1051
1278
|
if (beats === undefined) {
|
|
1052
|
-
return `the loop bound "${iterable}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted
|
|
1279
|
+
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` };
|
|
1053
1280
|
}
|
|
1054
1281
|
if (beats > POLL_LOOP_MAX_BEATS) {
|
|
1055
|
-
return `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow
|
|
1282
|
+
return { reason: `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow` };
|
|
1056
1283
|
}
|
|
1057
1284
|
if (POLL_BODY_HARD_REJECT.test(body)) {
|
|
1058
|
-
return "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks";
|
|
1285
|
+
return { reason: "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks" };
|
|
1059
1286
|
}
|
|
1060
1287
|
const readSegments = [];
|
|
1061
1288
|
for (const rawSegment of body.split(";")) {
|
|
1062
1289
|
const segment = rawSegment.trim();
|
|
1063
1290
|
if (segment.length === 0)
|
|
1064
|
-
return "empty command in the loop body";
|
|
1291
|
+
return { reason: "empty command in the loop body" };
|
|
1065
1292
|
const parsed = parseLeadingCommandName(segment);
|
|
1066
1293
|
if ("reject" in parsed)
|
|
1067
|
-
return parsed.reject;
|
|
1294
|
+
return { reason: parsed.reject };
|
|
1068
1295
|
if (parsed.name === "sleep") {
|
|
1069
1296
|
const sleepReason = pollLoopSleepReason(segment);
|
|
1070
1297
|
if (sleepReason !== undefined)
|
|
1071
|
-
return sleepReason;
|
|
1298
|
+
return { reason: sleepReason };
|
|
1072
1299
|
continue;
|
|
1073
1300
|
}
|
|
1074
1301
|
readSegments.push(segment);
|
|
1075
1302
|
}
|
|
1076
1303
|
if (readSegments.length === 0) {
|
|
1077
|
-
return "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed";
|
|
1304
|
+
return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
|
|
1078
1305
|
}
|
|
1079
|
-
const bodyHasCd = readSegments.some((seg) =>
|
|
1080
|
-
const p = parseLeadingCommandName(seg);
|
|
1081
|
-
return "name" in p && p.name === "cd";
|
|
1082
|
-
});
|
|
1306
|
+
const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
|
|
1083
1307
|
const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
|
|
1084
1308
|
const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
|
|
1085
1309
|
if (verdict.reason !== undefined)
|
|
1086
|
-
return verdict
|
|
1310
|
+
return verdict;
|
|
1087
1311
|
if (verdict.recursiveReadPaths !== undefined) {
|
|
1088
1312
|
const roots = verdict.recursiveReadPaths;
|
|
1089
1313
|
const shown = roots.slice(0, POLL_LOOP_CAUSE_MAX_PATHS);
|
|
1090
|
-
return `this lexical check cannot bound what a recursive read form in the loop body traverses — ${roots.length} path(s) taken as traversal roots: ${shown.join(", ")}${roots.length > shown.length ? `, and ${roots.length - shown.length} more` : ""}
|
|
1314
|
+
return { ...verdict, reason: `this lexical check cannot bound what a recursive read form in the loop body traverses — ${roots.length} path(s) taken as traversal roots: ${shown.join(", ")}${roots.length > shown.length ? `, and ${roots.length - shown.length} more` : ""}` };
|
|
1091
1315
|
}
|
|
1092
1316
|
if (verdict.undecidedPaths !== undefined) {
|
|
1093
|
-
return `the loop body carries an unexpanded glob (${verdict.undecidedPaths.join(", ")}) — what a REPEATED read touches is decided at run time, so it is not auto-allowed
|
|
1317
|
+
return { ...verdict, reason: `the loop body carries an unexpanded glob (${verdict.undecidedPaths.join(", ")}) — what a REPEATED read touches is decided at run time, so it is not auto-allowed` };
|
|
1094
1318
|
}
|
|
1095
|
-
return
|
|
1319
|
+
return verdict;
|
|
1096
1320
|
}
|
|
@@ -73,24 +73,28 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
|
|
|
73
73
|
* · the boundary DECLARED an objection — a deny-listed operand, a recursive walk under the deny judge, an
|
|
74
74
|
* operand outside the roots — ⇒ `{ reversible: false, mandated: true }` (+ the recursive-reach `cause`);
|
|
75
75
|
* · the boundary could NOT READ where the command reads — an unexpanded glob operand (the compound face
|
|
76
|
-
* reports it as undecided), a `~`-rooted operand
|
|
77
|
-
* cannot model — ⇒ `{ reversible: false }` (fail-closed; a plain ask, which a stored allow rule
|
|
78
|
-
* the seat did not declare anything, it declined to guess)
|
|
76
|
+
* reports it as undecided), a `~`-rooted operand under an adapter that declares no home, a working-directory
|
|
77
|
+
* move the walk cannot model — ⇒ `{ reversible: false }` (fail-closed; a plain ask, which a stored allow rule
|
|
78
|
+
* may retire — the seat did not declare anything, it declined to guess). The engine wires the environment's
|
|
79
|
+
* declared home, so `~/x`, `$HOME/x` and `${HOME}/x` RESOLVE and are judged like the absolute spelling;
|
|
79
80
|
* · otherwise ⇒ `{ reversible: true }`. This includes every command whose SHAPE the read faces refuse whole
|
|
80
81
|
* (an unlisted program, a redirection, a substitution, a segment that is not a reader): those shapes carry
|
|
81
82
|
* no read-face verdict, so this seat has no question to ask — the residual is the doctrine's, and under
|
|
82
|
-
* `"off"` the doctrine asks nothing.
|
|
83
|
-
*
|
|
83
|
+
* `"off"` the doctrine asks nothing. A listed reader refused for a PER-SEGMENT shape (follow mode, a device,
|
|
84
|
+
* a blocking stdin read) is different: the compound face still walks its operands and carries the boundary's
|
|
85
|
+
* structural findings beside the sentence, so `tail -f <denied>` is the declared ask `cat <denied>` is.
|
|
86
|
+
* `run_in_background` is likewise not this seat's question (it changes WHEN the command runs, never WHERE it
|
|
87
|
+
* reads).
|
|
84
88
|
*
|
|
85
89
|
* Reach = the read faces' reach, and nothing wider: the compound face walks the operands of a command whose
|
|
86
90
|
* every segment is a listed reader, and the bounded poll-loop face walks a loop body of listed readers. A
|
|
87
|
-
* read hidden in a shape the faces refuse whole (`cat x | <unlisted>`, `$(cat x)`, `cat $DIR/x
|
|
88
|
-
* `env cat x`, a path-prefixed `/bin/cat x`) is not
|
|
91
|
+
* read hidden in a shape the faces refuse whole (`cat x | <unlisted>`, `$(cat x)`, `cat $DIR/x` — a variable other
|
|
92
|
+
* than the home's own deterministic spellings — a launcher `env cat x`, a path-prefixed `/bin/cat x`) is not
|
|
93
|
+
* seen here — deny-listed or out-of-root alike — exactly
|
|
89
94
|
* the shapes the `"classify"` doctrine exists to ask about. The text-level containment gate the classify
|
|
90
95
|
* seat consults on its demotion arms is deliberately not consulted here ({@link boundaryDeclared}: it may
|
|
91
|
-
* add a mandate to an ask, never originate one). The poll-loop face
|
|
92
|
-
*
|
|
93
|
-
* ({@link pollLoopBoundaryVerdict}).
|
|
96
|
+
* add a mandate to an ask, never originate one). The poll-loop face's structured verdict
|
|
97
|
+
* ({@link classifyBoundedReadonlyPollLoopDetailed}) is read the same three ways as the compound face's.
|
|
94
98
|
*
|
|
95
99
|
* The reader set is the engine's classify set ({@link BASH_CLASSIFY_DEFAULT_ALLOW}) — here it is NOT an
|
|
96
100
|
* auto-allow list but the set of programs whose operands the faces read; a wider set means more reads are
|