pi-harness-runtime 1.1.448 → 1.1.451

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/AGENTS.md +24 -3
  2. package/CHANGELOG.md +59 -0
  3. package/README.md +30 -0
  4. package/harness/key-file-audit.ts +252 -42
  5. package/harness/startup-report.ts +32 -7
  6. package/index.ts +142 -22
  7. package/package.json +1 -1
  8. package/packages/agent-policy/package.json +1 -1
  9. package/packages/auth/package.json +1 -1
  10. package/packages/autonomous-runtime/package.json +1 -1
  11. package/packages/checkpoint/package.json +1 -1
  12. package/packages/clipboard/package.json +1 -1
  13. package/packages/codex-watcher/package.json +1 -1
  14. package/packages/config-capture/package.json +1 -1
  15. package/packages/contracts/package.json +1 -1
  16. package/packages/cookie-sanitizer/package.json +1 -1
  17. package/packages/evaluation-engine/package.json +1 -1
  18. package/packages/event-bus/package.json +1 -1
  19. package/packages/event-store/package.json +1 -1
  20. package/packages/experience-replay/package.json +1 -1
  21. package/packages/file-copy-helper/package.json +1 -1
  22. package/packages/intent-analyzer/package.json +1 -1
  23. package/packages/jev-judge/dist/auto-continue.d.ts +39 -1
  24. package/packages/jev-judge/dist/auto-continue.d.ts.map +1 -1
  25. package/packages/jev-judge/dist/auto-continue.js +91 -3
  26. package/packages/jev-judge/dist/auto-continue.js.map +1 -1
  27. package/packages/jev-judge/dist/autonomy-instruction.d.ts +52 -0
  28. package/packages/jev-judge/dist/autonomy-instruction.d.ts.map +1 -0
  29. package/packages/jev-judge/dist/autonomy-instruction.js +92 -0
  30. package/packages/jev-judge/dist/autonomy-instruction.js.map +1 -0
  31. package/packages/jev-judge/dist/index.d.ts +3 -1
  32. package/packages/jev-judge/dist/index.d.ts.map +1 -1
  33. package/packages/jev-judge/dist/index.js +2 -0
  34. package/packages/jev-judge/dist/index.js.map +1 -1
  35. package/packages/jev-judge/dist/question-risk.d.ts +8 -0
  36. package/packages/jev-judge/dist/question-risk.d.ts.map +1 -1
  37. package/packages/jev-judge/dist/question-risk.js +7 -0
  38. package/packages/jev-judge/dist/question-risk.js.map +1 -1
  39. package/packages/jev-judge/package.json +1 -1
  40. package/packages/jev-judge/src/auto-continue.ts +134 -5
  41. package/packages/jev-judge/src/autonomy-instruction.ts +140 -0
  42. package/packages/jev-judge/src/index.ts +16 -1
  43. package/packages/jev-judge/src/question-risk.ts +16 -0
  44. package/packages/knowledge-retrieval/package.json +1 -1
  45. package/packages/learning-engine/package.json +1 -1
  46. package/packages/logging/package.json +1 -1
  47. package/packages/memory-engine/package.json +1 -1
  48. package/packages/notification/dist/pending-question.d.ts +114 -3
  49. package/packages/notification/dist/pending-question.d.ts.map +1 -1
  50. package/packages/notification/dist/pending-question.js +277 -12
  51. package/packages/notification/dist/pending-question.js.map +1 -1
  52. package/packages/notification/package.json +1 -1
  53. package/packages/notification/src/pending-question.ts +389 -11
  54. package/packages/paperclip-integration/package.json +1 -1
  55. package/packages/performance-optimizer/package.json +1 -1
  56. package/packages/privilege-broker/package.json +1 -1
  57. package/packages/prompt-compiler/package.json +1 -1
  58. package/packages/providers/package.json +1 -1
  59. package/packages/qdrant-skills/package.json +1 -1
  60. package/packages/quota-manager/package.json +1 -1
  61. package/packages/relay/package.json +1 -1
  62. package/packages/skill-mcp-client/package.json +1 -1
  63. package/packages/skills/package.json +1 -1
  64. package/packages/todo-bd-sync/package.json +1 -1
  65. package/packages/token-estimation/package.json +1 -1
  66. package/packages/tooling-preference/package.json +1 -1
  67. package/packages/trajectory/package.json +1 -1
  68. package/packages/tui/package.json +1 -1
  69. package/packages/two-phase-review/package.json +1 -1
  70. package/packages/types/package.json +1 -1
  71. package/packages/webmcp-tester/package.json +1 -1
  72. package/packages/wiki/package.json +1 -1
  73. package/packages/write-review/package.json +1 -1
package/AGENTS.md CHANGED
@@ -117,9 +117,10 @@ git push origin develop
117
117
  # request into main.
118
118
  bun scripts/release-all.ts --release-as patch
119
119
 
120
- # 3. The auto merge workflow merges develop into main as soon as the
121
- # gates are green: "No sensitive terms" and "Tests".
122
- # A pull request that is not green waits for a human.
120
+ # 3. The auto merge workflow merges the pull request into main as soon
121
+ # as the gates are green: "No sensitive terms" and "Tests".
122
+ # The workflow merges the pull request itself, with the commit that
123
+ # was checked. A pull request that is not green waits for a human.
123
124
 
124
125
  # 4. After the merge, the tag is created on the tip of main.
125
126
  bun scripts/release-all.ts --tag
@@ -128,6 +129,26 @@ bun scripts/release-all.ts --tag
128
129
  # and creates the GitHub release.
129
130
  ```
130
131
 
132
+ **The merge gate**: `.github/workflows/auto-merge.yml` runs on a pull
133
+ request into `main`. It reads the checks of the head commit of the pull
134
+ request and merges the pull request when `No sensitive terms` and
135
+ `Tests` are both green. The merge is the GraphQL mutation
136
+ `mergePullRequest`, with the head commit that was checked - a push to
137
+ the pull request after the check has to pass the gates again. The
138
+ workflow checks out the base branch and runs no code of the pull
139
+ request, so a pull request cannot change the rule that judges it.
140
+
141
+ **The manual path**: a pull request that a workflow opened starts no
142
+ workflow of its own, so the manual run takes that work over.
143
+
144
+ ```bash
145
+ gh workflow run auto-merge.yml --ref develop -f open_release_pr=true
146
+ ```
147
+
148
+ The manual run opens the pull request from `develop` into `main` when
149
+ there is none, reads the same gates and merges the pull request when
150
+ they are green.
151
+
131
152
  **Rules**:
132
153
 
133
154
  - **Never publish from `develop`.** The release workflow refuses a tag
package/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [1.1.451](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.451) (2026-10-27)
6
+
7
+
8
+ ### Features
9
+
10
+ * **jev:** the pending question carries the ask and the answer ([a792133](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a792133e517fb8c12d3363e98942f3af30270f27))
11
+ * **merge:** a manual run can leave the merge to the pull request event ([b3d1950](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b3d1950b5941c78ccd56951acdafd1d4afbe1bff))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **hooks:** a tag is checked through the commit behind it ([c823e8e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c823e8e1f6973c62b5449a97c101759a4d5cbba5))
17
+ * **jev:** the alternatives of a question are parsed without a quadratic pattern ([63016a3](https://github.com/ManotLuijiu/pi-harness-runtime/commit/63016a3f0488acb801990dd46770c00d382b5858))
18
+ * **keys:** the key directory is audited as a tree ([081cc75](https://github.com/ManotLuijiu/pi-harness-runtime/commit/081cc75ac8ceff36b0d619c3f76371dc08459d3c))
19
+ * **merge:** the boolean input is read as a boolean ([971b716](https://github.com/ManotLuijiu/pi-harness-runtime/commit/971b7169a43db382dbdeb6bb9bab02ab56e0351c))
20
+ * **merge:** the manual job needs the right of the merge ([7ba685e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7ba685ec43d815f90aa35b742579e87e76c69209))
21
+ * **merge:** the merge of the pull request is the GraphQL mutation ([972bab4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/972bab4b0f7faf1b0047f3bb767ef6db3de4b2fa))
22
+ * **merge:** the workflow merges the pull request the gates cleared ([d7639ec](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7639ec9b83723510acb4a5c4e23d5d6167c3af6))
23
+ * **release:** a manual release keeps the version of its manifest ([122e661](https://github.com/ManotLuijiu/pi-harness-runtime/commit/122e661ea48cb8aff14c71dbd0221a4cf0ba4c8e))
24
+ * **release:** the release scans the tree of the commit it publishes ([7d47a0b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7d47a0b5406480a1e1d3a52623a76aabd1e2d0ba))
25
+
26
+ ### [1.1.450](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.450) (2026-10-27)
27
+
28
+
29
+ ### Features
30
+
31
+ * **jev:** the pending question carries the ask and the answer ([a792133](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a792133e517fb8c12d3363e98942f3af30270f27))
32
+ * **merge:** a manual run can leave the merge to the pull request event ([b3d1950](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b3d1950b5941c78ccd56951acdafd1d4afbe1bff))
33
+
34
+
35
+ ### Bug Fixes
36
+
37
+ * **hooks:** a tag is checked through the commit behind it ([c823e8e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c823e8e1f6973c62b5449a97c101759a4d5cbba5))
38
+ * **jev:** the alternatives of a question are parsed without a quadratic pattern ([63016a3](https://github.com/ManotLuijiu/pi-harness-runtime/commit/63016a3f0488acb801990dd46770c00d382b5858))
39
+ * **keys:** the key directory is audited as a tree ([081cc75](https://github.com/ManotLuijiu/pi-harness-runtime/commit/081cc75ac8ceff36b0d619c3f76371dc08459d3c))
40
+ * **merge:** the boolean input is read as a boolean ([971b716](https://github.com/ManotLuijiu/pi-harness-runtime/commit/971b7169a43db382dbdeb6bb9bab02ab56e0351c))
41
+ * **merge:** the manual job needs the right of the merge ([7ba685e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7ba685ec43d815f90aa35b742579e87e76c69209))
42
+ * **merge:** the merge of the pull request is the GraphQL mutation ([972bab4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/972bab4b0f7faf1b0047f3bb767ef6db3de4b2fa))
43
+ * **merge:** the workflow merges the pull request the gates cleared ([d7639ec](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7639ec9b83723510acb4a5c4e23d5d6167c3af6))
44
+ * **release:** the release scans the tree of the commit it publishes ([7d47a0b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7d47a0b5406480a1e1d3a52623a76aabd1e2d0ba))
45
+
46
+ ### [1.1.449](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.449) (2026-10-27)
47
+
48
+
49
+ ### Features
50
+
51
+ * **jev:** the pending question carries the ask and the answer ([a792133](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a792133e517fb8c12d3363e98942f3af30270f27))
52
+ * **merge:** a manual run can leave the merge to the pull request event ([b3d1950](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b3d1950b5941c78ccd56951acdafd1d4afbe1bff))
53
+
54
+
55
+ ### Bug Fixes
56
+
57
+ * **hooks:** a tag is checked through the commit behind it ([c823e8e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c823e8e1f6973c62b5449a97c101759a4d5cbba5))
58
+ * **keys:** the key directory is audited as a tree ([081cc75](https://github.com/ManotLuijiu/pi-harness-runtime/commit/081cc75ac8ceff36b0d619c3f76371dc08459d3c))
59
+ * **merge:** the boolean input is read as a boolean ([971b716](https://github.com/ManotLuijiu/pi-harness-runtime/commit/971b7169a43db382dbdeb6bb9bab02ab56e0351c))
60
+ * **merge:** the manual job needs the right of the merge ([7ba685e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7ba685ec43d815f90aa35b742579e87e76c69209))
61
+ * **merge:** the merge of the pull request is the GraphQL mutation ([972bab4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/972bab4b0f7faf1b0047f3bb767ef6db3de4b2fa))
62
+ * **merge:** the workflow merges the pull request the gates cleared ([d7639ec](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7639ec9b83723510acb4a5c4e23d5d6167c3af6))
63
+
5
64
  ### [1.1.448](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.447...v1.1.448) (2026-10-26)
6
65
 
7
66
 
package/README.md CHANGED
@@ -406,6 +406,23 @@ extension reads its own files (`qdrant-cluster-url.txt`, not
406
406
  variable). The startup fix commands always name the file the running code
407
407
  actually reads.
408
408
 
409
+ **The permissions of the key directory.** The directory holds the secrets of
410
+ the runtime, so it is created private (`700`) and audited at startup: a key
411
+ file or a directory of the key tree that the group or the others can read is
412
+ reported in the startup summary, together with one command that makes the
413
+ whole tree private again. The command is shown, never executed:
414
+
415
+ ```bash
416
+ chmod 700 ~/.pi-harness-runtime/keys && find ~/.pi-harness-runtime/keys -type d -exec chmod 700 {} + && find ~/.pi-harness-runtime/keys -type f -exec chmod 600 {} +
417
+ ```
418
+
419
+ On Windows the same intent needs the access control list instead of the mode
420
+ bits, and the summary says so:
421
+
422
+ ```powershell
423
+ icacls "$env:USERPROFILE\.pi-harness-runtime\keys" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F" /T
424
+ ```
425
+
409
426
  See [Relay Setup](#relay-setup-two-way-telegram-stream) for the `echo` commands that
410
427
  create the relay and Telegram key files.
411
428
 
@@ -1312,6 +1329,19 @@ bun scripts/release-all.ts --release-as patch # prepare the version on develop
1312
1329
  bun scripts/release-all.ts --tag # tag the tip of main after the merge
1313
1330
  ```
1314
1331
 
1332
+ **The merge gate**: the workflow merges the pull request itself, with
1333
+ the commit whose checks it read, so a push to the pull request after
1334
+ the check has to pass the gates again. The workflow runs the script of
1335
+ the base branch and no code of the pull request, so a pull request
1336
+ cannot change the rule that judges it.
1337
+
1338
+ A pull request that a workflow opened starts no workflow of its own, so
1339
+ the manual run takes the work over:
1340
+
1341
+ ```bash
1342
+ gh workflow run auto-merge.yml --ref develop -f open_release_pr=true
1343
+ ```
1344
+
1315
1345
  ## License
1316
1346
 
1317
1347
  MIT © 2026 MooCoding
@@ -15,7 +15,7 @@
15
15
  * state of a file (missing / empty).
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
21
 
@@ -319,6 +319,44 @@ export interface ExposedKeyFile {
319
319
  readableBy: "group" | "others" | "group and others";
320
320
  }
321
321
 
322
+ /**
323
+ * A directory of the key tree that other users on this machine can reach.
324
+ *
325
+ * A readable directory publishes the NAMES of the secrets below it and,
326
+ * together with a readable file, their content. The key directory itself is
327
+ * part of the audit: a directory of mode 755 hands the file names of every
328
+ * key to every account on the machine.
329
+ */
330
+ export interface ExposedKeyDirectory {
331
+ /**
332
+ * The name of the directory, relative to the key directory.
333
+ *
334
+ * The key directory itself is ".".
335
+ */
336
+ name: string;
337
+ /** The mode of the directory, as an octal string ("755"). */
338
+ mode: string;
339
+ /** Who can reach the directory besides the owner. */
340
+ readableBy: "group" | "others" | "group and others";
341
+ }
342
+
343
+ /**
344
+ * The information the audit needs about one entry of the key tree.
345
+ */
346
+ export interface KeyEntryInfo {
347
+ /** The permission bits. */
348
+ mode: number;
349
+ /** Is it a regular file? */
350
+ isFile: boolean;
351
+ /** Is it a directory? */
352
+ isDirectory?: boolean;
353
+ /** Is it a symbolic link? */
354
+ isSymbolicLink?: boolean;
355
+ }
356
+
357
+ /** How deep the audit walks into the key tree. */
358
+ const MAX_KEY_TREE_DEPTH = 4;
359
+
322
360
  export interface KeyFileAuditOptions {
323
361
  /** The key directory to scan (e.g. ~/.pi-harness-runtime/keys). */
324
362
  keysDir: string;
@@ -330,8 +368,15 @@ export interface KeyFileAuditOptions {
330
368
  exists?: (path: string) => boolean;
331
369
  /** Directory reader override (used by the tests). */
332
370
  readdir?: (path: string) => string[];
333
- /** File information override (used by the tests). */
334
- stat?: (path: string) => { mode: number; isFile: boolean } | undefined;
371
+ /**
372
+ * File information override (used by the tests).
373
+ *
374
+ * The audit never follows a symbolic link: a link is reported by the
375
+ * `isSymbolicLink` flag and is never classified as a regular file.
376
+ */
377
+ stat?: (path: string) => KeyEntryInfo | undefined;
378
+ /** The platform the fix commands are written for (defaults to the current one). */
379
+ platform?: NodeJS.Platform;
335
380
  }
336
381
 
337
382
  export interface KeyFileAuditResult {
@@ -341,6 +386,125 @@ export interface KeyFileAuditResult {
341
386
  byFeature: Map<string, MissingCredential[]>;
342
387
  /** The key files that other users on this machine can read. */
343
388
  exposed: ExposedKeyFile[];
389
+ /** The directories of the key tree that other users can reach. */
390
+ exposedDirectories: ExposedKeyDirectory[];
391
+ }
392
+
393
+ /**
394
+ * Compare two file names in a stable, locale independent order.
395
+ */
396
+ function compareByName(a: string, b: string): number {
397
+ if (a < b) return -1;
398
+ if (a > b) return 1;
399
+ return 0;
400
+ }
401
+
402
+ /**
403
+ * Who can reach an entry besides its owner.
404
+ */
405
+ function readableByOf(mode: number): ExposedKeyFile["readableBy"] | undefined {
406
+ const group = mode & 0o070;
407
+ const others = mode & 0o007;
408
+ if (group === 0 && others === 0) return undefined;
409
+ if (group !== 0 && others !== 0) return "group and others";
410
+ return group !== 0 ? "group" : "others";
411
+ }
412
+
413
+ /**
414
+ * The default entry reader: `lstat`, never `stat`.
415
+ *
416
+ * `lstat` does not follow a symbolic link, so a link to a secret is never
417
+ * read through and never counted as the file it points at.
418
+ */
419
+ function defaultStat(path: string): KeyEntryInfo | undefined {
420
+ try {
421
+ const info = lstatSync(path);
422
+ return {
423
+ mode: info.mode & 0o777,
424
+ isFile: info.isFile(),
425
+ isDirectory: info.isDirectory(),
426
+ isSymbolicLink: info.isSymbolicLink(),
427
+ };
428
+ } catch {
429
+ return undefined;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * The default directory reader.
435
+ */
436
+ function defaultReaddir(path: string): string[] {
437
+ try {
438
+ return readdirSync(path);
439
+ } catch {
440
+ return [];
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Walk the key tree and collect the exposed files and directories.
446
+ *
447
+ * The whole tree is scanned, not only the files of the inventory: a key
448
+ * file that the inventory does not know yet is a secret too. A symbolic
449
+ * link is never followed and never classified - it is neither a regular
450
+ * file nor a directory of the key tree.
451
+ */
452
+ function collectKeyTree(
453
+ options: KeyFileAuditOptions,
454
+ ): { files: ExposedKeyFile[]; directories: ExposedKeyDirectory[] } {
455
+ const readdir = options.readdir ?? defaultReaddir;
456
+ const stat = options.stat ?? defaultStat;
457
+ const files: ExposedKeyFile[] = [];
458
+ const directories: ExposedKeyDirectory[] = [];
459
+
460
+ // The key directory itself: a readable directory publishes the names of
461
+ // every secret below it.
462
+ const root = stat(options.keysDir);
463
+ if (root?.isDirectory) {
464
+ const readableBy = readableByOf(root.mode);
465
+ if (readableBy) {
466
+ directories.push({
467
+ name: ".",
468
+ mode: root.mode.toString(8).padStart(3, "0"),
469
+ readableBy,
470
+ });
471
+ }
472
+ }
473
+
474
+ const walk = (dir: string, relative: string, depth: number): void => {
475
+ if (depth > MAX_KEY_TREE_DEPTH) return;
476
+ for (const name of readdir(dir).sort(compareByName)) {
477
+ const path = join(dir, name);
478
+ const info = stat(path);
479
+ if (!info) continue;
480
+ // A symbolic link is neither followed nor classified.
481
+ if (info.isSymbolicLink) continue;
482
+ const entryName = relative ? `${relative}/${name}` : name;
483
+ if (info.isDirectory) {
484
+ const readableBy = readableByOf(info.mode);
485
+ if (readableBy) {
486
+ directories.push({
487
+ name: entryName,
488
+ mode: info.mode.toString(8).padStart(3, "0"),
489
+ readableBy,
490
+ });
491
+ }
492
+ walk(path, entryName, depth + 1);
493
+ continue;
494
+ }
495
+ if (!info.isFile) continue;
496
+ const readableBy = readableByOf(info.mode);
497
+ if (!readableBy) continue;
498
+ files.push({
499
+ file: entryName,
500
+ mode: info.mode.toString(8).padStart(3, "0"),
501
+ readableBy,
502
+ });
503
+ }
504
+ };
505
+
506
+ walk(options.keysDir, "", 1);
507
+ return { files, directories };
344
508
  }
345
509
 
346
510
  /**
@@ -355,45 +519,20 @@ export interface KeyFileAuditResult {
355
519
  * key file that the inventory does not know yet is a secret too.
356
520
  */
357
521
  export function scanExposedKeyFiles(options: KeyFileAuditOptions): ExposedKeyFile[] {
358
- const readdir =
359
- options.readdir ??
360
- ((path: string) => {
361
- try {
362
- return readdirSync(path);
363
- } catch {
364
- return [];
365
- }
366
- });
367
- const stat =
368
- options.stat ??
369
- ((path: string) => {
370
- try {
371
- const info = statSync(path);
372
- return { mode: info.mode & 0o777, isFile: info.isFile() };
373
- } catch {
374
- return undefined;
375
- }
376
- });
522
+ return collectKeyTree(options).files;
523
+ }
377
524
 
378
- const exposed: ExposedKeyFile[] = [];
379
- for (const name of readdir(options.keysDir).sort()) {
380
- const info = stat(join(options.keysDir, name));
381
- if (!info || !info.isFile) continue;
382
- const group = info.mode & 0o070;
383
- const others = info.mode & 0o007;
384
- if (group === 0 && others === 0) continue;
385
- exposed.push({
386
- file: name,
387
- mode: info.mode.toString(8).padStart(3, "0"),
388
- readableBy:
389
- group !== 0 && others !== 0
390
- ? "group and others"
391
- : group !== 0
392
- ? "group"
393
- : "others",
394
- });
395
- }
396
- return exposed;
525
+ /**
526
+ * The directories of the key tree that other users can reach.
527
+ *
528
+ * The key directory itself is the first one: a directory of mode 755 hands
529
+ * the file names of every key to every account on the machine. A missing
530
+ * key directory is not an exposure - there is nothing to expose.
531
+ */
532
+ export function scanExposedKeyDirectories(
533
+ options: KeyFileAuditOptions,
534
+ ): ExposedKeyDirectory[] {
535
+ return collectKeyTree(options).directories;
397
536
  }
398
537
 
399
538
  /**
@@ -471,7 +610,13 @@ export function auditKeyFiles(options: KeyFileAuditOptions): KeyFileAuditResult
471
610
  byFeature.set(entry.requirement.feature, list);
472
611
  }
473
612
 
474
- return { missing, byFeature, exposed: scanExposedKeyFiles(options) };
613
+ const tree = collectKeyTree(options);
614
+ return {
615
+ missing,
616
+ byFeature,
617
+ exposed: tree.files,
618
+ exposedDirectories: tree.directories,
619
+ };
475
620
  }
476
621
 
477
622
  /**
@@ -487,6 +632,71 @@ export function formatExposedKeyFileFix(
487
632
  return `chmod 600 ${join(displayKeysDir(keysDir), entry.file)}`;
488
633
  }
489
634
 
635
+ /**
636
+ * Quote a path for a POSIX shell.
637
+ *
638
+ * The key directory can hold a file name with a space, a quote or a dollar
639
+ * sign. The command must not run into the shell, and it must not turn a
640
+ * name into a second command.
641
+ */
642
+ export function shellQuote(value: string): string {
643
+ return `'${value.replace(/'/g, `'\\''`)}'`;
644
+ }
645
+
646
+ /**
647
+ * The one command that makes the whole key tree private again.
648
+ *
649
+ * The startup summary shows ONE command, not one command per file: a list
650
+ * of twenty `chmod` lines is a list nobody runs. The command covers the
651
+ * key directory, the directories below it and the files in it, and it is
652
+ * safe for any file name - `find` passes the names to `chmod` itself, so
653
+ * no name is ever interpreted by the shell.
654
+ *
655
+ * The command is never executed by the harness. It is a suggestion for the
656
+ * operator, and the operator decides when to run it.
657
+ */
658
+ export function formatKeyPermissionFix(
659
+ keysDir: string,
660
+ platform: NodeJS.Platform = process.platform,
661
+ ): string {
662
+ if (platform === "win32") {
663
+ // Windows has no Unix mode bits. The equivalent is the access
664
+ // control list of the directory and of the files below it.
665
+ return `icacls "${keysDir.replace(/"/g, '""')}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F" /T`;
666
+ }
667
+ const dir = shellQuote(keysDir);
668
+ return (
669
+ `chmod 700 ${dir} && ` +
670
+ `find ${dir} -type d -exec chmod 700 {} + && ` +
671
+ `find ${dir} -type f -exec chmod 600 {} +`
672
+ );
673
+ }
674
+
675
+ /**
676
+ * The fix lines for the exposed key files and directories.
677
+ *
678
+ * On a Unix-like system this is the single batch command. On Windows it is
679
+ * the guidance that the same intent needs - the Unix command does not
680
+ * exist there and would only confuse the operator.
681
+ */
682
+ export function formatKeyPermissionFixes(options: {
683
+ /** The key directory to repair. */
684
+ keysDir: string;
685
+ /** The platform the commands are written for. */
686
+ platform?: NodeJS.Platform;
687
+ }): string[] {
688
+ const platform = options.platform ?? process.platform;
689
+ const command = formatKeyPermissionFix(options.keysDir, platform);
690
+ if (platform === "win32") {
691
+ return [
692
+ "Windows: restrict the key directory to your account (the Unix mode bits do not exist here).",
693
+ command,
694
+ "The command changes the access rights, not the values.",
695
+ ];
696
+ }
697
+ return [command];
698
+ }
699
+
490
700
  /**
491
701
  * The fix commands for every key file that other users can read.
492
702
  */
@@ -11,7 +11,11 @@
11
11
  * - LINE must not be reported as configured when the audit could not run.
12
12
  */
13
13
 
14
- import type { ExposedKeyFile, KeyFileAuditResult } from "./key-file-audit.ts";
14
+ import type {
15
+ ExposedKeyDirectory,
16
+ ExposedKeyFile,
17
+ KeyFileAuditResult,
18
+ } from "./key-file-audit.ts";
15
19
 
16
20
  export type StartupStatus = "wait" | "ok" | "info" | "warn" | "err";
17
21
 
@@ -154,15 +158,16 @@ export function relayEndpointReport(options: {
154
158
  export function exposedKeyFilesReport(options: {
155
159
  /** The key files that other users can read. */
156
160
  exposed: ExposedKeyFile[];
161
+ /** The directories of the key tree that other users can reach. */
162
+ exposedDirectories?: ExposedKeyDirectory[];
157
163
  /** The `chmod` command for each of them. */
158
164
  fixes: string[];
159
165
  }): StartupReport | null {
160
- if (options.exposed.length === 0) return null;
161
- const count = options.exposed.length;
162
- const text =
163
- count === 1
164
- ? "1 key file can be read by other users on this machine"
165
- : `${count} key files can be read by other users on this machine`;
166
+ const directories = options.exposedDirectories ?? [];
167
+ if (options.exposed.length === 0 && directories.length === 0) return null;
168
+ const files = options.exposed.length;
169
+ const dirs = directories.length;
170
+ const text = exposedPermissionText(files, dirs);
166
171
  const details = [...options.fixes];
167
172
  if (details.length === 0) {
168
173
  details.push(
@@ -172,6 +177,26 @@ export function exposedKeyFilesReport(options: {
172
177
  return { label: "Keys", text, status: "warn", details };
173
178
  }
174
179
 
180
+ /**
181
+ * The text of the exposure line.
182
+ *
183
+ * It names the count and never a value: the line says that other users on
184
+ * the machine can read the files, it never says what is in them.
185
+ */
186
+ function exposedPermissionText(files: number, directories: number): string {
187
+ if (files > 0 && directories > 0) {
188
+ return `${files} key file(s) and ${directories} director(y/ies) of the key tree can be read by other users on this machine`;
189
+ }
190
+ if (files > 0) {
191
+ return files === 1
192
+ ? "1 key file can be read by other users on this machine"
193
+ : `${files} key files can be read by other users on this machine`;
194
+ }
195
+ return directories === 1
196
+ ? "1 directory of the key tree can be read by other users on this machine"
197
+ : `${directories} directories of the key tree can be read by other users on this machine`;
198
+ }
199
+
175
200
  /**
176
201
  * The two steps that create a usable LINE channel.
177
202
  *