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 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.
@@ -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,5 @@
1
+ export const PACKAGE_NAME = "pi-dcg";
2
+
3
+ export * from "./config.js";
4
+ export * from "./dcg-client.js";
5
+ export * from "./protocol.js";
@@ -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
+ }
@@ -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
+ }