@ipul-labs/forge 1.16.6 → 1.16.8

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/README.md CHANGED
@@ -252,6 +252,25 @@ FORGE_REPOS=gitlab:ipul/web-app,github:my-org/mobile
252
252
 
253
253
  ---
254
254
 
255
+ ## Security
256
+
257
+ Forge is designed for self-hosted deployment on trusted infrastructure. Security measures in place:
258
+
259
+ - **Timing-safe auth** — token comparison uses `crypto.timingSafeEqual` to prevent timing attacks
260
+ - **Rate limiting** — API endpoints are rate-limited (60 req/min per IP) to prevent brute-force
261
+ - **Security headers** — Helmet middleware sets X-Frame-Options, HSTS, X-Content-Type-Options, and CSP (production)
262
+ - **Request size limits** — JSON body capped at 1MB to prevent memory exhaustion
263
+ - **Secret redaction** — error logs and API responses scrub tokens (`glpat-*`, `ghp_*`, `oauth2:*@`) before output
264
+ - **File permissions** — config files (`~/.forge/config.json`) written with `0600`, session directories with `0700`
265
+ - **Git credential isolation** — SCM tokens passed via `GIT_ASKPASS` helper, never embedded in clone URLs
266
+ - **Path traversal protection** — symlink-aware `realpath` checks prevent file writes outside clone directories
267
+ - **No shell injection** — all subprocess calls use `spawn()` with argument arrays, never shell strings
268
+ - **Atomic config writes** — config updates use temp file + rename to prevent corruption
269
+
270
+ `FORGE_TOKEN` is a standalone dashboard password — never reuse an SCM token for this value.
271
+
272
+ ---
273
+
255
274
  ## Production deployment
256
275
 
257
276
  ```bash
@@ -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.8",
4
4
  "description": "AI-powered code change & deploy control panel — connects Claude, GitLab, and Kubernetes",
5
5
  "readmeFilename": "README.md",
6
6
  "type": "module",
@@ -34,6 +34,8 @@
34
34
  "diff": "^9.0.0",
35
35
  "dotenv": "^16.6.1",
36
36
  "express": "^4.21.0",
37
+ "express-rate-limit": "^8.5.1",
38
+ "helmet": "^8.1.0",
37
39
  "htm": "^3.1.1",
38
40
  "ink": "^7.0.1",
39
41
  "ink-select-input": "^6.2.0",
@@ -81,5 +83,5 @@
81
83
  "url": "git+https://gitlab.com/ipul-labs/forge.git"
82
84
  },
83
85
  "license": "MIT",
84
- "readme": "# Forge by iPuL\n\nAI-powered code change & deploy control panel. Connect an issue tracker, let Claude propose a fix, review the diff, approve — and Forge creates the branch, commits the changes, opens the MR/PR, and monitors the CI pipeline.\n\nBuilt on: Claude Code CLI · GitLab / GitHub / Azure DevOps · Kubernetes\n\n---\n\n## Why Forge\n\n**The CI refinement loop is the thing.** Every other AI coding tool hands off after the PR is open. Forge keeps going: when your pipeline fails, it grabs the last 150 lines of the failed job log, feeds them back to Claude with the original fix context, generates a targeted patch, and commits it to the branch — automatically. No human has to read the stack trace, translate it into a prompt, and paste it somewhere. The loop closes itself.\n\nEverything else fills in the gaps that matter for real teams:\n\n- **No per-token cost** — Claude runs via the `claude` CLI using your existing Claude subscription, not API credits. Heavy use doesn't trigger a bill.\n- **Multi-provider** — GitLab, GitHub, and Azure DevOps are all first-class. Switch providers with a single env var; all normalized data shapes stay the same.\n- **Self-hosted** — your code never leaves your infrastructure. The backend runs wherever you deploy it; AI calls go to Anthropic (or a local gateway via OpenClaw).\n- **Two-pass analysis keeps context focused** — Claude first identifies the 6 most relevant files, then reads only those when proposing the fix. This produces sharper diffs than dumping the whole repo into one prompt.\n\n---\n\n## How it works\n\n1. **Connect** — enter your Forge token to authenticate\n2. **Pick an issue** — Forge loads open issues from your project\n3. **Analyze** — Claude reads the repo file tree, identifies relevant files, and proposes a targeted fix\n4. **Review** — inspect the diff file-by-file; refine with natural-language feedback if needed\n5. **Apply** — Forge creates a branch, commits all changes, and opens a merge/pull request\n6. **Monitor** — watch the CI pipeline run; if it fails, click \"Fix with AI\" to iterate\n\n---\n\n## Getting started\n\n### Prerequisites\n\n- Node.js 20+\n- `claude` CLI installed and authenticated (`claude auth login`)\n- Credentials for your SCM provider (see [Configuration](#configuration))\n\n### Install\n\n```bash\ngit clone https://gitlab.com/ipul-labs/forge.git\ncd forge\nnpm install\n```\n\n### Configure\n\n```bash\n# Interactive setup — writes .env and ~/.forge/config.json\nnpx forge init\n```\n\nOr copy the example env and fill in the values manually:\n\n```bash\ncp .env.example .env\n# fill in FORGE_TOKEN plus your provider credentials\n```\n\n### Launch the UI\n\n```bash\nnpm run dev\n# opens http://localhost:5000 (frontend) with API on :5001\n```\n\nFor production:\n\n```bash\nnpm run build:app # builds Vite SPA to dist/\nnpm start # NODE_ENV=production node server.js\n```\n\n### CLI usage\n\nThe `forge` CLI drives the same orchestrator from the terminal. Install globally or use `npx`:\n\n```bash\n# Global install (adds `forge` to PATH)\nnpm install -g @ipul-labs/forge\n\n# Or run via npx from the repo\nnpx forge <command>\n```\n\n#### Interactive mode\n\nRun `forge` with no arguments to enter an interactive terminal session:\n\n```bash\nforge\n```\n\n```\n forge v1.15.1\n\n──────────────────────────────────────────────────\n ❯ issues --limit 5\n──────────────────────────────────────────────────\n group/repo | agent:claude | v1.15.1\n```\n\nTab-completes command names. Type `use <repo>` to switch repos. Commands that error stay in the session — only `exit`, `quit`, or Ctrl+D leave.\n\n#### End-to-end: issue to production\n\n```bash\nforge ship 42\n```\n\nRuns: analyze → review → validate → apply → watch CI → merge → watch deploy → close, with a `y/N` gate at every state-changing step.\n\n#### Step-by-step commands\n\n```bash\nforge issues # List open issues (--label X, --mine, --limit N)\nforge issue 42 # Show full description\nforge create-issue --title \"fix: broken widget\" # Create a new issue\nforge analyze 42 # Run two-pass analysis, cache result\nforge show # Re-render cached analysis\nforge refine --feedback \"...\" # Iterate with feedback\nforge validate # Run lint/typecheck/test against proposed changes\nforge apply # Push branch + open MR\nforge watch # Poll the MR pipeline\nforge ci-fix # Re-analyze with failed-pipeline logs\nforge merge # Merge the MR\nforge resolution-check # Verify merged MR closes the issue\nforge close # Close the issue\nforge use group/other-repo # Switch active repo\nforge update # Pull latest + reinstall dependencies\n```\n\n#### Options\n\n```\n-p, --port <n> Port for start\n--repo <group/repo> Primary repo\n--secondary <repo> Additional repo (repeatable)\n--all-repos Include every configured repo\n--model <id> Claude model override\n--agent <name> Agent backend: claude | openclaw\n--prompt <text> Extra user prompt\n--json JSON output\n--quiet Suppress progress\n--yes Skip confirmation gates\n--validate Run validate before apply\n```\n\n#### Diagnostics\n\n```bash\nforge doctor # Check prerequisites (node, claude CLI, env vars)\nforge session list # List cached sessions\nforge session clean # Remove stale session data\n```\n\nSee [`docs/cli.md`](./docs/cli.md) for the full command reference and [`docs/cli-ship-workflow.md`](./docs/cli-ship-workflow.md) for the workflow design.\n\n---\n\n## Configuration\n\n### Core\n\n| Variable | Required | Description |\n|---|---|---|\n| `FORGE_TOKEN` | Yes | Dashboard password — also auto-fills the UI login form |\n| `FORGE_REPOS` | Yes | Comma-separated repo list; first entry is the primary project |\n| `SCM_PROVIDER` | — | `gitlab` (default) · `github` · `azuredevops` |\n| `ANTHROPIC_API_KEY` | — | Enables live model list from Anthropic; falls back to hardcoded list |\n| `FORGE_MODEL` | — | Override default model (default: `claude-sonnet-4-6`) |\n| `K8S_CLUSTER_TOKEN` | — | K8s service account token for rollout monitoring |\n| `K8S_CLUSTER_SERVER` | — | K8s API server URL |\n| `PORT` | — | HTTP server port (default: `5001`) |\n| `OPENCLAW_GATEWAY_URL` | — | Enables OpenClaw agent backend; point at your local Gateway |\n| `OPENCLAW_TOKEN` | — | Bearer token for OpenClaw Gateway auth |\n\n### Provider credentials\n\nSCM tokens can be set here **or** in the Forge Settings UI (Settings → Connections). The UI value takes precedence over env vars.\n\n#### GitLab (`SCM_PROVIDER=gitlab`, default)\n\n| Variable | Required | Description |\n|---|---|---|\n| `GITLAB_TOKEN` | Yes | Personal access token with `api` + `write_repository` scopes |\n\n#### GitHub (`SCM_PROVIDER=github`)\n\n| Variable | Required | Description |\n|---|---|---|\n| `GITHUB_TOKEN` | Yes | Personal access token with `repo` + `workflow` scopes |\n| `GITHUB_OWNER` | Yes | Organization or user, e.g. `ipul-labs` |\n| `GITHUB_REPO` | Yes | Repository name, e.g. `web-app` |\n| `GITHUB_BASE_BRANCH` | — | Default target branch (default: `main`) |\n\n#### Azure DevOps (`SCM_PROVIDER=azuredevops`)\n\n| Variable | Required | Description |\n|---|---|---|\n| `AZURE_DEVOPS_TOKEN` | Yes | Personal access token (Full access or Code + Work Items) |\n| `AZURE_DEVOPS_ORG` | Yes | Organization name, e.g. `ipul-labs` |\n| `AZURE_DEVOPS_PROJECT` | Yes | Team project name, e.g. `web-app` |\n| `AZURE_DEVOPS_REPO` | — | Git repo name (defaults to project name) |\n| `AZURE_DEVOPS_BASE_BRANCH` | — | Default target branch (default: `main`) |\n\n### Deprecated (still work as fallbacks)\n\n| Variable | Replaced by |\n|---|---|\n| `FORGE_GITLAB_PROJECT` | First entry in `FORGE_REPOS` |\n| `VITE_FORGE_TOKEN` | Auto-derived from `FORGE_TOKEN` |\n| `FORGE_REFINE_REPO_ACCESS` | Settings UI toggle (Settings → Defaults) |\n\nSee `.env.example` for a full annotated list.\n\n---\n\n## Multi-provider support\n\nForge's SCM layer is a clean provider abstraction — all three providers implement the same `ScmProvider` interface and return identical normalized shapes (`NormalIssue`, `NormalMR`, `NormalPipeline`, etc.). The rest of the codebase never knows which provider is active.\n\n### Switching providers\n\n```bash\n# .env\nSCM_PROVIDER=github\nGITHUB_TOKEN=ghp_...\nGITHUB_OWNER=my-org\nGITHUB_REPO=my-repo\n```\n\n### Per-repo prefix (multi-repo mixed-provider setups)\n\n```bash\nFORGE_REPOS=gitlab:ipul/web-app,github:my-org/mobile\n```\n\n### Provider capabilities\n\n| Feature | GitLab | GitHub | Azure DevOps |\n|---|---|---|---|\n| Issues / Work Items | Yes | Yes | Yes (Work Items) |\n| Multi-file commits | Yes | Yes (Git Data API) | Yes (Pushes API) |\n| Merge / Pull Requests | Yes | Yes | Yes |\n| CI pipelines | Yes (GitLab CI) | Yes (GitHub Actions) | Yes (Azure Pipelines) |\n| Job log traces | Yes | Yes | Yes |\n| Server-side rebase | Yes | — | — |\n\n---\n\n## Production deployment\n\n```bash\nnpm run build:app # builds Vite SPA to dist/\nNODE_ENV=production node server.js\n```\n\nOr with Docker:\n\n```bash\ndocker build -t forge .\ndocker run -p 5001:5001 --env-file .env forge\n```\n\n---\n\n## Architecture\n\n```\nbin/\n forge-agent.js # CLI entry point (forge command)\n cli/\n ship.js # End-to-end orchestrator (Ink UI)\n analyze.js # Two-pass issue analysis\n resolution.js # Post-merge resolution check\n sessionStore.js # ~/.forge/sessions/ persistence\n ...\n\nserver/\n forge/\n routes.js # Express router — all /api/forge/* endpoints\n core/\n ForgeAgent.js # Orchestrates the full pipeline (analyze -> apply -> refine)\n claudeClient.js # Shells out to the claude CLI for structured output\n openclawClient.js # OpenClaw Gateway HTTP client\n agentClient.js # Dispatcher: routes to claudeClient or openclawClient\n k8s.js # Kubernetes client for rollout monitoring\n providers/\n base.js # Abstract ScmProvider — normalized shapes + interface\n gitlab.js # GitLab REST API v4\n github.js # GitHub REST API v3 + Git Data API\n azuredevops.js # Azure DevOps REST API\n index.js # createProvider() factory\n\nsrc/\n views/Forge/\n ForgeDashboard.tsx # Root UI component with tabbed issue/MR views\n api.ts # Token management + fetch helper\n types.ts # Shared TypeScript types\n utils.ts # SSE helper + state utilities\n components/ # Analysis progress, pipeline status, diff viewer\n hooks/\n useForgeDashboard.ts # All state, phase transitions, and API calls\n```\n\n### Two-pass analysis\n\n**Pass 1** — `identifyRelevantFiles`: Claude reads the issue + file tree, returns the max-6 most relevant paths.\n\n**Pass 2** — `proposeFix`: Claude reads the actual file contents and proposes a targeted fix as a JSON diff.\n\nBoth passes use `claude --print --output-format json` so cost and service tier are tracked automatically.\n\n### CI refinement\n\nWhen a pipeline fails, Forge fetches the last 150 lines of each failed job's trace log and feeds them to pass 2 with an explicit \"pipeline failed\" framing — not generic reviewer feedback. This improves fix quality for common failures like lint errors and type errors.\n\n### Provider abstraction\n\nAll three SCM providers implement `ScmProvider` from `providers/base.js`. The contract is:\n- **Normalized return types** — all methods return `NormalIssue`, `NormalMR`, `NormalPipeline`, etc., regardless of provider\n- **Instance-scoped** — each provider is instantiated with a repo string; no global state\n- **`supportsRebase`** — providers that don't support server-side rebase return `false`; the UI and API layer check this before showing the rebase button\n\n---\n\n## Programmatic usage\n\nMount the Forge Express router or use the core classes directly:\n\n```js\nimport forgeRouter from \"@ipul-labs/forge\"; // Express router\nimport { ForgeAgent } from \"@ipul-labs/forge/agent\"; // Core pipeline class\nimport { createProvider } from \"@ipul-labs/forge/providers\"; // SCM factory\n```\n\n```js\nconst agent = new ForgeAgent({ project: \"group/repo\" });\nconst result = await agent.analyzeIssue(42, (event) => console.log(event));\nconst { branchName, mr } = await agent.applyFix(result);\n```\n\n---\n\n## License\n\nMIT\n"
86
+ "readme": "# Forge by iPuL\n\nAI-powered code change & deploy control panel. Connect an issue tracker, let Claude propose a fix, review the diff, approve — and Forge creates the branch, commits the changes, opens the MR/PR, and monitors the CI pipeline.\n\nBuilt on: Claude Code CLI · GitLab / GitHub / Azure DevOps · Kubernetes\n\n---\n\n## Why Forge\n\n**The CI refinement loop is the thing.** Every other AI coding tool hands off after the PR is open. Forge keeps going: when your pipeline fails, it grabs the last 150 lines of the failed job log, feeds them back to Claude with the original fix context, generates a targeted patch, and commits it to the branch — automatically. No human has to read the stack trace, translate it into a prompt, and paste it somewhere. The loop closes itself.\n\nEverything else fills in the gaps that matter for real teams:\n\n- **No per-token cost** — Claude runs via the `claude` CLI using your existing Claude subscription, not API credits. Heavy use doesn't trigger a bill.\n- **Multi-provider** — GitLab, GitHub, and Azure DevOps are all first-class. Switch providers with a single env var; all normalized data shapes stay the same.\n- **Self-hosted** — your code never leaves your infrastructure. The backend runs wherever you deploy it; AI calls go to Anthropic (or a local gateway via OpenClaw).\n- **Two-pass analysis keeps context focused** — Claude first identifies the 6 most relevant files, then reads only those when proposing the fix. This produces sharper diffs than dumping the whole repo into one prompt.\n\n---\n\n## How it works\n\n1. **Connect** — enter your Forge token to authenticate\n2. **Pick an issue** — Forge loads open issues from your project\n3. **Analyze** — Claude reads the repo file tree, identifies relevant files, and proposes a targeted fix\n4. **Review** — inspect the diff file-by-file; refine with natural-language feedback if needed\n5. **Apply** — Forge creates a branch, commits all changes, and opens a merge/pull request\n6. **Monitor** — watch the CI pipeline run; if it fails, click \"Fix with AI\" to iterate\n\n---\n\n## Getting started\n\n### Prerequisites\n\n- Node.js 20+\n- `claude` CLI installed and authenticated (`claude auth login`)\n- Credentials for your SCM provider (see [Configuration](#configuration))\n\n### Install\n\n```bash\ngit clone https://gitlab.com/ipul-labs/forge.git\ncd forge\nnpm install\n```\n\n### Configure\n\n```bash\n# Interactive setup — writes .env and ~/.forge/config.json\nnpx forge init\n```\n\nOr copy the example env and fill in the values manually:\n\n```bash\ncp .env.example .env\n# fill in FORGE_TOKEN plus your provider credentials\n```\n\n### Launch the UI\n\n```bash\nnpm run dev\n# opens http://localhost:5000 (frontend) with API on :5001\n```\n\nFor production:\n\n```bash\nnpm run build:app # builds Vite SPA to dist/\nnpm start # NODE_ENV=production node server.js\n```\n\n### CLI usage\n\nThe `forge` CLI drives the same orchestrator from the terminal. Install globally or use `npx`:\n\n```bash\n# Global install (adds `forge` to PATH)\nnpm install -g @ipul-labs/forge\n\n# Or run via npx from the repo\nnpx forge <command>\n```\n\n#### Interactive mode\n\nRun `forge` with no arguments to enter an interactive terminal session:\n\n```bash\nforge\n```\n\n```\n forge v1.15.1\n\n──────────────────────────────────────────────────\n ❯ issues --limit 5\n──────────────────────────────────────────────────\n group/repo | agent:claude | v1.15.1\n```\n\nTab-completes command names. Type `use <repo>` to switch repos. Commands that error stay in the session — only `exit`, `quit`, or Ctrl+D leave.\n\n#### End-to-end: issue to production\n\n```bash\nforge ship 42\n```\n\nRuns: analyze → review → validate → apply → watch CI → merge → watch deploy → close, with a `y/N` gate at every state-changing step.\n\n#### Step-by-step commands\n\n```bash\nforge issues # List open issues (--label X, --mine, --limit N)\nforge issue 42 # Show full description\nforge create-issue --title \"fix: broken widget\" # Create a new issue\nforge analyze 42 # Run two-pass analysis, cache result\nforge show # Re-render cached analysis\nforge refine --feedback \"...\" # Iterate with feedback\nforge validate # Run lint/typecheck/test against proposed changes\nforge apply # Push branch + open MR\nforge watch # Poll the MR pipeline\nforge ci-fix # Re-analyze with failed-pipeline logs\nforge merge # Merge the MR\nforge resolution-check # Verify merged MR closes the issue\nforge close # Close the issue\nforge use group/other-repo # Switch active repo\nforge update # Pull latest + reinstall dependencies\n```\n\n#### Options\n\n```\n-p, --port <n> Port for start\n--repo <group/repo> Primary repo\n--secondary <repo> Additional repo (repeatable)\n--all-repos Include every configured repo\n--model <id> Claude model override\n--agent <name> Agent backend: claude | openclaw\n--prompt <text> Extra user prompt\n--json JSON output\n--quiet Suppress progress\n--yes Skip confirmation gates\n--validate Run validate before apply\n```\n\n#### Diagnostics\n\n```bash\nforge doctor # Check prerequisites (node, claude CLI, env vars)\nforge session list # List cached sessions\nforge session clean # Remove stale session data\n```\n\nSee [`docs/cli.md`](./docs/cli.md) for the full command reference and [`docs/cli-ship-workflow.md`](./docs/cli-ship-workflow.md) for the workflow design.\n\n---\n\n## Configuration\n\n### Core\n\n| Variable | Required | Description |\n|---|---|---|\n| `FORGE_TOKEN` | Yes | Dashboard password — also auto-fills the UI login form |\n| `FORGE_REPOS` | Yes | Comma-separated repo list; first entry is the primary project |\n| `SCM_PROVIDER` | — | `gitlab` (default) · `github` · `azuredevops` |\n| `ANTHROPIC_API_KEY` | — | Enables live model list from Anthropic; falls back to hardcoded list |\n| `FORGE_MODEL` | — | Override default model (default: `claude-sonnet-4-6`) |\n| `K8S_CLUSTER_TOKEN` | — | K8s service account token for rollout monitoring |\n| `K8S_CLUSTER_SERVER` | — | K8s API server URL |\n| `PORT` | — | HTTP server port (default: `5001`) |\n| `OPENCLAW_GATEWAY_URL` | — | Enables OpenClaw agent backend; point at your local Gateway |\n| `OPENCLAW_TOKEN` | — | Bearer token for OpenClaw Gateway auth |\n\n### Provider credentials\n\nSCM tokens can be set here **or** in the Forge Settings UI (Settings → Connections). The UI value takes precedence over env vars.\n\n#### GitLab (`SCM_PROVIDER=gitlab`, default)\n\n| Variable | Required | Description |\n|---|---|---|\n| `GITLAB_TOKEN` | Yes | Personal access token with `api` + `write_repository` scopes |\n\n#### GitHub (`SCM_PROVIDER=github`)\n\n| Variable | Required | Description |\n|---|---|---|\n| `GITHUB_TOKEN` | Yes | Personal access token with `repo` + `workflow` scopes |\n| `GITHUB_OWNER` | Yes | Organization or user, e.g. `ipul-labs` |\n| `GITHUB_REPO` | Yes | Repository name, e.g. `web-app` |\n| `GITHUB_BASE_BRANCH` | — | Default target branch (default: `main`) |\n\n#### Azure DevOps (`SCM_PROVIDER=azuredevops`)\n\n| Variable | Required | Description |\n|---|---|---|\n| `AZURE_DEVOPS_TOKEN` | Yes | Personal access token (Full access or Code + Work Items) |\n| `AZURE_DEVOPS_ORG` | Yes | Organization name, e.g. `ipul-labs` |\n| `AZURE_DEVOPS_PROJECT` | Yes | Team project name, e.g. `web-app` |\n| `AZURE_DEVOPS_REPO` | — | Git repo name (defaults to project name) |\n| `AZURE_DEVOPS_BASE_BRANCH` | — | Default target branch (default: `main`) |\n\n### Deprecated (still work as fallbacks)\n\n| Variable | Replaced by |\n|---|---|\n| `FORGE_GITLAB_PROJECT` | First entry in `FORGE_REPOS` |\n| `VITE_FORGE_TOKEN` | Auto-derived from `FORGE_TOKEN` |\n| `FORGE_REFINE_REPO_ACCESS` | Settings UI toggle (Settings → Defaults) |\n\nSee `.env.example` for a full annotated list.\n\n---\n\n## Multi-provider support\n\nForge's SCM layer is a clean provider abstraction — all three providers implement the same `ScmProvider` interface and return identical normalized shapes (`NormalIssue`, `NormalMR`, `NormalPipeline`, etc.). The rest of the codebase never knows which provider is active.\n\n### Switching providers\n\n```bash\n# .env\nSCM_PROVIDER=github\nGITHUB_TOKEN=ghp_...\nGITHUB_OWNER=my-org\nGITHUB_REPO=my-repo\n```\n\n### Per-repo prefix (multi-repo mixed-provider setups)\n\n```bash\nFORGE_REPOS=gitlab:ipul/web-app,github:my-org/mobile\n```\n\n### Provider capabilities\n\n| Feature | GitLab | GitHub | Azure DevOps |\n|---|---|---|---|\n| Issues / Work Items | Yes | Yes | Yes (Work Items) |\n| Multi-file commits | Yes | Yes (Git Data API) | Yes (Pushes API) |\n| Merge / Pull Requests | Yes | Yes | Yes |\n| CI pipelines | Yes (GitLab CI) | Yes (GitHub Actions) | Yes (Azure Pipelines) |\n| Job log traces | Yes | Yes | Yes |\n| Server-side rebase | Yes | — | — |\n\n---\n\n## Security\n\nForge is designed for self-hosted deployment on trusted infrastructure. Security measures in place:\n\n- **Timing-safe auth** — token comparison uses `crypto.timingSafeEqual` to prevent timing attacks\n- **Rate limiting** — API endpoints are rate-limited (60 req/min per IP) to prevent brute-force\n- **Security headers** — Helmet middleware sets X-Frame-Options, HSTS, X-Content-Type-Options, and CSP (production)\n- **Request size limits** — JSON body capped at 1MB to prevent memory exhaustion\n- **Secret redaction** — error logs and API responses scrub tokens (`glpat-*`, `ghp_*`, `oauth2:*@`) before output\n- **File permissions** — config files (`~/.forge/config.json`) written with `0600`, session directories with `0700`\n- **Git credential isolation** — SCM tokens passed via `GIT_ASKPASS` helper, never embedded in clone URLs\n- **Path traversal protection** — symlink-aware `realpath` checks prevent file writes outside clone directories\n- **No shell injection** — all subprocess calls use `spawn()` with argument arrays, never shell strings\n- **Atomic config writes** — config updates use temp file + rename to prevent corruption\n\n`FORGE_TOKEN` is a standalone dashboard password — never reuse an SCM token for this value.\n\n---\n\n## Production deployment\n\n```bash\nnpm run build:app # builds Vite SPA to dist/\nNODE_ENV=production node server.js\n```\n\nOr with Docker:\n\n```bash\ndocker build -t forge .\ndocker run -p 5001:5001 --env-file .env forge\n```\n\n---\n\n## Architecture\n\n```\nbin/\n forge-agent.js # CLI entry point (forge command)\n cli/\n ship.js # End-to-end orchestrator (Ink UI)\n analyze.js # Two-pass issue analysis\n resolution.js # Post-merge resolution check\n sessionStore.js # ~/.forge/sessions/ persistence\n ...\n\nserver/\n forge/\n routes.js # Express router — all /api/forge/* endpoints\n core/\n ForgeAgent.js # Orchestrates the full pipeline (analyze -> apply -> refine)\n claudeClient.js # Shells out to the claude CLI for structured output\n openclawClient.js # OpenClaw Gateway HTTP client\n agentClient.js # Dispatcher: routes to claudeClient or openclawClient\n k8s.js # Kubernetes client for rollout monitoring\n providers/\n base.js # Abstract ScmProvider — normalized shapes + interface\n gitlab.js # GitLab REST API v4\n github.js # GitHub REST API v3 + Git Data API\n azuredevops.js # Azure DevOps REST API\n index.js # createProvider() factory\n\nsrc/\n views/Forge/\n ForgeDashboard.tsx # Root UI component with tabbed issue/MR views\n api.ts # Token management + fetch helper\n types.ts # Shared TypeScript types\n utils.ts # SSE helper + state utilities\n components/ # Analysis progress, pipeline status, diff viewer\n hooks/\n useForgeDashboard.ts # All state, phase transitions, and API calls\n```\n\n### Two-pass analysis\n\n**Pass 1** — `identifyRelevantFiles`: Claude reads the issue + file tree, returns the max-6 most relevant paths.\n\n**Pass 2** — `proposeFix`: Claude reads the actual file contents and proposes a targeted fix as a JSON diff.\n\nBoth passes use `claude --print --output-format json` so cost and service tier are tracked automatically.\n\n### CI refinement\n\nWhen a pipeline fails, Forge fetches the last 150 lines of each failed job's trace log and feeds them to pass 2 with an explicit \"pipeline failed\" framing — not generic reviewer feedback. This improves fix quality for common failures like lint errors and type errors.\n\n### Provider abstraction\n\nAll three SCM providers implement `ScmProvider` from `providers/base.js`. The contract is:\n- **Normalized return types** — all methods return `NormalIssue`, `NormalMR`, `NormalPipeline`, etc., regardless of provider\n- **Instance-scoped** — each provider is instantiated with a repo string; no global state\n- **`supportsRebase`** — providers that don't support server-side rebase return `false`; the UI and API layer check this before showing the rebase button\n\n---\n\n## Programmatic usage\n\nMount the Forge Express router or use the core classes directly:\n\n```js\nimport forgeRouter from \"@ipul-labs/forge\"; // Express router\nimport { ForgeAgent } from \"@ipul-labs/forge/agent\"; // Core pipeline class\nimport { createProvider } from \"@ipul-labs/forge/providers\"; // SCM factory\n```\n\n```js\nconst agent = new ForgeAgent({ project: \"group/repo\" });\nconst result = await agent.analyzeIssue(42, (event) => console.log(event));\nconst { branchName, mr } = await agent.applyFix(result);\n```\n\n---\n\n## License\n\nMIT\n"
85
87
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { spawn } from "child_process";
17
+ import { writeFileSync, unlinkSync } from "fs";
17
18
  import fs from "fs/promises";
18
19
  import os from "os";
19
20
  import path from "path";
@@ -55,9 +56,26 @@ function sanitizeRepo(repo) {
55
56
  }
56
57
 
57
58
  function gitlabCloneUrl(project) {
59
+ return `https://gitlab.com/${project}.git`;
60
+ }
61
+
62
+ function createAskpassScript(token) {
63
+ const script = path.join(os.tmpdir(), `.forge-askpass-${process.pid}-${Date.now()}`);
64
+ writeFileSync(script, `#!/bin/sh\ncase "$1" in *Username*) echo oauth2;; *) echo "${token}";; esac\n`, { mode: 0o700 });
65
+ return script;
66
+ }
67
+
68
+ function gitEnvWithToken() {
58
69
  const { token } = getConnection("gitlab");
59
70
  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`;
71
+ const askpass = createAskpassScript(token);
72
+ return {
73
+ env: {
74
+ GIT_ASKPASS: askpass,
75
+ GIT_TERMINAL_PROMPT: "0",
76
+ },
77
+ cleanup: () => { try { unlinkSync(askpass); } catch {} },
78
+ };
61
79
  }
62
80
 
63
81
  function resolveCloneUrl(repoInput) {
@@ -90,26 +108,32 @@ export async function ensureWorkingTree(repo, branchName, onLog) {
90
108
  const hasRepo = await exists(path.join(dir, ".git"));
91
109
  const log = (m) => onLog?.(m);
92
110
 
93
- if (!hasRepo) {
94
- log(`Cloning ${repo} → ${dir}`);
95
- await fs.mkdir(dir, { recursive: true });
96
- const clone = await runGit(["clone", "--depth=1", resolveCloneUrl(repo), dir], {}, onLog);
97
- if (clone.code !== 0) throw new Error(`git clone failed (code ${clone.code})`);
98
- } else {
99
- log(`Reusing clone at ${dir}`);
100
- }
111
+ const { env: gitEnv, cleanup } = gitEnvWithToken();
112
+ const envOpts = { env: { ...process.env, ...gitEnv } };
113
+ try {
114
+ if (!hasRepo) {
115
+ log(`Cloning ${repo} → ${dir}`);
116
+ await fs.mkdir(dir, { recursive: true });
117
+ const clone = await runGit(["clone", "--depth=1", resolveCloneUrl(repo), dir], envOpts, onLog);
118
+ if (clone.code !== 0) throw new Error(`git clone failed (code ${clone.code})`);
119
+ } else {
120
+ log(`Reusing clone at ${dir}`);
121
+ }
101
122
 
102
- const fetch = await runGit(["fetch", "--depth=1", "origin", "main"], { cwd: dir }, onLog);
103
- if (fetch.code !== 0) throw new Error(`git fetch failed (code ${fetch.code})`);
123
+ const fetch = await runGit(["fetch", "--depth=1", "origin", "main"], { cwd: dir, ...envOpts }, onLog);
124
+ if (fetch.code !== 0) throw new Error(`git fetch failed (code ${fetch.code})`);
104
125
 
105
- const reset = await runGit(["reset", "--hard", "origin/main"], { cwd: dir }, onLog);
106
- if (reset.code !== 0) throw new Error(`git reset failed (code ${reset.code})`);
107
- await runGit(["clean", "-fd"], { cwd: dir }, onLog);
126
+ const reset = await runGit(["reset", "--hard", "origin/main"], { cwd: dir }, onLog);
127
+ if (reset.code !== 0) throw new Error(`git reset failed (code ${reset.code})`);
128
+ await runGit(["clean", "-fd"], { cwd: dir }, onLog);
108
129
 
109
- const checkout = await runGit(["checkout", "-B", branchName], { cwd: dir }, onLog);
110
- if (checkout.code !== 0) throw new Error(`git checkout ${branchName} failed (code ${checkout.code})`);
130
+ const checkout = await runGit(["checkout", "-B", branchName], { cwd: dir }, onLog);
131
+ if (checkout.code !== 0) throw new Error(`git checkout ${branchName} failed (code ${checkout.code})`);
111
132
 
112
- return dir;
133
+ return dir;
134
+ } finally {
135
+ cleanup();
136
+ }
113
137
  }
114
138
 
115
139
  /**
@@ -121,9 +145,10 @@ export async function ensureWorkingTree(repo, branchName, onLog) {
121
145
  * @param {(msg: string) => void} [onLog]
122
146
  */
123
147
  export async function overlayChanges(dir, changes, onLog) {
148
+ const realBase = await fs.realpath(dir);
124
149
  for (const c of changes) {
125
- const full = path.join(dir, c.file);
126
- if (!full.startsWith(dir + path.sep) && full !== dir) {
150
+ const full = path.resolve(dir, c.file);
151
+ if (!full.startsWith(realBase + path.sep) && full !== realBase) {
127
152
  throw new Error(`Refusing to write outside clone dir: ${c.file}`);
128
153
  }
129
154
  if (c.action === "delete") {
@@ -10,6 +10,7 @@
10
10
  * - Emits clone-step progress / log / step_result events.
11
11
  */
12
12
 
13
+ import { writeFileSync, unlinkSync } from "node:fs";
13
14
  import fs from "node:fs/promises";
14
15
  import os from "node:os";
15
16
  import path from "node:path";
@@ -26,11 +27,28 @@ function sanitizeRepo(repo) {
26
27
  }
27
28
 
28
29
  function gitlabCloneUrl(project) {
30
+ return `https://gitlab.com/${project}.git`;
31
+ }
32
+
33
+ function createAskpassScript(token) {
34
+ const script = path.join(os.tmpdir(), `.forge-askpass-${process.pid}`);
35
+ writeFileSync(script, `#!/bin/sh\ncase "$1" in *Username*) echo oauth2;; *) echo "${token}";; esac\n`, { mode: 0o700 });
36
+ return script;
37
+ }
38
+
39
+ function gitEnvWithToken() {
29
40
  const { token } = getConnection("gitlab");
30
41
  if (!token) {
31
42
  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
43
  }
33
- return `https://oauth2:${token}@gitlab.com/${project}.git`;
44
+ const askpass = createAskpassScript(token);
45
+ return {
46
+ env: {
47
+ GIT_ASKPASS: askpass,
48
+ GIT_TERMINAL_PROMPT: "0",
49
+ },
50
+ cleanup: () => { try { unlinkSync(askpass); } catch {} },
51
+ };
34
52
  }
35
53
 
36
54
  function resolveCloneUrl(repoInput) {
@@ -54,23 +72,29 @@ export async function ensureClone(repo, branchName, emit) {
54
72
  emit({ type: "progress", step: "clone", status: "running" });
55
73
  emit({ type: "log", step: "clone", message: hasRepo ? `Reusing clone at ${dir}` : `Cloning ${repo} → ${dir}` });
56
74
 
57
- if (!hasRepo) {
58
- await fs.mkdir(dir, { recursive: true });
59
- const clone = await runCmd(
60
- "git", ["clone", "--depth=1", resolveCloneUrl(repo), dir],
61
- {},
75
+ const { env: gitEnv, cleanup } = gitEnvWithToken();
76
+ const envOpts = { env: { ...process.env, ...gitEnv } };
77
+ try {
78
+ if (!hasRepo) {
79
+ await fs.mkdir(dir, { recursive: true });
80
+ const clone = await runCmd(
81
+ "git", ["clone", "--depth=1", resolveCloneUrl(repo), dir],
82
+ envOpts,
83
+ (l) => emit({ type: "log", step: "clone", message: l }),
84
+ );
85
+ if (clone.code !== 0) throw new Error(`git clone failed (code ${clone.code})`);
86
+ }
87
+
88
+ const fetch = await runCmd(
89
+ "git", ["fetch", "--depth=1", "origin", "main"],
90
+ { cwd: dir, ...envOpts },
62
91
  (l) => emit({ type: "log", step: "clone", message: l }),
63
92
  );
64
- if (clone.code !== 0) throw new Error(`git clone failed (code ${clone.code})`);
93
+ if (fetch.code !== 0) throw new Error(`git fetch failed (code ${fetch.code})`);
94
+ } finally {
95
+ cleanup();
65
96
  }
66
97
 
67
- const fetch = await runCmd(
68
- "git", ["fetch", "--depth=1", "origin", "main"],
69
- { cwd: dir },
70
- (l) => emit({ type: "log", step: "clone", message: l }),
71
- );
72
- if (fetch.code !== 0) throw new Error(`git fetch failed (code ${fetch.code})`);
73
-
74
98
  // Two-step reset+clean before the branch switch so any lingering overlay
75
99
  // from a prior run is wiped — `git checkout -B` alone refuses if dirty.
76
100
  // `clean -fd` preserves node_modules because it's .gitignored.
@@ -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";
@@ -39,13 +40,16 @@ function requireAuth(req, res, next) {
39
40
  });
40
41
  }
41
42
 
42
- const auth = req.headers.authorization ?? `Bearer ${req.query.token ?? ""}`;
43
+ const auth = req.headers.authorization
44
+ ?? (req.query.token ? `Bearer ${req.query.token}` : null);
43
45
 
44
46
  if (!auth?.startsWith("Bearer ")) {
45
47
  return res.status(401).json({ error: "Missing Authorization header." });
46
48
  }
47
49
 
48
- if (auth.slice(7) !== token) {
50
+ const provided = Buffer.from(auth.slice(7));
51
+ const expected = Buffer.from(token);
52
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
49
53
  return res.status(403).json({ error: "Invalid token." });
50
54
  }
51
55
 
@@ -54,12 +58,20 @@ function requireAuth(req, res, next) {
54
58
 
55
59
  // ─── Error handler ────────────────────────────────────────────────────────────
56
60
 
61
+ function redactSecrets(text) {
62
+ return text
63
+ .replace(/(?:token|key|secret|password|bearer)[=:\s]+\S+/gi, (m) => m.slice(0, m.indexOf(m.match(/[=:\s]/)[0]) + 1) + "***")
64
+ .replace(/oauth2:[^@]+@/gi, "oauth2:***@")
65
+ .replace(/ghp_\w+/g, "ghp_***")
66
+ .replace(/glpat-\w+/g, "glpat-***");
67
+ }
68
+
57
69
  function handleError(res, error, operation) {
58
70
  const msg = error?.message ?? "Unknown error";
59
- console.error(`[forge] ${operation} failed:`, msg);
71
+ console.error(`[forge] ${operation} failed:`, redactSecrets(msg));
60
72
  const upstream = /^(?:GitLab|GitHub) (\d{3}) on /.exec(msg);
61
73
  const status = error?.status ?? (upstream ? Number(upstream[1]) : 500);
62
- res.status(status).json({ error: msg, operation });
74
+ res.status(status).json({ error: redactSecrets(msg), operation });
63
75
  }
64
76
 
65
77
  // ─── Repo helpers ─────────────────────────────────────────────────────────────