flowcollab 0.3.7 → 0.3.9

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
@@ -1,17 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  /* flow:comment — append a free-form comment to a task's timeline.
3
- Usage: flow-comment <task_id> "<body>"
3
+
4
+ Usage:
5
+ flow-comment <task_id> "<body>"
6
+ flow-comment <task_id> "<body>" --commit=<sha> link a specific commit
7
+ flow-comment <task_id> "<body>" --commit link the current HEAD
8
+
9
+ When a commit is linked, the comment renders a clickable "View changes" diff
10
+ in the board timeline (Theme 34). The commit must be pushed to GitHub to be
11
+ viewable.
4
12
  */
5
13
 
6
- import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
14
+ import { execFileSync } from 'child_process';
15
+ import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
16
+
17
+ function headSha() {
18
+ try {
19
+ return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
20
+ } catch {
21
+ die('--commit with no value needs a git repo (could not run `git rev-parse HEAD`). Pass --commit=<sha> instead.');
22
+ }
23
+ }
7
24
 
8
25
  async function main() {
9
26
  const raw = positional(0);
10
27
  const body = positional(1);
11
- if (!raw || !body) die('Usage: flow-comment <task_id> "<body>"');
28
+ if (!raw || !body) die('Usage: flow-comment <task_id> "<body>" [--commit[=<sha>]]');
29
+
30
+ let commit;
31
+ if (process.argv.includes('--commit') || process.argv.some(a => a.startsWith('--commit='))) {
32
+ commit = arg('commit') || headSha();
33
+ if (!/^[0-9a-f]{7,64}$/i.test(commit)) die(`Invalid commit SHA: ${commit}`);
34
+ }
35
+
12
36
  const id = await resolveTaskId(raw);
13
- await flowFetch('/api/flow/comment', { method: 'POST', body: { task_id: id, body } });
14
- process.stdout.write('Comment posted.\n');
37
+ await flowFetch('/api/flow/comment', { method: 'POST', body: { task_id: id, body, ...(commit ? { commit } : {}) } });
38
+ process.stdout.write(commit ? `Comment posted (linked commit ${commit.slice(0, 7)}).\n` : 'Comment posted.\n');
15
39
  }
16
40
 
17
41
  main().catch(e => die(e.message || e));
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`);
package/bin/flow.mjs CHANGED
@@ -26,7 +26,7 @@ const CMDS = [
26
26
  ['login', 'login.mjs', 'Authorize the CLI via browser (device flow)'],
27
27
  ['pull', 'pull.mjs', 'Fetch board snapshot + @mentions (start of day)'],
28
28
  ['claim', 'claim.mjs', 'Claim a task and mark it in-progress'],
29
- ['comment', 'comment.mjs', 'Post a comment on a task'],
29
+ ['comment', 'comment.mjs', 'Post a comment (--commit[=<sha>] links a clickable diff)'],
30
30
  ['close', 'close.mjs', 'Mark a task done with a summary'],
31
31
  ['create', 'create.mjs', 'Create a new task'],
32
32
  ['edit', 'edit.mjs', 'Update task fields (title, priority, area, due…)'],
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.7",
3
+ "version": "0.3.9",
4
4
  "description": "Multi-Claude coordination layer — shared task board + CLI for teams running Claude Code",
5
5
  "type": "module",
6
6
  "files": [