@ipul-labs/forge 1.16.6 → 1.16.7

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.
@@ -48,17 +48,14 @@ export function sessionDir(repo, iid) {
48
48
  }
49
49
 
50
50
  async function ensureDir(dir) {
51
- await fs.mkdir(dir, { recursive: true });
51
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
52
52
  }
53
53
 
54
54
  export async function writeAnalysis(repo, iid, analysis) {
55
55
  const dir = sessionDir(repo, iid);
56
56
  await ensureDir(dir);
57
- await fs.writeFile(
58
- path.join(dir, "analysis.json"),
59
- JSON.stringify(analysis, null, 2),
60
- "utf8",
61
- );
57
+ const file = path.join(dir, "analysis.json");
58
+ await fs.writeFile(file, JSON.stringify(analysis, null, 2), { encoding: "utf8", mode: 0o600 });
62
59
  }
63
60
 
64
61
  export async function readAnalysis(repo, iid) {
@@ -84,7 +81,7 @@ export async function updateState(repo, iid, partial) {
84
81
  createdAt: now,
85
82
  };
86
83
  const next = { ...prev, ...partial, updatedAt: now };
87
- await fs.writeFile(file, JSON.stringify(next, null, 2), "utf8");
84
+ await fs.writeFile(file, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 });
88
85
  return next;
89
86
  }
90
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipul-labs/forge",
3
- "version": "1.16.6",
3
+ "version": "1.16.7",
4
4
  "description": "AI-powered code change & deploy control panel — connects Claude, GitLab, and Kubernetes",
5
5
  "readmeFilename": "README.md",
6
6
  "type": "module",
@@ -55,9 +55,21 @@ function sanitizeRepo(repo) {
55
55
  }
56
56
 
57
57
  function gitlabCloneUrl(project) {
58
+ return `https://gitlab.com/${project}.git`;
59
+ }
60
+
61
+ function gitEnvWithToken() {
58
62
  const { token } = getConnection("gitlab");
59
63
  if (!token) throw new Error("GitLab token is required to clone the repo locally. Set it in Settings → Connections or via GITLAB_TOKEN env var.");
60
- return `https://oauth2:${token}@gitlab.com/${project}.git`;
64
+ return {
65
+ GIT_ASKPASS: "echo",
66
+ GIT_TERMINAL_PROMPT: "0",
67
+ GIT_CONFIG_COUNT: "2",
68
+ GIT_CONFIG_KEY_0: "credential.helper",
69
+ GIT_CONFIG_VALUE_0: "",
70
+ GIT_CONFIG_KEY_1: "http.extraHeader",
71
+ GIT_CONFIG_VALUE_1: `Authorization: Bearer ${token}`,
72
+ };
61
73
  }
62
74
 
63
75
  function resolveCloneUrl(repoInput) {
@@ -90,16 +102,18 @@ export async function ensureWorkingTree(repo, branchName, onLog) {
90
102
  const hasRepo = await exists(path.join(dir, ".git"));
91
103
  const log = (m) => onLog?.(m);
92
104
 
105
+ const gitEnv = gitEnvWithToken();
106
+ const envOpts = { env: { ...process.env, ...gitEnv } };
93
107
  if (!hasRepo) {
94
108
  log(`Cloning ${repo} → ${dir}`);
95
109
  await fs.mkdir(dir, { recursive: true });
96
- const clone = await runGit(["clone", "--depth=1", resolveCloneUrl(repo), dir], {}, onLog);
110
+ const clone = await runGit(["clone", "--depth=1", resolveCloneUrl(repo), dir], envOpts, onLog);
97
111
  if (clone.code !== 0) throw new Error(`git clone failed (code ${clone.code})`);
98
112
  } else {
99
113
  log(`Reusing clone at ${dir}`);
100
114
  }
101
115
 
102
- const fetch = await runGit(["fetch", "--depth=1", "origin", "main"], { cwd: dir }, onLog);
116
+ const fetch = await runGit(["fetch", "--depth=1", "origin", "main"], { cwd: dir, ...envOpts }, onLog);
103
117
  if (fetch.code !== 0) throw new Error(`git fetch failed (code ${fetch.code})`);
104
118
 
105
119
  const reset = await runGit(["reset", "--hard", "origin/main"], { cwd: dir }, onLog);
@@ -121,9 +135,10 @@ export async function ensureWorkingTree(repo, branchName, onLog) {
121
135
  * @param {(msg: string) => void} [onLog]
122
136
  */
123
137
  export async function overlayChanges(dir, changes, onLog) {
138
+ const realBase = await fs.realpath(dir);
124
139
  for (const c of changes) {
125
- const full = path.join(dir, c.file);
126
- if (!full.startsWith(dir + path.sep) && full !== dir) {
140
+ const full = path.resolve(dir, c.file);
141
+ if (!full.startsWith(realBase + path.sep) && full !== realBase) {
127
142
  throw new Error(`Refusing to write outside clone dir: ${c.file}`);
128
143
  }
129
144
  if (c.action === "delete") {
@@ -26,11 +26,23 @@ function sanitizeRepo(repo) {
26
26
  }
27
27
 
28
28
  function gitlabCloneUrl(project) {
29
+ return `https://gitlab.com/${project}.git`;
30
+ }
31
+
32
+ function gitEnvWithToken() {
29
33
  const { token } = getConnection("gitlab");
30
34
  if (!token) {
31
35
  throw new Error("GitLab token is required to clone the repo for validation. Set it in Settings → Connections or via GITLAB_TOKEN env var.");
32
36
  }
33
- return `https://oauth2:${token}@gitlab.com/${project}.git`;
37
+ return {
38
+ GIT_ASKPASS: "echo",
39
+ GIT_TERMINAL_PROMPT: "0",
40
+ GIT_CONFIG_COUNT: "2",
41
+ GIT_CONFIG_KEY_0: "credential.helper",
42
+ GIT_CONFIG_VALUE_0: "",
43
+ GIT_CONFIG_KEY_1: "http.extraHeader",
44
+ GIT_CONFIG_VALUE_1: `Authorization: Bearer ${token}`,
45
+ };
34
46
  }
35
47
 
36
48
  function resolveCloneUrl(repoInput) {
@@ -54,11 +66,12 @@ export async function ensureClone(repo, branchName, emit) {
54
66
  emit({ type: "progress", step: "clone", status: "running" });
55
67
  emit({ type: "log", step: "clone", message: hasRepo ? `Reusing clone at ${dir}` : `Cloning ${repo} → ${dir}` });
56
68
 
69
+ const gitEnv = gitEnvWithToken();
57
70
  if (!hasRepo) {
58
71
  await fs.mkdir(dir, { recursive: true });
59
72
  const clone = await runCmd(
60
73
  "git", ["clone", "--depth=1", resolveCloneUrl(repo), dir],
61
- {},
74
+ { env: { ...process.env, ...gitEnv } },
62
75
  (l) => emit({ type: "log", step: "clone", message: l }),
63
76
  );
64
77
  if (clone.code !== 0) throw new Error(`git clone failed (code ${clone.code})`);
@@ -66,7 +79,7 @@ export async function ensureClone(repo, branchName, emit) {
66
79
 
67
80
  const fetch = await runCmd(
68
81
  "git", ["fetch", "--depth=1", "origin", "main"],
69
- { cwd: dir },
82
+ { cwd: dir, env: { ...process.env, ...gitEnv } },
70
83
  (l) => emit({ type: "log", step: "clone", message: l }),
71
84
  );
72
85
  if (fetch.code !== 0) throw new Error(`git fetch failed (code ${fetch.code})`);
@@ -8,10 +8,10 @@ import path from "node:path";
8
8
 
9
9
  export async function overlayChanges(dir, changes, emit) {
10
10
  emit({ type: "progress", step: "overlay", status: "running" });
11
+ const realBase = await fs.realpath(dir);
11
12
  for (const c of changes) {
12
- const full = path.join(dir, c.file);
13
- // Defensive: refuse to escape the clone dir.
14
- if (!full.startsWith(dir + path.sep) && full !== dir) {
13
+ const full = path.resolve(dir, c.file);
14
+ if (!full.startsWith(realBase + path.sep) && full !== realBase) {
15
15
  throw new Error(`Refusing to write outside clone dir: ${c.file}`);
16
16
  }
17
17
  if (c.action === "delete") {
@@ -21,6 +21,7 @@ import { Router } from "express";
21
21
  import * as configStore from "./core/configStore.js";
22
22
  import { listDeployments } from "./core/k8s.js";
23
23
  import { ForgeAgent } from "./core/ForgeAgent.js";
24
+ import crypto from "node:crypto";
24
25
  import { probeQuota } from "./core/quotaClient.js";
25
26
  import { validate as runValidation } from "./core/validator.js";
26
27
  import { createProvider } from "./core/providers/index.js";
@@ -45,7 +46,9 @@ function requireAuth(req, res, next) {
45
46
  return res.status(401).json({ error: "Missing Authorization header." });
46
47
  }
47
48
 
48
- if (auth.slice(7) !== token) {
49
+ const provided = Buffer.from(auth.slice(7));
50
+ const expected = Buffer.from(token);
51
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
49
52
  return res.status(403).json({ error: "Invalid token." });
50
53
  }
51
54
 
@@ -54,12 +57,20 @@ function requireAuth(req, res, next) {
54
57
 
55
58
  // ─── Error handler ────────────────────────────────────────────────────────────
56
59
 
60
+ function redactSecrets(text) {
61
+ return text
62
+ .replace(/(?:token|key|secret|password|bearer)[=:\s]+\S+/gi, (m) => m.slice(0, m.indexOf(m.match(/[=:\s]/)[0]) + 1) + "***")
63
+ .replace(/oauth2:[^@]+@/gi, "oauth2:***@")
64
+ .replace(/ghp_\w+/g, "ghp_***")
65
+ .replace(/glpat-\w+/g, "glpat-***");
66
+ }
67
+
57
68
  function handleError(res, error, operation) {
58
69
  const msg = error?.message ?? "Unknown error";
59
- console.error(`[forge] ${operation} failed:`, msg);
70
+ console.error(`[forge] ${operation} failed:`, redactSecrets(msg));
60
71
  const upstream = /^(?:GitLab|GitHub) (\d{3}) on /.exec(msg);
61
72
  const status = error?.status ?? (upstream ? Number(upstream[1]) : 500);
62
- res.status(status).json({ error: msg, operation });
73
+ res.status(status).json({ error: redactSecrets(msg), operation });
63
74
  }
64
75
 
65
76
  // ─── Repo helpers ─────────────────────────────────────────────────────────────