@delorenj/pjangler 1.3.7 → 1.4.3
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/.mise/scripts/link-agentfiles.sh +38 -5
- package/README.md +20 -0
- package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
- package/dist/assets/project-notebook-skill/SKILL.md +64 -0
- package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
- package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
- package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
- package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
- package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
- package/dist/index.js +16982 -8532
- package/dist/index.js.map +7 -0
- package/dist/mcp-server.js +14107 -8089
- package/dist/mcp-server.js.map +7 -0
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +7 -0
- package/package.json +9 -4
- package/templates/commonproject/copier.yml +14 -5
- package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
- package/templates/commonproject/template/mise.toml.jinja +12 -6
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.gitignore.jinja +1 -0
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +140 -22
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +827 -10
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +72 -10
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +11 -4
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
set -eu
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
# Symlink the per-CLI agent instruction files at AGENTS.md.
|
|
5
|
+
#
|
|
6
|
+
# A mise ENTER hook runs with cwd set to the directory the user cd'd into, NOT
|
|
7
|
+
# to config_root — measured on mise 2026.8.10, and true even for a parent config
|
|
8
|
+
# when the entered directory is a nested child. `mise run <task>` does run at
|
|
9
|
+
# config_root, which is why the old cwd-relative version looked correct for
|
|
10
|
+
# years: only the enter-hook path was wrong. So take the root explicitly, and
|
|
11
|
+
# fall back to this script's own location — never to cwd.
|
|
12
|
+
root="${1:-${MISE_CONFIG_ROOT:-}}"
|
|
13
|
+
own_root=$(CDPATH= cd "$(dirname "$0")/../.." && pwd -P)
|
|
14
|
+
if [ -z "$root" ]; then
|
|
15
|
+
root="$own_root"
|
|
7
16
|
else
|
|
8
|
-
|
|
17
|
+
root=$(CDPATH= cd "$root" && pwd -P)
|
|
9
18
|
fi
|
|
19
|
+
if [ "$root" != "$own_root" ]; then
|
|
20
|
+
echo "link-agentfiles: refusing to act on $root; this script belongs to $own_root" >&2
|
|
21
|
+
echo "link-agentfiles: a nested repo must ship its own .mise/scripts copy" >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
CDPATH= cd "$root"
|
|
26
|
+
if [ ! -f AGENTS.md ]; then
|
|
27
|
+
echo "No AGENTS.md in $root. Nothing to symlink."
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# `ln -sf` cannot tell a stale link it owns from a real file it must not touch:
|
|
32
|
+
# -f means "unlink whatever is there". A hand-written CLAUDE.md was silently
|
|
33
|
+
# replaced by a symlink, and the script printed a green checkmark while doing it.
|
|
34
|
+
for name in CLAUDE.md GEMINI.md; do
|
|
35
|
+
if [ -e "$name" ] && [ ! -L "$name" ]; then
|
|
36
|
+
echo "link-agentfiles: refusing to replace the real file $root/$name with a symlink" >&2
|
|
37
|
+
echo "link-agentfiles: move its content into AGENTS.md, then delete $name" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
ln -sfn AGENTS.md "$name"
|
|
41
|
+
done
|
|
42
|
+
echo "✅ AGENTS.md links verified in $root"
|
package/README.md
CHANGED
|
@@ -18,6 +18,26 @@ node dist/index.js --help
|
|
|
18
18
|
pjangler --help
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Project notebook
|
|
22
|
+
|
|
23
|
+
`pjangler notebook` manages one companion Open Notebook for a registered
|
|
24
|
+
repository. The binding and stable Overview note IDs live in `.project.json`
|
|
25
|
+
and the project registry; endpoint, authentication mode, defaults, and finite
|
|
26
|
+
capture limits live in the registry's global `notebook` configuration.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pjangler notebook status . --json
|
|
30
|
+
pjangler notebook create . --live --json
|
|
31
|
+
pjangler notebook list notes . --json
|
|
32
|
+
pjangler notebook search notes "release evidence" . --json
|
|
33
|
+
pjangler notebook audit . --json
|
|
34
|
+
pjangler notebook migrate . --live --apply --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
True `SessionStart` and `SessionEnd` hooks are projected separately from the
|
|
38
|
+
project-scoped hook masters. Their per-repository policy stays fail-open and
|
|
39
|
+
disabled until explicitly enabled in `.project.json`.
|
|
40
|
+
|
|
21
41
|
## Orienting in a repo
|
|
22
42
|
|
|
23
43
|
`describe` reads a repo and reports what it actually is — detected type,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
dba418f3b40d2e1362e615bdfda6cb1616ec5e49e2e8bd81ca48144ef219b29f agents/openai.yaml
|
|
2
|
+
8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800 hooks/claude.settings.json
|
|
3
|
+
8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800 hooks/hooks.master.json
|
|
4
|
+
848d2f5ba9000c2538e21d5a21257c72a5e4799ee3a9145a4eb9d7b00179e52b hooks/session-end.sh
|
|
5
|
+
f146a81c3e6cfbbce28c85c3c2163255974abef61339638c2a0ac9902346905e hooks/session-start.sh
|
|
6
|
+
a25d1d34f9960b2b3bf17a00191d5477b81a05bffe5d4e183c2954cc3975b535 references/configuration.md
|
|
7
|
+
6a61d9dd0d5dd32f8a7fefcb82001bbad90523704fa1500b7e0becc3a848545c references/recovery.md
|
|
8
|
+
95d1b6199b0416f7fdf3306215bc10a9a647f64654ccbb26786cc228fc35ac37 scripts/project-hooks.py
|
|
9
|
+
8567ab2567ec412eb8d673dd713340b81a45e69374d9baee3cb7e827999be437 SKILL.md
|
|
10
|
+
7e16380d079277ee08b8ea64767b1596dc4e8f7ce1bd89c45d9d4821f6d493d3 tests/test_project_hooks.py
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: project-notebook
|
|
3
|
+
description: Operate PJangler Project Notebook as bounded, Git-grounded project memory. Use when an agent needs to inspect notebook status, read or update the stable Overview, manage scoped notes, search project knowledge, audit or migrate a binding, recover capture receipts, or install/check the canonical global Project Notebook hooks without changing foreign hooks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Project Notebook
|
|
7
|
+
|
|
8
|
+
Treat Git and the repository's Project Manifest as authoritative. Treat the
|
|
9
|
+
remote notebook as scoped, derivative context that PJangler owns and can audit.
|
|
10
|
+
|
|
11
|
+
## Work through PJangler
|
|
12
|
+
|
|
13
|
+
1. Resolve the repository explicitly when the current directory is ambiguous.
|
|
14
|
+
2. Inspect `pj notebook status [repo]` before assuming a healthy binding.
|
|
15
|
+
3. Use `--json` when another tool will consume the result. Check `ok`, symbolic
|
|
16
|
+
error code, and `next_actions`; do not infer success from prose.
|
|
17
|
+
4. Keep reads and mutations project-scoped. Never call the notebook service
|
|
18
|
+
directly or accept a result whose membership in the resolved binding is not
|
|
19
|
+
proven.
|
|
20
|
+
5. Use `pj notebook audit [repo]` to diagnose Drift. Use
|
|
21
|
+
`pj notebook migrate [repo]` for an owned repair plan, adding `--apply` for
|
|
22
|
+
local changes and `--live` only when remote repair is intended.
|
|
23
|
+
6. Preserve repository files as the source of truth. Never overwrite Git
|
|
24
|
+
content from notebook content.
|
|
25
|
+
|
|
26
|
+
## Use the public command surface
|
|
27
|
+
|
|
28
|
+
Use these command families rather than internal hook or worker entrypoints:
|
|
29
|
+
|
|
30
|
+
- `pj notebook status|create|overview|audit|migrate`
|
|
31
|
+
- `pj notebook list notes`, `get note`, `add note`, `update note`, `delete note`
|
|
32
|
+
- `pj notebook search notes`
|
|
33
|
+
- `pj notebook capture list|retry`
|
|
34
|
+
|
|
35
|
+
Direct note mutation authorizes only that selected operation. `create` and live
|
|
36
|
+
migration require `--live`; note deletion requires confirmation or `--yes`.
|
|
37
|
+
Never delete the stable Overview note.
|
|
38
|
+
|
|
39
|
+
For complete command forms, policy precedence, hook projection, and safe
|
|
40
|
+
configuration, read [configuration.md](references/configuration.md). For
|
|
41
|
+
capture pressure, Drift, snapshots, uninstall, and failure recovery, read
|
|
42
|
+
[recovery.md](references/recovery.md).
|
|
43
|
+
|
|
44
|
+
## Handle session context safely
|
|
45
|
+
|
|
46
|
+
- Let the global wrappers scope themselves at runtime. Global installation does
|
|
47
|
+
not enable a repository.
|
|
48
|
+
- Use true `SessionStart` and `SessionEnd` only. Never substitute `Stop` for a
|
|
49
|
+
session boundary.
|
|
50
|
+
- Keep hook payloads and user content out of argv and logs.
|
|
51
|
+
- Treat hook failures as bounded, actionable, fail-open diagnostics.
|
|
52
|
+
- Treat `PROJECT NOTEBOOK OVERVIEW DRIFT` as stale context. Run the exact audit
|
|
53
|
+
or migration action reported before relying on the stored Overview.
|
|
54
|
+
- Treat retention pressure as an admission diagnostic, not a receipt state.
|
|
55
|
+
Never delete or compact an unresolved receipt to make room.
|
|
56
|
+
|
|
57
|
+
## Protect credentials and foreign configuration
|
|
58
|
+
|
|
59
|
+
Resolve endpoint and authentication through PJangler runtime configuration;
|
|
60
|
+
never store them in a repository Manifest, note, hook command, or skill file.
|
|
61
|
+
Use `scripts/project-hooks.py` for global Claude projection. Its owner marker is
|
|
62
|
+
exactly `project-notebook.v1`; do not hand-edit live settings or remove
|
|
63
|
+
Bloodbank, Hindsight, Git checkpoint, notification, CommonProject, or other
|
|
64
|
+
foreign hooks.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Project Notebook"
|
|
3
|
+
short_description: "Operate Git-grounded project memory safely"
|
|
4
|
+
default_prompt: "Use $project-notebook to inspect this repository's notebook status and take the safest scoped next action."
|
|
5
|
+
policy:
|
|
6
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"skill": "project-notebook",
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"path": "agents/openai.yaml",
|
|
7
|
+
"sha256": "dba418f3b40d2e1362e615bdfda6cb1616ec5e49e2e8bd81ca48144ef219b29f",
|
|
8
|
+
"mode": "0644"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "hooks/claude.settings.json",
|
|
12
|
+
"sha256": "8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800",
|
|
13
|
+
"mode": "0644"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "hooks/hooks.master.json",
|
|
17
|
+
"sha256": "8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800",
|
|
18
|
+
"mode": "0644"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "hooks/session-end.sh",
|
|
22
|
+
"sha256": "848d2f5ba9000c2538e21d5a21257c72a5e4799ee3a9145a4eb9d7b00179e52b",
|
|
23
|
+
"mode": "0755"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "hooks/session-start.sh",
|
|
27
|
+
"sha256": "f146a81c3e6cfbbce28c85c3c2163255974abef61339638c2a0ac9902346905e",
|
|
28
|
+
"mode": "0755"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "references/configuration.md",
|
|
32
|
+
"sha256": "a25d1d34f9960b2b3bf17a00191d5477b81a05bffe5d4e183c2954cc3975b535",
|
|
33
|
+
"mode": "0644"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "references/recovery.md",
|
|
37
|
+
"sha256": "6a61d9dd0d5dd32f8a7fefcb82001bbad90523704fa1500b7e0becc3a848545c",
|
|
38
|
+
"mode": "0644"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "scripts/project-hooks.py",
|
|
42
|
+
"sha256": "95d1b6199b0416f7fdf3306215bc10a9a647f64654ccbb26786cc228fc35ac37",
|
|
43
|
+
"mode": "0755"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "SKILL.md",
|
|
47
|
+
"sha256": "8567ab2567ec412eb8d673dd713340b81a45e69374d9baee3cb7e827999be437",
|
|
48
|
+
"mode": "0644"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "tests/test_project_hooks.py",
|
|
52
|
+
"sha256": "7e16380d079277ee08b8ea64767b1596dc4e8f7ce1bd89c45d9d4821f6d493d3",
|
|
53
|
+
"mode": "0644"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-start.sh\"",
|
|
9
|
+
"timeout": 3
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionEnd": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-end.sh\"",
|
|
20
|
+
"timeout": 1
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-start.sh\"",
|
|
9
|
+
"timeout": 3
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionEnd": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-end.sh\"",
|
|
20
|
+
"timeout": 1
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/python3 -I
|
|
2
|
+
"""Fail-open SessionEnd bridge to the fixed PJangler user installation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import grp
|
|
7
|
+
import os
|
|
8
|
+
import pwd
|
|
9
|
+
import signal
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DISPLAY_EVENT = "SessionEnd"
|
|
16
|
+
HOOK_EVENT = "session-close"
|
|
17
|
+
REQUEST_LIMIT_BYTES = 1_048_576
|
|
18
|
+
STREAM_LIMIT_BYTES = REQUEST_LIMIT_BYTES + 1
|
|
19
|
+
CHILD_TIMEOUT_SECONDS = 0.75
|
|
20
|
+
NODE_BINARY = Path("/usr/bin/node")
|
|
21
|
+
AUTH_VARIABLE = "OPEN_NOTEBOOK_PASSWORD"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LauncherError(RuntimeError):
|
|
25
|
+
"""A bounded launcher-validation failure."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fail_open(reason: str) -> int:
|
|
29
|
+
print(f"project-notebook: {DISPLAY_EVENT} {reason}", file=sys.stderr)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_identity() -> pwd.struct_passwd:
|
|
34
|
+
entry = pwd.getpwuid(os.geteuid())
|
|
35
|
+
if entry.pw_uid != os.geteuid():
|
|
36
|
+
raise LauncherError("skipped; canonical user identity is invalid")
|
|
37
|
+
home = Path(entry.pw_dir)
|
|
38
|
+
if not home.is_absolute() or home == Path("/") or ".." in home.parts:
|
|
39
|
+
raise LauncherError("skipped; canonical user home is invalid")
|
|
40
|
+
return entry
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def private_primary_group(entry: pwd.struct_passwd) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
group = grp.getgrgid(entry.pw_gid)
|
|
46
|
+
primary_users = {
|
|
47
|
+
candidate.pw_name for candidate in pwd.getpwall() if candidate.pw_gid == entry.pw_gid
|
|
48
|
+
}
|
|
49
|
+
except (KeyError, OSError):
|
|
50
|
+
return False
|
|
51
|
+
if entry.pw_name not in primary_users:
|
|
52
|
+
return False
|
|
53
|
+
writers = primary_users | set(group.gr_mem)
|
|
54
|
+
return writers <= {entry.pw_name}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def path_components(path: Path) -> list[Path]:
|
|
58
|
+
if not path.is_absolute() or path == Path("/"):
|
|
59
|
+
raise LauncherError("skipped; trusted path is invalid")
|
|
60
|
+
components: list[Path] = []
|
|
61
|
+
current = Path("/")
|
|
62
|
+
for component in path.parts[1:]:
|
|
63
|
+
if component in ("", ".", ".."):
|
|
64
|
+
raise LauncherError("skipped; trusted path is invalid")
|
|
65
|
+
current /= component
|
|
66
|
+
components.append(current)
|
|
67
|
+
return components
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reject_symlink_components(path: Path) -> None:
|
|
71
|
+
for component in path_components(path):
|
|
72
|
+
try:
|
|
73
|
+
information = component.lstat()
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise LauncherError("skipped; trusted path is unavailable") from exc
|
|
76
|
+
if stat.S_ISLNK(information.st_mode):
|
|
77
|
+
raise LauncherError("skipped; trusted path contains a symlink")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_owned_component(
|
|
81
|
+
path: Path,
|
|
82
|
+
entry: pwd.struct_passwd,
|
|
83
|
+
*,
|
|
84
|
+
directory: bool,
|
|
85
|
+
private_group: bool,
|
|
86
|
+
) -> None:
|
|
87
|
+
try:
|
|
88
|
+
information = path.lstat()
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise LauncherError("skipped; trusted path is unavailable") from exc
|
|
91
|
+
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
|
92
|
+
if stat.S_ISLNK(information.st_mode) or not expected_type(information.st_mode):
|
|
93
|
+
raise LauncherError("skipped; trusted path has an unsafe type")
|
|
94
|
+
if information.st_uid != entry.pw_uid or information.st_gid != entry.pw_gid:
|
|
95
|
+
raise LauncherError("skipped; trusted path ownership is unsafe")
|
|
96
|
+
mode = stat.S_IMODE(information.st_mode)
|
|
97
|
+
special = stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX
|
|
98
|
+
if mode & (special | stat.S_IWOTH):
|
|
99
|
+
raise LauncherError("skipped; trusted path permissions are unsafe")
|
|
100
|
+
if mode & stat.S_IWGRP and not private_group:
|
|
101
|
+
raise LauncherError("skipped; trusted path group is not private")
|
|
102
|
+
if not directory and not mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
|
|
103
|
+
raise LauncherError("skipped; trusted pj is not executable")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_owned_tree(
|
|
107
|
+
home: Path,
|
|
108
|
+
target: Path,
|
|
109
|
+
entry: pwd.struct_passwd,
|
|
110
|
+
*,
|
|
111
|
+
final_directory: bool,
|
|
112
|
+
private_group: bool,
|
|
113
|
+
) -> None:
|
|
114
|
+
try:
|
|
115
|
+
relative = target.relative_to(home)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise LauncherError("skipped; resolved pj is outside canonical home") from exc
|
|
118
|
+
reject_symlink_components(target)
|
|
119
|
+
validate_owned_component(home, entry, directory=True, private_group=private_group)
|
|
120
|
+
current = home
|
|
121
|
+
for index, component in enumerate(relative.parts):
|
|
122
|
+
current /= component
|
|
123
|
+
validate_owned_component(
|
|
124
|
+
current,
|
|
125
|
+
entry,
|
|
126
|
+
directory=final_directory or index < len(relative.parts) - 1,
|
|
127
|
+
private_group=private_group,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_node_binary() -> None:
|
|
132
|
+
try:
|
|
133
|
+
information = NODE_BINARY.lstat()
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise LauncherError("skipped; fixed node runtime is unavailable") from exc
|
|
136
|
+
mode = stat.S_IMODE(information.st_mode)
|
|
137
|
+
special = stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX
|
|
138
|
+
if (
|
|
139
|
+
stat.S_ISLNK(information.st_mode)
|
|
140
|
+
or not stat.S_ISREG(information.st_mode)
|
|
141
|
+
or information.st_uid != 0
|
|
142
|
+
or mode & (special | stat.S_IWGRP | stat.S_IWOTH)
|
|
143
|
+
or not mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
144
|
+
):
|
|
145
|
+
raise LauncherError("skipped; fixed node runtime is unsafe")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_launcher(entry: pwd.struct_passwd) -> Path:
|
|
149
|
+
home = Path(entry.pw_dir)
|
|
150
|
+
launcher = home / ".local" / "bin" / "pj"
|
|
151
|
+
private_group = private_primary_group(entry)
|
|
152
|
+
reject_symlink_components(launcher.parent)
|
|
153
|
+
validate_owned_tree(
|
|
154
|
+
home,
|
|
155
|
+
launcher.parent,
|
|
156
|
+
entry,
|
|
157
|
+
final_directory=True,
|
|
158
|
+
private_group=private_group,
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
resolved = launcher.resolve(strict=True)
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
raise LauncherError("skipped; trusted pj launcher cannot be resolved") from exc
|
|
164
|
+
validate_owned_tree(
|
|
165
|
+
home,
|
|
166
|
+
resolved,
|
|
167
|
+
entry,
|
|
168
|
+
final_directory=False,
|
|
169
|
+
private_group=private_group,
|
|
170
|
+
)
|
|
171
|
+
return resolved
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def sanitized_environment(entry: pwd.struct_passwd) -> dict[str, str]:
|
|
175
|
+
environment = {
|
|
176
|
+
"HOME": entry.pw_dir,
|
|
177
|
+
"USER": entry.pw_name,
|
|
178
|
+
"LOGNAME": entry.pw_name,
|
|
179
|
+
"PATH": "/usr/bin:/bin",
|
|
180
|
+
"LANG": "C.UTF-8",
|
|
181
|
+
"LC_ALL": "C.UTF-8",
|
|
182
|
+
}
|
|
183
|
+
authentication = os.environ.get(AUTH_VARIABLE)
|
|
184
|
+
if authentication is not None:
|
|
185
|
+
environment[AUTH_VARIABLE] = authentication
|
|
186
|
+
return environment
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def invoke(entry: pwd.struct_passwd, launcher: Path, payload: bytes) -> int:
|
|
190
|
+
process = subprocess.Popen(
|
|
191
|
+
[str(NODE_BINARY), str(launcher), "notebook", "hook", HOOK_EVENT],
|
|
192
|
+
stdin=subprocess.PIPE,
|
|
193
|
+
env=sanitized_environment(entry),
|
|
194
|
+
close_fds=True,
|
|
195
|
+
start_new_session=True,
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
process.communicate(payload, timeout=CHILD_TIMEOUT_SECONDS)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
try:
|
|
201
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
202
|
+
except ProcessLookupError:
|
|
203
|
+
pass
|
|
204
|
+
process.wait()
|
|
205
|
+
return fail_open("timed out; run pj notebook capture list")
|
|
206
|
+
if process.returncode != 0:
|
|
207
|
+
return fail_open("failed open; run pj notebook capture list")
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main() -> int:
|
|
212
|
+
entry = canonical_identity()
|
|
213
|
+
validate_node_binary()
|
|
214
|
+
launcher = resolve_launcher(entry)
|
|
215
|
+
payload = sys.stdin.buffer.read(STREAM_LIMIT_BYTES)
|
|
216
|
+
if len(payload) > REQUEST_LIMIT_BYTES:
|
|
217
|
+
return fail_open("skipped; hook payload exceeds 1048576 bytes")
|
|
218
|
+
return invoke(entry, launcher, payload)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
try:
|
|
223
|
+
result = main()
|
|
224
|
+
except LauncherError as exc:
|
|
225
|
+
result = fail_open(str(exc))
|
|
226
|
+
except Exception:
|
|
227
|
+
result = fail_open("failed open; run pj notebook capture list")
|
|
228
|
+
raise SystemExit(result)
|