@codyswann/lisa 4.32.0 → 4.32.2
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/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +673 -2
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +8 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +6 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +673 -2
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/inject-resolved-config.mjs +224 -17
- package/plugins/lisa/hooks/inject-resolved-config.sh +28 -0
- package/plugins/lisa/hooks/parity-safety-net.sh +673 -2
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +673 -2
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +224 -17
- package/plugins/lisa-copilot/hooks/inject-resolved-config.sh +28 -0
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +673 -2
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +224 -17
- package/plugins/lisa-cursor/hooks/inject-resolved-config.sh +28 -0
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +673 -2
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/inject-resolved-config.mjs +224 -17
- package/plugins/src/base/hooks/inject-resolved-config.sh +28 -0
- package/plugins/src/base/hooks/parity-safety-net.sh +673 -2
package/package.json
CHANGED
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
"zod-validation-error": "^4.0.0"
|
|
166
166
|
},
|
|
167
167
|
"name": "@codyswann/lisa",
|
|
168
|
-
"version": "4.32.
|
|
168
|
+
"version": "4.32.2",
|
|
169
169
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
170
170
|
"main": "dist/index.js",
|
|
171
171
|
"exports": {
|
|
@@ -313,7 +313,7 @@
|
|
|
313
313
|
"test": "tests"
|
|
314
314
|
},
|
|
315
315
|
"types": "./dist/index.d.ts",
|
|
316
|
-
"lisaReleaseCommit": "
|
|
317
|
-
"gitHead": "
|
|
318
|
-
"lisaReleaseTag": "v4.32.
|
|
316
|
+
"lisaReleaseCommit": "44180393649b76125ea1a90169e23cd98fa79ca1",
|
|
317
|
+
"gitHead": "44180393649b76125ea1a90169e23cd98fa79ca1",
|
|
318
|
+
"lisaReleaseTag": "v4.32.2"
|
|
319
319
|
}
|
|
@@ -116,27 +116,39 @@ const SENSITIVE_WORDS = new Set([
|
|
|
116
116
|
"actor",
|
|
117
117
|
"actors",
|
|
118
118
|
"apikey",
|
|
119
|
+
"arn",
|
|
119
120
|
"auth",
|
|
121
|
+
"authorization",
|
|
120
122
|
"bearer",
|
|
123
|
+
"cert",
|
|
124
|
+
"certificate",
|
|
125
|
+
"connection",
|
|
121
126
|
"cookie",
|
|
122
127
|
"credential",
|
|
123
128
|
"credentials",
|
|
124
129
|
"dsn",
|
|
125
130
|
"email",
|
|
126
131
|
"identity",
|
|
132
|
+
"jwt",
|
|
127
133
|
"key",
|
|
128
134
|
"keys",
|
|
129
135
|
"login",
|
|
130
136
|
"mail",
|
|
137
|
+
"mfa",
|
|
131
138
|
"oauth",
|
|
139
|
+
"otp",
|
|
132
140
|
"passphrase",
|
|
133
141
|
"passwd",
|
|
134
142
|
"password",
|
|
135
143
|
"pat",
|
|
144
|
+
"pem",
|
|
145
|
+
"pin",
|
|
146
|
+
"private",
|
|
136
147
|
"secret",
|
|
137
148
|
"session",
|
|
138
149
|
"signature",
|
|
139
150
|
"token",
|
|
151
|
+
"totp",
|
|
140
152
|
"user",
|
|
141
153
|
"username",
|
|
142
154
|
"webhook",
|
|
@@ -156,6 +168,36 @@ const SENSITIVE_WORDS = new Set([
|
|
|
156
168
|
*/
|
|
157
169
|
const EMAIL_SHAPED = /[^\s@,=]+@[^\s@,=]+\.[^\s@,=]+/g;
|
|
158
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Value shapes that are a credential whatever key they arrived under.
|
|
173
|
+
*
|
|
174
|
+
* {@link SENSITIVE_WORDS} can only ever cover key names somebody thought of.
|
|
175
|
+
* {@link SUBTREE_ORDER} is a SUBTREE allowlist, so a key added under an
|
|
176
|
+
* already-listed parent reaches every session with nobody reviewing it — which
|
|
177
|
+
* is the case these patterns exist for, and the reason the preamble's sentence
|
|
178
|
+
* was made to describe this mechanism rather than promise more than it has.
|
|
179
|
+
*
|
|
180
|
+
* Each pattern is anchored on a structural marker (a scheme with a
|
|
181
|
+
* `user:password@` userinfo, a vendor key prefix, three base64url segments),
|
|
182
|
+
* never on entropy. An entropy heuristic would redact ids a config legitimately
|
|
183
|
+
* carries — routine ids, revisions, digests — and this block exists to make
|
|
184
|
+
* configuration visible, so a false redaction is a real cost, not a free one.
|
|
185
|
+
*/
|
|
186
|
+
const CREDENTIAL_SHAPED = [
|
|
187
|
+
// A private key pasted into a config value.
|
|
188
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
189
|
+
// Credentials in a connection string's userinfo. The whole URL goes: the
|
|
190
|
+
// host is not worth keeping at the price of splicing a line back together
|
|
191
|
+
// around the part that was cut out.
|
|
192
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^\s:@,=]+:[^\s@,=]+@[^\s,=]*/g,
|
|
193
|
+
// Vendor-prefixed API keys.
|
|
194
|
+
/\b(?:[a-z]{2}_(?:live|test)_[A-Za-z0-9]{8,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|sk-[A-Za-z0-9_-]{16,})\b/g,
|
|
195
|
+
// A signed token: three base64url segments, header first.
|
|
196
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
|
197
|
+
// An ARN carries an account number in its fifth field.
|
|
198
|
+
/\barn:[a-z0-9-]*:[^\s,=]*/g,
|
|
199
|
+
];
|
|
200
|
+
|
|
159
201
|
/** What replaces a redacted value, so the KEY's existence is still visible. */
|
|
160
202
|
const REDACTED = "[redacted]";
|
|
161
203
|
|
|
@@ -168,9 +210,15 @@ const REDACTED = "[redacted]";
|
|
|
168
210
|
* arbitrary host project — and `tests/unit/hooks/inject-resolved-config.test.ts`
|
|
169
211
|
* imports the real constants and asserts this table still agrees with them, so
|
|
170
212
|
* the copy cannot drift silently.
|
|
213
|
+
*
|
|
214
|
+
* EXPORTED so that test can compare PATH-TO-VALUE PAIRS. Asserting instead that
|
|
215
|
+
* each constant appears somewhere in this file pins only the set of values
|
|
216
|
+
* present: swapping the `harness` and `gates.unproven` entries preserves that
|
|
217
|
+
* set, leaves every test in both suites green, and tells every session with no
|
|
218
|
+
* declared harness that its harness is `warn`.
|
|
171
219
|
* @type {ReadonlyArray<{path: string, value: string, owner: string}>}
|
|
172
220
|
*/
|
|
173
|
-
const BUILT_IN_DEFAULTS = [
|
|
221
|
+
export const BUILT_IN_DEFAULTS = [
|
|
174
222
|
{ path: "harness", value: "claude", owner: "src/core/config.ts" },
|
|
175
223
|
{
|
|
176
224
|
path: "gates.runner",
|
|
@@ -256,12 +304,15 @@ function isSensitivePath(path) {
|
|
|
256
304
|
}
|
|
257
305
|
|
|
258
306
|
/**
|
|
259
|
-
* Redact anything
|
|
307
|
+
* Redact anything identity- or credential-shaped in already-rendered text.
|
|
260
308
|
* @param {string} text Rendered line or block.
|
|
261
|
-
* @returns {string} The text with
|
|
309
|
+
* @returns {string} The text with those values replaced.
|
|
262
310
|
*/
|
|
263
311
|
function scrub(text) {
|
|
264
|
-
return
|
|
312
|
+
return CREDENTIAL_SHAPED.reduce(
|
|
313
|
+
(carried, pattern) => carried.replace(pattern, REDACTED),
|
|
314
|
+
text.replace(EMAIL_SHAPED, REDACTED)
|
|
315
|
+
);
|
|
265
316
|
}
|
|
266
317
|
|
|
267
318
|
/**
|
|
@@ -273,6 +324,35 @@ function isObject(value) {
|
|
|
273
324
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
274
325
|
}
|
|
275
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Keys that reach an object's prototype rather than its own properties.
|
|
329
|
+
*
|
|
330
|
+
* `JSON.parse` makes `__proto__` an OWN property, so a later `merged[key] =`
|
|
331
|
+
* hands it to the prototype setter — and `key in config` and `node[key]` both
|
|
332
|
+
* read the prototype chain. A local override file carrying nothing but a
|
|
333
|
+
* `__proto__` object could therefore make this block announce a gate that no
|
|
334
|
+
* file declares, while the gate runner (which destructures own properties)
|
|
335
|
+
* enforced something else. The injected view is the one an agent is told to
|
|
336
|
+
* act on, so it is the one that must not be forgeable.
|
|
337
|
+
*/
|
|
338
|
+
const PROTOTYPE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Strip prototype-reaching keys from parsed JSON, at every depth.
|
|
342
|
+
* @param {unknown} value A parsed JSON value.
|
|
343
|
+
* @returns {unknown} The same value with {@link PROTOTYPE_KEYS} removed.
|
|
344
|
+
*/
|
|
345
|
+
function withoutPrototypeKeys(value) {
|
|
346
|
+
if (Array.isArray(value)) return value.map(withoutPrototypeKeys);
|
|
347
|
+
if (!isObject(value)) return value;
|
|
348
|
+
const out = {};
|
|
349
|
+
for (const [key, child] of Object.entries(value)) {
|
|
350
|
+
if (PROTOTYPE_KEYS.has(key)) continue;
|
|
351
|
+
out[key] = withoutPrototypeKeys(child);
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
276
356
|
/**
|
|
277
357
|
* Deep-merge the local override onto the committed config.
|
|
278
358
|
*
|
|
@@ -290,11 +370,41 @@ function deepMerge(base, override) {
|
|
|
290
370
|
if (!isObject(base) || !isObject(override)) return override;
|
|
291
371
|
const merged = { ...base };
|
|
292
372
|
for (const [key, value] of Object.entries(override)) {
|
|
373
|
+
// Belt and braces: `readJson` has already stripped these, and this loop is
|
|
374
|
+
// the assignment that would otherwise reach the prototype setter.
|
|
375
|
+
if (PROTOTYPE_KEYS.has(key)) continue;
|
|
293
376
|
merged[key] = deepMerge(base[key], value);
|
|
294
377
|
}
|
|
295
378
|
return merged;
|
|
296
379
|
}
|
|
297
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Why a file could not be read, said without quoting the file or the disk.
|
|
383
|
+
*
|
|
384
|
+
* A `JSON.parse` failure message is not safe to repeat. V8 embeds roughly the
|
|
385
|
+
* first ten characters of the offending input in one of its forms — the entire
|
|
386
|
+
* file when the file is short — and this path runs against the gitignored local
|
|
387
|
+
* override, the one file identity is supposed to live in. There are no keys at
|
|
388
|
+
* that point, so the key-name filter cannot help. An `fs` failure message is
|
|
389
|
+
* not safe either: it carries the absolute path.
|
|
390
|
+
*
|
|
391
|
+
* Only the position is kept, because a line and column number describe the
|
|
392
|
+
* file's SHAPE and can be acted on, while quoting its contents adds nothing a
|
|
393
|
+
* reader with the file in front of them does not already have.
|
|
394
|
+
* @param {unknown} err What was thrown.
|
|
395
|
+
* @returns {string} A reason carrying no content and no path.
|
|
396
|
+
*/
|
|
397
|
+
function safeReason(err) {
|
|
398
|
+
const code = isObject(err) ? err.code : undefined;
|
|
399
|
+
if (typeof code === "string") return `${code} while reading the file`;
|
|
400
|
+
const message =
|
|
401
|
+
isObject(err) && typeof err.message === "string" ? err.message : "";
|
|
402
|
+
const position = /at position \d+(?: \(line \d+ column \d+\))?/.exec(message);
|
|
403
|
+
return position === null
|
|
404
|
+
? "not valid JSON"
|
|
405
|
+
: `not valid JSON ${position[0].replace("at position", "at byte")}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
298
408
|
/**
|
|
299
409
|
* Read one JSON file.
|
|
300
410
|
* @param {string} path Absolute path.
|
|
@@ -309,9 +419,9 @@ function readJson(path) {
|
|
|
309
419
|
if (!isObject(parsed)) {
|
|
310
420
|
return { state: "bad", reason: "root is not a JSON object" };
|
|
311
421
|
}
|
|
312
|
-
return { state: "ok", value: parsed };
|
|
422
|
+
return { state: "ok", value: withoutPrototypeKeys(parsed) };
|
|
313
423
|
} catch (err) {
|
|
314
|
-
return { state: "bad", reason: err
|
|
424
|
+
return { state: "bad", reason: safeReason(err) };
|
|
315
425
|
}
|
|
316
426
|
}
|
|
317
427
|
|
|
@@ -405,6 +515,41 @@ function levelOf(declaration) {
|
|
|
405
515
|
return declared !== null && LEVELS.has(declared) ? declared : null;
|
|
406
516
|
}
|
|
407
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Render one moment-and-level bucket across as many lines as its ids need.
|
|
520
|
+
*
|
|
521
|
+
* The generic {@link MAX_LINE} cut was being applied to the one line this
|
|
522
|
+
* module promises never elides: at 30 gates in a single bucket, six ids
|
|
523
|
+
* vanished behind `… (line truncated)` while the header above still counted
|
|
524
|
+
* them and the words beside it said not to bypass one. Wrapping is what keeps
|
|
525
|
+
* that promise — a count of what was dropped would still be an agent told the
|
|
526
|
+
* name of 24 gates out of 30 it must not skip.
|
|
527
|
+
* @param {string} bucket The `moment level` label.
|
|
528
|
+
* @param {string[]} ids Gate ids declared at that bucket.
|
|
529
|
+
* @returns {string[]} Lines, each within {@link MAX_LINE}.
|
|
530
|
+
*/
|
|
531
|
+
function bucketLines(bucket, ids) {
|
|
532
|
+
const lines = [];
|
|
533
|
+
let prefix = ` ${bucket}: `;
|
|
534
|
+
let current = [];
|
|
535
|
+
const flush = () => {
|
|
536
|
+
if (current.length === 0) return;
|
|
537
|
+
lines.push(`${prefix}${current.join(", ")}`);
|
|
538
|
+
prefix = ` ${bucket} (cont.): `;
|
|
539
|
+
current = [];
|
|
540
|
+
};
|
|
541
|
+
for (const id of ids) {
|
|
542
|
+
const width =
|
|
543
|
+
prefix.length +
|
|
544
|
+
current.reduce((carried, held) => carried + held.length + 2, 0) +
|
|
545
|
+
id.length;
|
|
546
|
+
if (current.length > 0 && width > MAX_LINE) flush();
|
|
547
|
+
current.push(id);
|
|
548
|
+
}
|
|
549
|
+
flush();
|
|
550
|
+
return lines;
|
|
551
|
+
}
|
|
552
|
+
|
|
408
553
|
/**
|
|
409
554
|
* Render the gates block grouped by moment and level.
|
|
410
555
|
*
|
|
@@ -441,7 +586,7 @@ function renderGates(gates) {
|
|
|
441
586
|
`gates (${count} declared) — a gate below is enforced at that moment; do not skip, weaken or bypass one:`,
|
|
442
587
|
...[...grouped.entries()]
|
|
443
588
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
444
|
-
.
|
|
589
|
+
.flatMap(([bucket, ids]) => bucketLines(bucket, ids)),
|
|
445
590
|
];
|
|
446
591
|
}
|
|
447
592
|
|
|
@@ -473,8 +618,25 @@ function renderDefaults(config) {
|
|
|
473
618
|
* @returns {string} The rendered body.
|
|
474
619
|
*/
|
|
475
620
|
function renderBody(config) {
|
|
621
|
+
// The gap lines go FIRST, and that ordering is the whole point rather than a
|
|
622
|
+
// presentation choice. `withinBudget` truncates from the end, so appending
|
|
623
|
+
// them last meant the budget ate them first: a config with a large `policy`
|
|
624
|
+
// block and no `tracker` rendered ZERO `NOT DECLARED` lines and zero
|
|
625
|
+
// built-in-default lines — "a gap has no symptom at all", the failure this
|
|
626
|
+
// hook was built against, reproduced inside the hook. A value that does not
|
|
627
|
+
// fit can be looked up in the file. An absence cannot be looked up anywhere,
|
|
628
|
+
// so it is the line that must survive.
|
|
476
629
|
/** @type {string[]} */
|
|
477
|
-
const lines =
|
|
630
|
+
const lines = renderDefaults(config);
|
|
631
|
+
const gapLines = lines.length;
|
|
632
|
+
// The gates block is the one this module promises never elides, so the lines
|
|
633
|
+
// it produced are named here rather than inferred from their shape later.
|
|
634
|
+
// `bucketLines` keeps every line inside MAX_LINE by wrapping, but it cannot
|
|
635
|
+
// wrap a SINGLE id longer than the line — nothing splits an identifier — so
|
|
636
|
+
// without this the generic cut would come back for exactly the id that most
|
|
637
|
+
// needs reading in full.
|
|
638
|
+
/** @type {Set<string>} */
|
|
639
|
+
const unelidable = new Set();
|
|
478
640
|
const seen = new Set();
|
|
479
641
|
for (const key of SUBTREE_ORDER) {
|
|
480
642
|
if (!(key in config)) continue;
|
|
@@ -488,7 +650,9 @@ function renderBody(config) {
|
|
|
488
650
|
if (unproven !== undefined) {
|
|
489
651
|
lines.push(`gates.unproven: ${renderLeaf(unproven)}`);
|
|
490
652
|
}
|
|
491
|
-
|
|
653
|
+
const gateLines = renderGates(gates);
|
|
654
|
+
for (const gateLine of gateLines) unelidable.add(gateLine);
|
|
655
|
+
lines.push(...gateLines);
|
|
492
656
|
continue;
|
|
493
657
|
}
|
|
494
658
|
renderSubtree(config[key], key, lines);
|
|
@@ -501,16 +665,47 @@ function renderBody(config) {
|
|
|
501
665
|
`other declared keys (not summarised here): ${unlisted.join(", ")}`
|
|
502
666
|
);
|
|
503
667
|
}
|
|
504
|
-
lines
|
|
505
|
-
|
|
668
|
+
return withinBudget(lines, { unelidable, gapLines });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* The overflow notice, for a body that kept `keptCount` of `total` lines.
|
|
673
|
+
*
|
|
674
|
+
* Built from the counts rather than held as a constant because the notice is
|
|
675
|
+
* itself part of the body it describes: making room for it changes how many
|
|
676
|
+
* lines were dropped, which changes the sentence, which changes how much room
|
|
677
|
+
* it needs. See {@link withinBudget}.
|
|
678
|
+
* @param {number} total How many lines were rendered in all.
|
|
679
|
+
* @param {number} keptCount How many of them survived the budget.
|
|
680
|
+
* @param {number} gapLines How many leading lines describe gaps and defaults.
|
|
681
|
+
* @returns {string} The notice line.
|
|
682
|
+
*/
|
|
683
|
+
function overflowNotice(total, keptCount, gapLines) {
|
|
684
|
+
// Two things this notice used to get wrong. It counted LINES while reading as
|
|
685
|
+
// if it counted keys — one line carries many leaves — and it sent the reader
|
|
686
|
+
// to the config file for lines that no file can answer. The second is fixed
|
|
687
|
+
// by ordering (`renderBody` renders the declared-vs-default lines first, so
|
|
688
|
+
// they are never the omitted ones); saying so is what makes the advice true.
|
|
689
|
+
// The claim stays CONDITIONAL, because a config whose gap lines alone
|
|
690
|
+
// exhaust the budget would otherwise have this sentence assert a
|
|
691
|
+
// completeness the body does not have — the exact defect this hook exists to
|
|
692
|
+
// remove, committed by the sentence announcing it.
|
|
693
|
+
const completeness =
|
|
694
|
+
keptCount >= gapLines
|
|
695
|
+
? `The declared-vs-default lines above are complete — read ${MAIN_CONFIG} and ${LOCAL_CONFIG} for the omitted values.`
|
|
696
|
+
: `The declared-vs-default lines above are NOT complete, so a gap may be missing entirely — read ${MAIN_CONFIG} and ${LOCAL_CONFIG}.`;
|
|
697
|
+
return `… ${total - keptCount} further rendered line(s) omitted to stay within the session-context budget; a line can carry several keys, so more keys than that are unrendered. ${completeness}`;
|
|
506
698
|
}
|
|
507
699
|
|
|
508
700
|
/**
|
|
509
701
|
* Join lines, stopping at the budget and counting what did not fit.
|
|
510
702
|
* @param {string[]} lines Rendered lines.
|
|
703
|
+
* @param {object} [options] Rendering constraints.
|
|
704
|
+
* @param {Set<string>} [options.unelidable] Lines the MAX_LINE cut may not touch.
|
|
705
|
+
* @param {number} [options.gapLines] How many leading lines describe gaps.
|
|
511
706
|
* @returns {string} The joined body.
|
|
512
707
|
*/
|
|
513
|
-
function withinBudget(lines) {
|
|
708
|
+
function withinBudget(lines, { unelidable = new Set(), gapLines = 0 } = {}) {
|
|
514
709
|
const kept = [];
|
|
515
710
|
let size = 0;
|
|
516
711
|
for (const raw of lines) {
|
|
@@ -519,13 +714,23 @@ function withinBudget(lines) {
|
|
|
519
714
|
// joined body afterwards would be redacting text that had already escaped.
|
|
520
715
|
const scrubbed = scrub(raw);
|
|
521
716
|
const line =
|
|
522
|
-
scrubbed.length > MAX_LINE
|
|
717
|
+
scrubbed.length > MAX_LINE && !unelidable.has(raw)
|
|
523
718
|
? `${scrubbed.slice(0, MAX_LINE)}… (line truncated)`
|
|
524
719
|
: scrubbed;
|
|
525
720
|
if (size + line.length > CONTEXT_BUDGET) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
721
|
+
// The notice has to fit INSIDE the budget, not be appended past it. It
|
|
722
|
+
// used to be pushed after the check, so a body that filled the budget
|
|
723
|
+
// exactly overshot it by the length of the sentence explaining that it
|
|
724
|
+
// had not. Lines come off the end until it fits, and the notice is
|
|
725
|
+
// rebuilt each time because dropping one is one more line to admit to.
|
|
726
|
+
while (
|
|
727
|
+
kept.length > 0 &&
|
|
728
|
+
size + overflowNotice(lines.length, kept.length, gapLines).length >
|
|
729
|
+
CONTEXT_BUDGET
|
|
730
|
+
) {
|
|
731
|
+
size -= kept.pop().length + 1;
|
|
732
|
+
}
|
|
733
|
+
kept.push(overflowNotice(lines.length, kept.length, gapLines));
|
|
529
734
|
break;
|
|
530
735
|
}
|
|
531
736
|
kept.push(line);
|
|
@@ -590,7 +795,9 @@ export function buildContext(projectDir) {
|
|
|
590
795
|
`These are this project's EFFECTIVE Lisa configuration values, already resolved from ${sources}.\n` +
|
|
591
796
|
`Act on them directly. Do not re-read the config files to learn them, and do not act ` +
|
|
592
797
|
`against them. A line marked [Lisa built-in default] is NOT something this project ` +
|
|
593
|
-
`declared.
|
|
798
|
+
`declared. A key whose NAME is identity- or credential-shaped is withheld and counted, ` +
|
|
799
|
+
`and a VALUE matching a known credential shape is replaced with ${REDACTED}: a ` +
|
|
800
|
+
`best-effort filter, not proof that anything it left alone is safe to repeat.\n\n` +
|
|
594
801
|
renderBody(resolved)
|
|
595
802
|
);
|
|
596
803
|
}
|
|
@@ -44,5 +44,33 @@ if [ -z "${PROJECT_DIR}" ] || [ ! -d "${PROJECT_DIR}" ]; then
|
|
|
44
44
|
PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
45
45
|
fi
|
|
46
46
|
|
|
47
|
+
# ...but the harness sets CLAUDE_PROJECT_DIR to wherever the session STARTED,
|
|
48
|
+
# which in a monorepo is routinely a subdirectory. Validating only that the
|
|
49
|
+
# directory exists left the git fallback above unreachable in exactly that case,
|
|
50
|
+
# so a fully configured repository reported "No Lisa configuration found" — the
|
|
51
|
+
# loudest possible wrong answer, in the one state this hook exists to make loud.
|
|
52
|
+
#
|
|
53
|
+
# So walk up for the config instead of assuming the declared directory holds it.
|
|
54
|
+
# The repository root bounds the walk: a config file ABOVE it belongs to some
|
|
55
|
+
# other project, and adopting it would be the same wrong answer wearing a
|
|
56
|
+
# different hat. `.git` is a file in a worktree and a directory otherwise, hence
|
|
57
|
+
# `-e`. The depth cap is a symlink-loop guard, not a policy.
|
|
58
|
+
config_root() {
|
|
59
|
+
dir="$1"
|
|
60
|
+
depth=0
|
|
61
|
+
while [ -n "${dir}" ] && [ "${dir}" != "/" ] && [ "${depth}" -lt 32 ]; do
|
|
62
|
+
if [ -f "${dir}/.lisa.config.json" ] || [ -f "${dir}/.lisa.config.local.json" ]; then
|
|
63
|
+
printf '%s' "${dir}"
|
|
64
|
+
return 0
|
|
65
|
+
fi
|
|
66
|
+
[ -e "${dir}/.git" ] && return 1
|
|
67
|
+
dir="$(dirname "${dir}")"
|
|
68
|
+
depth=$((depth + 1))
|
|
69
|
+
done
|
|
70
|
+
return 1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
CONFIG_DIR="$(config_root "${PROJECT_DIR}")" && PROJECT_DIR="${CONFIG_DIR}"
|
|
74
|
+
|
|
47
75
|
printf '%s' "$INPUT" | node "$RENDERER" --project-dir "$PROJECT_DIR" || true
|
|
48
76
|
exit 0
|