@programinglive/dev-workflow-mcp-server 1.12.1 → 1.13.1

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.
Files changed (48) hide show
  1. package/.dockerignore +24 -0
  2. package/.env.example +11 -31
  3. package/README.md +109 -6
  4. package/db/adapters/base.js +32 -0
  5. package/db/adapters/mysql.js +13 -7
  6. package/db/adapters/postgres.js +12 -7
  7. package/db/adapters/sqlite.js +17 -9
  8. package/db/index.js +2 -1
  9. package/docker-compose.yml +7 -5
  10. package/exec.js +78 -12
  11. package/factory-client.js +142 -10
  12. package/index.js +28 -19
  13. package/install.js +6 -5
  14. package/package.json +4 -1
  15. package/release-wrapper.js +32 -19
  16. package/scripts/check-review-guard.mjs +33 -0
  17. package/scripts/create-admin.js +11 -9
  18. package/scripts/install-husky.js +3 -2
  19. package/scripts/lint.mjs +20 -0
  20. package/scripts/release.cjs +25 -22
  21. package/scripts/release.js +6 -5
  22. package/scripts/setup-stack.js +42 -41
  23. package/scripts/update-release-notes.cjs +13 -12
  24. package/scripts/update-release-notes.js +8 -7
  25. package/scripts/verify-bundle.mjs +8 -7
  26. package/security/audit.js +86 -0
  27. package/security/commands.js +10 -0
  28. package/security/config.js +24 -0
  29. package/security/files.js +27 -0
  30. package/security/http.js +26 -0
  31. package/security/log-bridge.cjs +17 -0
  32. package/security/policy.js +37 -0
  33. package/security/references.js +21 -0
  34. package/security/runtime-secrets.js +42 -0
  35. package/security/sanitizer.js +42 -0
  36. package/security/scanner.js +108 -0
  37. package/tools/definitions.js +51 -3
  38. package/tools/handler-modules/commit-handlers.js +3 -7
  39. package/tools/handler-modules/factory-handlers.js +84 -0
  40. package/tools/handler-modules/index.js +78 -3
  41. package/tools/handler-modules/shared.js +5 -2
  42. package/tools/handler-modules/status-handlers.js +1 -1
  43. package/tools/handler-modules/summary-handlers.js +2 -1
  44. package/tools/handler-modules/workflow-handlers.js +6 -1
  45. package/utils.js +1 -0
  46. package/verify-build.js +7 -6
  47. package/vite.config.js +4 -0
  48. package/workflow-state.js +249 -34
package/.dockerignore CHANGED
@@ -12,6 +12,30 @@ yarn-error.log
12
12
  .env
13
13
  .env.local
14
14
  .env.*.local
15
+ **/.env*
16
+ **/.npmrc
17
+ **/.ssh
18
+ **/.docker
19
+ **/.aws
20
+ **/.kube
21
+ **/.config/gcloud
22
+ **/.git-credentials
23
+ **/.netrc
24
+ **/.aws
25
+ **/.kube
26
+ **/.config/gcloud
27
+ **/.git-credentials
28
+ **/.netrc
29
+ **/*credentials*.json
30
+ **/service-account*.json
31
+ **/id_rsa*
32
+ **/id_ed25519*
33
+ **/*.pem
34
+ **/*.key
35
+ **/*.p12
36
+ **/*.pfx
37
+ .state
38
+ **/*.db*
15
39
 
16
40
  # Development files
17
41
  *.log
package/.env.example CHANGED
@@ -1,32 +1,12 @@
1
- # Database Configuration
2
- # Supported types: sqlite, mysql, postgres
1
+ # Strict mode is the default and the only supported mode.
2
+ DEV_WORKFLOW_SECURITY_MODE=strict
3
3
  DEV_WORKFLOW_DB_TYPE=sqlite
4
-
5
- # Connection URLs
6
- # MySQL Example: mysql://user:password@localhost:3306/database
7
- # Postgres Example: postgres://user:password@localhost:5432/database
8
- # Postgres Docker Example: postgres://devworkflow:password@postgres:5432/devworkflow
9
- DEV_WORKFLOW_DB_URL=
10
-
11
- # Optional: Override user ID
12
- # DEV_WORKFLOW_USER_ID=my-custom-user-id
13
-
14
- # ── Homelab Development ───────────────────────────────────────────────────────
15
- # These are reference values. Replace with your actual credentials.
16
- # Never commit this file with real values — use .env instead.
17
-
18
- # GitHub
19
- GH_TOKEN=
20
-
21
- # GitLab (profile mahardhika)
22
- GL_TOKEN=
23
-
24
- # Cloudflare (for wrangler / CDN)
25
- CF_ACCOUNT_ID=
26
- CF_API_TOKEN=
27
-
28
- # Tailscale (for auth key generation, optional)
29
- TAILSCALE_AUTH_KEY=
30
-
31
- # SSH config — do NOT store private keys here.
32
- # Private keys stay in ~/.ssh/ on each machine.
4
+ # Supply PROJECT_DATABASE_URL privately through your shell/service manager.
5
+ # Do not put its value in MCP client JSON or knowledge records.
6
+ DEV_WORKFLOW_DB_URL_ENV=
7
+ # For PostgreSQL/MySQL: DEV_WORKFLOW_DB_URL_ENV=PROJECT_DATABASE_URL
8
+ # For SQLite, omit DEV_WORKFLOW_DB_URL_ENV or leave it empty.
9
+ # Knowledge example: secret_ref: env://PROJECT_DATABASE_URL
10
+ # Runtime-only web auth secrets must be independently generated (32+ characters).
11
+ # JWT_SECRET and SESSION_SECRET: provision outside this example file.
12
+ # Never commit real credentials.
package/README.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Dev Workflow MCP Server
2
2
 
3
+ ## Credential isolation
4
+
5
+ Never commit real credentials. Never store raw secrets inside the workflow
6
+ knowledge database. Use environment variables or a secret manager.
7
+
8
+ `DEV_WORKFLOW_SECURITY_MODE=strict` is the default and only supported mode.
9
+ Knowledge may describe credential locations and requirements using SecretReference:
10
+
11
+ ```yaml
12
+ database:
13
+ host: db.internal
14
+ secret_ref: env://PROJECT_DATABASE_URL
15
+ classification: INTERNAL
16
+ ```
17
+
18
+ Supported reference metadata: `env://VARIABLE_NAME`, `op://vault/item/field`,
19
+ `vault://path/to/secret`, `aws-secrets://secret-name`, `gcp-secret://secret-name`,
20
+ `bitwarden://item/field`. MCP does not resolve these references.
21
+
22
+ For PostgreSQL/MySQL, client configuration should specify the variable name only:
23
+
24
+ ```json
25
+ {"DEV_WORKFLOW_DB_TYPE":"postgres","DEV_WORKFLOW_DB_URL_ENV":"PROJECT_DATABASE_URL"}
26
+ ```
27
+
28
+ Provision that variable in the server's service manager, shell or secret-manager
29
+ launcher outside the model and client JSON. The legacy direct URL setting remains
30
+ supported with a value-free deprecation warning. SQLite needs neither URL setting.
31
+
32
+ Raw secret input and writes are rejected with sanitized findings. SECRET-labelled
33
+ records are never returned; PUBLIC, INTERNAL (default) and CONFIDENTIAL records
34
+ still pass through the output scanner. Use `npm run security:audit` or MCP
35
+ `security_audit` for bounded, current-user/project findings. Protected files return
36
+ metadata only; the audit does not read their content, resolve secrets or repair data.
37
+
38
+ Migration: supply explicit `JWT_SECRET`, `SESSION_SECRET` (32+ characters) for their
39
+ respective web servers and `ADMIN_PASSWORD` (16+ characters) to create an admin.
40
+ Login now uses an HttpOnly cookie; `token` is no longer returned in JSON. Previously
41
+ issued bearer tokens remain accepted until expiry. Update custom clients to cookie
42
+ authentication. Browser cross-site cookie policies may require a same-site API domain.
43
+ Compose requires explicit database credentials and `JWT_SECRET`; build its API from
44
+ the repository root. Strict release tools accept the established `npm run release`
45
+ syntax only; run other deployment commands in a trusted operator session.
46
+
47
+ Read the [audit, threat model and migration plan](docs/security-audit.md) and
48
+ [verification and remaining risks](docs/security-verification.md). Passing tests
49
+ does not prove that all secrets are detected. Trusted executable repository scripts,
50
+ external tools, runtime authentication stores, historical Git and backups still
51
+ require operational controls. A credential already pasted into a model cannot be
52
+ removed by this server.
53
+
3
54
  An MCP server that enforces development workflow discipline: **tests → documentation → commit → release**, with no way to skip a step.
4
55
 
5
56
  ```mermaid
@@ -86,12 +137,14 @@ See [MCP client configuration](#mcp-client-configuration).
86
137
  | `project_summary_data` | Read the persisted `project-summary.json` |
87
138
  | `project_summary_db` | Read the per-user summary from SQLite |
88
139
  | `list_factory_tasks` | List your Factory task queue (requires config) |
140
+ | `create_factory_task` | Record a new task with a required definition of done |
141
+ | `update_factory_task` | Edit a task, including filling a missing definition of done |
89
142
  | `claim_factory_task` | Claim a Factory task and start a local workflow |
90
143
  | `sync_task_status` | Report a status change back to Factory |
91
144
 
92
145
  ### Factory task integration
93
146
 
94
- Three tools connect this server to the **Factory** board at
147
+ Five tools connect this server to the **Factory** board at
95
148
  `factory.programinglive.com`. They read and write the real task table
96
149
  (`factory_tasks`), not the retired PocketBase `activity_tasks` collection
97
150
  (which still exists but holds 0 records and never reached the board).
@@ -127,6 +180,8 @@ account only sees its own tasks.
127
180
  | Tool | Writes? | What it does |
128
181
  |---|---|---|
129
182
  | `list_factory_tasks` | no | Reads `GET /api/factory`, maps tasks plus their latest work session, and orders them status → priority. Defaults to `running, queued, backlog, review, blocked` |
183
+ | `create_factory_task` | yes | Creates a task (`POST /api/factory {action:create_task}`). Requires `acceptance` and `definitionOfDone`; defaults to `backlog` so recording work never starts a worker run |
184
+ | `update_factory_task` | yes | Edits title, description, acceptance or definitionOfDone (`POST /api/factory {action:update_task}`), sending the task's current version so Factory can reject a stale edit with 409 |
130
185
  | `claim_factory_task` | yes | Opens a work session (`POST /api/work {action:start}`), which is what turns the task `running` on the board, then starts a local workflow |
131
186
  | `sync_task_status` | yes | Uses the only endpoint that can produce the status; see the table below |
132
187
 
@@ -143,10 +198,40 @@ the endpoint that actually supports it:
143
198
  | `done` | — rejected | A human approves the work in Factory |
144
199
 
145
200
  `review` requires an active work session. Asking for it without one fails with
146
- "no active work session" instead of silently doing nothing — the task must be
201
+ "no active work session" instead of silently doing nothing - the task must be
147
202
  claimed first. Full behaviour, limits (summary 2000 chars, step 240 chars) and
148
203
  troubleshooting live in [docs/factory-integration.md](docs/factory-integration.md).
149
204
 
205
+ #### Review is reported once, not twice
206
+
207
+ Factory reads a second `review` report as an **approval**, so a task can jump to
208
+ `done` without a human deciding anything. That happened on 29 October 2026 to
209
+ four tasks whose earlier session was already `review` while the board still said
210
+ `backlog`.
211
+
212
+ The client now refuses `review` when the latest lifecycle event for that task is
213
+ a `work.review` or `task.done`, and says what to do instead:
214
+
215
+ ```
216
+ Task "..." sudah pernah dilaporkan untuk review (work.review pada ...).
217
+ Melaporkan review kedua kali dibaca Factory sebagai approval, ...
218
+ Minta pemilik memutuskan: set done bila pekerjaan diterima, atau kembalikan
219
+ ke backlog bila perlu dikerjakan ulang.
220
+ ```
221
+
222
+ `blocked` and `paused` are unaffected: only `review` carries approval semantics.
223
+
224
+ A later `work.paused` event re-opens the task, so rework can be reported again.
225
+ That matters because `paused` is the only supported way back from `review` to
226
+ `backlog`; without the exception the guard would block legitimate second
227
+ reviews forever.
228
+
229
+ #### Task ids are UUIDs
230
+
231
+ `list_factory_tasks` prints the full UUID, because `sync_task_status` and
232
+ `claim_factory_task` reject the 8-character short key. Earlier versions printed
233
+ the short key, which forced agents to call `GET /api/factory` by hand.
234
+
150
235
 
151
236
  ### Example session
152
237
 
@@ -275,7 +360,7 @@ supported:
275
360
  ```bash
276
361
  # .env or environment variable
277
362
  DEV_WORKFLOW_DB_TYPE=postgres
278
- DEV_WORKFLOW_DB_URL=postgresql://user:pass@localhost:5432/devworkflow
363
+ DEV_WORKFLOW_DB_URL=postgresql://USER:[REDACTED]@HOST:5432/database
279
364
  ```
280
365
 
281
366
  | Variable | Default | Description |
@@ -373,8 +458,8 @@ The two skipped tests are the MySQL and PostgreSQL adapters. They run only when
373
458
  database is reachable:
374
459
 
375
460
  ```bash
376
- TEST_MYSQL_URL=mysql://user:pass@localhost:3306/db npm test
377
- TEST_POSTGRES_URL=postgres://user:pass@localhost:5432/db npm test
461
+ TEST_MYSQL_URL=mysql://USER:[REDACTED]@HOST:5432/database npm test
462
+ TEST_POSTGRES_URL=postgres://USER:[REDACTED]@HOST:5432/database npm test
378
463
  ```
379
464
 
380
465
  To exercise them against real servers without exposing them publicly:
@@ -518,9 +603,27 @@ installed. Run `npm install` in the repo.
518
603
  because there is no prebuild for your Node ABI. Either switch to a Node version
519
604
  that has one, or install Visual Studio Build Tools.
520
605
 
521
- **`EPERM ... better_sqlite3.node`** — a running process holds the file. Stop it,
606
+ **`EPERM ... better_sqlite3.node`** - a running process holds the file. Stop it,
522
607
  then reinstall.
523
608
 
609
+ **`commit_and_push` says "No changes detected" while the project clearly has
610
+ changes** - git ran in the wrong repository. The server used to fall back to its
611
+ own package directory, which is also a git repo, so commits landed there (or
612
+ nowhere) instead of in your project. It now refuses to run git in the package
613
+ repo and asks for the project explicitly:
614
+
615
+ ```bash
616
+ export WORKFLOW_PROJECT_DIR=/path/to/your/project
617
+ ```
618
+
619
+ Point it at any directory inside the project; the repo root is discovered
620
+ upward. Non-git commands are unaffected.
621
+
622
+ **A task jumped straight to `done` after reporting `review`** - it was reported
623
+ twice. Factory treats the second report as approval. Current versions block that
624
+ at the client, but if it already happened, review the task and move it back to
625
+ `backlog` if the work is not accepted.
626
+
524
627
  **OpenCode does not load the server** — check the config shape. V2 requires
525
628
  `mcp.servers` and rejects `enabled`:
526
629
 
@@ -1,6 +1,38 @@
1
+ import { assertSafeKnowledge } from '../../security/policy.js';
2
+ import { securityOutputFilter } from '../../security/sanitizer.js';
3
+
4
+ /**
5
+ * Nilai `task_type` yang selalu boleh masuk kolom NOT NULL.
6
+ *
7
+ * MENGAPA ADA
8
+ * -----------
9
+ * Kolom `workflow_history.task_type` adalah NOT NULL, tetapi entri history
10
+ * yang lebih tua tidak selalu punya nilainya: penanda `dropped` versi lama
11
+ * ditulis tanpa `taskType` sama sekali, dan sebagian entri `forced` menyimpan
12
+ * string kosong. Saat `undefined` di-bind, driver mengubahnya menjadi NULL dan
13
+ * SQLite menolak dengan `NOT NULL constraint failed: workflow_history.task_type`.
14
+ *
15
+ * Dulu kegagalan itu membatalkan seluruh `syncToDatabase()`: ringkasan proyek
16
+ * dan state workflow ikut tidak pernah ditulis, jadi riwayat proyek diam-diam
17
+ * berhenti tersimpan. Normalisasi di sini membuat data lama tetap dapat
18
+ * disinkronkan tanpa menebak isi aslinya.
19
+ */
20
+ export function normalizeTaskType(value) {
21
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'other';
22
+ }
23
+
1
24
  export class DbAdapter {
2
25
  constructor(config = {}) {
3
26
  this.config = config;
27
+ // Guard direct adapter use as well as the db/index facade.
28
+ for (const name of ['insertHistoryEntry','saveState','updateSummaryForUser']) {
29
+ const operation=this[name].bind(this);
30
+ this[name]=async(...args)=>{assertSafeKnowledge(args);return operation(...args);};
31
+ }
32
+ for (const name of ['getState','getHistoryForUser','getSummaryForUser','getHistorySummary']) {
33
+ const operation=this[name].bind(this);
34
+ this[name]=async(...args)=>securityOutputFilter(await operation(...args));
35
+ }
4
36
  }
5
37
 
6
38
  async connect() {
@@ -1,5 +1,6 @@
1
1
  import mysql from "mysql2/promise";
2
- import { DbAdapter } from "./base.js";
2
+ import { DbAdapter, normalizeTaskType } from "./base.js";
3
+ import { assertSafeKnowledge, classify } from '../../security/policy.js';
3
4
 
4
5
  const FREQUENCY_FORMATS = {
5
6
  daily: "%Y-%m-%d",
@@ -69,6 +70,8 @@ export class MysqlAdapter extends DbAdapter {
69
70
  )
70
71
  `);
71
72
 
73
+ const [classificationColumns]=await conn.query("SHOW COLUMNS FROM workflow_history LIKE 'classification'");
74
+ if(!classificationColumns.length) await conn.query("ALTER TABLE workflow_history ADD COLUMN classification VARCHAR(20) NOT NULL DEFAULT 'INTERNAL'");
72
75
  // Indexes are effectively covered by PRIMARY/UNIQUE keys, but we can add secondary if needed.
73
76
  } finally {
74
77
  conn.release();
@@ -89,27 +92,28 @@ export class MysqlAdapter extends DbAdapter {
89
92
  // MySQL INSERT ON DUPLICATE KEY UPDATE
90
93
  const query = `
91
94
  INSERT INTO workflow_history
92
- (user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason)
93
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
95
+ (user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason, classification)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
94
97
  ON DUPLICATE KEY UPDATE
95
98
  commit_message=VALUES(commit_message),
96
99
  forced=VALUES(forced),
97
100
  force_reason=VALUES(force_reason),
98
101
  dropped=VALUES(dropped),
99
- drop_reason=VALUES(drop_reason)
102
+ drop_reason=VALUES(drop_reason), classification=VALUES(classification)
100
103
  `;
101
104
 
102
105
  await this.pool.execute(query, [
103
106
  userId,
104
107
  normalizedProjectPath,
105
108
  entry.taskDescription,
106
- entry.taskType,
109
+ normalizeTaskType(entry.taskType),
107
110
  entry.commitMessage,
108
111
  this.formatDate(entry.timestamp),
109
112
  entry.forced ? 1 : 0,
110
113
  entry.forceReason || null,
111
114
  entry.dropped ? 1 : 0,
112
- entry.dropReason || null
115
+ entry.dropReason || null,
116
+ classify(entry)
113
117
  ]);
114
118
 
115
119
  await this.updateSummaryForUser(userId, normalizedProjectPath);
@@ -120,7 +124,7 @@ export class MysqlAdapter extends DbAdapter {
120
124
  const normalizedProjectPath = projectPath || '';
121
125
 
122
126
  const [rows] = await this.pool.execute(`
123
- SELECT task_type, timestamp, task_description
127
+ SELECT classification, task_type, timestamp, task_description
124
128
  FROM workflow_history
125
129
  WHERE user_id = ? AND project_path = ?
126
130
  ORDER BY timestamp DESC
@@ -132,7 +136,9 @@ export class MysqlAdapter extends DbAdapter {
132
136
  taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
133
137
  }
134
138
 
139
+ assertSafeKnowledge(rows);
135
140
  const recentTasks = rows.slice(0, 5).map(r => ({
141
+ classification: r.classification,
136
142
  description: r.task_description,
137
143
  type: r.task_type,
138
144
  timestamp: r.timestamp,
@@ -1,5 +1,6 @@
1
1
  import pg from "pg";
2
- import { DbAdapter } from "./base.js";
2
+ import { DbAdapter, normalizeTaskType } from "./base.js";
3
+ import { assertSafeKnowledge, classify } from '../../security/policy.js';
3
4
 
4
5
  const { Pool } = pg;
5
6
 
@@ -59,6 +60,7 @@ export class PostgresAdapter extends DbAdapter {
59
60
  )
60
61
  `);
61
62
 
63
+ await client.query("ALTER TABLE workflow_history ADD COLUMN IF NOT EXISTS classification TEXT NOT NULL DEFAULT 'INTERNAL'");
62
64
  // Migration: Add columns to existing table if they don't exist
63
65
  const columns = await client.query(`
64
66
  SELECT column_name FROM information_schema.columns WHERE table_name = 'workflow_history'
@@ -125,27 +127,28 @@ export class PostgresAdapter extends DbAdapter {
125
127
 
126
128
  const query = `
127
129
  INSERT INTO workflow_history
128
- (user_id, project_path, description, task_type, commit_message, completed_at, forced, force_reason, dropped, drop_reason)
129
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
130
+ (user_id, project_path, description, task_type, commit_message, completed_at, forced, force_reason, dropped, drop_reason, classification)
131
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
130
132
  ON CONFLICT (user_id, project_path, completed_at, description) DO UPDATE SET
131
133
  commit_message=EXCLUDED.commit_message,
132
134
  forced=EXCLUDED.forced,
133
135
  force_reason=EXCLUDED.force_reason,
134
136
  dropped=EXCLUDED.dropped,
135
- drop_reason=EXCLUDED.drop_reason
137
+ drop_reason=EXCLUDED.drop_reason, classification=EXCLUDED.classification
136
138
  `;
137
139
 
138
140
  await this.pool.query(query, [
139
141
  numericUserId,
140
142
  normalizedProjectPath,
141
143
  entry.taskDescription || entry.description,
142
- entry.taskType,
144
+ normalizeTaskType(entry.taskType),
143
145
  entry.commitMessage,
144
146
  entry.timestamp || entry.completed_at || new Date().toISOString(),
145
147
  entry.forced || false,
146
148
  entry.forceReason || null,
147
149
  entry.dropped || false,
148
- entry.dropReason || null
150
+ entry.dropReason || null,
151
+ classify(entry)
149
152
  ]);
150
153
 
151
154
  await this.updateSummaryForUser(userId, normalizedProjectPath);
@@ -157,7 +160,7 @@ export class PostgresAdapter extends DbAdapter {
157
160
  const numericUserId = this.getNumericUserId(userId);
158
161
 
159
162
  const res = await this.pool.query(`
160
- SELECT task_type, completed_at as timestamp, description as task_description
163
+ SELECT classification, task_type, completed_at as timestamp, description as task_description
161
164
  FROM workflow_history
162
165
  WHERE user_id = $1 AND project_path = $2
163
166
  ORDER BY completed_at DESC
@@ -172,7 +175,9 @@ export class PostgresAdapter extends DbAdapter {
172
175
  taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
173
176
  }
174
177
 
178
+ assertSafeKnowledge(rows);
175
179
  const recentTasks = rows.slice(0, 5).map(r => ({
180
+ classification: r.classification,
176
181
  description: r.task_description,
177
182
  type: r.task_type,
178
183
  timestamp: r.timestamp,
@@ -1,7 +1,9 @@
1
+ import { safeLog } from '../../security/sanitizer.js';
1
2
  import Database from "better-sqlite3";
2
3
  import { existsSync } from "fs";
3
4
  import path from "path";
4
- import { DbAdapter } from "./base.js";
5
+ import { DbAdapter, normalizeTaskType } from "./base.js";
6
+ import { assertSafeKnowledge, classify } from '../../security/policy.js';
5
7
 
6
8
  const DB_FILENAME = "dev-workflow.db";
7
9
  const FREQUENCY_FORMATS = {
@@ -32,7 +34,7 @@ export class SqliteAdapter extends DbAdapter {
32
34
  limit--;
33
35
  }
34
36
  if (limit === 0) {
35
- console.error("Warning: Could not find project root effectively, defaulting to INIT_CWD");
37
+ safeLog('error', "Warning: Could not find project root effectively, defaulting to INIT_CWD");
36
38
  projectRoot = initCwd;
37
39
  }
38
40
  return path.join(projectRoot, ".state", DB_FILENAME);
@@ -89,6 +91,9 @@ export class SqliteAdapter extends DbAdapter {
89
91
  `;
90
92
 
91
93
  this.db.exec(createHistory);
94
+ if(!this.db.prepare('PRAGMA table_info(workflow_history)').all().some(col=>col.name==='classification')) {
95
+ this.db.exec("ALTER TABLE workflow_history ADD COLUMN classification TEXT NOT NULL DEFAULT 'INTERNAL'");
96
+ }
92
97
 
93
98
  // 2. MIGRATION: Handle existing databases (workflow_history)
94
99
  try {
@@ -104,7 +109,7 @@ export class SqliteAdapter extends DbAdapter {
104
109
  // Duplicate entries from old schema (with no project_path) effectively mean project_path='' which matches new default.
105
110
  }
106
111
  } catch (e) {
107
- console.error("Migration error (workflow_history):", e.message);
112
+ safeLog('error', "Migration error (workflow_history):", e.message);
108
113
  }
109
114
 
110
115
  // 3. MIGRATION: Handle project_summary (Drop and Recreate if schema mismatch)
@@ -147,27 +152,28 @@ export class SqliteAdapter extends DbAdapter {
147
152
  // SQLite upsert syntax
148
153
  const stmt = this.db.prepare(`
149
154
  INSERT INTO workflow_history
150
- (user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason)
151
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
155
+ (user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason, classification)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
152
157
  ON CONFLICT(user_id, project_path, timestamp, task_description) DO UPDATE SET
153
158
  commit_message=excluded.commit_message,
154
159
  forced=excluded.forced,
155
160
  force_reason=excluded.force_reason,
156
161
  dropped=excluded.dropped,
157
- drop_reason=excluded.drop_reason
162
+ drop_reason=excluded.drop_reason, classification=excluded.classification
158
163
  `);
159
164
 
160
165
  stmt.run(
161
166
  userId,
162
167
  normalizedProjectPath,
163
168
  entry.taskDescription,
164
- entry.taskType,
169
+ normalizeTaskType(entry.taskType),
165
170
  entry.commitMessage,
166
171
  entry.timestamp || new Date().toISOString(),
167
172
  entry.forced ? 1 : 0,
168
173
  entry.forceReason || null,
169
174
  entry.dropped ? 1 : 0,
170
- entry.dropReason || null
175
+ entry.dropReason || null,
176
+ classify(entry)
171
177
  );
172
178
 
173
179
  await this.updateSummaryForUser(userId, normalizedProjectPath);
@@ -178,7 +184,7 @@ export class SqliteAdapter extends DbAdapter {
178
184
  const normalizedProjectPath = projectPath || '';
179
185
 
180
186
  const getHistory = this.db.prepare(`
181
- SELECT task_type, timestamp, task_description
187
+ SELECT classification, task_type, timestamp, task_description
182
188
  FROM workflow_history
183
189
  WHERE user_id = ? AND project_path = ?
184
190
  ORDER BY timestamp DESC
@@ -191,7 +197,9 @@ export class SqliteAdapter extends DbAdapter {
191
197
  taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
192
198
  }
193
199
 
200
+ assertSafeKnowledge(rows);
194
201
  const recentTasks = rows.slice(0, 5).map(r => ({
202
+ classification: r.classification,
195
203
  description: r.task_description,
196
204
  type: r.task_type,
197
205
  timestamp: r.timestamp,
package/db/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SqliteAdapter } from "./adapters/sqlite.js";
2
2
  import { MysqlAdapter } from "./adapters/mysql.js";
3
3
  import { PostgresAdapter } from "./adapters/postgres.js";
4
+ import { databaseConnectionUrl } from '../security/config.js';
4
5
 
5
6
  let adapterInstance = null;
6
7
 
@@ -11,7 +12,7 @@ export async function getAdapter() {
11
12
 
12
13
  const type = (process.env.DEV_WORKFLOW_DB_TYPE || "sqlite").toLowerCase();
13
14
  const config = {
14
- connectionUrl: process.env.DEV_WORKFLOW_DB_URL,
15
+ connectionUrl: databaseConnectionUrl(),
15
16
  dbPath: process.env.DEV_WORKFLOW_DB_PATH // optional override for sqlite
16
17
  };
17
18
 
@@ -7,7 +7,7 @@ services:
7
7
  restart: unless-stopped
8
8
  environment:
9
9
  POSTGRES_USER: ${POSTGRES_USER:-devworkflow}
10
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devworkflow_secure_password_change_me}
10
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Provision POSTGRES_PASSWORD privately}
11
11
  POSTGRES_DB: ${POSTGRES_DB:-devworkflow}
12
12
  ports:
13
13
  - "5432:5432"
@@ -21,14 +21,16 @@ services:
21
21
 
22
22
  api:
23
23
  build:
24
- context: ./web
25
- dockerfile: Dockerfile.api
24
+ context: .
25
+ dockerfile: web/Dockerfile.api
26
26
  container_name: dev-workflow-api
27
27
  ports:
28
28
  - "8080:8080"
29
29
  environment:
30
- - DEV_WORKFLOW_DB_URL=${DEV_WORKFLOW_DB_URL}
31
- - SESSION_SECRET=${SESSION_SECRET:-dev-workflow-secret-change-in-production}
30
+ - DEV_WORKFLOW_DB_URL_ENV=PROJECT_DATABASE_URL
31
+ - PROJECT_DATABASE_URL=${PROJECT_DATABASE_URL:?Provision PROJECT_DATABASE_URL privately}
32
+ - JWT_SECRET=${JWT_SECRET:?Provision JWT_SECRET privately}
33
+ - DEV_WORKFLOW_SECURITY_MODE=strict
32
34
  - NODE_ENV=${NODE_ENV:-production}
33
35
  - API_PORT=8080
34
36
  depends_on:
package/exec.js CHANGED
@@ -1,10 +1,22 @@
1
- import { exec as execCallback } from "child_process";
1
+ import { exec as execCallback, execFile as execFileCallback } from "child_process";
2
2
  import { existsSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { promisify } from "util";
6
+ import { sanitize } from './security/sanitizer.js';
6
7
 
7
8
  const execPromise = promisify(execCallback);
9
+ const execFilePromise = promisify(execFileCallback);
10
+
11
+ /**
12
+ * Direktori paket server MCP ini.
13
+ *
14
+ * Penting: repo ini juga repo git. Kalau git dijalankan di sini, hasilnya
15
+ * "No changes detected" sementara perubahan proyek tidak pernah ter-commit -
16
+ * persis bug yang terjadi 29 Oktober 2026 saat `commit_and_push` melaporkan
17
+ * commit `docs(release): catat rilis 1.12.3` milik repo paket, bukan repo kerja.
18
+ */
19
+ export const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
8
20
 
9
21
  function findRepoRoot(startDir) {
10
22
  if (!startDir) {
@@ -32,18 +44,72 @@ function findRepoRoot(startDir) {
32
44
  }
33
45
  }
34
46
 
35
- function getDefaultCwd() {
36
- const moduleDir = path.dirname(fileURLToPath(import.meta.url));
37
- const candidateDirs = [process.cwd(), process.env.INIT_CWD, moduleDir];
38
- return candidateDirs.reduce((resolved, candidate) => {
39
- if (resolved) {
40
- return resolved;
47
+ function samePath(a, b) {
48
+ return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
49
+ }
50
+
51
+ /**
52
+ * Menentukan direktori repo proyek tempat git boleh dijalankan.
53
+ *
54
+ * Urutan: eksplisit -> WORKFLOW_PROJECT_DIR/PROJECT_DIR -> kandidat.
55
+ * Repo paket MCP selalu dilewati; kalau tidak ada pilihan lain, lebih baik
56
+ * gagal dengan pesan jelas daripada commit ke repo yang salah.
57
+ */
58
+ export function resolveWorkingDirectory({ explicit = "", env = process.env, candidates = [] } = {}) {
59
+ if (explicit) {
60
+ const root = findRepoRoot(explicit);
61
+ if (!root) {
62
+ throw new Error(`Direktori "${explicit}" bukan repo git (tidak ada .git).`);
63
+ }
64
+ return root;
65
+ }
66
+
67
+ const envDir = (env.WORKFLOW_PROJECT_DIR || env.PROJECT_DIR || "").trim();
68
+ if (envDir) {
69
+ const root = findRepoRoot(envDir);
70
+ if (!root) {
71
+ throw new Error(`WORKFLOW_PROJECT_DIR "${envDir}" bukan repo git (tidak ada .git).`);
41
72
  }
42
- return findRepoRoot(candidate) || null;
43
- }, null) || process.cwd();
73
+ return root;
74
+ }
75
+
76
+ const found = candidates.map((candidate) => findRepoRoot(candidate)).filter(Boolean);
77
+ const usable = found.find((dir) => !samePath(dir, PACKAGE_DIR));
78
+ if (usable) {
79
+ return usable;
80
+ }
81
+
82
+ throw new Error(
83
+ "Tidak bisa menentukan repo proyek: satu-satunya kandidat adalah repo paket server MCP. " +
84
+ "Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari direktori proyek.",
85
+ );
44
86
  }
45
87
 
46
- export function exec(command, options = {}) {
47
- const execOptions = options.cwd ? options : { ...options, cwd: getDefaultCwd() };
48
- return execPromise(command, execOptions);
88
+ function getDefaultCwd(command) {
89
+ try {
90
+ return resolveWorkingDirectory({
91
+ env: process.env,
92
+ candidates: [process.cwd(), process.env.INIT_CWD],
93
+ });
94
+ } catch (error) {
95
+ // Perintah non-git (echo, npm run release, ...) tidak butuh repo proyek.
96
+ // Yang berbahaya hanya git: di sana cwd menentukan repo mana yang diubah.
97
+ if (/^\s*git\b/i.test(String(command))) {
98
+ throw error;
99
+ }
100
+ return process.cwd();
101
+ }
102
+ }
103
+
104
+ export async function exec(command, options = {}) {
105
+ const {executable,arguments:args,...runtimeOptions}=options;
106
+ const execOptions = runtimeOptions.cwd ? runtimeOptions : { ...runtimeOptions, cwd: getDefaultCwd(executable || command) };
107
+ try { return sanitize(await (executable ? execFilePromise(executable,args,{...execOptions,shell:false}) : execPromise(command, execOptions))); }
108
+ catch(error) {
109
+ const safe=new Error(sanitize(error.message));
110
+ safe.code=error.code;
111
+ safe.stdout=sanitize(error.stdout || '');
112
+ safe.stderr=sanitize(error.stderr || '');
113
+ throw safe;
114
+ }
49
115
  }