@ijfw/memory-server 1.4.0 → 1.4.1
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/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +30 -4
- package/src/dashboard-client.html +210 -1
- package/src/dashboard-server.js +243 -0
- package/src/dispatch/extension.js +234 -1
- package/src/extension-installer.js +39 -0
- package/src/extension-permission-check.mjs +79 -0
- package/src/extension-registry.js +619 -0
- package/src/extension-signer.js +165 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +30 -1
package/src/extension-signer.js
CHANGED
|
@@ -641,6 +641,52 @@ export function verifyManifestSignature(manifest, trustedKeys) {
|
|
|
641
641
|
return { valid: true, publisherKeyId: kid, reason: 'ok' };
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
// === B6: Revoked publishers store ========================================
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Read the revoked publishers list from ~/.ijfw/state/revoked-publishers.json.
|
|
648
|
+
* Returns an empty list when absent or malformed.
|
|
649
|
+
*
|
|
650
|
+
* @returns {Promise<Array<{keyId: string, revoked_at: string, reason: string, superseded_by: string|null}>>}
|
|
651
|
+
*/
|
|
652
|
+
export async function readRevokedPublishers() {
|
|
653
|
+
const path = join(homedir(), '.ijfw', 'state', 'revoked-publishers.json');
|
|
654
|
+
let raw;
|
|
655
|
+
try {
|
|
656
|
+
raw = await readFile(path, 'utf8');
|
|
657
|
+
} catch {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
let parsed;
|
|
661
|
+
try {
|
|
662
|
+
parsed = JSON.parse(raw);
|
|
663
|
+
} catch {
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
if (!parsed || !Array.isArray(parsed.revoked)) return [];
|
|
667
|
+
return parsed.revoked.filter(r => typeof r.keyId === 'string');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Module-level revoked set cache — loaded once per process, refreshed by applyRegistry.
|
|
671
|
+
// Export for test isolation only (allows tests to reset after changing HOME).
|
|
672
|
+
export function _resetRevokedCacheForTest() { _revokedSet = null; }
|
|
673
|
+
let _revokedSet = null;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* O(1) check: is a keyId on the revoked list?
|
|
677
|
+
* Loads the set on first call; cached for the process lifetime.
|
|
678
|
+
*
|
|
679
|
+
* @param {string} keyId
|
|
680
|
+
* @returns {Promise<boolean>}
|
|
681
|
+
*/
|
|
682
|
+
export async function isRevoked(keyId) {
|
|
683
|
+
if (_revokedSet === null) {
|
|
684
|
+
const list = await readRevokedPublishers();
|
|
685
|
+
_revokedSet = new Set(list.map(r => r.keyId));
|
|
686
|
+
}
|
|
687
|
+
return _revokedSet.has(keyId);
|
|
688
|
+
}
|
|
689
|
+
|
|
644
690
|
/**
|
|
645
691
|
* Read the trusted publishers JSON. Returns `{publishers: {}}` when absent or
|
|
646
692
|
* malformed (fail-closed for verification: no trusted keys means nothing is
|
|
@@ -701,6 +747,10 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
|
|
|
701
747
|
if (typeof publicKey !== 'string' || publicKey.indexOf('BEGIN PUBLIC KEY') === -1) {
|
|
702
748
|
return { ok: false, error: 'publicKey must be PEM-encoded' };
|
|
703
749
|
}
|
|
750
|
+
// B6: refuse to add a revoked publisher
|
|
751
|
+
if (await isRevoked(keyId)) {
|
|
752
|
+
return { ok: false, error: 'publisher revoked by IJFW registry' };
|
|
753
|
+
}
|
|
704
754
|
let fp;
|
|
705
755
|
try {
|
|
706
756
|
fp = publicKeyFingerprint(publicKey);
|
|
@@ -720,6 +770,121 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
|
|
|
720
770
|
return { ok: true, store };
|
|
721
771
|
}
|
|
722
772
|
|
|
773
|
+
// === B8: Key rotation + revocation ==========================================
|
|
774
|
+
//
|
|
775
|
+
// Rotation token is signed by the OLD private key — proof of control.
|
|
776
|
+
// An attacker without the old private key cannot produce a valid token.
|
|
777
|
+
// A publisher who lost their old private key must contact the registry
|
|
778
|
+
// maintainer for out-of-band manual key replacement (see docs/REGISTRY-MAINTAINER.md).
|
|
779
|
+
//
|
|
780
|
+
// Token shape: { rotated_at, old_key_id, new_key_id, new_public_key, signature }
|
|
781
|
+
// Canonical signing bytes: all fields except `signature`, sorted by key (sortKeysDeep).
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Produce a rotation token asserting that newPublicKey supersedes the key
|
|
785
|
+
* identified by oldPrivateKey. Signed by the old private key.
|
|
786
|
+
*
|
|
787
|
+
* @param {string} oldPrivateKeyPem
|
|
788
|
+
* @param {string} newPublicKeyPem
|
|
789
|
+
* @param {object} [opts]
|
|
790
|
+
* @param {string} [opts.rotated_at] ISO timestamp (defaults to now)
|
|
791
|
+
* @returns {{ rotated_at: string, old_key_id: string, new_key_id: string, new_public_key: string, signature: string }}
|
|
792
|
+
*/
|
|
793
|
+
export function signRotationToken(oldPrivateKeyPem, newPublicKeyPem, opts = {}) {
|
|
794
|
+
const priv = createPrivateKey(oldPrivateKeyPem);
|
|
795
|
+
// Derive old_key_id from the old private key's matching public key.
|
|
796
|
+
const oldPub = createPublicKey(priv);
|
|
797
|
+
const old_key_id = publicKeyFingerprint(oldPub.export({ type: 'spki', format: 'pem' }).toString());
|
|
798
|
+
const new_key_id = publicKeyFingerprint(newPublicKeyPem);
|
|
799
|
+
|
|
800
|
+
const token = {
|
|
801
|
+
rotated_at: opts.rotated_at || new Date().toISOString(),
|
|
802
|
+
old_key_id,
|
|
803
|
+
new_key_id,
|
|
804
|
+
new_public_key: newPublicKeyPem,
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// Canonical signing bytes: sortKeysDeep of token (signature excluded — not present yet).
|
|
808
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
|
|
809
|
+
const sigBuf = cryptoSign(null, bytes, priv);
|
|
810
|
+
return { ...token, signature: `ed25519:${sigBuf.toString('base64')}` };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Verify a rotation token against the old public key.
|
|
815
|
+
* Checks:
|
|
816
|
+
* 1. Signature is valid Ed25519 over canonical bytes (signature field excluded).
|
|
817
|
+
* 2. fingerprint(oldPublicKey) === token.old_key_id.
|
|
818
|
+
* 3. token.rotated_at is within opts.max_age_ms (default 90 days).
|
|
819
|
+
*
|
|
820
|
+
* @param {object} token
|
|
821
|
+
* @param {string} oldPublicKeyPem
|
|
822
|
+
* @param {object} [opts]
|
|
823
|
+
* @param {number} [opts.max_age_ms] Maximum token age in ms (default 90 days)
|
|
824
|
+
* @returns {{ valid: boolean, reason: string }}
|
|
825
|
+
*/
|
|
826
|
+
export function verifyRotationToken(token, oldPublicKeyPem, opts = {}) {
|
|
827
|
+
if (!token || typeof token !== 'object') {
|
|
828
|
+
return { valid: false, reason: 'token must be an object' };
|
|
829
|
+
}
|
|
830
|
+
const { rotated_at, old_key_id, new_key_id, new_public_key, signature } = token;
|
|
831
|
+
if (!rotated_at || !old_key_id || !new_key_id || !new_public_key || !signature) {
|
|
832
|
+
return { valid: false, reason: 'token missing required fields' };
|
|
833
|
+
}
|
|
834
|
+
if (typeof signature !== 'string' || !signature.startsWith('ed25519:')) {
|
|
835
|
+
return { valid: false, reason: 'signature must be "ed25519:<base64>"' };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Expiry check: reject tokens older than max_age_ms (default 90 days).
|
|
839
|
+
const MAX_AGE_MS = opts.max_age_ms ?? (90 * 24 * 60 * 60 * 1000);
|
|
840
|
+
const rotatedAtMs = new Date(rotated_at).getTime();
|
|
841
|
+
if (isNaN(rotatedAtMs)) {
|
|
842
|
+
return { valid: false, reason: 'rotated_at is not a valid date' };
|
|
843
|
+
}
|
|
844
|
+
if (Date.now() - rotatedAtMs > MAX_AGE_MS) {
|
|
845
|
+
return { valid: false, reason: 'rotation token expired' };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Check old_key_id matches the supplied public key fingerprint.
|
|
849
|
+
let fp;
|
|
850
|
+
try {
|
|
851
|
+
fp = publicKeyFingerprint(oldPublicKeyPem);
|
|
852
|
+
} catch (err) {
|
|
853
|
+
return { valid: false, reason: `old public key parse failed: ${err.message}` };
|
|
854
|
+
}
|
|
855
|
+
if (fp !== old_key_id) {
|
|
856
|
+
return { valid: false, reason: `old_key_id mismatch: token says ${old_key_id} but supplied key fingerprints to ${fp}` };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Reconstruct canonical signing bytes (exclude signature field).
|
|
860
|
+
const payload = { rotated_at, old_key_id, new_key_id, new_public_key };
|
|
861
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(payload)), 'utf8');
|
|
862
|
+
|
|
863
|
+
let pubKey;
|
|
864
|
+
try {
|
|
865
|
+
pubKey = createPublicKey(oldPublicKeyPem);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
return { valid: false, reason: `old public key unparseable: ${err.message}` };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let sigBuf;
|
|
871
|
+
try {
|
|
872
|
+
sigBuf = Buffer.from(signature.slice('ed25519:'.length), 'base64');
|
|
873
|
+
} catch {
|
|
874
|
+
return { valid: false, reason: 'signature base64 decode failed' };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let ok;
|
|
878
|
+
try {
|
|
879
|
+
ok = cryptoVerify(null, bytes, pubKey, sigBuf);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
return { valid: false, reason: `verify threw: ${err.message}` };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (!ok) return { valid: false, reason: 'signature does not verify' };
|
|
885
|
+
return { valid: true, reason: 'ok' };
|
|
886
|
+
}
|
|
887
|
+
|
|
723
888
|
/**
|
|
724
889
|
* Remove a trusted publisher entry by keyId. Idempotent.
|
|
725
890
|
*
|
package/src/memory-feedback.js
CHANGED
|
@@ -89,20 +89,16 @@ export async function readRecentReceipts(projectRoot, limit = 50) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* detectRepeatedFail(receipts, opts)
|
|
93
93
|
*
|
|
94
94
|
* Examines the last `opts.window` (default 10) receipts for repeated FAIL/FLAG
|
|
95
95
|
* on the same affected_artifacts[].type value.
|
|
96
96
|
*
|
|
97
|
-
* If a single artifact_type appears in >= opts.threshold (default 3) receipts
|
|
98
|
-
* within the window with a FAIL or FLAG verdict, one pattern object is emitted
|
|
99
|
-
* for that artifact_type.
|
|
100
|
-
*
|
|
101
97
|
* @param {object[]} receipts
|
|
102
98
|
* @param {{ threshold?: number, window?: number }} [opts]
|
|
103
99
|
* @returns {Array<{ kind: string, artifact_type: string, count: number, threshold: number, sample: string[] }>}
|
|
104
100
|
*/
|
|
105
|
-
|
|
101
|
+
function detectRepeatedFail(receipts, opts = {}) {
|
|
106
102
|
const threshold = typeof opts.threshold === 'number' ? opts.threshold : 3;
|
|
107
103
|
const window = typeof opts.window === 'number' ? opts.window : 10;
|
|
108
104
|
|
|
@@ -152,6 +148,192 @@ export function detectPatterns(receipts, opts = {}) {
|
|
|
152
148
|
return patterns;
|
|
153
149
|
}
|
|
154
150
|
|
|
151
|
+
/**
|
|
152
|
+
* detectRisingFailRate(receipts, opts)
|
|
153
|
+
*
|
|
154
|
+
* Compares the fail rate in the most recent `window` receipts to the `window`
|
|
155
|
+
* receipts before that. If the rate rose by >= minRise (absolute), emits a
|
|
156
|
+
* rising-fail-rate pattern.
|
|
157
|
+
*
|
|
158
|
+
* @param {object[]} receipts
|
|
159
|
+
* @param {{ window?: number, minRise?: number }} [opts]
|
|
160
|
+
* @returns {Array<{ kind: string, from_rate: number, to_rate: number, window: number, suggestion: string }>}
|
|
161
|
+
*/
|
|
162
|
+
export function detectRisingFailRate(receipts, opts = {}) {
|
|
163
|
+
try {
|
|
164
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 20;
|
|
165
|
+
const minRise = typeof opts.minRise === 'number' ? opts.minRise : 0.2;
|
|
166
|
+
|
|
167
|
+
if (!Array.isArray(receipts) || receipts.length < 2) return [];
|
|
168
|
+
|
|
169
|
+
const recent = receipts.slice(0, window);
|
|
170
|
+
const prior = receipts.slice(window, window * 2);
|
|
171
|
+
|
|
172
|
+
if (prior.length === 0) return [];
|
|
173
|
+
|
|
174
|
+
const failRate = (arr) => {
|
|
175
|
+
const valid = arr.filter((r) => r && typeof r === 'object' && typeof r.verdict === 'string');
|
|
176
|
+
if (valid.length === 0) return 0;
|
|
177
|
+
return valid.filter((r) => FAIL_VERDICTS.has(r.verdict)).length / valid.length;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const fromRate = failRate(prior);
|
|
181
|
+
const toRate = failRate(recent);
|
|
182
|
+
|
|
183
|
+
if (toRate - fromRate < minRise) return [];
|
|
184
|
+
|
|
185
|
+
const fromPct = Math.round(fromRate * 100);
|
|
186
|
+
const toPct = Math.round(toRate * 100);
|
|
187
|
+
|
|
188
|
+
return [{
|
|
189
|
+
kind: 'rising-fail-rate',
|
|
190
|
+
from_rate: fromRate,
|
|
191
|
+
to_rate: toRate,
|
|
192
|
+
window,
|
|
193
|
+
suggestion: `gate fail rate rose from ${fromPct}% to ${toPct}% in the last ${window} receipts — consider rolling back the most recent changes`,
|
|
194
|
+
}];
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* detectCrossSkillCorrelation(receipts, opts)
|
|
202
|
+
*
|
|
203
|
+
* Looks at the last `window` receipts. If >= minDistinctGates distinct gate_id
|
|
204
|
+
* prefixes (split on first `-` or `:`) have a FAIL/FLAG verdict, emits a
|
|
205
|
+
* cross-skill-correlation pattern.
|
|
206
|
+
*
|
|
207
|
+
* @param {object[]} receipts
|
|
208
|
+
* @param {{ window?: number, minDistinctGates?: number }} [opts]
|
|
209
|
+
* @returns {Array<{ kind: string, distinct_gates: number, window: number, suggestion: string }>}
|
|
210
|
+
*/
|
|
211
|
+
export function detectCrossSkillCorrelation(receipts, opts = {}) {
|
|
212
|
+
try {
|
|
213
|
+
const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 10;
|
|
214
|
+
const minDistinctGates = typeof opts.minDistinctGates === 'number' ? opts.minDistinctGates : 3;
|
|
215
|
+
|
|
216
|
+
if (!Array.isArray(receipts) || receipts.length === 0) return [];
|
|
217
|
+
|
|
218
|
+
const windowReceipts = receipts.slice(0, window);
|
|
219
|
+
const prefixes = new Set();
|
|
220
|
+
|
|
221
|
+
for (const receipt of windowReceipts) {
|
|
222
|
+
if (!receipt || typeof receipt !== 'object') continue;
|
|
223
|
+
if (!FAIL_VERDICTS.has(receipt.verdict)) continue;
|
|
224
|
+
if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
|
|
225
|
+
|
|
226
|
+
// Take the prefix before the first `-` or `:`
|
|
227
|
+
const prefix = receipt.gate_id.split(/[-:]/)[0];
|
|
228
|
+
if (prefix) prefixes.add(prefix);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (prefixes.size < minDistinctGates) return [];
|
|
232
|
+
|
|
233
|
+
return [{
|
|
234
|
+
kind: 'cross-skill-correlation',
|
|
235
|
+
distinct_gates: prefixes.size,
|
|
236
|
+
window,
|
|
237
|
+
suggestion: `${prefixes.size} different gates flagged in the last ${window} receipts — review project state, not individual artifacts`,
|
|
238
|
+
}];
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* detectRegression(receipts, opts)
|
|
246
|
+
*
|
|
247
|
+
* For each unique (gate_id, artifact_type) key in receipts: if the most recent
|
|
248
|
+
* `failWindow` receipts were all FAIL/FLAG but the `passWindow` receipts before
|
|
249
|
+
* that were all PASS, emits a regression pattern.
|
|
250
|
+
*
|
|
251
|
+
* artifact_type is the TYPE field (e.g. 'chapter'), never the ID.
|
|
252
|
+
*
|
|
253
|
+
* @param {object[]} receipts
|
|
254
|
+
* @param {{ passWindow?: number, failWindow?: number }} [opts]
|
|
255
|
+
* @returns {Array<{ kind: string, gate_id: string, artifact_type: string, suggestion: string }>}
|
|
256
|
+
*/
|
|
257
|
+
export function detectRegression(receipts, opts = {}) {
|
|
258
|
+
try {
|
|
259
|
+
const passWindow = typeof opts.passWindow === 'number' && opts.passWindow > 0 ? opts.passWindow : 5;
|
|
260
|
+
const failWindow = typeof opts.failWindow === 'number' && opts.failWindow > 0 ? opts.failWindow : 2;
|
|
261
|
+
|
|
262
|
+
if (!Array.isArray(receipts) || receipts.length === 0) return [];
|
|
263
|
+
|
|
264
|
+
// Build per-(gate_id, artifact_type) ordered lists (receipts[0] = most recent).
|
|
265
|
+
// receipts are assumed newest-first (as returned by readRecentReceipts).
|
|
266
|
+
const streams = new Map(); // key -> [receipt, ...]
|
|
267
|
+
|
|
268
|
+
for (const receipt of receipts) {
|
|
269
|
+
if (!receipt || typeof receipt !== 'object') continue;
|
|
270
|
+
if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
|
|
271
|
+
if (!Array.isArray(receipt.affected_artifacts)) continue;
|
|
272
|
+
|
|
273
|
+
const seenTypes = new Set();
|
|
274
|
+
for (const artifact of receipt.affected_artifacts) {
|
|
275
|
+
if (!artifact || typeof artifact !== 'object') continue;
|
|
276
|
+
if (typeof artifact.type !== 'string' || artifact.type.length === 0) continue;
|
|
277
|
+
|
|
278
|
+
const t = artifact.type;
|
|
279
|
+
if (seenTypes.has(t)) continue;
|
|
280
|
+
seenTypes.add(t);
|
|
281
|
+
|
|
282
|
+
const key = `${receipt.gate_id}\x00${t}`;
|
|
283
|
+
if (!streams.has(key)) streams.set(key, []);
|
|
284
|
+
streams.get(key).push(receipt);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const patterns = [];
|
|
289
|
+
|
|
290
|
+
for (const [key, stream] of streams.entries()) {
|
|
291
|
+
if (stream.length < failWindow + passWindow) continue;
|
|
292
|
+
|
|
293
|
+
const recentSlice = stream.slice(0, failWindow);
|
|
294
|
+
const priorSlice = stream.slice(failWindow, failWindow + passWindow);
|
|
295
|
+
|
|
296
|
+
const allRecentFail = recentSlice.every((r) => FAIL_VERDICTS.has(r.verdict));
|
|
297
|
+
const allPriorPass = priorSlice.every((r) => r.verdict === 'PASS');
|
|
298
|
+
|
|
299
|
+
if (!allRecentFail || !allPriorPass) continue;
|
|
300
|
+
|
|
301
|
+
const [gate_id, artifact_type] = key.split('\x00');
|
|
302
|
+
patterns.push({
|
|
303
|
+
kind: 'regression',
|
|
304
|
+
gate_id,
|
|
305
|
+
artifact_type,
|
|
306
|
+
suggestion: `gate ${gate_id} on ${artifact_type} was passing last ${passWindow} runs; failing now — likely regression`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return patterns;
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* detectPatterns(receipts, opts)
|
|
318
|
+
*
|
|
319
|
+
* Dispatcher: runs all four detectors and returns the union in deterministic
|
|
320
|
+
* order: repeated-fail-on-same-artifact, rising-fail-rate, cross-skill-correlation,
|
|
321
|
+
* regression.
|
|
322
|
+
*
|
|
323
|
+
* @param {object[]} receipts
|
|
324
|
+
* @param {{ threshold?: number, window?: number }} [opts]
|
|
325
|
+
* @returns {object[]}
|
|
326
|
+
*/
|
|
327
|
+
export function detectPatterns(receipts, opts = {}) {
|
|
328
|
+
if (!Array.isArray(receipts)) return [];
|
|
329
|
+
return [
|
|
330
|
+
...detectRepeatedFail(receipts, opts),
|
|
331
|
+
...detectRisingFailRate(receipts, opts),
|
|
332
|
+
...detectCrossSkillCorrelation(receipts, opts),
|
|
333
|
+
...detectRegression(receipts, opts),
|
|
334
|
+
];
|
|
335
|
+
}
|
|
336
|
+
|
|
155
337
|
/**
|
|
156
338
|
* getFeedbackSuggestions(projectRoot, opts)
|
|
157
339
|
*
|
|
@@ -178,10 +360,12 @@ export async function getFeedbackSuggestions(projectRoot, opts = {}) {
|
|
|
178
360
|
const receipts = await readRecentReceipts(projectRoot, limit);
|
|
179
361
|
const patterns = detectPatterns(receipts, { threshold, window });
|
|
180
362
|
|
|
181
|
-
return patterns.map(
|
|
182
|
-
(p)
|
|
183
|
-
`Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope
|
|
184
|
-
|
|
363
|
+
return patterns.map((p) => {
|
|
364
|
+
if (p.kind === 'repeated-fail-on-same-artifact') {
|
|
365
|
+
return `Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope`;
|
|
366
|
+
}
|
|
367
|
+
return `Pattern detected: ${p.suggestion}`;
|
|
368
|
+
});
|
|
185
369
|
} catch {
|
|
186
370
|
return [];
|
|
187
371
|
}
|
package/src/runtime-mediator.js
CHANGED
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
* pass -- that would defeat the sandbox.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readFile, mkdir, appendFile } from 'node:fs/promises';
|
|
20
|
+
import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
|
|
24
|
+
// Log rotation: when permission-events.jsonl exceeds this many lines, rename
|
|
25
|
+
// to .0 (overwriting any prior .0) and start fresh. Total on disk = 2 * cap.
|
|
26
|
+
const ROTATION_LINE_CAP = 10_000;
|
|
27
|
+
|
|
24
28
|
// Sentinel returned from getActiveExtension when the file exists but is
|
|
25
29
|
// invalid. Callers compare with === to distinguish from null (no file).
|
|
26
30
|
const MALFORMED = Object.freeze({ __malformed: true });
|
|
@@ -123,14 +127,39 @@ export function checkPermission(action, target, activeExt) {
|
|
|
123
127
|
};
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Rotate permission-events.jsonl if it exceeds ROTATION_LINE_CAP lines.
|
|
132
|
+
* Renames current file to .0 (overwriting any prior .0) then the caller
|
|
133
|
+
* appends to the fresh empty file. Best effort -- never throws.
|
|
134
|
+
*/
|
|
135
|
+
async function maybeRotateEventLog(logPath) {
|
|
136
|
+
try {
|
|
137
|
+
let st;
|
|
138
|
+
try { st = await stat(logPath); } catch { return; } // ENOENT = no rotation needed
|
|
139
|
+
if (st.size === 0) return;
|
|
140
|
+
// Count newlines in a single Buffer read. This is the amortised cost.
|
|
141
|
+
const buf = await readFile(logPath);
|
|
142
|
+
let count = 0;
|
|
143
|
+
for (let i = 0; i < buf.length; i++) {
|
|
144
|
+
if (buf[i] === 0x0a) count++; // '\n'
|
|
145
|
+
}
|
|
146
|
+
if (count < ROTATION_LINE_CAP) return;
|
|
147
|
+
await rename(logPath, logPath + '.0');
|
|
148
|
+
} catch {
|
|
149
|
+
// Rotation failure is non-fatal.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
126
153
|
/**
|
|
127
154
|
* Append one JSON line to ~/.ijfw/state/permission-events.jsonl. Best effort:
|
|
128
155
|
* never throws. The forensic trail is a nice-to-have, not a critical path.
|
|
156
|
+
* Rotation check runs on each append (cost amortised).
|
|
129
157
|
*/
|
|
130
158
|
export async function logPermissionEvent(event, opts = {}) {
|
|
131
159
|
try {
|
|
132
160
|
const home = opts.homeDir || process.env.HOME || homedir();
|
|
133
161
|
await mkdir(stateDir(home), { recursive: true });
|
|
162
|
+
await maybeRotateEventLog(eventLogPath(home));
|
|
134
163
|
const line = JSON.stringify(event) + '\n';
|
|
135
164
|
await appendFile(eventLogPath(home), line, 'utf8');
|
|
136
165
|
} catch {
|