glassbox-trace 0.11.0 → 0.12.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/src/fence.mjs CHANGED
@@ -15,6 +15,9 @@ import path from 'node:path';
15
15
  import crypto from 'node:crypto';
16
16
  import { createRequire } from 'node:module';
17
17
  import { findSessions, subagentFiles, resolveTarget, claudeHome } from './cli.mjs';
18
+ import { constants as BUF } from 'node:buffer';
19
+ import { streamBytes } from './jsonl.mjs';
20
+ import { readTextFile } from './textfile.mjs';
18
21
  const require = createRequire(import.meta.url);
19
22
  const core = require('./trace-core.js');
20
23
 
@@ -64,10 +67,24 @@ export function mask(v) { const s = String(v); if (/-----BEGIN/.test(s)) return
64
67
  // `re` must have the g flag; `value(m)` picks the secret out of the match (default: the whole match).
65
68
  // In raw JSONL a newline inside a string is the two characters \n, so a key at the start of a line sits right after
66
69
  // an "n"; B is the word boundary that also accepts an escaped \n, \r or \t before the match.
70
+ // A PEM / PGP body: header lines (Proc-Type:, DEK-Info:, Comment:, Version:) and line breaks — real, or the escaped
71
+ // \n / \r\n of a JSON string — removed, what's left is base64 and at least 64 characters long.
72
+ // Keys arrive with line numbers too: the Read tool's `12\t…` and `cat -n`'s padded ` 12\t…` (a literal tab, or
73
+ // \t once JSON-escaped, or \\t twice), and a key that sat inside a JSON file (service-account.json) carries its
74
+ // newlines as \\n — so any run of backslashes before r / n is a line break here (0.12 review).
75
+ export function isKeyBlock(v) {
76
+ const body = String(v).replace(/^-----BEGIN[^-]*-----/, '').replace(/-----END[^-]*-----$/, '');
77
+ const lines = body.split(/\r?\n|(?:\\+[rn])+/).map((l) => l.replace(/^\s*\d+(?:\\+t|\t|\u2192)/, '').trim().replace(/^"|",?$/g, '')).filter((l) => l && !/^[A-Za-z][\w-]*:\s/.test(l));
78
+ const b64 = lines.join('');
79
+ return b64.length >= 64 && /^[A-Za-z0-9+/=]+$/.test(b64);
80
+ }
67
81
  const B = String.raw`(?:(?<![A-Za-z0-9_])|(?<=\\[nrt]))`;
68
82
  const R = (src, flags = 'g') => new RegExp(B + src, flags);
69
83
  export const DETECTORS = [
70
- { id: 'private-key', severity: 'error', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, what: 'a private key' },
84
+ // The body is bounded (16,384 chars; an RSA-4096 PEM is ~3.3 KB) and can't contain another marker, and it must be
85
+ // a key: after the header lines and line breaks (real or JSON-escaped), base64 only. 0.11 matched from a BEGIN in
86
+ // one tool result to an END megabytes later. DESIGN.md §21.
87
+ { id: 'private-key', severity: 'error', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----(?:(?!-----)[\s\S]){1,16384}?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/g, ok: isKeyBlock, what: 'a private key' },
71
88
  { id: 'aws-access-key', severity: 'error', re: R(String.raw`(?:AKIA|ASIA)[0-9A-Z]{16}\b`), what: 'an AWS access key id' },
72
89
  { id: 'github-token', severity: 'error', re: R(String.raw`(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b`), what: 'a GitHub token' },
73
90
  { id: 'anthropic-key', severity: 'error', re: R(String.raw`sk-ant-[A-Za-z0-9_-]{20,}`), what: 'an Anthropic API key' },
@@ -82,6 +99,43 @@ export const DETECTORS = [
82
99
  { id: 'jwt', severity: 'warn', re: R(String.raw`eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`), what: 'a JWT' },
83
100
  { id: 'generic-secret', severity: 'warn', re: R(String.raw`(?:(?:api[_-]?key|secret[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|token|passwd|password|pwd)\b(?:\\?["']?\s*[:=]\s*\\?["']?)|bearer\s+)([A-Za-z0-9_\-/+=.]{16,})`, 'gi'), value: (m) => m[1], ok: (v) => !PLACEHOLDER.test(v) && /[0-9]/.test(v) && /[A-Za-z]/.test(v) && entropy(v) >= 3.5, what: 'a secret named by its context' },
84
101
  ];
102
+
103
+ // Export-control and classification markings: opt-in with --markings, because a conversation *about* ITAR is not a
104
+ // spill. Each pattern is the marking as it is printed on a document (banner, legend, distribution statement), not
105
+ // the word in prose. A marking is not a secret, so the preview is the marking itself; and it is never shredded:
106
+ // removing the marking would leave the controlled text in the transcript, unmarked.
107
+ // Each starts with B (defined above): in a transcript a marking at the start of a line follows a JSON "\n",
108
+ // where \b would see the n as a word character and miss it.
109
+ export const MARKINGS = [
110
+ { id: 'classification-banner', severity: 'error', re: R(String.raw`(?:(?:TOP SECRET|SECRET|CONFIDENTIAL)\/\/[A-Z][A-Z0-9/ ,-]{1,40}|NOFORN\b)`), what: 'a classification banner' },
111
+ { id: 'itar-marking', severity: 'error', re: R(String.raw`ITAR[- ](?:CONTROLLED|RESTRICTED)\b`), what: 'an ITAR marking' },
112
+ { id: 'itar-marking', severity: 'error', re: /subject to the International Traffic in Arms Regulations|export is restricted by the Arms Export Control Act/gi, what: 'the ITAR / AECA export legend' },
113
+ { id: 'cui-marking', severity: 'error', re: R(String.raw`(?:CUI\/\/[A-Z][A-Z0-9/-]*|CONTROLLED UNCLASSIFIED INFORMATION\b)`), what: 'a CUI marking' },
114
+ { id: 'distribution-statement', severity: 'error', re: R(String.raw`DISTRIBUTION STATEMENT [B-F]\b`), what: 'a DoD distribution statement (B–F)' },
115
+ { id: 'export-controlled', severity: 'warn', re: R(String.raw`EXPORT[- ]CONTROLLED\b`), what: 'an export-controlled marking' },
116
+ { id: 'export-controlled', severity: 'warn', re: /subject to the Export Administration Regulations/gi, what: 'the EAR export legend' },
117
+ { id: 'eccn', severity: 'warn', re: R(String.raw`ECCN[:\s#]{0,3}[0-9][A-E][0-9]{3}(?:\.[a-z0-9.]{1,8})?\b`, 'gi'), what: 'an export classification number' },
118
+ { id: 'ear99', severity: 'info', re: R(String.raw`EAR99\b`), what: 'an EAR99 classification' },
119
+ ];
120
+ export const MARKING_ADVICE = 'Text marked as controlled reached the model and this disk. Treat it as a possible spill and follow your export-compliance procedure. --shred leaves markings alone: removing one would leave the controlled text unmarked.';
121
+ export const isMarking = (rule, list = MARKINGS) => list.some((d) => d.id === rule);
122
+
123
+ // A company's own markings: --markings-file FILE, a JSON list of { id, pattern, flags?, severity? }.
124
+ export function loadMarkings(file) {
125
+ let json; try { json = JSON.parse(readTextFile(String(file))); } catch (e) { throw new Error(`--markings-file ${file}: ${e.code === 'ENOENT' ? 'no such file' : 'not valid JSON'}`); }
126
+ if (!Array.isArray(json)) throw new Error(`--markings-file ${file}: expected a list of { "id": …, "pattern": … }`);
127
+ const taken = new Set([...DETECTORS.map((d) => d.id), ...MARKINGS.map((d) => d.id), 'credential-file-read']);
128
+ return json.map((m, i) => {
129
+ if (!m || typeof m.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,40}$/.test(m.id)) throw new Error(`--markings-file ${file}: entry ${i + 1} needs an "id" of lower-case letters, digits and dashes`);
130
+ if (taken.has(m.id)) throw new Error(`--markings-file ${file}: "${m.id}" is already a built-in rule; pick another id`);
131
+ if (typeof m.pattern !== 'string' || !m.pattern) throw new Error(`--markings-file ${file}: "${m.id}" needs a "pattern"`);
132
+ const sev = m.severity || 'warn'; if (!(sev in SEV)) throw new Error(`--markings-file ${file}: "${m.id}" severity must be error, warn or info`);
133
+ let re; try { re = new RegExp(m.pattern, String(m.flags || '').replace(/g/g, '') + 'g'); } catch (e) { throw new Error(`--markings-file ${file}: "${m.id}" pattern is not a valid regular expression (${e.message})`); }
134
+ taken.add(m.id);
135
+ return { id: m.id, severity: sev, re, what: m.what || `the "${m.id}" marking` };
136
+ });
137
+ }
138
+
85
139
  export const ADVICE = {
86
140
  'private-key': 'Generate a new key pair and remove the old public key everywhere it is authorised; then shred.',
87
141
  'aws-access-key': 'Deactivate the key in IAM and issue a new one; check CloudTrail for use; then shred.',
@@ -139,21 +193,25 @@ export function scanText(text, opts = {}) {
139
193
  const hits = [];
140
194
  const spans = []; // [start, end] of accepted hits, for overlap suppression
141
195
  const overlaps = (a, b) => spans.some(([x, y]) => a < y && b > x);
142
- for (const d of DETECTORS) {
196
+ const marks = opts.markings || [];
197
+ for (const d of DETECTORS.concat(marks)) {
198
+ const marking = marks.includes(d);
143
199
  d.re.lastIndex = 0; let m;
144
200
  while ((m = d.re.exec(text))) {
201
+ if (!m[0]) { d.re.lastIndex++; continue; } // a custom pattern that can match nothing must not spin
145
202
  const value = d.value ? d.value(m) : m[0];
146
203
  const start = d.value ? m.index + m[0].length - value.length : m.index, end = start + value.length;
147
204
  if (d.ok && !d.ok(value)) continue;
148
205
  if (overlaps(start, end)) continue;
149
206
  spans.push([start, end]);
150
- hits.push({ rule: d.id, severity: d.severity, start, end, value, preview: mask(value), fingerprint: fp(value), line: lineAt(starts, start) });
207
+ hits.push({ rule: d.id, severity: d.severity, start, end, value, preview: marking ? value.replace(/\\[nrt]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) : mask(value), fingerprint: fp(value), line: lineAt(starts, start), marking });
151
208
  }
152
209
  }
153
210
  hits.sort((a, b) => a.start - b.start);
154
211
  // Attribution needs the record; parse each line once, and walk every line for tool_use ids (results come after calls)
155
212
  // and for credential-file reads.
156
- const tools = new Map(); const parsed = new Map(); const fileReads = [];
213
+ // opts.state carries the tool_use map from one segment of a big file to the next (results come after their calls).
214
+ const tools = opts.state ? (opts.state.tools || (opts.state.tools = new Map())) : new Map(); const parsed = new Map(); const fileReads = [];
157
215
  const hitLines = new Set(hits.map((h) => h.line));
158
216
  for (let n = 1; n <= starts.length; n++) {
159
217
  const raw = lineText(text, starts, n); if (!raw.trim()) continue;
@@ -164,7 +222,9 @@ export function scanText(text, opts = {}) {
164
222
  if (hitLines.has(n)) parsed.set(n, loc);
165
223
  }
166
224
  for (const h of hits) Object.assign(h, opts.where ? { where: opts.where } : parsed.get(h.line) || { where: 'other' });
167
- return hits.concat(fileReads).sort((a, b) => a.start - b.start || SEV[a.severity] - SEV[b.severity]);
225
+ const all = hits.concat(fileReads).sort((a, b) => a.start - b.start || SEV[a.severity] - SEV[b.severity]);
226
+ if (opts.lineOffset) for (const h of all) h.line += opts.lineOffset;
227
+ return all;
168
228
  }
169
229
 
170
230
  // Text Glassbox keeps for later (memory notes, session digests): every value a detector recognises becomes
@@ -192,19 +252,57 @@ function rows(hits) {
192
252
  const map = new Map();
193
253
  for (const h of hits) {
194
254
  const k = [h.rule, h.fingerprint, h.where, h.tool || '', h.path || ''].join('|');
195
- if (map.has(k)) { const r = map.get(k); r.count++; r.spans.push([h.start, h.end]); continue; }
196
- map.set(k, Object.defineProperties({ rule: h.rule, severity: h.severity, where: h.where, tool: h.tool, path: h.path, line: h.line, preview: h.preview, fingerprint: h.fingerprint, count: 1 }, {
197
- value: { value: h.value, enumerable: false }, spans: { value: h.value == null ? [] : [[h.start, h.end]], enumerable: false },
255
+ if (map.has(k)) { const r = map.get(k); r.count++; if (h.value != null && !h.marking) r.spans.push([h.start, h.end]); continue; } // a credential-file read has no value, a marking is never shredded
256
+ map.set(k, Object.defineProperties(Object.assign({ rule: h.rule, severity: h.severity, where: h.where, tool: h.tool, path: h.path, line: h.line, preview: h.preview, fingerprint: h.fingerprint, count: 1 }, h.marking ? { marking: true } : {}), {
257
+ // a marking has no spans, so shred never touches it
258
+ value: { value: h.value, enumerable: false }, spans: { value: h.value == null || h.marking ? [] : [[h.start, h.end]], enumerable: false },
198
259
  }));
199
260
  }
200
261
  return [...map.values()];
201
262
  }
202
263
 
264
+ // A big file in segments of whole lines (≤ segmentBytes, 32 MiB by default), each decoded on its own: the whole
265
+ // file never becomes one string (V8's limit is ~512 MB). fn(text, lineOffset, raw) per segment. DESIGN.md §21.
266
+ export const SEGMENT_BYTES = 32 * 1048576;
267
+ export function forEachSegment(file, segBytes, fn) {
268
+ const fd = fs.openSync(file, 'r'); const chunk = Buffer.allocUnsafe(Math.max(Math.min(segBytes, 8 * 1048576), 4096));
269
+ let carry = Buffer.alloc(0), lineOffset = 0;
270
+ const emit = (seg) => {
271
+ if (seg.length > BUF.MAX_STRING_LENGTH) throw new Error(`a single line of ${seg.length} bytes is too large to scan`);
272
+ fn(seg.toString('utf8'), lineOffset, seg);
273
+ let n = 0; for (let i = seg.indexOf(10); i >= 0; i = seg.indexOf(10, i + 1)) n++;
274
+ lineOffset += n;
275
+ };
276
+ try {
277
+ for (;;) {
278
+ const got = fs.readSync(fd, chunk, 0, chunk.length, null); if (!got) break;
279
+ carry = carry.length ? Buffer.concat([carry, chunk.subarray(0, got)]) : Buffer.from(chunk.subarray(0, got));
280
+ if (carry.length < segBytes) continue;
281
+ const cut = carry.lastIndexOf(10); if (cut < 0) continue; // one line longer than a segment: keep reading
282
+ const seg = carry.subarray(0, cut + 1); carry = Buffer.from(carry.subarray(cut + 1));
283
+ emit(seg);
284
+ }
285
+ if (carry.length) emit(carry);
286
+ } finally { fs.closeSync(fd); }
287
+ }
288
+
289
+ function scanFileSegmented(file, meta) {
290
+ const state = {}, hits = []; let lines = 0;
291
+ forEachSegment(file, meta.segmentBytes || SEGMENT_BYTES, (text, off) => {
292
+ for (const h of scanText(text, { key: meta.key, where: meta.where, markings: meta.markings, state, lineOffset: off })) hits.push(h);
293
+ lines = off + text.split('\n').length;
294
+ });
295
+ const findings = rows(meta.store ? hits.filter((h) => h.rule !== 'credential-file-read') : hits).map((r) => Object.assign(r, {
296
+ file, session: meta.store ? meta.session || undefined : meta.session || path.basename(file).replace(/\.jsonl$/, ''), project: meta.project || undefined, store: meta.store || undefined }));
297
+ return { file, bytes: fs.statSync(file).size, lines, findings, streamed: true };
298
+ }
299
+
203
300
  export function scanFile(file, meta = {}) {
301
+ if (!meta.store && fs.statSync(file).size > streamBytes()) return scanFileSegmented(file, meta);
204
302
  const buf = fs.readFileSync(file);
205
303
  if (meta.store && isBinary(buf)) return { file, bytes: buf.length, skipped: 'binary file', findings: [] };
206
304
  const text = buf.toString('utf8');
207
- const hits = scanText(text, { key: meta.key, where: meta.where });
305
+ const hits = scanText(text, { key: meta.key, where: meta.where, markings: meta.markings });
208
306
  const findings = rows(meta.store ? hits.filter((h) => h.rule !== 'credential-file-read') : hits).map((r) => Object.assign(r, {
209
307
  file, session: meta.store ? meta.session || undefined : meta.session || path.basename(file).replace(/\.jsonl$/, ''), project: meta.project || undefined, store: meta.store || undefined }));
210
308
  return { file, bytes: buf.length, lines: text.split('\n').length, findings, text, raw: buf };
@@ -215,6 +313,7 @@ export function scanFile(file, meta = {}) {
215
313
  // anything is written; on failure the file is left as it was.
216
314
  export function shredFile(file, findings, opts = {}) {
217
315
  const replacer = opts.replacer || ((f) => `[FENCED:${f.rule}:${f.fingerprint}]`);
316
+ if (opts.raw == null && opts.text == null && fs.statSync(file).size > streamBytes()) return shredFileSegmented(file, findings, replacer, opts);
218
317
  const raw = opts.raw || fs.readFileSync(file);
219
318
  const text = opts.text != null ? opts.text : raw.toString('utf8');
220
319
  const json = opts.json != null ? opts.json : /\.jsonl$/i.test(file);
@@ -235,6 +334,38 @@ export function shredFile(file, findings, opts = {}) {
235
334
  return { written: true, values: spans.length };
236
335
  }
237
336
 
337
+ // The big-file rewrite: scan each segment again with the same key (so fingerprints match), replace every hit whose
338
+ // rule and fingerprint were reported, check the segment the way the small path checks the file, and write it to the
339
+ // temp file; rename only when every segment passed. Same guarantees as the small path, one segment in memory.
340
+ function shredFileSegmented(file, findings, replacer, opts) {
341
+ // Markings are never shredded (removing one would leave controlled text unmarked), so they are not wanted here.
342
+ const want = new Map(); for (const f of findings) if (f.rule !== 'credential-file-read' && !f.marking) want.set(f.rule + '|' + f.fingerprint, f);
343
+ if (!want.size) return { written: false, values: 0, reason: 'nothing to shred' };
344
+ const json = opts.json != null ? opts.json : /\.jsonl$/i.test(file);
345
+ const tmp = file + '.glassbox-shred~'; const mode = fs.statSync(file).mode & 0o777;
346
+ const fd = fs.openSync(tmp, 'w', mode); const state = {}; let values = 0, failed = null;
347
+ try {
348
+ forEachSegment(file, opts.segmentBytes || SEGMENT_BYTES, (text, off, raw) => {
349
+ if (failed) return;
350
+ if (!Buffer.from(text, 'utf8').equals(raw)) { failed = 'not plain UTF-8 text; nothing written'; return; }
351
+ const hits = scanText(text, { key: opts.key, state }).filter((h) => h.value != null && want.has(h.rule + '|' + h.fingerprint));
352
+ let out = text;
353
+ if (hits.length) {
354
+ out = ''; let pos = 0;
355
+ for (const h of hits) { if (h.start < pos) continue; out += text.slice(pos, h.start) + replacer(want.get(h.rule + '|' + h.fingerprint)); pos = h.end; values++; }
356
+ out += text.slice(pos);
357
+ const before = text.split('\n'), after = out.split('\n');
358
+ if (before.length !== after.length) { failed = 'line count changed; nothing written'; return; }
359
+ if (json) for (let i = 0; i < after.length; i++) { if (after[i] === before[i]) continue; const l = after[i].replace(/\r$/, ''); if (!l.trim()) continue; try { JSON.parse(l); } catch (e) { failed = `line ${off + i + 1} would not parse after the rewrite; nothing written`; return; } }
360
+ }
361
+ fs.writeSync(fd, out);
362
+ });
363
+ } catch (e) { failed = e.message; } finally { fs.closeSync(fd); }
364
+ if (failed || !values) { try { fs.unlinkSync(tmp); } catch (e) { } return { written: false, values, reason: failed || 'nothing to shred' }; }
365
+ fs.renameSync(tmp, file);
366
+ return { written: true, values };
367
+ }
368
+
238
369
  // Which files to scan. opts: { home, target, since (ms epoch), project, sessionsOnly }.
239
370
  export function targets(opts = {}) {
240
371
  const home = opts.home || claudeHome();
@@ -275,16 +406,18 @@ export function fence(opts = {}) {
275
406
  const home = opts.home || claudeHome();
276
407
  const keyFile = opts.keyFile ? path.resolve(opts.keyFile) : defaultKeyFile(home);
277
408
  const key = opts.keyFile ? loadKey(keyFile) : ensureKey(keyFile);
409
+ // --markings: the built-in export-control set; --markings-file adds a company's own (and implies --markings).
410
+ const markings = opts.markings || opts.markingsFile ? MARKINGS.concat(opts.markingsFile ? loadMarkings(opts.markingsFile) : []) : [];
278
411
  const files = [], findings = [], skipped = []; let bytes = 0; const sessions = new Set();
279
412
  const shredded = { files: 0, values: 0, refused: [] };
280
413
  for (const t of tg.list) {
281
414
  if (t.store && fs.statSync(t.file).size > STORE_MAX_BYTES) { skipped.push({ file: t.file, reason: `larger than ${STORE_MAX_BYTES / 1048576} MB` }); continue; }
282
- let r; try { r = scanFile(t.file, { session: t.session, project: t.project, store: t.store, where: t.where, key }); } catch (e) { shredded.refused.push({ file: t.file, reason: e.message }); continue; }
415
+ let r; try { r = scanFile(t.file, { session: t.session, project: t.project, store: t.store, where: t.where, key, markings }); } catch (e) { shredded.refused.push({ file: t.file, reason: e.message }); continue; }
283
416
  if (r.skipped) { skipped.push({ file: t.file, reason: r.skipped }); continue; }
284
- files.push({ file: t.file, session: t.session || undefined, store: t.store, bytes: r.bytes, findings: r.findings.length });
417
+ files.push({ file: t.file, session: t.session || undefined, store: t.store, bytes: r.bytes, findings: r.findings.length, streamed: r.streamed || undefined });
285
418
  bytes += r.bytes; if (!t.store) sessions.add(t.session);
286
419
  findings.push(...r.findings);
287
- if (opts.shred) { const s = shredFile(t.file, r.findings, { text: r.text, raw: r.raw }); if (s.written) { shredded.files++; shredded.values += s.values; } else if (s.reason !== 'nothing to shred') shredded.refused.push({ file: t.file, reason: s.reason }); }
420
+ if (opts.shred) { const s = shredFile(t.file, r.findings, r.streamed ? { key } : { text: r.text, raw: r.raw }); if (s.written) { shredded.files++; shredded.values += s.values; } else if (s.reason !== 'nothing to shred') shredded.refused.push({ file: t.file, reason: s.reason }); }
288
421
  }
289
422
  findings.sort((a, b) => SEV[a.severity] - SEV[b.severity] || a.file.localeCompare(b.file) || a.line - b.line);
290
423
  // Files actually scanned per store (skipped ones are listed under scanned.skipped); undefined when stores were not in scope.
@@ -292,12 +425,15 @@ export function fence(opts = {}) {
292
425
  const bySeverity = { error: 0, warn: 0, info: 0 }; for (const f of findings) bySeverity[f.severity]++;
293
426
  const byRule = {}; for (const f of findings) byRule[f.rule] = (byRule[f.rule] || 0) + f.count;
294
427
  const secretMap = new Map();
295
- for (const f of findings) { if (f.rule === 'credential-file-read') continue; const k = f.fingerprint; if (!secretMap.has(k)) secretMap.set(k, { fingerprint: k, rule: f.rule, severity: f.severity, preview: f.preview, sessions: new Set(), files: new Set(), occurrences: 0, advice: ADVICE[f.rule] }); const s = secretMap.get(k); if (f.session && !f.store) s.sessions.add(f.session); s.files.add(f.file); s.occurrences += f.count; }
296
- const secrets = [...secretMap.values()].map((s) => Object.assign(s, { sessions: s.sessions.size, files: s.files.size })).sort((a, b) => SEV[a.severity] - SEV[b.severity] || b.sessions - a.sessions || b.occurrences - a.occurrences);
428
+ const markMap = new Map();
429
+ for (const f of findings) { if (f.rule === 'credential-file-read') continue; const map = f.marking ? markMap : secretMap; const k = f.marking ? f.rule + '|' + f.preview : f.fingerprint; if (!map.has(k)) map.set(k, { fingerprint: f.fingerprint, rule: f.rule, severity: f.severity, preview: f.preview, sessions: new Set(), files: new Set(), occurrences: 0, advice: f.marking ? MARKING_ADVICE : ADVICE[f.rule] }); const s = map.get(k); if (f.session && !f.store) s.sessions.add(f.session); s.files.add(f.file); s.occurrences += f.count; }
430
+ const spread = (m) => [...m.values()].map((s) => Object.assign(s, { sessions: s.sessions.size, files: s.files.size })).sort((a, b) => SEV[a.severity] - SEV[b.severity] || b.sessions - a.sessions || b.occurrences - a.occurrences);
431
+ const secrets = spread(secretMap), marked = spread(markMap);
297
432
  const rep = { glassbox: core.VERSION, kind: 'fence', schema: 2, generated: new Date().toISOString(), mode: tg.mode, home: tg.home, dir: tg.dir, since: opts.since ? new Date(opts.since).toISOString() : undefined,
298
433
  fingerprint: { alg: 'hmac-sha256', chars: 8, key: keyFile },
299
434
  scanned: { files: files.length, sessions: sessions.size, bytes, stores: storeCounts, storesNote: tg.storesNote, skipped },
300
- summary: { bySeverity, byRule, filesWithFindings: files.filter((f) => f.findings).length, distinctSecrets: secrets.length, secrets },
435
+ summary: { bySeverity, byRule, filesWithFindings: files.filter((f) => f.findings).length, distinctSecrets: secrets.length, secrets, markings: markings.length ? marked : undefined },
436
+ markings: markings.length ? { rules: [...new Set(markings.map((d) => d.id))], custom: opts.markingsFile ? path.resolve(opts.markingsFile) : undefined } : undefined,
301
437
  findings, files };
302
438
  if (opts.shred) rep.shredded = shredded;
303
439
  return rep;
@@ -317,17 +453,25 @@ export function fenceText(rep) {
317
453
  const nStore = rep.scanned.stores ? Object.values(rep.scanned.stores).reduce((a, b) => a + b, 0) : 0;
318
454
  L.push(`Glassbox fence · ${what}${nStore ? ` + ${storeLine(rep.scanned.stores)}` : ''} · ${fmtBytes(rep.scanned.bytes)}${rep.since ? ` · since ${rep.since.slice(0, 10)}` : ''}`);
319
455
  if (rep.scanned.storesNote) L.push(` (${rep.scanned.storesNote})`);
320
- if (!rep.findings.length) { L.push(' nothing found — no known credential formats, no secrets named by context, no credential-file reads'); }
456
+ if (rep.markings) L.push(` export-control markings: on (${rep.markings.rules.length} rules${rep.markings.custom ? `, including ${rep.markings.custom}` : ''})`);
457
+ if (!rep.findings.length) { L.push(` nothing found — no known credential formats, no secrets named by context, no credential-file reads${rep.markings ? ', no export-control markings' : ''}`); }
321
458
  else {
322
- L.push(` ${s.error} error · ${s.warn} warn · ${s.info} info · ${rep.summary.distinctSecrets} distinct secret${rep.summary.distinctSecrets === 1 ? '' : 's'} in ${rep.summary.filesWithFindings} of ${rep.scanned.files} files`, '');
459
+ L.push(` ${s.error} error · ${s.warn} warn · ${s.info} info · ${rep.summary.distinctSecrets} distinct secret${rep.summary.distinctSecrets === 1 ? '' : 's'}${rep.summary.markings ? ` · ${rep.summary.markings.length} marking${rep.summary.markings.length === 1 ? '' : 's'}` : ''} in ${rep.summary.filesWithFindings} of ${rep.scanned.files} files`, '');
323
460
  let last = null;
324
461
  for (const f of rep.findings) {
325
462
  if (f.file !== last) { L.push(` ${f.file}${f.session && !f.file.includes(f.session) ? ` (session ${f.session.slice(0, 8)})` : ''}`); last = f.file; }
326
- L.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.rule.padEnd(21)} line ${String(f.line).padStart(6)} ${f.preview}${f.count > 1 ? ` ×${f.count}` : ''} — ${place(f)}`);
463
+ L.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.rule.padEnd(22)} line ${String(f.line).padStart(6)} ${f.preview}${f.count > 1 ? ` ×${f.count}` : ''} — ${place(f)}`);
464
+ }
465
+ if (rep.summary.secrets.length) {
466
+ L.push('', ' Secrets, by how far they spread:');
467
+ for (const x of rep.summary.secrets.slice(0, 20)) L.push(` ${x.severity.toUpperCase().padEnd(5)} ${x.rule.padEnd(22)} ${x.fingerprint} ${x.preview} in ${x.sessions} session${x.sessions === 1 ? '' : 's'}, ${x.occurrences} place${x.occurrences === 1 ? '' : 's'}`);
468
+ L.push('', ' A secret that reached a transcript reached a disk: rotate it, then run again with --shred to overwrite it in place.');
469
+ }
470
+ if (rep.summary.markings && rep.summary.markings.length) {
471
+ L.push('', ' Export-control markings, by how far they spread:');
472
+ for (const x of rep.summary.markings.slice(0, 20)) L.push(` ${x.severity.toUpperCase().padEnd(5)} ${x.rule.padEnd(22)} "${x.preview}" in ${x.sessions} session${x.sessions === 1 ? '' : 's'}, ${x.occurrences} place${x.occurrences === 1 ? '' : 's'}`);
473
+ L.push('', ' ' + MARKING_ADVICE);
327
474
  }
328
- L.push('', ' Secrets, by how far they spread:');
329
- for (const x of rep.summary.secrets.slice(0, 20)) L.push(` ${x.severity.toUpperCase().padEnd(5)} ${x.rule.padEnd(21)} ${x.fingerprint} ${x.preview} in ${x.sessions} session${x.sessions === 1 ? '' : 's'}, ${x.occurrences} place${x.occurrences === 1 ? '' : 's'}`);
330
- L.push('', ' A secret that reached a transcript reached a disk: rotate it, then run again with --shred to overwrite it in place.');
331
475
  }
332
476
  if (rep.scanned.skipped && rep.scanned.skipped.length) L.push(` skipped ${rep.scanned.skipped.length} file${rep.scanned.skipped.length === 1 ? '' : 's'} (binary or too large)`);
333
477
  L.push(` fingerprints: HMAC-SHA256 under ${rep.fingerprint.key} (keep that file on this machine)`);
@@ -339,11 +483,20 @@ export function fenceMarkdown(rep) {
339
483
  const L = [`# Glassbox fence — ${rep.generated.slice(0, 10)}`, '', `Scanned ${rep.scanned.files} file${rep.scanned.files === 1 ? '' : 's'} (${rep.scanned.sessions} session${rep.scanned.sessions === 1 ? '' : 's'}${rep.scanned.stores && Object.keys(rep.scanned.stores).length ? ` + ${storeLine(rep.scanned.stores)}` : ''}, ${fmtBytes(rep.scanned.bytes)})${rep.since ? ` written since ${rep.since.slice(0, 10)}` : ''}. Previews are masked and fingerprints are the first 8 hex of HMAC-SHA256 under a key kept on this machine; this report holds no secret values.`, ''];
340
484
  if (rep.scanned.storesNote) L.push(`_${rep.scanned.storesNote}._`, '');
341
485
  const s = rep.summary.bySeverity;
342
- if (!rep.findings.length) { L.push('**Nothing found.** No known credential formats, no secrets named by context, no credential-file reads.'); return L.join('\n') + '\n'; }
343
- L.push(`**${s.error} error · ${s.warn} warn · ${s.info} info** — ${rep.summary.distinctSecrets} distinct secret${rep.summary.distinctSecrets === 1 ? '' : 's'} in ${rep.summary.filesWithFindings} of ${rep.scanned.files} files.`, '');
344
- L.push('## Secrets, by how far they spread', '', '| severity | rule | fingerprint | preview | sessions | places | do |', '|---|---|---|---|---:|---:|---|');
345
- for (const x of rep.summary.secrets) L.push(`| ${x.severity} | \`${x.rule}\` | \`${x.fingerprint}\` | ${x.preview} | ${x.sessions} | ${x.occurrences} | ${x.advice} |`);
346
- L.push('', '## Where, file by file', '');
486
+ if (rep.markings) L.push(`Export-control markings were checked (${rep.markings.rules.map((r) => `\`${r}\``).join(', ')}).`, '');
487
+ if (!rep.findings.length) { L.push(`**Nothing found.** No known credential formats, no secrets named by context, no credential-file reads${rep.markings ? ', no export-control markings' : ''}.`); return L.join('\n') + '\n'; }
488
+ L.push(`**${s.error} error · ${s.warn} warn · ${s.info} info** — ${rep.summary.distinctSecrets} distinct secret${rep.summary.distinctSecrets === 1 ? '' : 's'}${rep.summary.markings ? `, ${rep.summary.markings.length} marking${rep.summary.markings.length === 1 ? '' : 's'}` : ''} in ${rep.summary.filesWithFindings} of ${rep.scanned.files} files.`, '');
489
+ if (rep.summary.secrets.length) {
490
+ L.push('## Secrets, by how far they spread', '', '| severity | rule | fingerprint | preview | sessions | places | do |', '|---|---|---|---|---:|---:|---|');
491
+ for (const x of rep.summary.secrets) L.push(`| ${x.severity} | \`${x.rule}\` | \`${x.fingerprint}\` | ${x.preview} | ${x.sessions} | ${x.occurrences} | ${x.advice} |`);
492
+ L.push('');
493
+ }
494
+ if (rep.summary.markings && rep.summary.markings.length) {
495
+ L.push('## Export-control markings, by how far they spread', '', '| severity | rule | marking | sessions | places |', '|---|---|---|---:|---:|');
496
+ for (const x of rep.summary.markings) L.push(`| ${x.severity} | \`${x.rule}\` | ${x.preview.replace(/\|/g, '\\|')} | ${x.sessions} | ${x.occurrences} |`);
497
+ L.push('', MARKING_ADVICE, '');
498
+ }
499
+ L.push('## Where, file by file', '');
347
500
  const byFile = new Map(); for (const f of rep.findings) { if (!byFile.has(f.file)) byFile.set(f.file, []); byFile.get(f.file).push(f); }
348
501
  for (const [file, list] of byFile) {
349
502
  L.push(`### ${file}`, '', list[0].store ? `${STORE_WORDS[list[0].store] || list[0].store}${list[0].session ? ` · session \`${list[0].session}\`` : ''}` : `session \`${list[0].session}\`${list[0].project ? ` · project ${list[0].project}` : ''}`, '', '| rule | severity | line | preview | where |', '|---|---|---:|---|---|');
package/src/guard.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  // - only failures count (denied-by-the-human and interrupted calls don't; they have their own flow);
6
6
  // - the chain is broken by anything that could have changed the outcome: an edit, another command,
7
7
  // any non-read call, a success, or a new human prompt. Re-running tests after a fix is never blocked;
8
- // - calls are compared on what they do, not how they're labelled: Bash/PowerShell `description`,
9
- // `timeout` and `run_in_background` are ignored, because the agent often rewords them on a retry.
8
+ // - calls are compared on what they do, not how they're labelled: a Bash/PowerShell `description` is ignored,
9
+ // because the agent often rewords it on a retry; a changed `timeout` or `run_in_background` is a change.
10
10
  // It never approves anything: when it has nothing to say it prints nothing, which leaves Claude Code's
11
11
  // normal permission flow in charge ("allow" would skip the user's permission prompts).
12
12
  import fs from 'node:fs';
@@ -15,7 +15,9 @@ const require = createRequire(import.meta.url);
15
15
  const core = require('./trace-core.js');
16
16
 
17
17
  export const GUARD_DEFAULTS = { maxFailures: 2, tailBytes: 4 * 1048576 };
18
- const IGNORED_KEYS = new Set(['description', 'timeout', 'run_in_background']);
18
+ // Only the label. A longer timeout or a background run after a timeout is a real change (0.12 review: the guard is
19
+ // on by default in the plugin now, so a false block costs more than a missed loop).
20
+ const IGNORED_KEYS = new Set(['description']);
19
21
 
20
22
  export function guardKey(name, input) {
21
23
  const i = {};
package/src/jsonl.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // Reading transcripts of any size. V8 caps a string at buffer.constants.MAX_STRING_LENGTH (536,870,888 characters
2
+ // on Node 22), so readFileSync(file, 'utf8') throws ERR_STRING_TOO_LONG on a transcript past ~512 MB before a single
3
+ // line is parsed — an outside review found a 1.26 GB session that broke `check` outright. Files at or under
4
+ // streamBytes() are read exactly as before; bigger ones line by line, keeping the parsed record and dropping the
5
+ // line string at once. Synchronous on purpose: the callers (check, the Stop hook, fence) are synchronous.
6
+ // DESIGN.md §21.
7
+ import fs from 'node:fs';
8
+ import { constants } from 'node:buffer';
9
+ import { createRequire } from 'node:module';
10
+ const require = createRequire(import.meta.url);
11
+ const core = require('./trace-core.js');
12
+
13
+ export const STREAM_BYTES = 64 * 1048576;
14
+ export const CHUNK_BYTES = 8 * 1048576;
15
+ export function streamBytes() { const n = Number(process.env.GLASSBOX_STREAM_BYTES); return Number.isFinite(n) && n > 0 ? n : STREAM_BYTES; }
16
+
17
+ // fn(line, n) for every line, 1-based, CRLF and a leading BOM removed — the lines text.split(/\r?\n/) would give.
18
+ // A line longer than maxLine bytes is never decoded: fn(null, n, bytes). Lines are cut on the 0x0A byte, which
19
+ // never occurs inside a UTF-8 multi-byte sequence, so a character split across two chunks is never split here.
20
+ export function forEachLine(file, fn, opts = {}) {
21
+ const chunkBytes = opts.chunkBytes || CHUNK_BYTES;
22
+ const maxLine = opts.maxLine || constants.MAX_STRING_LENGTH;
23
+ const fd = fs.openSync(file, 'r');
24
+ const buf = Buffer.allocUnsafe(chunkBytes);
25
+ let parts = [], partBytes = 0, n = 0, first = true, tooLong = false;
26
+ const emit = (line) => {
27
+ n++;
28
+ if (tooLong || line.length > maxLine) { fn(null, n, tooLong ? partBytes : line.length); return; }
29
+ let s = line.toString('utf8');
30
+ if (first && s.charCodeAt(0) === 0xfeff) s = s.slice(1);
31
+ if (s.charCodeAt(s.length - 1) === 13) s = s.slice(0, -1);
32
+ fn(s, n);
33
+ };
34
+ try {
35
+ for (;;) {
36
+ const got = fs.readSync(fd, buf, 0, chunkBytes, null);
37
+ if (!got) break;
38
+ let at = 0;
39
+ for (;;) {
40
+ const nl = buf.indexOf(10, at);
41
+ if (nl < 0 || nl >= got) {
42
+ if (at < got) { partBytes += got - at; if (partBytes > maxLine) { tooLong = true; parts = []; } else parts.push(Buffer.from(buf.subarray(at, got))); }
43
+ break;
44
+ }
45
+ const piece = buf.subarray(at, nl);
46
+ const line = parts.length ? Buffer.concat([...parts, piece]) : piece;
47
+ if (tooLong) partBytes += piece.length;
48
+ emit(line); first = false;
49
+ parts = []; partBytes = 0; tooLong = false; at = nl + 1;
50
+ }
51
+ }
52
+ // The last line when the file doesn't end in \n. (After a final \n, split() would add an empty line; it is blank,
53
+ // so no caller sees it either way.)
54
+ if (parts.length || tooLong) emit(parts.length ? Buffer.concat(parts) : Buffer.alloc(0));
55
+ } finally { fs.closeSync(fd); }
56
+ return n;
57
+ }
58
+
59
+ // The records of one transcript file, as core.parseLines would give them from its text.
60
+ export const SLIM_LINE = 262144;
61
+ export function readRecords(file, name, opts = {}) {
62
+ const records = [], problems = [];
63
+ const slim = opts.slim !== false, keep = opts.keep || 32768;
64
+ const bytes = fs.statSync(file).size;
65
+ forEachLine(file, (line, n, len) => {
66
+ if (line == null) { problems.push({ file: name, line: n, reason: `line too long (${len} bytes); skipped` }); return; }
67
+ const t = line.trim(); if (!t) return;
68
+ try {
69
+ const r = JSON.parse(t);
70
+ // A long line's huge strings become head + marker + tail (core.charsOf still gives their size): holding every
71
+ // multi-MB tool result of a 1 GB session at once would run Node out of heap after the string limit was solved.
72
+ // The JSON round trip matters as much as the cut: V8's JSON.parse and slice() can hand back strings that point
73
+ // into the source, so even a uuid kept from a 4 MB line would keep the whole line alive. A copy owns its text.
74
+ if (r && typeof r === 'object') records.push(core.normaliseLoose(slim && t.length > SLIM_LINE ? JSON.parse(JSON.stringify(core.slimRecord(r, keep))) : r, n, name));
75
+ else problems.push({ file: name, line: n, reason: 'not an object' });
76
+ } catch (e) { problems.push({ file: name, line: n, reason: 'bad JSON: ' + String(e.message).slice(0, 80) }); }
77
+ }, opts);
78
+ return { name, records, problems, bytes };
79
+ }
80
+
81
+ // One file for parseTrace: { name, text } as before, or { name, records, problems, bytes } past the threshold.
82
+ // A whole-file JSON array or a .meta.json is always text (they can't be read line by line; a JSON array past the
83
+ // string limit is reported, not thrown).
84
+ export function readTranscript(file, name, opts = {}) {
85
+ const size = fs.statSync(file).size;
86
+ if (size <= (opts.streamBytes || streamBytes()) || /\.meta\.json$/.test(name)) return { name, text: fs.readFileSync(file, 'utf8') };
87
+ // The first non-blank character decides; read until there is one (a JSON array can follow a long run of blanks).
88
+ const fd = fs.openSync(file, 'r'); const head = Buffer.alloc(65536); let first = '';
89
+ try { for (let pos = 0; ;) { const got = fs.readSync(fd, head, 0, head.length, pos); if (!got) break; const t = head.subarray(0, got).toString('latin1').replace(/^\xef\xbb\xbf/, '').replace(/^\s+/, ''); if (t) { first = t[0]; break; } pos += got; } } finally { fs.closeSync(fd); }
90
+ if (first === '[') {
91
+ if (size < constants.MAX_STRING_LENGTH / 2) return { name, text: fs.readFileSync(file, 'utf8') };
92
+ return { name, records: [], problems: [{ file: name, line: 0, reason: `a JSON array of ${size} bytes is too large to read; convert it to JSONL (one record per line)` }], bytes: size };
93
+ }
94
+ return readRecords(file, name, opts);
95
+ }
96
+
97
+ // Does the file contain `needle` (already lowercased)? Line by line, so it works on any size.
98
+ export function fileIncludes(file, needle) {
99
+ if (fs.statSync(file).size <= streamBytes()) return fs.readFileSync(file, 'utf8').toLowerCase().includes(needle);
100
+ let hit = false;
101
+ try { forEachLine(file, (line) => { if (!hit && line && line.toLowerCase().includes(needle)) { hit = true; throw STOP; } }); } catch (e) { if (e !== STOP) throw e; }
102
+ return hit;
103
+ }
104
+ const STOP = Symbol('stop');
@@ -0,0 +1,32 @@
1
+ // Where Claude Code's managed settings live, and which hook commands they already run. Kept apart from managed.mjs
2
+ // (which imports the CLI) so the plugin's PreToolUse guard can ask "is Glassbox already installed for every user?"
3
+ // without loading anything heavy. Claude Code reads managed-settings.json, then managed-settings.d/*.json by name.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export function managedDir(platform = process.platform, env = process.env) {
8
+ if (env.GLASSBOX_MANAGED_DIR) return env.GLASSBOX_MANAGED_DIR;
9
+ return platform === 'win32' ? 'C:\\Program Files\\ClaudeCode' : platform === 'darwin' ? '/Library/Application Support/ClaudeCode' : '/etc/claude-code';
10
+ }
11
+ export const dropInFile = (dir) => path.join(dir || managedDir(), 'managed-settings.d', 'glassbox.json');
12
+
13
+ export function managedFiles(dir = managedDir()) {
14
+ const out = [path.join(dir, 'managed-settings.json')];
15
+ try { for (const n of fs.readdirSync(path.join(dir, 'managed-settings.d')).filter((x) => /\.json$/i.test(x)).sort()) out.push(path.join(dir, 'managed-settings.d', n)); } catch (e) { }
16
+ return out.filter((f) => { try { return fs.statSync(f).isFile(); } catch (e) { return false; } });
17
+ }
18
+
19
+ // Every hook command in the managed settings (and in any extra settings files given), for the events asked.
20
+ export function hookCommands(files, events) {
21
+ const out = [];
22
+ for (const f of files) {
23
+ let j; try { j = JSON.parse(fs.readFileSync(f, 'utf8').replace(/^\ufeff/, '')); } catch (e) { continue; }
24
+ const hooks = (j && j.hooks) || {};
25
+ for (const [ev, list] of Object.entries(hooks)) {
26
+ if (events && !events.includes(ev)) continue;
27
+ if (!Array.isArray(list)) continue;
28
+ for (const entry of list) for (const h of (entry && Array.isArray(entry.hooks) ? entry.hooks : [])) if (h && typeof h.command === 'string') out.push(h.command);
29
+ }
30
+ }
31
+ return out;
32
+ }
@@ -0,0 +1,76 @@
1
+ // glassbox managed — Glassbox for every user on a machine, through Claude Code's managed settings, and the
2
+ // temp-folder policy that goes with it.
3
+ //
4
+ // Claude Code reads managed settings from a fixed folder per OS (C:\Program Files\ClaudeCode,
5
+ // /Library/Application Support/ClaudeCode, /etc/claude-code): managed-settings.json, plus every *.json in
6
+ // managed-settings.d/ beside it (2.1.278). Glassbox writes its own drop-in, managed-settings.d/glassbox.json,
7
+ // and never edits the organisation's managed-settings.json. Writing there needs an administrator; printing the
8
+ // fragment doesn't (DESIGN.md §24). Nothing leaves the machine.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { hookEntries } from './cli.mjs';
13
+ import { readTextFile } from './textfile.mjs';
14
+
15
+ import { managedDir, dropInFile } from './managed-paths.mjs';
16
+ export { managedDir, dropInFile };
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // The temp-folder policy: what `open` may write to the temp folder without --out.
20
+ // allow (default) the full transcript, as before
21
+ // redact every prompt, tool input, result and assistant text blanked first («N chars»)
22
+ // deny nothing: `open` needs --out, a place the user chose
23
+ // From GLASSBOX_TEMP, else from `env.GLASSBOX_TEMP` in the managed settings (where IT already sets Claude Code's
24
+ // env for every user), else allow.
25
+ // ---------------------------------------------------------------------------
26
+ export const TEMP_POLICIES = ['allow', 'redact', 'deny'];
27
+ export function readManagedEnv(dir) {
28
+ dir = dir || managedDir();
29
+ const files = [path.join(dir, 'managed-settings.json')];
30
+ const d = path.join(dir, 'managed-settings.d');
31
+ try { for (const f of fs.readdirSync(d).filter((f) => f.endsWith('.json')).sort()) files.push(path.join(d, f)); } catch (e) { }
32
+ const env = {};
33
+ for (const f of files) { try { const j = JSON.parse(readTextFile(f)); if (j && j.env && typeof j.env === 'object') Object.assign(env, j.env); } catch (e) { } }
34
+ return env;
35
+ }
36
+ export function tempPolicy(env = process.env, dir) {
37
+ const v = env.GLASSBOX_TEMP != null ? env.GLASSBOX_TEMP : readManagedEnv(dir).GLASSBOX_TEMP;
38
+ const p = String(v == null ? 'allow' : v).toLowerCase();
39
+ if (!TEMP_POLICIES.includes(p)) throw new Error(`GLASSBOX_TEMP must be allow, redact or deny (got "${v}")`);
40
+ return { policy: p, from: env.GLASSBOX_TEMP != null ? 'GLASSBOX_TEMP' : v != null ? 'managed settings' : 'default' };
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The drop-in
45
+ // ---------------------------------------------------------------------------
46
+ // opts: { command, hook, feedback, context, guard, failOn, statusline, noColor, temp }
47
+ export function managedFragment(opts = {}) {
48
+ if (!opts.hook && !opts.statusline && !opts.temp) throw new Error('managed needs at least one of --hook, --statusline or --temp allow|redact|deny');
49
+ if (opts.temp && !TEMP_POLICIES.includes(opts.temp)) throw new Error(`--temp must be allow, redact or deny (got "${opts.temp}")`);
50
+ const out = {};
51
+ if (opts.hook) { const h = hookEntries({ command: opts.command, feedback: opts.feedback, context: opts.context, memory: opts.memory, guard: opts.guard, failOn: opts.failOn }); out.hooks = {}; for (const ev of h.events) out.hooks[ev] = [h.entries[ev]]; }
52
+ if (opts.statusline) out.statusLine = { type: 'command', command: `${opts.command} statusline${opts.noColor ? ' --no-color' : ''}`, padding: 0 };
53
+ if (opts.temp) out.env = { GLASSBOX_TEMP: opts.temp };
54
+ return out;
55
+ }
56
+
57
+ // A path under one user's profile can't be run by the machine's other users, and managed settings apply to all.
58
+ export function userScoped(command, home = os.homedir()) {
59
+ const norm = (s) => String(s).replace(/\\/g, '/').toLowerCase();
60
+ return !!home && norm(command).includes(norm(home));
61
+ }
62
+
63
+ export function writeDropIn(fragment, opts = {}) {
64
+ const file = opts.file || dropInFile(opts.dir);
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ if (fs.existsSync(file)) fs.copyFileSync(file, file + '.glassbox-backup');
67
+ // No comment key: Claude Code validates settings files, and the file's name already says whose it is.
68
+ fs.writeFileSync(file, JSON.stringify(fragment, null, 2) + '\n');
69
+ return { file };
70
+ }
71
+ export function removeDropIn(opts = {}) {
72
+ const file = opts.file || dropInFile(opts.dir);
73
+ if (!fs.existsSync(file)) return { file, removed: false };
74
+ fs.unlinkSync(file);
75
+ return { file, removed: true };
76
+ }
@@ -115,6 +115,18 @@ function gitRoot(dir) {
115
115
  }
116
116
  function settingsOf(home) { try { return JSON.parse(readTextFile(path.join(home, 'settings.json'))) || {}; } catch (e) { return {}; } }
117
117
 
118
+ // The project's auto memory folder, as Claude Code resolves it: settings.autoMemoryDirectory (with ~), else
119
+ // <claude home>/projects/<git root, encoded>/memory. dir is null when no such folder exists; enabled is false when
120
+ // settings.autoMemoryEnabled is false. Shared by the audit and by the Stop hook's lessons block (0.12).
121
+ export function autoMemoryDir(opts = {}) {
122
+ const project = path.resolve(opts.project || process.cwd());
123
+ const home = opts.home || claudeHome();
124
+ const userHome = opts.userHome || os.homedir();
125
+ const settings = settingsOf(home);
126
+ const dirs = opts.dir ? [opts.dir] : settings.autoMemoryDirectory ? [String(settings.autoMemoryDirectory).replace(/^~(?=\/|\\|$)/, userHome)] : encodings(gitRoot(project) || project).map((e) => path.join(home, 'projects', e, 'memory'));
127
+ return { dir: dirs.find(isDir) || null, enabled: settings.autoMemoryEnabled !== false };
128
+ }
129
+
118
130
  export function auditMemory(opts = {}) {
119
131
  const project = path.resolve(opts.project || process.cwd());
120
132
  const home = opts.home || claudeHome();
@@ -158,10 +170,8 @@ export function auditMemory(opts = {}) {
158
170
  }
159
171
  }
160
172
  // 3. Auto memory: the first 200 lines or 25 KB of MEMORY.md at launch; topic files when Claude reads them.
161
- const settings = settingsOf(home);
162
- const autoOff = settings.autoMemoryEnabled === false;
163
- const memDirs = opts.autoMemoryDir ? [opts.autoMemoryDir] : settings.autoMemoryDirectory ? [String(settings.autoMemoryDirectory).replace(/^~(?=\/|\\|$)/, userHome)] : encodings(gitRoot(project) || project).map((e) => path.join(home, 'projects', e, 'memory'));
164
- const memDir = memDirs.find(isDir);
173
+ const { dir: memDir, enabled } = autoMemoryDir({ project, home, userHome, dir: opts.autoMemoryDir });
174
+ const autoOff = !enabled;
165
175
  if (memDir && !autoOff) {
166
176
  const cut = (t) => { let out = t.split(/\r?\n/).slice(0, L.memoryLines).join('\n'); if (Buffer.byteLength(out) > L.memoryBytes) out = Buffer.from(out).subarray(0, L.memoryBytes).toString('utf8'); return out; };
167
177
  const idx = add(path.join(memDir, 'MEMORY.md'), 'auto-memory', 'auto memory index (first 200 lines / 25 KB)', { limitTo: cut });