@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 +178 -0
- package/README.md +93 -2
- package/package.json +46 -4
- package/src/cloak.ts +396 -0
- package/src/git-interceptor.ts +73 -0
- package/src/reject-guard.ts +22 -0
- package/src/reserve.ts +318 -0
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
|
-
#
|
|
1
|
+
# @tduyng/pi-guard
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|