@sema-agent/core 7.49.3 → 7.49.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 7.49.5 — 2026-10-13
2
+
3
+ **Hard-breaking changes (#1701):** none. **Closed sets:** none. **Package exports:** +0 / −0 names (`posixModeBitsMeaningful` lives in `src/core/posix-mode-face.ts` and is not on the package index); member changes, all optional: `vetPeerRegistryDirectory(dir, { platform? })`, `judgePeerRecordFile(stat, selfUid, platform?)`, `new FileDurableRulePartitionProvider(dir, onError, { platform? })`. **Behavior:** on Windows the rule store and the peer registry stop reading the platform's `0o666` as "writable by group or others" — a loosening on exactly one platform, and the one that restores the store's purpose there (open to veto: it departs from "refuse what you cannot prove safe" for that platform); POSIX hosts are unchanged.
4
+
5
+ ### A platform with no POSIX permission bits is not "writable by everyone" (#1701; fix; security fail-open on Windows; hard-breaking: none, open to veto; exports: +0 / −0) (@test @server @cli @client-core)
6
+
7
+ - **Before (7.49.4 and earlier).** The permission-rule store refused its whole directory when `(mode & 0o022) !== 0` — "writable by group or others" — and loaded ZERO rules. Windows has no POSIX permission bits: Node reports `0o666` for every file and directory there (a directory created with mode `0o700` too), so on a Windows host the check fired on every run. A persisted `deny` / `ask` row never applied (the engine disclosed "loading zero rules" and went on), no `allow` row was read either, the write face refused every write (an "always allow" could not be saved), and the org state file in the same directory was refused with it. The peer registry's directory chain (`vetPeerRegistryDirectory`) and a registration file (`judgePeerRecordFile`) judged the same bits and refused with `group_or_world_writable` on every Windows path. A POSIX host loaded normally.
8
+ - **Now.** ONE predicate, `posixModeBitsMeaningful(platform = process.platform)` (`src/core/posix-mode-face.ts`: `platform !== "win32"`), is read by every site that tests those bits: the store's directory vetting (bucket read, org state read, the write face's lock, the adoption marker writer), the peer registry's chain walk and its file judge, and the execution env's file-mode report (`HAS_POSIX_MODE_FACE`, which already asked this question for itself and now reads the same predicate — no second spelling of the fact). Where the bits are not meaningful the group/other-writable judgment is not made. The owner judgment is unchanged (it was already skipped where `process.getuid` is absent); symlink, not-a-directory, unreadable, checksum and shape refusals are unchanged. On a platform with bits (macOS, Linux) nothing changes: a `0o777` directory is still refused whole. The store says it ONCE per store instance through its existing error disclosure (`onError`): the directory is not checked for being writable by other accounts, this platform reports no POSIX permission bits, so who else can write there is not verified here. The peer registry's two functions are pure verdicts with no disclosure seat; they only skip. There is no ACL-level check (KL-1701-a).
9
+ - **Who needs it.** Every Windows host that persists permission rules (a `deny` / `ask` row, an org snapshot, a rule imported through the consent flow) — until now none of them took part in a decision there.
10
+ - **Who is hurt.** None by design. What changes for a Windows host: persisted rows now take part — `allow` rows silence the asks they were approved to silence, `deny` / `ask` rows apply, org state loads — which is the contract on every other platform. A host that was (unknowingly) running with zero persisted rules will see asks and refusals appear and approved commands stop asking. A directory another Windows account can write to is no longer refused by this check (KL-1701-a).
11
+ - **Compensation.** Nothing to do on POSIX. A Windows deployment that wants the old refusal on a shared machine has no switch for it (the old refusal was this bug, not a policy); restrict the rule directory's ACL to the engine's account.
12
+ - **Consumers.** server / cli / client-core on Windows: the `onError` sink of `FileDurableRulePartitionProvider` now receives one extra sentence per provider instance on win32 (it contains the directory); a host that treats every `onError` line as an error should read this one as a notice. Nothing else on the wire or the card changes.
13
+ - Pins: `test/backlog1701-win32-mode-bits.test.ts` (① the predicate; ② the rule store on a REAL `0o777` directory with the platform injected — win32 loads the deny row, says it once however many reads, says it again per instance, still refuses a symlink / corrupt bucket, the write face and the org read follow it; darwin / linux still refuse whole with no skip notice; a `0o700` darwin directory loads with no notice; a throwing sink changes nothing; ③ the same deny row on the real Runner — win32 refuses the command, a `0o777` darwin directory is the pre-fix reading and runs it; ④ the peer registry — the file judge and the chain walk with a `0o777` ancestor, the other arms unchanged; ⑤ no source file outside the predicate tests those bits without importing it, and the execution env's mode face flips with the platform its module loads under).
14
+
15
+ ## 7.49.4 — 2026-10-13
16
+
17
+ **Rebuild of 7.49.3 — no source change.** The npm tarball of 7.49.3 (shasum `77a7fd37…`, now deprecated) carried the `dist/` of 7.49.2: it was published with `--ignore-scripts` from a checkout whose `dist/` had not been rebuilt (the release-candidate build and gates had run on a detached gate copy). None of the four 7.49.3 entries below (#1685, #1691, #1692, #1693) were in that package. 7.49.4 is the same source as tag `v7.49.3` built in the publishing checkout and compared byte-for-byte with the gated dist before publish; every 7.49.3 entry, criterion (`docs/release-criteria/7.49.3.json`) and wire manifest applies to 7.49.4. **Hard-breaking changes:** none. **Closed sets:** none. **Package exports:** +0 / −0 (`npm run handoff:diff v7.49.3 HEAD`). Consumers pinned to 7.49.3 must move to 7.49.4 (@server @cli @web-admin @client-core @test).
18
+
1
19
  ## 7.49.3 — 2026-10-13
2
20
 
3
21
  **Hard-breaking changes (#1685, #1693, #1691, #1692):** none. **Closed sets:** none (`READ_CANDIDATE_PROVENANCES`, `SHELL_READ_FINDINGS`, `ASK_CLASSES`, `PERSISTED_RULE_MANDATES` unchanged). **Package exports:** +0 / −0 names (`npm run handoff:diff v7.49.2 HEAD`: 2 609 → 2 609); member changes, all optional: `ReadCandidate.device` / `through`, and the plan's redirection record `fd` (`ShellReadSegment.redirects[]`). **Behavior:** #1685 makes the reference's device table a non-question at every entry of the shell read station and of the shape tier — a loosening for the standard streams (open to veto: it departs from the reference at the output-redirection, read-operand and write-operand entries), and a one-line tightening for `touch` / `mkdir` of the null device; #1693 makes an environment non-answer a bypass-immune ask — a tightening that restores what 7.49.2's own documentation promised; #1691 removes a value that was left in clear after a bearer token (a name=value glued to the token by `:` `,` `@` `!` `;` `|` `&`) and hides the character that joins it; #1692 gives the overlapping re-scan's work allowance a floor (2^17 characters of matches, binding below 16 KB of input only), so a short text with a dense line is read in full instead of losing every line behind it — a scan that still runs out of work masks to the end of the input exactly as in 7.49.2 — redaction only, nothing a run could do before is refused. **Known limits added / closed** (`docs/KNOWN-LIMITS.md`): KL-1644-a closed; KL-1685-b / -c / -d / -e / -f added; KL-958-f, KL-958-g, KL-958-h added (KL-958-d and KL-958-e restated).
@@ -216,20 +216,25 @@ export type PeerRegistryDirectoryVerdict = {
216
216
  path: string;
217
217
  };
218
218
  /** Vet a registry directory and every ancestor. `uid` defaults to the process uid; on a platform with
219
- * no uid (win32) the owner arm is skipped. Refuses on the FIRST failing ancestor (closest first). */
219
+ * no uid (win32) the owner arm is skipped. Refuses on the FIRST failing ancestor (closest first).
220
+ * `platform` (default: this process's) decides whether the permission bits are judged at all
221
+ * ({@link posixModeBitsMeaningful}): where they are not meaningful the group/world-writable arm is skipped,
222
+ * and nothing in this verdict says so — the caller that reads it owns any disclosure. */
220
223
  export declare function vetPeerRegistryDirectory(dir: string, opts?: {
221
224
  uid?: number;
222
225
  stopAt?: string;
226
+ platform?: NodeJS.Platform;
223
227
  }): Promise<PeerRegistryDirectoryVerdict>;
224
228
  /** §2.1 ③ — the read-side judgment on ONE registration file's stat facts (pure; the reader supplies
225
229
  * the stat): owner must be this uid, mode must carry no group/world write bit, and it must be a
226
- * regular file under the read cap. A refused file is skipped LOUDLY by the reader. */
230
+ * regular file under the read cap. A refused file is skipped LOUDLY by the reader. The mode arm is judged
231
+ * only where `platform` (default: this process's) has permission bits to judge ({@link posixModeBitsMeaningful}). */
227
232
  export declare function judgePeerRecordFile(stat: {
228
233
  uid?: number;
229
234
  mode: number;
230
235
  isFile: boolean;
231
236
  size: number;
232
- }, selfUid: number | undefined): {
237
+ }, selfUid: number | undefined, platform?: NodeJS.Platform): {
233
238
  ok: true;
234
239
  } | {
235
240
  ok: false;
@@ -2,6 +2,7 @@ import { lstat } from "node:fs/promises";
2
2
  import { isAbsolute, dirname, resolve as resolvePath } from "node:path";
3
3
  import { formatPeerNameRef, mintPeerRef, normalizePeerName, parsePeerNameRef, PEER_REF_RE } from "./cross-session-ref.js";
4
4
  import { inlineUntrusted } from "../core/untrusted-text.js";
5
+ import { posixModeBitsMeaningful } from "../core/posix-mode-face.js";
5
6
  export const PEER_SESSION_RECORD_SCHEMA_VERSION = 1;
6
7
  export const PEER_SESSION_RECORD_MAX_BYTES = 262_144;
7
8
  export const PEER_PRESENCE_MAX_CHARS = 120;
@@ -252,6 +253,7 @@ export async function vetPeerRegistryDirectory(dir, opts) {
252
253
  if (!isAbsolute(dir))
253
254
  return { ok: false, code: "not_absolute", path: dir };
254
255
  const uid = opts?.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
256
+ const judgeModeBits = posixModeBitsMeaningful(opts?.platform);
255
257
  const stopAt = opts?.stopAt !== undefined ? resolvePath(opts.stopAt) : undefined;
256
258
  let cursor = resolvePath(dir);
257
259
  for (;;) {
@@ -271,7 +273,7 @@ export async function vetPeerRegistryDirectory(dir, opts) {
271
273
  if (!isRoot) {
272
274
  if (uid !== undefined && st.uid !== uid)
273
275
  return { ok: false, code: "foreign_owner", path: cursor };
274
- if ((st.mode & 0o022) !== 0)
276
+ if (judgeModeBits && (st.mode & 0o022) !== 0)
275
277
  return { ok: false, code: "group_or_world_writable", path: cursor };
276
278
  }
277
279
  if (isRoot)
@@ -279,12 +281,12 @@ export async function vetPeerRegistryDirectory(dir, opts) {
279
281
  cursor = parent;
280
282
  }
281
283
  }
282
- export function judgePeerRecordFile(stat, selfUid) {
284
+ export function judgePeerRecordFile(stat, selfUid, platform) {
283
285
  if (!stat.isFile)
284
286
  return { ok: false, code: "not_regular" };
285
287
  if (selfUid !== undefined && stat.uid !== undefined && stat.uid !== selfUid)
286
288
  return { ok: false, code: "foreign_owner" };
287
- if ((stat.mode & 0o022) !== 0)
289
+ if (posixModeBitsMeaningful(platform) && (stat.mode & 0o022) !== 0)
288
290
  return { ok: false, code: "group_or_world_writable" };
289
291
  if (stat.size > PEER_SESSION_RECORD_MAX_BYTES)
290
292
  return { ok: false, code: "too_large" };
@@ -87,12 +87,13 @@ function removedRegion(pass, match, groups, inserted) {
87
87
  return [head, match.length - tail];
88
88
  }
89
89
  const SOLO_SCAN_WORK_PER_CHAR = 8;
90
+ const SOLO_SCAN_WORK_FLOOR = 1 << 17;
90
91
  function soloHits(input, passes) {
91
92
  const hits = [];
92
93
  passes.forEach((pass, i) => {
93
94
  const re = new RegExp(pass.re.source, pass.re.flags.includes("g") ? pass.re.flags : `${pass.re.flags}g`);
94
95
  const rescans = pass.family !== undefined;
95
- let work = SOLO_SCAN_WORK_PER_CHAR * input.length;
96
+ let work = Math.max(SOLO_SCAN_WORK_PER_CHAR * input.length, SOLO_SCAN_WORK_FLOOR);
96
97
  for (let m = re.exec(input); m !== null; m = re.exec(input)) {
97
98
  const at = m.index;
98
99
  const familyAt = () => (typeof pass.family === "function" ? pass.family(input, at) : pass.family);
@@ -16,7 +16,7 @@ const QUOTED_VALUE = [
16
16
  ...ESCAPED_QUOTES.map((q, i) => pairedValue(`(${BACKSLASHES})${q}`, `(?<!\\\\)(?:\\${i + 2})${q}`)),
17
17
  `${QUOTE}(?!${AFTER_CLOSING_QUOTE})${GLUED_RUN}`,
18
18
  ].join("|");
19
- const BEARER_TAIL = `(?<==)(?:(?!${QUOTE})${GLUED_RUN}|${QUOTED_VALUE})`;
19
+ const BEARER_TAIL = `(?:(?!${QUOTE})\\S${GLUED_RUN}|(?<==)(?:${QUOTED_VALUE}))`;
20
20
  export const CREDENTIAL_VOCABULARY = {
21
21
  values: [
22
22
  { id: "github-token", kind: "prefixed-token", source: "arg-summary", confidence: "high", pass: "vendor-prefixed", match: new RegExp(`(?<![A-Za-z0-9])gh[opsur][-_]${VENDOR_BODY}`, "g"), strict: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/ },
@@ -208,9 +208,13 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
208
208
  case "mandated": {
209
209
  if (verdict.details !== undefined)
210
210
  pass.shellReadDetails = verdict.details;
211
+ const unreadable = verdict.finding === "unanswered";
211
212
  if (pass.decision.action !== "allow") {
212
- if (pass.decision.action === "ask")
213
- pass.decision = { ...pass.decision, probeMandated: true };
213
+ if (pass.decision.action === "ask") {
214
+ pass.decision = !unreadable
215
+ ? { ...pass.decision, probeMandated: true }
216
+ : { ...pass.decision, requiresRealApproval: true, ...(pass.decision.guard === undefined ? { guard: "unverifiable_shell_text" } : { foldedAskClasses: [...new Set([...(pass.decision.foldedAskClasses ?? []), "unverifiable_shell_text"])] }) };
217
+ }
214
218
  return;
215
219
  }
216
220
  const candidate = normalizeReadRootCandidate(verdict.readRootCandidate);
@@ -218,7 +222,7 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
218
222
  action: "ask",
219
223
  message: shellReadAskMessage(toolName, verdict),
220
224
  decisionReason: "safety",
221
- probeMandated: true,
225
+ ...(unreadable ? { requiresRealApproval: true, guard: "unverifiable_shell_text" } : { probeMandated: true }),
222
226
  ...(account !== undefined ? { probeReason: account } : {}),
223
227
  ...(candidate !== undefined ? { probeReadRootCandidate: candidate } : {}),
224
228
  };
@@ -9,7 +9,7 @@ import { heldValues } from "./tool-name-readings.js";
9
9
  import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
10
10
  import { bindPathRules, judgeRuleRows, rowsWhereTheyLead } from "./path-rule-binding.js";
11
11
  import { readShellCommand } from "./shell-lexer.js";
12
- import { readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
12
+ import { lineDescriptorsOfText, readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
13
13
  export const RULE_BEHAVIORS = Object.freeze(["deny", "ask", "allow"]);
14
14
  const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
15
15
  export function isRuleBehavior(v) {
@@ -951,7 +951,7 @@ export function findAdmittingRule(rules, call) {
951
951
  const dir = cdDirs[i];
952
952
  const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
953
953
  if (first === undefined) {
954
- if (readsOnlyAsSegment(segment, lineObjection))
954
+ if (readsOnlyAsSegment(segment, lineObjection, call.command))
955
955
  continue;
956
956
  return undefined;
957
957
  }
@@ -960,8 +960,8 @@ export function findAdmittingRule(rules, call) {
960
960
  }
961
961
  return covering.length > 0 ? covering : undefined;
962
962
  }
963
- function readsOnlyAsSegment(segment, lineObjection) {
964
- return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment).readOnly;
963
+ function readsOnlyAsSegment(segment, lineObjection, whole) {
964
+ return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment, undefined, undefined, () => lineDescriptorsOfText(whole)).readOnly;
965
965
  }
966
966
  function firstEligibleDirectoryRule(rules, directory, call) {
967
967
  for (const rule of rules) {
@@ -1067,7 +1067,7 @@ export function segmentCoverageOf(command, rules, call) {
1067
1067
  const dir = cdDirs[i];
1068
1068
  return {
1069
1069
  segment: segment.trim(),
1070
- covered: readsOnlyAsSegment(segment, lineObjection) ||
1070
+ covered: readsOnlyAsSegment(segment, lineObjection, folded) ||
1071
1071
  firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
1072
1072
  (dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
1073
1073
  proposed.some((p) => p.rule.tool === "Read"
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Does this platform report POSIX permission bits worth judging? Windows' `Stats.mode` is a projection of
3
+ * the read-only attribute (`0o666` / `0o444`; a directory created with mode `0o700` reads `0o666` too), not
4
+ * permission bits — so a group/world-writable test over it fires on every file and says nothing about who may
5
+ * write. Every reader of `mode & 0o022` (and the file-mode report of the execution env) asks THIS predicate,
6
+ * so a platform never judges a bit it cannot have.
7
+ *
8
+ * `platform` is a parameter so a caller can ask about the platform it was told, not only the one it runs on.
9
+ */
10
+ export declare function posixModeBitsMeaningful(platform?: NodeJS.Platform): boolean;
@@ -0,0 +1,3 @@
1
+ export function posixModeBitsMeaningful(platform = process.platform) {
2
+ return platform !== "win32";
3
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ShellCommandShape } from "./shell-lexer.js";
2
+ import { type LineDescriptors } from "../tools/fs/shell-read-device.js";
2
3
  /** The reader's answer: read-only, or not — with the first reason the reading stopped on (a sentence a
3
4
  * trace or a card can show; never adjudication input). */
4
5
  export type ReadOnlyShellVerdict = {
@@ -32,7 +33,7 @@ export declare function changesDirectoryBeforeGit(shape: ShellCommandShape, cwd?
32
33
  * stands ({@link changesDirectoryBeforeGit}). Anything the reading does not positively admit is NOT read-only — the
33
34
  * answer is never a guess, and a `false` costs one question.
34
35
  */
35
- export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string): ReadOnlyShellVerdict;
36
+ export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string, bound?: LineDescriptors | (() => LineDescriptors)): ReadOnlyShellVerdict;
36
37
  /**
37
38
  * The reader's LINE-level objection to `command` — what makes no segment of it read-only in its place, whatever the
38
39
  * segment reads on its own: the line's structure, or a `cd` beside a `git` ({@link changesDirectoryBeforeGit}). A caller
@@ -40,3 +41,9 @@ export declare function readOnlyShellVerdict(command: string, shape?: ShellComma
40
41
  * line first.
41
42
  */
42
43
  export declare function readOnlyLineObjection(command: string, cwd?: string): string | undefined;
44
+ /**
45
+ * What the whole text of `command` binds — every redirection of every segment (a single one, an `exec`'s, a `{name}` one included),
46
+ * read by the station's own computation ({@link descriptorsOfPlan}): the one binding map the shape tier's operand reading shares with
47
+ * the station's rebinding rule. The plan is read with no listed readers: the bindings do not depend on which programs read.
48
+ */
49
+ export declare function lineDescriptorsOfText(command: string): LineDescriptors;
@@ -1,5 +1,8 @@
1
1
  import { posix } from "node:path";
2
2
  import { readShellCommand } from "./shell-lexer.js";
3
+ import { descriptorsOfPlan, newLineDescriptors, standardDeviceUnrebound } from "../tools/fs/shell-read-device.js";
4
+ import { planShellReads } from "../tools/fs/shell-read-plan.js";
5
+ import { writesOnlyStandardDevices } from "../tools/fs/shell-read-write-targets.js";
3
6
  import { FIND_ACTION_PRIMARIES, FIND_NEWER_PRIMARY, FIND_VALUE_PRIMARIES, FLAG_VALUE_ACCEPTS, READ_ONLY_BARE_ONLY, READ_ONLY_BARE_PROGRAMS, READ_ONLY_COMMAND_TABLE, READ_ONLY_ENV_NAMES, READ_ONLY_EXACT_FORMS, READ_ONLY_GLOB_PROGRAMS, XARGS_READ_ONLY_TARGETS, dockerRetargetDanger, } from "./read-only-shell-table.js";
4
7
  const NOT = (reason) => ({ readOnly: false, reason });
5
8
  export const CD_GIT_COMPOUND_SENTENCE = "This command changes directory before running a version-control command, which can pick up untrusted hooks or repository configuration from the target directory. Approve only if you trust it.";
@@ -39,7 +42,7 @@ const PRINTF_ESCAPE = new RegExp(`%[^%a-zA-Z]*${PRINTF_LENGTH}\\\\[0-7xX]`);
39
42
  const PRINTF_NUMERIC = new RegExp(`%[-+ 0#']*[0-9.*]*${PRINTF_LENGTH}[diouxXeEfFgGaAn]`);
40
43
  const JQ_DANGER_FLAG = /^['"]?-[a-zA-Z]*[fL]/;
41
44
  const JQ_DANGER_TEXT = /--from-file|--rawfile|--slurpfile|--run-tests|--library-path|\benv\b|\$ENV\b|\binclude\b|\bimport\b/;
42
- export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd) {
45
+ export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd, bound) {
43
46
  const line = lineStructureObjection(shape);
44
47
  if (line !== undefined)
45
48
  return NOT(line);
@@ -59,8 +62,12 @@ export function readOnlyShellVerdict(command, shape = readShellCommand(command),
59
62
  }
60
63
  if (changesDirectoryBeforeGit(shape, cwd))
61
64
  return NOT(CD_GIT_REASON);
65
+ const descriptors = newLineDescriptors();
66
+ descriptors.unknown = shape.segments.reduce((n, s) => n + s.redirections.length, 0) > 1;
67
+ let boundOfText;
68
+ const bindings = () => (boundOfText ??= (typeof bound === "function" ? bound() : bound) ?? lineDescriptorsOfText(command));
62
69
  for (const seg of shape.segments) {
63
- const verdict = segmentVerdict(seg);
70
+ const verdict = segmentVerdict(seg, descriptors, bindings);
64
71
  if (!verdict.readOnly)
65
72
  return verdict;
66
73
  }
@@ -125,10 +132,10 @@ function peelBuiltinPrefix(words) {
125
132
  return t;
126
133
  }
127
134
  }
128
- function segmentVerdict(seg) {
135
+ function segmentVerdict(seg, line, bindings) {
129
136
  for (const r of seg.redirections) {
130
137
  const target = r.target.text;
131
- if (!READING_REDIRECTS.has(r.op) && target !== "/dev/null" && !(r.op === ">&" && /^\d+$/.test(target)))
138
+ if (!READING_REDIRECTS.has(r.op) && !standardDeviceUnrebound(target, line, []) && !(r.op === ">&" && /^\d+$/.test(target)))
132
139
  return NOT(`the redirection \`${r.op} ${target}\` writes`);
133
140
  if (/^\/dev\/(tcp|udp)\//.test(target))
134
141
  return NOT("a redirection to a network pseudo-device");
@@ -148,11 +155,17 @@ function segmentVerdict(seg) {
148
155
  const program = words[0];
149
156
  if (run.some((w) => expansionKind(w) === "glob"))
150
157
  return GLOB_PROGRAMS.has(program) ? READ_ONLY : NOT(`\`${program}\` with a glob argument — the glob may name anything`);
158
+ if (DEVICE_WRITERS.has(program) && writesOnlyStandardDevices(program, words.slice(1), bindings()))
159
+ return READ_ONLY;
151
160
  const structural = structuralReading(words);
152
161
  if (structural !== null)
153
162
  return structural ? READ_ONLY : NOT(`\`${program}\` with these arguments is not a read-only form`);
154
163
  return tableReading(words, run);
155
164
  }
165
+ export function lineDescriptorsOfText(command) {
166
+ return descriptorsOfPlan(planShellReads(command, { readers: new Set(), cwd: "/", roots: [] }));
167
+ }
168
+ const DEVICE_WRITERS = new Set(["tee", "cp"]);
156
169
  function structuralReading(words) {
157
170
  const program = words[0];
158
171
  if (BARE_ONLY.has(program))
@@ -167,6 +180,8 @@ function structuralReading(words) {
167
180
  return form[0] !== "docker" || !dockerRetargetDanger(words);
168
181
  if (program === "echo")
169
182
  return true;
183
+ if (program === "exec")
184
+ return words.length === 1;
170
185
  if (program === "printf")
171
186
  return printfReading(words);
172
187
  if (program === "ls")
@@ -9,6 +9,7 @@ import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.j
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
10
  import { __resetSecretEnvScrubNoticeAnnouncement, announceSecretEnvScrub, inheritEnvPolicyProblem, inheritedHostEnv, scrubSecretEnv } from "../../core/secret-env.js";
11
11
  import { physicalPathOfAsync } from "../../core/physical-path.js";
12
+ import { posixModeBitsMeaningful } from "../../core/posix-mode-face.js";
12
13
  import { walkSymlinkChain } from "./symlink-chain.js";
13
14
  import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
14
15
  import { BackgroundShellError } from "../../core/background-shell.js";
@@ -41,7 +42,7 @@ function fileKindFromStats(stats) {
41
42
  }
42
43
  return undefined;
43
44
  }
44
- const HAS_POSIX_MODE_FACE = process.platform !== "win32";
45
+ const HAS_POSIX_MODE_FACE = posixModeBitsMeaningful();
45
46
  function fileInfoFromStats(path, stats) {
46
47
  const kind = fileKindFromStats(stats);
47
48
  if (!kind) {
@@ -113,15 +113,23 @@ export type RuleAdoptionFile = {
113
113
  export declare function readRuleAdoptionFile(dir: string): RuleAdoptionFile | undefined;
114
114
  /** Atomically publish the adoption marker (same write-temp → fsync → rename discipline as the store). */
115
115
  export declare function writeRuleAdoptionFile(dir: string, content: RuleAdoptionFile): void;
116
+ /** What a directory vetting reads from its host. Absent ⇒ this process's platform, and a skipped judgment goes unsaid. */
117
+ interface DirVetting {
118
+ /** The platform whose permission bits are read (default: this process's — see {@link posixModeBitsMeaningful}). */
119
+ readonly platform?: NodeJS.Platform;
120
+ /** Said when the group/other-writable judgment was NOT made because the platform reports no permission bits. */
121
+ readonly modeBitsUnjudged?: (dir: string) => void;
122
+ }
116
123
  /** One principal's rule file. */
117
124
  declare class FileDurableRulePartition implements WritableDurableRulePartition {
118
125
  private readonly dir;
119
126
  private readonly file;
120
127
  private readonly acquireWriteLock;
121
128
  private readonly onError?;
129
+ private readonly vetting?;
122
130
  readonly durability: StoreDurability;
123
131
  readonly fidelity: StoreFidelity;
124
- constructor(dir: string, file: string, acquireWriteLock: () => void, onError?: ((message: string) => void) | undefined);
132
+ constructor(dir: string, file: string, acquireWriteLock: () => void, onError?: ((message: string) => void) | undefined, vetting?: DirVetting | undefined);
125
133
  /** Read and validate. Any refusal answers with an empty set AND says so — never a silent empty store. The answer's
126
134
  * sentence names no location ({@link refusalText}); the disclosure that accompanies it does. */
127
135
  private read;
@@ -198,9 +206,13 @@ export declare class FileDurableRulePartitionProvider implements DurableRulePart
198
206
  private readonly onError?;
199
207
  private lock;
200
208
  private orgPersistence;
209
+ /** What every directory vetting of this instance reads; it says an unjudged mode check ONCE per instance. */
210
+ private readonly vetting;
201
211
  constructor(dir: string, onError?: ((message: string) => void) | undefined, opts?: {
202
212
  /** The data root whose adoption marker gates construction (design/183 I6). Absent ⇒ no root gate. */
203
213
  dataRoot?: string;
214
+ /** The platform whose permission bits the directory vetting reads. Absent ⇒ this process's platform. */
215
+ platform?: NodeJS.Platform;
204
216
  });
205
217
  /** The org partition's durable home in this directory (design/182 §7.4 contract; `org.json`). Shares
206
218
  * this provider's writer lock — an install is a write. */
@@ -2,6 +2,7 @@ import { closeSync, constants as FS, existsSync, fsyncSync, lstatSync, mkdirSync
2
2
  import { join } from "node:path";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { isRuleBehavior } from "../../core/permission-rule-model.js";
5
+ import { posixModeBitsMeaningful } from "../../core/posix-mode-face.js";
5
6
  import { PERMISSION_RULE_WRITER, applySyncJoin, applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, unknownRuleWriteDeltaError, } from "../../core/permission-rule-store.js";
6
7
  import { canonicalize } from "../../core/canonical-json.js";
7
8
  import { BootLock, FileStoreLockError } from "./fs-atomic.js";
@@ -118,13 +119,17 @@ const EMPTY_READ = { rules: [], tombstones: [], rev: 0 };
118
119
  function checksumOf(body) {
119
120
  return `sha256:${createHash("sha256").update(canonicalize(body), "utf8").digest("hex")}`;
120
121
  }
121
- function unsafeDirFault(dir) {
122
+ function unsafeDirFault(dir, vetting) {
122
123
  mkdirSync(dir, { recursive: true, mode: 0o700 });
123
124
  const st = lstatSync(dir);
124
125
  if (st.isSymbolicLink() || !st.isDirectory())
125
126
  return "is a symlink or not a directory";
126
127
  if (typeof process.getuid === "function" && st.uid !== process.getuid())
127
128
  return "is owned by another user";
129
+ if (!posixModeBitsMeaningful(vetting?.platform)) {
130
+ vetting?.modeBitsUnjudged?.(dir);
131
+ return undefined;
132
+ }
128
133
  if ((st.mode & 0o022) !== 0)
129
134
  return `is writable by group or others (mode ${(st.mode & 0o777).toString(8)}) — anyone who can write there can grant themselves permissions`;
130
135
  return undefined;
@@ -135,15 +140,18 @@ function assertSafeDir(dir) {
135
140
  throw new Error(`permission-rule store directory ${dir} ${fault}; refusing to use it`);
136
141
  }
137
142
  function refusalText(onError, sentence, location) {
143
+ discloseTo(onError, `${sentence} (at ${location})`);
144
+ return `${sentence}. The location is reported to the store's error disclosure (onError), not in this message.`;
145
+ }
146
+ function discloseTo(onError, message) {
138
147
  try {
139
- onError?.(`${sentence} (at ${location})`);
148
+ onError?.(message);
140
149
  }
141
150
  catch {
142
151
  }
143
- return `${sentence}. The location is reported to the store's error disclosure (onError), not in this message.`;
144
152
  }
145
- function assertSafeDirAtRequest(dir, onError, act) {
146
- const fault = unsafeDirFault(dir);
153
+ function assertSafeDirAtRequest(dir, onError, act, vetting) {
154
+ const fault = unsafeDirFault(dir, vetting);
147
155
  if (fault !== undefined)
148
156
  throw new Error(refusalText(onError, `the permission-rule store directory ${fault}; refusing to ${act} it`, dir));
149
157
  }
@@ -171,13 +179,15 @@ class FileDurableRulePartition {
171
179
  file;
172
180
  acquireWriteLock;
173
181
  onError;
182
+ vetting;
174
183
  durability = "durable";
175
184
  fidelity = "json";
176
- constructor(dir, file, acquireWriteLock, onError) {
185
+ constructor(dir, file, acquireWriteLock, onError, vetting) {
177
186
  this.dir = dir;
178
187
  this.file = file;
179
188
  this.acquireWriteLock = acquireWriteLock;
180
189
  this.onError = onError;
190
+ this.vetting = vetting;
181
191
  }
182
192
  read() {
183
193
  const bucket = "this owner's permission-rule bucket";
@@ -185,7 +195,7 @@ class FileDurableRulePartition {
185
195
  const unreadable = (sentence, location = this.file) => ({ unreadable: refusalText(this.onError, sentence, location) });
186
196
  let raw;
187
197
  try {
188
- const dirFault = unsafeDirFault(this.dir);
198
+ const dirFault = unsafeDirFault(this.dir, this.vetting);
189
199
  if (dirFault !== undefined)
190
200
  return unreadable(`the permission-rule store directory ${dirFault}; loading zero rules`, this.dir);
191
201
  const st = lstatSync(this.file);
@@ -228,11 +238,7 @@ class FileDurableRulePartition {
228
238
  return { file: parsed };
229
239
  }
230
240
  disclose(message) {
231
- try {
232
- this.onError?.(message);
233
- }
234
- catch {
235
- }
241
+ discloseTo(this.onError, message);
236
242
  }
237
243
  screenDurableRows(file) {
238
244
  const isSession = (scope) => scope?.kind === "session";
@@ -246,7 +252,7 @@ class FileDurableRulePartition {
246
252
  return { rules, tombstones, quarantined };
247
253
  }
248
254
  write(next) {
249
- assertSafeDirAtRequest(this.dir, this.onError, "write through");
255
+ assertSafeDirAtRequest(this.dir, this.onError, "write through", this.vetting);
250
256
  atomicPublish(this.dir, this.file, JSON.stringify({ ...next, checksum: checksumOf(next) }, null, 2));
251
257
  }
252
258
  async list() {
@@ -392,11 +398,22 @@ export class FileDurableRulePartitionProvider {
392
398
  onError;
393
399
  lock;
394
400
  orgPersistence;
401
+ vetting;
395
402
  constructor(dir, onError, opts) {
396
403
  this.dir = dir;
397
404
  this.onError = onError;
398
405
  if (opts?.dataRoot !== undefined)
399
406
  assertAdoptionBootGate(opts.dataRoot, "FileDurableRulePartitionProvider");
407
+ let said = false;
408
+ this.vetting = {
409
+ platform: opts?.platform,
410
+ modeBitsUnjudged: (where) => {
411
+ if (said)
412
+ return;
413
+ said = true;
414
+ discloseTo(this.onError, `the permission-rule store directory is not checked for being writable by other accounts: this platform reports no POSIX permission bits, so who else can write there is not verified here (at ${where})`);
415
+ },
416
+ };
400
417
  }
401
418
  adoptionMarker() {
402
419
  const judged = judgeRuleAdoptionFile(this.dir);
@@ -410,13 +427,13 @@ export class FileDurableRulePartitionProvider {
410
427
  return this.fileOrgPersistence();
411
428
  }
412
429
  fileOrgPersistence() {
413
- this.orgPersistence ??= new FileOrgRuleStatePersistence(this.dir, this.acquireWriteLock, this.onError);
430
+ this.orgPersistence ??= new FileOrgRuleStatePersistence(this.dir, this.acquireWriteLock, this.onError, this.vetting);
414
431
  return this.orgPersistence;
415
432
  }
416
433
  acquireWriteLock = () => {
417
434
  if (this.lock !== undefined)
418
435
  return;
419
- assertSafeDirAtRequest(this.dir, this.onError, "write through");
436
+ assertSafeDirAtRequest(this.dir, this.onError, "write through", this.vetting);
420
437
  const lock = new BootLock(join(this.dir, "PERMISSION-RULES-WRITER-LOCK"));
421
438
  try {
422
439
  lock.acquire();
@@ -437,11 +454,11 @@ export class FileDurableRulePartitionProvider {
437
454
  return this.readOnlySourceView(principalRuleFileName(principal), principalRuleFileName(rec.toPrincipal), "adopted" in adoption || adoption.marker.phase >= 4);
438
455
  }
439
456
  }
440
- return new FileDurableRulePartition(this.dir, join(this.dir, principalRuleFileName(principal)), this.acquireWriteLock, this.onError);
457
+ return new FileDurableRulePartition(this.dir, join(this.dir, principalRuleFileName(principal)), this.acquireWriteLock, this.onError, this.vetting);
441
458
  }
442
459
  readOnlySourceView(sourceName, targetName, flipped) {
443
460
  const pick = flipped ? targetName : existsSync(join(this.dir, sourceName)) ? sourceName : targetName;
444
- return readOnlyRuleStoreView(new FileDurableRulePartition(this.dir, join(this.dir, pick), this.acquireWriteLock, this.onError));
461
+ return readOnlyRuleStoreView(new FileDurableRulePartition(this.dir, join(this.dir, pick), this.acquireWriteLock, this.onError, this.vetting));
445
462
  }
446
463
  forLocalOwner() {
447
464
  const adoption = this.adoptionMarker();
@@ -451,7 +468,7 @@ export class FileDurableRulePartitionProvider {
451
468
  return this.readOnlySourceView(LOCAL_OWNER_FILE, principalRuleFileName(rec.toPrincipal), "adopted" in adoption || adoption.marker.phase >= 4);
452
469
  }
453
470
  }
454
- return new FileDurableRulePartition(this.dir, join(this.dir, LOCAL_OWNER_FILE), this.acquireWriteLock, this.onError);
471
+ return new FileDurableRulePartition(this.dir, join(this.dir, LOCAL_OWNER_FILE), this.acquireWriteLock, this.onError, this.vetting);
455
472
  }
456
473
  dispose() {
457
474
  this.lock?.release();
@@ -462,12 +479,14 @@ class FileOrgRuleStatePersistence {
462
479
  dir;
463
480
  acquireWriteLock;
464
481
  onError;
482
+ vetting;
465
483
  file;
466
484
  writeChain = Promise.resolve();
467
- constructor(dir, acquireWriteLock, onError) {
485
+ constructor(dir, acquireWriteLock, onError, vetting) {
468
486
  this.dir = dir;
469
487
  this.acquireWriteLock = acquireWriteLock;
470
488
  this.onError = onError;
489
+ this.vetting = vetting;
471
490
  this.file = join(dir, ORG_STATE_FILE);
472
491
  }
473
492
  readSync() {
@@ -477,7 +496,7 @@ class FileOrgRuleStatePersistence {
477
496
  const ioFault = (err) => `could not be read (${err?.code ?? err?.name ?? "unknown"})`;
478
497
  let dirFault;
479
498
  try {
480
- dirFault = unsafeDirFault(this.dir);
499
+ dirFault = unsafeDirFault(this.dir, this.vetting);
481
500
  }
482
501
  catch (err) {
483
502
  return refuse(`the permission-rule store directory ${ioFault(err)}`, `${this.dir}: ${thrownText(err)}`);
@@ -524,7 +543,7 @@ class FileOrgRuleStatePersistence {
524
543
  }
525
544
  installOrgStateSync(state) {
526
545
  this.acquireWriteLock();
527
- assertSafeDirAtRequest(this.dir, this.onError, "write through");
546
+ assertSafeDirAtRequest(this.dir, this.onError, "write through", this.vetting);
528
547
  state = jsonForm(state);
529
548
  const cur = this.readSync();
530
549
  if (cur !== undefined && state.revisionHighWater < cur.revisionHighWater) {
@@ -2,8 +2,9 @@ import { anchorSpelling, isAbsoluteForFamily, isBlockedDevicePath, isShellRooted
2
2
  import { PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, argvOf, lexShell, literalTildeSpelling, } from "../../shell/lex.js";
3
3
  import { shellWordsOf } from "../../shell/shell-words.js";
4
4
  import { textBindsHome } from "./shell-read-bindings.js";
5
+ import { GENERATOR_DEVICES, newLineDescriptors, bindDescriptors, isNamedDescriptorWord, standardDeviceUnrebound, descriptorsBoundBy } from "./shell-read-device.js";
5
6
  import { NOT_AUTO_ALLOWED, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
6
- import { BLOCK_WORD_NAMES, attachedStdinFile, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings, readArgRoles, readArgs, readProgramTextLanguage, runsShellText, selectedOptions, slotValueCandidates, suppliesFirstOperandSlot, takesSeparatedDatum, valuesAreLiteral, writeReadings, writesPath, } from "./program-read-model.js";
7
+ import { BLOCK_WORD_NAMES, attachedStdinFile, byteBoundIsSmall, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings, readArgs, readProgramTextLanguage, runsShellText, selectedOptions, slotValueCandidates, suppliesFirstOperandSlot, takesSeparatedDatum, valuesAreLiteral, writeReadings, writesPath, } from "./program-read-model.js";
7
8
  export const BASH_READONLY_DEFAULT_ALLOW = [
8
9
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
9
10
  "grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
@@ -82,8 +83,8 @@ export function carriesShellRedirection(text) {
82
83
  return SHELL_REDIRECTION_OPERATORS.test(text);
83
84
  }
84
85
  const SHELL_REDIRECTION_OPERATORS = /[<>]/;
85
- function isDataFreeRedirection(r) {
86
- if (r.op === ">" && r.fd === 2 && r.target !== undefined && r.target.raw === "/dev/null")
86
+ function isDataFreeRedirection(r, line) {
87
+ if (r.op === ">" && r.fd === 2 && r.target !== undefined && standardDeviceUnrebound(r.target.raw, line, descriptorsBoundBy(r)))
87
88
  return true;
88
89
  return r.op === ">&" && r.dup !== undefined && /^[12]$/.test(r.dup) && (r.fd === undefined || r.fd === 1 || r.fd === 2);
89
90
  }
@@ -103,6 +104,13 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
103
104
  if ("parseFailure" in lexed)
104
105
  return inClass(source) || SHELL_EXPANSION_CHARS.test(source) ? reason : PARSE_FAILURE_SENTENCE[lexed.parseFailure.reason];
105
106
  const words = [];
107
+ const line = newLineDescriptors();
108
+ for (const s of lexed.segments)
109
+ for (const t of s.tokens)
110
+ if (t.kind === "redirect")
111
+ bindDescriptors(line, t);
112
+ else if (t.kind === "word" && isNamedDescriptorWord(t.raw))
113
+ line.named = true;
106
114
  for (const s of lexed.segments) {
107
115
  if (s.connector === "\n")
108
116
  return reason;
@@ -115,7 +123,7 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
115
123
  }
116
124
  if (t.kind !== "redirect")
117
125
  return reason;
118
- if (opts.dataFreeRedirections === true && isDataFreeRedirection(t))
126
+ if (opts.dataFreeRedirections === true && isDataFreeRedirection(t, line))
119
127
  continue;
120
128
  if (!tolerate)
121
129
  return reason;
@@ -795,30 +803,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
795
803
  }
796
804
  unlistedSegments.add(si);
797
805
  }
798
- const HEAD_AUTO_ALLOW_MAX = 1_000_000;
799
- const byteBoundIsSmall = (row, args) => {
800
- if (row === undefined)
801
- return false;
802
- let byteBound = false;
803
- for (const e of readArgRoles(row, args)) {
804
- if (e.kind === "operand" || e.kind === "end-of-options")
805
- continue;
806
- if (e.kind === "value" && e.spec?.bounds === "bytes") {
807
- if (!/^\d+$/.test(args[e.at]) || Number(args[e.at]) > HEAD_AUTO_ALLOW_MAX)
808
- return false;
809
- byteBound = true;
810
- continue;
811
- }
812
- if (e.kind === "option" && typeof e.reading === "object" && e.reading.spec?.bounds === "bytes") {
813
- if (!/^\d+$/.test(e.reading.attached) || Number(e.reading.attached) > HEAD_AUTO_ALLOW_MAX)
814
- return false;
815
- byteBound = true;
816
- continue;
817
- }
818
- return false;
819
- }
820
- return byteBound;
821
- };
822
806
  const foldedSegments = [];
823
807
  let shapeRefusal = nameRefusal;
824
808
  for (let si = 0; si < segments.length; si++) {
@@ -896,7 +880,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
896
880
  shapeRefusal ??= `\`${name}\` in follow mode never terminates ${NOT_AUTO_ALLOWED}`;
897
881
  continue;
898
882
  }
899
- const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
900
883
  const deviceCandidates = openableWordsOf(row, restArgs);
901
884
  const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
902
885
  const rescuedByHead = byteBoundIsSmall(row, restArgs) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));