patchwarden 0.4.0 → 0.6.1

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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local-only confirmation for medium-risk assessment tickets.
4
+ *
5
+ * This command is deliberately not exposed through the MCP registry. It must
6
+ * be invoked by a person (or another explicitly trusted local workflow) in a
7
+ * terminal that uses the same PatchWarden configuration as the server.
8
+ */
9
+ export {};
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local-only confirmation for medium-risk assessment tickets.
4
+ *
5
+ * This command is deliberately not exposed through the MCP registry. It must
6
+ * be invoked by a person (or another explicitly trusted local workflow) in a
7
+ * terminal that uses the same PatchWarden configuration as the server.
8
+ */
9
+ import { loadConfig } from "../config.js";
10
+ import { confirmAssessment } from "./assessmentStore.js";
11
+ import { errorPayload } from "../errors.js";
12
+ loadConfig();
13
+ const assessmentId = process.argv[2];
14
+ if (!assessmentId || process.argv.length !== 3) {
15
+ console.error("Usage: patchwarden-confirm <full_assessment_id>");
16
+ console.error("The display-only assessment_short_id is not accepted.");
17
+ process.exit(1);
18
+ }
19
+ try {
20
+ const result = confirmAssessment(assessmentId);
21
+ console.log(JSON.stringify(result, null, 2));
22
+ }
23
+ catch (error) {
24
+ console.error(JSON.stringify(errorPayload(error), null, 2));
25
+ process.exit(1);
26
+ }
package/dist/config.d.ts CHANGED
@@ -6,19 +6,40 @@ export interface PatchWardenConfig {
6
6
  workspaceRoot: string;
7
7
  plansDir: string;
8
8
  tasksDir: string;
9
+ assessmentsDir: string;
10
+ assessmentTtlSeconds: number;
9
11
  agents: Record<string, AgentConfig>;
10
12
  allowedTestCommands: string[];
13
+ repoAllowedTestCommands?: Record<string, string[]>;
11
14
  maxReadFileBytes: number;
12
15
  defaultTaskTimeoutSeconds: number;
13
16
  maxTaskTimeoutSeconds: number;
14
17
  watcherStaleSeconds: number;
15
- toolProfile?: "full" | "chatgpt_core";
18
+ toolProfile?: "full" | "chatgpt_core" | "chatgpt_direct";
16
19
  repoAliases?: Record<string, string>;
20
+ enableAgentAssessment?: boolean;
21
+ agentAssessmentTimeoutSeconds?: number;
22
+ agentAssessmentMaxOutputBytes?: number;
23
+ agentAssessmentAgentName?: string;
24
+ enableDirectProfile?: boolean;
25
+ directSessionsDir: string;
26
+ directSessionTtlSeconds: number;
27
+ directMaxPatchBytes: number;
28
+ directMaxFileBytes: number;
29
+ directAllowedCommands?: string[];
30
+ repoDirectAllowedCommands?: Record<string, string[]>;
17
31
  }
18
32
  export declare function loadConfig(configPath?: string): PatchWardenConfig;
33
+ export declare function reloadConfig(configPath?: string): PatchWardenConfig;
19
34
  export declare function getConfig(): PatchWardenConfig;
20
35
  /** Resolve workspaceRoot: expand relative paths */
21
36
  export declare function resolveWorkspaceRoot(config: PatchWardenConfig): string;
22
37
  /** Resolve plans/tasks dirs relative to workspaceRoot */
23
38
  export declare function getPlansDir(config: PatchWardenConfig): string;
24
39
  export declare function getTasksDir(config: PatchWardenConfig): string;
40
+ export declare function getAssessmentsDir(config: PatchWardenConfig): string;
41
+ export declare function getRepoAllowedTestCommands(config: PatchWardenConfig, repoPath: string): string[];
42
+ export declare function getAllConfiguredTestCommands(config: PatchWardenConfig): string[];
43
+ export declare function getDirectSessionsDir(config: PatchWardenConfig): string;
44
+ export declare function getRepoDirectAllowedCommands(config: PatchWardenConfig, repoPath: string): string[];
45
+ export declare function getAllConfiguredDirectCommands(config: PatchWardenConfig): string[];
package/dist/config.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
3
  // ── Defaults ──────────────────────────────────────────────────────
4
4
  const DEFAULT_CONFIG = {
5
5
  workspaceRoot: process.cwd(),
6
6
  plansDir: ".patchwarden/plans",
7
7
  tasksDir: ".patchwarden/tasks",
8
+ assessmentsDir: ".patchwarden/assessments",
9
+ assessmentTtlSeconds: 3600,
8
10
  agents: {
9
11
  codex: {
10
12
  command: "codex",
@@ -33,17 +35,41 @@ const DEFAULT_CONFIG = {
33
35
  "pytest",
34
36
  "cargo test",
35
37
  ],
38
+ repoAllowedTestCommands: {},
36
39
  maxReadFileBytes: 200_000,
37
40
  defaultTaskTimeoutSeconds: 900,
38
41
  maxTaskTimeoutSeconds: 3600,
39
42
  watcherStaleSeconds: 30,
40
43
  toolProfile: "full",
44
+ enableAgentAssessment: false,
45
+ agentAssessmentTimeoutSeconds: 120,
46
+ agentAssessmentMaxOutputBytes: 524288,
47
+ enableDirectProfile: false,
48
+ directSessionsDir: ".patchwarden/direct-sessions",
49
+ directSessionTtlSeconds: 3600,
50
+ directMaxPatchBytes: 200_000,
51
+ directMaxFileBytes: 500_000,
52
+ directAllowedCommands: [
53
+ "npm test",
54
+ "npm run test",
55
+ "npm run build",
56
+ "npm run lint",
57
+ "node --check main.js",
58
+ ],
59
+ repoDirectAllowedCommands: {},
41
60
  };
42
61
  // ── Load config ───────────────────────────────────────────────────
43
62
  let _config = null;
44
63
  export function loadConfig(configPath) {
45
64
  if (_config)
46
65
  return _config;
66
+ return loadConfigInternal(configPath);
67
+ }
68
+ export function reloadConfig(configPath) {
69
+ _config = null;
70
+ return loadConfigInternal(configPath);
71
+ }
72
+ function loadConfigInternal(configPath) {
47
73
  const explicitPath = configPath || process.env.PATCHWARDEN_CONFIG;
48
74
  const candidatePaths = explicitPath
49
75
  ? [explicitPath]
@@ -87,6 +113,9 @@ export function getPlansDir(config) {
87
113
  export function getTasksDir(config) {
88
114
  return resolve(config.workspaceRoot, config.tasksDir);
89
115
  }
116
+ export function getAssessmentsDir(config) {
117
+ return resolve(config.workspaceRoot, config.assessmentsDir);
118
+ }
90
119
  function stripBom(value) {
91
120
  return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
92
121
  }
@@ -100,12 +129,37 @@ function normalizeConfig(config) {
100
129
  if (!config.tasksDir || typeof config.tasksDir !== "string") {
101
130
  throw new Error("tasksDir must be a non-empty string");
102
131
  }
132
+ if (!config.assessmentsDir || typeof config.assessmentsDir !== "string") {
133
+ throw new Error("assessmentsDir must be a non-empty string");
134
+ }
135
+ if (!Number.isInteger(config.assessmentTtlSeconds) || config.assessmentTtlSeconds < 60 || config.assessmentTtlSeconds > 86400) {
136
+ throw new Error("assessmentTtlSeconds must be an integer from 60 to 86400");
137
+ }
103
138
  if (!config.agents || typeof config.agents !== "object") {
104
139
  throw new Error("agents must be an object");
105
140
  }
106
141
  if (!Array.isArray(config.allowedTestCommands)) {
107
142
  throw new Error("allowedTestCommands must be an array");
108
143
  }
144
+ if (config.allowedTestCommands.some((command) => typeof command !== "string" || command.trim() === "")) {
145
+ throw new Error("allowedTestCommands must contain only non-empty command strings");
146
+ }
147
+ if (!config.repoAllowedTestCommands || typeof config.repoAllowedTestCommands !== "object" || Array.isArray(config.repoAllowedTestCommands)) {
148
+ throw new Error("repoAllowedTestCommands must be an object mapping workspace-relative repository paths to command arrays");
149
+ }
150
+ const repoAllowedTestCommands = {};
151
+ for (const [repoKey, commands] of Object.entries(config.repoAllowedTestCommands)) {
152
+ const normalizedKey = normalizeRepoKey(repoKey);
153
+ const resolvedRepo = resolve(config.workspaceRoot, normalizedKey);
154
+ const relativeRepo = relative(resolve(config.workspaceRoot), resolvedRepo);
155
+ if (isAbsolute(repoKey) || relativeRepo === ".." || relativeRepo.startsWith(`..${sep}`) || isAbsolute(relativeRepo)) {
156
+ throw new Error(`repoAllowedTestCommands key must stay inside workspaceRoot: "${repoKey}"`);
157
+ }
158
+ if (!Array.isArray(commands) || commands.some((command) => typeof command !== "string" || command.trim() === "")) {
159
+ throw new Error(`repoAllowedTestCommands["${repoKey}"] must be an array of non-empty command strings`);
160
+ }
161
+ repoAllowedTestCommands[normalizedKey] = [...new Set(commands.map((command) => command.trim()))];
162
+ }
109
163
  if (!Number.isFinite(config.maxReadFileBytes) || config.maxReadFileBytes <= 0) {
110
164
  throw new Error("maxReadFileBytes must be a positive number");
111
165
  }
@@ -121,11 +175,110 @@ function normalizeConfig(config) {
121
175
  if (!Number.isInteger(config.watcherStaleSeconds) || config.watcherStaleSeconds < 5 || config.watcherStaleSeconds > 3600) {
122
176
  throw new Error("watcherStaleSeconds must be an integer from 5 to 3600");
123
177
  }
124
- if (config.toolProfile !== undefined && config.toolProfile !== "full" && config.toolProfile !== "chatgpt_core") {
125
- throw new Error('toolProfile must be "full" or "chatgpt_core"');
178
+ if (config.toolProfile !== undefined && config.toolProfile !== "full" && config.toolProfile !== "chatgpt_core" && config.toolProfile !== "chatgpt_direct") {
179
+ throw new Error('toolProfile must be "full", "chatgpt_core", or "chatgpt_direct"');
180
+ }
181
+ if (config.enableAgentAssessment !== undefined && typeof config.enableAgentAssessment !== "boolean") {
182
+ throw new Error("enableAgentAssessment must be a boolean");
183
+ }
184
+ if (config.agentAssessmentTimeoutSeconds !== undefined) {
185
+ if (!Number.isInteger(config.agentAssessmentTimeoutSeconds) || config.agentAssessmentTimeoutSeconds < 10 || config.agentAssessmentTimeoutSeconds > 600) {
186
+ throw new Error("agentAssessmentTimeoutSeconds must be an integer from 10 to 600");
187
+ }
188
+ }
189
+ if (config.agentAssessmentMaxOutputBytes !== undefined) {
190
+ if (!Number.isInteger(config.agentAssessmentMaxOutputBytes) || config.agentAssessmentMaxOutputBytes < 16384 || config.agentAssessmentMaxOutputBytes > 8388608) {
191
+ throw new Error("agentAssessmentMaxOutputBytes must be an integer from 16384 to 8388608");
192
+ }
193
+ }
194
+ if (config.agentAssessmentAgentName !== undefined && typeof config.agentAssessmentAgentName !== "string") {
195
+ throw new Error("agentAssessmentAgentName must be a string");
196
+ }
197
+ if (config.enableDirectProfile !== undefined && typeof config.enableDirectProfile !== "boolean") {
198
+ throw new Error("enableDirectProfile must be a boolean");
199
+ }
200
+ if (!config.directSessionsDir || typeof config.directSessionsDir !== "string") {
201
+ throw new Error("directSessionsDir must be a non-empty string");
202
+ }
203
+ if (!Number.isInteger(config.directSessionTtlSeconds) || config.directSessionTtlSeconds < 60 || config.directSessionTtlSeconds > 86400) {
204
+ throw new Error("directSessionTtlSeconds must be an integer from 60 to 86400");
205
+ }
206
+ if (!Number.isInteger(config.directMaxPatchBytes) || config.directMaxPatchBytes <= 0) {
207
+ throw new Error("directMaxPatchBytes must be a positive integer");
208
+ }
209
+ if (!Number.isInteger(config.directMaxFileBytes) || config.directMaxFileBytes <= 0) {
210
+ throw new Error("directMaxFileBytes must be a positive integer");
211
+ }
212
+ if (config.directAllowedCommands !== undefined) {
213
+ if (!Array.isArray(config.directAllowedCommands)) {
214
+ throw new Error("directAllowedCommands must be an array");
215
+ }
216
+ if (config.directAllowedCommands.some((command) => typeof command !== "string" || command.trim() === "")) {
217
+ throw new Error("directAllowedCommands must contain only non-empty command strings");
218
+ }
219
+ }
220
+ if (config.repoDirectAllowedCommands !== undefined) {
221
+ if (typeof config.repoDirectAllowedCommands !== "object" || Array.isArray(config.repoDirectAllowedCommands)) {
222
+ throw new Error("repoDirectAllowedCommands must be an object mapping workspace-relative repository paths to command arrays");
223
+ }
224
+ const repoDirectAllowedCommands = {};
225
+ for (const [repoKey, commands] of Object.entries(config.repoDirectAllowedCommands)) {
226
+ const normalizedKey = normalizeRepoKey(repoKey);
227
+ const resolvedRepo = resolve(config.workspaceRoot, normalizedKey);
228
+ const relativeRepo = relative(resolve(config.workspaceRoot), resolvedRepo);
229
+ if (isAbsolute(repoKey) || relativeRepo === ".." || relativeRepo.startsWith(`..${sep}`) || isAbsolute(relativeRepo)) {
230
+ throw new Error(`repoDirectAllowedCommands key must stay inside workspaceRoot: "${repoKey}"`);
231
+ }
232
+ if (!Array.isArray(commands) || commands.some((command) => typeof command !== "string" || command.trim() === "")) {
233
+ throw new Error(`repoDirectAllowedCommands["${repoKey}"] must be an array of non-empty command strings`);
234
+ }
235
+ repoDirectAllowedCommands[normalizedKey] = [...new Set(commands.map((command) => command.trim()))];
236
+ }
237
+ config.repoDirectAllowedCommands = repoDirectAllowedCommands;
126
238
  }
127
239
  return {
128
240
  ...config,
129
241
  workspaceRoot: resolve(config.workspaceRoot),
242
+ allowedTestCommands: [...new Set(config.allowedTestCommands.map((command) => command.trim()))],
243
+ repoAllowedTestCommands,
130
244
  };
131
245
  }
246
+ export function getRepoAllowedTestCommands(config, repoPath) {
247
+ const target = comparablePath(resolve(repoPath));
248
+ for (const [repoKey, commands] of Object.entries(config.repoAllowedTestCommands || {})) {
249
+ if (comparablePath(resolve(config.workspaceRoot, repoKey)) === target)
250
+ return [...commands];
251
+ }
252
+ return [];
253
+ }
254
+ export function getAllConfiguredTestCommands(config) {
255
+ return [...new Set([
256
+ ...config.allowedTestCommands,
257
+ ...Object.values(config.repoAllowedTestCommands || {}).flat(),
258
+ ])];
259
+ }
260
+ export function getDirectSessionsDir(config) {
261
+ return resolve(config.workspaceRoot, config.directSessionsDir);
262
+ }
263
+ export function getRepoDirectAllowedCommands(config, repoPath) {
264
+ const target = comparablePath(resolve(repoPath));
265
+ for (const [repoKey, commands] of Object.entries(config.repoDirectAllowedCommands || {})) {
266
+ if (comparablePath(resolve(config.workspaceRoot, repoKey)) === target)
267
+ return [...commands];
268
+ }
269
+ return [];
270
+ }
271
+ export function getAllConfiguredDirectCommands(config) {
272
+ return [...new Set([
273
+ ...(config.directAllowedCommands || []),
274
+ ...Object.values(config.repoDirectAllowedCommands || {}).flat(),
275
+ ])];
276
+ }
277
+ function normalizeRepoKey(value) {
278
+ const trimmed = String(value).trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
279
+ return trimmed === "" ? "." : trimmed;
280
+ }
281
+ function comparablePath(value) {
282
+ const normalized = resolve(value).replace(/\\/g, "/");
283
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
284
+ }
@@ -0,0 +1,23 @@
1
+ import { type DirectSessionVerificationRun } from "./directSessionStore.js";
2
+ export interface AuditCheck {
3
+ name: string;
4
+ result: "pass" | "warn" | "fail";
5
+ detail: string;
6
+ reason_code?: string;
7
+ }
8
+ export interface DirectSessionAuditOutput {
9
+ session_id: string;
10
+ decision: "pass" | "warn" | "fail";
11
+ reason_codes: string[];
12
+ blocking_findings: string[];
13
+ warnings: string[];
14
+ evidence: {
15
+ changed_files_total: number;
16
+ verification_runs: DirectSessionVerificationRun[];
17
+ diff_path: string;
18
+ summary_path: string;
19
+ audit_path: string;
20
+ };
21
+ next_action: string;
22
+ }
23
+ export declare function auditDirectSession(sessionId: string): DirectSessionAuditOutput;
@@ -0,0 +1,309 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join, resolve, relative, isAbsolute } from "node:path";
3
+ import { getConfig } from "../config.js";
4
+ import { isSensitivePath } from "../security/sensitiveGuard.js";
5
+ import { readDirectSession, getDirectSessionDir, } from "./directSessionStore.js";
6
+ // ── Main audit function ────────────────────────────────────────────
7
+ export function auditDirectSession(sessionId) {
8
+ const config = getConfig();
9
+ const session = readDirectSession(sessionId);
10
+ const sessionDir = getDirectSessionDir(sessionId);
11
+ const checks = [];
12
+ const reasonCodes = [];
13
+ const blockingFindings = [];
14
+ const warnings = [];
15
+ // Check 1: session finalized
16
+ checks.push({
17
+ name: "session_finalized",
18
+ result: session.finalized ? "pass" : "fail",
19
+ detail: session.finalized
20
+ ? "Session has been finalized."
21
+ : "Session has not been finalized. Call finalize_direct_session first.",
22
+ reason_code: session.finalized ? undefined : "session_not_finalized",
23
+ });
24
+ // Get change artifacts if available
25
+ const artifacts = session.change_artifacts;
26
+ // Check 2: diff empty
27
+ const changedFilesTotal = artifacts?.changed_files?.length ?? 0;
28
+ checks.push({
29
+ name: "diff_empty",
30
+ result: changedFilesTotal === 0 ? "warn" : "pass",
31
+ detail: changedFilesTotal === 0
32
+ ? "No file changes detected in this session."
33
+ : `${changedFilesTotal} file(s) changed.`,
34
+ reason_code: changedFilesTotal === 0 ? "empty_diff" : undefined,
35
+ });
36
+ if (artifacts) {
37
+ // Check 3: out-of-scope changes
38
+ const outOfScope = findOutOfScopeChanges(artifacts, session.resolved_repo_path);
39
+ checks.push({
40
+ name: "out_of_scope_changes",
41
+ result: outOfScope.length === 0 ? "pass" : "fail",
42
+ detail: outOfScope.length === 0
43
+ ? "All changes are within the session repo_path."
44
+ : `${outOfScope.length} file(s) modified outside session repo_path: ${outOfScope.join(", ")}`,
45
+ reason_code: outOfScope.length > 0 ? "out_of_scope_changes" : undefined,
46
+ });
47
+ // Check 4: sensitive files
48
+ const sensitiveFiles = findSensitiveChanges(artifacts);
49
+ checks.push({
50
+ name: "sensitive_file_access",
51
+ result: sensitiveFiles.length === 0 ? "pass" : "fail",
52
+ detail: sensitiveFiles.length === 0
53
+ ? "No sensitive files modified."
54
+ : `Sensitive files modified: ${sensitiveFiles.join(", ")}`,
55
+ reason_code: sensitiveFiles.length > 0 ? "sensitive_file_modified" : undefined,
56
+ });
57
+ // Check 5: node_modules modification
58
+ const nodeModulesChanges = findPathChanges(artifacts, "node_modules");
59
+ checks.push({
60
+ name: "node_modules_modified",
61
+ result: nodeModulesChanges.length === 0 ? "pass" : "fail",
62
+ detail: nodeModulesChanges.length === 0
63
+ ? "No node_modules modifications."
64
+ : `node_modules modified: ${nodeModulesChanges.join(", ")}`,
65
+ reason_code: nodeModulesChanges.length > 0 ? "node_modules_modified" : undefined,
66
+ });
67
+ // Check 6: release/dist modification
68
+ const releaseChanges = findPathChanges(artifacts, "release");
69
+ const distChanges = findPathChanges(artifacts, "dist");
70
+ const artifactDirChanges = [...releaseChanges, ...distChanges];
71
+ const hasBuildArtifacts = artifacts.artifact_hygiene.counts.tracked_build_artifacts > 0;
72
+ checks.push({
73
+ name: "release_dist_modified",
74
+ result: artifactDirChanges.length === 0
75
+ ? "pass"
76
+ : hasBuildArtifacts
77
+ ? "warn"
78
+ : "fail",
79
+ detail: artifactDirChanges.length === 0
80
+ ? "No release/dist modifications."
81
+ : `release/dist modified: ${artifactDirChanges.join(", ")}${hasBuildArtifacts ? " (build-generated)" : " (not build-generated)"}`,
82
+ reason_code: artifactDirChanges.length > 0
83
+ ? hasBuildArtifacts
84
+ ? "build_artifact_modified"
85
+ : "release_dist_manually_modified"
86
+ : undefined,
87
+ });
88
+ // Check 7: file deletion
89
+ const deletedFiles = artifacts.changed_files.filter((f) => f.change === "deleted");
90
+ checks.push({
91
+ name: "file_deletion",
92
+ result: deletedFiles.length === 0 ? "pass" : "fail",
93
+ detail: deletedFiles.length === 0
94
+ ? "No files deleted."
95
+ : `Files deleted: ${deletedFiles.map((f) => f.path).join(", ")}`,
96
+ reason_code: deletedFiles.length > 0 ? "file_deleted" : undefined,
97
+ });
98
+ // Check 8: file rename
99
+ const renamedFiles = artifacts.changed_files.filter((f) => f.change === "renamed");
100
+ checks.push({
101
+ name: "file_rename",
102
+ result: renamedFiles.length === 0 ? "pass" : "fail",
103
+ detail: renamedFiles.length === 0
104
+ ? "No files renamed."
105
+ : `Files renamed: ${renamedFiles.map((f) => `${f.old_path} → ${f.path}`).join(", ")}`,
106
+ reason_code: renamedFiles.length > 0 ? "file_renamed" : undefined,
107
+ });
108
+ // Check 9: package-lock / dependency changes
109
+ const packageLockChanges = artifacts.changed_files.filter((f) => f.path === "package-lock.json" ||
110
+ f.path === "package.json" ||
111
+ f.path === "yarn.lock" ||
112
+ f.path === "pnpm-lock.yaml");
113
+ checks.push({
114
+ name: "dependency_changes",
115
+ result: packageLockChanges.length === 0 ? "pass" : "warn",
116
+ detail: packageLockChanges.length === 0
117
+ ? "No dependency file changes."
118
+ : `Dependency files changed: ${packageLockChanges.map((f) => f.path).join(", ")}`,
119
+ reason_code: packageLockChanges.length > 0 ? "dependency_file_changed" : undefined,
120
+ });
121
+ // Check 13: title vs changed files consistency (warning only)
122
+ if (session.title && changedFilesTotal > 0) {
123
+ checks.push({
124
+ name: "title_change_consistency",
125
+ result: "pass",
126
+ detail: `Session title: "${session.title}", ${changedFilesTotal} file(s) changed. (Semantic check not performed - deterministic audit only.)`,
127
+ reason_code: undefined,
128
+ });
129
+ }
130
+ else {
131
+ checks.push({
132
+ name: "title_change_consistency",
133
+ result: "pass",
134
+ detail: "No title provided or no changes to compare.",
135
+ });
136
+ }
137
+ // Check 14: suspicious changes
138
+ const suspiciousCount = artifacts.artifact_hygiene.counts.suspicious_changes;
139
+ checks.push({
140
+ name: "suspicious_changes",
141
+ result: suspiciousCount === 0 ? "pass" : "warn",
142
+ detail: suspiciousCount === 0
143
+ ? "No suspicious changes detected."
144
+ : `${suspiciousCount} suspicious change(s) detected.`,
145
+ reason_code: suspiciousCount > 0 ? "suspicious_changes" : undefined,
146
+ });
147
+ // Check 15: runtime generated files
148
+ const runtimeCount = artifacts.artifact_hygiene.counts.runtime_generated_files;
149
+ checks.push({
150
+ name: "runtime_generated_files",
151
+ result: runtimeCount === 0 ? "pass" : "warn",
152
+ detail: runtimeCount === 0
153
+ ? "No runtime-generated files detected."
154
+ : `${runtimeCount} runtime-generated file(s) detected.`,
155
+ reason_code: runtimeCount > 0 ? "runtime_generated_files" : undefined,
156
+ });
157
+ // Check 16: tracked build artifacts
158
+ const buildArtifactCount = artifacts.artifact_hygiene.counts.tracked_build_artifacts;
159
+ checks.push({
160
+ name: "tracked_build_artifacts",
161
+ result: buildArtifactCount === 0 ? "pass" : "warn",
162
+ detail: buildArtifactCount === 0
163
+ ? "No tracked build artifacts detected."
164
+ : `${buildArtifactCount} tracked build artifact(s) detected.`,
165
+ reason_code: buildArtifactCount > 0 ? "tracked_build_artifacts" : undefined,
166
+ });
167
+ }
168
+ // Check 10: at least one verification command run
169
+ const verificationRuns = session.verification_runs || [];
170
+ const hasVerification = verificationRuns.length > 0;
171
+ const hasSourceChanges = artifacts
172
+ ? artifacts.changed_files.some((f) => f.kind === "source")
173
+ : false;
174
+ checks.push({
175
+ name: "verification_run",
176
+ result: hasVerification
177
+ ? "pass"
178
+ : hasSourceChanges
179
+ ? "fail"
180
+ : "warn",
181
+ detail: hasVerification
182
+ ? `${verificationRuns.length} verification command(s) run.`
183
+ : hasSourceChanges
184
+ ? "Source files were modified but no verification commands were run."
185
+ : "No verification commands were run (no source changes detected).",
186
+ reason_code: !hasVerification
187
+ ? hasSourceChanges
188
+ ? "source_changes_without_verification"
189
+ : "no_verification_run"
190
+ : undefined,
191
+ });
192
+ // Check 11: verification commands passed
193
+ if (hasVerification) {
194
+ const allPassed = verificationRuns.every((r) => r.passed);
195
+ checks.push({
196
+ name: "verification_passed",
197
+ result: allPassed ? "pass" : "fail",
198
+ detail: allPassed
199
+ ? "All verification commands passed."
200
+ : `${verificationRuns.filter((r) => !r.passed).length} verification command(s) failed.`,
201
+ reason_code: !allPassed ? "verification_failed" : undefined,
202
+ });
203
+ }
204
+ // Collect results
205
+ for (const check of checks) {
206
+ if (check.reason_code)
207
+ reasonCodes.push(check.reason_code);
208
+ if (check.result === "fail")
209
+ blockingFindings.push(`${check.name}: ${check.detail}`);
210
+ if (check.result === "warn")
211
+ warnings.push(`${check.name}: ${check.detail}`);
212
+ }
213
+ // Determine decision
214
+ const hasFail = checks.some((c) => c.result === "fail");
215
+ const hasWarn = checks.some((c) => c.result === "warn");
216
+ const decision = hasFail ? "fail" : hasWarn ? "warn" : "pass";
217
+ const diffPath = join(sessionDir, "diff.patch");
218
+ const summaryPath = join(sessionDir, "summary.md");
219
+ const auditPath = join(sessionDir, "audit.md");
220
+ const output = {
221
+ session_id: sessionId,
222
+ decision,
223
+ reason_codes: reasonCodes,
224
+ blocking_findings: blockingFindings,
225
+ warnings,
226
+ evidence: {
227
+ changed_files_total: changedFilesTotal,
228
+ verification_runs: verificationRuns,
229
+ diff_path: diffPath,
230
+ summary_path: summaryPath,
231
+ audit_path: auditPath,
232
+ },
233
+ next_action: decision === "pass"
234
+ ? "Audit passed. Changes are safe to accept."
235
+ : decision === "warn"
236
+ ? "Audit completed with warnings. Review the warnings before accepting changes."
237
+ : "Audit failed. Review the blocking findings and create a new session to fix issues.",
238
+ };
239
+ // Write audit.json and audit.md
240
+ writeFileSync(join(sessionDir, "audit.json"), JSON.stringify(output, null, 2), "utf-8");
241
+ writeFileSync(auditPath, formatAuditMd(output, checks, session), "utf-8");
242
+ return output;
243
+ }
244
+ // ── Helper functions ───────────────────────────────────────────────
245
+ function findOutOfScopeChanges(artifacts, resolvedRepoPath) {
246
+ const normalizedRepo = resolve(resolvedRepoPath);
247
+ return artifacts.changed_files
248
+ .filter((f) => {
249
+ const fullPath = resolve(normalizedRepo, f.path);
250
+ const rel = relative(normalizedRepo, fullPath);
251
+ return isAbsolute(rel) || rel.startsWith("..");
252
+ })
253
+ .map((f) => f.path);
254
+ }
255
+ function findSensitiveChanges(artifacts) {
256
+ return artifacts.changed_files
257
+ .filter((f) => isSensitivePath(f.path))
258
+ .map((f) => f.path);
259
+ }
260
+ function findPathChanges(artifacts, dirName) {
261
+ return artifacts.changed_files
262
+ .filter((f) => {
263
+ const normalized = f.path.replace(/\\/g, "/");
264
+ return normalized.startsWith(`${dirName}/`) || normalized.includes(`/${dirName}/`);
265
+ })
266
+ .map((f) => f.path);
267
+ }
268
+ function formatAuditMd(output, checks, session) {
269
+ const lines = [
270
+ "# Direct Session Audit Report",
271
+ "",
272
+ `**Session ID:** ${output.session_id}`,
273
+ `**Decision:** ${output.decision.toUpperCase()}`,
274
+ `**Changed files:** ${output.evidence.changed_files_total}`,
275
+ "",
276
+ "## Checks",
277
+ "",
278
+ "| Check | Result | Detail |",
279
+ "|---|---|---|",
280
+ ];
281
+ for (const check of checks) {
282
+ const emoji = check.result === "pass" ? "PASS" : check.result === "warn" ? "WARN" : "FAIL";
283
+ lines.push(`| ${check.name} | ${emoji} | ${check.detail.replace(/\|/g, "\\|")} |`);
284
+ }
285
+ if (output.blocking_findings.length > 0) {
286
+ lines.push("", "## Blocking Findings", "");
287
+ for (const finding of output.blocking_findings) {
288
+ lines.push(`- ${finding}`);
289
+ }
290
+ }
291
+ if (output.warnings.length > 0) {
292
+ lines.push("", "## Warnings", "");
293
+ for (const warning of output.warnings) {
294
+ lines.push(`- ${warning}`);
295
+ }
296
+ }
297
+ lines.push("", "## Evidence", "");
298
+ lines.push(`- Diff: \`${output.evidence.diff_path}\``);
299
+ lines.push(`- Summary: \`${output.evidence.summary_path}\``);
300
+ lines.push(`- Verification runs: ${output.evidence.verification_runs.length}`);
301
+ if (output.evidence.verification_runs.length > 0) {
302
+ lines.push("", "### Verification Results", "");
303
+ for (const run of output.evidence.verification_runs) {
304
+ lines.push(`- **${run.command}**: ${run.passed ? "PASSED" : "FAILED"} (exit code: ${run.exit_code})`);
305
+ }
306
+ }
307
+ lines.push("", `**Next action:** ${output.next_action}`);
308
+ return lines.join("\n");
309
+ }
@@ -0,0 +1,20 @@
1
+ import type { DirectSessionRecord } from "./directSessionStore.js";
2
+ export declare function guardDirectSessionActive(session: DirectSessionRecord): void;
3
+ export declare function guardDirectSessionFinalized(session: DirectSessionRecord): void;
4
+ /**
5
+ * Resolve a relative path against the session's repo_path and verify it stays
6
+ * inside both the repo and the workspace root.
7
+ */
8
+ export declare function guardDirectPath(filePath: string, resolvedRepoPath: string, workspaceRoot: string): string;
9
+ /**
10
+ * Guard a read path: must be inside repo, not sensitive, not binary.
11
+ */
12
+ export declare function guardDirectReadPath(filePath: string, resolvedRepoPath: string, workspaceRoot: string): string;
13
+ /**
14
+ * Guard a write path: must be inside repo, not sensitive, not binary,
15
+ * not in node_modules, release, or dist directories.
16
+ */
17
+ export declare function guardDirectWritePath(filePath: string, resolvedRepoPath: string, workspaceRoot: string): string;
18
+ export declare function guardDirectPatchSize(patchBytes: number): void;
19
+ export declare function guardDirectFileSize(fileBytes: number): void;
20
+ export declare function isBinaryFile(filePath: string): boolean;