pi-harness-runtime 1.1.447 → 1.1.450

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 +82 -9
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +74 -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 +2 -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
@@ -88,7 +88,7 @@ A task with a cheap tier and an expensive tier MUST use the cheap tier while the
88
88
 
89
89
  ## Versioning Convention
90
90
 
91
- **Only patch-level updates (`0.10.x`)** — no minor/major bumps until the app is fully functional.
91
+ **Only patch-level updates (`1.1.x`)** — no minor/major bumps until the app is fully functional.
92
92
 
93
93
  When releasing:
94
94
 
@@ -98,25 +98,98 @@ bun scripts/release-all.ts --release-as minor # only if explicitly requested
98
98
  bun scripts/release-all.ts --release-as major # only if explicitly requested
99
99
  ```
100
100
 
101
- ## Release Tag Convention (CRITICAL)
101
+ The version of a release follows the published history, not the manifest.
102
+ The script reads the last tag and bumps from there, so a release that
103
+ follows `v1.1.447` is `v1.1.448`.
102
104
 
103
- **ALWAYS release a tag after ANY code change.** Use patch version bump (0.10.x → 0.10.x+1).
105
+ ## Release Path: develop -> main -> npm (CRITICAL)
104
106
 
105
- This repo uses tag-based GitHub Actions builds — no CI on push. Every code change should be tagged for release.
107
+ `develop` is the branch the work is done on. `main` is the branch npm
108
+ publishes. A release is a **tag on `main`**, and only such a tag
109
+ publishes.
106
110
 
107
111
  ```bash
108
- # After git push, immediately tag and push:
109
- git tag v0.10.32 && git push origin v0.10.32
112
+ # 1. The work is committed to develop and pushed.
113
+ git push origin develop
110
114
 
111
- # Or use the release script:
115
+ # 2. The version is prepared on develop. The script bumps the manifest,
116
+ # syncs the workspace, commits, pushes develop, and opens the pull
117
+ # request into main.
112
118
  bun scripts/release-all.ts --release-as patch
119
+
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.
124
+
125
+ # 4. After the merge, the tag is created on the tip of main.
126
+ bun scripts/release-all.ts --tag
127
+
128
+ # 5. The tag triggers release.yml, which publishes to npm through OIDC
129
+ # and creates the GitHub release.
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
113
146
  ```
114
147
 
115
- **Rule**: Every `git push` should be followed by a tag push. No code change should go unreleased.
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
+
152
+ **Rules**:
153
+
154
+ - **Never publish from `develop`.** The release workflow refuses a tag
155
+ that is not reachable from `main`.
156
+ - **Never run a local `npm publish`.** The registry is published by the
157
+ release workflow, with the OIDC credential of the repository.
158
+ - **A tag is a release.** The tag names the version, and the version in
159
+ the manifest of `main` has to be the one the tag names.
160
+ - **The npm Trusted Publisher is bound to `release.yml` on `main`.** A
161
+ tag or a branch push cannot publish on its own.
162
+
163
+ ## Local Hooks: Husky (CRITICAL)
164
+
165
+ The hooks of the repository live in `.husky/` and are managed by
166
+ [Husky](https://typicode.github.io/husky/). There is no second hook
167
+ mechanism: `core.hooksPath` is `.husky/_`.
168
+
169
+ ```bash
170
+ bunx husky # install the hooks (also done by `bun install`)
171
+ ```
172
+
173
+ - **`.husky/pre-commit`** scans the staged change for a client name and
174
+ runs `lint-staged` on the staged files.
175
+ - **`.husky/pre-push`** scans the whole pushed range.
176
+ - The terms live in `~/.pi-harness-runtime/sensitive-terms.txt` (mode
177
+ `600`), outside the repository. The list is never committed and never
178
+ printed.
179
+ - The hooks **fail closed**: a machine without the list stops the commit
180
+ and names the way out. `PI_SENSITIVE_TERMS_SKIP=1` is the documented
181
+ way out for a contributor without the private list — the check of the
182
+ repository does that work for them.
183
+ - `git commit --no-verify` and `git push --no-verify` skip the hooks.
184
+ They are an escape hatch for a broken checkout, not a way to work.
185
+
186
+ **The local hook is the primary protection.** This repository and
187
+ `develop` are public: a name that is pushed there is disclosed, and the
188
+ check of the repository can only keep it from being merged.
116
189
 
117
190
  ## npm Publish Authentication
118
191
 
119
- - **Normal release path: do NOT run local `npm publish`.** Push the code and release tag; GitHub Actions will sync/publish to npm.
192
+ - **Normal release path: do NOT run local `npm publish`.** Push the change to `develop`, merge it into `main`, and tag `main`; the release workflow publishes through OIDC.
120
193
  - **GitHub Actions OIDC release path should not require `npm login`.**
121
194
  - If a **local/manual npm publish fallback** is needed and `npm whoami` fails, ask the user to run:
122
195
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
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.450](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.450) (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:** the release scans the tree of the commit it publishes ([7d47a0b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7d47a0b5406480a1e1d3a52623a76aabd1e2d0ba))
24
+
25
+ ### [1.1.449](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.448...v1.1.449) (2026-10-27)
26
+
27
+
28
+ ### Features
29
+
30
+ * **jev:** the pending question carries the ask and the answer ([a792133](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a792133e517fb8c12d3363e98942f3af30270f27))
31
+ * **merge:** a manual run can leave the merge to the pull request event ([b3d1950](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b3d1950b5941c78ccd56951acdafd1d4afbe1bff))
32
+
33
+
34
+ ### Bug Fixes
35
+
36
+ * **hooks:** a tag is checked through the commit behind it ([c823e8e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c823e8e1f6973c62b5449a97c101759a4d5cbba5))
37
+ * **keys:** the key directory is audited as a tree ([081cc75](https://github.com/ManotLuijiu/pi-harness-runtime/commit/081cc75ac8ceff36b0d619c3f76371dc08459d3c))
38
+ * **merge:** the boolean input is read as a boolean ([971b716](https://github.com/ManotLuijiu/pi-harness-runtime/commit/971b7169a43db382dbdeb6bb9bab02ab56e0351c))
39
+ * **merge:** the manual job needs the right of the merge ([7ba685e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/7ba685ec43d815f90aa35b742579e87e76c69209))
40
+ * **merge:** the merge of the pull request is the GraphQL mutation ([972bab4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/972bab4b0f7faf1b0047f3bb767ef6db3de4b2fa))
41
+ * **merge:** the workflow merges the pull request the gates cleared ([d7639ec](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7639ec9b83723510acb4a5c4e23d5d6167c3af6))
42
+
43
+ ### [1.1.448](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.447...v1.1.448) (2026-10-26)
44
+
45
+
46
+ ### Features
47
+
48
+ * **release:** publish from a tag on main and gate the merge on the checks ([d744098](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d7440985974e5bc6613c4e6ee05fb79a00996a0c))
49
+
5
50
  ### [1.1.190](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.403...v1.1.190) (2026-10-24)
6
51
 
7
52
 
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
 
@@ -1268,6 +1285,63 @@ callbacks are queued on disk instead.
1268
1285
  > **Note:** the token value is never logged, only the name of the source it came
1269
1286
  > from (`env RELAY_AUTH_TOKEN`, `keys/relay-auth-token.txt`, …).
1270
1287
 
1288
+ ## Contributing
1289
+
1290
+ The repository is public, and it holds the guard that keeps a client name
1291
+ out of it. Three layers do that work:
1292
+
1293
+ | Layer | What it does |
1294
+ | ----- | ------------ |
1295
+ | `.husky/pre-commit` | Scans the staged change and the staged paths, and runs `lint-staged` on the staged files. |
1296
+ | `.husky/pre-push` | Scans the whole pushed range. |
1297
+ | `.github/workflows/sensitive-terms.yml` | Scans every push and every pull request. |
1298
+
1299
+ **Local setup**:
1300
+
1301
+ ```bash
1302
+ bun install # installs the dependencies and the git hooks
1303
+ bunx husky # only when the hooks are not installed yet
1304
+ ```
1305
+
1306
+ The terms live in a private file outside the repository:
1307
+
1308
+ ```bash
1309
+ mkdir -p ~/.pi-harness-runtime
1310
+ chmod 700 ~/.pi-harness-runtime
1311
+ printf '%s\n' "the term" >> ~/.pi-harness-runtime/sensitive-terms.txt
1312
+ chmod 600 ~/.pi-harness-runtime/sensitive-terms.txt
1313
+ ```
1314
+
1315
+ **Without a list the hooks stop the commit** and name the way out. The
1316
+ list is a private thing, so a contributor who does not have it sets
1317
+ `PI_SENSITIVE_TERMS_SKIP=1` and lets the check of the repository do that
1318
+ work. `git commit --no-verify` and `git push --no-verify` skip the hooks
1319
+ too — they are an escape hatch for a broken checkout, not a way to work.
1320
+
1321
+ **The release path**: the work is committed to `develop`, the version is
1322
+ prepared there, the pull request into `main` is merged by the auto merge
1323
+ workflow once `No sensitive terms` and `Tests` are green, and the tag on
1324
+ `main` publishes to npm through OIDC. A tag that is not on `main`
1325
+ publishes nothing.
1326
+
1327
+ ```bash
1328
+ bun scripts/release-all.ts --release-as patch # prepare the version on develop
1329
+ bun scripts/release-all.ts --tag # tag the tip of main after the merge
1330
+ ```
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
+
1271
1345
  ## License
1272
1346
 
1273
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
  *