@goodandready/dsh-goal 0.2.18 → 0.2.19

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,4 +1,45 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import { getSafeCwd } from './goal-engine-constants.js';
4
+
5
+ /**
6
+ * Check if directory is inside a valid git repository
7
+ * @param {string} workspaceRoot
8
+ * @returns {boolean}
9
+ */
10
+ export function isGitRepository(workspaceRoot) {
11
+ if (!workspaceRoot || typeof workspaceRoot !== 'string') return false;
12
+ try {
13
+ const safePath = getSafeCwd(workspaceRoot);
14
+ if (!fs.existsSync(safePath)) return false;
15
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
16
+ cwd: safePath,
17
+ stdio: ['ignore', 'pipe', 'ignore'],
18
+ timeout: 2000,
19
+ });
20
+ return true;
21
+ } catch (err) {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Validate that a git ref name is safe to pass to git commands
28
+ * Prevents flag injection (-*), path traversal, and illegal ref characters
29
+ * @param {string} ref
30
+ * @returns {boolean}
31
+ */
32
+ export function isValidGitRef(ref) {
33
+ if (!ref || typeof ref !== 'string') return false;
34
+ const trimmed = ref.trim();
35
+ if (!trimmed || trimmed.startsWith('-') || trimmed.endsWith('/') || trimmed.endsWith('.lock')) return false;
36
+ // Disallow control characters, whitespace, and git-forbidden special characters
37
+ if (/[\x00-\x20\x7f~^:?*\[\\ ]/.test(trimmed)) return false;
38
+ // Disallow consecutive dots or slashes, and reflog syntax @{
39
+ if (/\.\.|\/\/|@\{/.test(trimmed)) return false;
40
+ // Must match valid git ref character set
41
+ return /^[a-zA-Z0-9._/-]+$/.test(trimmed);
42
+ }
2
43
 
3
44
  /**
4
45
  * Format a sanitized git branch name from goal title
@@ -21,9 +62,11 @@ export function sanitizeBranchSlug(title) {
21
62
  * @returns {string} branch name or 'HEAD'
22
63
  */
23
64
  export function getCurrentGitBranch(cwd = process.cwd()) {
65
+ const safeCwd = getSafeCwd(cwd);
66
+ if (!isGitRepository(safeCwd)) return 'main';
24
67
  try {
25
68
  return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
26
- cwd,
69
+ cwd: safeCwd,
27
70
  encoding: 'utf8',
28
71
  stdio: ['ignore', 'pipe', 'ignore'],
29
72
  }).trim();
@@ -39,10 +82,12 @@ export function getCurrentGitBranch(cwd = process.cwd()) {
39
82
  * @returns {boolean} success
40
83
  */
41
84
  export function createGoalBranch(cwd = process.cwd(), branchName) {
42
- if (!branchName) return false;
85
+ if (!isValidGitRef(branchName)) return false;
86
+ const safeCwd = getSafeCwd(cwd);
87
+ if (!isGitRepository(safeCwd)) return false;
43
88
  try {
44
89
  execFileSync('git', ['checkout', '-b', branchName], {
45
- cwd,
90
+ cwd: safeCwd,
46
91
  encoding: 'utf8',
47
92
  stdio: ['ignore', 'pipe', 'pipe'],
48
93
  });
@@ -60,15 +105,17 @@ export function createGoalBranch(cwd = process.cwd(), branchName) {
60
105
  * @returns {boolean} success
61
106
  */
62
107
  export function mergeGoalBranch(cwd = process.cwd(), branchName, targetBranch = 'main') {
63
- if (!branchName) return false;
108
+ if (!isValidGitRef(branchName) || !isValidGitRef(targetBranch)) return false;
109
+ const safeCwd = getSafeCwd(cwd);
110
+ if (!isGitRepository(safeCwd)) return false;
64
111
  try {
65
- execFileSync('git', ['checkout', targetBranch], {
66
- cwd,
112
+ execFileSync('git', ['checkout', '--', targetBranch], {
113
+ cwd: safeCwd,
67
114
  encoding: 'utf8',
68
115
  stdio: ['ignore', 'pipe', 'pipe'],
69
116
  });
70
- execFileSync('git', ['merge', '--no-ff', '-m', `chore(goal): merge ${branchName}`, branchName], {
71
- cwd,
117
+ execFileSync('git', ['merge', '--no-ff', '-m', `chore(goal): merge ${branchName}`, '--', branchName], {
118
+ cwd: safeCwd,
72
119
  encoding: 'utf8',
73
120
  stdio: ['ignore', 'pipe', 'pipe'],
74
121
  });
@@ -86,15 +133,17 @@ export function mergeGoalBranch(cwd = process.cwd(), branchName, targetBranch =
86
133
  * @returns {boolean} success
87
134
  */
88
135
  export function discardGoalBranch(cwd = process.cwd(), branchName, targetBranch = 'main') {
89
- if (!branchName) return false;
136
+ if (!isValidGitRef(branchName) || !isValidGitRef(targetBranch)) return false;
137
+ const safeCwd = getSafeCwd(cwd);
138
+ if (!isGitRepository(safeCwd)) return false;
90
139
  try {
91
- execFileSync('git', ['checkout', targetBranch], {
92
- cwd,
140
+ execFileSync('git', ['checkout', '--', targetBranch], {
141
+ cwd: safeCwd,
93
142
  encoding: 'utf8',
94
143
  stdio: ['ignore', 'pipe', 'pipe'],
95
144
  });
96
- execFileSync('git', ['branch', '-D', branchName], {
97
- cwd,
145
+ execFileSync('git', ['branch', '-D', '--', branchName], {
146
+ cwd: safeCwd,
98
147
  encoding: 'utf8',
99
148
  stdio: ['ignore', 'pipe', 'pipe'],
100
149
  });
@@ -74,31 +74,37 @@ export function updateChecklistInMarkdown(markdown, itemText, done) {
74
74
  * @returns {{ token?: string, baseUrl: string }}
75
75
  */
76
76
  export function resolveGiteaAuth(options = {}) {
77
- const defaultUrl = options.baseUrl || process.env.GITEA_BASE_URL || 'http://127.0.0.1:3005';
77
+ const baseUrl = options.baseUrl || process.env.GITEA_BASE_URL || "";
78
78
  if (options.token) {
79
- return { token: options.token, baseUrl: defaultUrl };
79
+ return { token: options.token, baseUrl };
80
80
  }
81
81
  if (process.env.GITEA_TOKEN) {
82
- return { token: process.env.GITEA_TOKEN, baseUrl: defaultUrl };
82
+ return { token: process.env.GITEA_TOKEN, baseUrl };
83
83
  }
84
84
 
85
- const credPath = process.env.DSH_GITEA_CREDENTIALS || '/mnt/external/Project/DEV/.gitea-agent-credentials.json';
86
- try {
87
- if (fs.existsSync(credPath)) {
88
- const raw = fs.readFileSync(credPath, 'utf8');
89
- const creds = JSON.parse(raw);
90
- const agent = creds?.agents?.['deepseek-harness'] || creds?.agents?.antigravity;
91
- if (agent?.token) {
92
- return {
93
- token: agent.token,
94
- baseUrl: options.baseUrl || agent.http_remote_base || defaultUrl,
95
- };
85
+ const credPath = process.env.DSH_GITEA_CREDENTIALS;
86
+ if (credPath && typeof credPath === "string") {
87
+ try {
88
+ if (fs.existsSync(credPath)) {
89
+ const raw = fs.readFileSync(credPath, "utf8");
90
+ const creds = JSON.parse(raw);
91
+ const agentName = process.env.DSH_GITEA_AGENT;
92
+ const agent = (agentName && creds?.agents?.[agentName])
93
+ || creds?.agents?.["deepseek-harness"]
94
+ || creds?.agents?.antigravity
95
+ || creds;
96
+ if (agent?.token) {
97
+ return {
98
+ token: agent.token,
99
+ baseUrl: baseUrl || agent.http_remote_base || "",
100
+ };
101
+ }
96
102
  }
103
+ } catch (err) {
104
+ // Non-fatal credential read error
97
105
  }
98
- } catch (err) {
99
- // Non-fatal credential read error
100
106
  }
101
- return { baseUrl: defaultUrl };
107
+ return { baseUrl };
102
108
  }
103
109
 
104
110
  /**
@@ -122,9 +128,9 @@ export function resolveRepoInfo(cwd = process.cwd(), options = {}) {
122
128
  return { owner: match[1], repo: match[2] };
123
129
  }
124
130
  } catch (err) {
125
- // Fallback to default repository
131
+ // Fallback to options or empty
126
132
  }
127
- return { owner: 'goodandready', repo: 'dsh-goal' };
133
+ return { owner: options.owner || "", repo: options.repo || "" };
128
134
  }
129
135
 
130
136
  /**
@@ -140,11 +146,14 @@ export async function syncMilestoneWithIssue(goal, milestone, options = {}) {
140
146
  }
141
147
 
142
148
  const auth = resolveGiteaAuth(options);
143
- if (!auth.token) {
144
- return { synced: false, error: 'No Gitea API token available' };
149
+ if (!auth.token || !auth.baseUrl) {
150
+ return { synced: false, error: 'No Gitea API token or base URL available' };
145
151
  }
146
152
 
147
153
  const repoInfo = resolveRepoInfo(goal.cwd || process.cwd(), options);
154
+ if (!repoInfo.owner || !repoInfo.repo) {
155
+ return { synced: false, error: "No Gitea repository owner or name available" };
156
+ }
148
157
  const issueNum = goal.issueRef.issueNumber;
149
158
  const issueApiUrl = `${auth.baseUrl}/api/v1/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${issueNum}`;
150
159
 
@@ -206,14 +206,14 @@ export function createMilestoneCheckpoint(milestone, sessionId = 'default', cwd)
206
206
  }).trim();
207
207
 
208
208
  if (status) {
209
- execFileSync('git', ['add', '-A'], {
209
+ execFileSync('git', ['add', '-u'], {
210
210
  stdio: ['ignore', 'ignore', 'ignore'],
211
211
  timeout: 3000,
212
212
  cwd: cwd || undefined,
213
213
  });
214
214
  const cleanTitle = (milestone.title || '').replace(/[\"\`\$]/g, '');
215
215
  const msg = `checkpoint(goal): [${milestone.id}] ${cleanTitle}`;
216
- execFileSync('git', ['commit', '-m', msg, '--no-verify'], {
216
+ execFileSync('git', ['commit', '-m', msg], {
217
217
  stdio: ['ignore', 'ignore', 'ignore'],
218
218
  timeout: 5000,
219
219
  cwd: cwd || undefined,
@@ -21,7 +21,7 @@ export class GitSnapshotManager {
21
21
  timeout: 2000,
22
22
  });
23
23
  return true;
24
- } catch {
24
+ } catch (err) {
25
25
  return false;
26
26
  }
27
27
  }
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  /**
2
4
  * Enums and helpers for DSH Goal Engine
3
5
  */
@@ -244,3 +246,47 @@ export function buildSnapshot(goal, sid, engine) {
244
246
  enableVoiceAnnouncements: engine.enableVoiceAnnouncements ?? false,
245
247
  };
246
248
  }
249
+
250
+
251
+ /**
252
+ * Check if child path is inside parent directory or equal to it
253
+ * @param {string} parent
254
+ * @param {string} child
255
+ * @returns {boolean}
256
+ */
257
+ export function isPathInsideOrEqual(parent, child) {
258
+ if (!parent || !child) return false;
259
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
260
+ return !rel || (!rel.startsWith("..") && !path.isAbsolute(rel));
261
+ }
262
+
263
+ /**
264
+ * Validate and confine working directory to ensure it exists and is allowed
265
+ * @param {string} requestedCwd
266
+ * @param {string[]} [allowedRoots=[]]
267
+ * @returns {string} resolved safe cwd
268
+ */
269
+ export function getSafeCwd(requestedCwd, allowedRoots = []) {
270
+ const defaultRoot = allowedRoots[0] || process.cwd();
271
+ if (!requestedCwd || typeof requestedCwd !== "string" || !requestedCwd.trim()) {
272
+ return defaultRoot;
273
+ }
274
+
275
+ try {
276
+ const resolved = path.resolve(requestedCwd.trim());
277
+ const st = fs.statSync(resolved);
278
+ if (!st.isDirectory()) {
279
+ return null;
280
+ }
281
+
282
+ const roots = allowedRoots.length > 0 ? allowedRoots : [process.cwd()];
283
+ const isAllowed = roots.some((root) => isPathInsideOrEqual(root, resolved));
284
+ if (!isAllowed) {
285
+ return null;
286
+ }
287
+
288
+ return resolved;
289
+ } catch (err) {
290
+ return null;
291
+ }
292
+ }
@@ -1,4 +1,4 @@
1
- import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot } from './goal-engine-constants.js';
1
+ import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
2
2
  import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
3
3
  import { EngineStore } from './engine-store.js';
4
4
  import { buildStatePromptInjection } from './engine-prompt.js';
@@ -193,7 +193,7 @@ export class GoalEngine {
193
193
  title: cleanTitle,
194
194
  description: options.description?.trim() || '',
195
195
  lang: options.lang || detectLanguage(cleanTitle),
196
- cwd: options.cwd ? String(options.cwd).trim() : null,
196
+ cwd: options.cwd ? getSafeCwd(options.cwd) : null,
197
197
  state: GoalState.RUNNING,
198
198
  startedAt: now,
199
199
  pausedAt: null,
@@ -227,8 +227,9 @@ export class GoalEngine {
227
227
 
228
228
  if (this.autoBranchOnGoalStart && options.autoBranch !== false && this.storagePath !== null && process.env.NODE_ENV !== 'test') {
229
229
  const slug = sanitizeBranchSlug(cleanTitle);
230
- const parentBranch = getCurrentGitBranch(options.cwd);
231
- if (createGoalBranch(options.cwd, slug)) {
230
+ const targetCwd = goal.cwd || process.cwd();
231
+ const parentBranch = getCurrentGitBranch(targetCwd);
232
+ if (createGoalBranch(targetCwd, slug)) {
232
233
  goal.gitBranch = slug;
233
234
  goal.gitParentBranch = parentBranch;
234
235
  }
package/lib/index.js CHANGED
@@ -74,7 +74,7 @@ export function safeService(ctx, name) {
74
74
  return ctx.reflect.get(name, false) || null;
75
75
  }
76
76
  return ctx[name] || null;
77
- } catch {
77
+ } catch (err) {
78
78
  return null;
79
79
  }
80
80
  }
package/lib/routes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { MilestoneStatus, detectLanguage, sessionIdOf } from './goal-engine-constants.js';
3
+ import { MilestoneStatus, detectLanguage, sessionIdOf, isPathInsideOrEqual, getSafeCwd } from './goal-engine-constants.js';
4
+ import { isValidGitRef } from './engine-git-branch.js';
4
5
  import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
5
6
  import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
6
7
  import { parseIssueChecklist } from './engine-issue-sync.js';
@@ -19,31 +20,42 @@ export function isLoopback(value) {
19
20
 
20
21
  export function isTrustedCaller(req) {
21
22
  if (!req) return false;
22
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
23
- if (isLoopback(remote)) return true;
24
-
25
- const host = String(req.headers?.host || '').toLowerCase();
26
- const origin = String(req.headers?.origin || '').trim().toLowerCase();
27
- if (origin) {
28
- try {
29
- const u = new URL(origin);
30
- if (u.host === host) return true;
31
- } catch (err) { /* invalid url */ }
23
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || "";
24
+ const site = req.headers?.["sec-fetch-site"];
25
+
26
+ // 1. Cross-site and same-site browser requests are rejected
27
+ if (site === "cross-site" || site === "same-site") return false;
28
+
29
+ // 2. Same-origin browser UI requests (operator UI on LAN or localhost)
30
+ if (site === "same-origin") {
31
+ const host = String(req.headers?.host || "").toLowerCase();
32
+ const origin = String(req.headers?.origin || "").trim().toLowerCase();
33
+ if (origin && host) {
34
+ try {
35
+ const u = new URL(origin);
36
+ if (u.host === host) return true;
37
+ } catch (err) {
38
+ return false;
39
+ }
40
+ }
41
+ const referer = String(req.headers?.referer || "").trim().toLowerCase();
42
+ if (referer && host) {
43
+ try {
44
+ const u = new URL(referer);
45
+ if (u.host === host) return true;
46
+ } catch (err) {
47
+ return false;
48
+ }
49
+ }
32
50
  return false;
33
51
  }
34
52
 
35
- const referer = String(req.headers?.referer || '').trim().toLowerCase();
36
- if (referer) {
37
- try {
38
- const u = new URL(referer);
39
- if (u.host === host) return true;
40
- } catch (err) { /* invalid url */ }
41
- return false;
42
- }
53
+ // 3. No declared site: only loopback remote clients (CLI, reverse proxy) are trusted
54
+ if (isLoopback(remote)) return true;
43
55
 
44
- // Fallback for tests or local CLI without socket where host header targets loopback
45
- if (!remote && host) {
46
- const hostName = host.split(':')[0];
56
+ // 4. Fallback for test harnesses without remote socket where host header explicitly targets loopback
57
+ if (!remote && req.headers?.host) {
58
+ const hostName = String(req.headers.host).split(":")[0];
47
59
  if (isLoopback(hostName)) {
48
60
  return true;
49
61
  }
@@ -52,36 +64,7 @@ export function isTrustedCaller(req) {
52
64
  return false;
53
65
  }
54
66
 
55
- export function isPathInsideOrEqual(parent, child) {
56
- if (!parent || !child) return false;
57
- const rel = path.relative(path.resolve(parent), path.resolve(child));
58
- return !rel || (!rel.startsWith('..') && !path.isAbsolute(rel));
59
- }
60
-
61
- export function getSafeCwd(requestedCwd, allowedRoots = []) {
62
- const defaultRoot = allowedRoots[0] || process.cwd();
63
- if (!requestedCwd || typeof requestedCwd !== 'string' || !requestedCwd.trim()) {
64
- return defaultRoot;
65
- }
66
-
67
- try {
68
- const resolved = path.resolve(requestedCwd.trim());
69
- const st = fs.statSync(resolved);
70
- if (!st.isDirectory()) {
71
- return null;
72
- }
73
-
74
- const roots = allowedRoots.length > 0 ? allowedRoots : [process.cwd()];
75
- const isAllowed = roots.some((root) => isPathInsideOrEqual(root, resolved));
76
- if (!isAllowed) {
77
- return null;
78
- }
79
-
80
- return resolved;
81
- } catch (err) {
82
- return null;
83
- }
84
- }
67
+ export { isPathInsideOrEqual, getSafeCwd };
85
68
 
86
69
  /**
87
70
  * Register webServer HTTP routes and SSE stream for DSH Goal
@@ -583,6 +566,14 @@ export function registerRoutes(ctx, {
583
566
  case 'branch_action': {
584
567
  const op = String(data.operation || data.branchOperation || 'merge').toLowerCase();
585
568
  const targetBranch = String(data.targetBranch || 'main');
569
+ if (!isValidGitRef(targetBranch)) {
570
+ res.statusCode = 400;
571
+ return res.end(JSON.stringify({ error: `Invalid targetBranch ref: "${targetBranch}"` }));
572
+ }
573
+ if (op !== 'merge' && op !== 'discard') {
574
+ res.statusCode = 400;
575
+ return res.end(JSON.stringify({ error: `Invalid branch operation: "${op}". Allowed: merge, discard` }));
576
+ }
586
577
  if (engine) {
587
578
  if (op === 'merge') {
588
579
  const ok = engine.mergeGoalBranch(targetBranch, sid);
package/lib/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { MilestoneManager } from './milestone-manager.js';
2
2
  import { AddonStore } from './addon-store.js';
3
+ import { getSafeCwd } from './goal-engine-constants.js';
3
4
 
4
5
  /**
5
6
  * Tools module for @goodandready/dsh-goal (Core Addon).
@@ -83,9 +84,10 @@ export function registerTools(ctx, { milestoneManager, goalsService, logger = co
83
84
  if (!milestoneId) {
84
85
  throw new Error('milestoneId is required for complete action');
85
86
  }
86
- const completed = await mm.completeMilestone(goalId, milestoneId, {
87
- cwd: toolCtx?.session?.cwd || toolCtx?.cwd || process.cwd(),
88
- });
87
+ const rawCwd = toolCtx?.session?.cwd || toolCtx?.cwd || process.cwd();
88
+ const safeCwd = getSafeCwd(rawCwd);
89
+ const notes = typeof args.notes === 'string' ? args.notes : '';
90
+ const completed = await mm.completeMilestone(goalId, milestoneId, notes, safeCwd);
89
91
  return { ok: true, goalId, milestoneId, milestone: completed };
90
92
  }
91
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
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",
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test test/*.test.mjs"
22
+ "test": "NODE_ENV=test node --test test/*.test.mjs"
23
23
  },
24
24
  "keywords": [
25
25
  "dsh",