@cosmicdrift/kumiko-guards 0.386.0 → 0.388.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/LICENSE +21 -57
- package/package.json +4 -4
- package/src/_lib/agent-docs.ts +12 -7
- package/src/_lib/comment-trivia.ts +148 -0
- package/src/_lib/guard-kit.ts +2 -2
- package/src/_lib/guards-doc.ts +4 -1
- package/src/_lib/roots.ts +9 -5
- package/src/_lib/run-slots.ts +39 -10
- package/src/_lib/scan-scope.ts +1 -1
- package/src/_lib/security-audit.ts +15 -8
- package/src/_lib/sql-inventory.ts +2 -2
- package/src/changes.json +45 -0
- package/src/check-as-casts.ts +6 -7
- package/src/check-complexity.ts +86 -29
- package/src/check-licenses.ts +23 -6
- package/src/check-real-provider-isolation.ts +1 -1
- package/src/check-runtime-isolation.ts +2 -2
- package/src/check-security.ts +2 -1
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/dependency-license-exceptions.json +0 -24
- package/src/guard-admin-api.ts +5 -6
- package/src/guard-app-feature-structure.ts +9 -9
- package/src/guard-comment-issue-ref-only.ts +223 -0
- package/src/guard-comment-lang.ts +10 -141
- package/src/guard-direct-entity-writes.ts +43 -31
- package/src/guard-direct-fetch.ts +3 -3
- package/src/guard-feature-integration-tests.ts +1 -1
- package/src/guard-file-size.ts +2 -2
- package/src/guard-i18n-locale-mount.ts +4 -4
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-i18n-ui-strings.ts +5 -5
- package/src/guard-import-extensions.ts +34 -9
- package/src/guard-no-custom-primitives.ts +2 -4
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-direct-process-env.ts +2 -2
- package/src/guard-no-inline-styles.ts +1 -1
- package/src/guard-no-raw-hooks.ts +0 -2
- package/src/guard-primitives-discipline.ts +1 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-raw-classname.ts +1 -1
- package/src/guard-raw-interactive-elements.ts +1 -1
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-restricted-symbols.ts +2 -3
- package/src/guard-tailwind-scan-surface.ts +28 -29
- package/src/guard-tenant-escalation.ts +3 -3
- package/src/guard-test-template-drift.ts +1 -1
- package/src/guard-test-timeouts.ts +12 -4
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/guard-write-handler-qns.ts +1 -1
- package/src/pre-push.sh +22 -6
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +11 -4
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +3 -2
package/LICENSE
CHANGED
|
@@ -1,57 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
controlling, or under common control with the Licensor ("Affiliates"), or
|
|
23
|
-
contractors acting on their behalf. The Licensor remains free to use the
|
|
24
|
-
Licensed Work for any purpose, including for the operation of kumiko.rocks.
|
|
25
|
-
|
|
26
|
-
Change Date: 2030-05-05
|
|
27
|
-
Change License: Apache License, Version 2.0
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Terms
|
|
31
|
-
|
|
32
|
-
The Licensor hereby grants you the right to copy, modify, create derivative works,
|
|
33
|
-
redistribute, and make non-production use of the Licensed Work. The Licensor may
|
|
34
|
-
make an Additional Use Grant, above, permitting limited production use.
|
|
35
|
-
|
|
36
|
-
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
37
|
-
available distribution of the Licensed Work under this License, whichever comes
|
|
38
|
-
first, this License will convert to the Change License.
|
|
39
|
-
|
|
40
|
-
This Business Source License governs use of the Licensed Work in all cases, except
|
|
41
|
-
as to any use that is explicitly granted in the Additional Use Grant above or
|
|
42
|
-
under the Change License after the Change Date.
|
|
43
|
-
|
|
44
|
-
If your use of the Licensed Work does not comply with the requirements of this
|
|
45
|
-
License, you must cease use of the Licensed Work immediately.
|
|
46
|
-
|
|
47
|
-
All copies of the Licensed Work, and all derivative works thereof, must include
|
|
48
|
-
this License.
|
|
49
|
-
|
|
50
|
-
This License does not grant you any right, title, or interest in any trademark,
|
|
51
|
-
logo, or branding of the Licensor, except as required to comply with this License.
|
|
52
|
-
|
|
53
|
-
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
54
|
-
“AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
|
55
|
-
WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
56
|
-
TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
|
|
57
|
-
ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marc Frost
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.388.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.388.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.388.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/_lib/agent-docs.ts
CHANGED
|
@@ -336,14 +336,21 @@ function renderExamplesIndex(recipes: readonly Recipe[]): string {
|
|
|
336
336
|
);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
const RECIPE_SKIPPED_DIRS = new Set(["node_modules", "screenshots", "dist"]);
|
|
340
|
+
const GENERATED_FILE = /\.generated\./;
|
|
341
|
+
|
|
339
342
|
function listSourceFiles(dir: string, prefix = ""): string[] {
|
|
340
343
|
if (!existsSync(dir)) return [];
|
|
341
344
|
const found: string[] = [];
|
|
342
345
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
343
346
|
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
344
347
|
if (entry.isDirectory()) {
|
|
345
|
-
|
|
346
|
-
|
|
348
|
+
if (!RECIPE_SKIPPED_DIRS.has(entry.name))
|
|
349
|
+
found.push(...listSourceFiles(join(dir, entry.name), rel));
|
|
350
|
+
} else if (
|
|
351
|
+
SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension)) &&
|
|
352
|
+
!GENERATED_FILE.test(entry.name)
|
|
353
|
+
) {
|
|
347
354
|
found.push(rel);
|
|
348
355
|
}
|
|
349
356
|
}
|
|
@@ -362,13 +369,11 @@ function renderRecipe(recipe: Recipe): string {
|
|
|
362
369
|
if (existsSync(readmePath)) {
|
|
363
370
|
parts.push(`## README\n\n${readFileSync(readmePath, "utf-8").replace(/\r\n/g, "\n").trim()}`);
|
|
364
371
|
}
|
|
365
|
-
const sourceBlocks = listSourceFiles(
|
|
366
|
-
const content = readFileSync(join(recipe.dir,
|
|
367
|
-
.replace(/\r\n/g, "\n")
|
|
368
|
-
.trimEnd();
|
|
372
|
+
const sourceBlocks = listSourceFiles(recipe.dir).map((rel) => {
|
|
373
|
+
const content = readFileSync(join(recipe.dir, rel), "utf-8").replace(/\r\n/g, "\n").trimEnd();
|
|
369
374
|
const fence = fenceFor(content);
|
|
370
375
|
const language = rel.endsWith(".tsx") ? "tsx" : "ts";
|
|
371
|
-
return `###
|
|
376
|
+
return `### ${rel}\n\n${fence}${language}\n${content}\n${fence}`;
|
|
372
377
|
});
|
|
373
378
|
if (sourceBlocks.length > 0) parts.push(`## Source files\n\n${sourceBlocks.join("\n\n")}`);
|
|
374
379
|
return finishFile(`${RECIPES_REL}/${recipe.slug}`, parts.join("\n\n"));
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ts } from "ts-morph";
|
|
2
|
+
|
|
3
|
+
export const COMMENT_KIND_LINE = "line";
|
|
4
|
+
export const COMMENT_KIND_BLOCK = "block";
|
|
5
|
+
|
|
6
|
+
export type CommentTrivia = {
|
|
7
|
+
readonly start: number;
|
|
8
|
+
readonly kind: typeof COMMENT_KIND_LINE | typeof COMMENT_KIND_BLOCK;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Tokens that end an operand without being identifiers or keywords.
|
|
13
|
+
const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
14
|
+
ts.SyntaxKind.NumericLiteral,
|
|
15
|
+
ts.SyntaxKind.BigIntLiteral,
|
|
16
|
+
ts.SyntaxKind.StringLiteral,
|
|
17
|
+
ts.SyntaxKind.NoSubstitutionTemplateLiteral,
|
|
18
|
+
ts.SyntaxKind.TemplateTail,
|
|
19
|
+
ts.SyntaxKind.RegularExpressionLiteral,
|
|
20
|
+
ts.SyntaxKind.CloseParenToken,
|
|
21
|
+
ts.SyntaxKind.CloseBracketToken,
|
|
22
|
+
ts.SyntaxKind.CloseBraceToken,
|
|
23
|
+
ts.SyntaxKind.PlusPlusToken,
|
|
24
|
+
ts.SyntaxKind.MinusMinusToken,
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Reserved words after which an expression (so possibly a regex) starts.
|
|
28
|
+
const EXPRESSION_INTRODUCING_KEYWORDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
29
|
+
ts.SyntaxKind.ReturnKeyword,
|
|
30
|
+
ts.SyntaxKind.TypeOfKeyword,
|
|
31
|
+
ts.SyntaxKind.InstanceOfKeyword,
|
|
32
|
+
ts.SyntaxKind.InKeyword,
|
|
33
|
+
ts.SyntaxKind.CaseKeyword,
|
|
34
|
+
ts.SyntaxKind.VoidKeyword,
|
|
35
|
+
ts.SyntaxKind.DeleteKeyword,
|
|
36
|
+
ts.SyntaxKind.ThrowKeyword,
|
|
37
|
+
ts.SyntaxKind.NewKeyword,
|
|
38
|
+
ts.SyntaxKind.YieldKeyword,
|
|
39
|
+
ts.SyntaxKind.AwaitKeyword,
|
|
40
|
+
ts.SyntaxKind.ElseKeyword,
|
|
41
|
+
ts.SyntaxKind.DoKeyword,
|
|
42
|
+
ts.SyntaxKind.ExtendsKeyword,
|
|
43
|
+
ts.SyntaxKind.DefaultKeyword,
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function isIdentifierOrKeyword(kind: ts.SyntaxKind): boolean {
|
|
47
|
+
return (
|
|
48
|
+
kind === ts.SyntaxKind.Identifier ||
|
|
49
|
+
(kind >= ts.SyntaxKind.FirstKeyword && kind <= ts.SyntaxKind.LastKeyword)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// After an operand end a `/` is division, not the start of a regex literal.
|
|
54
|
+
// Contextual keywords (`from`, `type`, `of`, ...) scan as keyword kinds but are
|
|
55
|
+
// valid member and variable names, so every non-introducing keyword counts. A `!`
|
|
56
|
+
// is the non-null postfix only when the token before it ended an operand.
|
|
57
|
+
function isOperandEnd(kind: ts.SyntaxKind, previousEndedOperand: boolean): boolean {
|
|
58
|
+
if (kind === ts.SyntaxKind.ExclamationToken) return previousEndedOperand;
|
|
59
|
+
if (OPERAND_END_PUNCTUATION_KINDS.has(kind)) return true;
|
|
60
|
+
return isIdentifierOrKeyword(kind) && !EXPRESSION_INTRODUCING_KEYWORDS.has(kind);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
64
|
+
ts.SyntaxKind.SingleLineCommentTrivia,
|
|
65
|
+
ts.SyntaxKind.MultiLineCommentTrivia,
|
|
66
|
+
ts.SyntaxKind.WhitespaceTrivia,
|
|
67
|
+
ts.SyntaxKind.NewLineTrivia,
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// The raw scanner always yields SlashToken for `/`; without the rescan a regex
|
|
71
|
+
// body like /\{/ would shift braceDepth and end a template early. After an
|
|
72
|
+
// operand the rescan would instead swallow `/ 2; // {` as a regex and hide the comment.
|
|
73
|
+
function rescanSlashAsRegex(
|
|
74
|
+
scanner: ts.Scanner,
|
|
75
|
+
kind: ts.SyntaxKind,
|
|
76
|
+
previousEndedOperand: boolean,
|
|
77
|
+
): ts.SyntaxKind {
|
|
78
|
+
if (previousEndedOperand) return kind;
|
|
79
|
+
const reScanned = scanner.reScanSlashToken();
|
|
80
|
+
return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function closesTemplateSubstitution(
|
|
84
|
+
templateBraceDepths: readonly number[],
|
|
85
|
+
braceDepth: number,
|
|
86
|
+
): boolean {
|
|
87
|
+
return (
|
|
88
|
+
templateBraceDepths.length > 0 &&
|
|
89
|
+
braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Template substitutions can nest braces (object literals, arrow bodies, other
|
|
94
|
+
// templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
|
|
95
|
+
// never resumes template mode, so reScanTemplateToken() must run once brace
|
|
96
|
+
// depth returns to the level the substitution opened at.
|
|
97
|
+
export function scanCommentTrivia(fullText: string): CommentTrivia[] {
|
|
98
|
+
const comments: CommentTrivia[] = [];
|
|
99
|
+
const scanner = ts.createScanner(
|
|
100
|
+
ts.ScriptTarget.Latest,
|
|
101
|
+
/* skipTrivia */ false,
|
|
102
|
+
ts.LanguageVariant.Standard,
|
|
103
|
+
fullText,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const templateBraceDepths: number[] = [];
|
|
107
|
+
let braceDepth = 0;
|
|
108
|
+
let previousEndedOperand = false;
|
|
109
|
+
|
|
110
|
+
let kind = scanner.scan();
|
|
111
|
+
while (kind !== ts.SyntaxKind.EndOfFileToken) {
|
|
112
|
+
switch (kind) {
|
|
113
|
+
case ts.SyntaxKind.SingleLineCommentTrivia:
|
|
114
|
+
case ts.SyntaxKind.MultiLineCommentTrivia:
|
|
115
|
+
comments.push({
|
|
116
|
+
start: scanner.getTokenStart(),
|
|
117
|
+
kind:
|
|
118
|
+
kind === ts.SyntaxKind.SingleLineCommentTrivia ? COMMENT_KIND_LINE : COMMENT_KIND_BLOCK,
|
|
119
|
+
text: scanner.getTokenText(),
|
|
120
|
+
});
|
|
121
|
+
break;
|
|
122
|
+
case ts.SyntaxKind.SlashToken:
|
|
123
|
+
case ts.SyntaxKind.SlashEqualsToken:
|
|
124
|
+
kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
|
|
125
|
+
break;
|
|
126
|
+
case ts.SyntaxKind.TemplateHead:
|
|
127
|
+
case ts.SyntaxKind.TemplateMiddle:
|
|
128
|
+
templateBraceDepths.push(braceDepth);
|
|
129
|
+
break;
|
|
130
|
+
case ts.SyntaxKind.OpenBraceToken:
|
|
131
|
+
braceDepth++;
|
|
132
|
+
break;
|
|
133
|
+
case ts.SyntaxKind.CloseBraceToken:
|
|
134
|
+
if (closesTemplateSubstitution(templateBraceDepths, braceDepth)) {
|
|
135
|
+
templateBraceDepths.pop();
|
|
136
|
+
kind = scanner.reScanTemplateToken(false);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (braceDepth > 0) braceDepth--;
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
|
|
145
|
+
kind = scanner.scan();
|
|
146
|
+
}
|
|
147
|
+
return comments;
|
|
148
|
+
}
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -339,7 +339,7 @@ export type RunResult = {
|
|
|
339
339
|
readonly scanKinds?: readonly string[];
|
|
340
340
|
/** Files the scan matched — the runner's own count, basis of the floor. */
|
|
341
341
|
readonly matchedFiles?: number;
|
|
342
|
-
/** Roots whose declared sourceRoots hold no .ts/.tsx at all
|
|
342
|
+
/** Roots whose declared sourceRoots hold no .ts/.tsx at all. */
|
|
343
343
|
readonly violatingRoots?: readonly string[];
|
|
344
344
|
/** Violations excused by the per-repo security baseline — security guards only. */
|
|
345
345
|
readonly frozenFindings?: number;
|
|
@@ -597,7 +597,7 @@ function guardKitVersion(): string {
|
|
|
597
597
|
|
|
598
598
|
/**
|
|
599
599
|
* Pure preflight verdict, injectable for tests: which of the two globally
|
|
600
|
-
* silent-green cases
|
|
600
|
+
* silent-green cases applies, if any. Not to be confused with a
|
|
601
601
|
* single guard finding no target repos — that stays a per-guard `skipped`
|
|
602
602
|
* line in `reportResults`.
|
|
603
603
|
*/
|
package/src/_lib/guards-doc.ts
CHANGED
|
@@ -34,10 +34,13 @@ function headerComment(source: string): string {
|
|
|
34
34
|
return body.join("\n");
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Abbreviations that end in a period but not the sentence.
|
|
38
|
+
const SENTENCE_END_RE = /(?<!\be\.g|\bi\.e|\bresp|\bz\. B|\bvs|\betc)\.(\s|$)/;
|
|
39
|
+
|
|
37
40
|
function purposeOf(source: string): string {
|
|
38
41
|
const paragraph = headerComment(source).split(/\n\s*\n/)[0] ?? "";
|
|
39
42
|
const flat = paragraph.replace(/\s+/g, " ").trim();
|
|
40
|
-
const sentenceEnd = flat.search(
|
|
43
|
+
const sentenceEnd = flat.search(SENTENCE_END_RE);
|
|
41
44
|
const sentence = sentenceEnd === -1 ? flat : flat.slice(0, sentenceEnd + 1);
|
|
42
45
|
return sentence.replace(/\|/g, "\\|") || "-";
|
|
43
46
|
}
|
package/src/_lib/roots.ts
CHANGED
|
@@ -183,14 +183,18 @@ function isDerivedAppFallback(root: RepoRoot): boolean {
|
|
|
183
183
|
return root.manifestSource === "derived" && root.kind === "app";
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
186
|
+
/**
|
|
187
|
+
* The repo of the nearest `kumiko.json` at or above `start`, which is the app root in a git tree
|
|
188
|
+
* that holds several. The walk stops at the git toplevel, so a foreign `kumiko.json` further up
|
|
189
|
+
* (workspace parent, home dir) never captures a repo that has none of its own.
|
|
190
|
+
*/
|
|
191
|
+
function nearestManifestRepo(start: string, stopAt: string | undefined): RepoRoot | undefined {
|
|
188
192
|
for (let curr = start; ; curr = dirname(curr)) {
|
|
189
193
|
if (existsSync(join(curr, REPO_MANIFEST_FILE))) {
|
|
190
194
|
const repo = repoAt(curr);
|
|
191
195
|
if (repo) return repo;
|
|
192
196
|
}
|
|
193
|
-
if (curr === dirname(curr)) return undefined;
|
|
197
|
+
if (curr === stopAt || curr === dirname(curr)) return undefined;
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
|
|
@@ -210,9 +214,9 @@ function nearestManifestRepo(start: string): RepoRoot | undefined {
|
|
|
210
214
|
*/
|
|
211
215
|
export function findLocalRepo(cwd: string = process.cwd()): RepoRoot | undefined {
|
|
212
216
|
const start = resolve(cwd);
|
|
213
|
-
const fromManifest = nearestManifestRepo(start);
|
|
214
|
-
if (fromManifest) return fromManifest;
|
|
215
217
|
const toplevel = gitToplevelOf(start);
|
|
218
|
+
const fromManifest = nearestManifestRepo(start, toplevel);
|
|
219
|
+
if (fromManifest) return fromManifest;
|
|
216
220
|
const fromGit = toplevel !== undefined ? repoAt(toplevel) : undefined;
|
|
217
221
|
if (fromGit) return fromGit;
|
|
218
222
|
let fallback: RepoRoot | undefined;
|
package/src/_lib/run-slots.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import { join } from "node:path";
|
|
3
11
|
|
|
4
12
|
const DEFAULT_SLOTS = 2;
|
|
5
13
|
const POLL_INTERVAL_MS = 1000;
|
|
6
14
|
const PIDLESS_ORPHAN_AGE_MS = 60_000;
|
|
7
15
|
// Upper bound against pid reuse after SIGKILL/OOM: a reused pid would otherwise hold the slot forever.
|
|
8
|
-
|
|
16
|
+
// The slot mtime is never refreshed, so this must exceed the longest legitimate guard run.
|
|
17
|
+
const MAX_SLOT_AGE_MS = 2 * 60 * 60_000;
|
|
9
18
|
const PID_FILE = "pid";
|
|
10
19
|
|
|
11
20
|
type Environment = Readonly<Record<string, string | undefined>>;
|
|
@@ -31,13 +40,19 @@ export function resolveGuardSlotCount(environment: Environment): number {
|
|
|
31
40
|
return Number(raw);
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
function errorCode(error: unknown): string {
|
|
44
|
+
return error instanceof Error && "code" in error && typeof error.code === "string"
|
|
45
|
+
? error.code
|
|
46
|
+
: "unknown";
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
function defaultIsPidAlive(pid: number): boolean {
|
|
35
50
|
try {
|
|
36
51
|
process.kill(pid, 0);
|
|
37
52
|
return true;
|
|
38
53
|
} catch (error) {
|
|
39
54
|
// EPERM means the process exists but belongs to another user.
|
|
40
|
-
return error
|
|
55
|
+
return errorCode(error) === "EPERM";
|
|
41
56
|
}
|
|
42
57
|
}
|
|
43
58
|
|
|
@@ -73,12 +88,6 @@ function isOrphanedSlot(
|
|
|
73
88
|
return ageMs > PIDLESS_ORPHAN_AGE_MS;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
function errorCode(error: unknown): string {
|
|
77
|
-
return error instanceof Error && "code" in error && typeof error.code === "string"
|
|
78
|
-
? error.code
|
|
79
|
-
: "unknown";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
91
|
function tryClaimSlot(slotPath: string, pid: number): boolean {
|
|
83
92
|
try {
|
|
84
93
|
mkdirSync(slotPath);
|
|
@@ -148,6 +157,25 @@ function defaultSlotsDir(): string {
|
|
|
148
157
|
return uid === undefined ? "/tmp/kumiko-guard-slots" : `/tmp/kumiko-guard-slots-${uid}`;
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
// The default dir lives in a shared /tmp: another local user could pre-create it (or swap in a
|
|
161
|
+
// symlink) to hold slots forever or redirect the pid write.
|
|
162
|
+
function assertPrivateOwnDirectory(dir: string): void {
|
|
163
|
+
const uid = process.getuid?.();
|
|
164
|
+
// skip: no uid to compare against on this platform.
|
|
165
|
+
if (uid === undefined) return;
|
|
166
|
+
const stats = lstatSync(dir);
|
|
167
|
+
if (
|
|
168
|
+
stats.isSymbolicLink() ||
|
|
169
|
+
!stats.isDirectory() ||
|
|
170
|
+
stats.uid !== uid ||
|
|
171
|
+
(stats.mode & 0o077) !== 0
|
|
172
|
+
) {
|
|
173
|
+
throw Object.assign(new Error(`${dir} is not a private directory owned by the current user`), {
|
|
174
|
+
code: "EUNSAFE",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
151
179
|
export function acquireGuardRunSlot(options: GuardRunSlotOptions = {}): () => void {
|
|
152
180
|
const slotCount = resolveGuardSlotCount(options.environment ?? process.env);
|
|
153
181
|
if (slotCount === 0) return () => {};
|
|
@@ -164,7 +192,8 @@ export function acquireGuardRunSlot(options: GuardRunSlotOptions = {}): () => vo
|
|
|
164
192
|
for (;;) {
|
|
165
193
|
let slotPath: string | undefined;
|
|
166
194
|
try {
|
|
167
|
-
mkdirSync(slotsDir, { recursive: true });
|
|
195
|
+
mkdirSync(slotsDir, { recursive: true, mode: 0o700 });
|
|
196
|
+
assertPrivateOwnDirectory(slotsDir);
|
|
168
197
|
slotPath = claimFreeSlot(slotsDir, slotCount, pid, isPidAlive, now);
|
|
169
198
|
} catch (error) {
|
|
170
199
|
// A throttle must never block the push: fail open.
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type RootScan = {
|
|
|
35
35
|
readonly root: RepoRoot;
|
|
36
36
|
/** Absolute paths handed to the guard, sorted. */
|
|
37
37
|
readonly files: readonly string[];
|
|
38
|
-
/** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the
|
|
38
|
+
/** All .ts/.tsx under the declared sourceRoots minus excludes, before extension/within filters — the floor input. */
|
|
39
39
|
readonly sourceSurface: number;
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -35,6 +35,7 @@ export type SecurityEvaluation = {
|
|
|
35
35
|
readonly blocking: AuditAdvisory[];
|
|
36
36
|
readonly warnings: AuditAdvisory[];
|
|
37
37
|
readonly suppressed: AuditAdvisory[];
|
|
38
|
+
readonly usedExceptions: SecurityException[];
|
|
38
39
|
readonly counts: Record<Severity, number>;
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -90,12 +91,20 @@ function matchesAdvisory(exceptionAdvisory: string, advisoryId: string, url: str
|
|
|
90
91
|
function findSecurityException(
|
|
91
92
|
advisory: AuditAdvisory,
|
|
92
93
|
exceptions: readonly SecurityException[],
|
|
94
|
+
nowMs: number,
|
|
93
95
|
): SecurityException | undefined {
|
|
94
|
-
|
|
96
|
+
const matching = exceptions.filter(
|
|
95
97
|
(ex) =>
|
|
96
98
|
matchesAdvisory(ex.advisory, advisory.advisoryId, advisory.url) &&
|
|
97
99
|
(ex.package === "*" || matchesPackageName(ex.package, advisory.package)),
|
|
98
100
|
);
|
|
101
|
+
// An expired earlier entry (e.g. bundled) must not shadow a valid later one (e.g. repo-level).
|
|
102
|
+
return matching.find((ex) => isSecurityExceptionActive(ex, nowMs)) ?? matching[0];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// isExceptionValid() treats a missing expiry as "forever valid", which is wrong for security: such an exception would suppress a critical permanently.
|
|
106
|
+
function isSecurityExceptionActive(exception: SecurityException, nowMs: number): boolean {
|
|
107
|
+
return Boolean(exception.expires) && isExceptionValid(exception.expires, nowMs).valid;
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
export function evaluateSecurity(
|
|
@@ -107,18 +116,16 @@ export function evaluateSecurity(
|
|
|
107
116
|
const blocking: AuditAdvisory[] = [];
|
|
108
117
|
const warnings: AuditAdvisory[] = [];
|
|
109
118
|
const suppressed: AuditAdvisory[] = [];
|
|
119
|
+
const usedExceptions = new Set<SecurityException>();
|
|
110
120
|
|
|
111
121
|
for (const advisory of advisories) {
|
|
112
122
|
counts[advisory.severity] += 1;
|
|
113
|
-
const exception = findSecurityException(advisory, exceptions);
|
|
114
|
-
|
|
115
|
-
const covered =
|
|
116
|
-
exception !== undefined &&
|
|
117
|
-
Boolean(exception.expires) &&
|
|
118
|
-
isExceptionValid(exception.expires, nowMs).valid;
|
|
123
|
+
const exception = findSecurityException(advisory, exceptions, nowMs);
|
|
124
|
+
const covered = exception !== undefined && isSecurityExceptionActive(exception, nowMs);
|
|
119
125
|
|
|
120
126
|
if (covered) {
|
|
121
127
|
suppressed.push(advisory);
|
|
128
|
+
usedExceptions.add(exception);
|
|
122
129
|
} else if (FAIL_SEVERITIES.has(advisory.severity)) {
|
|
123
130
|
blocking.push(advisory);
|
|
124
131
|
} else if (advisory.severity === "moderate") {
|
|
@@ -126,7 +133,7 @@ export function evaluateSecurity(
|
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
|
|
129
|
-
return { blocking, warnings, suppressed, counts };
|
|
136
|
+
return { blocking, warnings, suppressed, usedExceptions: [...usedExceptions], counts };
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
/** True when `bytes` starts with the gzip magic header (1f 8b). */
|
|
@@ -58,7 +58,7 @@ export type SqlInventoryReport = {
|
|
|
58
58
|
};
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
-
/** Paths where `.unsafe()` / `asRawClient()` are permitted
|
|
61
|
+
/** Paths where `.unsafe()` / `asRawClient()` are permitted. */
|
|
62
62
|
export const RAW_SQL_ALLOWLIST: ReadonlyArray<RegExp> = [
|
|
63
63
|
// Layout-agnostic: framework packages/... AND flat app src/... AND enterprise packages/<pkg>/...
|
|
64
64
|
/(^|\/)(packages\/[^/]+\/)?src\/db\/queries\//,
|
|
@@ -107,7 +107,7 @@ const PATTERNS: ReadonlyArray<{
|
|
|
107
107
|
{ kind: "execute", re: /\.execute\s*\(/ },
|
|
108
108
|
];
|
|
109
109
|
|
|
110
|
-
/** Only these kinds fail CI; delete_from/execute stay inventory-only
|
|
110
|
+
/** Only these kinds fail CI; delete_from/execute stay inventory-only. */
|
|
111
111
|
export const BLOCKING_SQL_KINDS: ReadonlyArray<SqlInventoryKind> = ["unsafe", "asRawClient"];
|
|
112
112
|
|
|
113
113
|
const TS_GLOB = new Bun.Glob("**/*.{ts,tsx}");
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,49 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Complexity hard cap 40, thin-wrapper guard skips typed table slots",
|
|
6
|
+
"detail": "A function above complexity 40 in the repo's own files fails the complexity guard, independent of the complexity baseline and of the complexity-budget tag, also with --no-baseline. The thin-wrapper guard skips a function that is a value of an object literal with a declared type or a satisfies check, inline or referenced by name, because the table fixes its signature.",
|
|
7
|
+
"migration": "Split every function above complexity 40 before the bump; the complexity-budget tag and the baseline do not cover it. `bun check-complexity.ts --all --no-baseline` lists them under \"Above hard cap\"."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.388.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Complexity hard cap, --all, file-size threshold 1200",
|
|
13
|
+
"detail": "Functions with complexity above 60 are a violation in the repo's own files, independent of the complexity baseline and of the complexity-budget tag, also with --no-baseline. The report lists them in their own section. `bun check-complexity.ts --all` prints all hotspots in the standalone run, --write-baseline and --no-baseline. The file-size guard now freezes files above 1200 lines instead of 2000.",
|
|
14
|
+
"migration": "Consumer repos without a complexity baseline get a hard error for functions above complexity 60; split them. File size stays a warning there without a baseline; with one, files between 1200 and 2000 lines count as new hits until `--write-baseline` is run."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.388.0",
|
|
18
|
+
"type": "fix",
|
|
19
|
+
"title": "Harden guards infrastructure (pre-push scan range, slot dir, root walk)",
|
|
20
|
+
"detail": "The pre-push secret scan covers every pushed ref instead of HEAD. The guard slot directory is created 0700 and ignored when not private; slot max age is 2 h. findLocalRepo no longer picks up a kumiko.json above the git root. Runtime-isolation keeps web/dev/ files as client."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.388.0",
|
|
24
|
+
"type": "fix",
|
|
25
|
+
"title": "Guards report fewer false results",
|
|
26
|
+
"detail": "The import-extensions guard now also checks import types, mock.module and template-literal specifiers. The comment-issue-ref-only guard no longer treats file names like round2.ts as plan labels. The direct-entity-writes guard recognises r.entity registrations with a constant or shorthand name, reports direct writes on package catalog tables even when the scope has no own ES tables, keeps receiver text on one line and names the installed kumiko-guards bin in its catalog rewrite hint. The app-feature-structure guard no longer tells dev/ and scripts/ files to move under web/, and the bundled license exceptions drop the stale first-party and create-kumiko-app entries."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.388.0",
|
|
30
|
+
"type": "fix",
|
|
31
|
+
"title": "Dependency exceptions, timeout and boundary guards no longer miss or invent findings",
|
|
32
|
+
"detail": "A renewed license or security exception now wins over an expired earlier one, license exceptions must match the package license and a real semver range, and only exceptions that suppressed an advisory block on near expiry. The test-timeouts guard also flags union-typed and untyped timeout arguments, the renderer-boundaries guard covers headless and live imports, import-extensions ignores real .js files without a .ts sibling, and the generated agent examples now include bin, e2e, seeds and scripts files."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.388.0",
|
|
36
|
+
"type": "fix",
|
|
37
|
+
"title": "Guards --explain uses the injectable project builder",
|
|
38
|
+
"detail": "runGuardsCli builds the explain project through the same buildProject dependency as the check run, which lets tests assert that --explain takes no guard run slot without parsing the whole repository."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.387.0",
|
|
42
|
+
"type": "improvement",
|
|
43
|
+
"title": "Comment Issue-Ref guard",
|
|
44
|
+
"detail": "The comment-issue-ref-only guard flags comments that consist only of an issue or PR reference or a planning label (`#1234`, `fw#1234`, `Phase 3`, `S2.H1`) with no explanatory word besides it. Consecutive own-line `//` comments count as one paragraph, so a reference at the end of an explaining paragraph passes. Hits are frozen per file in `.kumiko-comment-issue-ref-baseline.json` and may not grow; without the baseline file the guard only warns. Deliberate exceptions take `// kumiko-lint-ignore comment-issue-ref-only <reason>`.",
|
|
45
|
+
"migration": "Optional. Freeze existing hits with `kumiko-guards guards --write-baseline --guard=comment-issue-ref-only`."
|
|
46
|
+
},
|
|
2
47
|
{
|
|
3
48
|
"version": "0.385.0",
|
|
4
49
|
"type": "improvement",
|
package/src/check-as-casts.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function isConstAssertion(cast: AsExpression): boolean {
|
|
|
117
117
|
// optional ParenthesizedExpression in between (depending on parenthesisation):
|
|
118
118
|
// `x as unknown as Y` → AsExpr(target=Y) → AsExpr(target=unknown) → x
|
|
119
119
|
// `(x as unknown) as Y` → AsExpr(target=Y) → ParenExpr → AsExpr(target=unknown) → x
|
|
120
|
-
//
|
|
120
|
+
// Traverse parentheses transparently.
|
|
121
121
|
function unwrapParens(node: Node | undefined): Node | undefined {
|
|
122
122
|
while (node?.getKind() === SyntaxKind.ParenthesizedExpression) {
|
|
123
123
|
node = node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression();
|
|
@@ -277,7 +277,7 @@ function restOfLine(fullText: string, from: number): string {
|
|
|
277
277
|
export function extractBoundaryReason(cast: AsExpression): string | null {
|
|
278
278
|
const fullText = cast.getSourceFile().getFullText();
|
|
279
279
|
|
|
280
|
-
// (1) Leading comments
|
|
280
|
+
// (1) Leading comments of the enclosing statement
|
|
281
281
|
const stmt =
|
|
282
282
|
cast.getFirstAncestorByKind(SyntaxKind.VariableStatement) ??
|
|
283
283
|
cast.getFirstAncestorByKind(SyntaxKind.ExpressionStatement) ??
|
|
@@ -288,7 +288,7 @@ export function extractBoundaryReason(cast: AsExpression): string | null {
|
|
|
288
288
|
if (reason !== null) return reason;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
// (2) Cast
|
|
291
|
+
// (2) Cast range itself (multi-line casts, inline comments inside)
|
|
292
292
|
const inlineReason = boundaryReasonIn(fullText.slice(cast.getStart(), cast.getEnd()));
|
|
293
293
|
if (inlineReason !== null) return inlineReason;
|
|
294
294
|
|
|
@@ -327,9 +327,8 @@ const FILE_DEFAULT_REASONS: ReadonlyArray<{
|
|
|
327
327
|
boundaryReason: BoundaryReason;
|
|
328
328
|
}> = [
|
|
329
329
|
{
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// NavDef, ConfigKeys, …). Per Konstruktion ein schema-walk.
|
|
330
|
+
// Feature-AST extractors cast erased ts-morph parse results to typed feature definitions
|
|
331
|
+
// (EntityDefinition, RelationDef, NavDef, ConfigKeys, …), so every cast is a schema walk.
|
|
333
332
|
pattern: /\/engine\/feature-ast\//,
|
|
334
333
|
boundaryReason: "schema-walk",
|
|
335
334
|
},
|
|
@@ -605,7 +604,7 @@ function writeBaseline(all: readonly Site[]): void {
|
|
|
605
604
|
legitRatchet.write(legitCountsByPackage(all));
|
|
606
605
|
}
|
|
607
606
|
|
|
608
|
-
// Per
|
|
607
|
+
// Per file:target: current count against the baseline. More → regression.
|
|
609
608
|
// A cast swap (cast A removed, cast B with a different target added) is detected
|
|
610
609
|
// this way although the total stays stable.
|
|
611
610
|
// Merge-conflict markers, truncated writes and hand edits all land here; the
|