deepdebug-local-agent 1.0.23 → 1.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.
Files changed (178) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +76 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +76 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/bin/install.js +16 -5
  26. package/cloudbuild-full.yaml +57 -0
  27. package/docker-compose.yml +5 -3
  28. package/docs/OBSERVABILITY.md +164 -0
  29. package/docs/byoc-agent-image.md +70 -0
  30. package/docs/dlp.md +78 -0
  31. package/docs/graphify-packaging-plan.md +133 -0
  32. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  33. package/docs/releasing.md +37 -0
  34. package/docs/signed-commands.md +163 -0
  35. package/env.example +34 -0
  36. package/helm/Chart.yaml +2 -2
  37. package/helm/templates/deployment.yaml +13 -3
  38. package/helm/templates/networkpolicy.yaml +22 -0
  39. package/helm/templates/secret.yaml +1 -0
  40. package/helm/values.yaml +13 -14
  41. package/mcp-server.js +4 -1
  42. package/package.json +9 -4
  43. package/scripts/next-version.mjs +75 -0
  44. package/scripts/upgrade-observability-push.sh +227 -0
  45. package/src/ai-engine.js +22 -22
  46. package/src/config/trusted-signing-keys.json +3 -0
  47. package/src/detectors/build-tool-detector.js +50 -0
  48. package/src/detectors/multi-project-detector.js +22 -22
  49. package/src/detectors/service-detector.js +24 -24
  50. package/src/dlp/engine.js +111 -0
  51. package/src/dlp/index.js +137 -0
  52. package/src/dlp/rules.js +97 -0
  53. package/src/dlp/validators.js +123 -0
  54. package/src/exec-utils.js +26 -4
  55. package/src/git/base-git-provider.js +117 -117
  56. package/src/git/bitbucket-provider.js +2 -2
  57. package/src/git/git-provider-registry.js +18 -18
  58. package/src/git/github-provider.js +13 -13
  59. package/src/git/multi-repo-pr.js +161 -0
  60. package/src/mcp-http-server.js +32 -16
  61. package/src/observability/bundle-files.js +418 -0
  62. package/src/observability/detect.js +151 -0
  63. package/src/observability/provision.js +212 -0
  64. package/src/observability/render-cli.js +59 -0
  65. package/src/observability/routes.js +88 -0
  66. package/src/patch/patch-engine.js +28 -28
  67. package/src/patch-manager.js +85 -85
  68. package/src/patch_bkp.js +14 -14
  69. package/src/ports.js +4 -4
  70. package/src/routes/workspace.route.js +47 -16
  71. package/src/runtimes/base-runtime.js +75 -75
  72. package/src/runtimes/java/gradle-runtime.js +17 -17
  73. package/src/runtimes/java/java-integrations.js +25 -25
  74. package/src/runtimes/java/maven-runtime.js +34 -34
  75. package/src/runtimes/node/node-integrations.js +1 -1
  76. package/src/runtimes/node/npm-runtime.js +14 -14
  77. package/src/runtimes/node/yarn-runtime.js +4 -4
  78. package/src/runtimes/runtime-registry.js +25 -25
  79. package/src/server-local.js +4 -4
  80. package/src/server.js +1366 -333
  81. package/src/siblings/burst-suppressor.js +89 -0
  82. package/src/siblings/capabilities.js +273 -0
  83. package/src/siblings/env-compat.cjs +58 -0
  84. package/src/siblings/incident-forwarder.js +322 -0
  85. package/src/siblings/logger.js +15 -0
  86. package/src/siblings/metrics.js +94 -0
  87. package/src/utils/agent-auth.js +184 -0
  88. package/src/utils/approval-token.js +56 -0
  89. package/src/utils/bind-host.js +29 -0
  90. package/src/utils/branch-refspec.js +98 -0
  91. package/src/utils/child-env.js +34 -0
  92. package/src/utils/environment-diagnostics.js +55 -55
  93. package/src/utils/exec-policy.js +962 -0
  94. package/src/utils/exec-utils.js +15 -15
  95. package/src/utils/file-count.js +46 -0
  96. package/src/utils/fs-utils.js +10 -10
  97. package/src/utils/multi-repo-open.js +132 -0
  98. package/src/utils/open-gate.js +183 -0
  99. package/src/utils/ranged-read.js +65 -0
  100. package/src/utils/request-guards.js +77 -0
  101. package/src/utils/request-signing.js +342 -0
  102. package/src/utils/root-recovery.js +244 -0
  103. package/src/utils/safe-exec-args.js +58 -0
  104. package/src/utils/sandbox.js +182 -0
  105. package/src/utils/search-outcome.js +56 -0
  106. package/src/utils/workspace-root.js +80 -0
  107. package/src/validators/syntax-validator.js +149 -0
  108. package/src/workspace/detect-port.js +16 -16
  109. package/src/workspace/file-reader.js +1 -1
  110. package/src/workspace/process-manager.js +46 -46
  111. package/src/workspace-manager.js +86 -24
  112. package/test/adapters/registry.test.js +9 -2
  113. package/test/agent-auth.test.js +194 -0
  114. package/test/bind-host.test.js +26 -0
  115. package/test/branch-refspec.test.js +224 -0
  116. package/test/burst-suppressor.test.js +60 -0
  117. package/test/capabilities-access.test.js +47 -0
  118. package/test/capabilities.test.js +87 -0
  119. package/test/child-env.test.js +23 -0
  120. package/test/dlp/engine.test.js +173 -0
  121. package/test/dlp/fixtures.js +126 -0
  122. package/test/dlp/index.test.js +166 -0
  123. package/test/env-compat.test.js +71 -0
  124. package/test/exec-policy.test.js +463 -0
  125. package/test/file-count.test.js +64 -0
  126. package/test/incident-forwarder.test.js +231 -0
  127. package/test/multi-repo-open.test.js +180 -0
  128. package/test/multi-repo-pr.test.js +129 -0
  129. package/test/next-version.test.js +48 -0
  130. package/test/observability-detect.test.js +199 -0
  131. package/test/open-gate.test.js +341 -0
  132. package/test/ranged-read.test.js +60 -0
  133. package/test/request-guards.test.js +91 -0
  134. package/test/request-signing.test.js +284 -0
  135. package/test/root-recovery-sidecar.test.js +171 -0
  136. package/test/root-recovery.test.js +274 -0
  137. package/test/safe-exec-args.test.js +58 -0
  138. package/test/sandbox.test.js +98 -0
  139. package/test/search-outcome.test.js +50 -0
  140. package/test/workspace-root.test.js +64 -0
  141. package/tools/graphify-validation/README.md +92 -0
  142. package/tools/graphify-validation/config/questions.json +31 -0
  143. package/tools/graphify-validation/config/repos.json +9 -0
  144. package/tools/graphify-validation/config/symbols.json +6 -0
  145. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  146. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  147. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  148. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  149. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  150. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  151. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  152. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  153. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  154. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  156. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  157. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  158. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  159. package/tools/graphify-validation/lib/gv_common.py +130 -0
  160. package/tools/graphify-validation/lib/licenses.py +47 -0
  161. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  162. package/tools/graphify-validation/lib/recall.py +115 -0
  163. package/tools/graphify-validation/lib/report.py +226 -0
  164. package/tools/graphify-validation/lib/run_all.py +237 -0
  165. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  166. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  167. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  168. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  169. package/tools/graphify-validation/requirements-py311.lock +508 -0
  170. package/tools/graphify-validation/requirements-tools.in +1 -0
  171. package/tools/graphify-validation/requirements-tools.lock +371 -0
  172. package/tools/graphify-validation/requirements.in +1 -0
  173. package/tools/graphify-validation/requirements.lock +502 -0
  174. package/tools/graphify-validation/run.sh +43 -0
  175. package/tools/graphify-validation/tests/test_harness.py +151 -0
  176. package/cloudbuild-agent-qa.yaml +0 -43
  177. package/cloudbuild-agent-staging.yaml +0 -40
  178. package/cloudbuild.yaml.deprecated +0 -42
@@ -9,24 +9,24 @@ import { FileReader } from "../workspace/file-reader.js";
9
9
  /**
10
10
  * MultiProjectDetector
11
11
  *
12
- * Detecta múltiplos projetos dentro de um workspace
13
- * Escaneia subpastas em busca de projetos independentes (pom.xml, package.json, etc)
12
+ * Detects multiple projects within a workspace
13
+ * Scans subfolders looking for independent projects (pom.xml, package.json, etc)
14
14
  */
15
15
  export class MultiProjectDetector {
16
16
  constructor(workspaceRoot) {
17
17
  this.workspaceRoot = workspaceRoot;
18
- this.maxDepth = 2; // Escanear até 2 níveis de profundidade
18
+ this.maxDepth = 2; // Scan up to 2 levels deep
19
19
  }
20
20
 
21
21
  /**
22
- * Detecta todos os projetos no workspace
22
+ * Detects all projects in the workspace
23
23
  */
24
24
  async detectProjects() {
25
25
  console.log(`🔍 Scanning workspace for multiple projects: ${this.workspaceRoot}`);
26
26
 
27
27
  const projects = [];
28
28
 
29
- // 1️⃣ Verificar se o próprio workspace root é um projeto
29
+ // 1️⃣ Check whether the workspace root itself is a project
30
30
  if (await this.isProject(this.workspaceRoot)) {
31
31
  console.log(`✅ Root is a project`);
32
32
  projects.push({
@@ -36,13 +36,13 @@ export class MultiProjectDetector {
36
36
  });
37
37
  }
38
38
 
39
- // 2️⃣ Escanear subpastas em busca de projetos
39
+ // 2️⃣ Scan subfolders looking for projects
40
40
  const subProjects = await this.scanSubdirectories(this.workspaceRoot, 0);
41
41
  projects.push(...subProjects);
42
42
 
43
43
  console.log(`✅ Found ${projects.length} project(s) in workspace`);
44
44
 
45
- // 3️⃣ Detectar serviços para cada projeto
45
+ // 3️⃣ Detect services for each project
46
46
  const allServices = [];
47
47
  for (const project of projects) {
48
48
  console.log(`🔎 Detecting services in: ${project.name}`);
@@ -55,7 +55,7 @@ export class MultiProjectDetector {
55
55
  }
56
56
 
57
57
  /**
58
- * Verifica se um diretório é um projeto
58
+ * Checks whether a directory is a project
59
59
  */
60
60
  async isProject(dir) {
61
61
  const markers = [
@@ -67,12 +67,12 @@ export class MultiProjectDetector {
67
67
  'pyproject.toml', // Python Poetry
68
68
  'go.mod', // Go
69
69
  'Cargo.toml', // Rust
70
- '.csproj', // .NET (qualquer arquivo .csproj)
70
+ '.csproj', // .NET (any .csproj file)
71
71
  '.sln' // .NET solution
72
72
  ];
73
73
 
74
74
  for (const marker of markers) {
75
- // Para .csproj e .sln, procurar qualquer arquivo com extensão
75
+ // For .csproj and .sln, look for any file with that extension
76
76
  if (marker.startsWith('.')) {
77
77
  try {
78
78
  const files = await readdir(dir);
@@ -83,7 +83,7 @@ export class MultiProjectDetector {
83
83
  continue;
84
84
  }
85
85
  } else {
86
- // Para markers específicos, verificar se existe
86
+ // For specific markers, check whether they exist
87
87
  if (await exists(path.join(dir, marker))) {
88
88
  return true;
89
89
  }
@@ -94,7 +94,7 @@ export class MultiProjectDetector {
94
94
  }
95
95
 
96
96
  /**
97
- * Escaneia subdiretórios recursivamente
97
+ * Scans subdirectories recursively
98
98
  */
99
99
  async scanSubdirectories(dir, depth) {
100
100
  if (depth >= this.maxDepth) {
@@ -109,7 +109,7 @@ export class MultiProjectDetector {
109
109
  for (const entry of entries) {
110
110
  if (!entry.isDirectory()) continue;
111
111
 
112
- // Ignorar pastas comuns que não são projetos
112
+ // Ignore common folders that are not projects
113
113
  const ignoreDirs = [
114
114
  'node_modules', 'target', 'build', 'dist', 'out',
115
115
  '.git', '.idea', '.vscode', '__pycache__',
@@ -123,7 +123,7 @@ export class MultiProjectDetector {
123
123
 
124
124
  const fullPath = path.join(dir, entry.name);
125
125
 
126
- // Verificar se é um projeto
126
+ // Check whether it is a project
127
127
  if (await this.isProject(fullPath)) {
128
128
  console.log(`✅ Found project: ${entry.name}`);
129
129
  projects.push({
@@ -131,11 +131,11 @@ export class MultiProjectDetector {
131
131
  path: fullPath,
132
132
  isRoot: false
133
133
  });
134
- // Não escanear dentro de projetos encontrados
134
+ // Do not scan inside projects that were found
135
135
  continue;
136
136
  }
137
137
 
138
- // Continuar escaneando recursivamente
138
+ // Keep scanning recursively
139
139
  const subProjects = await this.scanSubdirectories(fullPath, depth + 1);
140
140
  projects.push(...subProjects);
141
141
  }
@@ -147,19 +147,19 @@ export class MultiProjectDetector {
147
147
  }
148
148
 
149
149
  /**
150
- * Detecta serviços em um projeto específico
150
+ * Detects services in a specific project
151
151
  */
152
152
  async detectServicesInProject(project) {
153
153
  try {
154
- // Usar o scanner para analisar o projeto
154
+ // Use the scanner to analyze the project
155
155
  const scanner = new WorkspaceScanner(project.path);
156
156
  const structure = await scanner.scan();
157
157
 
158
- // Detectar linguagem
158
+ // Detect the language
159
159
  const languageDetector = new LanguageDetector(structure.files);
160
160
  const languageInfo = languageDetector.detect();
161
161
 
162
- // Detectar framework
162
+ // Detect the framework
163
163
  const fileReader = new FileReader(project.path);
164
164
  const frameworkDetector = new FrameworkDetector(
165
165
  languageInfo.primary,
@@ -168,7 +168,7 @@ export class MultiProjectDetector {
168
168
  );
169
169
  const frameworkInfo = await frameworkDetector.detect();
170
170
 
171
- // Detectar serviços
171
+ // Detect the services
172
172
  const serviceDetector = new ServiceDetector(
173
173
  project.path,
174
174
  languageInfo,
@@ -177,7 +177,7 @@ export class MultiProjectDetector {
177
177
 
178
178
  const services = await serviceDetector.detect();
179
179
 
180
- // Adicionar nome do workspace a cada serviço
180
+ // Add the workspace name to each service
181
181
  return services.map(service => ({
182
182
  ...service,
183
183
  workspaceName: project.name,
@@ -4,8 +4,8 @@ import { exists, readFile } from "../fs-utils.js";
4
4
  /**
5
5
  * ServiceDetector
6
6
  *
7
- * Detecta serviços/microservices em um workspace
8
- * Identifica: nome, porta, tipo, comando de start
7
+ * Detects services/microservices in a workspace
8
+ * Identifies: name, port, type, start command
9
9
  */
10
10
  export class ServiceDetector {
11
11
  constructor(root, languageInfo, frameworkInfo) {
@@ -16,12 +16,12 @@ export class ServiceDetector {
16
16
  }
17
17
 
18
18
  /**
19
- * Detecta todos os serviços no workspace
19
+ * Detects all services in the workspace
20
20
  */
21
21
  async detect() {
22
22
  const services = [];
23
23
 
24
- // Detectar por tipo de projeto
24
+ // Detect by project type
25
25
  if (this.language === "java" && this.framework === "spring-boot") {
26
26
  const service = await this.detectSpringBootService();
27
27
  if (service) services.push(service);
@@ -41,20 +41,20 @@ export class ServiceDetector {
41
41
  }
42
42
 
43
43
  /**
44
- * Detecta serviço Spring Boot
44
+ * Detects a Spring Boot service
45
45
  */
46
46
  async detectSpringBootService() {
47
47
  const pomPath = path.join(this.root, "pom.xml");
48
48
  const gradlePath = path.join(this.root, "build.gradle");
49
49
 
50
- let serviceName = path.basename(this.root); // Fallback: nome da pasta
50
+ let serviceName = path.basename(this.root); // Fallback: folder name
51
51
  let port = 8080;
52
52
 
53
- // Tentar extrair nome do pom.xml (ignorando o parent)
53
+ // Try to extract the name from pom.xml (ignoring the parent)
54
54
  if (await exists(pomPath)) {
55
55
  const content = await readFile(pomPath, "utf8");
56
56
 
57
- // Remove o bloco <parent>...</parent> para não pegar o artifactId errado
57
+ // Remove the <parent>...</parent> block so we do not pick the wrong artifactId
58
58
  const contentWithoutParent = content.replace(/<parent>[\s\S]*?<\/parent>/gi, '');
59
59
  const artifactIdMatch = contentWithoutParent.match(/<artifactId>([^<]+)<\/artifactId>/);
60
60
 
@@ -63,7 +63,7 @@ export class ServiceDetector {
63
63
  }
64
64
  }
65
65
 
66
- // Tentar extrair do build.gradle
66
+ // Try to extract it from build.gradle
67
67
  if (await exists(gradlePath)) {
68
68
  const content = await readFile(gradlePath, "utf8");
69
69
  const nameMatch = content.match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/);
@@ -72,7 +72,7 @@ export class ServiceDetector {
72
72
  }
73
73
  }
74
74
 
75
- // Tentar detectar porta do application.yml/properties
75
+ // Try to detect the port from application.yml/properties
76
76
  port = await this.detectSpringBootPort();
77
77
 
78
78
  return {
@@ -89,7 +89,7 @@ export class ServiceDetector {
89
89
  }
90
90
 
91
91
  /**
92
- * Detecta porta do Spring Boot
92
+ * Detects the Spring Boot port
93
93
  */
94
94
  async detectSpringBootPort() {
95
95
  const candidates = [
@@ -103,21 +103,21 @@ export class ServiceDetector {
103
103
  if (await exists(fullPath)) {
104
104
  const content = await readFile(fullPath, "utf8");
105
105
 
106
- // YAML - múltiplos padrões
106
+ // YAML - multiple patterns
107
107
  if (rel.endsWith(".yml") || rel.endsWith(".yaml")) {
108
- // Padrão 1: server:\n port: 8080
108
+ // Pattern 1: server:\n port: 8080
109
109
  let match = content.match(/server:\s*\n\s*port:\s*(\d+)/);
110
110
  if (match) return parseInt(match[1]);
111
111
 
112
- // Padrão 2: server.port: 8080
112
+ // Pattern 2: server.port: 8080
113
113
  match = content.match(/server\.port:\s*(\d+)/);
114
114
  if (match) return parseInt(match[1]);
115
115
 
116
- // Padrão 3: port: ${PORT:8080}
116
+ // Pattern 3: port: ${PORT:8080}
117
117
  match = content.match(/port:\s*\$\{[^}]*:(\d+)\}/);
118
118
  if (match) return parseInt(match[1]);
119
119
 
120
- // Padrão 4: server:\n port: 8080 (com mais espaços)
120
+ // Pattern 4: server:\n port: 8080 (with more spaces)
121
121
  match = content.match(/server:\s*[\r\n]+\s*port:\s*(\d+)/);
122
122
  if (match) return parseInt(match[1]);
123
123
  }
@@ -134,7 +134,7 @@ export class ServiceDetector {
134
134
  }
135
135
 
136
136
  /**
137
- * Detecta serviço Node.js
137
+ * Detects a Node.js service
138
138
  */
139
139
  async detectNodeService() {
140
140
  const pkgPath = path.join(this.root, "package.json");
@@ -157,10 +157,10 @@ export class ServiceDetector {
157
157
  }
158
158
 
159
159
  /**
160
- * Detecta porta do Node.js
160
+ * Detects the Node.js port
161
161
  */
162
162
  async detectNodePort() {
163
- // Tentar .env
163
+ // Try .env
164
164
  const envPath = path.join(this.root, ".env");
165
165
  if (await exists(envPath)) {
166
166
  const content = await readFile(envPath, "utf8");
@@ -168,7 +168,7 @@ export class ServiceDetector {
168
168
  if (match) return parseInt(match[1]);
169
169
  }
170
170
 
171
- // Defaults por framework
171
+ // Defaults per framework
172
172
  const defaults = {
173
173
  "next": 3000,
174
174
  "express": 3000,
@@ -179,7 +179,7 @@ export class ServiceDetector {
179
179
  }
180
180
 
181
181
  /**
182
- * Detecta serviço Python
182
+ * Detects a Python service
183
183
  */
184
184
  async detectPythonService() {
185
185
  return {
@@ -196,7 +196,7 @@ export class ServiceDetector {
196
196
  }
197
197
 
198
198
  /**
199
- * Detecta porta do Python
199
+ * Detects the Python port
200
200
  */
201
201
  async detectPythonPort() {
202
202
  const defaults = {
@@ -209,7 +209,7 @@ export class ServiceDetector {
209
209
  }
210
210
 
211
211
  /**
212
- * Detecta serviço .NET
212
+ * Detects a .NET service
213
213
  */
214
214
  detectDotNetService() {
215
215
  return {
@@ -226,7 +226,7 @@ export class ServiceDetector {
226
226
  }
227
227
 
228
228
  /**
229
- * Detecta serviço Go
229
+ * Detects a Go service
230
230
  */
231
231
  detectGoService() {
232
232
  return {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * DLP engine: pure functions, no env, no I/O, no module state.
3
+ *
4
+ * maskSensitive(text, opts) -> { text, redactions, total, blocked, blockedClasses, ... }
5
+ * mode 'off' text returned untouched, nothing scanned.
6
+ * mode 'warn' text returned untouched; redactions/blocked report what enforce WOULD do.
7
+ * mode 'enforce' text returned with every hit replaced by [REDACTED:<rule_id>];
8
+ * when a hit belongs to a class in blockClasses the whole text is
9
+ * replaced by [BLOCKED:<class>] and `blocked` is true.
10
+ * The matched value is never returned, logged or counted: only rule ids and counts.
11
+ */
12
+ import { RULES, RULESET_VERSION } from './rules.js';
13
+ import { createHash } from 'node:crypto';
14
+
15
+ export const MODES = Object.freeze(['off', 'warn', 'enforce']);
16
+ export const DEFAULT_MAX_BYTES = 2_000_000;
17
+
18
+ /** @param {unknown} v @returns {'off'|'warn'|'enforce'} unknown values fall back to 'off'. */
19
+ export function normalizeMode(v) {
20
+ const m = typeof v === 'string' ? v.trim().toLowerCase() : '';
21
+ return MODES.includes(m) ? m : 'off';
22
+ }
23
+
24
+ /** Stable hash of the active ruleset (sent with config.update / heartbeat). */
25
+ export function rulesetHash(rules = RULES) {
26
+ const h = createHash('sha256');
27
+ h.update(RULESET_VERSION);
28
+ for (const rule of rules) h.update(`\n${rule.id}|${rule.class}|${rule.re.source}|${rule.re.flags}|${rule.group ?? 0}`);
29
+ return h.digest('hex');
30
+ }
31
+
32
+ export const REDACTED = (id) => `[REDACTED:${id}]`;
33
+ export const BLOCKED = (cls) => `[BLOCKED:${cls}]`;
34
+ export const OVERSIZE = '[REDACTED:dlp_oversize]';
35
+
36
+ /**
37
+ * @param {string} text
38
+ * @param {{mode?: string, rules?: ReadonlyArray<object>, blockClasses?: Iterable<string>, maxBytes?: number}} [opts]
39
+ */
40
+ export function maskSensitive(text, opts = {}) {
41
+ const mode = normalizeMode(opts.mode);
42
+ const empty = { text, mode, redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false, rulesetVersion: RULESET_VERSION };
43
+ if (mode === 'off' || typeof text !== 'string' || text.length === 0) return empty;
44
+
45
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
46
+ if (text.length > maxBytes) {
47
+ // Cannot scan safely in bounded time: enforce fails closed, warn only reports.
48
+ return { ...empty, text: mode === 'enforce' ? OVERSIZE : text, oversize: true };
49
+ }
50
+
51
+ const rules = opts.rules ?? RULES;
52
+ const blockSet = new Set(opts.blockClasses ?? []);
53
+ const redactions = {};
54
+ const hitClasses = new Set();
55
+ let out = text;
56
+ let total = 0;
57
+
58
+ for (const rule of rules) {
59
+ const re = new RegExp(rule.re.source, rule.re.flags.includes('g') ? rule.re.flags : rule.re.flags + 'g');
60
+ out = out.replace(re, (...args) => {
61
+ const match = args[0];
62
+ const groupIdx = rule.group ?? 0;
63
+ const target = groupIdx === 0 ? match : args[groupIdx];
64
+ if (typeof target !== 'string' || target.length === 0) return match;
65
+ if (target.includes('[REDACTED:')) return match; // never re-mask a placeholder (idempotence)
66
+ if (rule.validate && !rule.validate(target)) return match;
67
+ redactions[rule.id] = (redactions[rule.id] || 0) + 1;
68
+ hitClasses.add(rule.class);
69
+ total++;
70
+ if (groupIdx === 0) return REDACTED(rule.id);
71
+ const at = match.lastIndexOf(target);
72
+ return match.slice(0, at) + REDACTED(rule.id) + match.slice(at + target.length);
73
+ });
74
+ }
75
+
76
+ const blockedClasses = [...hitClasses].filter((c) => blockSet.has(c));
77
+ const blocked = blockedClasses.length > 0;
78
+ if (mode === 'warn') return { ...empty, redactions, total, blocked, blockedClasses };
79
+ return { ...empty, text: blocked ? BLOCKED(blockedClasses[0]) : out, redactions, total, blocked, blockedClasses };
80
+ }
81
+
82
+ /**
83
+ * Apply maskSensitive to every string in a JSON-like value (objects, arrays).
84
+ * Keys are not touched. Returns a new value plus the merged report.
85
+ */
86
+ export function maskDeep(value, opts = {}) {
87
+ const report = { redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false };
88
+ const seen = new WeakSet();
89
+ const walk = (v) => {
90
+ if (typeof v === 'string') {
91
+ const res = maskSensitive(v, opts);
92
+ for (const [k, n] of Object.entries(res.redactions)) report.redactions[k] = (report.redactions[k] || 0) + n;
93
+ report.total += res.total;
94
+ report.blocked ||= res.blocked;
95
+ report.oversize ||= res.oversize;
96
+ for (const c of res.blockedClasses) if (!report.blockedClasses.includes(c)) report.blockedClasses.push(c);
97
+ return res.text;
98
+ }
99
+ if (v === null || typeof v !== 'object') return v;
100
+ if (seen.has(v)) return '[circular]';
101
+ seen.add(v);
102
+ if (Array.isArray(v)) return v.map(walk);
103
+ const o = {};
104
+ for (const [k, x] of Object.entries(v)) o[k] = walk(x);
105
+ return o;
106
+ };
107
+ const mode = normalizeMode(opts.mode);
108
+ if (mode === 'off') return { value, mode, ...report };
109
+ const masked = walk(value);
110
+ return { value: mode === 'warn' ? value : masked, mode, ...report };
111
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DLP for the Local Agent (T4-09): public API.
3
+ *
4
+ * Pure, reusable core (no env, no state): maskSensitive, maskDeep (engine.js)
5
+ * Configured wrappers (env + counters): protectEgress, protectEgressAsync, protectDeep
6
+ *
7
+ * Flag: LOCAL_AGENT_DLP_MODE=off|warn|enforce (default off: output byte-identical to before)
8
+ * LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (default none; enforce only)
9
+ * LOCAL_AGENT_DLP_MAX_BYTES=2000000 (enforce fails closed above it)
10
+ *
11
+ * Fail-closed (D19): in enforce, an internal DLP error, an oversize input or a
12
+ * PolicyGate decision that is not an explicit ALLOW (including offline/timeout)
13
+ * withholds the content ([DLP_DENIED]) instead of letting it out.
14
+ *
15
+ * Reports carry rule ids and counts only; never the matched value.
16
+ */
17
+ import { maskSensitive, maskDeep, normalizeMode, rulesetHash, DEFAULT_MAX_BYTES } from './engine.js';
18
+ import { RULES, RULESET_VERSION, RULE_CLASSES } from './rules.js';
19
+
20
+ export { maskSensitive, maskDeep, normalizeMode, rulesetHash, RULES, RULESET_VERSION, RULE_CLASSES };
21
+
22
+ export const DENIED = '[DLP_DENIED]';
23
+ const NOOP = () => {};
24
+
25
+ /** Read the DLP configuration from an env-like object. */
26
+ export function dlpConfigFromEnv(env = process.env) {
27
+ const maxRaw = Number.parseInt(env.LOCAL_AGENT_DLP_MAX_BYTES ?? '', 10);
28
+ const blockClasses = String(env.LOCAL_AGENT_DLP_BLOCK_CLASSES ?? '')
29
+ .split(',').map((s) => s.trim()).filter((c) => RULE_CLASSES.includes(c));
30
+ return {
31
+ mode: normalizeMode(env.LOCAL_AGENT_DLP_MODE),
32
+ blockClasses,
33
+ maxBytes: Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DEFAULT_MAX_BYTES,
34
+ };
35
+ }
36
+
37
+ // ---- counters (for heartbeat / dlp_redactions_total) -------------------------
38
+ const counters = { scanned: 0, redactedTotal: 0, blocked: 0, denied: 0, byRule: new Map() };
39
+
40
+ function record(res) {
41
+ counters.scanned++;
42
+ counters.redactedTotal += res.total;
43
+ if (res.blocked) counters.blocked++;
44
+ for (const [id, n] of Object.entries(res.redactions)) counters.byRule.set(id, (counters.byRule.get(id) || 0) + n);
45
+ }
46
+
47
+ /** Snapshot for the heartbeat: counts only. `dlp_redactions_total` is per rule id. */
48
+ export function getDlpStats(env = process.env) {
49
+ const cfg = dlpConfigFromEnv(env);
50
+ return {
51
+ mode: cfg.mode,
52
+ ruleset_version: RULESET_VERSION,
53
+ ruleset_hash: rulesetHash(),
54
+ scanned: counters.scanned,
55
+ blocked: counters.blocked,
56
+ denied: counters.denied,
57
+ dlp_redactions_total: Object.fromEntries(counters.byRule),
58
+ };
59
+ }
60
+
61
+ export function resetDlpStats() {
62
+ counters.scanned = 0; counters.redactedTotal = 0; counters.blocked = 0; counters.denied = 0;
63
+ counters.byRule.clear();
64
+ }
65
+
66
+ function localLog(log, source, res) {
67
+ if (res.total === 0 && !res.oversize) return;
68
+ try {
69
+ // Counts per rule id only. The masked value is never part of the record.
70
+ log({ dlp: true, source: source || 'unknown', mode: res.mode, total: res.total, rules: res.redactions, blocked: res.blocked, oversize: res.oversize }, '[dlp] sensitive content detected');
71
+ } catch { /* logging must never break the data path */ }
72
+ }
73
+
74
+ /**
75
+ * Mask one string leaving the agent.
76
+ * @param {string} text
77
+ * @param {{env?: object, source?: string, log?: Function}} [opts]
78
+ * @returns {{text: string, mode: string, redactions: Record<string, number>, total: number, blocked: boolean, denied: boolean}}
79
+ */
80
+ export function protectEgress(text, opts = {}) {
81
+ const cfg = dlpConfigFromEnv(opts.env ?? process.env);
82
+ if (cfg.mode === 'off') return { text, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
83
+ try {
84
+ const res = maskSensitive(text, cfg);
85
+ record(res);
86
+ localLog(opts.log ?? NOOP, opts.source, res);
87
+ return { text: res.text, mode: cfg.mode, redactions: res.redactions, total: res.total, blocked: res.blocked, denied: false };
88
+ } catch {
89
+ if (cfg.mode !== 'enforce') return { text, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
90
+ counters.denied++;
91
+ return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Same as protectEgress, plus a per-action PolicyGate decision (D19).
97
+ * `policyDecision` is optional; when given, enforce requires an explicit 'ALLOW'.
98
+ * Offline, throwing, timing out or answering anything else = DENY.
99
+ * @param {string} text
100
+ * @param {{policyDecision?: () => Promise<string>|string, timeoutMs?: number, env?: object, source?: string, log?: Function}} [opts]
101
+ */
102
+ export async function protectEgressAsync(text, opts = {}) {
103
+ const cfg = dlpConfigFromEnv(opts.env ?? process.env);
104
+ if (cfg.mode === 'enforce' && typeof opts.policyDecision === 'function') {
105
+ let verdict;
106
+ let timer;
107
+ try {
108
+ const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('policy timeout')), opts.timeoutMs ?? 3000); });
109
+ verdict = await Promise.race([Promise.resolve().then(opts.policyDecision), timeout]);
110
+ } catch {
111
+ verdict = undefined;
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ if (verdict !== 'ALLOW') {
116
+ counters.denied++;
117
+ return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
118
+ }
119
+ }
120
+ return protectEgress(text, opts);
121
+ }
122
+
123
+ /** Mask every string in a JSON-like payload. Returns { value, ... } with the same report fields. */
124
+ export function protectDeep(value, opts = {}) {
125
+ const cfg = dlpConfigFromEnv(opts.env ?? process.env);
126
+ if (cfg.mode === 'off') return { value, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
127
+ try {
128
+ const res = maskDeep(value, cfg);
129
+ record(res);
130
+ localLog(opts.log ?? NOOP, opts.source, { ...res, mode: cfg.mode });
131
+ return { ...res, denied: false };
132
+ } catch {
133
+ if (cfg.mode !== 'enforce') return { value, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
134
+ counters.denied++;
135
+ return { value: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
136
+ }
137
+ }