@goodandready/dsh-goal 0.2.23 → 0.2.25

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.
@@ -7,11 +7,11 @@ import { getSafeCwd } from './goal-engine-constants.js';
7
7
  * @param {string} workspaceRoot
8
8
  * @returns {boolean}
9
9
  */
10
- export function isGitRepository(workspaceRoot) {
10
+ export function isGitRepository(workspaceRoot, allowedRoots = []) {
11
11
  if (!workspaceRoot || typeof workspaceRoot !== 'string') return false;
12
12
  try {
13
- const safePath = getSafeCwd(workspaceRoot);
14
- if (!fs.existsSync(safePath)) return false;
13
+ const safePath = getSafeCwd(workspaceRoot, allowedRoots);
14
+ if (!safePath || !fs.existsSync(safePath)) return false;
15
15
  execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
16
16
  cwd: safePath,
17
17
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -61,9 +61,9 @@ export function sanitizeBranchSlug(title) {
61
61
  * @param {string} cwd
62
62
  * @returns {string} branch name or 'HEAD'
63
63
  */
64
- export function getCurrentGitBranch(cwd = process.cwd()) {
65
- const safeCwd = getSafeCwd(cwd);
66
- if (!isGitRepository(safeCwd)) return 'main';
64
+ export function getCurrentGitBranch(cwd = process.cwd(), allowedRoots = []) {
65
+ const safeCwd = getSafeCwd(cwd, allowedRoots);
66
+ if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) return 'main';
67
67
  try {
68
68
  return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
69
69
  cwd: safeCwd,
@@ -81,10 +81,10 @@ export function getCurrentGitBranch(cwd = process.cwd()) {
81
81
  * @param {string} branchName
82
82
  * @returns {boolean} success
83
83
  */
84
- export function createGoalBranch(cwd = process.cwd(), branchName) {
84
+ export function createGoalBranch(cwd = process.cwd(), branchName, allowedRoots = []) {
85
85
  if (!isValidGitRef(branchName)) return false;
86
- const safeCwd = getSafeCwd(cwd);
87
- if (!isGitRepository(safeCwd)) return false;
86
+ const safeCwd = getSafeCwd(cwd, allowedRoots);
87
+ if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) return false;
88
88
  try {
89
89
  execFileSync('git', ['checkout', '-b', branchName], {
90
90
  cwd: safeCwd,
@@ -104,12 +104,12 @@ export function createGoalBranch(cwd = process.cwd(), branchName) {
104
104
  * @param {string} targetBranch
105
105
  * @returns {boolean} success
106
106
  */
107
- export function mergeGoalBranch(cwd = process.cwd(), branchName, targetBranch = 'main') {
107
+ export function mergeGoalBranch(cwd = process.cwd(), branchName, targetBranch = 'main', allowedRoots = []) {
108
108
  if (!isValidGitRef(branchName) || !isValidGitRef(targetBranch)) return false;
109
- const safeCwd = getSafeCwd(cwd);
110
- if (!isGitRepository(safeCwd)) return false;
109
+ const safeCwd = getSafeCwd(cwd, allowedRoots);
110
+ if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) return false;
111
111
  try {
112
- execFileSync('git', ['checkout', '--', targetBranch], {
112
+ execFileSync('git', ['checkout', targetBranch], {
113
113
  cwd: safeCwd,
114
114
  encoding: 'utf8',
115
115
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -132,12 +132,12 @@ export function mergeGoalBranch(cwd = process.cwd(), branchName, targetBranch =
132
132
  * @param {string} targetBranch
133
133
  * @returns {boolean} success
134
134
  */
135
- export function discardGoalBranch(cwd = process.cwd(), branchName, targetBranch = 'main') {
135
+ export function discardGoalBranch(cwd = process.cwd(), branchName, targetBranch = 'main', allowedRoots = []) {
136
136
  if (!isValidGitRef(branchName) || !isValidGitRef(targetBranch)) return false;
137
- const safeCwd = getSafeCwd(cwd);
138
- if (!isGitRepository(safeCwd)) return false;
137
+ const safeCwd = getSafeCwd(cwd, allowedRoots);
138
+ if (!safeCwd || !isGitRepository(safeCwd, allowedRoots)) return false;
139
139
  try {
140
- execFileSync('git', ['checkout', '--', targetBranch], {
140
+ execFileSync('git', ['checkout', targetBranch], {
141
141
  cwd: safeCwd,
142
142
  encoding: 'utf8',
143
143
  stdio: ['ignore', 'pipe', 'pipe'],
package/lib/routes.js CHANGED
@@ -8,31 +8,82 @@ import { parseIssueChecklist } from './engine-issue-sync.js';
8
8
 
9
9
  export function isLoopback(value) {
10
10
  if (!value) return false;
11
- const address = String(value).toLowerCase().replace(/^\[|\]$/g, '');
11
+ let address = String(value).toLowerCase().replace(/^\[|\]$/g, '');
12
+ if (address.includes(':')) {
13
+ const colons = address.split(':').length - 1;
14
+ if (colons === 1) address = address.split(':')[0];
15
+ }
16
+ address = address.replace(/^::ffff:/, '');
12
17
  return Boolean(
13
18
  address === 'localhost' ||
14
19
  address === 'localhost.' ||
15
20
  address === '::1' ||
16
- address.startsWith('127.') ||
17
- address.startsWith('::ffff:127.')
21
+ address.startsWith('127.')
18
22
  );
19
23
  }
20
24
 
21
- export function isTrustedCaller(req) {
25
+ export function isTrustedHost(val) {
26
+ if (!val || typeof val !== 'string') return false;
27
+ let hostname = val.trim().toLowerCase();
28
+ if (hostname.startsWith('[')) {
29
+ const end = hostname.indexOf(']');
30
+ if (end > 0) hostname = hostname.slice(1, end);
31
+ } else if (hostname.includes(':')) {
32
+ const colons = hostname.split(':').length - 1;
33
+ if (colons === 1) hostname = hostname.split(':')[0];
34
+ }
35
+ const clean = hostname.replace(/^::ffff:/, '');
36
+ if (clean === 'localhost' || clean === 'localhost.' || clean.endsWith('.local') || clean.endsWith('.lan')) return true;
37
+ if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true;
38
+ if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true;
39
+ const match172 = clean.match(/^172\.(\d+)\./);
40
+ if (match172) {
41
+ const octet = parseInt(match172[1], 10);
42
+ if (octet >= 16 && octet <= 31) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export function isTrustedCaller(req, authToken = process.env.DSH_GOAL_AUTH_TOKEN) {
22
48
  if (!req) return false;
23
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || "";
49
+ const rawRemote = req.socket?.remoteAddress || req.connection?.remoteAddress || "";
50
+ const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '');
24
51
  const site = req.headers?.["sec-fetch-site"];
25
52
 
26
53
  // 1. Cross-site and same-site browser requests are rejected
27
54
  if (site === "cross-site" || site === "same-site") return false;
28
55
 
29
- // 2. Same-origin browser UI requests (operator UI on LAN or localhost)
56
+ // 2. If remoteAddress is present, it must be a trusted local/private IP (never untrusted WAN)
57
+ if (cleanRemote && !isTrustedHost(cleanRemote)) {
58
+ return false;
59
+ }
60
+
61
+ // 3. If Host header is present, it must be a trusted local/private host (DNS rebinding guard)
62
+ const host = String(req.headers?.host || "").trim().toLowerCase();
63
+ if (host && !isTrustedHost(host)) {
64
+ return false;
65
+ }
66
+
67
+ // 4. Optional authorization token check (supports x-dsh-auth-token or Authorization: Bearer <token>)
68
+ if (authToken) {
69
+ const headerVal = req.headers?.["x-dsh-auth-token"] || req.headers?.authorization;
70
+ const cleanHeader = headerVal ? String(headerVal).replace(/^Bearer\s+/i, "").trim() : "";
71
+ if (cleanHeader === authToken) {
72
+ return true;
73
+ }
74
+ // When authToken is explicitly configured, non-loopback callers without valid token are rejected
75
+ if (!isLoopback(cleanRemote)) {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ // 5. Same-origin browser UI requests (operator UI on LAN or localhost)
30
81
  if (site === "same-origin") {
31
- const host = String(req.headers?.host || "").toLowerCase();
32
82
  const origin = String(req.headers?.origin || "").trim().toLowerCase();
33
83
  if (origin && host) {
34
84
  try {
35
85
  const u = new URL(origin);
86
+ if (!isTrustedHost(u.hostname)) return false;
36
87
  if (u.host === host) return true;
37
88
  } catch (err) {
38
89
  return false;
@@ -42,6 +93,7 @@ export function isTrustedCaller(req) {
42
93
  if (referer && host) {
43
94
  try {
44
95
  const u = new URL(referer);
96
+ if (!isTrustedHost(u.hostname)) return false;
45
97
  if (u.host === host) return true;
46
98
  } catch (err) {
47
99
  return false;
@@ -50,12 +102,12 @@ export function isTrustedCaller(req) {
50
102
  return false;
51
103
  }
52
104
 
53
- // 3. No declared site: only loopback remote clients (CLI, reverse proxy) are trusted
54
- if (isLoopback(remote)) return true;
105
+ // 6. No declared site: only loopback remote clients (CLI, reverse proxy) are trusted
106
+ if (isLoopback(cleanRemote)) return true;
55
107
 
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];
108
+ // 7. Fallback for test harnesses without remote socket where host header explicitly targets loopback
109
+ if (!cleanRemote && host) {
110
+ const hostName = host.split(":")[0];
59
111
  if (isLoopback(hostName)) {
60
112
  return true;
61
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-goal",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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",