@tduyng/pi-guard 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -53
- package/package.json +2 -2
- package/src/cloak.ts +0 -1
- package/src/git-interceptor.ts +39 -16
- package/src/reserve.ts +5 -3
package/README.md
CHANGED
|
@@ -1,85 +1,77 @@
|
|
|
1
1
|
# @tduyng/pi-guard
|
|
2
2
|
|
|
3
|
-
Four
|
|
3
|
+
Four guards that cost nothing until something goes wrong: parallel sessions off each other's files, secrets out of read results, git never hanging on an editor, and a stray rejection never killing the session.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it costs
|
|
6
|
+
|
|
7
|
+
`reserve` is a deferred tool at 215 tokens in the sessions that load it. The other three are event hooks and spawn no schema at all, so a session that never reserves a path pays nothing for this package.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
The failures it prevents are the expensive kind. A session killed mid task costs the whole turn, and a credential in a transcript costs a rotation.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Reserve
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
Pi serializes writes inside one process, so two sessions in one folder still overwrite each other. This is the cross-session half.
|
|
12
14
|
|
|
13
|
-
|
|
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.
|
|
15
|
+
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. Reserving `extensions/pi-goal/src/` takes the whole subtree, and an exact path takes one file. `release` with no paths gives everything back, and `list` prints every live session with what it holds.
|
|
20
16
|
|
|
21
|
-
|
|
17
|
+
Liveness is `process.kill(pid, 0)`, so a killed session takes its reservations with it. Nothing to keep alive, no heartbeat, no daemon.
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
`bash` is checked too: a command that writes a reserved path is turned away, whether it says `sed -i`, `git checkout`, or redirects into the path, while a command that only reads it is left alone.
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
What this cannot see is a mutation that names no path, so `git add -A` and a bare `git commit` still take whatever is staged. Two sessions committing in one repo also race on `.git/index.lock`, and no reservation covers that. Delegate the commit with `isolation: "worktree"` on `subagent`, which works in a checkout of its own, and the branch it names outlives the checkout.
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
## Cloak
|
|
28
24
|
|
|
29
|
-
|
|
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
|
|
25
|
+
A `tool_result` hook redacts secrets out of `read` results before they enter the model's context. Three checks, each switchable.
|
|
40
26
|
|
|
41
|
-
|
|
27
|
+
### Vendor keys
|
|
42
28
|
|
|
43
|
-
|
|
29
|
+
Known API key shapes by fixed literal prefix, with a word boundary so a substring inside a longer identifier does not match. No entropy scanning and no shape guessing.
|
|
44
30
|
|
|
45
|
-
|
|
31
|
+
`AKIA` and `ASIA` for AWS, `sk-` for OpenAI and Anthropic, `sk_live_` and `sk_test_` for Stripe, `ghp_` `gho_` `ghs_` `ghr_` and `github_pat_` for GitHub, `eyJ` for JWTs, `xoxb-` `xoxp-` `xoxa-` `xoxr-` `xoxs-` for Slack, `glpat-` for GitLab, `AIza` for Google, `npm_` for npm, `SG.` for SendGrid, and `Bearer` followed by a token.
|
|
46
32
|
|
|
47
|
-
|
|
33
|
+
Each match becomes the first 4 characters, then stars (minimum 8, maximum 20), then `[REDACTED:type]`.
|
|
48
34
|
|
|
49
|
-
|
|
35
|
+
### PEM blocks
|
|
50
36
|
|
|
51
|
-
|
|
37
|
+
`-----BEGIN ...-----` certificate and key blocks are replaced with `[REDACTED:PEM TYPE]`.
|
|
52
38
|
|
|
53
|
-
|
|
39
|
+
### Sensitive file types
|
|
40
|
+
|
|
41
|
+
A path that matches a sensitive pattern replaces the whole read with one marker, such as `[REDACTED:Protected File (SSH Key)]`. The list covers `.pem`, `.crt`, `.cer`, `.p12`, `.pfx`, SSH keys and `known_hosts` and `authorized_keys`, `.aws/credentials`, `.npmrc`, `.pypirc`, `.netrc`, `.docker/config.json`, and `.kube/config`.
|
|
54
42
|
|
|
55
|
-
|
|
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.
|
|
43
|
+
### Rules of your own
|
|
63
44
|
|
|
64
|
-
|
|
45
|
+
`cloak.json` in the agent directory, or `.pi/cloak.json` in a project, adds patterns. A project file overrides the scalar keys and appends its `patterns` to the agent dir ones.
|
|
65
46
|
|
|
66
|
-
|
|
47
|
+
| Key | Default | Meaning |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `enabled` | `true` | Master switch |
|
|
50
|
+
| `cloakCharacter` | `*` | Masking character |
|
|
51
|
+
| `cloakLength` | `null` | Fixed replacement length, null keeps the original |
|
|
52
|
+
| `tryAllPatterns` | `true` | Apply every matching rule, not just the first |
|
|
53
|
+
| `patterns` | `[]` | Rules with `filePattern` (glob), `cloakPattern` (regex), and optional `replace` |
|
|
54
|
+
| `vendorKeys` | `true` | Built-in prefix detection |
|
|
55
|
+
| `pemBlocks` | `true` | PEM detection |
|
|
56
|
+
| `sensitiveFileTypes` | `true` | Whole-file redaction for sensitive paths |
|
|
67
57
|
|
|
68
|
-
|
|
58
|
+
When two matches overlap the same span, the first applied wins and later overlapping matches are skipped. `/cloak-status` shows whether cloak is on, how many custom patterns loaded, and which built-in checks are active.
|
|
69
59
|
|
|
70
|
-
|
|
60
|
+
A nix store hash, a `narHash` field, or a sha256 digest in a derivation will not false-positive, because nothing scans by entropy. The hook does not touch the outbound provider payload, the assistant's reply, or any tool other than `read`. A custom `patterns` match replaces only the matched spans, while a sensitive file type replaces the whole read.
|
|
71
61
|
|
|
72
62
|
## Git interceptor
|
|
73
63
|
|
|
74
|
-
Two guards for agent-driven git
|
|
64
|
+
Two guards for agent-driven git.
|
|
75
65
|
|
|
76
|
-
|
|
66
|
+
Editor hang prevention sets `GIT_EDITOR=true`, `GIT_SEQUENCE_EDITOR=true`, and `GIT_MERGE_AUTOEDIT=no` on `process.env`. Pi builds a child's environment by spreading `process.env`, so one assignment covers the bash tool, `bg`, and every other spawn.
|
|
77
67
|
|
|
78
|
-
|
|
68
|
+
Hook bypass prevention blocks any bash command containing `--no-verify`. The agent should fix what the hook is complaining about, or ask you. This is string matching and best effort, not a security boundary.
|
|
79
69
|
|
|
80
70
|
## Reject guard
|
|
81
71
|
|
|
82
|
-
Pi registers an `uncaughtException` handler but no `unhandledRejection` handler
|
|
72
|
+
Pi registers an `uncaughtException` handler but no `unhandledRejection` handler, so one unhandled rejected promise tears down the whole session. This guard adds the missing listener, logs the reason with a stack trace to stderr, and keeps the session alive. The line starts with `[reject-guard] unhandled rejection (session kept alive):`.
|
|
73
|
+
|
|
74
|
+
The listener installs once. `/reload` re-evaluates the module, and an install flag on `globalThis` keeps a second registration from logging every rejection twice.
|
|
83
75
|
|
|
84
76
|
## Install
|
|
85
77
|
|
|
@@ -89,6 +81,4 @@ pi install npm:@tduyng/pi-guard
|
|
|
89
81
|
|
|
90
82
|
Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-guard`
|
|
91
83
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
No configuration required. All four guards activate on install. Customize redaction via `cloak.json` as described above.
|
|
84
|
+
All four guards activate on install with no configuration. Customize redaction through `cloak.json`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-guard",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Secret redaction, cross-session path reservations, git safety, and a rejection guard. Three of the four add no schema at all.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
package/src/cloak.ts
CHANGED
|
@@ -111,7 +111,6 @@ function readConfigFile(path: string): CloakConfig {
|
|
|
111
111
|
export function mergeCloakConfig(base: CloakConfig, project: CloakConfig): CloakConfig {
|
|
112
112
|
return {
|
|
113
113
|
...base,
|
|
114
|
-
...project,
|
|
115
114
|
patterns: [
|
|
116
115
|
...(Array.isArray(base.patterns) ? base.patterns : []),
|
|
117
116
|
...(Array.isArray(project.patterns) ? project.patterns : [])
|
package/src/git-interceptor.ts
CHANGED
|
@@ -2,8 +2,6 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
2
2
|
import { Validator } from 'ata-validator'
|
|
3
3
|
import { t } from 'ata-validator/t'
|
|
4
4
|
|
|
5
|
-
const NO_VERIFY_RE = /--no-verify\b|\bgit\b[^;\n]*\bcommit\b[^;\n]*(?:^|\s)-[A-Za-z]*n[A-Za-z]*(?:\s|=|$)/
|
|
6
|
-
|
|
7
5
|
const SENSITIVE_COMMAND_RE =
|
|
8
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
|
|
9
7
|
|
|
@@ -24,6 +22,41 @@ const isCommandInput = new Validator(CommandInput).isValidObject
|
|
|
24
22
|
const isThenRunCommand = new Validator(t.object({ command: t.string() })).isValidObject
|
|
25
23
|
const isThenRunText = new Validator(t.string()).isValidObject
|
|
26
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
|
+
|
|
27
60
|
export default function (pi: ExtensionAPI) {
|
|
28
61
|
process.env['GIT_EDITOR'] = 'true'
|
|
29
62
|
process.env['GIT_SEQUENCE_EDITOR'] = 'true'
|
|
@@ -31,20 +64,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
31
64
|
|
|
32
65
|
pi.on('tool_call', (event) => {
|
|
33
66
|
if (!isCommandInput(event.input)) return
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
else if (isThenRunText(input.then_run)) command = input.then_run
|
|
39
|
-
else return
|
|
40
|
-
}
|
|
41
|
-
if (!command) return
|
|
42
|
-
|
|
43
|
-
if (/\bgit\b/.test(command) && NO_VERIFY_RE.test(command)) {
|
|
44
|
-
return { block: true, reason: BLOCK_REASON }
|
|
45
|
-
}
|
|
46
|
-
if (SENSITIVE_COMMAND_RE.test(command)) {
|
|
47
|
-
return { block: true, reason: SENSITIVE_REASON }
|
|
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 }
|
|
48
71
|
}
|
|
49
72
|
})
|
|
50
73
|
}
|
package/src/reserve.ts
CHANGED
|
@@ -119,12 +119,14 @@ function stripQuotes(token: string): string {
|
|
|
119
119
|
*/
|
|
120
120
|
function writtenPaths(command: string): string[] {
|
|
121
121
|
const redirects = [...command.matchAll(REDIRECT)].map((match) => stripQuotes(match[1] ?? ''))
|
|
122
|
-
|
|
123
|
-
if (!
|
|
124
|
-
return
|
|
122
|
+
const mutates = SHELL_MUTATIONS.test(command)
|
|
123
|
+
if (redirects.length > 0 && !mutates) return redirects
|
|
124
|
+
if (!mutates) return []
|
|
125
|
+
const tokens = command
|
|
125
126
|
.split(/[\s;&|<>()]+/)
|
|
126
127
|
.map(stripQuotes)
|
|
127
128
|
.filter((token) => token !== '' && !token.startsWith('-'))
|
|
129
|
+
return [...new Set([...redirects, ...tokens])]
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export function commandConflicts(cwd: string, session: string, command: string): Conflict[] {
|