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.
- package/AGENTS.md +24 -3
- package/CHANGELOG.md +59 -0
- package/README.md +30 -0
- package/harness/key-file-audit.ts +252 -42
- package/harness/startup-report.ts +32 -7
- package/index.ts +142 -22
- package/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/dist/auto-continue.d.ts +39 -1
- package/packages/jev-judge/dist/auto-continue.d.ts.map +1 -1
- package/packages/jev-judge/dist/auto-continue.js +91 -3
- package/packages/jev-judge/dist/auto-continue.js.map +1 -1
- package/packages/jev-judge/dist/autonomy-instruction.d.ts +52 -0
- package/packages/jev-judge/dist/autonomy-instruction.d.ts.map +1 -0
- package/packages/jev-judge/dist/autonomy-instruction.js +92 -0
- package/packages/jev-judge/dist/autonomy-instruction.js.map +1 -0
- package/packages/jev-judge/dist/index.d.ts +3 -1
- package/packages/jev-judge/dist/index.d.ts.map +1 -1
- package/packages/jev-judge/dist/index.js +2 -0
- package/packages/jev-judge/dist/index.js.map +1 -1
- package/packages/jev-judge/dist/question-risk.d.ts +8 -0
- package/packages/jev-judge/dist/question-risk.d.ts.map +1 -1
- package/packages/jev-judge/dist/question-risk.js +7 -0
- package/packages/jev-judge/dist/question-risk.js.map +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/jev-judge/src/auto-continue.ts +134 -5
- package/packages/jev-judge/src/autonomy-instruction.ts +140 -0
- package/packages/jev-judge/src/index.ts +16 -1
- package/packages/jev-judge/src/question-risk.ts +16 -0
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/notification/dist/pending-question.d.ts +114 -3
- package/packages/notification/dist/pending-question.d.ts.map +1 -1
- package/packages/notification/dist/pending-question.js +277 -12
- package/packages/notification/dist/pending-question.js.map +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/notification/src/pending-question.ts +389 -11
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/tooling-preference/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/two-phase-review/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- 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
|
|
121
|
-
# gates are green: "No sensitive terms" and "Tests".
|
|
122
|
-
#
|
|
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
|
|
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
|
-
/**
|
|
334
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
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
|
*
|