@tduyng/pi-guard 0.0.0-stage → 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,178 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ Use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 tduyng
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @tduyng/pi-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Four safety guards for pi: path reservations between parallel sessions, secret redaction on read results, git hook enforcement, and unhandled rejection handling.
4
+
5
+ ## Reserve
6
+
7
+ Pi serializes writes inside one process, so two sessions in one folder still overwrite each other. This guard is the cross-session half. A session records what it holds in `.pi/reservations/<session>.json`, and a `tool_call` hook refuses `edit` and `write` against a path a live peer holds.
8
+
9
+ Reserving `extensions/pi-goal/src/` takes the whole subtree and an exact path takes one file, while `release` with no paths gives everything back and `list` prints every live session with what it holds.
10
+
11
+ Liveness is `process.kill(pid, 0)`, so a session that is killed takes its reservations with it. Nothing to keep alive, no heartbeat, no daemon.
12
+
13
+ Reservations cover files, and `bash` is checked too: a command that writes a reserved path is
14
+ turned away, whether it says `sed -i`, `git checkout`, or redirects into it. A command that only
15
+ reads a reserved path is left alone. What this cannot see is a mutation that names no path at
16
+ all, so `git add -A` and a bare `git commit` still take whatever is staged. Two sessions
17
+ committing in one repo also race on `.git/index.lock`, and no reservation covers that. Delegate
18
+ the commit with `isolation: "worktree"` on `subagent`, which works in a
19
+ checkout of its own. The branch is named in the result and its commits outlive the checkout.
20
+
21
+ ## Cloak: secret redaction
22
+
23
+ A `tool_result` hook intercepts `read` tool results and redacts secrets before they enter the model's context. Three independent checks, all configurable:
24
+
25
+ ### Vendor key detection
26
+
27
+ Matches known API key shapes by fixed literal prefix. No entropy or shape scanning. Each prefix has a word boundary check so a substring inside a longer identifier does not false-positive. Patterns:
28
+
29
+ - `AKIA` and `ASIA` (AWS access keys)
30
+ - `sk-` (OpenAI/Anthropic)
31
+ - `sk_live_` and `sk_test_` (Stripe)
32
+ - `ghp_`, `gho_`, `ghs_`, `ghr_`, `github_pat_` (GitHub)
33
+ - `eyJ` (JWTs, three-part base64 structure)
34
+ - `xoxb-`, `xoxp-`, `xoxa-`, `xoxr-`, `xoxs-` (Slack)
35
+ - `glpat-` (GitLab)
36
+ - `AIza` (Google)
37
+ - `npm_` (npm)
38
+ - `SG.` (SendGrid)
39
+ - `Bearer` followed by a token
40
+
41
+ Each match is replaced with a marker: the first 4 characters plus stars (minimum 8, maximum 20) plus `[REDACTED:type]`.
42
+
43
+ ### PEM block detection
44
+
45
+ Matches `-----BEGIN ...-----` certificate and key blocks. Replaced with `[REDACTED:PEM TYPE]`.
46
+
47
+ ### Sensitive file types
48
+
49
+ If the file path matches a sensitive pattern, the entire read is replaced with a single marker like `[REDACTED:Protected File (SSH Key)]`. Each pattern carries a type label used in the marker. Patterns include `.pem` (PEM File), `.crt` and `.cer` (Certificate File), `.p12` and `.pfx` (PKCS12 File), `.ssh/id_rsa`, `.ssh/id_ed25519`, `.ssh/id_ecdsa`, and `.ssh/id_dsa` (SSH Key), `.ssh/known_hosts` (SSH Known Hosts), `.ssh/authorized_keys` (SSH Authorized Keys), `.aws/credentials` (AWS Credentials), `.npmrc` (npm Config), `.pypirc` (PyPI Config), `.netrc` (Netrc File), `.docker/config.json` (Docker Config), `.kube/config` (Kube Config).
50
+
51
+ ### Custom patterns
52
+
53
+ Place a `cloak.json` in the agent directory (`getAgentDir()`) or in a project's `.pi/cloak.json` to add your own rules. A project file overrides the scalar keys and appends its `patterns` to the agent dir ones. Keys:
54
+
55
+ - `enabled` (boolean, default `true`): master switch.
56
+ - `cloakCharacter` (string, default `*`): masking character.
57
+ - `cloakLength` (number or null): fixed replacement length. Null preserves original length.
58
+ - `tryAllPatterns` (boolean, default `true`): apply all matching rules, not just the first.
59
+ - `patterns` (array): custom rules with `filePattern` (glob), `cloakPattern` (regex), and optional `replace`.
60
+ - `vendorKeys` (boolean, default `true`): built-in vendor prefix detection.
61
+ - `pemBlocks` (boolean, default `true`): PEM block detection.
62
+ - `sensitiveFileTypes` (boolean, default `true`): whole-file redaction for sensitive paths.
63
+
64
+ When multiple patterns overlap the same span, the first applied match wins and later matches overlapping that span are skipped.
65
+
66
+ Use `/cloak-status` to see whether cloak is enabled, how many custom patterns are loaded, and which built-in checks are on.
67
+
68
+ ### What cloak does not do
69
+
70
+ The extension does not scan by entropy or shape. A nix store hash, a `narHash` field, or a sha256 digest in a Nix derivation will not false-positive. The hook does not touch the outbound provider payload, the assistant's reply, or tools other than `read`. For files matching custom `patterns`, only the matched spans are replaced. Sensitive file types replace the entire read.
71
+
72
+ ## Git interceptor
73
+
74
+ Two guards for agent-driven git commands:
75
+
76
+ **Editor hang prevention**: sets `GIT_EDITOR=true`, `GIT_SEQUENCE_EDITOR=true`, and `GIT_MERGE_AUTOEDIT=no` on `process.env`. Pi builds a child's env by spreading `process.env`, so this one assignment covers the bash tool, `bg`, and any other spawn. Git never spawns an interactive editor.
77
+
78
+ **Hook bypass prevention**: blocks any bash command containing `--no-verify`. The agent should fix the underlying issue causing the hook to fail, or ask the human for help. This is string matching, best-effort, not a security boundary.
79
+
80
+ ## Reject guard
81
+
82
+ Pi registers an `uncaughtException` handler but no `unhandledRejection` handler. An unhandled rejected promise tears down the whole session. This guard adds an `unhandledRejection` listener that logs the rejection reason (with stack trace when available) to stderr and keeps the session alive. The log line starts with `[reject-guard] unhandled rejection (session kept alive):`. The listener installs once. A `/reload` re-evaluates the module, and an install flag on `globalThis` keeps a second registration from logging every rejection twice.
83
+
84
+ ## Install
85
+
86
+ ```bash
87
+ pi install npm:@tduyng/pi-guard
88
+ ```
89
+
90
+ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-guard`
91
+
92
+ ## Configure
93
+
94
+ No configuration required. All four guards activate on install. Customize redaction via `cloak.json` as described above.
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@tduyng/pi-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Four guards: keep parallel pi sessions off each other's files, redact secrets out of read results, keep a stray rejected promise from killing the session, and stop git from hanging on an editor or skipping hooks.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-coding-agent",
10
+ "security",
11
+ "secrets",
12
+ "git",
13
+ "safety"
14
+ ],
15
+ "homepage": "https://gitlab.com/tduyng/pi-agent/-/tree/main/extensions/pi-guard#readme",
16
+ "bugs": "https://gitlab.com/tduyng/pi-agent/-/issues",
17
+ "license": "Apache-2.0",
18
+ "author": "tduyng",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://gitlab.com/tduyng/pi-agent.git",
22
+ "directory": "extensions/pi-guard"
23
+ },
24
+ "type": "module",
25
+ "files": [
26
+ "src",
27
+ "!src/**/*.test.ts",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "*"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/reserve.ts",
40
+ "./src/cloak.ts",
41
+ "./src/git-interceptor.ts",
42
+ "./src/reject-guard.ts"
43
+ ]
44
+ },
45
+ "dependencies": {
46
+ "ata-validator": "^1.62.1"
47
+ }
48
+ }
package/src/cloak.ts ADDED
@@ -0,0 +1,396 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+ import { Validator } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ const isOpenObject = new Validator(t.object({}, { additionalProperties: true })).isValidObject
9
+ const isString = new Validator(t.string()).isValidObject
10
+ const isNumber = new Validator(t.number()).isValidObject
11
+
12
+ export const CLOAK_CONFIG_FILE = 'cloak.json'
13
+
14
+ function configPath(): string {
15
+ return join(getAgentDir(), CLOAK_CONFIG_FILE)
16
+ }
17
+
18
+ function projectConfigPath(): string {
19
+ return join(process.cwd(), '.pi', CLOAK_CONFIG_FILE)
20
+ }
21
+
22
+ interface CloakPattern {
23
+ pattern: string
24
+ replace?: string
25
+ }
26
+
27
+ interface CloakRule {
28
+ filePattern: string | string[]
29
+ cloakPattern: string | CloakPattern[]
30
+ replace?: string
31
+ }
32
+
33
+ export interface CloakConfig {
34
+ enabled?: boolean
35
+ cloakCharacter?: string
36
+ cloakLength?: number | null
37
+ tryAllPatterns?: boolean
38
+ patterns?: CloakRule[]
39
+ vendorKeys?: boolean
40
+ pemBlocks?: boolean
41
+ sensitiveFileTypes?: boolean
42
+ }
43
+
44
+ /** What the file on disk parsed to: the two scalars below are narrowed there, once. */
45
+ interface LoadedConfig extends CloakConfig {
46
+ cloakCharacter: string
47
+ cloakLength?: number
48
+ }
49
+
50
+ interface CompiledRegex {
51
+ regex: RegExp
52
+ replace?: string
53
+ }
54
+
55
+ interface CompiledRule {
56
+ fileRegex: RegExp
57
+ regexes: CompiledRegex[]
58
+ }
59
+
60
+ function globToRegExp(glob: string): RegExp {
61
+ let out = '^'
62
+ for (let index = 0; index < glob.length; index++) {
63
+ const char = glob[index]
64
+ if (char === undefined) break
65
+ const next = glob[index + 1]
66
+ if (char === '*' && next === '*') {
67
+ out += '.*'
68
+ index++
69
+ if (glob[index + 1] === '/') index++
70
+ continue
71
+ }
72
+ if (char === '*') {
73
+ out += '[^/]*'
74
+ continue
75
+ }
76
+ out += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
77
+ }
78
+ return new RegExp(`${out}$`)
79
+ }
80
+
81
+ function compileRule(rule: CloakRule): CompiledRule {
82
+ const filePatterns = Array.isArray(rule.filePattern) ? rule.filePattern : [rule.filePattern]
83
+ const specs: CloakPattern[] = Array.isArray(rule.cloakPattern)
84
+ ? rule.cloakPattern
85
+ : [{ pattern: rule.cloakPattern, replace: rule.replace }]
86
+ return {
87
+ fileRegex: new RegExp(filePatterns.map((pattern) => globToRegExp(pattern).source).join('|')),
88
+ regexes: specs.map((spec) => ({ regex: new RegExp(spec.pattern, 'gi'), replace: spec.replace }))
89
+ }
90
+ }
91
+
92
+ export function compileRules(patterns: CloakRule[]): CompiledRule[] {
93
+ const compiled: CompiledRule[] = []
94
+ for (const rule of patterns) {
95
+ try {
96
+ compiled.push(compileRule(rule))
97
+ } catch {}
98
+ }
99
+ return compiled
100
+ }
101
+
102
+ function readConfigFile(path: string): CloakConfig {
103
+ try {
104
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
105
+ return isOpenObject(parsed) && !Array.isArray(parsed) ? (parsed as CloakConfig) : {}
106
+ } catch {
107
+ return {}
108
+ }
109
+ }
110
+
111
+ export function mergeCloakConfig(base: CloakConfig, project: CloakConfig): CloakConfig {
112
+ return {
113
+ ...base,
114
+ patterns: [
115
+ ...(Array.isArray(base.patterns) ? base.patterns : []),
116
+ ...(Array.isArray(project.patterns) ? project.patterns : [])
117
+ ]
118
+ }
119
+ }
120
+
121
+ function loadConfig(): LoadedConfig {
122
+ try {
123
+ const parsed = mergeCloakConfig(readConfigFile(configPath()), readConfigFile(projectConfigPath()))
124
+ const length = parsed.cloakLength
125
+
126
+ return {
127
+ enabled: true,
128
+ ...parsed,
129
+ cloakCharacter: isString(parsed.cloakCharacter) ? parsed.cloakCharacter : '*',
130
+ cloakLength: isNumber(length) && Number.isFinite(length) && length >= 0 ? length : undefined,
131
+ patterns: Array.isArray(parsed.patterns) ? parsed.patterns : []
132
+ }
133
+ } catch {
134
+ return { enabled: true, cloakCharacter: '*', patterns: [] }
135
+ }
136
+ }
137
+
138
+ function mask(match: string, cloakChar: string, cloakLength: number | null | undefined): string {
139
+ const visible = match.slice(0, 1)
140
+ const target = cloakLength ?? Math.max(match.length, 1)
141
+ return visible + cloakChar.repeat(Math.max(0, target - 1))
142
+ }
143
+
144
+ interface Match {
145
+ start: number
146
+ end: number
147
+ replacement: string
148
+ }
149
+
150
+ interface VendorPattern {
151
+ name: string
152
+ prefix: string
153
+ tail: RegExp
154
+ wordBoundary: boolean
155
+ }
156
+
157
+ const VENDOR: VendorPattern[] = [
158
+ { name: 'AWS Access Key', prefix: 'AKIA', tail: /^AKIA[A-Z0-9]{16}\b/, wordBoundary: true },
159
+ { name: 'AWS Temp Access Key', prefix: 'ASIA', tail: /^ASIA[A-Z0-9]{16}\b/, wordBoundary: true },
160
+ { name: 'Bearer Token', prefix: 'Bearer', tail: /^Bearer\s+[a-zA-Z0-9._-]{20,}\b/, wordBoundary: true },
161
+ { name: 'OpenAI/Anthropic API Key', prefix: 'sk-', tail: /^sk-[a-zA-Z0-9._-]{20,}\b/, wordBoundary: true },
162
+ { name: 'Stripe Live Key', prefix: 'sk_live_', tail: /^sk_live_[a-zA-Z0-9]{20,}\b/, wordBoundary: true },
163
+ { name: 'Stripe Test Key', prefix: 'sk_test_', tail: /^sk_test_[a-zA-Z0-9]{20,}\b/, wordBoundary: true },
164
+ { name: 'GitHub Token', prefix: 'gh', tail: /^gh[pousr]_[a-zA-Z0-9]{36,}\b/, wordBoundary: true },
165
+ {
166
+ name: 'GitHub Fine-grained PAT',
167
+ prefix: 'github_pat_',
168
+ tail: /^github_pat_[a-zA-Z0-9_]{20,}\b/,
169
+ wordBoundary: true
170
+ },
171
+ {
172
+ name: 'JWT',
173
+ prefix: 'eyJ',
174
+ tail: /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
175
+ wordBoundary: true
176
+ },
177
+ { name: 'Slack Token', prefix: 'xoxb-', tail: /^xox[baprs]-[A-Za-z0-9-]{20,}\b/, wordBoundary: true },
178
+ { name: 'GitLab Token', prefix: 'glpat-', tail: /^glpat-[A-Za-z0-9_-]{20,}\b/, wordBoundary: true },
179
+ { name: 'Google API Key', prefix: 'AIza', tail: /^AIza[A-Za-z0-9_-]{35}\b/, wordBoundary: true },
180
+ { name: 'npm Token', prefix: 'npm_', tail: /^npm_[a-zA-Z0-9]{36,}\b/, wordBoundary: true },
181
+ {
182
+ name: 'SendGrid API Key',
183
+ prefix: 'SG.',
184
+ tail: /^SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{32,}\b/,
185
+ wordBoundary: true
186
+ }
187
+ ]
188
+
189
+ function isWordChar(c: string): boolean {
190
+ const code = c.charCodeAt(0)
191
+ return (
192
+ (code >= 0x30 && code <= 0x39) ||
193
+ (code >= 0x41 && code <= 0x5a) ||
194
+ (code >= 0x61 && code <= 0x7a) ||
195
+ code === 0x5f
196
+ )
197
+ }
198
+
199
+ function buildMarker(value: string, type: string): string {
200
+ const prefix = value.slice(0, 4)
201
+ const stars = '*'.repeat(Math.min(20, Math.max(8, value.length - 4)))
202
+ return `${prefix}${stars}[REDACTED:${type}]`
203
+ }
204
+
205
+ export function vendorScan(text: string): Match[] {
206
+ const matches: Match[] = []
207
+ for (const { name, prefix, tail, wordBoundary } of VENDOR) {
208
+ let idx = 0
209
+ while (idx < text.length) {
210
+ const found = text.indexOf(prefix, idx)
211
+ if (found === -1) break
212
+ if (wordBoundary) {
213
+ const prev = found > 0 ? text[found - 1] : ''
214
+ if (prev && isWordChar(prev)) {
215
+ idx = found + 1
216
+ continue
217
+ }
218
+ }
219
+ const window = text.slice(found)
220
+ const m = tail.exec(window)
221
+ if (!m) {
222
+ idx = found + 1
223
+ continue
224
+ }
225
+ const start = found
226
+ const end = found + m[0].length
227
+ matches.push({ start, end, replacement: buildMarker(m[0], name) })
228
+ idx = end
229
+ }
230
+ }
231
+ return matches
232
+ }
233
+
234
+ const PEM_BLOCK_REGEX = /-----BEGIN ([A-Z ]+)-----\r?\n([\s\S]*?)(?:\r?\n-----END \1-----|$)/g
235
+
236
+ export function pemScan(text: string): Match[] {
237
+ const matches: Match[] = []
238
+ PEM_BLOCK_REGEX.lastIndex = 0
239
+ let m: RegExpExecArray | null
240
+ while ((m = PEM_BLOCK_REGEX.exec(text)) !== null) {
241
+ matches.push({ start: m.index, end: m.index + m[0].length, replacement: `[REDACTED:PEM ${m[1] ?? ''}]` })
242
+ }
243
+ return matches
244
+ }
245
+
246
+ /**
247
+ * A quoted JSON field whose name is a credential word, and whose value is shaped like one:
248
+ * no whitespace, at least 16 characters, and a digit. Without the shape the name alone
249
+ * masks `"tokenizers": "0.22.2"` and `"token": "ts-node -T -s scripts/cli/ts/token"`,
250
+ * which is most of a package.json.
251
+ */
252
+ const CREDENTIAL_FIELD_REGEX =
253
+ /("(?:[a-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key)[a-z0-9_-]*)"\s*:\s*")(?=[^"\s]{16,}")(?=[^"]*\d)[^"]+"/gi
254
+
255
+ export function credentialFieldScan(text: string): Match[] {
256
+ const matches: Match[] = []
257
+ CREDENTIAL_FIELD_REGEX.lastIndex = 0
258
+ let m: RegExpExecArray | null
259
+ while ((m = CREDENTIAL_FIELD_REGEX.exec(text)) !== null) {
260
+ const start = m.index + m[1]!.length
261
+ matches.push({ start, end: m.index + m[0].length - 1, replacement: '[REDACTED:credential field]' })
262
+ }
263
+ return matches
264
+ }
265
+
266
+ export function applyMatches(text: string, matches: Match[]): string {
267
+ if (matches.length === 0) return text
268
+ const sorted = matches.toSorted((a, b) => a.start - b.start)
269
+ const parts: string[] = []
270
+ let cursor = 0
271
+ for (const m of sorted) {
272
+ if (m.start < cursor) continue
273
+ if (m.start > cursor) parts.push(text.slice(cursor, m.start))
274
+ parts.push(m.replacement)
275
+ cursor = m.end
276
+ }
277
+ if (cursor < text.length) parts.push(text.slice(cursor))
278
+ return parts.join('')
279
+ }
280
+
281
+ const SENSITIVE_PATH_PATTERNS: { pattern: RegExp; type: string }[] = [
282
+ { pattern: /\.pem$/i, type: 'PEM File' },
283
+ { pattern: /\.crt$/i, type: 'Certificate File' },
284
+ { pattern: /\.cer$/i, type: 'Certificate File' },
285
+ { pattern: /\.p12$/i, type: 'PKCS12 File' },
286
+ { pattern: /\.pfx$/i, type: 'PKCS12 File' },
287
+ { pattern: /(^|\/)\.ssh\/id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i, type: 'SSH Key' },
288
+ { pattern: /\.ssh\/known_hosts$/i, type: 'SSH Known Hosts' },
289
+ { pattern: /\.ssh\/authorized_keys$/i, type: 'SSH Authorized Keys' },
290
+ { pattern: /\.aws\/credentials$/i, type: 'AWS Credentials' },
291
+ { pattern: /\.npmrc$/i, type: 'npm Config' },
292
+ { pattern: /\.pypirc$/i, type: 'PyPI Config' },
293
+ { pattern: /\.netrc$/i, type: 'Netrc File' },
294
+ { pattern: /\.docker\/config\.json$/i, type: 'Docker Config' },
295
+ { pattern: /\.kube\/config$/i, type: 'Kube Config' },
296
+ { pattern: /(?:^|\/)\.env(?:\.[\w-]+)?$/i, type: 'Environment File' }
297
+ ]
298
+
299
+ export function matchSensitivePath(path: string): string | null {
300
+ for (const { pattern, type } of SENSITIVE_PATH_PATTERNS) if (pattern.test(path)) return type
301
+ return null
302
+ }
303
+
304
+ // Canonical copy; pi-subagent/src/redact.ts mirrors it so that package stands alone.
305
+ const SIDE_MODEL_REDACTIONS: Array<[RegExp, string]> = [
306
+ [
307
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
308
+ '[redacted private key block]'
309
+ ],
310
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted aws access key]'],
311
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, '[redacted api key]'],
312
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [redacted]'],
313
+ [/^(\s*(?:export\s+)?[A-Za-z_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z_]*\s*=\s*)("?)[^"]+\2$/gm, '$1$2[redacted]$2']
314
+ ]
315
+
316
+ export function redactSecrets(text: string): string {
317
+ let out = text
318
+ for (const [re, replacement] of SIDE_MODEL_REDACTIONS) out = out.replace(re, replacement)
319
+ return out
320
+ }
321
+
322
+ export default function (pi: ExtensionAPI) {
323
+ const config = loadConfig()
324
+
325
+ pi.registerCommand('cloak-status', {
326
+ description: 'Show cloak config status',
327
+ handler: async (_args, ctx) => {
328
+ const status = config.enabled === false ? 'disabled' : 'enabled'
329
+ ctx.ui.notify(
330
+ `cloak ${status}, patterns=${(config.patterns ?? []).length}, ` +
331
+ `vendorKeys=${config.vendorKeys !== false}, pemBlocks=${config.pemBlocks !== false}, ` +
332
+ `sensitiveFileTypes=${config.sensitiveFileTypes !== false}`,
333
+ 'info'
334
+ )
335
+ }
336
+ })
337
+
338
+ if (config.enabled === false) return
339
+ const rules = compileRules(config.patterns ?? [])
340
+ const vendorKeysEnabled = config.vendorKeys !== false
341
+ const pemBlocksEnabled = config.pemBlocks !== false
342
+ const sensitiveFileTypesEnabled = config.sensitiveFileTypes !== false
343
+ if (rules.length === 0 && !vendorKeysEnabled && !pemBlocksEnabled && !sensitiveFileTypesEnabled) return
344
+
345
+ const cloakChar = config.cloakCharacter
346
+ const cloakLength = config.cloakLength ?? null
347
+
348
+ pi.on('tool_result', (event) => {
349
+ if (!Array.isArray(event.content)) return
350
+ const input = event.input as { path?: string }
351
+ const rawPath = event.toolName === 'read' ? (input.path ?? '') : ''
352
+
353
+ const sensitiveType = sensitiveFileTypesEnabled && rawPath ? matchSensitivePath(rawPath) : null
354
+ const matchingRules = rawPath ? rules.filter((rule) => rule.fileRegex.test(rawPath)) : []
355
+ if (!sensitiveType && matchingRules.length === 0 && !vendorKeysEnabled && !pemBlocksEnabled) return
356
+
357
+ let changed = false
358
+ const content = event.content.map((part) => {
359
+ if (part.type !== 'text') return part
360
+
361
+ if (sensitiveType) {
362
+ changed = true
363
+ return { ...part, text: `[REDACTED:Protected File (${sensitiveType})]` }
364
+ }
365
+
366
+ let text = part.text
367
+ for (const rule of matchingRules) {
368
+ for (const compiled of rule.regexes) {
369
+ const next =
370
+ compiled.replace !== undefined
371
+ ? text.replace(compiled.regex, compiled.replace)
372
+ : text.replace(compiled.regex, (match) => mask(match, cloakChar, cloakLength))
373
+ if (next !== text) {
374
+ changed = true
375
+ text = next
376
+ }
377
+ }
378
+ if (config.tryAllPatterns === false) break
379
+ }
380
+
381
+ const structuralMatches = [
382
+ ...(vendorKeysEnabled ? [...vendorScan(text), ...credentialFieldScan(text)] : []),
383
+ ...(pemBlocksEnabled ? pemScan(text) : [])
384
+ ]
385
+ if (structuralMatches.length > 0) {
386
+ changed = true
387
+ text = applyMatches(text, structuralMatches)
388
+ }
389
+
390
+ return changed ? { ...part, text } : part
391
+ })
392
+
393
+ if (!changed) return
394
+ return { content }
395
+ })
396
+ }
@@ -0,0 +1,73 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { Validator } from 'ata-validator'
3
+ import { t } from 'ata-validator/t'
4
+
5
+ const SENSITIVE_COMMAND_RE =
6
+ /(?:^|[\s"'=:~]|\/)\.env(?:\.[A-Za-z0-9_-]+)?(?=$|[\s"';&|),\]} ])|(?:^|[\s"'=:~]|\/)(?:[^\s"';&|]*\/)?(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?=$|[\s"';&|),\]} ])|(?:^|[\s"'=:~]|\/)[^\s"';&|]*\.(?:pem|key|p12|pfx)(?=$|[\s"';&|),\]} ])/i
7
+
8
+ const SENSITIVE_REASON =
9
+ 'BLOCKED: shell access to a credential or private-key file is not allowed, and a read of one ' +
10
+ 'comes back redacted. Ask the user for the value you need.'
11
+
12
+ const BLOCK_REASON =
13
+ 'BLOCKED: --no-verify is not allowed. Git hooks exist for a reason. ' +
14
+ 'Do not attempt to bypass them. Instead: fix the underlying issue causing ' +
15
+ 'the hook to fail, or ask the user for help.'
16
+
17
+ const CommandInput = t.object({
18
+ command: t.optional(t.string()),
19
+ then_run: t.optional(t.union([t.string(), t.object({ command: t.string() })]))
20
+ })
21
+ const isCommandInput = new Validator(CommandInput).isValidObject
22
+ const isThenRunCommand = new Validator(t.object({ command: t.string() })).isValidObject
23
+ const isThenRunText = new Validator(t.string()).isValidObject
24
+
25
+ function words(segment: string): string[] {
26
+ const found = segment.match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+/g) ?? []
27
+ return found.map((word) => word.replace(/^(["'])(.*)\1$/, '$2'))
28
+ }
29
+
30
+ function blocksHooks(command: string): boolean {
31
+ if (!/\bgit\b/.test(command)) return false
32
+ for (const segment of command.split(/[;&|\n]+/)) {
33
+ const tokens = words(segment)
34
+ const git = tokens.findIndex((token) => token === 'git' || token.endsWith('/git'))
35
+ if (git === -1) continue
36
+ const args = tokens.slice(git + 1)
37
+ const commit = args.findIndex((token) => token === 'commit')
38
+ if (commit === -1) continue
39
+ if (args.includes('--no-verify')) return true
40
+ if (args.some((token, index) => token === '-c' && (args[index + 1]?.startsWith('core.hooksPath=') ?? false)))
41
+ return true
42
+ const after = args.slice(commit + 1)
43
+ const message = after.findIndex(
44
+ (token) => token === '-m' || token === '--message' || token.startsWith('--message=')
45
+ )
46
+ const flags = message === -1 ? after : after.slice(0, message)
47
+ if (flags.some((token) => /^-[A-Za-z]*n[A-Za-z]*$/.test(token))) return true
48
+ }
49
+ return false
50
+ }
51
+
52
+ function commands(input: { command?: string; then_run?: string | { command: string } }): string[] {
53
+ const found: string[] = []
54
+ if (input.command !== undefined) found.push(input.command)
55
+ if (isThenRunCommand(input.then_run)) found.push(input.then_run.command)
56
+ else if (isThenRunText(input.then_run)) found.push(input.then_run)
57
+ return found
58
+ }
59
+
60
+ export default function (pi: ExtensionAPI) {
61
+ process.env['GIT_EDITOR'] = 'true'
62
+ process.env['GIT_SEQUENCE_EDITOR'] = 'true'
63
+ process.env['GIT_MERGE_AUTOEDIT'] = 'no'
64
+
65
+ pi.on('tool_call', (event) => {
66
+ if (!isCommandInput(event.input)) return
67
+ for (const command of commands(event.input)) {
68
+ if (command.trim() === '') continue
69
+ if (blocksHooks(command)) return { block: true, reason: BLOCK_REASON }
70
+ if (SENSITIVE_COMMAND_RE.test(command)) return { block: true, reason: SENSITIVE_REASON }
71
+ }
72
+ })
73
+ }
@@ -0,0 +1,22 @@
1
+ function isInstalled(): boolean {
2
+ return (globalThis as { __piRejectGuardInstalled?: boolean }).__piRejectGuardInstalled === true
3
+ }
4
+
5
+ function markInstalled(): void {
6
+ ;(globalThis as { __piRejectGuardInstalled?: boolean }).__piRejectGuardInstalled = true
7
+ }
8
+
9
+ function onUnhandledRejection(reason: {} | null | undefined): void {
10
+ const message = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)
11
+ console.error(`[reject-guard] unhandled rejection (session kept alive): ${message}`)
12
+ }
13
+
14
+ export function listenerCount(): number {
15
+ return process.listeners('unhandledRejection').filter((fn) => fn === onUnhandledRejection).length
16
+ }
17
+
18
+ export default function (): void {
19
+ if (isInstalled()) return
20
+ markInstalled()
21
+ process.on('unhandledRejection', onUnhandledRejection)
22
+ }
package/src/reserve.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { basename, join, resolve } from 'node:path'
3
+ import type { ExtensionAPI, ToolCallEvent } from '@earendil-works/pi-coding-agent'
4
+ import { Validator, type Infer } from 'ata-validator'
5
+ import { t } from 'ata-validator/t'
6
+
7
+ export const RESERVATIONS_DIR = join('.pi', 'reservations')
8
+
9
+ const ReservationSchema = t.object({
10
+ path: t.string(),
11
+ dir: t.boolean(),
12
+ reason: t.optional(t.string())
13
+ })
14
+
15
+ const RegistrationSchema = t.object({
16
+ session: t.string(),
17
+ pid: t.number(),
18
+ cwd: t.string(),
19
+ reservations: t.array(ReservationSchema)
20
+ })
21
+
22
+ export type Reservation = Infer<typeof ReservationSchema>
23
+ type Registration = Infer<typeof RegistrationSchema>
24
+ const isRegistration = new Validator(RegistrationSchema).isValidObject
25
+
26
+ export interface Conflict {
27
+ session: string
28
+ folder: string
29
+ pattern: string
30
+ reason?: string
31
+ }
32
+
33
+ export function isAlive(pid: number): boolean {
34
+ try {
35
+ process.kill(pid, 0)
36
+ return true
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ export function normalize(cwd: string, raw: string): Reservation | undefined {
43
+ const trimmed = raw.trim()
44
+ if (trimmed === '') return undefined
45
+ const dir = trimmed.endsWith('/')
46
+ return { path: resolve(cwd, dir ? trimmed.slice(0, -1) : trimmed), dir }
47
+ }
48
+
49
+ export function covers(file: string, reservation: Reservation): boolean {
50
+ if (!reservation.dir) return file === reservation.path
51
+ return file === reservation.path || file.startsWith(`${reservation.path}/`)
52
+ }
53
+
54
+ function parseRegistration(text: string): Registration | undefined {
55
+ try {
56
+ const parsed = JSON.parse(text)
57
+ return isRegistration(parsed) ? parsed : undefined
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
62
+
63
+ export function peers(cwd: string, session: string): Registration[] {
64
+ const dir = join(cwd, RESERVATIONS_DIR)
65
+ let names: string[]
66
+ try {
67
+ names = readdirSync(dir)
68
+ } catch {
69
+ return []
70
+ }
71
+ const live: Registration[] = []
72
+ for (const name of names) {
73
+ if (!name.endsWith('.json')) continue
74
+ try {
75
+ const record = parseRegistration(readFileSync(join(dir, name), 'utf8'))
76
+ if (record === undefined || record.session === session || !isAlive(record.pid)) continue
77
+ live.push(record)
78
+ } catch {
79
+ continue
80
+ }
81
+ }
82
+ return live
83
+ }
84
+
85
+ export function conflictsFor(cwd: string, session: string, file: string): Conflict[] {
86
+ return conflictsIn(peers(cwd, session), cwd, file)
87
+ }
88
+
89
+ function conflictsIn(records: Registration[], cwd: string, file: string): Conflict[] {
90
+ const target = resolve(cwd, file)
91
+ const found: Conflict[] = []
92
+ for (const record of records) {
93
+ for (const reservation of record.reservations) {
94
+ if (!covers(target, reservation)) continue
95
+ found.push({
96
+ session: record.session,
97
+ folder: basename(record.cwd) || record.cwd,
98
+ pattern: reservation.dir ? `${reservation.path}/` : reservation.path,
99
+ reason: reservation.reason
100
+ })
101
+ }
102
+ }
103
+ return found
104
+ }
105
+
106
+ const SHELL_MUTATIONS =
107
+ /(^|[\s;&|(])(sed\s+-i\w*|truncate|tee|rm|mv|cp|patch|git\s+(?:add|checkout|restore|mv|rm|reset|revert|stash|clean|apply|am))(?![\w-])/
108
+
109
+ const REDIRECT = />>?\s*("[^"]+"|'[^']+'|\S+)/g
110
+
111
+ function stripQuotes(token: string): string {
112
+ return token.replace(/^["']|["']$/g, '')
113
+ }
114
+
115
+ /**
116
+ * A path a shell command writes. `cat a > b` writes b and only reads a, so a redirect makes the
117
+ * redirect targets the writes and nothing else counts. Without one, a known mutating verb means
118
+ * every path token in the command could be the target.
119
+ */
120
+ function writtenPaths(command: string): string[] {
121
+ const redirects = [...command.matchAll(REDIRECT)].map((match) => stripQuotes(match[1] ?? ''))
122
+ const mutates = SHELL_MUTATIONS.test(command)
123
+ if (redirects.length > 0 && !mutates) return redirects
124
+ if (!mutates) return []
125
+ const tokens = command
126
+ .split(/[\s;&|<>()]+/)
127
+ .map(stripQuotes)
128
+ .filter((token) => token !== '' && !token.startsWith('-'))
129
+ return [...new Set([...redirects, ...tokens])]
130
+ }
131
+
132
+ export function commandConflicts(cwd: string, session: string, command: string): Conflict[] {
133
+ const found: Conflict[] = []
134
+ const seen = new Set<string>()
135
+ const candidates = writtenPaths(command)
136
+ if (candidates.length === 0) return found
137
+ const records = peers(cwd, session)
138
+ for (const candidate of candidates) {
139
+ for (const conflict of conflictsIn(records, cwd, candidate)) {
140
+ const key = `${conflict.session}:${conflict.pattern}`
141
+ if (seen.has(key)) continue
142
+ seen.add(key)
143
+ found.push(conflict)
144
+ }
145
+ }
146
+ return found
147
+ }
148
+
149
+ function declaredPath(event: ToolCallEvent): string | undefined {
150
+ if (event.toolName !== 'edit' && event.toolName !== 'write') return undefined
151
+ return isPathInput(event.input) ? event.input.path : undefined
152
+ }
153
+
154
+ function bashCommand(event: ToolCallEvent): string | undefined {
155
+ if (event.toolName !== 'bash') return undefined
156
+ return isCommandInput(event.input) ? event.input.command : undefined
157
+ }
158
+
159
+ function blockReason(file: string, conflicts: Conflict[]): string {
160
+ const held = conflicts[0] as Conflict
161
+ const lines = [file, `Reserved by session ${held.session} (in ${held.folder})`, `Reservation: ${held.pattern}`]
162
+ if (held.reason !== undefined) lines.push(`Reason: "${held.reason}"`)
163
+ lines.push(
164
+ '',
165
+ 'Ask that session to release it, reserve a different path, or delegate the work with isolation "worktree".'
166
+ )
167
+ return lines.join('\n')
168
+ }
169
+
170
+ const PathInput = t.object({ path: t.string() })
171
+ const isPathInput = new Validator(PathInput).isValidObject
172
+
173
+ const CommandInput = t.object({ command: t.string() })
174
+ const isCommandInput = new Validator(CommandInput).isValidObject
175
+
176
+ const Action = t.union([t.literal('reserve'), t.literal('release'), t.literal('list')])
177
+
178
+ function renderPeers(cwd: string, session: string, own: Reservation[]): string {
179
+ const lines = [`${cwd} (${RESERVATIONS_DIR})`, `this session: ${session}`]
180
+ if (own.length > 0) {
181
+ lines.push(' held by this session:')
182
+ for (const reservation of own) {
183
+ lines.push(` ${reservation.dir ? `${reservation.path}/` : reservation.path}`)
184
+ }
185
+ }
186
+ const others = peers(cwd, session)
187
+ if (others.length === 0) {
188
+ lines.push(' no other live session holds reservations here')
189
+ return lines.join('\n')
190
+ }
191
+ lines.push(' other live sessions:')
192
+ for (const record of others) {
193
+ const folder = basename(record.cwd) || record.cwd
194
+ lines.push(` ${record.session} (${folder}, pid ${record.pid})`)
195
+ for (const reservation of record.reservations) {
196
+ lines.push(` ${reservation.dir ? `${reservation.path}/` : reservation.path}`)
197
+ }
198
+ }
199
+ return lines.join('\n')
200
+ }
201
+
202
+ export default function reserveExtension(pi: ExtensionAPI): void {
203
+ let session = ''
204
+ let cwd = ''
205
+ let held: Reservation[] = []
206
+
207
+ function registryPath(): string {
208
+ return join(cwd, RESERVATIONS_DIR, `${session}.json`)
209
+ }
210
+
211
+ function flush(): void {
212
+ if (session === '') return
213
+ mkdirSync(join(cwd, RESERVATIONS_DIR), { recursive: true })
214
+ const record: Registration = { session, pid: process.pid, cwd, reservations: held }
215
+ const target = registryPath()
216
+ const temporary = `${target}.${process.pid}.tmp`
217
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, 'utf8')
218
+ renameSync(temporary, target)
219
+ }
220
+
221
+ pi.on('session_start', (_event, ctx) => {
222
+ session = ctx.sessionManager.getSessionId()
223
+ cwd = ctx.cwd
224
+ flush()
225
+ })
226
+
227
+ pi.on('session_shutdown', () => {
228
+ if (session === '') return
229
+ try {
230
+ unlinkSync(registryPath())
231
+ } catch {
232
+ // Already gone, which is the state this wants.
233
+ }
234
+ })
235
+
236
+ pi.on('tool_call', (event) => {
237
+ const file = declaredPath(event)
238
+ if (file !== undefined) {
239
+ const conflicts = conflictsFor(cwd, session, file)
240
+ if (conflicts.length === 0) return
241
+ return { block: true, reason: blockReason(file, conflicts) }
242
+ }
243
+ const command = bashCommand(event)
244
+ if (command === undefined) return
245
+ const conflicts = commandConflicts(cwd, session, command)
246
+ if (conflicts.length === 0) return
247
+ return { block: true, reason: blockReason(command, conflicts) }
248
+ })
249
+
250
+ pi.registerTool({
251
+ name: 'reserve',
252
+ label: 'Reserve Paths',
253
+ description:
254
+ 'Claim files or directories in this folder so parallel pi sessions do not edit them at the same time. Another live session holding a reservation is blocked from writing that path until it releases. A trailing slash reserves a whole subtree.',
255
+ promptSnippet: 'Reserve paths so parallel pi sessions do not write the same files',
256
+ annotations: {
257
+ readOnlyHint: false,
258
+ destructiveHint: false,
259
+ idempotentHint: true,
260
+ openWorldHint: false
261
+ },
262
+ exposure: 'deferred',
263
+ namespace: {
264
+ name: 'guard',
265
+ description: 'Cross-session safety: path reservations between parallel pi sessions.'
266
+ },
267
+ promptGuidelines: [
268
+ 'Reserve a path before a long edit run in a folder other pi sessions are using, and release it when the work lands.'
269
+ ],
270
+ parameters: t.object({
271
+ action: Action,
272
+ paths: t.optional(
273
+ t.array(t.string(), { description: 'Paths to reserve or release. Omit on release for all.' })
274
+ ),
275
+ reason: t.optional(t.string({ description: 'Why the path is held, shown to the blocked session.' }))
276
+ }),
277
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
278
+ const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }], details: {} })
279
+ if (session === '') return text('No session id yet. Reservations need a live session.')
280
+
281
+ if (params.action === 'list') return text(renderPeers(cwd, session, held))
282
+
283
+ if (params.action === 'reserve') {
284
+ const added: Reservation[] = []
285
+ for (const raw of params.paths ?? []) {
286
+ const reservation = normalize(cwd, raw)
287
+ if (reservation === undefined) continue
288
+ if (held.some((other) => covers(other.path, reservation) || covers(reservation.path, other))) {
289
+ continue
290
+ }
291
+ held.push({ ...reservation, reason: params.reason })
292
+ added.push(reservation)
293
+ }
294
+ flush()
295
+ if (added.length === 0)
296
+ return text('Nothing to reserve: every path given is already held by this session.')
297
+ return text(
298
+ `Reserved:\n${added
299
+ .map((reservation) => ` ${reservation.dir ? `${reservation.path}/` : reservation.path}`)
300
+ .join('\n')}`
301
+ )
302
+ }
303
+
304
+ const paths = params.paths ?? []
305
+ const before = held.length
306
+ if (paths.length === 0) {
307
+ held = []
308
+ } else {
309
+ const wanted = paths.map((raw) => normalize(cwd, raw)?.path)
310
+ held = held.filter((reservation) => !wanted.includes(reservation.path))
311
+ }
312
+ flush()
313
+ return text(
314
+ held.length === before ? 'No reservation matched those paths.' : `Released ${before - held.length}.`
315
+ )
316
+ }
317
+ })
318
+ }