total-recall-brain 3.43.2 → 3.44.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/bin/total-recall-server-release.mjs +4 -0
- package/docs/reference/cli-reference.md +14 -0
- package/package.json +5 -4
- package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
- package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
- package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
- package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +18 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/README.md +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +39 -6
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/config.schema.json +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +38 -5
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +38 -7
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +85 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +15 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +38 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +42 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +40 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +27 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +322 -40
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
- package/src/cli/connect.mjs +2 -29
- package/src/cli/context.mjs +1 -0
- package/src/cli/ingest-okf.mjs +4 -4
- package/src/cli/release.mjs +27 -2
- package/src/cli/startup.mjs +1 -0
- package/src/cli/update.mjs +14 -3
- package/src/core/automatic-brain-updates.mjs +13 -3
- package/src/core/autopull-install.mjs +14 -0
- package/src/core/brain-updates.mjs +12 -2
- package/src/core/context-compactor.mjs +7 -2
- package/src/core/context-compiler.mjs +6 -1
- package/src/core/context-policy.mjs +7 -3
- package/src/core/daemon-control.mjs +22 -0
- package/src/core/global-brain-updates.mjs +3 -3
- package/src/core/mesh-brain-updates.mjs +20 -5
- package/src/core/running-server.mjs +76 -0
- package/src/core/server-release.mjs +487 -0
- package/src/core/server-restart.mjs +128 -7
- package/src/core/source-brain-updates.mjs +35 -6
- package/src/core/ssss-kernel-bridge.mjs +1 -2
- package/src/core/startup-health.mjs +13 -2
- package/src/core/surface.mjs +3 -3
- package/src/core/task-context.mjs +9 -8
- package/src/core/validated-write.mjs +4 -0
- package/src/core/vault-files.mjs +8 -2
- package/src/core/vault.mjs +1 -1
- package/src/server/index.mjs +17 -1
|
@@ -311,6 +311,13 @@ function printHelp() {
|
|
|
311
311
|
the provider rejects the key; a value another key shares is refused.
|
|
312
312
|
--yes Revoke it
|
|
313
313
|
--json Machine-readable output
|
|
314
|
+
gh-login status Compare this machine's gh login with github_token (no values)
|
|
315
|
+
gh-login adopt Save gh's token as github_token for every machine that holds a grant.
|
|
316
|
+
Shows the scope change unless --yes is given.
|
|
317
|
+
--yes Do it
|
|
318
|
+
--revoke-old Also end the previous token at GitHub, once the new one is in place
|
|
319
|
+
gh-login use-store Give up this machine's own gh login and sign in from github_token.
|
|
320
|
+
Shows the plan unless --yes is given.
|
|
314
321
|
rotation-status Rotation coverage for every key in the vault
|
|
315
322
|
--json Machine-readable output
|
|
316
323
|
browser-logout Delete TR's persistent browser profile (all sessions)
|
|
@@ -429,6 +436,7 @@ function parseArgs(args) {
|
|
|
429
436
|
'rotate-auto',
|
|
430
437
|
'provider-keys',
|
|
431
438
|
'revoke',
|
|
439
|
+
'gh-login',
|
|
432
440
|
'rm',
|
|
433
441
|
'generate',
|
|
434
442
|
'account-sync',
|
|
@@ -629,6 +637,9 @@ function parseArgs(args) {
|
|
|
629
637
|
case '--revoke':
|
|
630
638
|
out.revokeHash = args[++i];
|
|
631
639
|
break;
|
|
640
|
+
case '--revoke-old':
|
|
641
|
+
out.revokeOld = true;
|
|
642
|
+
break;
|
|
632
643
|
case '--yes':
|
|
633
644
|
out.yes = true;
|
|
634
645
|
break;
|
|
@@ -707,7 +718,7 @@ function parseArgs(args) {
|
|
|
707
718
|
out.keys.push(a);
|
|
708
719
|
} else if (!a.startsWith('--') && !out.key) out.key = a;
|
|
709
720
|
else if (!a.startsWith('--') && !out.value) out.value = a;
|
|
710
|
-
else if (a.startsWith('--') && out.command
|
|
721
|
+
else if (a.startsWith('--') && ['revoke', 'gh-login'].includes(out.command)) out.unknownOption = a;
|
|
711
722
|
}
|
|
712
723
|
i++;
|
|
713
724
|
}
|
|
@@ -1637,6 +1648,12 @@ export default async function secretCli(argv) {
|
|
|
1637
1648
|
return;
|
|
1638
1649
|
}
|
|
1639
1650
|
|
|
1651
|
+
if (opts.command === 'gh-login') {
|
|
1652
|
+
const { runGhLogin } = await import('./cli-gh-login.mjs');
|
|
1653
|
+
if ((await runGhLogin(brainDir, opts)) !== 0) await exitAfterFlush(1);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1640
1657
|
if (opts.command === 'delete' || opts.command === 'rm') {
|
|
1641
1658
|
if (!opts.key) {
|
|
1642
1659
|
console.error('Usage: total-recall auth secret delete <key>');
|
|
@@ -4,16 +4,32 @@
|
|
|
4
4
|
* A secret that reaches a node (see secrets-sync.mjs) is only useful to people and
|
|
5
5
|
* agents there once the tool that uses it is signed in. This signs `gh` in from
|
|
6
6
|
* `github_token`, and wires git to it, so a token rotated once lands on every machine
|
|
7
|
-
* without anyone logging in by hand.
|
|
7
|
+
* without anyone logging in by hand.
|
|
8
|
+
*
|
|
9
|
+
* A login changed on the machine itself (`gh auth refresh -s workflow`) belongs to the operator,
|
|
10
|
+
* and comparing values cannot tell that from a rotation. So each node keeps the pair of tokens it
|
|
11
|
+
* last looked at, as fingerprints. Store token moved: rotation, sign in. `gh` token moved: someone
|
|
12
|
+
* else wrote it, so check both with GitHub first, and overwrite only when nothing would be lost.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here prints or logs a token.
|
|
8
15
|
*/
|
|
9
16
|
import { execFile } from 'node:child_process';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
10
18
|
|
|
11
19
|
const TIMEOUT_MS = 20_000;
|
|
20
|
+
const HOST = 'github.com';
|
|
21
|
+
const REVOKE_URL = 'https://api.github.com/credentials/revoke';
|
|
22
|
+
const NO_LOGIN = /no oauth token/i;
|
|
12
23
|
|
|
13
24
|
/** Run a command, optionally feeding stdin; never rejects. */
|
|
14
|
-
export function defaultRun(cmd, args, input) {
|
|
25
|
+
export function defaultRun(cmd, args, input, env = {}) {
|
|
15
26
|
return new Promise((resolve) => {
|
|
16
|
-
const
|
|
27
|
+
const childEnv = { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' };
|
|
28
|
+
// Either of these outranks gh's stored login, so a daemon that inherited one would
|
|
29
|
+
// report it as the login and could not sign in at all.
|
|
30
|
+
delete childEnv.GH_TOKEN;
|
|
31
|
+
delete childEnv.GITHUB_TOKEN;
|
|
32
|
+
const child = execFile(cmd, args, { timeout: TIMEOUT_MS, env: { ...childEnv, ...env } }, (error, stdout, stderr) => {
|
|
17
33
|
resolve({ ok: !error, code: error?.code ?? 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
18
34
|
});
|
|
19
35
|
child.stdin?.end(input === undefined ? undefined : String(input));
|
|
@@ -22,21 +38,149 @@ export function defaultRun(cmd, args, input) {
|
|
|
22
38
|
|
|
23
39
|
const firstLine = (text) => String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || 'unknown error';
|
|
24
40
|
|
|
41
|
+
/** The same value `auth secret list` shortens to its fingerprint. */
|
|
42
|
+
export const tokenFingerprint = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
43
|
+
|
|
44
|
+
/** What `gh` holds on this machine. The token stays in memory, for the caller to compare. */
|
|
45
|
+
export async function readGhToken(run = defaultRun) {
|
|
46
|
+
const res = await run('gh', ['auth', 'token', '--hostname', HOST]);
|
|
47
|
+
if (res.code === 'ENOENT') return { state: 'gh-missing' };
|
|
48
|
+
if (res.ok && res.stdout.trim()) return { state: 'token', token: res.stdout.trim() };
|
|
49
|
+
if (res.ok || NO_LOGIN.test(res.stderr)) return { state: 'none' };
|
|
50
|
+
// A locked keychain or a timeout is not an empty login: signing in over it could lose one.
|
|
51
|
+
return { state: 'unreadable', error: firstLine(res.stderr) };
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
55
|
+
* Ask GitHub about one token, which travels in this child's environment only.
|
|
56
|
+
* `gh api --include` prints the status line even for a 401 and nothing at all when offline,
|
|
57
|
+
* which separates a dead token from a dead network without matching error text.
|
|
58
|
+
* @returns {Promise<{ state: 'ok'|'rejected'|'unknown', scopes: string[], login?: string }>}
|
|
28
59
|
*/
|
|
29
|
-
export async function
|
|
60
|
+
export async function probeGithubToken(token, run = defaultRun) {
|
|
61
|
+
const res = await run('gh', ['api', '--hostname', HOST, '--include', 'user'], undefined, { GH_TOKEN: token });
|
|
62
|
+
const [head] = res.stdout.split(/\r?\n\r?\n/, 1);
|
|
63
|
+
const status = Number(head.match(/^HTTP\/\S+ (\d{3})/)?.[1]);
|
|
64
|
+
if (status === 401) return { state: 'rejected', scopes: [] };
|
|
65
|
+
if (status !== 200) return { state: 'unknown', scopes: [] };
|
|
66
|
+
let login = null;
|
|
67
|
+
try { login = JSON.parse(res.stdout.slice(head.length)).login; } catch { /* reported as unknown below */ }
|
|
68
|
+
if (!login) return { state: 'unknown', scopes: [] };
|
|
69
|
+
const scopes = (head.match(/^x-oauth-scopes:(.*)$/im)?.[1] || '').split(',').map((s) => s.trim()).filter(Boolean).sort();
|
|
70
|
+
return { state: 'ok', login, scopes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a `gh` token can do that the store's cannot, and the reverse. */
|
|
74
|
+
export function compareScopes(gh, store) {
|
|
75
|
+
return {
|
|
76
|
+
added: gh.scopes.filter((scope) => !store.scopes.includes(scope)),
|
|
77
|
+
removed: store.scopes.filter((scope) => !gh.scopes.includes(scope)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Sign `gh` in with `token` on stdin, then point git at it. */
|
|
82
|
+
export async function signInGh(token, run = defaultRun) {
|
|
83
|
+
const login = await run('gh', ['auth', 'login', '--hostname', HOST, '--git-protocol', 'https', '--with-token'], token);
|
|
84
|
+
if (!login.ok) return { status: 'failed', error: firstLine(login.stderr).replaceAll(token, '***') };
|
|
85
|
+
await run('gh', ['auth', 'setup-git', '--hostname', HOST]);
|
|
86
|
+
return { status: 'logged-in' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bring `gh` in step with the store, unless that would lose something the operator holds.
|
|
91
|
+
*
|
|
92
|
+
* @param {{ token?: string, record?: { store: string, gh: string }, run?: Function }} options
|
|
93
|
+
* `record` is the pair this node last looked at; pass back the `record` of the result.
|
|
94
|
+
* @returns {Promise<{
|
|
95
|
+
* status: 'no-token'|'gh-missing'|'current'|'logged-in'|'diverged'|'failed',
|
|
96
|
+
* reason?: string, first?: boolean, login?: string, store_login?: string|null,
|
|
97
|
+
* added?: string[], removed?: string[], replaced?: object, record?: object, error?: string,
|
|
98
|
+
* }>} `diverged` left `gh` alone; `first` is true only when this is news, and `record` is
|
|
99
|
+
* missing or unchanged when GitHub could not be asked.
|
|
100
|
+
*/
|
|
101
|
+
export async function projectGithubLogin({ token, record, run = defaultRun } = {}) {
|
|
30
102
|
const wanted = String(token || '').trim();
|
|
31
103
|
if (!wanted) return { status: 'no-token' };
|
|
32
104
|
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
if (
|
|
105
|
+
const held = await readGhToken(run);
|
|
106
|
+
if (held.state === 'gh-missing') return { status: 'gh-missing' };
|
|
107
|
+
if (held.state === 'unreadable') return { status: 'failed', error: held.error.replaceAll(wanted, '***') };
|
|
36
108
|
|
|
37
|
-
const
|
|
38
|
-
|
|
109
|
+
const storeFp = tokenFingerprint(wanted);
|
|
110
|
+
const inStep = { store: storeFp, gh: storeFp };
|
|
111
|
+
const signIn = async (reason, extra = {}) => {
|
|
112
|
+
const result = await signInGh(wanted, run);
|
|
113
|
+
return result.status === 'logged-in' ? { ...result, reason, ...extra, record: inStep } : result;
|
|
114
|
+
};
|
|
39
115
|
|
|
40
|
-
|
|
41
|
-
return { status: '
|
|
116
|
+
if (held.state === 'none') return signIn('no-login');
|
|
117
|
+
if (held.token === wanted) return { status: 'current', record: inStep };
|
|
118
|
+
|
|
119
|
+
const ghFp = tokenFingerprint(held.token);
|
|
120
|
+
if (record?.gh === ghFp) {
|
|
121
|
+
// Nothing moved: this difference was already reported, so stay quiet and off the network.
|
|
122
|
+
if (record.store === storeFp) return { status: 'diverged', first: false, reason: record.reason, login: record.login, store_login: record.store_login, added: record.added, removed: record.removed, record };
|
|
123
|
+
// Only the store moved. A rotation is deliberate, so it lands, over a local login too.
|
|
124
|
+
return signIn('rotation', record.gh === record.store ? {} : { replaced: { reason: record.reason, login: record.login, added: record.added, removed: record.removed } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const [gh, store] = await Promise.all([probeGithubToken(held.token, run), probeGithubToken(wanted, run)]);
|
|
128
|
+
if (gh.state === 'rejected') return signIn('gh-token-rejected');
|
|
129
|
+
// Offline, or GitHub answered oddly: not knowing is no reason to overwrite. Look again next cycle.
|
|
130
|
+
if (gh.state !== 'ok' || store.state === 'unknown') return { status: 'diverged', first: false, reason: 'unverified', record };
|
|
131
|
+
|
|
132
|
+
const keep = (reason, scopes = { added: [], removed: [] }) => {
|
|
133
|
+
const next = { store: storeFp, gh: ghFp, reason, login: gh.login, store_login: store.login ?? null, ...scopes };
|
|
134
|
+
return { status: 'diverged', first: true, reason, login: next.login, store_login: next.store_login, ...scopes, record: next };
|
|
135
|
+
};
|
|
136
|
+
if (store.state === 'rejected') return keep('store-token-rejected');
|
|
137
|
+
if (gh.login !== store.login) return keep('different-account');
|
|
138
|
+
const scopes = compareScopes(gh, store);
|
|
139
|
+
const sameScopes = !scopes.added.length && !scopes.removed.length;
|
|
140
|
+
// A local grant was removed again: same account, same powers, so nothing to lose.
|
|
141
|
+
if (sameScopes && record && record.gh !== record.store) return signIn('same-scopes');
|
|
142
|
+
// Otherwise someone asked for this new token on purpose, usually to rotate. Keep it.
|
|
143
|
+
return keep(sameScopes ? 'new-token' : 'different-scopes', scopes);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read-only comparison for operators. Holds fingerprints only, never a token. */
|
|
147
|
+
export async function inspectGithubLogin({ token, run = defaultRun } = {}) {
|
|
148
|
+
const wanted = String(token || '').trim();
|
|
149
|
+
if (!wanted) return { state: 'no-token' };
|
|
150
|
+
const held = await readGhToken(run);
|
|
151
|
+
if (held.state === 'gh-missing') return { state: 'gh-missing' };
|
|
152
|
+
if (held.state === 'unreadable') return { state: 'unreadable', error: held.error.replaceAll(wanted, '***') };
|
|
153
|
+
const store = { fingerprint: tokenFingerprint(wanted).slice(0, 12), ...(await probeGithubToken(wanted, run)) };
|
|
154
|
+
if (held.state === 'none') return { state: 'no-login', store };
|
|
155
|
+
if (held.token === wanted) return { state: 'same', store };
|
|
156
|
+
const gh = { fingerprint: tokenFingerprint(held.token).slice(0, 12), ...(await probeGithubToken(held.token, run)) };
|
|
157
|
+
// A rejected or unreachable token reports no scopes, which is not the same as holding none.
|
|
158
|
+
return { state: 'diverged', gh, store, ...(gh.state === 'ok' && store.state === 'ok' ? compareScopes(gh, store) : { added: [], removed: [] }) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* End a token at GitHub. This endpoint refuses authenticated requests, so it goes out without `gh`,
|
|
163
|
+
* and it answers 202 before anything happens: only GitHub rejecting the token afterwards is proof.
|
|
164
|
+
*/
|
|
165
|
+
export async function revokeGithubToken(token, {
|
|
166
|
+
run = defaultRun,
|
|
167
|
+
fetchImpl = fetch,
|
|
168
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
169
|
+
attempts = 5,
|
|
170
|
+
waitMs = 2000,
|
|
171
|
+
} = {}) {
|
|
172
|
+
let requestStatus = null;
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetchImpl(REVOKE_URL, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'total-recall' },
|
|
177
|
+
body: JSON.stringify({ credentials: [token] }),
|
|
178
|
+
});
|
|
179
|
+
requestStatus = res.status;
|
|
180
|
+
} catch { /* the check below decides */ }
|
|
181
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
182
|
+
if (attempt) await sleep(waitMs);
|
|
183
|
+
if ((await probeGithubToken(token, run)).state === 'rejected') return { revoked: true, request_status: requestStatus };
|
|
184
|
+
}
|
|
185
|
+
return { revoked: false, request_status: requestStatus };
|
|
42
186
|
}
|
|
@@ -3,8 +3,18 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import { getLeaderInfo, isLeader } from './leader-election.mjs';
|
|
4
4
|
import { logger } from './logger.mjs';
|
|
5
5
|
import { brainDir } from './config.mjs';
|
|
6
|
-
import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAuditAsync, META_KEY } from './secrets-store.mjs';
|
|
7
|
-
import {
|
|
6
|
+
import { resolveSecretsPath, loadSecrets, saveSecrets, ensureMeta, appendAuditAsync, rotateSecret, META_KEY } from './secrets-store.mjs';
|
|
7
|
+
import {
|
|
8
|
+
compareScopes,
|
|
9
|
+
defaultRun,
|
|
10
|
+
inspectGithubLogin,
|
|
11
|
+
probeGithubToken,
|
|
12
|
+
projectGithubLogin,
|
|
13
|
+
readGhToken,
|
|
14
|
+
revokeGithubToken,
|
|
15
|
+
signInGh,
|
|
16
|
+
tokenFingerprint,
|
|
17
|
+
} from './login-projections.mjs';
|
|
8
18
|
|
|
9
19
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
10
20
|
const FETCH_CONCURRENCY = 8;
|
|
@@ -33,6 +43,21 @@ export function secretsSyncMode(env = process.env) {
|
|
|
33
43
|
return 'mesh-identity';
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
/** The keys the leader says this machine may read. */
|
|
47
|
+
async function listGrantedKeys(base, fetchImpl) {
|
|
48
|
+
let listResponse;
|
|
49
|
+
try {
|
|
50
|
+
listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// "fetch failed" alone named neither the address nor the reason.
|
|
53
|
+
throw new Error(`No Total Recall server answered at ${base}: ${err.cause?.code || err.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
|
|
56
|
+
const { keys } = await listResponse.json();
|
|
57
|
+
if (!Array.isArray(keys)) throw new Error('Invalid grant list');
|
|
58
|
+
return keys;
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
/**
|
|
37
62
|
* Grant-based sync: ask the leader which keys this machine may read, fetch
|
|
38
63
|
* only those, and merge them into this node's own encrypted store. Keys that
|
|
@@ -45,16 +70,7 @@ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl =
|
|
|
45
70
|
// The leader's port, never this node's: the droplet serves on 3900 while devices
|
|
46
71
|
// default to 3000, and assuming our own port made every sync fail.
|
|
47
72
|
const base = meshUrl(leaderIp, '/api/secrets/mesh', port || serverPort());
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
listResponse = await fetchImpl(base, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
51
|
-
} catch (err) {
|
|
52
|
-
// "fetch failed" alone named neither the address nor the reason.
|
|
53
|
-
throw new Error(`No Total Recall server answered at ${base}: ${err.cause?.code || err.message}`);
|
|
54
|
-
}
|
|
55
|
-
if (!listResponse.ok) throw new Error(`Leader returned ${listResponse.status}`);
|
|
56
|
-
const { keys } = await listResponse.json();
|
|
57
|
-
if (!Array.isArray(keys)) throw new Error('Invalid grant list');
|
|
73
|
+
const keys = await listGrantedKeys(base, fetchImpl);
|
|
58
74
|
|
|
59
75
|
// Each key costs the leader a store decryption plus a mesh round trip (2-3 s from a laptop),
|
|
60
76
|
// so a few at a time keeps a full sync under a minute instead of several.
|
|
@@ -93,15 +109,143 @@ export async function syncGrantedSecrets(leaderIp, { dir = brainDir, fetchImpl =
|
|
|
93
109
|
return { granted: keys.length, updated, removed: removed.length };
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
const githubKeyName = (secrets) => (secrets.GITHUB_TOKEN && !secrets.github_token ? 'GITHUB_TOKEN' : 'github_token');
|
|
113
|
+
|
|
114
|
+
/** Reloads first: the checks take seconds, and a key saved meanwhile must survive. */
|
|
115
|
+
async function saveGithubRecord(dir, record) {
|
|
116
|
+
const secrets = ensureMeta(await loadSecrets(dir));
|
|
117
|
+
secrets[META_KEY].login_projections = { ...secrets[META_KEY].login_projections, gh: { ...record, at: new Date().toISOString() } };
|
|
118
|
+
await saveSecrets(dir, secrets);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One line on what a kept `gh` login holds that the store does not. */
|
|
122
|
+
function describeDifference({ reason, login, store_login: storeLogin, added = [], removed = [] }) {
|
|
123
|
+
if (reason === 'store-token-rejected') return 'GitHub rejects the stored token';
|
|
124
|
+
if (reason === 'different-account') return `gh is signed in as ${login}, the stored token belongs to ${storeLogin}`;
|
|
125
|
+
if (reason === 'new-token') return 'a new token with the same scopes';
|
|
126
|
+
return [added.length ? `adds ${added.join(', ')}` : '', removed.length ? `drops ${removed.join(', ')}` : ''].filter(Boolean).join('; ');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A desktop notice, once per token. Best effort: the audit log is the record. */
|
|
130
|
+
async function alertOperator(run, eventId, title, message) {
|
|
131
|
+
await run('total-recall', ['alerts', 'send', '--title', title, '--message', message, '--channels', 'os', '--severity', 'info', '--source', 'auth', '--event-id', eventId]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function reportGithubLogin(dir, github, run) {
|
|
101
135
|
if (github.status === 'failed') logger.error('secrets-sync', 'gh login from github_token failed', { error: github.error });
|
|
136
|
+
if (github.status === 'logged-in') {
|
|
137
|
+
await appendAuditAsync(dir, { action: 'login_projection', key: `github_token -> gh (${github.reason})`, actor: 'mesh-sync' });
|
|
138
|
+
if (github.replaced) {
|
|
139
|
+
const message = `A rotated github_token replaced this machine's own gh login (${describeDifference(github.replaced)}).`;
|
|
140
|
+
logger.warn('secrets-sync', message);
|
|
141
|
+
await alertOperator(run, `gh-login-replaced-${github.record.store.slice(0, 12)}`, 'gh login replaced by rotation', message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (github.status === 'diverged' && github.first) {
|
|
145
|
+
const difference = describeDifference(github);
|
|
146
|
+
await appendAuditAsync(dir, { action: 'login_projection_kept', key: `gh differs from github_token (${difference})`, actor: 'mesh-sync' });
|
|
147
|
+
const message = `gh on this machine differs from github_token (${difference}), so the sync left it alone. See: total-recall auth secret gh-login status`;
|
|
148
|
+
logger.warn('secrets-sync', message);
|
|
149
|
+
await alertOperator(run, `gh-login-kept-${github.record.gh.slice(0, 12)}`, 'gh login kept', message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Sign in the tools that use synced secrets (today: gh from github_token). */
|
|
154
|
+
export async function projectLogins({ dir = brainDir, run = defaultRun } = {}) {
|
|
155
|
+
const secrets = ensureMeta(await loadSecrets(dir));
|
|
156
|
+
const record = secrets[META_KEY].login_projections?.gh;
|
|
157
|
+
const github = await projectGithubLogin({ token: secrets.github_token || secrets.GITHUB_TOKEN, record, run });
|
|
158
|
+
if (github.record && (github.record.store !== record?.store || github.record.gh !== record?.gh)) await saveGithubRecord(dir, github.record);
|
|
159
|
+
await reportGithubLogin(dir, github, run);
|
|
102
160
|
return { github };
|
|
103
161
|
}
|
|
104
162
|
|
|
163
|
+
/** How `gh` on this machine compares with the store. Changes nothing. */
|
|
164
|
+
export async function githubLoginStatus({ dir = brainDir, run = defaultRun } = {}) {
|
|
165
|
+
const secrets = await loadSecrets(dir);
|
|
166
|
+
return inspectGithubLogin({ token: secrets.github_token || secrets.GITHUB_TOKEN, run });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Give up this machine's own `gh` login and sign in from the store. Plans unless `yes`. */
|
|
170
|
+
export async function useStoreGithubLogin({ dir = brainDir, yes = false, run = defaultRun } = {}) {
|
|
171
|
+
const secrets = await loadSecrets(dir);
|
|
172
|
+
const token = secrets.github_token || secrets.GITHUB_TOKEN;
|
|
173
|
+
if (!token) throw new Error('The store holds no github_token');
|
|
174
|
+
const status = await inspectGithubLogin({ token, run });
|
|
175
|
+
if (status.state === 'same') return { ok: true, changed: false, ...status };
|
|
176
|
+
if (!yes) return { ok: true, planned: true, ...status };
|
|
177
|
+
const result = await signInGh(token, run);
|
|
178
|
+
if (result.status !== 'logged-in') return { ok: false, changed: false, error: result.error };
|
|
179
|
+
const fingerprint = tokenFingerprint(token);
|
|
180
|
+
await saveGithubRecord(dir, { store: fingerprint, gh: fingerprint });
|
|
181
|
+
await appendAuditAsync(dir, { action: 'login_projection', key: 'github_token -> gh (operator)', actor: 'cli' });
|
|
182
|
+
return { ok: true, changed: true, ...status };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Save this machine's `gh` token as the mesh's `github_token`. This is what widens a credential
|
|
187
|
+
* across the mesh, so it plans unless `yes`. Only the leader's store reaches other nodes, and the
|
|
188
|
+
* next sync would overwrite a follower's own copy, so a follower writes to the leader over the mesh.
|
|
189
|
+
*/
|
|
190
|
+
export async function adoptGithubLogin({ dir = brainDir, yes = false, revokeOld = false, run = defaultRun, exec, fetchImpl = fetch, sleep } = {}) {
|
|
191
|
+
const secrets = await loadSecrets(dir);
|
|
192
|
+
const key = githubKeyName(secrets);
|
|
193
|
+
const previous = secrets[key];
|
|
194
|
+
const held = await readGhToken(run);
|
|
195
|
+
if (held.state !== 'token') throw new Error(held.error || 'gh holds no token to adopt. Sign in with gh first; nothing changed.');
|
|
196
|
+
if (!previous) throw new Error(`The store holds no ${key}; nothing changed. Save it with: total-recall auth secret set ${key} --stdin`);
|
|
197
|
+
const fingerprint = tokenFingerprint(held.token);
|
|
198
|
+
if (held.token === previous) return { ok: true, adopted: false, state: 'same', key, fingerprint: fingerprint.slice(0, 12) };
|
|
199
|
+
|
|
200
|
+
const [gh, store] = await Promise.all([probeGithubToken(held.token, run), probeGithubToken(previous, run)]);
|
|
201
|
+
if (gh.state === 'rejected') throw new Error("GitHub rejects gh's token; nothing changed. Sign in with gh again first.");
|
|
202
|
+
if (gh.state !== 'ok' || store.state === 'unknown') throw new Error('Could not check both tokens with GitHub; nothing changed.');
|
|
203
|
+
if (store.state === 'ok' && store.login !== gh.login) throw new Error(`gh is signed in as ${gh.login}, but ${key} belongs to ${store.login}; nothing changed.`);
|
|
204
|
+
|
|
205
|
+
const leader = await isLeader();
|
|
206
|
+
const leaderInfo = leader ? null : await getLeaderInfo();
|
|
207
|
+
if (!leader && !leaderInfo?.ip) throw new Error('No leader found to write to; nothing changed.');
|
|
208
|
+
const plan = {
|
|
209
|
+
key,
|
|
210
|
+
login: gh.login,
|
|
211
|
+
target: leader ? 'this machine' : leaderInfo.ip,
|
|
212
|
+
gh: { fingerprint: fingerprint.slice(0, 12), scopes: gh.scopes },
|
|
213
|
+
store: { fingerprint: tokenFingerprint(previous).slice(0, 12), state: store.state, scopes: store.scopes },
|
|
214
|
+
// A rejected token reports no scopes, which is not the same as holding none.
|
|
215
|
+
...(store.state === 'ok' ? compareScopes(gh, store) : { added: [], removed: [] }),
|
|
216
|
+
revoke_old: revokeOld,
|
|
217
|
+
};
|
|
218
|
+
if (!yes) return { ok: true, planned: true, ...plan };
|
|
219
|
+
|
|
220
|
+
if (leader) {
|
|
221
|
+
await rotateSecret(dir, key, held.token, { actor: 'gh-login-adopt' });
|
|
222
|
+
} else {
|
|
223
|
+
// Checked first: without a grant the leader would change and this machine would never catch up.
|
|
224
|
+
const granted = await listGrantedKeys(meshUrl(leaderInfo.ip, '/api/secrets/mesh', leaderInfo.port || serverPort()), fetch);
|
|
225
|
+
if (!granted.includes(key)) throw new Error(`This machine holds no grant for ${key}, so it would not receive the token back; nothing changed.`);
|
|
226
|
+
const runOnLeader = exec || (await import('./mesh.mjs')).execMeshCommand;
|
|
227
|
+
const rotated = await runOnLeader(leaderInfo.ip, `total-recall auth secret rotate ${key} --stdin`, { input: held.token, timeoutMs: 60_000 });
|
|
228
|
+
if (!rotated.success) throw new Error(`The leader did not take the token, so nothing changed: ${String(rotated.stderr || 'no detail').split('\n')[0].replaceAll(held.token, '***')}`);
|
|
229
|
+
// Prove it came back through the sync every other node uses.
|
|
230
|
+
await syncGrantedSecrets(leaderInfo.ip, { dir, port: leaderInfo.port });
|
|
231
|
+
if ((await loadSecrets(dir))[key] !== held.token) throw new Error(`The leader took the token, but this machine did not get it back. Check that its grant includes ${key}. The old token was left working.`);
|
|
232
|
+
// The leader runs no sync of its own, so its gh would keep the old token.
|
|
233
|
+
const leaderLogin = await runOnLeader(leaderInfo.ip, 'gh auth login --hostname github.com --git-protocol https --with-token && gh auth setup-git --hostname github.com', { input: held.token, timeoutMs: 60_000 });
|
|
234
|
+
plan.leader_gh = leaderLogin.success ? 'signed-in' : 'failed';
|
|
235
|
+
}
|
|
236
|
+
await saveGithubRecord(dir, { store: fingerprint, gh: fingerprint });
|
|
237
|
+
await appendAuditAsync(dir, { action: 'login_adopted', key: `gh -> ${key}${plan.added.length ? ` (adds ${plan.added.join(', ')})` : ''}`, actor: 'cli' });
|
|
238
|
+
|
|
239
|
+
if (!revokeOld || store.state !== 'ok') return { ok: plan.leader_gh !== 'failed', adopted: true, ...plan };
|
|
240
|
+
// Never end the old token while a machine still signs in with it.
|
|
241
|
+
if (plan.leader_gh === 'failed') return { ok: false, adopted: true, ...plan, revocation: { revoked: false, skipped: "the leader's gh still uses the old token" } };
|
|
242
|
+
const revocation = await revokeGithubToken(previous, { run, fetchImpl, ...(sleep ? { sleep } : {}) });
|
|
243
|
+
await appendAuditAsync(dir, { action: revocation.revoked ? 'revoke' : 'revoke_failed', key: `${key} (previous value)`, actor: 'cli' });
|
|
244
|
+
// GitHub does not say whether this ends only the one token, so check the new one still works.
|
|
245
|
+
revocation.new_token_works = (await probeGithubToken(held.token, run)).state === 'ok';
|
|
246
|
+
return { ok: revocation.revoked && revocation.new_token_works, adopted: true, ...plan, revocation };
|
|
247
|
+
}
|
|
248
|
+
|
|
105
249
|
export async function syncLoop() {
|
|
106
250
|
secretsSyncMode();
|
|
107
251
|
if (!(await isLeader())) {
|
|
@@ -5,7 +5,7 @@ One-shot, fail-closed code-quality gates for any repository. The plugin ships a
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
7
|
- Runs each configured gate once, as a background job, and writes `reports/latest.json`. There is no daemon.
|
|
8
|
-
- Gate kinds: a command (typecheck, lint, tests, conformance suites) with an output parser (`tsc`, `eslint-stylish`, `eslint-compact`, `biome`, `flake8`, `mypy`, `black`, `generic`), or `grep-forbid`, which turns a written repository invariant into a failing gate.
|
|
8
|
+
- Gate kinds: a command (typecheck, lint, tests, conformance suites) with an output parser (`tsc`, `eslint-stylish`, `eslint-compact`, `biome`, `flake8`, `mypy`, `black`, `generic`), or `grep-forbid`, which turns a written repository invariant into a failing gate. A `grep-forbid` pattern can read its terms from a file with `patternFile`, for words a published config must not contain.
|
|
9
9
|
- Tiers (`fast`, `full`, `remote`) keep heavy work off small machines. A bounded run lists the gates it skipped.
|
|
10
10
|
- Locking is fail-closed and machine-wide, so only one check runs per host. `--steal-lock` recovers a dead or unverifiable lock and refuses to take one from a verified running check.
|
|
11
11
|
- Reports record coverage, the git head and source freshness. A gate that scans zero files, or a tool that fails without parseable output, is never reported as clean.
|
|
@@ -44,7 +44,7 @@ The canonical gate list is a `skill_config` record in the repository's Total Rec
|
|
|
44
44
|
|
|
45
45
|
## Data and network access
|
|
46
46
|
|
|
47
|
-
The core runs only the commands in the repository's own config, with the repository as working directory. It reads tracked source files for `grep-forbid` gates and writes only `reports/` and lock files. It makes no network requests. Commands you configure may.
|
|
47
|
+
The core runs only the commands in the repository's own config, with the repository as working directory. It reads tracked source files and any `patternFile` for `grep-forbid` gates and writes only `reports/` and lock files. It makes no network requests. Commands you configure may.
|
|
48
48
|
|
|
49
49
|
## Development
|
|
50
50
|
|
|
@@ -104,8 +104,11 @@ function loadConfig() {
|
|
|
104
104
|
if (c.kind === 'grep-forbid') {
|
|
105
105
|
if (!Array.isArray(c.patterns) || c.patterns.length === 0) problems.push(`${where}: grep-forbid needs a non-empty "patterns" array`);
|
|
106
106
|
else c.patterns.forEach((p, j) => {
|
|
107
|
-
if (!p.pattern) problems.push(`${where}.patterns[${j}]:
|
|
108
|
-
else if (
|
|
107
|
+
if (!p.pattern === !p.patternFile) problems.push(`${where}.patterns[${j}]: needs exactly one of "pattern" and "patternFile"`);
|
|
108
|
+
else if (p.patternFile) {
|
|
109
|
+
try { new RegExp('x', p.flags || ''); }
|
|
110
|
+
catch (e) { problems.push(`${where}.patterns[${j}]: invalid flags — ${e.message}`); }
|
|
111
|
+
} else if (/^\(\?[a-z]+\)/.test(p.pattern)) {
|
|
109
112
|
problems.push(`${where}.patterns[${j}]: JS RegExp has no inline (?i) flags — use "flags": "i" instead`);
|
|
110
113
|
} else {
|
|
111
114
|
try { new RegExp(p.pattern, p.flags || ''); }
|
|
@@ -503,11 +506,41 @@ function applyFallbackViolationBaseline(check, findings) {
|
|
|
503
506
|
return retained;
|
|
504
507
|
}
|
|
505
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Reads a pattern's terms from a file, for words the config must not contain because the config
|
|
511
|
+
* is published and the words are private. Each line is a literal term; a # line is a comment.
|
|
512
|
+
*/
|
|
513
|
+
function readPatternFile(file) {
|
|
514
|
+
let text;
|
|
515
|
+
try { text = readFileSync(path.resolve(REPO_ROOT, file), 'utf8'); }
|
|
516
|
+
catch (error) { if (error.code === 'ENOENT') return { absent: true }; throw error; }
|
|
517
|
+
const terms = text.split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
|
|
518
|
+
return { terms };
|
|
519
|
+
}
|
|
520
|
+
|
|
506
521
|
function runGrepForbid(check) {
|
|
507
522
|
let findings = [];
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
523
|
+
const notes = [];
|
|
524
|
+
const patterns = [];
|
|
525
|
+
for (const p of check.patterns || []) {
|
|
526
|
+
const rule = { message: p.message, code: p.code || 'FORBIDDEN' };
|
|
527
|
+
if (!p.patternFile) { patterns.push({ ...rule, re: new RegExp(p.pattern, p.flags || '') }); continue; }
|
|
528
|
+
const { absent, terms } = readPatternFile(p.patternFile);
|
|
529
|
+
if (absent) {
|
|
530
|
+
// A public clone has no private list. Say the rule did not run; never call that a pass.
|
|
531
|
+
const note = `${check.id}: ${rule.code} did not run, ${p.patternFile} is not present.`;
|
|
532
|
+
notes.push(note);
|
|
533
|
+
console.error(` [code-quality] ${note}`);
|
|
534
|
+
} else if (!terms.length) {
|
|
535
|
+
findings.push({
|
|
536
|
+
file: '.agent/skills/code-quality/config.json', line: 1, col: 1,
|
|
537
|
+
code: 'CQ-EMPTY-LIST', check: check.id, severity: 'error',
|
|
538
|
+
message: `grep-forbid check "${check.id}" reads no terms from ${p.patternFile}.`, snippet: p.patternFile
|
|
539
|
+
});
|
|
540
|
+
} else {
|
|
541
|
+
patterns.push({ ...rule, re: new RegExp(terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), p.flags || '') });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
511
544
|
const exts = new Set(check.extensions || config.sourceExtensions || ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
|
|
512
545
|
const ignore = (check.ignorePaths || []).map((p) => new RegExp(p));
|
|
513
546
|
// Optional allowlist: when present, ONLY files matching one of these path
|
|
@@ -556,7 +589,7 @@ function runGrepForbid(check) {
|
|
|
556
589
|
return Promise.resolve({
|
|
557
590
|
id: check.id, ok: findings.length === 0, exitCode: findings.length ? 1 : 0,
|
|
558
591
|
timedOut: false, durationMs: 0, findings, scanned,
|
|
559
|
-
raw: findings.map((f) => `${f.file}:${f.line}: ${f.code} ${f.message}`).join('\n')
|
|
592
|
+
raw: [...notes, ...findings.map((f) => `${f.file}:${f.line}: ${f.code} ${f.message}`)].join('\n')
|
|
560
593
|
});
|
|
561
594
|
}
|
|
562
595
|
|
|
@@ -67,9 +67,10 @@
|
|
|
67
67
|
"items": {
|
|
68
68
|
"type": "object",
|
|
69
69
|
"additionalProperties": false,
|
|
70
|
-
"required": ["pattern"],
|
|
70
|
+
"oneOf": [{ "required": ["pattern"] }, { "required": ["patternFile"] }],
|
|
71
71
|
"properties": {
|
|
72
72
|
"pattern": { "type": "string", "minLength": 1 },
|
|
73
|
+
"patternFile": { "type": "string", "minLength": 1, "description": "Repo-relative file of literal terms, one a line, for words the published config must not contain. The rule reports that it did not run when the file is absent." },
|
|
73
74
|
"flags": { "type": "string", "pattern": "^[dgimsuvy]*$" },
|
|
74
75
|
"code": { "type": "string" },
|
|
75
76
|
"message": { "type": "string" }
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-code-quality",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "tr-plugin-code-quality",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.6",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@gregiteen/ssss-cli": "0.14.
|
|
12
|
+
"@gregiteen/ssss-cli": "0.14.5"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"total-recall-brain": "3.
|
|
15
|
+
"total-recall-brain": "3.43.3"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"node_modules/@alcalzone/ansi-tokenize": {
|
|
@@ -715,9 +715,9 @@
|
|
|
715
715
|
"license": "MIT"
|
|
716
716
|
},
|
|
717
717
|
"node_modules/@gregiteen/ssss-cli": {
|
|
718
|
-
"version": "0.14.
|
|
719
|
-
"resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.
|
|
720
|
-
"integrity": "sha512-
|
|
718
|
+
"version": "0.14.5",
|
|
719
|
+
"resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.5.tgz",
|
|
720
|
+
"integrity": "sha512-qVEYOocnwn97uJ0eDzmuwN06CYsYWr2t3+iKylpisBHX4wlLWk7P8EmFFdBlx7NyKVIZDDeYT164Gd7/T8UGgw==",
|
|
721
721
|
"license": "ISC",
|
|
722
722
|
"bin": {
|
|
723
723
|
"ssss": "scripts/ssss.mjs"
|
|
@@ -1580,9 +1580,9 @@
|
|
|
1580
1580
|
},
|
|
1581
1581
|
"node_modules/@ssss/cli": {
|
|
1582
1582
|
"name": "@gregiteen/ssss-cli",
|
|
1583
|
-
"version": "0.14.
|
|
1584
|
-
"resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.
|
|
1585
|
-
"integrity": "sha512-
|
|
1583
|
+
"version": "0.14.5",
|
|
1584
|
+
"resolved": "https://registry.npmjs.org/@gregiteen/ssss-cli/-/ssss-cli-0.14.5.tgz",
|
|
1585
|
+
"integrity": "sha512-qVEYOocnwn97uJ0eDzmuwN06CYsYWr2t3+iKylpisBHX4wlLWk7P8EmFFdBlx7NyKVIZDDeYT164Gd7/T8UGgw==",
|
|
1586
1586
|
"dev": true,
|
|
1587
1587
|
"license": "ISC",
|
|
1588
1588
|
"bin": {
|
|
@@ -5543,9 +5543,9 @@
|
|
|
5543
5543
|
}
|
|
5544
5544
|
},
|
|
5545
5545
|
"node_modules/total-recall-brain": {
|
|
5546
|
-
"version": "3.
|
|
5547
|
-
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.
|
|
5548
|
-
"integrity": "sha512-
|
|
5546
|
+
"version": "3.43.3",
|
|
5547
|
+
"resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.43.3.tgz",
|
|
5548
|
+
"integrity": "sha512-yjfPN/BQxEEa+iodyFXQov/6fRt1pZg/7RcmgsbpALwBt6Ki3JJjT3tzArBC7NxHQmXgGYyKnCYId6xe/W2rPg==",
|
|
5549
5549
|
"dev": true,
|
|
5550
5550
|
"license": "MIT",
|
|
5551
5551
|
"workspaces": [
|
|
@@ -5576,6 +5576,7 @@
|
|
|
5576
5576
|
"antigravity": "bin/antigravity.mjs",
|
|
5577
5577
|
"total-recall": "bin/total-recall.mjs",
|
|
5578
5578
|
"total-recall-memory": "bin/total-recall-memory.mjs",
|
|
5579
|
+
"total-recall-server-release": "bin/total-recall-server-release.mjs",
|
|
5579
5580
|
"vibebrain": "bin/total-recall.mjs"
|
|
5580
5581
|
},
|
|
5581
5582
|
"optionalDependencies": {
|