pi-dcg 0.0.0
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/CHANGELOG.md +30 -0
- package/CODE_OF_CONDUCT.md +40 -0
- package/CONTRIBUTING.md +48 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/SECURITY.md +57 -0
- package/THIRD-PARTY-NOTICES +9 -0
- package/extensions/index.ts +282 -0
- package/index.ts +1 -0
- package/package.json +67 -0
- package/src/config.ts +57 -0
- package/src/dcg-client.ts +208 -0
- package/src/index.ts +5 -0
- package/src/install-telemetry.ts +104 -0
- package/src/protocol.ts +134 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning for releases.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-07-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Initial `pi-dcg` Pi package.
|
|
14
|
+
- Guarding for agent `bash` calls and user `!`/`!!` commands through dcg's hook protocol.
|
|
15
|
+
- Pi-native handling for allow, deny, and ask decisions.
|
|
16
|
+
- Bounded, cancellable dcg subprocess execution with configurable bridge error behavior.
|
|
17
|
+
- Startup health status and `/dcg` diagnostics command.
|
|
18
|
+
- Best-effort install/update telemetry following monorepo policy.
|
|
19
|
+
- Unit and integration coverage for protocol, process, client, and extension behavior.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Made checked-command sealing idempotent when the package is loaded at more than one Pi scope.
|
|
24
|
+
- Avoided empty stdin writes for probe commands, which could race with fast-exiting dcg binaries and falsely report that dcg was unavailable.
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- Sealed approved agent `bash` commands and their input references so later Pi handlers cannot replace them after the dcg check.
|
|
29
|
+
- Kept dcg allow-once commands out of model-visible denial results while retaining user-only UI guidance.
|
|
30
|
+
- Documented that Pi's RPC control-channel `bash` command does not emit an extension event and therefore cannot be guarded by `pi-dcg`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
|
6
|
+
|
|
7
|
+
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
|
8
|
+
|
|
9
|
+
## Our Standards
|
|
10
|
+
|
|
11
|
+
Examples of behavior that contributes to a positive environment include:
|
|
12
|
+
|
|
13
|
+
- Demonstrating empathy and kindness toward other people
|
|
14
|
+
- Being respectful of differing opinions, viewpoints, and experiences
|
|
15
|
+
- Giving and gracefully accepting constructive feedback
|
|
16
|
+
- Accepting responsibility and apologizing to those affected by our mistakes
|
|
17
|
+
- Focusing on what is best not just for us as individuals, but for the overall community
|
|
18
|
+
|
|
19
|
+
Examples of unacceptable behavior include:
|
|
20
|
+
|
|
21
|
+
- The use of sexualized language or imagery, and sexual attention or advances
|
|
22
|
+
- Trolling, insulting or derogatory comments, and personal or political attacks
|
|
23
|
+
- Publishing others' private information without explicit permission
|
|
24
|
+
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
|
25
|
+
|
|
26
|
+
## Enforcement Responsibilities
|
|
27
|
+
|
|
28
|
+
Project maintainers are responsible for clarifying and enforcing these standards and may remove, edit, or reject contributions that are not aligned with this Code of Conduct.
|
|
29
|
+
|
|
30
|
+
## Scope
|
|
31
|
+
|
|
32
|
+
This Code of Conduct applies within all project spaces and when an individual is officially representing the project in public spaces.
|
|
33
|
+
|
|
34
|
+
## Enforcement
|
|
35
|
+
|
|
36
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers through GitHub. All complaints will be reviewed and investigated promptly and fairly.
|
|
37
|
+
|
|
38
|
+
## Attribution
|
|
39
|
+
|
|
40
|
+
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing to `pi-dcg`.
|
|
4
|
+
|
|
5
|
+
## Development setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run -w packages/pi-dcg check
|
|
10
|
+
npm run -w packages/pi-dcg test
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This package is source-distributed: Pi loads its TypeScript extension files directly. There is no runtime build step.
|
|
14
|
+
|
|
15
|
+
## Local testing
|
|
16
|
+
|
|
17
|
+
Install the checkout into a temporary Pi project:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
mkdir -p <test-project>
|
|
21
|
+
cd <test-project>
|
|
22
|
+
pi install -l /path/to/pi-mono/packages/pi-dcg
|
|
23
|
+
pi
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run `/dcg` to verify binary discovery. Exercise safe and destructive fixtures only through `dcg test` or a disposable sandbox; do not run genuinely destructive commands to test the bridge.
|
|
27
|
+
|
|
28
|
+
## Pull request checklist
|
|
29
|
+
|
|
30
|
+
- Run `npm run -w packages/pi-dcg check`.
|
|
31
|
+
- Run `npm run -w packages/pi-dcg test`.
|
|
32
|
+
- Run `npm audit --omit=dev`.
|
|
33
|
+
- Run `npm run -w packages/pi-dcg pack:dry-run` and inspect included files.
|
|
34
|
+
- Update README and SECURITY for behavior, environment, process, or data-flow changes.
|
|
35
|
+
- Update CHANGELOG for notable changes.
|
|
36
|
+
- Keep examples free of credentials, command secrets, machine-specific paths, and local policy content.
|
|
37
|
+
|
|
38
|
+
## Coding guidelines
|
|
39
|
+
|
|
40
|
+
- Keep extension wiring in `extensions/index.ts` and reusable behavior in `src/`.
|
|
41
|
+
- Start dcg directly; never interpolate command text into a shell command.
|
|
42
|
+
- Preserve hard-deny, cancellation, output-bound, cwd, and child-environment invariants documented in AGENTS.md.
|
|
43
|
+
- Treat environment variable names and defaults as public API.
|
|
44
|
+
- Keep tests independent of a real dcg installation.
|
|
45
|
+
|
|
46
|
+
## Code of conduct
|
|
47
|
+
|
|
48
|
+
This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Mocito
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# pi-dcg
|
|
2
|
+
|
|
3
|
+
Guard Pi shell commands with [Destructive Command Guard (dcg)](https://github.com/Dicklesworthstone/destructive_command_guard) before they execute.
|
|
4
|
+
|
|
5
|
+
`pi-dcg` is a Pi extension bridge. It does not bundle dcg, replace dcg policy, or provide a sandbox.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js 20.6 or newer
|
|
10
|
+
- Pi 0.80 or newer
|
|
11
|
+
- A separately installed `dcg` executable; dcg 0.6.8 or newer is recommended
|
|
12
|
+
|
|
13
|
+
Install dcg using its [upstream installation instructions](https://github.com/Dicklesworthstone/destructive_command_guard#installation), review its release-verification guidance, and confirm that the binary is visible in the same environment as Pi:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
dcg --version
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> **Separate license:** dcg is external software with its own nonstandard license, including an OpenAI/Anthropic rider. It is not included in this package. Review the [dcg license](https://github.com/Dicklesworthstone/destructive_command_guard/blob/main/LICENSE) before installing or using it.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:pi-dcg
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For project-local installation:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install -l npm:pi-dcg
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For a one-off checkout test:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pi -e /path/to/pi-mono/packages/pi-dcg
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What it guards
|
|
40
|
+
|
|
41
|
+
By default, the extension checks both Pi shell events available to extensions:
|
|
42
|
+
|
|
43
|
+
- agent calls to Pi's built-in `bash` tool;
|
|
44
|
+
- user `!command` and `!!command` invocations.
|
|
45
|
+
|
|
46
|
+
Pi's separate RPC control-channel `{"type":"bash"}` command does not emit either event in current Pi releases and cannot be intercepted by `pi-dcg`; see [Limitations](#limitations).
|
|
47
|
+
|
|
48
|
+
For every non-empty command, the extension starts dcg directly without a shell, sends a Claude-compatible `PreToolUse` payload on stdin, and waits for dcg's decision before Pi executes the command.
|
|
49
|
+
|
|
50
|
+
Pi allows `tool_call` handlers to rewrite tool arguments in sequence. `pi-dcg` checks mutations made by earlier handlers, then seals both the approved `command` value and its input reference. If a later handler attempts to replace either one, Pi blocks the tool call rather than executing a command dcg did not check.
|
|
51
|
+
|
|
52
|
+
| dcg response | Pi behavior |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| Empty stdout / explicit `allow` | Execute the command |
|
|
55
|
+
| `permissionDecision: "deny"` | Block and show bounded rule/remediation details |
|
|
56
|
+
| `permissionDecision: "ask"` | Ask for confirmation when UI is available; otherwise block |
|
|
57
|
+
| Bridge failure | Allow by default, visibly marking dcg unavailable; configurable to block |
|
|
58
|
+
|
|
59
|
+
Hard denials are never converted into one-click approvals. When dcg provides an allow-once code, `pi-dcg` shows the exact `dcg allow-once ...` command only in a user-facing UI notification. It is deliberately excluded from the model-visible blocked tool result so an agent cannot redeem the exception itself.
|
|
60
|
+
|
|
61
|
+
Run `/dcg` to probe the binary and show the active bridge configuration.
|
|
62
|
+
|
|
63
|
+
## Why this uses hook mode
|
|
64
|
+
|
|
65
|
+
The short upstream Pi recipe calls `dcg --robot test`. `pi-dcg` deliberately uses dcg's normal hook protocol instead because the current hook path provides the behavior expected from an agent integration:
|
|
66
|
+
|
|
67
|
+
- Pi-specific agent profiles and their pack/allowlist changes;
|
|
68
|
+
- hook policy and confidence handling;
|
|
69
|
+
- scoped allow-once checks and pending exception records;
|
|
70
|
+
- history/audit integration;
|
|
71
|
+
- structured rule, severity, explanation, and remediation fields.
|
|
72
|
+
|
|
73
|
+
The bridge sets `PI_CODING_AGENT=true` so dcg resolves `[agents.pi]` policy. It also sets `DCG_NO_SELF_HEAL=1` only for the child process: dcg's default hook self-healing targets Claude settings and should not rewrite those files merely because Pi asked for a decision.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
`pi-dcg` uses environment variables for bridge behavior. dcg's own `DCG_*` variables and TOML files continue to control policy.
|
|
78
|
+
|
|
79
|
+
| Variable | Default | Purpose |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `PI_DCG_BIN` | `DCG_BIN`, then `dcg` | Executable name or path. Leading `~/` is expanded. |
|
|
82
|
+
| `PI_DCG_TIMEOUT_MS` | `5000` | Whole child-process timeout, from 100 to 60000 ms. |
|
|
83
|
+
| `PI_DCG_ON_ERROR` | `allow` | `allow` (fail open) or `block` when the bridge cannot obtain a valid decision. |
|
|
84
|
+
| `PI_DCG_GUARD_USER_BASH` | `1` | Set to `0`, `false`, `no`, or `off` to skip user `!`/`!!` commands. |
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
PI_DCG_BIN="$HOME/.local/bin/dcg" pi
|
|
90
|
+
PI_DCG_ON_ERROR=block pi
|
|
91
|
+
PI_DCG_GUARD_USER_BASH=0 pi
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`PI_DCG_ON_ERROR=block` covers bridge failures such as a missing executable, timeout, malformed output, or oversized output. It cannot turn dcg's own intentional fail-open analysis decisions into failures. Configure dcg itself for stricter heredoc and hook behavior.
|
|
95
|
+
|
|
96
|
+
### Pi-specific dcg policy
|
|
97
|
+
|
|
98
|
+
Current dcg releases recognize the `pi` agent profile:
|
|
99
|
+
|
|
100
|
+
```toml
|
|
101
|
+
# ~/.config/dcg/config.toml or .dcg.toml
|
|
102
|
+
[agents.pi]
|
|
103
|
+
trust_level = "medium"
|
|
104
|
+
extra_packs = ["database", "containers"]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use real pack or category IDs reported by `dcg packs`.
|
|
108
|
+
|
|
109
|
+
## Process and data handling
|
|
110
|
+
|
|
111
|
+
- The command is sent only to the local dcg child process over stdin.
|
|
112
|
+
- The extension never invokes a shell to start dcg.
|
|
113
|
+
- dcg runs with Pi's current working directory, preserving project policy and allow-once scope.
|
|
114
|
+
- Captured stdout and stderr share a 512 KiB limit.
|
|
115
|
+
- dcg's human stderr output is captured rather than copied into Pi logs or model context.
|
|
116
|
+
- Denial text sent back to Pi is bounded to prevent context flooding.
|
|
117
|
+
|
|
118
|
+
On startup, this package also sends the monorepo-standard best-effort install/update telemetry ping to `mocito.dev`, once per package version. It is disabled in CI and respects Pi offline and telemetry settings. It contains the package name/version and platform/runtime/architecture only—never commands, paths, dcg output, or policy.
|
|
119
|
+
|
|
120
|
+
## Limitations
|
|
121
|
+
|
|
122
|
+
This extension intercepts Pi events, not operating-system process execution. It cannot see:
|
|
123
|
+
|
|
124
|
+
- custom tools that execute commands under another tool name;
|
|
125
|
+
- Pi's RPC control-channel `{"type":"bash"}` command, which does not emit a `user_bash` event;
|
|
126
|
+
- `pi.exec()` or child processes started internally by another extension;
|
|
127
|
+
- destructive behavior performed directly through non-shell tools;
|
|
128
|
+
- the contents of an opaque script invoked only as `./script.sh` unless dcg can infer or inspect the payload;
|
|
129
|
+
- commands that dcg itself intentionally allows after a parse, size, or deadline fallback.
|
|
130
|
+
|
|
131
|
+
`user_bash` handlers are first-result-wins in Pi. An earlier extension that fully handles `!` commands can prevent later handlers, including `pi-dcg`, from seeing them.
|
|
132
|
+
|
|
133
|
+
Use a container, VM, sandbox, restricted credentials, backups, and review controls when a hard security boundary is required.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install
|
|
139
|
+
npm run -w packages/pi-dcg check
|
|
140
|
+
npm run -w packages/pi-dcg test
|
|
141
|
+
npm run -w packages/pi-dcg pack:dry-run
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [SECURITY.md](./SECURITY.md).
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
`pi-dcg` is MIT licensed. dcg is separate external software and is not covered by this package's MIT license. See [THIRD-PARTY-NOTICES](./THIRD-PARTY-NOTICES).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are provided for the latest released version of `pi-dcg`.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
Do not open a public issue for a suspected vulnerability. Report privately through the repository maintainer's GitHub security contact. Include a description, reproduction steps, affected versions, and suggested mitigation when available.
|
|
10
|
+
|
|
11
|
+
## Security model
|
|
12
|
+
|
|
13
|
+
`pi-dcg` is a local guardrail bridge, not a sandbox or authorization boundary. Pi extensions execute with the same permissions as the user running Pi, and dcg is a separately installed executable with those permissions.
|
|
14
|
+
|
|
15
|
+
The bridge:
|
|
16
|
+
|
|
17
|
+
- intercepts Pi's built-in agent `bash` calls and, by default, user `!`/`!!` commands;
|
|
18
|
+
- starts the configured dcg executable directly without a shell;
|
|
19
|
+
- sends command text to that local child process on stdin;
|
|
20
|
+
- sets the child cwd to Pi's current working directory;
|
|
21
|
+
- validates dcg's structured stdout decision;
|
|
22
|
+
- seals an approved agent `bash` command and its input reference so later `tool_call` handlers cannot replace them unchecked;
|
|
23
|
+
- keeps allow-once commands out of model-visible denial results and shows them only through user-facing UI notifications;
|
|
24
|
+
- captures but does not log or forward dcg stderr;
|
|
25
|
+
- bounds child output and denial text;
|
|
26
|
+
- blocks a command when its check is cancelled;
|
|
27
|
+
- preserves hard dcg denials without a one-click bypass.
|
|
28
|
+
|
|
29
|
+
The child receives Pi's environment because dcg policy is intentionally configured through `DCG_*` variables. `pi-dcg` additionally sets `PI_CODING_AGENT=true`, `DCG_NO_SELF_HEAL=1`, and no-color flags for that child. Environment values are never logged or sent over the network by this package.
|
|
30
|
+
|
|
31
|
+
### Failure behavior
|
|
32
|
+
|
|
33
|
+
Bridge failures default to visible fail-open behavior to match dcg's integration philosophy. Set `PI_DCG_ON_ERROR=block` to block when the bridge cannot start dcg, times out, exceeds output limits, receives a nonzero exit, or cannot validate stdout.
|
|
34
|
+
|
|
35
|
+
This setting cannot detect dcg's internal intentional fail-open paths, which may return a valid allow after size, parse, AST, or deadline fallback. Configure dcg itself for stricter analysis where supported.
|
|
36
|
+
|
|
37
|
+
### Known bypasses
|
|
38
|
+
|
|
39
|
+
The extension cannot intercept arbitrary process creation. Important bypasses include:
|
|
40
|
+
|
|
41
|
+
- custom tools with other names;
|
|
42
|
+
- Pi's RPC control-channel `{"type":"bash"}` command, which does not emit a `user_bash` event;
|
|
43
|
+
- `pi.exec()` and child processes started inside another extension;
|
|
44
|
+
- non-shell file, database, cloud, or API operations;
|
|
45
|
+
- opaque generated scripts and dynamic payloads dcg cannot inspect;
|
|
46
|
+
- earlier Pi `user_bash` handlers that fully replace execution;
|
|
47
|
+
- dcg rules, packs, safe patterns, allowlists, bypass variables, and fail-open analysis behavior.
|
|
48
|
+
|
|
49
|
+
Use least-privilege credentials, version control, backups, containers/VMs, and OS-level sandboxing when destructive operations must be prevented rather than merely guarded.
|
|
50
|
+
|
|
51
|
+
## External dcg dependency and license
|
|
52
|
+
|
|
53
|
+
`pi-dcg` does not bundle or redistribute Destructive Command Guard. Users install it separately and are responsible for reviewing its code, releases, provenance, and nonstandard license, including its OpenAI/Anthropic rider. This package's MIT license does not apply to dcg.
|
|
54
|
+
|
|
55
|
+
## Telemetry
|
|
56
|
+
|
|
57
|
+
On startup, the package sends a best-effort install/update telemetry ping to `mocito.dev` once per package version unless disabled by CI, `PI_OFFLINE`, `PI_TELEMETRY`, or Pi's `enableInstallTelemetry` setting. The ping includes only package name/version and platform/runtime/architecture. It never includes commands, paths, dcg decisions, stderr, configuration, environment variables, prompts, credentials, or policy.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
THIRD-PARTY NOTICES
|
|
2
|
+
|
|
3
|
+
pi-dcg interoperates with Destructive Command Guard (dcg), maintained by Jeffrey Emanuel and contributors:
|
|
4
|
+
https://github.com/Dicklesworthstone/destructive_command_guard
|
|
5
|
+
|
|
6
|
+
Destructive Command Guard is NOT included, copied, linked, or redistributed in the pi-dcg npm package. It is a separately installed executable governed by its own nonstandard license, including an OpenAI/Anthropic rider:
|
|
7
|
+
https://github.com/Dicklesworthstone/destructive_command_guard/blob/main/LICENSE
|
|
8
|
+
|
|
9
|
+
The MIT license distributed with pi-dcg applies only to pi-dcg's independently authored bridge code and documentation. Users are responsible for reviewing and complying with dcg's separate license before installing or using dcg.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isToolCallEventType,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { loadDcgBridgeConfig, type DcgBridgeConfig } from "../src/config.js";
|
|
7
|
+
import {
|
|
8
|
+
DcgClient,
|
|
9
|
+
DcgProcessError,
|
|
10
|
+
isRecommendedDcgVersion,
|
|
11
|
+
MINIMUM_RECOMMENDED_DCG_VERSION,
|
|
12
|
+
type DcgClientLike,
|
|
13
|
+
} from "../src/dcg-client.js";
|
|
14
|
+
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
15
|
+
import { formatDcgDecision, getDcgAllowOnceCommand } from "../src/protocol.js";
|
|
16
|
+
|
|
17
|
+
const STATUS_KEY = "pi-dcg";
|
|
18
|
+
const MAX_COMMAND_PREVIEW_CHARS = 4_000;
|
|
19
|
+
const CHECKED_BASH_SEAL = Symbol.for("pi-dcg.checked-bash-seal");
|
|
20
|
+
|
|
21
|
+
type GuardOutcome = { block: false } | { block: true; reason: string };
|
|
22
|
+
type Health = "active" | "degraded" | "unknown";
|
|
23
|
+
|
|
24
|
+
export interface PiDcgDependencies {
|
|
25
|
+
client?: DcgClientLike;
|
|
26
|
+
config?: DcgBridgeConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function truncate(value: string, maxChars: number): string {
|
|
30
|
+
if (value.length <= maxChars) return value;
|
|
31
|
+
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function errorMessage(error: unknown): string {
|
|
35
|
+
if (error instanceof DcgProcessError) return error.message;
|
|
36
|
+
if (error instanceof Error && error.message.trim()) return error.message;
|
|
37
|
+
return "dcg failed for an unknown reason";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sealCheckedBashCommand(
|
|
41
|
+
event: { input: { command: string } },
|
|
42
|
+
command: string,
|
|
43
|
+
): void {
|
|
44
|
+
const input = event.input;
|
|
45
|
+
const seal = (event as unknown as Record<PropertyKey, unknown>)[CHECKED_BASH_SEAL];
|
|
46
|
+
if (seal !== undefined) {
|
|
47
|
+
if (
|
|
48
|
+
typeof seal === "object"
|
|
49
|
+
&& seal !== null
|
|
50
|
+
&& "input" in seal
|
|
51
|
+
&& "command" in seal
|
|
52
|
+
&& seal.input === input
|
|
53
|
+
&& seal.command === command
|
|
54
|
+
) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw new Error("pi-dcg found an inconsistent existing bash command seal");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Object.defineProperty(input, "command", {
|
|
61
|
+
configurable: false,
|
|
62
|
+
enumerable: true,
|
|
63
|
+
get: () => command,
|
|
64
|
+
set: () => {
|
|
65
|
+
throw new Error("pi-dcg blocked a bash command mutation after its safety check");
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
Object.defineProperty(event, "input", {
|
|
69
|
+
configurable: false,
|
|
70
|
+
enumerable: true,
|
|
71
|
+
get: () => input,
|
|
72
|
+
set: () => {
|
|
73
|
+
throw new Error("pi-dcg blocked a bash arguments replacement after its safety check");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
Object.defineProperty(event, CHECKED_BASH_SEAL, {
|
|
77
|
+
configurable: false,
|
|
78
|
+
enumerable: false,
|
|
79
|
+
value: { input, command },
|
|
80
|
+
writable: false,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function notify(
|
|
85
|
+
ctx: ExtensionContext,
|
|
86
|
+
message: string,
|
|
87
|
+
type: "info" | "warning" | "error",
|
|
88
|
+
): void {
|
|
89
|
+
if (!ctx.hasUI) return;
|
|
90
|
+
try {
|
|
91
|
+
ctx.ui.notify(message, type);
|
|
92
|
+
} catch {
|
|
93
|
+
// UI failures must never alter a dcg decision.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setStatus(
|
|
98
|
+
ctx: ExtensionContext,
|
|
99
|
+
health: Health,
|
|
100
|
+
config: DcgBridgeConfig,
|
|
101
|
+
version?: string,
|
|
102
|
+
): void {
|
|
103
|
+
if (!ctx.hasUI) return;
|
|
104
|
+
try {
|
|
105
|
+
if (health === "active") {
|
|
106
|
+
const label = version ? `dcg ${version}` : "dcg active";
|
|
107
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("success", `shield ${label}`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (health === "degraded") {
|
|
111
|
+
const behavior = config.onError === "block" ? "blocking" : "fail-open";
|
|
112
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", `shield dcg unavailable (${behavior})`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("muted", "shield dcg checking"));
|
|
116
|
+
} catch {
|
|
117
|
+
// Status rendering is advisory and must never alter a dcg decision.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clearStatus(ctx: ExtensionContext): void {
|
|
122
|
+
if (!ctx.hasUI) return;
|
|
123
|
+
try {
|
|
124
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
125
|
+
} catch {
|
|
126
|
+
// The session is already shutting down.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function piDcg(
|
|
131
|
+
pi: ExtensionAPI,
|
|
132
|
+
dependencies: PiDcgDependencies = {},
|
|
133
|
+
): void {
|
|
134
|
+
reportInstallTelemetry();
|
|
135
|
+
|
|
136
|
+
const config = dependencies.config ?? loadDcgBridgeConfig();
|
|
137
|
+
const client = dependencies.client ?? new DcgClient(config);
|
|
138
|
+
let version: string | undefined;
|
|
139
|
+
let lastNotifiedError: string | undefined;
|
|
140
|
+
let warnedAboutVersion = false;
|
|
141
|
+
|
|
142
|
+
const markHealthy = (ctx: ExtensionContext, detectedVersion = version): void => {
|
|
143
|
+
version = detectedVersion;
|
|
144
|
+
lastNotifiedError = undefined;
|
|
145
|
+
setStatus(ctx, "active", config, version);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const markDegraded = (ctx: ExtensionContext, error: unknown): void => {
|
|
149
|
+
const message = errorMessage(error);
|
|
150
|
+
setStatus(ctx, "degraded", config, version);
|
|
151
|
+
if (ctx.hasUI && message !== lastNotifiedError) {
|
|
152
|
+
lastNotifiedError = message;
|
|
153
|
+
const behavior = config.onError === "block" ? "Commands will be blocked." : "Commands will be allowed (fail-open).";
|
|
154
|
+
notify(ctx, `pi-dcg: ${message} ${behavior}`, "warning");
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const guard = async (
|
|
159
|
+
command: string,
|
|
160
|
+
cwd: string,
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
): Promise<GuardOutcome> => {
|
|
163
|
+
if (!command.trim()) return { block: false };
|
|
164
|
+
|
|
165
|
+
let result;
|
|
166
|
+
try {
|
|
167
|
+
result = await client.check(command, cwd, ctx.signal);
|
|
168
|
+
markHealthy(ctx);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof DcgProcessError && error.code === "aborted") {
|
|
171
|
+
return { block: true, reason: "dcg check was cancelled; the command was not run." };
|
|
172
|
+
}
|
|
173
|
+
markDegraded(ctx, error);
|
|
174
|
+
if (config.onError === "block") {
|
|
175
|
+
return {
|
|
176
|
+
block: true,
|
|
177
|
+
reason: `dcg could not evaluate this command: ${errorMessage(error)} Blocking because PI_DCG_ON_ERROR=block.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return { block: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (result.decision === "allow") return { block: false };
|
|
184
|
+
const reason = formatDcgDecision(result);
|
|
185
|
+
if (result.decision === "deny") {
|
|
186
|
+
const allowOnce = getDcgAllowOnceCommand(result);
|
|
187
|
+
if (allowOnce) {
|
|
188
|
+
notify(ctx, `dcg blocked the command. To authorize this exact command manually: ${allowOnce}`, "warning");
|
|
189
|
+
}
|
|
190
|
+
return { block: true, reason };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!ctx.hasUI) {
|
|
194
|
+
return { block: true, reason: `${reason}\n\nNo interactive UI is available to confirm this warning.` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let approved = false;
|
|
198
|
+
try {
|
|
199
|
+
approved = await ctx.ui.confirm(
|
|
200
|
+
"dcg requires confirmation",
|
|
201
|
+
`Command:\n${truncate(command, MAX_COMMAND_PREVIEW_CHARS)}\n\n${reason}`,
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
return { block: true, reason: `${reason}\n\nThe confirmation dialog failed, so the command was blocked.` };
|
|
205
|
+
}
|
|
206
|
+
return approved ? { block: false } : { block: true, reason: `${reason}\n\nThe command was not approved.` };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
210
|
+
if (!ctx.hasUI) return;
|
|
211
|
+
setStatus(ctx, "unknown", config);
|
|
212
|
+
try {
|
|
213
|
+
const probe = await client.probe(ctx.cwd);
|
|
214
|
+
markHealthy(ctx, probe.version);
|
|
215
|
+
if (!isRecommendedDcgVersion(probe.version) && !warnedAboutVersion) {
|
|
216
|
+
warnedAboutVersion = true;
|
|
217
|
+
notify(
|
|
218
|
+
ctx,
|
|
219
|
+
`pi-dcg: found dcg ${probe.version}; dcg ${MINIMUM_RECOMMENDED_DCG_VERSION} or newer is recommended.`,
|
|
220
|
+
"warning",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
markDegraded(ctx, error);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
229
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
230
|
+
const command = event.input.command;
|
|
231
|
+
const outcome = await guard(command, ctx.cwd, ctx);
|
|
232
|
+
if (outcome.block) return { block: true, reason: outcome.reason };
|
|
233
|
+
|
|
234
|
+
// Pi executes this same input object after all tool_call handlers finish.
|
|
235
|
+
// Seal the checked value so a later extension cannot replace it unchecked.
|
|
236
|
+
sealCheckedBashCommand(event, command);
|
|
237
|
+
return undefined;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (config.guardUserBash) {
|
|
241
|
+
pi.on("user_bash", async (event, ctx) => {
|
|
242
|
+
const outcome = await guard(event.command, event.cwd, ctx);
|
|
243
|
+
if (!outcome.block) return undefined;
|
|
244
|
+
return {
|
|
245
|
+
result: {
|
|
246
|
+
output: outcome.reason,
|
|
247
|
+
exitCode: 1,
|
|
248
|
+
cancelled: false,
|
|
249
|
+
truncated: false,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
pi.registerCommand("dcg", {
|
|
256
|
+
description: "Show pi-dcg status and configuration",
|
|
257
|
+
handler: async (args, ctx) => {
|
|
258
|
+
if (args.trim()) {
|
|
259
|
+
notify(ctx, "Usage: /dcg", "warning");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const probe = await client.probe(ctx.cwd);
|
|
264
|
+
markHealthy(ctx, probe.version);
|
|
265
|
+
const coverage = config.guardUserBash
|
|
266
|
+
? "agent bash and user !/!! commands (RPC bash excluded)"
|
|
267
|
+
: "agent bash commands";
|
|
268
|
+
notify(
|
|
269
|
+
ctx,
|
|
270
|
+
`pi-dcg is active\nBinary: ${config.binary}\nVersion: ${probe.version}\nCoverage: ${coverage}\nBridge errors: ${config.onError}`,
|
|
271
|
+
"info",
|
|
272
|
+
);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
markDegraded(ctx, error);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
280
|
+
clearStatus(ctx);
|
|
281
|
+
});
|
|
282
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extensions/index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-dcg",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Guard Pi shell commands with Destructive Command Guard.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jose Mocito",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jvm/pi-mono.git",
|
|
11
|
+
"directory": "packages/pi-dcg"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jvm/pi-mono/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/jvm/pi-mono/tree/main/packages/pi-dcg#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi",
|
|
21
|
+
"dcg",
|
|
22
|
+
"destructive-command-guard",
|
|
23
|
+
"command-safety",
|
|
24
|
+
"guardrails"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"index.ts",
|
|
36
|
+
"extensions",
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"SECURITY.md",
|
|
42
|
+
"CONTRIBUTING.md",
|
|
43
|
+
"CODE_OF_CONDUCT.md",
|
|
44
|
+
"THIRD-PARTY-NOTICES"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"check": "tsc --noEmit",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "node --import tsx --test tests/*.test.mjs",
|
|
50
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
57
|
+
"@types/node": "^26.1.0",
|
|
58
|
+
"tsx": "^4.23.0",
|
|
59
|
+
"typescript": "^6.0.3"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20.6.0"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_DCG_TIMEOUT_MS = 5_000;
|
|
5
|
+
export const MIN_DCG_TIMEOUT_MS = 100;
|
|
6
|
+
export const MAX_DCG_TIMEOUT_MS = 60_000;
|
|
7
|
+
export const MAX_DCG_OUTPUT_BYTES = 512 * 1024;
|
|
8
|
+
|
|
9
|
+
export type DcgErrorMode = "allow" | "block";
|
|
10
|
+
|
|
11
|
+
export interface DcgBridgeConfig {
|
|
12
|
+
binary: string;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
maxOutputBytes: number;
|
|
15
|
+
onError: DcgErrorMode;
|
|
16
|
+
guardUserBash: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isFalseEnvValue(value: string): boolean {
|
|
20
|
+
return ["0", "false", "no", "off", "n"].includes(value.trim().toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseTimeout(value: string | undefined): number {
|
|
24
|
+
if (value === undefined || value.trim() === "") return DEFAULT_DCG_TIMEOUT_MS;
|
|
25
|
+
const parsed = Number(value);
|
|
26
|
+
if (!Number.isInteger(parsed) || parsed < MIN_DCG_TIMEOUT_MS || parsed > MAX_DCG_TIMEOUT_MS) {
|
|
27
|
+
return DEFAULT_DCG_TIMEOUT_MS;
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function expandHome(path: string, home: string): string {
|
|
33
|
+
if (path === "~") return home;
|
|
34
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
35
|
+
return join(home, path.slice(2));
|
|
36
|
+
}
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadDcgBridgeConfig(
|
|
41
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
42
|
+
home: string = homedir(),
|
|
43
|
+
): DcgBridgeConfig {
|
|
44
|
+
const configuredBinary = env.PI_DCG_BIN?.trim() || env.DCG_BIN?.trim() || "dcg";
|
|
45
|
+
const onError = env.PI_DCG_ON_ERROR?.trim().toLowerCase() === "block" ? "block" : "allow";
|
|
46
|
+
const guardUserBash = env.PI_DCG_GUARD_USER_BASH === undefined
|
|
47
|
+
? true
|
|
48
|
+
: !isFalseEnvValue(env.PI_DCG_GUARD_USER_BASH);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
binary: expandHome(configuredBinary, home),
|
|
52
|
+
timeoutMs: parseTimeout(env.PI_DCG_TIMEOUT_MS),
|
|
53
|
+
maxOutputBytes: MAX_DCG_OUTPUT_BYTES,
|
|
54
|
+
onError,
|
|
55
|
+
guardUserBash,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { DcgBridgeConfig } from "./config.js";
|
|
3
|
+
import { parseDcgHookResponse, type DcgDecision } from "./protocol.js";
|
|
4
|
+
|
|
5
|
+
export const MINIMUM_RECOMMENDED_DCG_VERSION = "0.6.8";
|
|
6
|
+
|
|
7
|
+
export type DcgProcessErrorCode =
|
|
8
|
+
| "aborted"
|
|
9
|
+
| "output_limit"
|
|
10
|
+
| "spawn_failed"
|
|
11
|
+
| "timed_out";
|
|
12
|
+
|
|
13
|
+
export class DcgProcessError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
message: string,
|
|
16
|
+
public readonly code: DcgProcessErrorCode,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "DcgProcessError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProcessRequest {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
cwd: string;
|
|
27
|
+
env: NodeJS.ProcessEnv;
|
|
28
|
+
input?: string;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
maxOutputBytes: number;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProcessResult {
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
exitCode: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ProcessExecutor = (request: ProcessRequest) => Promise<ProcessResult>;
|
|
41
|
+
|
|
42
|
+
export const executeProcess: ProcessExecutor = (request) => new Promise((resolve, reject) => {
|
|
43
|
+
if (request.signal?.aborted) {
|
|
44
|
+
reject(new DcgProcessError("dcg check was cancelled", "aborted"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const child = spawn(request.command, request.args, {
|
|
49
|
+
cwd: request.cwd,
|
|
50
|
+
env: request.env,
|
|
51
|
+
stdio: [request.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let settled = false;
|
|
56
|
+
let stdout = "";
|
|
57
|
+
let stderr = "";
|
|
58
|
+
let outputBytes = 0;
|
|
59
|
+
|
|
60
|
+
const cleanup = (): void => {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const rejectOnce = (error: Error, kill = false): void => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
settled = true;
|
|
68
|
+
cleanup();
|
|
69
|
+
if (kill && child.exitCode === null) child.kill();
|
|
70
|
+
reject(error);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const append = (stream: "stdout" | "stderr", chunk: Buffer): void => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
outputBytes += chunk.byteLength;
|
|
76
|
+
if (outputBytes > request.maxOutputBytes) {
|
|
77
|
+
rejectOnce(new DcgProcessError("dcg output exceeded the bridge limit", "output_limit"), true);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (stream === "stdout") stdout += chunk.toString("utf8");
|
|
81
|
+
else stderr += chunk.toString("utf8");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onAbort = (): void => {
|
|
85
|
+
rejectOnce(new DcgProcessError("dcg check was cancelled", "aborted"), true);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const timeout = setTimeout(() => {
|
|
89
|
+
rejectOnce(new DcgProcessError(`dcg did not finish within ${request.timeoutMs}ms`, "timed_out"), true);
|
|
90
|
+
}, request.timeoutMs);
|
|
91
|
+
|
|
92
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk));
|
|
94
|
+
child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk));
|
|
95
|
+
child.once("error", (error) => {
|
|
96
|
+
rejectOnce(new DcgProcessError(`could not start dcg: ${error.message}`, "spawn_failed"));
|
|
97
|
+
});
|
|
98
|
+
child.once("close", (exitCode) => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
cleanup();
|
|
102
|
+
resolve({ stdout, stderr, exitCode });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (request.input !== undefined) {
|
|
106
|
+
if (!child.stdin) {
|
|
107
|
+
rejectOnce(new DcgProcessError("could not open dcg stdin", "spawn_failed"), true);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
child.stdin.once("error", (error) => {
|
|
111
|
+
rejectOnce(new DcgProcessError(`could not send the command to dcg: ${error.message}`, "spawn_failed"), true);
|
|
112
|
+
});
|
|
113
|
+
child.stdin.end(request.input, "utf8");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export interface DcgProbeResult {
|
|
118
|
+
version: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface DcgClientLike {
|
|
122
|
+
check(command: string, cwd: string, signal?: AbortSignal): Promise<DcgDecision>;
|
|
123
|
+
probe(cwd: string, signal?: AbortSignal): Promise<DcgProbeResult>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function processEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
127
|
+
return {
|
|
128
|
+
...environment,
|
|
129
|
+
PI_CODING_AGENT: "true",
|
|
130
|
+
DCG_NO_SELF_HEAL: "1",
|
|
131
|
+
DCG_NO_COLOR: "1",
|
|
132
|
+
NO_COLOR: "1",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseVersion(stdout: string): string {
|
|
137
|
+
const match = stdout.match(/(?:^|\s)v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/);
|
|
138
|
+
if (match?.[1]) return match[1];
|
|
139
|
+
const firstLine = stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
140
|
+
if (!firstLine) throw new Error("dcg --version returned no version");
|
|
141
|
+
return firstLine.replace(/^dcg\s+/i, "");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseSemver(value: string): [number, number, number] | undefined {
|
|
145
|
+
const match = value.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
146
|
+
if (!match) return undefined;
|
|
147
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isRecommendedDcgVersion(version: string): boolean {
|
|
151
|
+
const actual = parseSemver(version);
|
|
152
|
+
const minimum = parseSemver(MINIMUM_RECOMMENDED_DCG_VERSION);
|
|
153
|
+
if (!actual || !minimum) return false;
|
|
154
|
+
for (let index = 0; index < actual.length; index += 1) {
|
|
155
|
+
if (actual[index] !== minimum[index]) return actual[index] > minimum[index];
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class DcgClient implements DcgClientLike {
|
|
161
|
+
constructor(
|
|
162
|
+
public readonly config: DcgBridgeConfig,
|
|
163
|
+
private readonly processExecutor: ProcessExecutor = executeProcess,
|
|
164
|
+
private readonly environment: NodeJS.ProcessEnv = process.env,
|
|
165
|
+
) {}
|
|
166
|
+
|
|
167
|
+
async check(command: string, cwd: string, signal?: AbortSignal): Promise<DcgDecision> {
|
|
168
|
+
const input = `${JSON.stringify({
|
|
169
|
+
hook_event_name: "PreToolUse",
|
|
170
|
+
tool_name: "Bash",
|
|
171
|
+
tool_input: { command },
|
|
172
|
+
cwd,
|
|
173
|
+
})}\n`;
|
|
174
|
+
const result = await this.processExecutor({
|
|
175
|
+
command: this.config.binary,
|
|
176
|
+
args: [],
|
|
177
|
+
cwd,
|
|
178
|
+
env: processEnvironment(this.environment),
|
|
179
|
+
input,
|
|
180
|
+
timeoutMs: this.config.timeoutMs,
|
|
181
|
+
maxOutputBytes: this.config.maxOutputBytes,
|
|
182
|
+
signal,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (result.exitCode !== 0) {
|
|
186
|
+
const exit = result.exitCode === null ? "a signal" : `exit code ${result.exitCode}`;
|
|
187
|
+
throw new Error(`dcg hook failed with ${exit}`);
|
|
188
|
+
}
|
|
189
|
+
return parseDcgHookResponse(result.stdout);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async probe(cwd: string, signal?: AbortSignal): Promise<DcgProbeResult> {
|
|
193
|
+
const result = await this.processExecutor({
|
|
194
|
+
command: this.config.binary,
|
|
195
|
+
args: ["--version"],
|
|
196
|
+
cwd,
|
|
197
|
+
env: processEnvironment(this.environment),
|
|
198
|
+
timeoutMs: Math.min(this.config.timeoutMs, 1_500),
|
|
199
|
+
maxOutputBytes: this.config.maxOutputBytes,
|
|
200
|
+
signal,
|
|
201
|
+
});
|
|
202
|
+
if (result.exitCode !== 0) {
|
|
203
|
+
const exit = result.exitCode === null ? "a signal" : `exit code ${result.exitCode}`;
|
|
204
|
+
throw new Error(`dcg --version failed with ${exit}`);
|
|
205
|
+
}
|
|
206
|
+
return { version: parseVersion(result.stdout) };
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = "pi-dcg";
|
|
8
|
+
const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
|
|
9
|
+
const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
|
|
10
|
+
const CI_ENVIRONMENT_VARIABLES = [
|
|
11
|
+
"APPVEYOR",
|
|
12
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
13
|
+
"BUILDKITE",
|
|
14
|
+
"CIRCLECI",
|
|
15
|
+
"CODESPACES",
|
|
16
|
+
"DRONE",
|
|
17
|
+
"GITHUB_ACTIONS",
|
|
18
|
+
"GITLAB_CI",
|
|
19
|
+
"JENKINS_URL",
|
|
20
|
+
"NETLIFY",
|
|
21
|
+
"TEAMCITY_VERSION",
|
|
22
|
+
"TF_BUILD",
|
|
23
|
+
"TRAVIS",
|
|
24
|
+
"VERCEL",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
interface InstallTelemetryState {
|
|
28
|
+
lastReportedVersion?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PiSettingsDocument {
|
|
32
|
+
enableInstallTelemetry?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readJsonFile(path: string): unknown {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isTruthyEnvFlag(value: string | undefined): boolean {
|
|
44
|
+
if (!value) return false;
|
|
45
|
+
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPresentEnvFlag(value: string | undefined): boolean {
|
|
49
|
+
if (!value) return false;
|
|
50
|
+
const normalized = value.toLowerCase();
|
|
51
|
+
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isCiEnvironment(): boolean {
|
|
55
|
+
if (isTruthyEnvFlag(process.env.CI)) return true;
|
|
56
|
+
return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isInstallTelemetryEnabled(): boolean {
|
|
60
|
+
if (isCiEnvironment()) return false;
|
|
61
|
+
if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
|
|
62
|
+
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
63
|
+
|
|
64
|
+
const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
|
|
65
|
+
return settings.enableInstallTelemetry !== false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getPackageVersion(): string {
|
|
69
|
+
const packageJson = readJsonFile(fileURLToPath(new URL("../package.json", import.meta.url))) as { version?: unknown };
|
|
70
|
+
return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getInstallTelemetryUserAgent(version: string): string {
|
|
74
|
+
const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
|
|
75
|
+
const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
|
|
76
|
+
return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function reportInstallTelemetryAsync(): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
if (!isInstallTelemetryEnabled()) return;
|
|
82
|
+
|
|
83
|
+
const version = getPackageVersion();
|
|
84
|
+
const extensionsDir = join(getAgentDir(), "extensions");
|
|
85
|
+
const statePath = join(extensionsDir, "pi-dcg-install.json");
|
|
86
|
+
const state = readJsonFile(statePath) as InstallTelemetryState;
|
|
87
|
+
if (state.lastReportedVersion === version) return;
|
|
88
|
+
|
|
89
|
+
await mkdir(extensionsDir, { recursive: true });
|
|
90
|
+
await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
|
|
91
|
+
|
|
92
|
+
const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
|
|
93
|
+
await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
|
|
94
|
+
headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
|
|
95
|
+
signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
// Best-effort telemetry: ignore settings, filesystem, and network failures.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function reportInstallTelemetry(): void {
|
|
103
|
+
void reportInstallTelemetryAsync();
|
|
104
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const MAX_REASON_CHARS = 12_000;
|
|
2
|
+
const MAX_FIELD_CHARS = 8_000;
|
|
3
|
+
|
|
4
|
+
interface DcgRemediation {
|
|
5
|
+
safeAlternative?: string;
|
|
6
|
+
explanation?: string;
|
|
7
|
+
allowOnceCommand?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DcgHookOutput {
|
|
11
|
+
permissionDecision: "allow" | "deny" | "ask";
|
|
12
|
+
permissionDecisionReason?: string;
|
|
13
|
+
allowOnceCode?: string;
|
|
14
|
+
allowOnceFullHash?: string;
|
|
15
|
+
ruleId?: string;
|
|
16
|
+
packId?: string;
|
|
17
|
+
severity?: string;
|
|
18
|
+
confidence?: number;
|
|
19
|
+
remediation?: DcgRemediation;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DcgDecision =
|
|
23
|
+
| { decision: "allow" }
|
|
24
|
+
| { decision: "deny" | "ask"; hook: DcgHookOutput };
|
|
25
|
+
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function optionalString(value: unknown): string | undefined {
|
|
31
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function optionalNumber(value: unknown): number | undefined {
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseRemediation(value: unknown): DcgRemediation | undefined {
|
|
39
|
+
if (!isRecord(value)) return undefined;
|
|
40
|
+
const remediation = {
|
|
41
|
+
safeAlternative: optionalString(value.safeAlternative),
|
|
42
|
+
explanation: optionalString(value.explanation),
|
|
43
|
+
allowOnceCommand: optionalString(value.allowOnceCommand),
|
|
44
|
+
};
|
|
45
|
+
return Object.values(remediation).some((field) => field !== undefined) ? remediation : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseDcgHookResponse(stdout: string): DcgDecision {
|
|
49
|
+
const trimmed = stdout.trim().replace(/^\uFEFF/, "");
|
|
50
|
+
if (trimmed === "") return { decision: "allow" };
|
|
51
|
+
|
|
52
|
+
let parsed: unknown;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(trimmed) as unknown;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("dcg returned malformed JSON");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isRecord(parsed) || !isRecord(parsed.hookSpecificOutput)) {
|
|
60
|
+
throw new Error("dcg returned an unsupported hook response");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const raw = parsed.hookSpecificOutput;
|
|
64
|
+
const permissionDecision = raw.permissionDecision;
|
|
65
|
+
if (permissionDecision !== "allow" && permissionDecision !== "deny" && permissionDecision !== "ask") {
|
|
66
|
+
throw new Error("dcg hook response has an unknown permission decision");
|
|
67
|
+
}
|
|
68
|
+
if (permissionDecision === "allow") return { decision: "allow" };
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
decision: permissionDecision,
|
|
72
|
+
hook: {
|
|
73
|
+
permissionDecision,
|
|
74
|
+
permissionDecisionReason: optionalString(raw.permissionDecisionReason),
|
|
75
|
+
allowOnceCode: optionalString(raw.allowOnceCode),
|
|
76
|
+
allowOnceFullHash: optionalString(raw.allowOnceFullHash),
|
|
77
|
+
ruleId: optionalString(raw.ruleId),
|
|
78
|
+
packId: optionalString(raw.packId),
|
|
79
|
+
severity: optionalString(raw.severity),
|
|
80
|
+
confidence: optionalNumber(raw.confidence),
|
|
81
|
+
remediation: parseRemediation(raw.remediation),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function truncate(value: string, maxChars: number): string {
|
|
87
|
+
if (value.length <= maxChars) return value;
|
|
88
|
+
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function redactAllowOnceCommands(value: string): string {
|
|
92
|
+
return value.replace(/\bdcg\s+allow-once\b[^\r\n]*/gi, "[manual authorization command hidden]");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractReason(message: string | undefined): string | undefined {
|
|
96
|
+
if (!message) return undefined;
|
|
97
|
+
const match = message.match(/(?:^|\n)Reason:\s*([^\n]*(?:\n(?!\s*(?:Explanation|Rule|Pack|Command):)[^\n]*)*)/i);
|
|
98
|
+
const reason = match?.[1]?.trim();
|
|
99
|
+
return reason || truncate(message.trim(), MAX_FIELD_CHARS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function metadata(hook: DcgHookOutput): string | undefined {
|
|
103
|
+
const fields = [hook.severity, hook.ruleId ?? hook.packId].filter(Boolean);
|
|
104
|
+
if (hook.confidence !== undefined) fields.push(`confidence ${hook.confidence.toFixed(2)}`);
|
|
105
|
+
return fields.length > 0 ? fields.join(" · ") : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getDcgAllowOnceCommand(
|
|
109
|
+
result: Exclude<DcgDecision, { decision: "allow" }>,
|
|
110
|
+
): string | undefined {
|
|
111
|
+
const command = result.hook.remediation?.allowOnceCommand
|
|
112
|
+
?? (result.hook.allowOnceCode ? `dcg allow-once ${result.hook.allowOnceCode}` : undefined);
|
|
113
|
+
return command ? truncate(command.trim(), MAX_FIELD_CHARS) : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatDcgDecision(result: Exclude<DcgDecision, { decision: "allow" }>): string {
|
|
117
|
+
const { hook } = result;
|
|
118
|
+
const lines = [result.decision === "deny" ? "Blocked by dcg." : "dcg requires confirmation."];
|
|
119
|
+
const meta = metadata(hook);
|
|
120
|
+
if (meta) lines.push(meta);
|
|
121
|
+
|
|
122
|
+
const reason = extractReason(hook.permissionDecisionReason);
|
|
123
|
+
if (reason) lines.push(`Reason: ${truncate(reason, MAX_FIELD_CHARS)}`);
|
|
124
|
+
|
|
125
|
+
const explanation = hook.remediation?.explanation?.trim();
|
|
126
|
+
if (explanation && explanation !== reason) {
|
|
127
|
+
lines.push(`Details: ${truncate(explanation, MAX_FIELD_CHARS)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const alternative = hook.remediation?.safeAlternative?.trim();
|
|
131
|
+
if (alternative) lines.push(`Safer alternative: ${truncate(alternative, MAX_FIELD_CHARS)}`);
|
|
132
|
+
|
|
133
|
+
return truncate(redactAllowOnceCommands(lines.join("\n\n")), MAX_REASON_CHARS);
|
|
134
|
+
}
|