@westbayberry/dg 2.1.0 → 2.2.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/README.md +216 -226
- package/dist/agents/claude-code.js +113 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +113 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +285 -0
- package/dist/agents/registry.js +127 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +8 -3
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +231 -0
- package/dist/commands/audit.js +22 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +197 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +14 -44
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +2 -2
- package/dist/install-ui/block-render.js +1 -1
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +466 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +31 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +17 -6
- package/dist/launcher/env.js +71 -24
- package/dist/launcher/install-preflight.js +51 -14
- package/dist/launcher/live-install.js +25 -5
- package/dist/launcher/output-redaction.js +7 -4
- package/dist/launcher/preflight-prompt.js +14 -1
- package/dist/launcher/run.js +89 -86
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +145 -6
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +55 -16
- package/dist/proxy/metadata-map.js +41 -3
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +448 -49
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +12 -11
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +128 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +2 -1
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +16 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/service/state.js +27 -8
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +73 -39
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +225 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/report-writer.js +57 -0
- package/dist/verify/local.js +240 -42
- package/dist/verify/package-check.js +84 -28
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +6 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- package/dist/telemetry/events.js +0 -40
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { dirExists, ownedJsonHook, probeMinCliVersion } from "./persistence.js";
|
|
3
|
+
function configPath(home) {
|
|
4
|
+
return join(home, ".codex", "hooks.json");
|
|
5
|
+
}
|
|
6
|
+
function parseInput(stdin) {
|
|
7
|
+
try {
|
|
8
|
+
const obj = JSON.parse(stdin);
|
|
9
|
+
const command = obj.tool_input?.command;
|
|
10
|
+
if (typeof command !== "string") {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return typeof obj.cwd === "string" ? { command, cwd: obj.cwd } : { command };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function emitDecision(verdict) {
|
|
20
|
+
if (verdict.decision === "allow") {
|
|
21
|
+
return { stdout: "", exitCode: 0 };
|
|
22
|
+
}
|
|
23
|
+
const reason = verdict.reason ?? "Dependency Guardian firewall";
|
|
24
|
+
const suffix = verdict.decision === "ask" ? " (flagged for review — run the install in a terminal to decide)" : "";
|
|
25
|
+
return {
|
|
26
|
+
stdout: JSON.stringify({ decision: "block", reason: `${reason}${suffix}` }),
|
|
27
|
+
exitCode: 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function renderHookFile(dgCommand) {
|
|
31
|
+
return {
|
|
32
|
+
hooks: {
|
|
33
|
+
PreToolUse: [
|
|
34
|
+
{
|
|
35
|
+
matcher: "Bash",
|
|
36
|
+
hooks: [{ type: "command", command: dgCommand, timeout: 60, statusMessage: "Dependency Guardian install check" }],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function probeHookSupport(home, deps) {
|
|
43
|
+
if (!dirExists(join(home, ".codex"))) {
|
|
44
|
+
return { supported: false, detail: "~/.codex not found (is Codex CLI installed?)" };
|
|
45
|
+
}
|
|
46
|
+
return probeMinCliVersion("codex", "0.124.0", "Codex CLI", deps);
|
|
47
|
+
}
|
|
48
|
+
export const codexIntegration = {
|
|
49
|
+
id: "codex",
|
|
50
|
+
label: "Codex CLI",
|
|
51
|
+
kind: "owned-json",
|
|
52
|
+
maturity: "verified",
|
|
53
|
+
minVersion: "0.124.0",
|
|
54
|
+
postInstallNote: "Codex asks to trust new hooks: approve the dg hook when Codex prompts on its next start.",
|
|
55
|
+
configPath,
|
|
56
|
+
detect: (home) => dirExists(join(home, ".codex")),
|
|
57
|
+
probeHookSupport,
|
|
58
|
+
parseInput,
|
|
59
|
+
emitDecision,
|
|
60
|
+
...ownedJsonHook({
|
|
61
|
+
agent: "codex",
|
|
62
|
+
checkName: "dg PreToolUse hook",
|
|
63
|
+
render: renderHookFile,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { dirExists, mergedJsonHook, probeMinCliVersion } from "./persistence.js";
|
|
3
|
+
const SIGNATURE = "hook-exec copilot-cli";
|
|
4
|
+
function configPath(home) {
|
|
5
|
+
return join(home, ".copilot", "settings.json");
|
|
6
|
+
}
|
|
7
|
+
function isDgEntry(entry) {
|
|
8
|
+
return (typeof entry === "object" &&
|
|
9
|
+
entry !== null &&
|
|
10
|
+
typeof entry.command === "string" &&
|
|
11
|
+
entry.command.includes(SIGNATURE));
|
|
12
|
+
}
|
|
13
|
+
// Native camelCase event contract (PascalCase keys switch copilot into its
|
|
14
|
+
// VS Code snake_case compat mode, a different payload shape). toolName for
|
|
15
|
+
// the shell tool is "bash" — the UI's "shell" label is display-only.
|
|
16
|
+
function dgEntry(dgCommand) {
|
|
17
|
+
return { type: "command", matcher: "bash", command: dgCommand, timeoutSec: 60 };
|
|
18
|
+
}
|
|
19
|
+
export function insertDgHook(settings, dgCommand) {
|
|
20
|
+
const next = { ...settings };
|
|
21
|
+
const hooks = typeof next.hooks === "object" && next.hooks !== null ? { ...next.hooks } : {};
|
|
22
|
+
const pre = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
|
|
23
|
+
hooks.preToolUse = [...pre.filter((e) => !isDgEntry(e)), dgEntry(dgCommand)];
|
|
24
|
+
next.hooks = hooks;
|
|
25
|
+
return next;
|
|
26
|
+
}
|
|
27
|
+
export function removeDgHook(settings) {
|
|
28
|
+
const next = { ...settings };
|
|
29
|
+
if (typeof next.hooks !== "object" || next.hooks === null) {
|
|
30
|
+
return { settings: next, changed: false, empty: Object.keys(next).length === 0 };
|
|
31
|
+
}
|
|
32
|
+
const hooks = { ...next.hooks };
|
|
33
|
+
const pre = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
|
|
34
|
+
const withoutDg = pre.filter((e) => !isDgEntry(e));
|
|
35
|
+
const changed = withoutDg.length !== pre.length;
|
|
36
|
+
if (withoutDg.length > 0) {
|
|
37
|
+
hooks.preToolUse = withoutDg;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
delete hooks.preToolUse;
|
|
41
|
+
}
|
|
42
|
+
if (Object.keys(hooks).length > 0) {
|
|
43
|
+
next.hooks = hooks;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
delete next.hooks;
|
|
47
|
+
}
|
|
48
|
+
return { settings: next, changed, empty: Object.keys(next).length === 0 };
|
|
49
|
+
}
|
|
50
|
+
function hasDgHook(settings) {
|
|
51
|
+
const hooks = settings.hooks?.preToolUse;
|
|
52
|
+
return Array.isArray(hooks) && hooks.some((e) => isDgEntry(e));
|
|
53
|
+
}
|
|
54
|
+
function toolArgsObject(raw) {
|
|
55
|
+
if (typeof raw === "string") {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return typeof raw === "object" && raw !== null ? raw : null;
|
|
65
|
+
}
|
|
66
|
+
function parseInput(stdin) {
|
|
67
|
+
try {
|
|
68
|
+
const obj = JSON.parse(stdin);
|
|
69
|
+
const args = toolArgsObject(obj.toolArgs);
|
|
70
|
+
const command = args?.command;
|
|
71
|
+
if (typeof command !== "string") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return typeof obj.cwd === "string" ? { command, cwd: obj.cwd } : { command };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function emitDecision(verdict) {
|
|
81
|
+
if (verdict.decision === "allow") {
|
|
82
|
+
return { stdout: "{}", exitCode: 0 };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
stdout: JSON.stringify({
|
|
86
|
+
permissionDecision: verdict.decision,
|
|
87
|
+
...(verdict.reason ? { permissionDecisionReason: verdict.reason } : {}),
|
|
88
|
+
}),
|
|
89
|
+
exitCode: 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function probeHookSupport(home, deps) {
|
|
93
|
+
if (!dirExists(join(home, ".copilot"))) {
|
|
94
|
+
return { supported: false, detail: "~/.copilot not found (is GitHub Copilot CLI installed?)" };
|
|
95
|
+
}
|
|
96
|
+
return probeMinCliVersion("copilot", "1.0.61", "GitHub Copilot CLI", deps);
|
|
97
|
+
}
|
|
98
|
+
export const copilotCliIntegration = {
|
|
99
|
+
id: "copilot-cli",
|
|
100
|
+
label: "GitHub Copilot CLI",
|
|
101
|
+
kind: "merged-json",
|
|
102
|
+
maturity: "verified",
|
|
103
|
+
minVersion: "1.0.61",
|
|
104
|
+
configPath,
|
|
105
|
+
detect: (home) => dirExists(join(home, ".copilot")),
|
|
106
|
+
probeHookSupport,
|
|
107
|
+
parseInput,
|
|
108
|
+
emitDecision,
|
|
109
|
+
...mergedJsonHook({
|
|
110
|
+
checkName: "dg preToolUse hook",
|
|
111
|
+
insert: insertDgHook,
|
|
112
|
+
remove: removeDgHook,
|
|
113
|
+
isInstalled: hasDgHook,
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { dirExists, mergedJsonHook, readSettings } from "./persistence.js";
|
|
3
|
+
const SIGNATURE = "hook-exec cursor";
|
|
4
|
+
function configPath(home) {
|
|
5
|
+
return join(home, ".cursor", "hooks.json");
|
|
6
|
+
}
|
|
7
|
+
function isDgEntry(entry) {
|
|
8
|
+
return (typeof entry === "object" &&
|
|
9
|
+
entry !== null &&
|
|
10
|
+
typeof entry.command === "string" &&
|
|
11
|
+
entry.command.includes(SIGNATURE));
|
|
12
|
+
}
|
|
13
|
+
function hookList(settings) {
|
|
14
|
+
const hooks = settings.hooks;
|
|
15
|
+
if (typeof hooks !== "object" || hooks === null) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const list = hooks.beforeShellExecution;
|
|
19
|
+
return Array.isArray(list) ? list : [];
|
|
20
|
+
}
|
|
21
|
+
function insertHook(settings, dgCommand) {
|
|
22
|
+
const next = { ...settings };
|
|
23
|
+
const hooks = typeof next.hooks === "object" && next.hooks !== null ? { ...next.hooks } : {};
|
|
24
|
+
const list = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
|
|
25
|
+
hooks.beforeShellExecution = [...list.filter((entry) => !isDgEntry(entry)), { command: dgCommand }];
|
|
26
|
+
next.hooks = hooks;
|
|
27
|
+
if (next.version === undefined) {
|
|
28
|
+
next.version = 1;
|
|
29
|
+
}
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
function effectivelyEmpty(obj) {
|
|
33
|
+
const keys = Object.keys(obj);
|
|
34
|
+
return keys.length === 0 || (keys.length === 1 && keys[0] === "version");
|
|
35
|
+
}
|
|
36
|
+
function removeHook(settings) {
|
|
37
|
+
const next = { ...settings };
|
|
38
|
+
if (typeof next.hooks !== "object" || next.hooks === null) {
|
|
39
|
+
return { settings: next, changed: false, empty: effectivelyEmpty(next) };
|
|
40
|
+
}
|
|
41
|
+
const hooks = { ...next.hooks };
|
|
42
|
+
const list = Array.isArray(hooks.beforeShellExecution) ? hooks.beforeShellExecution : [];
|
|
43
|
+
const withoutDg = list.filter((entry) => !isDgEntry(entry));
|
|
44
|
+
const changed = withoutDg.length !== list.length;
|
|
45
|
+
if (withoutDg.length > 0) {
|
|
46
|
+
hooks.beforeShellExecution = withoutDg;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
delete hooks.beforeShellExecution;
|
|
50
|
+
}
|
|
51
|
+
if (Object.keys(hooks).length > 0) {
|
|
52
|
+
next.hooks = hooks;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
delete next.hooks;
|
|
56
|
+
}
|
|
57
|
+
return { settings: next, changed, empty: effectivelyEmpty(next) };
|
|
58
|
+
}
|
|
59
|
+
function hasDgHook(settings) {
|
|
60
|
+
return hookList(settings).some((entry) => isDgEntry(entry));
|
|
61
|
+
}
|
|
62
|
+
function parseInput(stdin) {
|
|
63
|
+
try {
|
|
64
|
+
const obj = JSON.parse(stdin);
|
|
65
|
+
if (typeof obj.command !== "string") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return typeof obj.cwd === "string" ? { command: obj.command, cwd: obj.cwd } : { command: obj.command };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function emitDecision(verdict) {
|
|
75
|
+
if (verdict.decision === "allow") {
|
|
76
|
+
return { stdout: JSON.stringify({ permission: "allow" }), exitCode: 0 };
|
|
77
|
+
}
|
|
78
|
+
const reason = verdict.reason ?? "Dependency Guardian firewall";
|
|
79
|
+
return {
|
|
80
|
+
stdout: JSON.stringify({ permission: verdict.decision, user_message: reason, agent_message: reason }),
|
|
81
|
+
exitCode: 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function probeHookSupport(home) {
|
|
85
|
+
if (!dirExists(join(home, ".cursor"))) {
|
|
86
|
+
return { supported: false, detail: "~/.cursor not found (is Cursor installed?)" };
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
readSettings(configPath(home));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { supported: false, detail: `${configPath(home)} exists but is not a JSON object dg can merge into` };
|
|
93
|
+
}
|
|
94
|
+
return { supported: true, detail: "requires Cursor 1.7+; dg cannot read your Cursor version from disk" };
|
|
95
|
+
}
|
|
96
|
+
export const cursorIntegration = {
|
|
97
|
+
id: "cursor",
|
|
98
|
+
label: "Cursor",
|
|
99
|
+
kind: "merged-json",
|
|
100
|
+
maturity: "verified",
|
|
101
|
+
minVersion: "1.7",
|
|
102
|
+
configPath,
|
|
103
|
+
detect: (home) => dirExists(join(home, ".cursor")),
|
|
104
|
+
probeHookSupport,
|
|
105
|
+
parseInput,
|
|
106
|
+
emitDecision,
|
|
107
|
+
...mergedJsonHook({
|
|
108
|
+
checkName: "dg beforeShellExecution hook",
|
|
109
|
+
insert: insertHook,
|
|
110
|
+
remove: removeHook,
|
|
111
|
+
isInstalled: hasDgHook,
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { dirExists, mergedJsonHook, probeMinCliVersion } from "./persistence.js";
|
|
3
|
+
const SIGNATURE = "hook-exec gemini";
|
|
4
|
+
function configPath(home) {
|
|
5
|
+
return join(home, ".gemini", "settings.json");
|
|
6
|
+
}
|
|
7
|
+
function isDgGroup(group) {
|
|
8
|
+
if (typeof group !== "object" || group === null) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const hooks = group.hooks;
|
|
12
|
+
if (!Array.isArray(hooks)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return hooks.some((h) => typeof h === "object" &&
|
|
16
|
+
h !== null &&
|
|
17
|
+
typeof h.command === "string" &&
|
|
18
|
+
h.command.includes(SIGNATURE));
|
|
19
|
+
}
|
|
20
|
+
// No timeout field: gemini reads it in MILLISECONDS (default 60000); the
|
|
21
|
+
// Claude-style seconds value 60 made gemini kill the hook at 60ms and run
|
|
22
|
+
// the command ungated (gemini fails open on hook timeout).
|
|
23
|
+
function dgGroup(dgCommand) {
|
|
24
|
+
return { matcher: "run_shell_command", hooks: [{ type: "command", command: dgCommand }] };
|
|
25
|
+
}
|
|
26
|
+
function insertHook(settings, dgCommand) {
|
|
27
|
+
const next = { ...settings };
|
|
28
|
+
const hooks = typeof next.hooks === "object" && next.hooks !== null ? { ...next.hooks } : {};
|
|
29
|
+
const pre = Array.isArray(hooks.BeforeTool) ? hooks.BeforeTool : [];
|
|
30
|
+
hooks.BeforeTool = [...pre.filter((g) => !isDgGroup(g)), dgGroup(dgCommand)];
|
|
31
|
+
next.hooks = hooks;
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
34
|
+
function removeHook(settings) {
|
|
35
|
+
const next = { ...settings };
|
|
36
|
+
if (typeof next.hooks !== "object" || next.hooks === null) {
|
|
37
|
+
return { settings: next, changed: false, empty: Object.keys(next).length === 0 };
|
|
38
|
+
}
|
|
39
|
+
const hooks = { ...next.hooks };
|
|
40
|
+
const pre = Array.isArray(hooks.BeforeTool) ? hooks.BeforeTool : [];
|
|
41
|
+
const withoutDg = pre.filter((g) => !isDgGroup(g));
|
|
42
|
+
const changed = withoutDg.length !== pre.length;
|
|
43
|
+
if (withoutDg.length > 0) {
|
|
44
|
+
hooks.BeforeTool = withoutDg;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
delete hooks.BeforeTool;
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(hooks).length > 0) {
|
|
50
|
+
next.hooks = hooks;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
delete next.hooks;
|
|
54
|
+
}
|
|
55
|
+
return { settings: next, changed, empty: Object.keys(next).length === 0 };
|
|
56
|
+
}
|
|
57
|
+
function hasDgHook(settings) {
|
|
58
|
+
const hooks = settings.hooks?.BeforeTool;
|
|
59
|
+
return Array.isArray(hooks) && hooks.some((g) => isDgGroup(g));
|
|
60
|
+
}
|
|
61
|
+
function parseInput(stdin) {
|
|
62
|
+
try {
|
|
63
|
+
const obj = JSON.parse(stdin);
|
|
64
|
+
const command = obj.tool_args?.command ?? obj.tool_input?.command;
|
|
65
|
+
if (typeof command !== "string") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return typeof obj.cwd === "string" ? { command, cwd: obj.cwd } : { command };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function emitDecision(verdict) {
|
|
75
|
+
if (verdict.decision === "allow") {
|
|
76
|
+
return { stdout: "{}", exitCode: 0 };
|
|
77
|
+
}
|
|
78
|
+
const reason = verdict.reason ?? "Dependency Guardian firewall";
|
|
79
|
+
return {
|
|
80
|
+
stdout: JSON.stringify({ decision: "block", reason }),
|
|
81
|
+
exitCode: 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function probeHookSupport(home, deps) {
|
|
85
|
+
if (!dirExists(join(home, ".gemini"))) {
|
|
86
|
+
return { supported: false, detail: "~/.gemini not found (is Gemini CLI installed?)" };
|
|
87
|
+
}
|
|
88
|
+
return probeMinCliVersion("gemini", "0.26.0", "Gemini CLI", deps);
|
|
89
|
+
}
|
|
90
|
+
export const geminiIntegration = {
|
|
91
|
+
id: "gemini",
|
|
92
|
+
label: "Gemini CLI",
|
|
93
|
+
kind: "merged-json",
|
|
94
|
+
maturity: "verified",
|
|
95
|
+
minVersion: "0.26.0",
|
|
96
|
+
configPath,
|
|
97
|
+
detect: (home) => dirExists(join(home, ".gemini")),
|
|
98
|
+
probeHookSupport,
|
|
99
|
+
parseInput,
|
|
100
|
+
emitDecision,
|
|
101
|
+
...mergedJsonHook({
|
|
102
|
+
checkName: "dg BeforeTool hook",
|
|
103
|
+
insert: insertHook,
|
|
104
|
+
remove: removeHook,
|
|
105
|
+
isInstalled: hasDgHook,
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { readCleanupRegistry, recordCleanupEntry, removeCleanupEntry, } from "../state/index.js";
|
|
6
|
+
export const LEGACY_AGENT_HOOK_SENTINEL = "dg-agent-hook-v1";
|
|
7
|
+
export function agentHookSentinel(agent) {
|
|
8
|
+
return `${LEGACY_AGENT_HOOK_SENTINEL}:${agent}`;
|
|
9
|
+
}
|
|
10
|
+
export class AgentHookError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export function dirExists(path) {
|
|
13
|
+
try {
|
|
14
|
+
return lstatSync(path).isDirectory();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function assertSafeNode(path, role) {
|
|
21
|
+
let stats;
|
|
22
|
+
try {
|
|
23
|
+
stats = lstatSync(path);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (stats.isSymbolicLink()) {
|
|
29
|
+
throw new AgentHookError(`${path} is a symlink; refusing to write through it. Edit the link target directly, then retry.`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof process.getuid === "function" && stats.uid !== process.getuid()) {
|
|
32
|
+
throw new AgentHookError(`${path} (${role}) is owned by another user; refusing to write to it.`);
|
|
33
|
+
}
|
|
34
|
+
if ((stats.mode & 0o002) !== 0) {
|
|
35
|
+
throw new AgentHookError(`${path} (${role}) is world-writable; refusing to write to it. Tighten its permissions, then retry.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function assertSafeWriteTarget(path) {
|
|
39
|
+
assertSafeNode(dirname(path), "directory");
|
|
40
|
+
assertSafeNode(path, "file");
|
|
41
|
+
}
|
|
42
|
+
export function readSettings(path) {
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
return { settings: {}, existed: false };
|
|
45
|
+
}
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new AgentHookError(`${path} is not valid JSON; refusing to modify it. Fix or remove it, then retry.`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
54
|
+
throw new AgentHookError(`${path} is not a JSON object; refusing to modify it.`);
|
|
55
|
+
}
|
|
56
|
+
return { settings: parsed, existed: true };
|
|
57
|
+
}
|
|
58
|
+
export function writeSettingsAtomic(path, settings) {
|
|
59
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
60
|
+
assertSafeWriteTarget(path);
|
|
61
|
+
const tmp = `${path}.dg-${randomBytes(6).toString("hex")}.tmp`;
|
|
62
|
+
writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
63
|
+
try {
|
|
64
|
+
renameSync(tmp, path);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
rmSync(tmp, { force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function wasCreatedByDg(ctx) {
|
|
72
|
+
try {
|
|
73
|
+
const reg = await readCleanupRegistry(ctx.paths);
|
|
74
|
+
return reg.entries.some((e) => e.kind === "agent-hook" && e.path === ctx.settingsPath && e.original === "dg-created");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function recordHookEntry(ctx, legacySentinels, created) {
|
|
81
|
+
for (const sentinel of legacySentinels) {
|
|
82
|
+
await removeCleanupEntry(ctx.paths, { kind: "agent-hook", path: ctx.settingsPath, sentinel });
|
|
83
|
+
}
|
|
84
|
+
await recordCleanupEntry(ctx.paths, {
|
|
85
|
+
kind: "agent-hook",
|
|
86
|
+
path: ctx.settingsPath,
|
|
87
|
+
mode: "mode1",
|
|
88
|
+
sentinel: agentHookSentinel(ctx.agent),
|
|
89
|
+
...(created ? { original: "dg-created" } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function removeHookEntries(ctx, legacySentinels) {
|
|
93
|
+
await removeCleanupEntry(ctx.paths, { kind: "agent-hook", path: ctx.settingsPath, sentinel: agentHookSentinel(ctx.agent) });
|
|
94
|
+
for (const sentinel of legacySentinels) {
|
|
95
|
+
await removeCleanupEntry(ctx.paths, { kind: "agent-hook", path: ctx.settingsPath, sentinel });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function mergedJsonHook(config) {
|
|
99
|
+
const legacy = config.legacySentinels ?? [];
|
|
100
|
+
return {
|
|
101
|
+
isInstalledCheckName: config.checkName,
|
|
102
|
+
async apply(ctx) {
|
|
103
|
+
const { settings, existed } = readSettings(ctx.settingsPath);
|
|
104
|
+
writeSettingsAtomic(ctx.settingsPath, config.insert(settings, ctx.dgCommand));
|
|
105
|
+
await recordHookEntry(ctx, legacy, !existed);
|
|
106
|
+
return { created: !existed };
|
|
107
|
+
},
|
|
108
|
+
async remove(ctx) {
|
|
109
|
+
let removed = false;
|
|
110
|
+
if (existsSync(ctx.settingsPath)) {
|
|
111
|
+
let settings = null;
|
|
112
|
+
try {
|
|
113
|
+
settings = readSettings(ctx.settingsPath).settings;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
settings = null;
|
|
117
|
+
}
|
|
118
|
+
if (settings) {
|
|
119
|
+
const result = config.remove(settings);
|
|
120
|
+
if (result.changed) {
|
|
121
|
+
removed = true;
|
|
122
|
+
if (result.empty && (await wasCreatedByDg(ctx))) {
|
|
123
|
+
rmSync(ctx.settingsPath, { force: true });
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
writeSettingsAtomic(ctx.settingsPath, result.settings);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await removeHookEntries(ctx, legacy);
|
|
132
|
+
return { removed };
|
|
133
|
+
},
|
|
134
|
+
verify(ctx) {
|
|
135
|
+
const checks = [];
|
|
136
|
+
const present = existsSync(ctx.settingsPath);
|
|
137
|
+
checks.push({ name: "settings file", ok: present, detail: present ? ctx.settingsPath : `${ctx.settingsPath} (absent)` });
|
|
138
|
+
if (!present) {
|
|
139
|
+
return checks;
|
|
140
|
+
}
|
|
141
|
+
let installed = false;
|
|
142
|
+
try {
|
|
143
|
+
installed = config.isInstalled(readSettings(ctx.settingsPath).settings);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
installed = false;
|
|
147
|
+
}
|
|
148
|
+
checks.push({ name: config.checkName, ok: installed, detail: installed ? "installed" : "not installed" });
|
|
149
|
+
return checks;
|
|
150
|
+
},
|
|
151
|
+
reverseEntry(entry, removed, missing, warnings) {
|
|
152
|
+
if (!existsSync(entry.path)) {
|
|
153
|
+
missing.push(entry.path);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let settings;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(readFileSync(entry.path, "utf8"));
|
|
159
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
160
|
+
warnings.push(`${entry.path}: not a JSON object; left untouched`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
settings = parsed;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
warnings.push(`${entry.path}: unreadable JSON; left untouched`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const result = config.remove(settings);
|
|
170
|
+
if (!result.changed) {
|
|
171
|
+
missing.push(entry.path);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (result.empty && entry.original === "dg-created") {
|
|
175
|
+
rmSync(entry.path, { force: true });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
try {
|
|
179
|
+
writeSettingsAtomic(entry.path, result.settings);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
warnings.push(`${entry.path}: ${error instanceof Error ? error.message : "could not rewrite settings"}`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
removed.push(entry.path);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function ownedJsonHook(config) {
|
|
191
|
+
const sentinel = agentHookSentinel(config.agent);
|
|
192
|
+
const ownsFile = (path) => {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
195
|
+
return parsed?.dgSentinel === sentinel;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
isInstalledCheckName: config.checkName,
|
|
203
|
+
async apply(ctx) {
|
|
204
|
+
const existed = existsSync(ctx.settingsPath);
|
|
205
|
+
if (existed && !ownsFile(ctx.settingsPath)) {
|
|
206
|
+
throw new AgentHookError(`${ctx.settingsPath} already exists and was not written by dg; refusing to overwrite it. Merge the output of 'dg agents --print ${ctx.agent}' yourself, or remove the file and retry.`);
|
|
207
|
+
}
|
|
208
|
+
writeSettingsAtomic(ctx.settingsPath, { dgSentinel: sentinel, ...config.render(ctx.dgCommand) });
|
|
209
|
+
await recordHookEntry(ctx, [], !existed);
|
|
210
|
+
return { created: !existed };
|
|
211
|
+
},
|
|
212
|
+
async remove(ctx) {
|
|
213
|
+
let removed = false;
|
|
214
|
+
if (existsSync(ctx.settingsPath) && ownsFile(ctx.settingsPath)) {
|
|
215
|
+
rmSync(ctx.settingsPath, { force: true });
|
|
216
|
+
removed = true;
|
|
217
|
+
}
|
|
218
|
+
await removeHookEntries(ctx, []);
|
|
219
|
+
return { removed };
|
|
220
|
+
},
|
|
221
|
+
verify(ctx) {
|
|
222
|
+
const checks = [];
|
|
223
|
+
const present = existsSync(ctx.settingsPath);
|
|
224
|
+
checks.push({ name: "hook file", ok: present, detail: present ? ctx.settingsPath : `${ctx.settingsPath} (absent)` });
|
|
225
|
+
if (!present) {
|
|
226
|
+
return checks;
|
|
227
|
+
}
|
|
228
|
+
const installed = ownsFile(ctx.settingsPath);
|
|
229
|
+
checks.push({ name: config.checkName, ok: installed, detail: installed ? "installed" : "present but not dg-owned" });
|
|
230
|
+
return checks;
|
|
231
|
+
},
|
|
232
|
+
reverseEntry(entry, removed, missing, warnings) {
|
|
233
|
+
if (!existsSync(entry.path)) {
|
|
234
|
+
missing.push(entry.path);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!ownsFile(entry.path)) {
|
|
238
|
+
warnings.push(`${entry.path}: not dg-owned; left untouched`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
rmSync(entry.path, { force: true });
|
|
242
|
+
removed.push(entry.path);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function defaultExecVersion(binary, args) {
|
|
247
|
+
try {
|
|
248
|
+
const result = spawnSync(binary, [...args], { encoding: "utf8", timeout: 1500 });
|
|
249
|
+
if (result.status !== 0 || typeof result.stdout !== "string") {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return result.stdout.trim() || null;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function parseSemver(text) {
|
|
259
|
+
const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(text);
|
|
260
|
+
if (!match) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
return [Number(match[1]), Number(match[2]), Number(match[3] ?? "0")];
|
|
264
|
+
}
|
|
265
|
+
export function probeMinCliVersion(binary, minVersion, label, deps) {
|
|
266
|
+
const execVersion = deps?.execVersion ?? defaultExecVersion;
|
|
267
|
+
const output = execVersion(binary, ["--version"]);
|
|
268
|
+
if (!output) {
|
|
269
|
+
return { supported: false, detail: `cannot confirm ${label} >= ${minVersion} (the ${binary} CLI did not report a version)` };
|
|
270
|
+
}
|
|
271
|
+
const found = parseSemver(output);
|
|
272
|
+
const wanted = parseSemver(minVersion);
|
|
273
|
+
if (!found || !wanted) {
|
|
274
|
+
return { supported: false, detail: `cannot confirm ${label} >= ${minVersion} (unrecognized version '${output}')` };
|
|
275
|
+
}
|
|
276
|
+
for (let index = 0; index < 3; index += 1) {
|
|
277
|
+
if ((found[index] ?? 0) > (wanted[index] ?? 0)) {
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
if ((found[index] ?? 0) < (wanted[index] ?? 0)) {
|
|
281
|
+
return { supported: false, detail: `${label} ${found.join(".")} is older than ${minVersion}, which added the hook dg needs` };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { supported: true, detail: `${label} ${found.join(".")}` };
|
|
285
|
+
}
|