fdeops 3.9.14 → 3.9.15

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 (2) hide show
  1. package/bin/fde.js +82 -20
  2. package/package.json +1 -1
package/bin/fde.js CHANGED
@@ -107,9 +107,13 @@ function resolveEngagement(opts = {}) {
107
107
  // bind - env, registry, pointer, or in-repo .fde. Basename matching is
108
108
  // read-only convenience; writing on a folder-name guess contaminates clients.
109
109
  const forWrite = !!opts.forWrite
110
+ const accept = (p) => acceptEngagementPath(p, { forWrite })
110
111
  // 1) explicit env (back-compat: accept old FDEOS_ENGAGEMENT too)
111
112
  const env = (process.env.FDEOPS_ENGAGEMENT || process.env.FDEOS_ENGAGEMENT || '').replace(/^~/, HOME).trim()
112
- if (env && fs.existsSync(env)) return env
113
+ if (env) {
114
+ const ok = accept(env)
115
+ if (ok) return ok
116
+ }
113
117
  // 2) workspace registry binding (written by resume --init). Match the cwd OR
114
118
  // any ancestor of it - FDEs run commands from src/, packages/api/, etc., not
115
119
  // just the repo root where they bound. Nearest (deepest) registered ancestor
@@ -122,8 +126,8 @@ function resolveEngagement(opts = {}) {
122
126
  .filter(r => cwd === r.workspace || cwd.startsWith(r.workspace + path.sep))
123
127
  .sort((a, b) => b.workspace.length - a.workspace.length)[0]
124
128
  if (reg) {
125
- const p = path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde')
126
- if (fs.existsSync(p)) return p
129
+ const ok = accept(path.join(ENGAGEMENTS_ROOT, reg.slug, '.fde'))
130
+ if (ok) return ok
127
131
  }
128
132
  // 3) global pointer file (back-compat: try old FDEOS-CLAUDE.md too)
129
133
  for (const ptrName of ['FDEOPS-CLAUDE.md', 'FDEOS-CLAUDE.md']) {
@@ -131,8 +135,8 @@ function resolveEngagement(opts = {}) {
131
135
  const ptr = fs.readFileSync(path.join(HOME, '.claude', ptrName), 'utf8')
132
136
  const m = ptr.match(/^(?:FDEOPS|FDEOS)_ENGAGEMENT=(.+)$/m)
133
137
  if (m) {
134
- const p = m[1].trim().replace(/^~/, HOME)
135
- if (fs.existsSync(p)) return p
138
+ const ok = accept(m[1].trim().replace(/^~/, HOME))
139
+ if (ok) return ok
136
140
  }
137
141
  } catch (_) {}
138
142
  }
@@ -150,14 +154,37 @@ function resolveEngagement(opts = {}) {
150
154
  )
151
155
  return null
152
156
  }
153
- process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
154
- return guess
157
+ const ok = accept(guess)
158
+ if (ok) {
159
+ process.stderr.write(`⚠ resolved engagement by directory name ("${slugGuess}"), not a saved binding (read-only). If this is the right client, run \`fde resume --init ${slugGuess}\` here to bind it before logging or debriefing.\n`)
160
+ return ok
161
+ }
155
162
  }
156
163
  // 5) in-repo .fde (engagement-approved only)
157
- if (fs.existsSync(path.join(cwd, '.fde'))) return path.join(cwd, '.fde')
164
+ const inRepo = accept(path.join(cwd, '.fde'))
165
+ if (inRepo) return inRepo
158
166
  return null
159
167
  }
160
168
 
169
+ // Engagement memory must be a directory. A file named .fde used to yield a
170
+ // healthy-looking green TRIAGE then raw ENOTDIR on write - refuse loudly.
171
+ function acceptEngagementPath(p, opts = {}) {
172
+ if (!p || !fs.existsSync(p)) return null
173
+ try {
174
+ const st = fs.statSync(p)
175
+ if (st.isDirectory()) return p
176
+ const msg =
177
+ `engagement path is not a directory (memory missing/broken): ${p}\n` +
178
+ ' repair: remove that file, then re-run: fde resume --init <name>'
179
+ console.error(msg)
180
+ if (opts.forWrite) process.exit(1)
181
+ return null
182
+ } catch (e) {
183
+ if (opts.forWrite) failFs(e, 'open', p)
184
+ return null
185
+ }
186
+ }
187
+
161
188
  function templatesDir() {
162
189
  for (const c of [path.join(__dirname, '..', 'templates', '.fde'), path.join(__dirname, 'templates', '.fde')]) {
163
190
  if (fs.existsSync(c)) return c
@@ -172,11 +199,19 @@ function readEng(eng, f) {
172
199
  }
173
200
 
174
201
  // Redact private notes and template hints from every model-facing read.
202
+ // Also strip terminal control chars so poisoned memory cannot smuggle ANSI
203
+ // into triage/prep/status (C0/C1 except tab/LF/CR).
204
+ function stripControlChars(s) {
205
+ return String(s || '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
206
+ }
207
+
175
208
  function stripPrivate(md) {
176
- return md
177
- .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
178
- .replace(/<private>[\s\S]*$/i, '(private - redacted)')
179
- .replace(/<!--[\s\S]*?-->/g, '')
209
+ return stripControlChars(
210
+ String(md || '')
211
+ .replace(/<private>[\s\S]*?<\/private>/gi, '(private - redacted)')
212
+ .replace(/<private>[\s\S]*$/i, '(private - redacted)')
213
+ .replace(/<!--[\s\S]*?-->/g, '')
214
+ )
180
215
  }
181
216
 
182
217
  // Read + redact in one step - the default way dashboard code should ever touch
@@ -239,6 +274,9 @@ function formatFsError(err, action, target) {
239
274
  const code = err && err.code
240
275
  const where = path.basename(String(target || '')) || String(target || 'path')
241
276
  if (code === 'ENOSPC') return `cannot ${action} ${where} - disk full`
277
+ if (code === 'ENOTDIR') {
278
+ return `cannot ${action} ${where} - engagement path is not a directory (memory missing/broken); remove the file and re-run fde resume --init`
279
+ }
242
280
  if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
243
281
  return `cannot ${action} ${where} - permission denied (read-only or locked down)`
244
282
  }
@@ -392,7 +430,7 @@ function datedEntry(eng, date, text, signal) {
392
430
  const bits = [`- [${date}]`]
393
431
  if (who) bits.push(`[${who}]`)
394
432
  if (signal) bits.push(`[signal:${signal}]`)
395
- bits.push(text)
433
+ bits.push(stripControlChars(text))
396
434
  return bits.join(' ')
397
435
  }
398
436
 
@@ -1136,7 +1174,7 @@ function smartProposeText(input) {
1136
1174
 
1137
1175
  function setNextAction(eng, text) {
1138
1176
  ensureMemoryGit(eng)
1139
- const bullet = `- ${String(text).replace(/^[-*]\s+/, '').trim()}`
1177
+ const bullet = `- ${stripControlChars(String(text).replace(/^[-*]\s+/, '').trim())}`
1140
1178
  const p = path.join(eng, 'context.md')
1141
1179
  let md = readEng(eng, 'context.md')
1142
1180
  if (!md) md = '# Engagement context\n\n'
@@ -1148,6 +1186,24 @@ function setNextAction(eng, text) {
1148
1186
  withFileLock(p, () => { atomicWriteFile(p, md.endsWith('\n') ? md : md + '\n') })
1149
1187
  }
1150
1188
 
1189
+ function looksLikeBinaryNoise(text) {
1190
+ const s = String(text || '')
1191
+ if (!s) return false
1192
+ if (s.includes('\0')) return true
1193
+ const sample = s.slice(0, 8192)
1194
+ let ctrl = 0
1195
+ let replacement = 0
1196
+ for (let i = 0; i < sample.length; i++) {
1197
+ const c = sample.charCodeAt(i)
1198
+ if (c === 0xfffd) replacement++
1199
+ if (c === 9 || c === 10 || c === 13) continue
1200
+ if (c < 32 || (c >= 0x7f && c <= 0x9f)) ctrl++
1201
+ }
1202
+ if (!sample.length) return false
1203
+ // Mostly-control or high U+FFFD density = urandom / binary mistyped as text.
1204
+ return (ctrl / sample.length) > 0.05 || (replacement / sample.length) > 0.1
1205
+ }
1206
+
1151
1207
  function readDebriefInput(args) {
1152
1208
  let input = ''
1153
1209
  if (args[0]) {
@@ -1160,19 +1216,25 @@ function readDebriefInput(args) {
1160
1216
  }
1161
1217
  let buf
1162
1218
  try { buf = fs.readFileSync(notesPath) } catch (_) { console.error(`cannot read ${args[0]}`); process.exit(1) }
1163
- if (buf.includes(0)) {
1164
- console.error(`debrief refused: ${args[0]} looks binary (null bytes). Paste text notes only.`)
1219
+ if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1220
+ console.error(`debrief refused: ${args[0]} looks binary or mostly non-printable. Paste text notes only.`)
1165
1221
  process.exit(1)
1166
1222
  }
1167
1223
  input = buf.toString('utf8')
1168
1224
  } else {
1169
- try { input = fs.readFileSync(0, 'utf8') } catch (_) {}
1170
- if (Buffer.byteLength(input, 'utf8') > DEBRIEF_MAX_BYTES) {
1225
+ let buf
1226
+ try { buf = fs.readFileSync(0) } catch (_) { buf = Buffer.alloc(0) }
1227
+ if (Buffer.byteLength(buf) > DEBRIEF_MAX_BYTES) {
1171
1228
  console.error(`debrief refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1172
1229
  process.exit(1)
1173
1230
  }
1231
+ if (buf.includes(0) || looksLikeBinaryNoise(buf.toString('utf8'))) {
1232
+ console.error('debrief refused: stdin looks binary or mostly non-printable. Paste text notes only.')
1233
+ process.exit(1)
1234
+ }
1235
+ input = buf.toString('utf8')
1174
1236
  }
1175
- return input
1237
+ return stripControlChars(input)
1176
1238
  }
1177
1239
 
1178
1240
  function previewLine(text, max = 240) {
@@ -1250,7 +1312,7 @@ function cmdDebrief(args) {
1250
1312
 
1251
1313
  let input = ''
1252
1314
  if (apply && !smart && !args[0]) {
1253
- try { input = fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8') } catch (_) {
1315
+ try { input = stripControlChars(fs.readFileSync(path.join(eng, DEBRIEF_PROPOSE), 'utf8')) } catch (_) {
1254
1316
  console.error('nothing to apply - run: fde debrief --smart <notes.md> then fde debrief --apply')
1255
1317
  process.exit(1)
1256
1318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.14",
3
+ "version": "3.9.15",
4
4
  "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",