@goodandready/dsh-goal 0.2.28 → 0.2.29

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.
@@ -1,39 +1,209 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { getSafeCwd } from './goal-engine-constants.js';
3
3
 
4
+ export const SAFE_ENV_WHITELIST = [
5
+ 'PATH',
6
+ 'HOME',
7
+ 'USER',
8
+ 'LOGNAME',
9
+ 'LANG',
10
+ 'LC_ALL',
11
+ 'LC_CTYPE',
12
+ 'SHELL',
13
+ 'TERM',
14
+ 'TMPDIR',
15
+ 'TMP',
16
+ 'TEMP',
17
+ 'NODE_ENV',
18
+ ];
19
+
20
+ const FORBIDDEN_SECRET_REGEX = /(TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|CREDENTIAL|PRIVATE|BEARER|API)/i;
21
+
22
+ export const DISALLOWED_BINARIES = new Set([
23
+ 'curl', 'wget', 'nc', 'ncat', 'netcat', 'telnet',
24
+ 'ssh', 'scp', 'sftp', 'ftp', 'rsync',
25
+ 'rm', 'dd', 'mkfs', 'shutdown', 'reboot', 'killall', 'kill',
26
+ ]);
27
+
28
+ export const SHELL_OPERATORS = new Set([';', '&&', '||', '|', '&', '>', '>>', '<', '`']);
29
+
30
+ /**
31
+ * Sanitize environment for child verification processes, strictly stripping tokens and secrets
32
+ * @param {object} [userEnv={}]
33
+ * @returns {object}
34
+ */
35
+ export function sanitizeVerificationEnv(userEnv = {}) {
36
+ const clean = {};
37
+ for (const key of SAFE_ENV_WHITELIST) {
38
+ if (process.env[key] !== undefined && !FORBIDDEN_SECRET_REGEX.test(key)) {
39
+ clean[key] = process.env[key];
40
+ }
41
+ }
42
+ if (userEnv && typeof userEnv === 'object') {
43
+ for (const [k, v] of Object.entries(userEnv)) {
44
+ if (k && !FORBIDDEN_SECRET_REGEX.test(k) && v !== undefined && v !== null) {
45
+ clean[k] = String(v);
46
+ }
47
+ }
48
+ }
49
+ if (!clean.NODE_ENV) {
50
+ clean.NODE_ENV = 'test';
51
+ }
52
+ return clean;
53
+ }
54
+
55
+ /**
56
+ * Parse a command string or array into an argv list
57
+ * @param {string|string[]} commandStr
58
+ * @returns {string[]}
59
+ */
60
+ export function parseCommandArgv(commandStr) {
61
+ if (Array.isArray(commandStr)) {
62
+ return commandStr.map(String).filter((s) => s.trim().length > 0);
63
+ }
64
+ if (!commandStr || typeof commandStr !== 'string') return [];
65
+ const trimmed = commandStr.trim();
66
+ if (!trimmed) return [];
67
+
68
+ const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
69
+ const args = [];
70
+ let match;
71
+ while ((match = regex.exec(trimmed)) !== null) {
72
+ if (match[1] !== undefined) {
73
+ args.push(match[1].replace(/\\"/g, '"'));
74
+ } else if (match[2] !== undefined) {
75
+ args.push(match[2].replace(/\\'/g, "'"));
76
+ } else if (match[3] !== undefined) {
77
+ args.push(match[3]);
78
+ }
79
+ }
80
+ return args;
81
+ }
82
+
83
+ /**
84
+ * Validate a verification command for dangerous operators, shell chaining, or exfiltration binaries
85
+ * @param {string|string[]} command
86
+ * @returns {{ valid: boolean, error?: string, argv?: string[], binary?: string }}
87
+ */
88
+ export function validateVerificationCommand(command) {
89
+ if (!command) return { valid: false, error: 'Empty or invalid verification command' };
90
+ const argv = parseCommandArgv(command);
91
+ if (argv.length === 0) return { valid: false, error: 'Empty or invalid verification command' };
92
+
93
+ const rawBinary = argv[0].toLowerCase().replace(/^.*[/\\]/, '');
94
+ if (DISALLOWED_BINARIES.has(rawBinary)) {
95
+ return { valid: false, error: `Execution blocked: disallowed command binary "${rawBinary}"` };
96
+ }
97
+
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const arg = argv[i];
100
+ if (SHELL_OPERATORS.has(arg)) {
101
+ return { valid: false, error: `Execution blocked: shell operator "${arg}" is not allowed` };
102
+ }
103
+ if (arg.startsWith('$(') || (arg.startsWith('`') && arg.endsWith('`'))) {
104
+ return { valid: false, error: 'Execution blocked: subshell substitution is not allowed' };
105
+ }
106
+ if (i > 0 && DISALLOWED_BINARIES.has(arg.toLowerCase().replace(/^.*[/\\]/, ''))) {
107
+ return { valid: false, error: `Execution blocked: disallowed binary "${arg}" in arguments` };
108
+ }
109
+ }
110
+
111
+ return { valid: true, argv, binary: rawBinary };
112
+ }
113
+
4
114
  /**
5
115
  * Execute a verification command in a confined working directory with timeout and output capture
6
- * @param {string} command Shell command to execute
116
+ * Uses sanitized environment (no secrets/tokens) and shell: false (no shell injection)
117
+ * @param {string|string[]} command Command to execute
7
118
  * @param {string} [targetCwd] Working directory
8
119
  * @param {object} [options]
9
120
  * @param {number} [options.timeoutMs=30000] Timeout in milliseconds
10
121
  * @param {number} [options.maxBuffer=1048576] Max stdout/stderr buffer in bytes (1MB)
11
- * @param {object} [options.env={}] Additional environment variables
122
+ * @param {object} [options.env={}] Additional environment variables (sanitized)
123
+ * @param {Function} [options.checkApproval] Custom approval check function
124
+ * @param {object} [options.approvalGate] ApprovalGate service instance
12
125
  * @returns {{ ok: boolean, exitCode: number, stdout: string, stderr: string, error?: string }}
13
126
  */
14
127
  export function runMilestoneVerification(command, targetCwd, options = {}) {
15
- if (!command || typeof command !== 'string' || !command.trim()) {
128
+ const validation = validateVerificationCommand(command);
129
+ if (!validation.valid) {
16
130
  return {
17
131
  ok: false,
18
132
  exitCode: 1,
19
133
  stdout: '',
20
134
  stderr: '',
21
- error: 'Empty or invalid verification command',
135
+ error: validation.error || 'Empty or invalid verification command',
22
136
  };
23
137
  }
24
138
 
25
139
  const safeCwd = getSafeCwd(targetCwd) || process.cwd();
26
140
  const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 30000;
27
141
  const maxBuffer = typeof options.maxBuffer === 'number' ? options.maxBuffer : 1024 * 1024;
142
+ const { argv, binary } = validation;
143
+
144
+ // 1. Approval seam check
145
+ if (typeof options.checkApproval === 'function') {
146
+ const decision = options.checkApproval({
147
+ command,
148
+ argv,
149
+ binary,
150
+ cwd: safeCwd,
151
+ });
152
+ if (decision && (decision.kind === 'deny' || decision.deny)) {
153
+ return {
154
+ ok: false,
155
+ exitCode: 1,
156
+ stdout: '',
157
+ stderr: '',
158
+ error: `Verification command denied by policy: ${decision.reason || 'denied'}`,
159
+ };
160
+ }
161
+ if (decision && (decision.kind === 'ask' || decision.ask)) {
162
+ return {
163
+ ok: false,
164
+ exitCode: 1,
165
+ stdout: '',
166
+ stderr: '',
167
+ error: `Verification command requires operator approval: ${decision.reason || 'approval required'}`,
168
+ };
169
+ }
170
+ }
171
+
172
+ if (options.approvalGate && typeof options.approvalGate.checkCommand === 'function') {
173
+ try {
174
+ const decision = options.approvalGate.checkCommand(command, safeCwd);
175
+ if (decision && (decision.verdict === 'deny' || decision.verdict === 'ask')) {
176
+ return {
177
+ ok: false,
178
+ exitCode: 1,
179
+ stdout: '',
180
+ stderr: '',
181
+ error: `Verification command blocked by approval gate: ${decision.reason || decision.verdict}`,
182
+ };
183
+ }
184
+ } catch (err) {
185
+ return {
186
+ ok: false,
187
+ exitCode: 1,
188
+ stdout: '',
189
+ stderr: '',
190
+ error: `Approval check error: ${err?.message || err}`,
191
+ };
192
+ }
193
+ }
194
+
195
+ // 2. Environment sanitization (strictly scrub secrets and tokens)
196
+ const cleanEnv = sanitizeVerificationEnv(options.env);
28
197
 
198
+ // 3. Execution without shell (shell: false)
29
199
  try {
30
- const res = spawnSync(command.trim(), {
31
- shell: true,
200
+ const res = spawnSync(argv[0], argv.slice(1), {
201
+ shell: false,
32
202
  cwd: safeCwd,
33
203
  timeout: timeoutMs,
34
204
  maxBuffer,
35
205
  encoding: 'utf8',
36
- env: { ...process.env, ...options.env },
206
+ env: cleanEnv,
37
207
  });
38
208
 
39
209
  if (res.error) {
@@ -38,6 +38,8 @@ export class GoalEngine {
38
38
  this.enableMilestoneDependencies = options.enableMilestoneDependencies ?? true;
39
39
  this.soundScheme = options.soundScheme ?? 'default';
40
40
  this.enableVoiceAnnouncements = options.enableVoiceAnnouncements ?? false;
41
+ this.approvalGate = options.approvalGate || null;
42
+ this.checkApproval = options.checkApproval || null;
41
43
  this.maxSessions = options.maxSessions ?? 100;
42
44
  this.goals = new Map();
43
45
  this.listeners = new Set();
@@ -359,7 +361,7 @@ export class GoalEngine {
359
361
  if (!target) return false;
360
362
 
361
363
  if (status === MilestoneStatus.COMPLETED && target.verificationCommand) {
362
- const vRes = verifyMilestoneAcceptance(target, goal.cwd || process.cwd(), goal, recordActivity);
364
+ const vRes = verifyMilestoneAcceptance(target, goal.cwd || process.cwd(), goal, recordActivity, { approvalGate: this.approvalGate, checkApproval: this.checkApproval });
363
365
  if (!vRes.ok) {
364
366
  this.emit(sid);
365
367
  return false;
package/lib/index.js CHANGED
@@ -121,6 +121,7 @@ export function apply(ctx, config = {}) {
121
121
  const milestoneManager = new MilestoneManager(addonStore, {
122
122
  gitSnapshotManager,
123
123
  enforceDependencies: currentSettings.enableMilestoneDependencies,
124
+ approvalGate: safeService(ctx, "approvalGate"),
124
125
  logger,
125
126
  });
126
127
  const tokenBudgetGuard = new TokenBudgetGuard({
@@ -167,6 +168,7 @@ export function apply(ctx, config = {}) {
167
168
  autoScaleBudgetNearCompletion: currentSettings.autoScaleBudgetNearCompletion,
168
169
  enableTemplatesDrawer: currentSettings.enableTemplatesDrawer,
169
170
  enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
171
+ approvalGate: safeService(ctx, "approvalGate"),
170
172
  logger,
171
173
  });
172
174
  registerPlanModeGuard(ctx, { engine: fallbackEngine, logger });
@@ -1,14 +1,16 @@
1
1
  import { syncMilestoneWithIssue } from './engine-issue-sync.js';
2
- import { runMilestoneVerification } from './engine-verification.js';
2
+ import { runMilestoneVerification, validateVerificationCommand } from './engine-verification.js';
3
3
 
4
4
  /**
5
5
  * Milestone and checklist coordinator for active core goals
6
6
  */
7
7
  export class MilestoneManager {
8
- constructor(addonStore, { gitSnapshotManager = null, enforceDependencies = true, logger = console } = {}) {
8
+ constructor(addonStore, { gitSnapshotManager = null, enforceDependencies = true, approvalGate = null, checkApproval = null, logger = console } = {}) {
9
9
  this.store = addonStore;
10
10
  this.gitSnapshotManager = gitSnapshotManager;
11
11
  this.enforceDependencies = enforceDependencies;
12
+ this.approvalGate = approvalGate;
13
+ this.checkApproval = checkApproval;
12
14
  this.logger = logger;
13
15
  }
14
16
 
@@ -80,7 +82,10 @@ export class MilestoneManager {
80
82
 
81
83
  // Acceptance Gate (Critic Verification) - Issue #163
82
84
  if (m.verificationCommand) {
83
- const vResult = runMilestoneVerification(m.verificationCommand, workspaceRoot);
85
+ const vResult = runMilestoneVerification(m.verificationCommand, workspaceRoot, {
86
+ approvalGate: this.approvalGate,
87
+ checkApproval: this.checkApproval,
88
+ });
84
89
  if (!vResult.ok) {
85
90
  m.verificationError = vResult.error;
86
91
  m.verificationOutput = (vResult.stderr || vResult.stdout || '').trim();
package/lib/tools.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateVerificationCommand } from './engine-verification.js';
1
2
  import { MilestoneManager } from './milestone-manager.js';
2
3
  import { AddonStore } from './addon-store.js';
3
4
  import { getSafeCwd } from './goal-engine-constants.js';
@@ -67,16 +68,30 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
67
68
  }
68
69
 
69
70
  if (action === 'set') {
71
+ if (Array.isArray(args.milestones)) {
72
+ for (const m of args.milestones) {
73
+ const vCmd = m && (m.verificationCommand || m.verification_command);
74
+ if (vCmd) {
75
+ const val = validateVerificationCommand(vCmd);
76
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
77
+ }
78
+ }
79
+ }
70
80
  const list = mm.setMilestones(goalId, args.milestones);
71
81
  return { ok: true, goalId, count: list.length, milestones: list };
72
82
  }
73
83
 
74
84
  if (action === 'add') {
85
+ const vCmd = args.verificationCommand || args.verification_command;
86
+ if (vCmd) {
87
+ const val = validateVerificationCommand(vCmd);
88
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
89
+ }
75
90
  const item = mm.addMilestone(goalId, {
76
91
  title: args.title,
77
92
  checklist: args.checklist,
78
93
  dependsOn: args.dependsOn || args.depends_on,
79
- verificationCommand: args.verificationCommand || args.verification_command,
94
+ verificationCommand: vCmd,
80
95
  });
81
96
  return { ok: true, goalId, milestone: item };
82
97
  }
@@ -196,6 +211,11 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
196
211
  if (typeof item === 'string') {
197
212
  return { id: `m-${idx + 1}`, title: item };
198
213
  }
214
+ const vCmd = item && (item.verificationCommand || item.verification_command);
215
+ if (vCmd) {
216
+ const val = validateVerificationCommand(vCmd);
217
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
218
+ }
199
219
  return item;
200
220
  });
201
221
  } else {
@@ -230,6 +250,11 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
230
250
  const status = args.status || 'in_progress';
231
251
  const notes = typeof args.notes === 'string' ? args.notes : '';
232
252
  if (!milestoneId) throw new Error('milestone_id is required');
253
+ const vCmd = args.verificationCommand || args.verification_command;
254
+ if (vCmd) {
255
+ const val = validateVerificationCommand(vCmd);
256
+ if (!val.valid) throw new Error(`Invalid verificationCommand: ${val.error}`);
257
+ }
233
258
  if (status === 'completed') {
234
259
  const rawCwd = toolCtx?.session?.cwd || toolCtx?.cwd || process.cwd();
235
260
  const safeCwd = getSafeCwd(rawCwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "description": "Core Goal Addon (Token Budget, Milestones, Git Snapshots & Retrospective Reports) for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",