@programinglive/dev-workflow-mcp-server 1.12.1 → 1.13.0
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/.dockerignore +24 -0
- package/.env.example +11 -31
- package/README.md +109 -6
- package/db/adapters/base.js +12 -0
- package/db/adapters/mysql.js +11 -5
- package/db/adapters/postgres.js +10 -5
- package/db/adapters/sqlite.js +15 -7
- package/db/index.js +2 -1
- package/docker-compose.yml +7 -5
- package/exec.js +78 -12
- package/factory-client.js +142 -10
- package/index.js +28 -19
- package/install.js +6 -5
- package/package.json +4 -1
- package/release-wrapper.js +32 -19
- package/scripts/check-review-guard.mjs +33 -0
- package/scripts/create-admin.js +11 -9
- package/scripts/install-husky.js +3 -2
- package/scripts/lint.mjs +20 -0
- package/scripts/release.cjs +25 -22
- package/scripts/release.js +6 -5
- package/scripts/setup-stack.js +42 -41
- package/scripts/update-release-notes.cjs +13 -12
- package/scripts/update-release-notes.js +8 -7
- package/scripts/verify-bundle.mjs +8 -7
- package/security/audit.js +86 -0
- package/security/commands.js +10 -0
- package/security/config.js +24 -0
- package/security/files.js +27 -0
- package/security/http.js +26 -0
- package/security/log-bridge.cjs +17 -0
- package/security/policy.js +37 -0
- package/security/references.js +21 -0
- package/security/runtime-secrets.js +42 -0
- package/security/sanitizer.js +42 -0
- package/security/scanner.js +108 -0
- package/tools/definitions.js +51 -3
- package/tools/handler-modules/commit-handlers.js +3 -7
- package/tools/handler-modules/factory-handlers.js +84 -0
- package/tools/handler-modules/index.js +45 -3
- package/tools/handler-modules/shared.js +5 -2
- package/tools/handler-modules/status-handlers.js +1 -1
- package/tools/handler-modules/summary-handlers.js +2 -1
- package/tools/handler-modules/workflow-handlers.js +2 -0
- package/utils.js +1 -0
- package/verify-build.js +7 -6
- package/vite.config.js +4 -0
- package/workflow-state.js +109 -18
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { scanSecrets } from './scanner.js';
|
|
4
|
+
import { classify,securityMode } from './policy.js';
|
|
5
|
+
import { sanitize } from './sanitizer.js';
|
|
6
|
+
import { isProtectedFile,protectedFileMetadata } from './files.js';
|
|
7
|
+
import { databaseConnectionUrl } from './config.js';
|
|
8
|
+
|
|
9
|
+
export async function auditDatabase({root,userId,projectPath=root,env=process.env}) {
|
|
10
|
+
const type=env.DEV_WORKFLOW_DB_TYPE || 'sqlite';
|
|
11
|
+
const issues=[]; let close=async()=>{}; let query;
|
|
12
|
+
try {
|
|
13
|
+
if(type==='sqlite') {
|
|
14
|
+
const dbPath=env.DEV_WORKFLOW_DB_PATH || path.join(root,'.state','dev-workflow.db');
|
|
15
|
+
try {await fs.access(dbPath);} catch{return {status:'not_present',issues:[]};}
|
|
16
|
+
const {default:Database}=await import('better-sqlite3');
|
|
17
|
+
const db=new Database(dbPath,{readonly:true,fileMustExist:true});
|
|
18
|
+
close=async()=>db.close();
|
|
19
|
+
query=async(table)=>db.prepare(`SELECT * FROM ${table} WHERE user_id = ? AND project_path = ? LIMIT 1001`).all(userId,projectPath);
|
|
20
|
+
} else if(['postgres','postgresql'].includes(type)) {
|
|
21
|
+
const {default:pg}=await import('pg');
|
|
22
|
+
const pool=new pg.Pool({connectionString:databaseConnectionUrl(env),connectionTimeoutMillis:3000});
|
|
23
|
+
close=()=>pool.end();
|
|
24
|
+
const {PostgresAdapter}=await import('../db/adapters/postgres.js');
|
|
25
|
+
const numericId=new PostgresAdapter().getNumericUserId(userId);
|
|
26
|
+
query=async(table)=>(await pool.query(`SELECT * FROM ${table} WHERE user_id = $1 AND project_path = $2 LIMIT 1001`,[numericId,projectPath])).rows;
|
|
27
|
+
} else if(type==='mysql') {
|
|
28
|
+
const {default:mysql}=await import('mysql2/promise');
|
|
29
|
+
const connection=await mysql.createConnection(databaseConnectionUrl(env));
|
|
30
|
+
close=()=>connection.end();
|
|
31
|
+
query=async(table)=>(await connection.execute(`SELECT * FROM ${table} WHERE user_id = ? AND project_path = ? LIMIT 1001`,[userId,projectPath]))[0];
|
|
32
|
+
} else return {status:'unsupported',issues:[]};
|
|
33
|
+
let rows_scanned=0,truncated=false;
|
|
34
|
+
const unavailable_tables=[];
|
|
35
|
+
for(const table of ['workflow_history','workflow_state','project_summary']) {
|
|
36
|
+
try {
|
|
37
|
+
const rows=await query(table); truncated ||= rows.length>1000;
|
|
38
|
+
for(const [index,row] of rows.slice(0,1000).entries()) {
|
|
39
|
+
rows_scanned++;
|
|
40
|
+
const findings=scanSecrets(row);
|
|
41
|
+
if(findings.length || classify(row)==='SECRET') issues.push({table,row_number:index+1,findings,detected_sensitivity:'SECRET'});
|
|
42
|
+
}
|
|
43
|
+
} catch {unavailable_tables.push(table);}
|
|
44
|
+
}
|
|
45
|
+
return {status:unavailable_tables.length?'partial':'scanned',rows_scanned,truncated,unavailable_tables,scope:'current user and project only',issues};
|
|
46
|
+
} catch {return {status:'unavailable',issues,remediation:'Verify database configuration and read permissions in a trusted operator session.'};}
|
|
47
|
+
finally {await close().catch(()=>{});}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function securityAudit({root,state={},stateFile,userId='default',env=process.env}) {
|
|
51
|
+
const report={mode:securityMode(env),status:'review_required',protected_files:[],potential_exposures:[],configuration:[],scan:{files_scanned:0,truncated:false,unreadable:0}};
|
|
52
|
+
const inspect=(source,value)=>{
|
|
53
|
+
const findings=scanSecrets(value);
|
|
54
|
+
if(findings.length || classify(value)==='SECRET') report.potential_exposures.push({source,findings,detected_sensitivity:'SECRET'});
|
|
55
|
+
};
|
|
56
|
+
inspect('in-memory knowledge',state);
|
|
57
|
+
// Scan only the current user's state artifacts; never enumerate other users' state.
|
|
58
|
+
if(stateFile) for(const file of [stateFile,path.join(path.dirname(stateFile),'project-summary.json')]) {
|
|
59
|
+
try {inspect('current-user knowledge artifact',JSON.parse(await fs.readFile(file,'utf8')));}catch {report.scan.unreadable++;}
|
|
60
|
+
}
|
|
61
|
+
const skip=new Set(['.git','node_modules','.state','dist','build','out','.next','.worktrees','.superpowers']);
|
|
62
|
+
async function walk(dir,depth=0) {
|
|
63
|
+
if(depth>12 || report.scan.files_scanned>=500) {report.scan.truncated=true;return;}
|
|
64
|
+
let entries;try{entries=await fs.readdir(dir,{withFileTypes:true});}catch{report.scan.unreadable++;return;}
|
|
65
|
+
for(const entry of entries) {
|
|
66
|
+
const file=path.join(dir,entry.name);
|
|
67
|
+
if(entry.isSymbolicLink()) continue;
|
|
68
|
+
if(entry.isDirectory()) {if(!skip.has(entry.name)) await walk(file,depth+1);continue;}
|
|
69
|
+
if(isProtectedFile(file)) {try{report.protected_files.push(await protectedFileMetadata(file));}catch{report.scan.unreadable++;}continue;}
|
|
70
|
+
if(!/\.(?:md|json|[cm]?js|tsx?|ya?ml|sh|ps1|toml)$/i.test(file)||entry.name.endsWith('lock.json'))continue;
|
|
71
|
+
if(report.scan.files_scanned>=500){report.scan.truncated=true;break;}
|
|
72
|
+
try {
|
|
73
|
+
const stat=await fs.stat(file);
|
|
74
|
+
if(stat.size>1024*1024){report.scan.truncated=true;continue;}
|
|
75
|
+
report.scan.files_scanned++;
|
|
76
|
+
inspect(path.relative(root,file),await fs.readFile(file,'utf8'));
|
|
77
|
+
}catch{report.scan.unreadable++;}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await walk(root);
|
|
81
|
+
if(env.DEV_WORKFLOW_DB_URL) report.configuration.push({issue:'legacy_database_configuration',remediation:'Use DEV_WORKFLOW_DB_URL_ENV and provision its target outside client JSON.'});
|
|
82
|
+
for(const name of ['JWT_SECRET','SESSION_SECRET']) if(!env[name]) report.configuration.push({issue:'signing_secret_not_configured',variable:name,scope:'required only when running the corresponding web authentication server'});
|
|
83
|
+
report.database=await auditDatabase({root,userId,env});
|
|
84
|
+
report.recommendations=['Review candidates in a trusted session; no detected values are returned.','Rotate exposed credentials before reviewing historical Git cleanup.','Replace raw credentials with SecretReference; protect backups and external logs.'];
|
|
85
|
+
return sanitize(report);
|
|
86
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function assertSafeToolCommand(name,args) {
|
|
2
|
+
const command=name==='perform_release'?args.command:name==='run_full_workflow'?args.releaseCommand:undefined;
|
|
3
|
+
if(command && !/^(?:(?:major|minor|patch)|npm(?:\.cmd)? run release(?::(?:major|minor|patch))?(?: -- (?:--release-as (?:major|minor|patch)|--first-release))?)$/.test(command.trim())) {
|
|
4
|
+
const error=new Error('Strict mode permits npm run release[:major|minor|patch] only. Arbitrary shell commands and file reads are denied. Run custom release actions in a trusted operator session.');
|
|
5
|
+
error.code='UNSAFE_COMMAND_DENIED'; throw error;
|
|
6
|
+
}
|
|
7
|
+
if(args.branch && !/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(args.branch)) {
|
|
8
|
+
const error=new Error('Unsafe branch syntax denied');error.code='UNSAFE_COMMAND_DENIED';throw error;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { safeLog } from './sanitizer.js';
|
|
2
|
+
import { securityMode } from './policy.js';
|
|
3
|
+
import { registerRuntimeSecret } from './runtime-secrets.js';
|
|
4
|
+
|
|
5
|
+
export function databaseConnectionUrl(env=process.env) {
|
|
6
|
+
securityMode(env);
|
|
7
|
+
const variable=env.DEV_WORKFLOW_DB_URL_ENV;
|
|
8
|
+
if(variable) {
|
|
9
|
+
if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable)) throw new Error('DEV_WORKFLOW_DB_URL_ENV must name an environment variable');
|
|
10
|
+
if(!env[variable]) throw new Error('Referenced database environment variable is missing');
|
|
11
|
+
registerRuntimeSecret(env[variable]);
|
|
12
|
+
return env[variable];
|
|
13
|
+
}
|
|
14
|
+
if(env.DEV_WORKFLOW_DB_URL) safeLog('warn','DEV_WORKFLOW_DB_URL is deprecated; configure DEV_WORKFLOW_DB_URL_ENV outside client configuration.');
|
|
15
|
+
registerRuntimeSecret(env.DEV_WORKFLOW_DB_URL);
|
|
16
|
+
return env.DEV_WORKFLOW_DB_URL;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requireSigningSecret(name,env=process.env) {
|
|
20
|
+
const value=env[name];
|
|
21
|
+
if(!value || value.length<32 || /change[-_ ]?(?:me|in|this|production)|dev-workflow.*secret/i.test(value)) throw new Error('Configure a strong authentication signing secret outside the knowledge system');
|
|
22
|
+
registerRuntimeSecret(value);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assertSafeKnowledge } from './policy.js';
|
|
4
|
+
import { sanitize } from './sanitizer.js';
|
|
5
|
+
|
|
6
|
+
export function isProtectedFile(file) {
|
|
7
|
+
const normalized=String(file).replaceAll('\\','/').toLowerCase();
|
|
8
|
+
const name=normalized.split('/').at(-1);
|
|
9
|
+
return /^\.env(?:\.|$)/.test(name) || /^(?:credentials|service[-_]account).*\.json$/.test(name) ||
|
|
10
|
+
/^(?:id_rsa|id_ed25519|id_dsa|id_ecdsa)(?:\.|$)/.test(name) || /\.(?:pem|key|p12|pfx)$/.test(name) ||
|
|
11
|
+
/^(?:\.npmrc|\.git-credentials|\.netrc|_netrc|application_default_credentials\.json)$/.test(name) || normalized.includes('/.docker/') || normalized.includes('/.ssh/') || normalized.includes('/.aws/') || normalized.includes('/.kube/') || normalized.includes('/gcloud/');
|
|
12
|
+
}
|
|
13
|
+
export async function protectedFileMetadata(file) {
|
|
14
|
+
const resolved=await fs.realpath(file);
|
|
15
|
+
const stat=await fs.stat(resolved);
|
|
16
|
+
return sanitize({path:file,protected:isProtectedFile(file)||isProtectedFile(resolved),size_bytes:stat.size,kind:stat.isFile()?'file':'directory',content:'[OMITTED]'});
|
|
17
|
+
}
|
|
18
|
+
export async function readKnowledgeFile(file) {
|
|
19
|
+
const metadata=await protectedFileMetadata(file);
|
|
20
|
+
if(metadata.protected) return metadata;
|
|
21
|
+
// Refuse links entirely: do not race a link replacement between check and read.
|
|
22
|
+
const stat=await fs.lstat(file);
|
|
23
|
+
if(stat.isSymbolicLink() || !stat.isFile() || stat.size>1024*1024) throw new Error('Knowledge file is not an eligible regular file');
|
|
24
|
+
const content=await fs.readFile(file,'utf8');
|
|
25
|
+
assertSafeKnowledge({path:file,content});
|
|
26
|
+
return {path:file,classification:'INTERNAL',content};
|
|
27
|
+
}
|
package/security/http.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { securityOutputFilter } from './sanitizer.js';
|
|
2
|
+
import { assertSafeKnowledge } from './policy.js';
|
|
3
|
+
|
|
4
|
+
export function securityJsonMiddleware(req,res,next) {
|
|
5
|
+
const json=res.json.bind(res);
|
|
6
|
+
res.json=value=>json(securityOutputFilter(value));
|
|
7
|
+
next();
|
|
8
|
+
}
|
|
9
|
+
export function knowledgeInputMiddleware(req,res,next) {
|
|
10
|
+
try {assertSafeKnowledge(req.body);next();}
|
|
11
|
+
catch(error) {
|
|
12
|
+
res.status(422).json({error:'Knowledge input rejected; use SecretReference.',findings:error.findings || []});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Authentication is a runtime boundary, separate from knowledge responses.
|
|
17
|
+
export const AUTH_COOKIE='dev_workflow_auth';
|
|
18
|
+
export function authenticationToken(req) {
|
|
19
|
+
const header=req.headers.authorization;
|
|
20
|
+
if(typeof header==='string' && header.startsWith('Bearer ')) return header.slice(7);
|
|
21
|
+
const cookie=String(req.headers.cookie || '').split(';').map(v=>v.trim()).find(v=>v.startsWith(AUTH_COOKIE+'='));
|
|
22
|
+
try{return cookie ? decodeURIComponent(cookie.slice(AUTH_COOKIE.length+1)) : null;}catch{return null;}
|
|
23
|
+
}
|
|
24
|
+
export function authCookieOptions(production=true) {
|
|
25
|
+
return {httpOnly:true,secure:production,sameSite:production?'none':'lax',path:'/',maxAge:30*24*60*60*1000};
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Node 20-compatible CommonJS bridge to the single ESM security implementation.
|
|
2
|
+
const pending=new Set();
|
|
3
|
+
let security;
|
|
4
|
+
const ready=Promise.all([import('./sanitizer.js'),import('./policy.js')]).then(([logging,policy])=>{security={...logging,...policy};});
|
|
5
|
+
function safeLog(level,...values) {
|
|
6
|
+
const operation=ready.then(()=>security.safeLog(level,...values)).catch(()=>process.stderr.write('[REDACTED: logging unavailable]\n'));
|
|
7
|
+
pending.add(operation); operation.finally(()=>pending.delete(operation));
|
|
8
|
+
}
|
|
9
|
+
async function flushLogs(){await Promise.all([...pending]);}
|
|
10
|
+
function assertSafeKnowledge(value){if(!security)throw new Error('Security initialization required; await log-bridge.ready');return security.assertSafeKnowledge(value);}
|
|
11
|
+
function spawnLogged(command,args,options={}) {
|
|
12
|
+
const result=require('node:child_process').spawnSync(command,args,{...options,stdio:'pipe',encoding:'utf8',maxBuffer:16*1024*1024});
|
|
13
|
+
if(result.stdout)safeLog('info',result.stdout);
|
|
14
|
+
if(result.stderr)safeLog('error',result.stderr);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
module.exports={ready,safeLog,flushLogs,assertSafeKnowledge,spawnLogged};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { scanSecrets } from './scanner.js';
|
|
2
|
+
|
|
3
|
+
export const CLASSIFICATIONS=Object.freeze(['PUBLIC','INTERNAL','CONFIDENTIAL','SECRET']);
|
|
4
|
+
export function securityMode(env=process.env) {
|
|
5
|
+
const mode=env.DEV_WORKFLOW_SECURITY_MODE || 'strict';
|
|
6
|
+
if(mode!=='strict') throw new Error('Only strict DEV_WORKFLOW_SECURITY_MODE is supported');
|
|
7
|
+
return mode;
|
|
8
|
+
}
|
|
9
|
+
export function classify(value) {
|
|
10
|
+
if(scanSecrets(value).length) return 'SECRET';
|
|
11
|
+
let rank=value && typeof value==='object' && (value.classification || value.sensitivity) ? 0 : 1; const seen=new WeakSet();
|
|
12
|
+
function visit(item) {
|
|
13
|
+
if(typeof item==='string' && /^[\s]*[\[{]/.test(item)) {try{visit(JSON.parse(item));}catch{} return;}
|
|
14
|
+
if(!item||typeof item!=='object'||seen.has(item)) return; seen.add(item);
|
|
15
|
+
for(const key of ['classification','sensitivity']) {
|
|
16
|
+
if(item[key]!==undefined) {
|
|
17
|
+
const index=CLASSIFICATIONS.indexOf(String(item[key]).toUpperCase());
|
|
18
|
+
if(index<0) throw new Error('Unknown knowledge classification');
|
|
19
|
+
rank=Math.max(rank,index);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for(const child of Object.values(item)) visit(child);
|
|
23
|
+
}
|
|
24
|
+
visit(value); return CLASSIFICATIONS[rank];
|
|
25
|
+
}
|
|
26
|
+
export class SecretContentError extends Error {
|
|
27
|
+
constructor(findings=[]) {
|
|
28
|
+
super('Secret content denied. Replace credential material with a SecretReference.');
|
|
29
|
+
this.code='SECRET_CONTENT_DENIED'; this.findings=findings;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function assertSafeKnowledge(value) {
|
|
33
|
+
securityMode();
|
|
34
|
+
const findings=scanSecrets(value);
|
|
35
|
+
if(findings.length || classify(value)==='SECRET') throw new SecretContentError(findings);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const PROVIDERS = new Set(['env','op','vault','aws-secrets','gcp-secret','bitwarden']);
|
|
2
|
+
|
|
3
|
+
export class SecretReference {
|
|
4
|
+
constructor(uri) {
|
|
5
|
+
if (typeof uri !== 'string' || uri.length > 512) throw new Error('Invalid SecretReference');
|
|
6
|
+
const match = /^([a-z-]+):\/\/([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*)$/.exec(uri);
|
|
7
|
+
if (!match || !PROVIDERS.has(match[1]) || (match[1] === 'env' && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(match[2])) || match[2].split('/').some(p=>p==='.'||p==='..')) {
|
|
8
|
+
throw new Error('Invalid SecretReference; use a supported provider URI without credentials');
|
|
9
|
+
}
|
|
10
|
+
this.uri = uri;
|
|
11
|
+
this.provider = match[1];
|
|
12
|
+
Object.freeze(this);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isSecretReference(value) {
|
|
17
|
+
try { new SecretReference(value); return true; } catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Metadata only. Deliberately no resolve(), fetch(), or provider credential API.
|
|
21
|
+
export const secretProviders = Object.freeze([...PROVIDERS]);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Runtime-only redaction memory. Never serialize this set or expose a resolver.
|
|
2
|
+
const material=new Set();
|
|
3
|
+
let scanValues=null;
|
|
4
|
+
export function registerRuntimeSecret(value) {
|
|
5
|
+
if(typeof value==='string' && value.length>0) {
|
|
6
|
+
material.add(value);
|
|
7
|
+
scanValues=null;
|
|
8
|
+
try{const url=new URL(value);if(url.password){material.add(url.password);material.add(decodeURIComponent(url.password));}}catch{}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function prepareRuntimeSecretScan() {
|
|
12
|
+
// Environment is inspected locally, never returned/logged/stored. This also
|
|
13
|
+
// covers opaque credential formats not recognized by provider patterns.
|
|
14
|
+
const values=[...material];
|
|
15
|
+
for(const [name,value] of Object.entries(process.env)) {
|
|
16
|
+
if(/(?:PASSWORD|PASSWD|TOKEN|SECRET|API[_-]?KEY|PRIVATE[_-]?KEY|DATABASE_URL|DEV_WORKFLOW_DB_URL$)/i.test(name) && !/(?:_ENV|_MODE|_REF)$/.test(name) && value && !/^(?:env|op|vault|aws-secrets|gcp-secret|bitwarden):\/\//.test(value)) values.push(value);
|
|
17
|
+
}
|
|
18
|
+
scanValues=[...new Set(values)].sort((a,b)=>b.length-a.length);
|
|
19
|
+
}
|
|
20
|
+
export function runtimeSecretSpans(text) {
|
|
21
|
+
const spans=[];
|
|
22
|
+
if(!scanValues)prepareRuntimeSecretScan();
|
|
23
|
+
for(const value of scanValues) if(value) {
|
|
24
|
+
let offset=0,index;
|
|
25
|
+
while((index=text.indexOf(value,offset))>=0) {
|
|
26
|
+
const before=text[index-1]||'',after=text[index+value.length]||'';
|
|
27
|
+
if(value.length>=8 || (!/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after))) spans.push({start:index,end:index+value.length,type:'known_runtime_credential',severity:'high'});
|
|
28
|
+
offset=index+value.length;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return spans;
|
|
32
|
+
}
|
|
33
|
+
export function redactRuntimeSecrets(text) {
|
|
34
|
+
let out='',cursor=0;
|
|
35
|
+
for(const span of runtimeSecretSpans(text).sort((a,b)=>a.start-b.start || b.end-a.end)) {
|
|
36
|
+
if(span.end<=cursor)continue;
|
|
37
|
+
if(span.start<cursor){cursor=span.end;continue;}
|
|
38
|
+
out+=text.slice(cursor,span.start)+'[REDACTED]';cursor=span.end;
|
|
39
|
+
}
|
|
40
|
+
out+=text.slice(cursor);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { REDACTED, redactText, isSensitiveKey, isSafeValue, isWorkflowSessionField } from './scanner.js';
|
|
2
|
+
import { redactRuntimeSecrets,prepareRuntimeSecretScan } from './runtime-secrets.js';
|
|
3
|
+
|
|
4
|
+
export function sanitize(value) {
|
|
5
|
+
prepareRuntimeSecretScan();
|
|
6
|
+
const seen=new WeakSet();
|
|
7
|
+
function clean(item,depth=0) {
|
|
8
|
+
if(depth>64) return '[OMITTED]';
|
|
9
|
+
if(typeof item==='string') {
|
|
10
|
+
const trimmed=item.trim();
|
|
11
|
+
if(trimmed.startsWith('{')||trimmed.startsWith('[')) {
|
|
12
|
+
try { return JSON.stringify(clean(JSON.parse(item),depth+1)); } catch { /* text */ }
|
|
13
|
+
}
|
|
14
|
+
return redactRuntimeSecrets(redactText(item));
|
|
15
|
+
}
|
|
16
|
+
if(typeof item==='bigint') return String(item);
|
|
17
|
+
if(typeof item==='function'||typeof item==='symbol') return '[OMITTED]';
|
|
18
|
+
if(item===null||typeof item!=='object') return item;
|
|
19
|
+
if(seen.has(item)) return '[CIRCULAR]'; seen.add(item);
|
|
20
|
+
if(item instanceof Date) return Date.prototype.toISOString.call(item);
|
|
21
|
+
if(Buffer.isBuffer(item)||ArrayBuffer.isView(item)) return '[BINARY OMITTED]';
|
|
22
|
+
if([item.classification,item.sensitivity].some(label=>label!==undefined && !['PUBLIC','INTERNAL','CONFIDENTIAL'].includes(String(label).toUpperCase()))) return {classification:'SECRET',content:REDACTED};
|
|
23
|
+
if(item instanceof Error) return {name:'Error',message:clean(item.message,depth+1)};
|
|
24
|
+
const out=Array.isArray(item)?[]:{};
|
|
25
|
+
for(const [key,descriptor] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
|
|
26
|
+
if(key==='length' && Array.isArray(item)) continue;
|
|
27
|
+
const safeKey=redactRuntimeSecrets(redactText(key));
|
|
28
|
+
const child='value' in descriptor ? descriptor.value : REDACTED;
|
|
29
|
+
Object.defineProperty(out,safeKey,{value:isSensitiveKey(key)&&!isSafeValue(child)&&!isWorkflowSessionField(key,child)?REDACTED:clean(child,depth+1),enumerable:true,writable:true,configurable:true});
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
try {return clean(value);} catch {return '[REDACTED: inspection failed]';}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const securityOutputFilter = sanitize;
|
|
37
|
+
|
|
38
|
+
export function safeLog(level,...values) {
|
|
39
|
+
const last=values.at(-1);
|
|
40
|
+
const sink=last && typeof last.write==='function' ? values.pop() : process.stderr;
|
|
41
|
+
try {sink.write(JSON.stringify(sanitize({level,values}))+'\n');} catch {sink.write('[REDACTED: logging failed]\n');}
|
|
42
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isSecretReference, SecretReference } from './references.js';
|
|
2
|
+
import { runtimeSecretSpans,prepareRuntimeSecretScan } from './runtime-secrets.js';
|
|
3
|
+
|
|
4
|
+
export const REDACTED = '[REDACTED]';
|
|
5
|
+
const sensitiveName = /(?:^|[_\-.])(?:password|passwd|pwd|secret|token|api[_-]?key|authorization|cookie|session|credential|private[_-]?key|connection[_-]?string)(?:$|[_\-.])/i;
|
|
6
|
+
export function isSensitiveKey(key) {
|
|
7
|
+
if(/^(?:secret_type|secret_provider|provider|token_count)$/i.test(key)) return false;
|
|
8
|
+
const normalized=String(key).replace(/([a-z])([A-Z])/g,'$1_$2');
|
|
9
|
+
return sensitiveName.test(normalized) || /^(?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|DATABASE_URL|DEV_WORKFLOW_DB_URL|accessToken|refreshToken|clientSecret|passwordHash|set-cookie)$/i.test(key);
|
|
10
|
+
}
|
|
11
|
+
export function isSafeValue(value) {
|
|
12
|
+
if(value && typeof value==='object' && typeof value.uri==='string' && Object.keys(value).every(k=>['uri','provider'].includes(k)) && isSecretReference(value.uri) && (!value.provider||value.provider===value.uri.split('://')[0])) return true;
|
|
13
|
+
return value instanceof SecretReference || value === '' || value == null || typeof value === 'boolean' ||
|
|
14
|
+
(typeof value === 'string' && (isSecretReference(value) || /^(?:\[REDACTED\]|\*{3,}|<[^<>\r\n]+>|\$\{[A-Z_][A-Z0-9_]*\})$/.test(value)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isWorkflowSessionField(key,value) {
|
|
18
|
+
return key==='session' && value && typeof value==='object' &&
|
|
19
|
+
['active','review','blocked','paused','completed','ended'].includes(value.state);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const patterns = [
|
|
23
|
+
['private_key', /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----|$)/g],
|
|
24
|
+
['ssh_key', /\b(?:ssh-rsa|ssh-ed25519|ecdsa-sha2-\S+)\s+[A-Za-z0-9+/=]{40,}(?:[^\r\n]*)/g],
|
|
25
|
+
['github_token', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g],
|
|
26
|
+
['aws_access_key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
|
|
27
|
+
['api_key', /\b(?:sk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}|[sr]k_(?:live|test)_[A-Za-z0-9]{16,}|AIza[A-Za-z0-9_-]{30,}|xox[baprs]-[A-Za-z0-9-]{16,}|v1\.0-[A-Za-z0-9_-]{30,})\b/g],
|
|
28
|
+
['oauth_token', /\b(?:ya29\.[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,})\b/g],
|
|
29
|
+
['jwt', /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{8,}\b/g],
|
|
30
|
+
['connection_credentials', /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:([^\s@/]+)@/gid, 1],
|
|
31
|
+
['authorization', /\b(?:authorization|proxy-authorization)\s*["']?\s*[:=]\s*["']?((?:(?:Bearer|Basic|Token)\s+)?[^\r\n,"'}]+)/gid, 1],
|
|
32
|
+
['bearer_token', /\bBearer\s+([A-Za-z0-9_.~+\/-]+=*)/gid, 1],
|
|
33
|
+
['cookie', /\b(?:set-cookie|cookie)\s*["']?\s*[:=]\s*["']?([^\r\n"'}]+)/gid, 1],
|
|
34
|
+
['sensitive_assignment', /\b([A-Za-z_][\w.-]*(?:PASSWORD|PASSWD|PWD|TOKEN|SECRET|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|DATABASE_URL|SESSION)[\w.-]*|password|passwd|pwd|token|secret|api[_-]?key|authorization|cookie|access_token|refresh_token|session(?:_id)?)\s*["']?\s*[:=]\s*(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s,;<>}]+))/gid, 'assignment'],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function entropy(value) {
|
|
38
|
+
const counts={}; for(const c of value) counts[c]=(counts[c]||0)+1;
|
|
39
|
+
return Object.values(counts).reduce((sum,n)=>sum-(n/value.length)*Math.log2(n/value.length),0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function secretSpans(text) {
|
|
43
|
+
if(isSecretReference(text)) return [];
|
|
44
|
+
const references=[...text.matchAll(/\b[a-z-]+:\/\/[^\s,"'<>]+/g)].filter(m=>isSecretReference(m[0])).map(m=>[m.index,m.index+m[0].length]);
|
|
45
|
+
const found=runtimeSecretSpans(text);
|
|
46
|
+
for (const [type,re,group] of patterns) {
|
|
47
|
+
re.lastIndex=0;
|
|
48
|
+
for(const match of text.matchAll(re)) {
|
|
49
|
+
const index=group==='assignment' ? [2,3,4].find(i=>match[i]!==undefined) : group;
|
|
50
|
+
let start=index ? match.indices[index][0] : match.index;
|
|
51
|
+
let end=index ? match.indices[index][1] : match.index+match[0].length;
|
|
52
|
+
if(references.some(([a,b])=>match.index>=a && end<=b)) continue;
|
|
53
|
+
const value=text.slice(start,end);
|
|
54
|
+
if(type==='authorization' && /^(?:Bearer|Basic|Token)\s+\[REDACTED\]$/i.test(value)) continue;
|
|
55
|
+
if(type==='sensitive_assignment' && /^(?:authorization|cookie)$/i.test(match[1])) continue;
|
|
56
|
+
if (isSafeValue(value)) continue;
|
|
57
|
+
if(type==='authorization' && /^(Bearer|Basic|Token)\s+/i.test(value)) start+=value.match(/^\S+\s+/)[0].length;
|
|
58
|
+
found.push({start,end,type,severity:type==='private_key'?'critical':'high'});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Context-only entropy: avoid flagging commit hashes, IDs, URLs and arbitrary prose.
|
|
62
|
+
for (const m of text.matchAll(/\b(?:key|credential)\s*[:=]\s*([A-Za-z0-9_+\/-]{24,})/gid)) {
|
|
63
|
+
if(!isSafeValue(m[1]) && entropy(m[1])>=3.5) found.push({start:m.indices[1][0],end:m.indices[1][1],type:'high_entropy_credential',severity:'high'});
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function redactText(text) {
|
|
69
|
+
const spans=secretSpans(text).sort((a,b)=>a.start-b.start || b.end-a.end);
|
|
70
|
+
let out='',cursor=0;
|
|
71
|
+
for(const span of spans) {
|
|
72
|
+
if(span.end<=cursor) continue;
|
|
73
|
+
if(span.start<cursor) {cursor=span.end; continue;}
|
|
74
|
+
out+=text.slice(cursor,span.start)+REDACTED; cursor=span.end;
|
|
75
|
+
}
|
|
76
|
+
return out+text.slice(cursor);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function scanSecrets(value) {
|
|
80
|
+
prepareRuntimeSecretScan();
|
|
81
|
+
const findings=[],seen=new WeakSet();
|
|
82
|
+
const add=(type,location,severity='high')=>findings.push({type,location:redactText(location),severity,sanitized_preview:REDACTED,suggested_secret_ref:'env://REQUIRED_CREDENTIAL'});
|
|
83
|
+
function visit(item,location='$',depth=0) {
|
|
84
|
+
if(depth>64) { add('inspection_limit',location); return; }
|
|
85
|
+
if(typeof item==='function' || typeof item==='symbol') {add('executable_knowledge',location);return;}
|
|
86
|
+
if(typeof item==='string') {
|
|
87
|
+
const trimmed=item.trim();
|
|
88
|
+
if(trimmed.startsWith('{')||trimmed.startsWith('[')) {
|
|
89
|
+
try {visit(JSON.parse(item),location,depth+1);return;} catch { /* scan text */ }
|
|
90
|
+
}
|
|
91
|
+
for(const span of secretSpans(item)) add(span.type,`${location}:line ${item.slice(0,span.start).split('\n').length}`,span.severity);
|
|
92
|
+
} else if(item && typeof item==='object') {
|
|
93
|
+
if(seen.has(item)) return; seen.add(item);
|
|
94
|
+
const prototype=Object.getPrototypeOf(item);
|
|
95
|
+
if(![Object.prototype,Array.prototype,Date.prototype,Error.prototype,SecretReference.prototype,null].includes(prototype)) add('unsupported_knowledge_type',location);
|
|
96
|
+
if(item instanceof Error) visit(item.message,`${location}.message`,depth+1);
|
|
97
|
+
for(const [key,descriptor] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
|
|
98
|
+
const childLocation=`${location}.${redactText(key)}`;
|
|
99
|
+
visit(key,`${location}.[key]`,depth+1);
|
|
100
|
+
if(!('value' in descriptor)) {add('uninspectable_property',childLocation);continue;}
|
|
101
|
+
const child=descriptor.value;
|
|
102
|
+
if(isSensitiveKey(key) && !isSafeValue(child) && !isWorkflowSessionField(key,child)) add('sensitive_field',childLocation);
|
|
103
|
+
visit(child,childLocation,depth+1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
visit(value); return findings;
|
|
108
|
+
}
|
package/tools/definitions.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export function getToolList() {
|
|
2
2
|
return [
|
|
3
|
+
{
|
|
4
|
+
name:'security_audit',
|
|
5
|
+
description:'Audit current project knowledge, files, runtime configuration metadata and scoped database rows. Returns sanitized findings only; does not resolve credentials or read protected file contents.',
|
|
6
|
+
inputSchema:{type:'object',properties:{},additionalProperties:false},
|
|
7
|
+
},
|
|
3
8
|
{
|
|
4
9
|
name: "start_task",
|
|
5
10
|
description:
|
|
@@ -11,6 +16,10 @@ export function getToolList() {
|
|
|
11
16
|
type: "string",
|
|
12
17
|
description: "Clear description of what you're going to code",
|
|
13
18
|
},
|
|
19
|
+
classification: {
|
|
20
|
+
type:'string',enum:['PUBLIC','INTERNAL','CONFIDENTIAL','SECRET'],
|
|
21
|
+
description:'Knowledge sensitivity. Defaults to INTERNAL. SECRET input is denied; supply a SecretReference instead.',
|
|
22
|
+
},
|
|
14
23
|
type: {
|
|
15
24
|
type: "string",
|
|
16
25
|
enum: ["feature", "bugfix", "refactor", "other"],
|
|
@@ -397,7 +406,7 @@ export function getToolList() {
|
|
|
397
406
|
{
|
|
398
407
|
name: "create_factory_task",
|
|
399
408
|
description:
|
|
400
|
-
"Create a task on the Factory board through the official write path (POST /api/factory create_task). Defaults to status 'backlog', which records the work WITHOUT starting a worker run. Only pass status 'queued' when you really want the worker to execute it - Factory rejects a queued task (409) unless 'acceptance' contains a ```run block. Use list_factory_projects first to get a real projectId.",
|
|
409
|
+
"Create a task on the Factory board through the official write path (POST /api/factory create_task). Defaults to status 'backlog', which records the work WITHOUT starting a worker run. Only pass status 'queued' when you really want the worker to execute it - Factory rejects a queued task (409) unless 'acceptance' contains a ```run block. Every task needs a 'definitionOfDone' checklist: the concrete conditions that must be true before it can be marked done. Use list_factory_projects first to get a real projectId.",
|
|
401
410
|
inputSchema: {
|
|
402
411
|
type: "object",
|
|
403
412
|
properties: {
|
|
@@ -418,6 +427,11 @@ export function getToolList() {
|
|
|
418
427
|
description:
|
|
419
428
|
"What must be true for this task to be done. 3-3000 characters. Required by Factory.",
|
|
420
429
|
},
|
|
430
|
+
definitionOfDone: {
|
|
431
|
+
type: "string",
|
|
432
|
+
description:
|
|
433
|
+
"Checklist of the concrete conditions that must be true before the task can be marked done, one per line, for example '- [ ] build lulus'. 3-3000 characters. Required by Factory, and a task cannot move to done while it is empty. Different from 'acceptance', which also carries the ```run block for the worker.",
|
|
434
|
+
},
|
|
421
435
|
priority: {
|
|
422
436
|
type: "string",
|
|
423
437
|
enum: ["low", "normal", "high"],
|
|
@@ -430,7 +444,40 @@ export function getToolList() {
|
|
|
430
444
|
"Defaults to backlog. 'queued' also creates a worker run and requires a ```run block in acceptance.",
|
|
431
445
|
},
|
|
432
446
|
},
|
|
433
|
-
required: ["projectId", "title", "acceptance"],
|
|
447
|
+
required: ["projectId", "title", "acceptance", "definitionOfDone"],
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "update_factory_task",
|
|
452
|
+
description:
|
|
453
|
+
"Edit the details of an existing Factory task (title, description, acceptance, definitionOfDone). Use it to fill the definition of done of a task created before Factory required it, because a task cannot move to 'done' while that checklist is empty. It reads the task's current version and lets Factory reject a stale edit with 409 instead of overwriting someone else's change. Supply at least one field.",
|
|
454
|
+
inputSchema: {
|
|
455
|
+
type: "object",
|
|
456
|
+
properties: {
|
|
457
|
+
recordId: {
|
|
458
|
+
type: "string",
|
|
459
|
+
description: "Factory task id (UUID) from list_factory_tasks.",
|
|
460
|
+
},
|
|
461
|
+
title: {
|
|
462
|
+
type: "string",
|
|
463
|
+
description: "New title, 3-140 characters. Omit to keep the current one.",
|
|
464
|
+
},
|
|
465
|
+
description: {
|
|
466
|
+
type: "string",
|
|
467
|
+
description: "New context text, max 4000 characters. Omit to keep the current one.",
|
|
468
|
+
},
|
|
469
|
+
acceptance: {
|
|
470
|
+
type: "string",
|
|
471
|
+
description:
|
|
472
|
+
"New acceptance text, 3-3000 characters. This is also where the ```run block for the worker lives, so keep it intact unless you mean to change what the runner executes.",
|
|
473
|
+
},
|
|
474
|
+
definitionOfDone: {
|
|
475
|
+
type: "string",
|
|
476
|
+
description:
|
|
477
|
+
"New checklist of conditions that must be true before the task can be marked done, 3-3000 characters, one condition per line, for example '- [ ] build lulus'.",
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
required: ["recordId"],
|
|
434
481
|
},
|
|
435
482
|
},
|
|
436
483
|
{
|
|
@@ -476,7 +523,8 @@ export function getToolList() {
|
|
|
476
523
|
},
|
|
477
524
|
reason: {
|
|
478
525
|
type: "string",
|
|
479
|
-
description:
|
|
526
|
+
description:
|
|
527
|
+
"Why. Required when status is 'blocked'. Also required by Factory (min 3 characters) when status is 'done' or 'cancelled' - it becomes the closure note on the board. Otherwise used as the session summary.",
|
|
480
528
|
},
|
|
481
529
|
nextAction: {
|
|
482
530
|
type: "string",
|
|
@@ -62,13 +62,9 @@ export async function handleCommitAndPush(args, context) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
} else {
|
|
70
|
-
await exec(`git commit -m ${utils.shellEscape(generatedSummary)}`);
|
|
71
|
-
}
|
|
65
|
+
const commitArguments=['commit','-m',generatedSummary];
|
|
66
|
+
if(commitBody)commitArguments.push('-m',commitBody);
|
|
67
|
+
await exec('git commit',{executable:'git',arguments:commitArguments});
|
|
72
68
|
} catch (error) {
|
|
73
69
|
const output = error.stderr || error.stdout || error.message || "Unknown error";
|
|
74
70
|
if (output.includes("nothing to commit")) {
|