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.
- package/AGENTS.md +82 -9
- package/CHANGELOG.md +45 -0
- package/README.md +74 -0
- package/harness/key-file-audit.ts +252 -42
- package/harness/startup-report.ts +32 -7
- package/index.ts +142 -22
- package/package.json +2 -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
|
@@ -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 (`
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
## Release Path: develop -> main -> npm (CRITICAL)
|
|
104
106
|
|
|
105
|
-
|
|
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
|
-
#
|
|
109
|
-
git
|
|
112
|
+
# 1. The work is committed to develop and pushed.
|
|
113
|
+
git push origin develop
|
|
110
114
|
|
|
111
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
*
|