@graphorin/eslint-plugin 0.6.0 → 0.7.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/CHANGELOG.md +26 -0
- package/README.md +12 -4
- package/dist/index.d.ts +30 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -22
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/index.ts +113 -0
- package/src/rules/_comment-utils.ts +37 -0
- package/src/rules/no-bare-tool-exec.ts +120 -0
- package/src/rules/no-implicit-network-call.ts +236 -0
- package/src/rules/no-secret-in-deps.ts +113 -0
- package/src/rules/no-secret-unwrap.ts +118 -0
- package/src/rules/no-third-party-workflow-aliases.ts +100 -0
- package/src/rules/provider-middleware-order.ts +113 -0
- package/src/rules/tool-description-required.ts +75 -0
- package/src/rules/tool-examples-recommended.ts +61 -0
- package/src/rules/tool-parameter-naming.ts +75 -0
- package/src/tool-discovery.ts +804 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment-scanning helpers shared by the rule modules. We use raw
|
|
3
|
+
* `getAllComments` + line-distance heuristics instead of `getComments
|
|
4
|
+
* Before / Inside / After` because ESLint's per-node comment attachment
|
|
5
|
+
* leaves orphan comments at the program top-level when the node
|
|
6
|
+
* itself is nested inside an expression statement.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Rule } from 'eslint';
|
|
10
|
+
import type { Comment, Node } from 'estree';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns `true` when the source contains a comment whose `value`
|
|
14
|
+
* matches `tag` and which is positioned within `1` line of `node`'s
|
|
15
|
+
* start, or anywhere inside `node`'s span.
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export function nodeHasNearbyComment(
|
|
20
|
+
context: Rule.RuleContext,
|
|
21
|
+
node: Node,
|
|
22
|
+
tagPattern: RegExp,
|
|
23
|
+
maxGap: number = 1,
|
|
24
|
+
): boolean {
|
|
25
|
+
const startLine = node.loc?.start.line ?? 0;
|
|
26
|
+
const endLine = node.loc?.end.line ?? Number.POSITIVE_INFINITY;
|
|
27
|
+
const allComments = context.sourceCode.getAllComments() as Comment[];
|
|
28
|
+
for (const c of allComments) {
|
|
29
|
+
if (!tagPattern.test(c.value)) continue;
|
|
30
|
+
const cStart = c.loc?.start.line ?? 0;
|
|
31
|
+
const cEnd = c.loc?.end.line ?? 0;
|
|
32
|
+
if (cEnd <= startLine && startLine - cEnd <= maxGap) return true;
|
|
33
|
+
if (cStart >= startLine && cStart <= endLine) return true;
|
|
34
|
+
if (cStart >= endLine && cStart - endLine <= maxGap) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/no-bare-tool-exec`. Flags any `tool({...})`
|
|
3
|
+
* invocation whose `execute` function does not reference
|
|
4
|
+
* `ctx.signal`. The intent is principle 3 (streaming-first) +
|
|
5
|
+
* DEC-143 (cancellation contract): every long-running tool MUST
|
|
6
|
+
* propagate the abort signal to the underlying I/O so cancellation
|
|
7
|
+
* actually frees resources.
|
|
8
|
+
*
|
|
9
|
+
* The check is intentionally lexical - it scans the function body for
|
|
10
|
+
* the literal string `signal` (matching `ctx.signal`,
|
|
11
|
+
* `request.signal`, `args.signal`, etc.). False positives are
|
|
12
|
+
* acceptable because the fix is one comment line; false negatives are
|
|
13
|
+
* not, because they hide cancellation bugs in production.
|
|
14
|
+
*
|
|
15
|
+
* Per-call opt-out: `// graphorin-allow-bare-exec: <reason>` on the
|
|
16
|
+
* line above the `execute` function or anywhere inside the tool
|
|
17
|
+
* builder call.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Rule } from 'eslint';
|
|
23
|
+
import type {
|
|
24
|
+
ArrowFunctionExpression,
|
|
25
|
+
CallExpression,
|
|
26
|
+
FunctionExpression,
|
|
27
|
+
Identifier,
|
|
28
|
+
ObjectExpression,
|
|
29
|
+
Property,
|
|
30
|
+
} from 'estree';
|
|
31
|
+
|
|
32
|
+
import { nodeHasNearbyComment } from './_comment-utils.js';
|
|
33
|
+
|
|
34
|
+
const ALLOW_TAG = /graphorin-allow-bare-exec/;
|
|
35
|
+
const SIGNAL_REFERENCE_RE = /\bsignal\b/;
|
|
36
|
+
|
|
37
|
+
const rule: Rule.RuleModule = {
|
|
38
|
+
meta: {
|
|
39
|
+
type: 'suggestion',
|
|
40
|
+
docs: {
|
|
41
|
+
description:
|
|
42
|
+
'Require every `tool({...})` `execute` function to reference `ctx.signal` so cancellation propagates to the underlying I/O (principle 3, DEC-143).',
|
|
43
|
+
recommended: true,
|
|
44
|
+
},
|
|
45
|
+
schema: [],
|
|
46
|
+
messages: {
|
|
47
|
+
missingSignal:
|
|
48
|
+
"tool '{{name}}' `execute` function does not reference `ctx.signal`; long-running tools MUST propagate the abort signal. Add `// graphorin-allow-bare-exec: <reason>` to opt out.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
52
|
+
return {
|
|
53
|
+
CallExpression(node: CallExpression): void {
|
|
54
|
+
if (!isToolBuilderCall(node)) return;
|
|
55
|
+
const arg = node.arguments[0];
|
|
56
|
+
if (arg === undefined || arg.type !== 'ObjectExpression') return;
|
|
57
|
+
const exec = findExecuteProperty(arg as ObjectExpression);
|
|
58
|
+
if (exec === null) return;
|
|
59
|
+
if (!isFunctionLike(exec)) return;
|
|
60
|
+
const fn = exec as ArrowFunctionExpression | FunctionExpression;
|
|
61
|
+
if (!fn.body) return;
|
|
62
|
+
const fnSource = context.sourceCode.getText(fn.body);
|
|
63
|
+
if (SIGNAL_REFERENCE_RE.test(fnSource)) return;
|
|
64
|
+
if (hasAllowComment(context, node)) return;
|
|
65
|
+
const name = extractToolName(arg as ObjectExpression) ?? '<unknown>';
|
|
66
|
+
context.report({
|
|
67
|
+
node: fn,
|
|
68
|
+
messageId: 'missingSignal',
|
|
69
|
+
data: { name },
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function isToolBuilderCall(node: CallExpression): boolean {
|
|
77
|
+
if (node.callee.type !== 'Identifier') return false;
|
|
78
|
+
return (node.callee as Identifier).name === 'tool';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findExecuteProperty(obj: ObjectExpression): Property['value'] | null {
|
|
82
|
+
for (const prop of obj.properties) {
|
|
83
|
+
if (prop.type !== 'Property') continue;
|
|
84
|
+
const property = prop as Property;
|
|
85
|
+
if (property.computed) continue;
|
|
86
|
+
const key = property.key;
|
|
87
|
+
if (key.type === 'Identifier' && key.name === 'execute') return property.value;
|
|
88
|
+
if (key.type === 'Literal' && key.value === 'execute') return property.value;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isFunctionLike(
|
|
94
|
+
value: Property['value'],
|
|
95
|
+
): value is ArrowFunctionExpression | FunctionExpression {
|
|
96
|
+
return value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function extractToolName(obj: ObjectExpression): string | null {
|
|
100
|
+
for (const prop of obj.properties) {
|
|
101
|
+
if (prop.type !== 'Property') continue;
|
|
102
|
+
const property = prop as Property;
|
|
103
|
+
if (property.computed) continue;
|
|
104
|
+
const key = property.key;
|
|
105
|
+
const isName =
|
|
106
|
+
(key.type === 'Identifier' && key.name === 'name') ||
|
|
107
|
+
(key.type === 'Literal' && key.value === 'name');
|
|
108
|
+
if (!isName) continue;
|
|
109
|
+
if (property.value.type === 'Literal' && typeof property.value.value === 'string') {
|
|
110
|
+
return property.value.value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasAllowComment(context: Rule.RuleContext, node: CallExpression): boolean {
|
|
117
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default rule;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/no-implicit-network-call` (DEC-154 / ADR-041).
|
|
3
|
+
* Flags any direct network primitive in code under a `@graphorin/*`
|
|
4
|
+
* package's `src/` directory unless the call site carries an opt-out
|
|
5
|
+
* comment whose text contains `'graphorin-allow-network'`:
|
|
6
|
+
*
|
|
7
|
+
* - calls: `fetch(...)`, `http(s).request/get(...)`, `axios(...)` /
|
|
8
|
+
* `axios.<verb>(...)`, `undici.<verb>(...)` / `got.<verb>(...)`,
|
|
9
|
+
* `net.createConnection/connect(...)`, `tls.connect(...)`,
|
|
10
|
+
* `dgram.createSocket(...)`
|
|
11
|
+
* - constructors: `new XMLHttpRequest()`, `new WebSocket(...)`,
|
|
12
|
+
* `new EventSource(...)`
|
|
13
|
+
* - imports of HTTP clients: `node-fetch`, `undici`, `got`, `axios`,
|
|
14
|
+
* `ky`, `ws` (static, dynamic `import(...)`, and `require(...)`)
|
|
15
|
+
*
|
|
16
|
+
* Companion to the `pnpm run check-no-network` static analysis script;
|
|
17
|
+
* the two matchers are kept in lockstep (this rule mirrors the EB-10
|
|
18
|
+
* hardening that taught the script about undici/got, raw sockets,
|
|
19
|
+
* WebSocket/EventSource, and HTTP-client import specifiers). The lint
|
|
20
|
+
* surface catches the pattern at author time so reviewers do not need
|
|
21
|
+
* to wait for CI to flag a missed network gate.
|
|
22
|
+
*
|
|
23
|
+
* The rule is intentionally limited to the framework's own code paths
|
|
24
|
+
* - consumer applications can call `fetch` freely. Activation is
|
|
25
|
+
* two-stage (W-039): the linted file path must match
|
|
26
|
+
* `/packages/<pkg>/src/` AND the nearest package.json's `name` must
|
|
27
|
+
* start with one of `options[0].packagePrefixes` (default
|
|
28
|
+
* `['@graphorin/']`), so a downstream pnpm monorepo with the standard
|
|
29
|
+
* `packages/*\/src` layout no longer gets errors on its own `fetch()`
|
|
30
|
+
* calls just for adopting `flat/recommended`.
|
|
31
|
+
*
|
|
32
|
+
* FAIL-OPEN fallback: when no package.json resolves above the file
|
|
33
|
+
* (virtual paths in editor buffers or programmatic `Linter` runs), the
|
|
34
|
+
* rule activates on the path match alone - exactly the pre-W-039
|
|
35
|
+
* behaviour, so a resolution hiccup can only over-flag framework-shaped
|
|
36
|
+
* paths, never silently disable the guard.
|
|
37
|
+
*
|
|
38
|
+
* @packageDocumentation
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { dirname, join } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import type { Rule } from 'eslint';
|
|
45
|
+
import type {
|
|
46
|
+
CallExpression,
|
|
47
|
+
Identifier,
|
|
48
|
+
ImportDeclaration,
|
|
49
|
+
MemberExpression,
|
|
50
|
+
NewExpression,
|
|
51
|
+
} from 'estree';
|
|
52
|
+
|
|
53
|
+
import { nodeHasNearbyComment } from './_comment-utils.js';
|
|
54
|
+
|
|
55
|
+
const ALLOW_TAG = /graphorin-allow-network/;
|
|
56
|
+
const FRAMEWORK_PATH_RE = /\bpackages\/[a-z0-9_-]+\/src\b/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Directory -> nearest package.json `name` (or null when none
|
|
60
|
+
* resolves). Module-level so one walk serves every file of a package
|
|
61
|
+
* within a lint run.
|
|
62
|
+
*/
|
|
63
|
+
const packageNameCache = new Map<string, string | null>();
|
|
64
|
+
|
|
65
|
+
/** Walk up from `fileDir` to the nearest package.json name (cached). */
|
|
66
|
+
function nearestPackageName(fileDir: string): string | null {
|
|
67
|
+
const cached = packageNameCache.get(fileDir);
|
|
68
|
+
if (cached !== undefined) return cached;
|
|
69
|
+
let name: string | null = null;
|
|
70
|
+
let dir = fileDir;
|
|
71
|
+
for (;;) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = readFileSync(join(dir, 'package.json'), 'utf8');
|
|
74
|
+
const parsed = JSON.parse(raw) as { name?: unknown };
|
|
75
|
+
name = typeof parsed.name === 'string' ? parsed.name : null;
|
|
76
|
+
break;
|
|
77
|
+
} catch {
|
|
78
|
+
const parent = dirname(dir);
|
|
79
|
+
if (parent === dir) break;
|
|
80
|
+
dir = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
packageNameCache.set(fileDir, name);
|
|
84
|
+
return name;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const NETWORK_CALLEES = new Set(['fetch', 'XMLHttpRequest']);
|
|
88
|
+
const HTTP_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'request']);
|
|
89
|
+
const CLIENT_NAMESPACE_VERBS = new Set(['request', 'stream', 'fetch', 'get', 'post']);
|
|
90
|
+
const NETWORK_CONSTRUCTORS = new Set(['XMLHttpRequest', 'WebSocket', 'EventSource']);
|
|
91
|
+
const HTTP_CLIENT_SPECIFIERS = new Set(['node-fetch', 'undici', 'got', 'axios', 'ky', 'ws']);
|
|
92
|
+
|
|
93
|
+
const rule: Rule.RuleModule = {
|
|
94
|
+
meta: {
|
|
95
|
+
type: 'problem',
|
|
96
|
+
docs: {
|
|
97
|
+
description:
|
|
98
|
+
'Disallow direct network primitives (`fetch`, `axios`/`undici`/`got`, `http.request`, raw `net`/`tls`/`dgram` sockets, `WebSocket`/`EventSource`/`XMLHttpRequest`, HTTP-client imports) in `@graphorin/*` framework code without an explicit opt-out comment (DEC-154).',
|
|
99
|
+
recommended: true,
|
|
100
|
+
},
|
|
101
|
+
schema: [
|
|
102
|
+
{
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
packagePrefixes: {
|
|
106
|
+
type: 'array',
|
|
107
|
+
items: { type: 'string' },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
messages: {
|
|
114
|
+
forbidden:
|
|
115
|
+
"direct network call '{{callee}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out.",
|
|
116
|
+
forbiddenImport:
|
|
117
|
+
"HTTP-client import '{{specifier}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out.",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
121
|
+
const filename = context.filename.replace(/\\/g, '/');
|
|
122
|
+
if (!FRAMEWORK_PATH_RE.test(filename)) {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
// W-039 second stage: only packages whose manifest name matches a
|
|
126
|
+
// configured prefix are "framework code". No resolvable manifest ->
|
|
127
|
+
// fail open (path-only activation, the historical behaviour).
|
|
128
|
+
const options = (context.options?.[0] ?? {}) as { packagePrefixes?: readonly string[] };
|
|
129
|
+
const prefixes = options.packagePrefixes ?? ['@graphorin/'];
|
|
130
|
+
const packageName = nearestPackageName(dirname(context.filename));
|
|
131
|
+
if (packageName !== null && !prefixes.some((p) => packageName.startsWith(p))) {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
CallExpression(node: CallExpression): void {
|
|
136
|
+
const specifier = requiredClientSpecifier(node);
|
|
137
|
+
if (specifier !== null) {
|
|
138
|
+
if (hasAllowComment(context, node)) return;
|
|
139
|
+
context.report({
|
|
140
|
+
node,
|
|
141
|
+
messageId: 'forbiddenImport',
|
|
142
|
+
data: { specifier },
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const name = describeCallee(node);
|
|
147
|
+
if (name === null) return;
|
|
148
|
+
if (hasAllowComment(context, node)) return;
|
|
149
|
+
context.report({
|
|
150
|
+
node,
|
|
151
|
+
messageId: 'forbidden',
|
|
152
|
+
data: { callee: name },
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
NewExpression(node: NewExpression): void {
|
|
156
|
+
if (node.callee.type !== 'Identifier') return;
|
|
157
|
+
const callee = node.callee as Identifier;
|
|
158
|
+
if (!NETWORK_CONSTRUCTORS.has(callee.name)) return;
|
|
159
|
+
if (hasAllowComment(context, node)) return;
|
|
160
|
+
context.report({
|
|
161
|
+
node,
|
|
162
|
+
messageId: 'forbidden',
|
|
163
|
+
data: { callee: `new ${callee.name}` },
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
ImportDeclaration(node: ImportDeclaration): void {
|
|
167
|
+
const source = node.source.value;
|
|
168
|
+
if (typeof source !== 'string' || !HTTP_CLIENT_SPECIFIERS.has(source)) return;
|
|
169
|
+
if (nodeHasNearbyComment(context, node, ALLOW_TAG, 1)) return;
|
|
170
|
+
context.report({
|
|
171
|
+
node,
|
|
172
|
+
messageId: 'forbiddenImport',
|
|
173
|
+
data: { specifier: source },
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
ImportExpression(node: Rule.Node): void {
|
|
177
|
+
const source = (node as { source?: { type?: string; value?: unknown } }).source;
|
|
178
|
+
if (source?.type !== 'Literal' || typeof source.value !== 'string') return;
|
|
179
|
+
if (!HTTP_CLIENT_SPECIFIERS.has(source.value)) return;
|
|
180
|
+
if (nodeHasNearbyComment(context, node as never, ALLOW_TAG, 1)) return;
|
|
181
|
+
context.report({
|
|
182
|
+
node: node as never,
|
|
183
|
+
messageId: 'forbiddenImport',
|
|
184
|
+
data: { specifier: source.value },
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** `require('<http client>')` - import-shaped despite being a call. */
|
|
192
|
+
function requiredClientSpecifier(node: CallExpression): string | null {
|
|
193
|
+
if (node.callee.type !== 'Identifier') return null;
|
|
194
|
+
if ((node.callee as Identifier).name !== 'require') return null;
|
|
195
|
+
const arg = node.arguments[0];
|
|
196
|
+
if (arg === undefined || arg.type !== 'Literal') return null;
|
|
197
|
+
const value = (arg as { value?: unknown }).value;
|
|
198
|
+
if (typeof value !== 'string' || !HTTP_CLIENT_SPECIFIERS.has(value)) return null;
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function describeCallee(node: CallExpression): string | null {
|
|
203
|
+
if (node.callee.type === 'Identifier') {
|
|
204
|
+
const name = (node.callee as Identifier).name;
|
|
205
|
+
if (NETWORK_CALLEES.has(name) || name === 'axios') return name;
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (node.callee.type === 'MemberExpression') {
|
|
209
|
+
const me = node.callee as MemberExpression;
|
|
210
|
+
if (me.computed) return null;
|
|
211
|
+
const objName = me.object.type === 'Identifier' ? (me.object as Identifier).name : null;
|
|
212
|
+
if (objName === null) return null;
|
|
213
|
+
const propName = me.property.type === 'Identifier' ? (me.property as Identifier).name : null;
|
|
214
|
+
if (propName === null) return null;
|
|
215
|
+
if (
|
|
216
|
+
(objName === 'http' || objName === 'https') &&
|
|
217
|
+
(propName === 'request' || propName === 'get')
|
|
218
|
+
)
|
|
219
|
+
return `${objName}.${propName}`;
|
|
220
|
+
if (objName === 'axios' && HTTP_VERBS.has(propName)) return `axios.${propName}`;
|
|
221
|
+
if ((objName === 'undici' || objName === 'got') && CLIENT_NAMESPACE_VERBS.has(propName))
|
|
222
|
+
return `${objName}.${propName}`;
|
|
223
|
+
if (objName === 'net' && (propName === 'createConnection' || propName === 'connect'))
|
|
224
|
+
return `net.${propName}`;
|
|
225
|
+
if (objName === 'tls' && propName === 'connect') return 'tls.connect';
|
|
226
|
+
if (objName === 'dgram' && propName === 'createSocket') return 'dgram.createSocket';
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function hasAllowComment(context: Rule.RuleContext, node: CallExpression | NewExpression): boolean {
|
|
233
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default rule;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/no-secret-in-deps` (RB-24 / DEC-137). Flags any
|
|
3
|
+
* call shaped like `withChildToolSecretsContext({ secretsAllowed:
|
|
4
|
+
* [...] }, fn)` where the `secretsAllowed` array is non-empty and the
|
|
5
|
+
* surrounding call site does not include a comment whose text starts
|
|
6
|
+
* with `'rb-24-justification:'` (case-insensitive).
|
|
7
|
+
*
|
|
8
|
+
* The rule enforces the principle-of-least-authority discipline from
|
|
9
|
+
* DEC-137: granting a child tool scope access to parent secrets is
|
|
10
|
+
* opt-in, and the opt-in MUST be explained in code so security
|
|
11
|
+
* reviewers can audit the inheritance graph without guessing.
|
|
12
|
+
*
|
|
13
|
+
* History: this rule originally matched `Agent.toTool({
|
|
14
|
+
* inheritSecrets: [...] })`, a pre-0.5 API shape that no longer
|
|
15
|
+
* exists - `AgentToToolOptions` deliberately has no secret-inheritance
|
|
16
|
+
* mechanism at that boundary. The DEC-137 grant point today is the
|
|
17
|
+
* explicit child ACL scope opened via `withChildToolSecretsContext`
|
|
18
|
+
* from `@graphorin/security` (whose `secretsAllowed` is intersected
|
|
19
|
+
* with the parent allowlist), so that is what the rule matches now.
|
|
20
|
+
*
|
|
21
|
+
* The rule is intentionally syntactic - it operates on the literal
|
|
22
|
+
* call site without trying to resolve the value of the array. This
|
|
23
|
+
* keeps the rule cheap and avoids false negatives from spread /
|
|
24
|
+
* function-call expressions that hide the inheritance shape.
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Rule } from 'eslint';
|
|
30
|
+
import type {
|
|
31
|
+
ArrayExpression,
|
|
32
|
+
CallExpression,
|
|
33
|
+
Identifier,
|
|
34
|
+
MemberExpression,
|
|
35
|
+
ObjectExpression,
|
|
36
|
+
Property,
|
|
37
|
+
} from 'estree';
|
|
38
|
+
|
|
39
|
+
import { nodeHasNearbyComment } from './_comment-utils.js';
|
|
40
|
+
|
|
41
|
+
const JUSTIFICATION_TAG = /\brb-24-justification\s*:/i;
|
|
42
|
+
const GRANT_CALLEE = 'withChildToolSecretsContext';
|
|
43
|
+
|
|
44
|
+
const rule: Rule.RuleModule = {
|
|
45
|
+
meta: {
|
|
46
|
+
type: 'suggestion',
|
|
47
|
+
docs: {
|
|
48
|
+
description:
|
|
49
|
+
'Require an `// rb-24-justification:` comment when `withChildToolSecretsContext({ secretsAllowed: [...] })` grants a non-empty allowlist to a child tool scope (DEC-137).',
|
|
50
|
+
recommended: true,
|
|
51
|
+
},
|
|
52
|
+
schema: [],
|
|
53
|
+
messages: {
|
|
54
|
+
missingJustification:
|
|
55
|
+
'`secretsAllowed` is non-empty but the call site lacks an `// rb-24-justification: <reason>` comment. Document why this child tool scope inherits parent secrets per DEC-137.',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
59
|
+
return {
|
|
60
|
+
CallExpression(node: CallExpression): void {
|
|
61
|
+
if (!isGrantCall(node)) return;
|
|
62
|
+
const arg = node.arguments[0];
|
|
63
|
+
if (arg === undefined || arg.type !== 'ObjectExpression') return;
|
|
64
|
+
const objectArg = arg as ObjectExpression;
|
|
65
|
+
const secretsAllowed = findSecretsAllowedProperty(objectArg);
|
|
66
|
+
if (secretsAllowed === null) return;
|
|
67
|
+
if (!isNonEmptyArray(secretsAllowed)) return;
|
|
68
|
+
if (hasJustificationComment(context, node)) return;
|
|
69
|
+
context.report({
|
|
70
|
+
node: secretsAllowed,
|
|
71
|
+
messageId: 'missingJustification',
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function isGrantCall(node: CallExpression): boolean {
|
|
79
|
+
if (node.callee.type === 'Identifier') {
|
|
80
|
+
return (node.callee as Identifier).name === GRANT_CALLEE;
|
|
81
|
+
}
|
|
82
|
+
if (node.callee.type === 'MemberExpression') {
|
|
83
|
+
const callee = node.callee as MemberExpression;
|
|
84
|
+
if (callee.computed) return false;
|
|
85
|
+
if (callee.property.type !== 'Identifier') return false;
|
|
86
|
+
return callee.property.name === GRANT_CALLEE;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findSecretsAllowedProperty(obj: ObjectExpression): Property['value'] | null {
|
|
92
|
+
for (const prop of obj.properties) {
|
|
93
|
+
if (prop.type !== 'Property') continue;
|
|
94
|
+
const property = prop as Property;
|
|
95
|
+
const key = property.key;
|
|
96
|
+
if (property.computed) continue;
|
|
97
|
+
if (key.type === 'Identifier' && key.name === 'secretsAllowed') return property.value;
|
|
98
|
+
if (key.type === 'Literal' && key.value === 'secretsAllowed') return property.value;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isNonEmptyArray(value: Property['value']): boolean {
|
|
104
|
+
if (value.type !== 'ArrayExpression') return false;
|
|
105
|
+
const arr = value as ArrayExpression;
|
|
106
|
+
return arr.elements.some((e) => e !== null);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hasJustificationComment(context: Rule.RuleContext, node: CallExpression): boolean {
|
|
110
|
+
return nodeHasNearbyComment(context, node, JUSTIFICATION_TAG, 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default rule;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: `@graphorin/no-secret-unwrap` (DEC-020 / ADR-026 / Phase 16).
|
|
3
|
+
*
|
|
4
|
+
* Flags member expressions that look like an unprotected unwrap of a
|
|
5
|
+
* `SecretValue` instance - `<expr>.unwrap()` and `<expr>.reveal()` -
|
|
6
|
+
* outside an allow-listed context. The framework convention is:
|
|
7
|
+
*
|
|
8
|
+
* - Prefer `value.use(fn)` / `value.useBuffer(fn)` for scoped reads;
|
|
9
|
+
* they limit the lifetime of the derived V8 string to a single
|
|
10
|
+
* callback invocation.
|
|
11
|
+
* - Use `value.reveal()` only as a one-shot escape hatch with an
|
|
12
|
+
* adjacent justification. The audit log records the call.
|
|
13
|
+
* - Never use `value.unwrap()` - it is `@deprecated` and is an alias
|
|
14
|
+
* for `.reveal()` retained only for the v0.x compatibility window.
|
|
15
|
+
* The rule reports `unwrap()` as `'error'` even when an
|
|
16
|
+
* opt-out comment is present so the deprecation cliff stays sharp.
|
|
17
|
+
*
|
|
18
|
+
* Per-call opt-out: `// graphorin-allow-secret-unwrap: <reason>` on
|
|
19
|
+
* the line above the call site or anywhere inside the enclosing
|
|
20
|
+
* expression-statement source span. The opt-out is honoured for
|
|
21
|
+
* `reveal()` but **not** for `unwrap()` (the deprecation supersedes
|
|
22
|
+
* any local justification).
|
|
23
|
+
*
|
|
24
|
+
* The rule is intentionally lexical - it matches `.unwrap()` /
|
|
25
|
+
* `.reveal()` on any `MemberExpression` regardless of the receiver
|
|
26
|
+
* type. False positives are tolerated because (a) the receiver is
|
|
27
|
+
* almost always a `SecretValue` in this codebase and (b) the
|
|
28
|
+
* one-line opt-out is cheap.
|
|
29
|
+
*
|
|
30
|
+
* Known collision (W-043): Zod's `ZodOptional`/`ZodNullable`/
|
|
31
|
+
* `ZodDefault` expose `.unwrap()`, and Rust-style result libraries use
|
|
32
|
+
* it too - schema-introspection code trips the rule. The
|
|
33
|
+
* `allowReceiverPattern` option carves those receivers out: when the
|
|
34
|
+
* SOURCE TEXT of the receiver expression matches the pattern, both
|
|
35
|
+
* `unwrap` and `reveal` reports are skipped. The default stays
|
|
36
|
+
* undefined (byte-identical historical behaviour - the deprecation
|
|
37
|
+
* cliff keeps its edge), and there is deliberately NO built-in
|
|
38
|
+
* "looks like Zod" auto-heuristic: a nondeterministic guess is worse
|
|
39
|
+
* than an explicit, narrow setting (pick a suffix pattern like
|
|
40
|
+
* `'Schema$'`, or prefer a file-glob rule override).
|
|
41
|
+
*
|
|
42
|
+
* @packageDocumentation
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { Rule } from 'eslint';
|
|
46
|
+
import type { CallExpression, Identifier, MemberExpression } from 'estree';
|
|
47
|
+
|
|
48
|
+
import { nodeHasNearbyComment } from './_comment-utils.js';
|
|
49
|
+
|
|
50
|
+
const ALLOW_TAG = /graphorin-allow-secret-unwrap/;
|
|
51
|
+
|
|
52
|
+
const rule: Rule.RuleModule = {
|
|
53
|
+
meta: {
|
|
54
|
+
type: 'problem',
|
|
55
|
+
docs: {
|
|
56
|
+
description:
|
|
57
|
+
'Disallow `.unwrap()` / `.reveal()` calls on `SecretValue` instances. Prefer `.use(fn)` (scoped) or attach a `// graphorin-allow-secret-unwrap: <reason>` opt-out comment for `.reveal()`.',
|
|
58
|
+
recommended: true,
|
|
59
|
+
},
|
|
60
|
+
schema: [
|
|
61
|
+
{
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
allowReceiverPattern: { type: 'string' },
|
|
65
|
+
},
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
messages: {
|
|
70
|
+
avoidReveal:
|
|
71
|
+
'`.reveal()` returns the unwrapped secret as a V8 string. Prefer `.use(fn)` so the value is scoped to a single callback. Add `// graphorin-allow-secret-unwrap: <reason>` to opt out.',
|
|
72
|
+
avoidUnwrap:
|
|
73
|
+
'`.unwrap()` is deprecated - call `.reveal()` (audited) or `.use(fn)` (scoped). The opt-out comment is intentionally NOT honoured for `.unwrap()` so the deprecation stays sharp.',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
77
|
+
const options = (context.options?.[0] ?? {}) as { allowReceiverPattern?: string };
|
|
78
|
+
const allowReceiver =
|
|
79
|
+
options.allowReceiverPattern !== undefined ? new RegExp(options.allowReceiverPattern) : null;
|
|
80
|
+
return {
|
|
81
|
+
CallExpression(node: CallExpression): void {
|
|
82
|
+
const callee = node.callee;
|
|
83
|
+
if (callee.type !== 'MemberExpression') return;
|
|
84
|
+
const me = callee as MemberExpression;
|
|
85
|
+
if (me.computed) return;
|
|
86
|
+
if (me.property.type !== 'Identifier') return;
|
|
87
|
+
const propName = (me.property as Identifier).name;
|
|
88
|
+
if (propName !== 'unwrap' && propName !== 'reveal') return;
|
|
89
|
+
// W-043 carve-out: a receiver whose source text matches the
|
|
90
|
+
// configured pattern is not a SecretValue (Zod schemas,
|
|
91
|
+
// Rust-style results) - skip both report paths.
|
|
92
|
+
if (allowReceiver?.test(context.sourceCode.getText(me.object)) === true) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (propName === 'unwrap') {
|
|
96
|
+
context.report({
|
|
97
|
+
node,
|
|
98
|
+
messageId: 'avoidUnwrap',
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (propName === 'reveal') {
|
|
103
|
+
if (hasAllowComment(context, node)) return;
|
|
104
|
+
context.report({
|
|
105
|
+
node,
|
|
106
|
+
messageId: 'avoidReveal',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function hasAllowComment(context: Rule.RuleContext, node: CallExpression): boolean {
|
|
115
|
+
return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default rule;
|