@crewhaus/tool-desktop 0.7.0 → 0.8.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.
package/dist/index.js CHANGED
@@ -47,19 +47,22 @@
47
47
  * drives all of them, and the un-injected platform is deliberately a platform
48
48
  * no backend serves — see `./host.ts`.
49
49
  */
50
- import { join } from "node:path";
50
+ import { createHash } from "node:crypto";
51
+ import { realpathSync } from "node:fs";
52
+ import { tmpdir } from "node:os";
53
+ import { isAbsolute, join } from "node:path";
51
54
  import { buildTool } from "@crewhaus/tool-builder";
52
55
  import { z } from "zod";
53
56
  import { hostPlatform, now, sessionEnv } from "./host";
54
57
  import { classifyClipboardInfo, classifyClipboardRead, planClipboardRead, planClipboardWrite, } from "./lib/clipboard";
55
58
  import { osascriptProgramText } from "./lib/escape";
56
- import { fs } from "./lib/fsseam";
59
+ import { fs, StateFileError } from "./lib/fsseam";
57
60
  import { classifyNotify, planNotify } from "./lib/notify";
58
61
  import { OPENABLE_SCHEMES, classifyOpen, classifyTarget, planOpen, refusePath, } from "./lib/open";
59
62
  import { Unknowns, unavailable, unsupportedPlatform } from "./lib/outcome";
60
- import { DEFAULT_HOLD_MINUTES, MAX_HOLD_MINUTES, SAFE_REASON, STATE_RELATIVE_PATH, boundedSeconds, classifyLiveness, isAssertionState, planHold, planProcessProbe, planRelease, } from "./lib/power";
63
+ import { DEFAULT_HOLD_MINUTES, LEGACY_STATE_RELATIVE_PATH, MAX_HOLD_MINUTES, SAFE_REASON, boundedSeconds, classifyLiveness, isAssertionState, isHolderCommand, planHold, planProcessProbe, planRelease, } from "./lib/power";
61
64
  import { parseIoregIdleSeconds, parseIoregLocked, parseLoginctl, parseWindowsPresence, parseXprintidleSeconds, planPresence, sessionKind, } from "./lib/presence";
62
- import { DUPLEX_VALUES, MAX_COPIES, classifyPrint, parseQueues, planPrint, planPrinterProbe, refusePrintOptions, unreadableQueues, } from "./lib/print";
65
+ import { DUPLEX_VALUES, MAX_COPIES, classifyPrint, parseQueues, parseWindowsQueues, planPrint, planPrinterProbe, refusePrintOptions, unreadableQueues, } from "./lib/print";
63
66
  import { classifyWindowList, planWindowList } from "./lib/windows";
64
67
  import { ToolPermissionError, resolveSafe, workspaceRoot } from "./paths";
65
68
  import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS, capText, detachHostCommand, runHostCommand, } from "./run";
@@ -114,7 +117,8 @@ const DEFAULT_CLIPBOARD_MAX_CHARS = 20_000;
114
117
  const MAX_CLIPBOARD_MAX_CHARS = 1_000_000;
115
118
  export const clipboardRead = buildTool({
116
119
  name: "ClipboardRead",
117
- description: 'Read the operator\'s system clipboard as text. WHAT THE CALLER TAKES ON: the clipboard is where people put a password, a recovery code or an API key for the seconds between copying and pasting it, and whatever is on it when this runs is returned verbatim into the model\'s context, from there into the conversation transcript, and from there into any log or trace the harness keeps. This tool does NOT scan for or redact secrets: a heuristic that catches some patterns and misses others earns trust it cannot honour, and the caller who relied on it pastes the one it missed into a ticket. Ask only when the operator has been told to copy something for you, and treat the result as sensitive for the rest of the run. Four distinct outcomes, never conflated: "read" with the text, "empty" (the clipboard was reachable and holds nothing), "noTextFlavour" (it holds something — an image, a file — that has no text form), and "unavailable" with a reason when there was no clipboard to ask (no desktop session, no backend program, an unsupported platform). An empty clipboard and an unreadable one are never reported as the same thing.',
120
+ operativeArgs: [],
121
+ description: 'Read the operator\'s system clipboard as text. Use it only when the operator has been told to copy something for you. WHAT THE CALLER TAKES ON: the clipboard is where people keep a password, a recovery code or an API key for the seconds between copying and pasting it, and whatever is on it is returned verbatim into the model\'s context, the transcript, and any log or trace the harness keeps. This tool does NOT scan for or redact secrets: a heuristic that catches some and misses others earns trust it cannot honour. Treat the result as sensitive for the rest of the run. Four outcomes, never conflated: "read" with the text; "empty" (reachable, holds nothing); "noTextFlavour" (it holds something with no text form, such as an image or a file); and "unavailable" with a reason when there was no clipboard to ask (no desktop session, no backend program, an unsupported platform).',
118
122
  inputSchema: z.object({
119
123
  format: z
120
124
  .enum(["text", "html"])
@@ -212,6 +216,7 @@ export const clipboardRead = buildTool({
212
216
  // ---------------------------------------------------------------------------
213
217
  export const clipboardWrite = buildTool({
214
218
  name: "ClipboardWrite",
219
+ operativeArgs: [],
215
220
  description: "Put text on the operator's system clipboard, replacing whatever was there. Declared destructive because that replacement cannot be undone: the thing the operator had copied a moment ago is gone, and there is no clipboard history to restore it from. The payload is piped to the backend on STDIN and never appears as a command argument, because argv is world-readable through a process listing on every platform here. Use dryRun to see which backend and argument list would be used without writing anything. On X11 the writing process owns the selection and must outlive this call, which xclip handles by forking its own resident holder — so on Linux the content survives this tool returning, but not the X session ending.",
216
221
  inputSchema: z.object({
217
222
  text: z
@@ -286,6 +291,7 @@ export const clipboardWrite = buildTool({
286
291
  const MAX_NOTIFY_FIELD = 2_000;
287
292
  export const desktopNotify = buildTool({
288
293
  name: "DesktopNotify",
294
+ operativeArgs: [],
289
295
  description: 'Show a notification on the operator\'s desktop — a macOS notification, a FreeDesktop notification on Linux, a toast on Windows. The title, body and subtitle may contain anything at all: on macOS they travel as argv items that the AppleScript reads with `item N of argv`, so a body containing quotes or semicolons is displayed rather than executed, and on Windows they are XML-escaped into a document that crosses to PowerShell through the environment rather than as source. The result says "dispatched", not "shown": every one of these APIs accepts a notification and returns, and whether a human saw it depends on Do Not Disturb, a Focus mode and whether the screen is on — none of which is observable from here. A host with no notification daemon, no display session or no notifier installed is reported as unavailable with the reason.',
290
296
  inputSchema: z.object({
291
297
  title: z.string().min(1).max(MAX_NOTIFY_FIELD).describe("the notification's title line"),
@@ -361,7 +367,8 @@ export const desktopNotify = buildTool({
361
367
  // ---------------------------------------------------------------------------
362
368
  export const openExternal = buildTool({
363
369
  name: "OpenExternal",
364
- description: `Hand a URL or a local path to the operating system to open with whatever is registered for it. The scheme set is an ALLOW-LIST and it is short: ${OPENABLE_SCHEMES.join(", ")}. Everything else is refused by name — file: (it would bypass this tool's path containment), smb: and nfs: (they mount a remote share and can leak an authentication handshake to the host named in the URL), javascript:, data:, and the Windows ms-* shell handlers. The allowSchemes input can only NARROW that set, never widen it. What is checked is what is opened: an http(s) URL is handed to the desktop in its normalised form rather than as you typed it, a URL containing a backslash is refused (parsers disagree about whether that is a path separator, so the host checked here would not be the host opened there), a URL carrying credentials before the "@" is refused (they would sit in a process listing and in this transcript, and "google.com@evil.example" reads as the host it is not), and a mailto: may carry only the headers RFC 6068 calls safe — attach= is refused by name, because a mail client that honours it reads a local file into the message. A target with no scheme is treated as a filesystem path, resolved inside the workspace root with symlinks followed, and refused if it lands outside; an EXECUTABLE file is refused even inside the workspace, because "open this" and "run this" are the same gesture to a desktop. The result says "handedOff", not "opened": every opener returns as soon as the handler has been asked and none of them report what it then did.`,
370
+ operativeArgs: [{ field: "target", kind: "url" }],
371
+ description: `Ask the operating system to open a URL or a workspace file with its registered handler. Use it to show the operator a page or a file. Only ${OPENABLE_SCHEMES.join(", ")} are accepted, and allowSchemes can only narrow that; file:, smb:, nfs:, javascript:, data: and the Windows ms-* handlers are refused by name. An http(s) URL is handed off normalised; one with a backslash or credentials before the "@" is refused, and a mailto: may carry only RFC 6068's safe headers (attach= is refused). A target with no scheme is a path, resolved inside the workspace with symlinks followed and refused outside it; an executable file is refused, and so is a type the desktop runs, installs or follows (scripts, installers, configuration profiles, shortcut and location files). The result says "handedOff", not "opened": no opener reports what the handler did. What opens acts in the operator's desktop session, with their cookies, and outlives this call, so every call is gated like a change and carries a justification.`,
365
372
  inputSchema: z.object({
366
373
  target: z
367
374
  .string()
@@ -377,6 +384,21 @@ export const openExternal = buildTool({
377
384
  timeoutMs: timeoutSchema,
378
385
  }),
379
386
  readOnly: false,
387
+ // The effect outlives the call and the process — the package's own test for
388
+ // "destructive" (see PowerAssertion): an app is launched, a tab opens with
389
+ // the operator's cookies. As destructive:false it ran with no rule at all
390
+ // in auto mode, so a workspace .fileloc could launch any app unasked
391
+ // (flag-truth-5#6). A destructive tool that goes where the model points
392
+ // also carries a justification (apps/cli/src/flag-rules.test.ts, rule 1):
393
+ // opening `https://host/?q=<secret>` in the operator's browser is an
394
+ // outbound request like HttpRequest's. The justification gate runs AFTER
395
+ // the permission decision, so an `alwaysAllow` OpenExternal rule no longer
396
+ // skips it: every call is scored by `security.justification.judge`, or,
397
+ // when a spec sets none, by the rule-based judge (with its one-time
398
+ // stderr warning), which denies a justification that shares no salient
399
+ // word with the instructions.
400
+ destructive: true,
401
+ requireJustification: true,
380
402
  concurrencySafe: false,
381
403
  scope: "external",
382
404
  ioCapability: "process",
@@ -465,6 +487,7 @@ export const openExternal = buildTool({
465
487
  // ---------------------------------------------------------------------------
466
488
  export const printDocument = buildTool({
467
489
  name: "PrintDocument",
490
+ operativeArgs: [{ field: "path", kind: "path" }],
468
491
  description: 'Send a local file to a printer through CUPS (macOS and Linux) or to a Windows queue. The queue is probed with lpstat FIRST, on both the dry run and the real print, so the two resolve through the same code and a dry run is a prefix of the real thing rather than a separate prediction. That probe is also what keeps the answers apart: a host whose scheduler is not running prints nothing and lists nothing, which looks exactly like a host with no printers, and the two are reported differently. Declared destructive because paper and toner do not come back. On Windows the honest scope is narrow and stated rather than papered over: Out-Printer sends TEXT to a queue and cannot rasterise a PDF or an image, and it has no copies, duplex or page-range option — asking for one there is refused by name instead of silently dropped. Printer names and page ranges are matched against closed patterns and refused if they do not fit, because lp documents no "--" and a destination beginning with "-" would become an option.',
469
492
  inputSchema: z.object({
470
493
  path: z
@@ -547,11 +570,13 @@ export const printDocument = buildTool({
547
570
  const probe = await run(probeRequest, timeoutMs, context?.signal);
548
571
  const queues = probe.refused === true || probe.missing || probe.timedOut
549
572
  ? unreadableQueues(probe.missing
550
- ? "lpstat is not installed on this host, so the queue could not be read"
573
+ ? `${platform === "win32" ? "PowerShell" : "lpstat"} is not installed on this host, so the queue could not be read`
551
574
  : probe.timedOut
552
575
  ? "the queue probe did not finish within its timeout and was killed, so whether this host has printers is unknown — it is NOT that it has none"
553
576
  : probe.stderr)
554
- : parseQueues(probe.stdout, probe.stderr);
577
+ : platform === "win32"
578
+ ? parseWindowsQueues(probe.stdout, probe.stderr)
579
+ : parseQueues(probe.stdout, probe.stderr);
555
580
  const queueJson = {
556
581
  printers: queues.printers,
557
582
  defaultPrinter: queues.defaultPrinter,
@@ -594,6 +619,7 @@ export const printDocument = buildTool({
594
619
  // ---------------------------------------------------------------------------
595
620
  export const windowList = buildTool({
596
621
  name: "WindowList",
622
+ operativeArgs: [],
597
623
  description: "List the windows open on the operator's desktop, with the app, title and — where the backend can supply them — position and size. It never answers an empty list for a failure: a macOS host that has not granted Accessibility comes back as unavailable naming that grant in System Settings, a Wayland session comes back as unavailable explaining that Wayland provides no protocol for one client to enumerate another's windows (there is nothing to install that would fix it), a missing wmctrl comes back as unavailable naming the package, and a stream cut at the output cap is reported as truncated so the list is read as a prefix rather than as the whole desktop. Fields a backend cannot supply are null rather than zero or false — wmctrl reports no focus state, so every window's focused is null rather than a claim that nothing has focus.",
598
624
  inputSchema: z.object({ timeoutMs: timeoutSchema }),
599
625
  readOnly: true,
@@ -640,6 +666,7 @@ export const windowList = buildTool({
640
666
  const DEFAULT_IDLE_THRESHOLD_SECONDS = 300;
641
667
  export const userPresence = buildTool({
642
668
  name: "UserPresence",
669
+ operativeArgs: [],
643
670
  description: "Report whether a human is likely at this machine: seconds since the last input, whether the screen is locked, and what kind of session this is (console, ssh or headless). Every field is separately nullable and a null always carries an entry in `unknown` naming the probe that failed, because the tempting fallbacks here invert the tool's purpose — reporting idle:false when nothing could be asked tells a workflow the operator is at the keyboard, and reporting idle:true tells it the opposite and sends it off to act unattended. macOS reads idle time out of the IOKit registry, in nanoseconds, and lock state from the same dump; Linux needs xprintidle (usually not installed, and unavailable under Wayland, which exposes no idle query at all) and reads logind's LockedHint, which is a HINT a screen locker has to set and some do not; Windows uses GetLastInputInfo and the presence of LogonUI. An ssh session reports sessionKind \"ssh\" and does not pretend the far end's idle time says anything about the person typing.",
644
671
  inputSchema: z.object({
645
672
  idleThresholdSeconds: z
@@ -749,11 +776,47 @@ export const userPresence = buildTool({
749
776
  // ---------------------------------------------------------------------------
750
777
  // PowerAssertion
751
778
  // ---------------------------------------------------------------------------
779
+ /**
780
+ * Where the held assertion is remembered: a file only this user can write,
781
+ * OUTSIDE the workspace, keyed by the workspace.
782
+ *
783
+ * 0.7.0 kept it at `.crewhaus/power-assertion.json` in the workspace, where
784
+ * the agent the tool serves can write, and `release` SIGTERMs the pid the
785
+ * record names (security-10#1). The directory is `$XDG_RUNTIME_DIR/crewhaus`
786
+ * when the session has one (Linux: per-user, 0700, cleared at logout), else
787
+ * `crewhaus-<uid>` under the OS temp directory (per-user on macOS and
788
+ * Windows), created 0700 and refused if another user owns it. The file name
789
+ * carries a hash of the workspace root, so two workspaces never share one.
790
+ */
791
+ export function powerStateFile() {
792
+ const runtime = process.env["XDG_RUNTIME_DIR"];
793
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
794
+ const dir = runtime !== undefined && runtime !== "" && isAbsolute(runtime)
795
+ ? join(runtime, "crewhaus")
796
+ : join(tmpdir(), uid === undefined ? "crewhaus" : `crewhaus-${uid}`);
797
+ let root = workspaceRoot();
798
+ try {
799
+ root = realpathSync(root);
800
+ }
801
+ catch {
802
+ // keep the lexical root
803
+ }
804
+ const key = createHash("sha256").update(root).digest("hex").slice(0, 16);
805
+ return join(dir, `power-assertion-${key}.json`);
806
+ }
752
807
  function statePath() {
753
- return join(workspaceRoot(), STATE_RELATIVE_PATH);
808
+ return powerStateFile();
754
809
  }
755
810
  function readState() {
756
- const text = fs().readText(statePath());
811
+ let text;
812
+ try {
813
+ text = fs().readText(statePath());
814
+ }
815
+ catch (err) {
816
+ if (!(err instanceof StateFileError))
817
+ throw err;
818
+ return { kind: "unreadable", reason: err.message };
819
+ }
757
820
  if (text === undefined)
758
821
  return { kind: "none" };
759
822
  let parsed;
@@ -777,6 +840,7 @@ function readState() {
777
840
  }
778
841
  export const powerAssertion = buildTool({
779
842
  name: "PowerAssertion",
843
+ operativeArgs: [],
780
844
  description: `Hold a BOUNDED sleep inhibitor around a long step, release it, or report on one. The deadline is mandatory and is not enforced by this process: caffeinate exits at its own -t, systemd-inhibit releases when its sleep returns, and the Windows holder parks for a fixed Start-Sleep — so killing the whole harness still ends the assertion on time, which an in-process reaper could not promise. Every result says when the assertion expires. The maximum is ${MAX_HOLD_MINUTES} minutes and the default is ${DEFAULT_HOLD_MINUTES}. A held assertion is remembered in a state file, and release verifies that the recorded pid is still running THIS package's holder before signalling anything: pids are reused, and a stale file must never become a SIGTERM to whatever now owns that number. A probe that could not run at all leaves the state alone and reports "unknown" rather than assuming the holder is gone.`,
781
845
  inputSchema: z.object({
782
846
  action: z
@@ -825,17 +889,37 @@ export const powerAssertion = buildTool({
825
889
  ...head,
826
890
  outcome: "unreadableState",
827
891
  reason: `${stateRead.reason} — so this package cannot say whether an inhibitor it started is still running. Nothing was signalled and nothing was started; any holder recorded there still ends at its own deadline. Delete the file to start again.`,
828
- statePath: STATE_RELATIVE_PATH,
892
+ statePath: statePath(),
829
893
  });
830
894
  }
831
895
  const state = stateRead.kind === "state" ? stateRead.state : undefined;
832
896
  const liveness = async (recorded) => {
897
+ // A record made on another platform cannot name a holder of ours here:
898
+ // it is somebody else's pid, whatever that process is running.
899
+ if (recorded.platform !== platform)
900
+ return { verdict: "reused", argv: [] };
833
901
  const probeRequest = planProcessProbe(platform, recorded.pid);
834
902
  if (probeRequest === undefined)
835
903
  return { verdict: "unknown", argv: [] };
836
904
  const probe = await run(probeRequest, timeoutMs, context?.signal);
837
- return { verdict: classifyLiveness(recorded.marker, probe), argv: probeRequest.argv };
905
+ // The live command line is checked against what THIS package starts on
906
+ // this platform, never against the record's own `marker` field: the
907
+ // record supplies the pid, so it must not also choose what that pid is
908
+ // checked against (security-10#1).
909
+ return {
910
+ verdict: classifyLiveness((line) => isHolderCommand(platform, line), probe),
911
+ argv: probeRequest.argv,
912
+ };
838
913
  };
914
+ // 0.7.0's record in the workspace is never read (the agent could have
915
+ // written it), only mentioned, so an operator who upgraded mid-hold knows
916
+ // why release finds nothing and that the holder ends on its own.
917
+ const legacyNote = () => fs().stat(join(workspaceRoot(), LEGACY_STATE_RELATIVE_PATH))?.exists === true
918
+ ? {
919
+ legacyStateFile: LEGACY_STATE_RELATIVE_PATH,
920
+ legacyNote: "a record from an earlier version is in the workspace. It is not read, because the agent can write files there; nothing it names is signalled, and any holder it recorded ends at its own deadline. Delete the file.",
921
+ }
922
+ : {};
839
923
  const stateJson = (recorded) => ({
840
924
  pid: recorded.pid,
841
925
  backend: recorded.backend,
@@ -847,7 +931,12 @@ export const powerAssertion = buildTool({
847
931
  });
848
932
  if (input.action === "status") {
849
933
  if (state === undefined) {
850
- return json({ ...head, outcome: "none", reason: "this package is holding no assertion" });
934
+ return json({
935
+ ...head,
936
+ outcome: "none",
937
+ reason: "this package is holding no assertion",
938
+ ...legacyNote(),
939
+ });
851
940
  }
852
941
  const { verdict, argv } = await liveness(state);
853
942
  const expired = now() >= state.expiresAt;
@@ -878,7 +967,12 @@ export const powerAssertion = buildTool({
878
967
  }
879
968
  if (input.action === "release") {
880
969
  if (state === undefined) {
881
- return json({ ...head, outcome: "none", reason: "this package is holding no assertion" });
970
+ return json({
971
+ ...head,
972
+ outcome: "none",
973
+ reason: "this package is holding no assertion",
974
+ ...legacyNote(),
975
+ });
882
976
  }
883
977
  const { verdict, argv } = await liveness(state);
884
978
  if (verdict === "unknown") {
@@ -893,22 +987,30 @@ export const powerAssertion = buildTool({
893
987
  probe: argv.join(" "),
894
988
  });
895
989
  }
990
+ // A dry run changes nothing, the record included.
991
+ const dry = input.dryRun === true;
896
992
  if (verdict === "gone") {
897
- fs().remove(statePath());
993
+ if (!dry)
994
+ fs().remove(statePath());
898
995
  return json({
899
996
  ...head,
900
997
  outcome: "expired",
901
998
  ...stateJson(state),
902
- reason: "the holder had already ended; the recorded assertion was cleared",
999
+ reason: dry
1000
+ ? "the holder has already ended; a real release would clear the recorded assertion"
1001
+ : "the holder had already ended; the recorded assertion was cleared",
903
1002
  });
904
1003
  }
905
1004
  if (verdict === "reused") {
906
- fs().remove(statePath());
1005
+ if (!dry)
1006
+ fs().remove(statePath());
907
1007
  return json({
908
1008
  ...head,
909
1009
  outcome: "stale",
910
1010
  ...stateJson(state),
911
- reason: "a process with the recorded pid exists but is not this package's holder, so the pid was reused — NOTHING was signalled and the recorded assertion was cleared",
1011
+ reason: `${state.platform !== platform
1012
+ ? `the record was made on ${state.platform}, not ${platform}, so its pid is not a holder of this package's`
1013
+ : "a process with the recorded pid exists but is not this package's holder, so the pid was reused"} — NOTHING was signalled and the recorded assertion ${dry ? "would be" : "was"} cleared`,
912
1014
  });
913
1015
  }
914
1016
  const killRequest = planRelease(platform, state.pid);
@@ -1004,7 +1106,25 @@ export const powerAssertion = buildTool({
1004
1106
  expiresAt,
1005
1107
  reason: input.reason ?? null,
1006
1108
  };
1007
- fs().writeTextAtomic(statePath(), JSON.stringify(recorded));
1109
+ try {
1110
+ fs().writeTextAtomic(statePath(), JSON.stringify(recorded));
1111
+ }
1112
+ catch (err) {
1113
+ if (!(err instanceof StateFileError))
1114
+ throw err;
1115
+ // The holder is running and bounded; only the record is missing.
1116
+ return json({
1117
+ ...head,
1118
+ outcome: "heldUnrecorded",
1119
+ backend: plan.backend,
1120
+ scope,
1121
+ pid: detached.pid,
1122
+ seconds: bounded.seconds,
1123
+ startedAt: new Date(startedAt).toISOString(),
1124
+ expiresAt: new Date(expiresAt).toISOString(),
1125
+ reason: `${err.message} — the inhibitor is held and still ends at its own deadline, but release cannot end it sooner`,
1126
+ });
1127
+ }
1008
1128
  return json({
1009
1129
  ...head,
1010
1130
  outcome: "held",
@@ -111,6 +111,7 @@ export declare function classifyClipboardRead(result: {
111
111
  readonly missing: boolean;
112
112
  readonly refused?: boolean;
113
113
  readonly stdoutTruncated?: boolean;
114
+ readonly outputIncomplete?: boolean;
114
115
  }): ClipboardReadOutcome;
115
116
  /**
116
117
  * Read the macOS disambiguating probe.
@@ -192,6 +192,12 @@ export function classifyClipboardRead(result) {
192
192
  // have said is unknown, and unknown is not empty.
193
193
  return unavailable("unknown", "the clipboard program did not finish within its timeout and was killed - the clipboard could not be read, which is not the same as it being empty");
194
194
  }
195
+ if (result.code === 0 && result.stdout.length === 0 && result.outputIncomplete === true) {
196
+ // Something the reader started held its output open past the drain
197
+ // grace, and nothing had arrived by then. Nothing arriving is not the
198
+ // clipboard being empty.
199
+ return unavailable("unknown", "the clipboard program's output could not be read to its end, and nothing had arrived - the clipboard could not be read, which is not the same as it being empty");
200
+ }
195
201
  if (result.code === 0) {
196
202
  if (result.stdout.length > 0)
197
203
  return { outcome: "read", text: result.stdout };
@@ -7,14 +7,26 @@ export type PathFacts = {
7
7
  readonly mode: number;
8
8
  readonly sizeBytes: number;
9
9
  };
10
+ /**
11
+ * The three state-file operations are for a PRIVATE file: one in a directory
12
+ * only this user can write (see `powerStateFile` in ../index.ts). The real
13
+ * implementation refuses the directory when another user owns it or can write
14
+ * it, and refuses a symlink, FIFO or other non-regular file at the leaf —
15
+ * throwing a {@link StateFileError} that names the path and the reason.
16
+ */
10
17
  export type HostFs = {
11
18
  stat(absolutePath: string): PathFacts | undefined;
19
+ /** The file's text, or undefined when there is no file. */
12
20
  readText(absolutePath: string): string | undefined;
13
21
  /** Write via a temporary file and a rename, so a crash cannot leave half a
14
22
  * state file behind for the next call to parse. */
15
23
  writeTextAtomic(absolutePath: string, text: string): void;
16
24
  remove(absolutePath: string): void;
17
25
  };
26
+ /** A private state file, or its directory, that cannot be trusted. Never carries the file's content. */
27
+ export declare class StateFileError extends Error {
28
+ readonly name = "StateFileError";
29
+ }
18
30
  export declare function fs(): HostFs;
19
31
  export declare function _setFs(replacement: HostFs | undefined): void;
20
32
  /** The owner/group/other execute bits. */
@@ -12,8 +12,55 @@
12
12
  * umask of whoever ran it, and a test that wrote a real state file would be
13
13
  * racing every other test in the same process.
14
14
  */
15
- import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
16
- import { dirname } from "node:path";
15
+ import { chmodSync, lstatSync, mkdirSync, statSync, unlinkSync } from "node:fs";
16
+ import { basename, dirname } from "node:path";
17
+ import { openForReadSync, writeFileSafe } from "@crewhaus/tool-safety/fs";
18
+ /** A private state file, or its directory, that cannot be trusted. Never carries the file's content. */
19
+ export class StateFileError extends Error {
20
+ name = "StateFileError";
21
+ }
22
+ /** A state record is a few hundred bytes; anything past this is not one. */
23
+ const STATE_MAX_BYTES = 64 * 1024;
24
+ /**
25
+ * Is `dir` a real directory that only this user can write? With `create`,
26
+ * make it (mode 0700) when it is missing, and tighten the mode of one this
27
+ * user owns. A directory someone else owns — another user pre-creating
28
+ * `/tmp/crewhaus-<uid>` to plant a record — is refused, because the record
29
+ * names the pid `release` signals.
30
+ */
31
+ function checkPrivateDir(dir, create) {
32
+ let stats;
33
+ try {
34
+ stats = lstatSync(dir, { throwIfNoEntry: false });
35
+ }
36
+ catch (err) {
37
+ throw new StateFileError(`the state directory ${dir} cannot be read: ${err.message}`);
38
+ }
39
+ if (stats === undefined) {
40
+ if (!create)
41
+ return "absent";
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ stats = lstatSync(dir);
44
+ }
45
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
46
+ throw new StateFileError(`the state directory ${dir} is not a directory (a symlink or file is there), so it is not trusted`);
47
+ }
48
+ if (process.platform === "win32")
49
+ return "ok";
50
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
51
+ if (uid !== undefined && stats.uid !== uid) {
52
+ throw new StateFileError(`the state directory ${dir} belongs to another user, so a record in it is not trusted`);
53
+ }
54
+ // Group or other WRITE is what lets someone else plant a record; read is
55
+ // only the pid and the deadline. A directory this call creates is 0700.
56
+ if ((stats.mode & 0o022) !== 0) {
57
+ if (!create) {
58
+ throw new StateFileError(`the state directory ${dir} can be written by other users (mode ${(stats.mode & 0o777).toString(8)}), so a record in it is not trusted`);
59
+ }
60
+ chmodSync(dir, 0o700);
61
+ }
62
+ return "ok";
63
+ }
17
64
  const nodeFs = {
18
65
  stat(absolutePath) {
19
66
  let stats;
@@ -41,21 +88,49 @@ const nodeFs = {
41
88
  };
42
89
  },
43
90
  readText(absolutePath) {
44
- try {
45
- return readFileSync(absolutePath, "utf-8");
46
- }
47
- catch {
91
+ const dir = dirname(absolutePath);
92
+ if (checkPrivateDir(dir, false) === "absent")
48
93
  return undefined;
94
+ // Contained to the directory, never through a link at the leaf, and a
95
+ // FIFO is refused before it is opened (a blocking open would freeze
96
+ // every session in the process).
97
+ const read = openForReadSync(dir, basename(absolutePath), {
98
+ maxBytes: STATE_MAX_BYTES,
99
+ followLeafSymlink: false,
100
+ });
101
+ if (!read.ok) {
102
+ if (read.code === "not-found")
103
+ return undefined;
104
+ throw new StateFileError(`the state file ${absolutePath} is not trusted: ${read.reason}`);
105
+ }
106
+ if (read.truncated) {
107
+ throw new StateFileError(`the state file ${absolutePath} is larger than ${STATE_MAX_BYTES} bytes, so it is not a record this package wrote`);
49
108
  }
109
+ return read.text;
50
110
  },
51
111
  writeTextAtomic(absolutePath, text) {
52
- mkdirSync(dirname(absolutePath), { recursive: true });
53
- const temp = `${absolutePath}.${process.pid}.tmp`;
54
- writeFileSync(temp, text, { mode: 0o600 });
55
- renameSync(temp, absolutePath);
112
+ const dir = dirname(absolutePath);
113
+ checkPrivateDir(dir, true);
114
+ // A random O_EXCL|O_NOFOLLOW temp, renamed into place: the old
115
+ // `<path>.<pid>.tmp` was a predictable name opened through any link.
116
+ const written = writeFileSafe(dir, basename(absolutePath), text, {
117
+ overwrite: true,
118
+ mode: 0o600,
119
+ });
120
+ if (!written.ok) {
121
+ throw new StateFileError(`the state file ${absolutePath} was not written: ${written.reason}`);
122
+ }
56
123
  },
57
124
  remove(absolutePath) {
58
- rmSync(absolutePath, { force: true });
125
+ // unlink removes a link itself, never what it points at.
126
+ try {
127
+ unlinkSync(absolutePath);
128
+ }
129
+ catch (err) {
130
+ if (err.code === "ENOENT")
131
+ return;
132
+ throw new StateFileError(`the state file ${absolutePath} could not be removed: ${err.message}`);
133
+ }
59
134
  },
60
135
  };
61
136
  let fsOverride;
@@ -106,7 +106,13 @@ export declare function uriScheme(value: string): string | undefined;
106
106
  * pure.
107
107
  */
108
108
  export declare function classifyTarget(target: string, allowed: readonly OpenableScheme[]): TargetDecision;
109
- /** The lowercase extension, or `""`. */
109
+ /**
110
+ * The lowercase extension, or `""`.
111
+ *
112
+ * Trailing dots and spaces are dropped first: Windows strips them when it
113
+ * opens a path, so `x.bat.` and `x.bat ` run `x.bat`, and reading them as
114
+ * "no extension" let a refused type through by spelling.
115
+ */
110
116
  export declare function extensionOf(path: string): string;
111
117
  /**
112
118
  * Why a contained path is still not openable.