flowcollab 0.3.8 → 0.3.10

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/bin/_client.mjs CHANGED
@@ -123,6 +123,19 @@ export function die(msg, code = 1) {
123
123
  process.exit(code);
124
124
  }
125
125
 
126
+ // S14: strip ANSI / control chars and @to: tokens from server-supplied text (task
127
+ // titles, decision titles) before printing — they reach another agent's trusted stdout.
128
+ export function sanitizeText(s, maxLen = 120) {
129
+ if (!s || typeof s !== 'string') return '';
130
+ return s
131
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
132
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
133
+ .replace(/@to:\S+/g, '[mention-stripped]')
134
+ .replace(/\r?\n|\r/g, ' ')
135
+ .trim()
136
+ .slice(0, maxLen);
137
+ }
138
+
126
139
  export function arg(name, fallback) {
127
140
  const argv = process.argv.slice(2);
128
141
  for (let i = 0; i < argv.length; i++) {
@@ -157,10 +170,10 @@ export async function resolveTaskId(input) {
157
170
  const decRes = await flowFetch('/api/flow/decisions');
158
171
  const taskMatches = allTasks.filter(t => typeof t.id === 'string' && t.id.startsWith(stripped));
159
172
  if (taskMatches.length === 1) return taskMatches[0].id;
160
- if (taskMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${taskMatches.length} tasks:\n${taskMatches.map(t => ` #${t.id.slice(0, 6)} ${t.title}`).join('\n')}`);
173
+ if (taskMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${taskMatches.length} tasks:\n${taskMatches.map(t => ` #${t.id.slice(0, 6)} ${sanitizeText(t.title)}`).join('\n')}`);
161
174
  const decisions = decRes.decisions || [];
162
175
  const decMatches = decisions.filter(d => d.id.startsWith(stripped));
163
176
  if (decMatches.length === 0) throw new Error(`No task or decision found with ID prefix "${stripped}". Run \`flow-pull\` to see your current tasks.`);
164
- if (decMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${decMatches.length} decisions:\n${decMatches.map(d => ` #${d.id.slice(0, 6)} ${d.proposal_title || ''}`).join('\n')}`);
177
+ if (decMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${decMatches.length} decisions:\n${decMatches.map(d => ` #${d.id.slice(0, 6)} ${sanitizeText(d.proposal_title || '')}`).join('\n')}`);
165
178
  return decMatches[0].id;
166
179
  }
package/bin/comment.mjs CHANGED
@@ -28,8 +28,15 @@ async function main() {
28
28
  if (!raw || !body) die('Usage: flow-comment <task_id> "<body>" [--commit[=<sha>]]');
29
29
 
30
30
  let commit;
31
- if (process.argv.includes('--commit') || process.argv.some(a => a.startsWith('--commit='))) {
32
- commit = arg('commit') || headSha();
31
+ const hasEq = process.argv.some(a => a.startsWith('--commit='));
32
+ if (process.argv.includes('--commit') || hasEq) {
33
+ // B14: --commit= with an empty value is a typo, not "use HEAD". Bare --commit means HEAD.
34
+ if (hasEq) {
35
+ commit = arg('commit');
36
+ if (!commit) die('--commit= was given with no value. Use --commit (HEAD) or --commit=<sha>.');
37
+ } else {
38
+ commit = headSha();
39
+ }
33
40
  if (!/^[0-9a-f]{7,64}$/i.test(commit)) die(`Invalid commit SHA: ${commit}`);
34
41
  }
35
42
 
package/bin/decisions.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  --timeout=<sec> give up after N seconds, default 1800 (30 min); 0 = forever
20
20
  */
21
21
 
22
- import { flowFetch, resolveTaskId, arg, positional, die } from './_client.mjs';
22
+ import { flowFetch, resolveTaskId, arg, positional, die, sanitizeText } from './_client.mjs';
23
23
 
24
24
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
25
25
 
@@ -36,10 +36,10 @@ async function list() {
36
36
  }
37
37
  process.stdout.write(`${decisions.length} pending decision${decisions.length === 1 ? '' : 's'}:\n\n`);
38
38
  for (const d of decisions) {
39
- process.stdout.write(`#${d.id.slice(0, 6)} ${d.proposal_title}\n`);
39
+ process.stdout.write(`#${d.id.slice(0, 6)} ${sanitizeText(d.proposal_title)}\n`);
40
40
  if (d.parent_task_id) {
41
41
  const p = taskById.get(d.parent_task_id);
42
- const ref = p ? `#${p.id.slice(0, 6)} — "${p.title}"` : `#${d.parent_task_id.slice(0, 6)}`;
42
+ const ref = p ? `#${p.id.slice(0, 6)} — "${sanitizeText(p.title)}"` : `#${d.parent_task_id.slice(0, 6)}`;
43
43
  process.stdout.write(` Parent: ${ref}\n`);
44
44
  }
45
45
  process.stdout.write(` Confidence: ${d.confidence} | Suggest: ${d.suggested_assignee_id || '-'} / ${d.suggested_priority || '-'} / ${d.suggested_type || '-'}\n`);
@@ -50,7 +50,14 @@ async function list() {
50
50
 
51
51
  async function watch(rawId) {
52
52
  const interval = Math.max(1, Number(arg('interval')) || 5);
53
- const timeout = arg('timeout') !== undefined ? Math.max(0, Number(arg('timeout'))) : 1800;
53
+ // B9: arg() returns '' (not undefined) for a valueless --timeout; treat empty/invalid
54
+ // as the default rather than Number('')===0 → "poll forever". --timeout=0 is still forever.
55
+ const rawTimeout = arg('timeout');
56
+ let timeout = 1800;
57
+ if (rawTimeout !== undefined && rawTimeout !== '') {
58
+ const n = Number(rawTimeout);
59
+ timeout = Number.isFinite(n) ? Math.max(0, n) : 1800;
60
+ }
54
61
 
55
62
  // Resolve the prefix to a full UUID once, up front, while the decision is
56
63
  // still pending (resolveTaskId can only resolve prefixes via the pending
package/bin/scan.mjs CHANGED
@@ -19,16 +19,23 @@ import { join, extname, relative } from 'node:path';
19
19
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '__pycache__', 'vendor', '.cache', 'out']);
20
20
  const CODE_EXTS = new Set(['.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rb', '.java', '.sh', '.rs', '.c', '.cpp', '.h', '.cs', '.php', '.vue', '.svelte']);
21
21
 
22
- function walkFiles(dir, base) {
22
+ // S13: the security scan also looks at config/secret files (where keys actually live).
23
+ const SEC_EXTS = new Set([...CODE_EXTS, '.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', '.properties', '.xml', '.pem', '.key']);
24
+ function secMatch(name) {
25
+ return SEC_EXTS.has(extname(name)) || name.startsWith('.env') || name === '.npmrc' || name === '.netrc';
26
+ }
27
+
28
+ function walkFiles(dir, match, base) {
23
29
  base = base || dir;
30
+ match = match || ((name) => CODE_EXTS.has(extname(name)));
24
31
  const out = [];
25
32
  let entries;
26
33
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
27
34
  for (const e of entries) {
28
35
  if (SKIP_DIRS.has(e.name)) continue;
29
36
  const full = join(dir, e.name);
30
- if (e.isDirectory()) { out.push(...walkFiles(full, base)); continue; }
31
- if (CODE_EXTS.has(extname(e.name))) out.push(full);
37
+ if (e.isDirectory()) { out.push(...walkFiles(full, match, base)); continue; }
38
+ if (match(e.name)) out.push(full);
32
39
  }
33
40
  return out;
34
41
  }
@@ -140,6 +147,10 @@ async function scanPRs(repo) {
140
147
  // ── Security scan ──────────────────────────────────────────────────────────────
141
148
  const SEC_PATTERNS = [
142
149
  { re: /(['"`])(sk_live_|AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z]{36}|glpat-[0-9A-Za-z]{20}|xox[bpas]-[0-9A-Za-z-]{10,})[0-9A-Za-z-]*\1/i, label: 'Hardcoded secret (live key pattern)' },
150
+ // S13: same prefixes but UNQUOTED (e.g. KEY=value in .env), variable length
151
+ { re: /\b(sk_(?:live|test)_[0-9A-Za-z]{10,}|whsec_[0-9A-Za-z]{10,}|ghp_[0-9A-Za-z]{20,}|github_pat_[0-9A-Za-z_]{20,}|glpat-[0-9A-Za-z_-]{15,}|xox[bpas]-[0-9A-Za-z-]{10,}|AKIA[0-9A-Z]{16})\b/, label: 'Secret token (unquoted key prefix)' },
152
+ // S13: env-style assignment of a secret-named variable (KEY=value, no quotes needed)
153
+ { re: /(?:^|\b)[A-Za-z][A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|ACCESS_KEY)\s*=\s*\S{6,}/i, label: 'Secret in env-style assignment' },
143
154
  { re: /\beval\s*\(/, label: 'eval() usage (code injection risk)' },
144
155
  { re: /dangerouslySetInnerHTML/, label: 'dangerouslySetInnerHTML (XSS risk)' },
145
156
  { re: /(?:child_process.*\.(?:exec|execSync|spawn|spawnSync)\s*\(|execSync\s*\(|require\(['"]child_process['"]\))/, label: 'exec/spawn call (command injection risk)' },
@@ -150,7 +161,8 @@ const SEC_PATTERNS = [
150
161
  { re: /(?:password|secret|apikey|api_key|access_token)\s*(?:=|:)\s*['"`][^'"`]{6,}/i, label: 'Possible hardcoded credential' },
151
162
  ];
152
163
 
153
- function scanSecurity(files, dir) {
164
+ function scanSecurity(dir) {
165
+ const files = walkFiles(dir, secMatch); // S13: include config/.env/secret files
154
166
  const out = [];
155
167
  for (const { re, label } of SEC_PATTERNS) {
156
168
  const hits = grepLines(files, re);
@@ -194,7 +206,7 @@ async function main() {
194
206
  if (repo) process.stdout.write(` repo: ${repo}\n`);
195
207
  process.stdout.write('\n');
196
208
 
197
- const needFiles = all || doTodos || doSecurity;
209
+ const needFiles = all || doTodos; // security walks its own (broader) file set
198
210
  const files = needFiles ? walkFiles(dir) : [];
199
211
  if (needFiles) process.stdout.write(` scanning ${files.length} source files...\n`);
200
212
 
@@ -230,7 +242,7 @@ async function main() {
230
242
 
231
243
  if (all || doSecurity) {
232
244
  process.stdout.write(' [security] scanning patterns...\n');
233
- const s = scanSecurity(files, dir);
245
+ const s = scanSecurity(dir);
234
246
  if (doSecurity) { printSection('Security concerns', s); return; }
235
247
  if (all && s.length) printSection('Security concerns', s);
236
248
  else if (all) process.stdout.write('\n[security] No common patterns found.\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowcollab",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [