@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.
- package/lib/engine-verification.js +177 -7
- package/lib/goal-engine.js +3 -1
- package/lib/index.js +2 -0
- package/lib/milestone-manager.js +8 -3
- package/lib/tools.js +26 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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(
|
|
31
|
-
shell:
|
|
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:
|
|
206
|
+
env: cleanEnv,
|
|
37
207
|
});
|
|
38
208
|
|
|
39
209
|
if (res.error) {
|
package/lib/goal-engine.js
CHANGED
|
@@ -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 });
|
package/lib/milestone-manager.js
CHANGED
|
@@ -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:
|
|
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