tewip-ai 0.75.0 → 0.77.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/README.md
CHANGED
|
@@ -1252,6 +1252,22 @@ offline, in CI, before anything runs.
|
|
|
1252
1252
|
because that is the difference between a wrong answer and a tool call somebody else chose. On
|
|
1253
1253
|
1,691 files of real agent code: **zero findings**, which is the precision claim; recall is
|
|
1254
1254
|
measured against planted examples and said to be.
|
|
1255
|
+
- **Which of these tools anybody wrote a test about.** Not "does this project have tests" - every
|
|
1256
|
+
project has tests - but whether *this* tool is mentioned in any of them, and especially the ones
|
|
1257
|
+
that cannot be undone. A name too short or too common to look for is reported as undecidable
|
|
1258
|
+
rather than counted as covered. And the statement is made precisely in both directions: a tool
|
|
1259
|
+
missing from the list is not a tool whose behaviour is untested, it is a tool nothing tests **by
|
|
1260
|
+
that name** - the wiring between the name the model sees and the code behind it. Tewip finds 8 of
|
|
1261
|
+
its own 17 that way. Also names which eval framework the project uses, and which it looked for
|
|
1262
|
+
when it finds none.
|
|
1263
|
+
- **What the coding agent installed here was permitted to do.** `.claude/settings.json` and
|
|
1264
|
+
`.mcp.json` are committed, anybody with write access can change them, and neither turns up in a
|
|
1265
|
+
review the way a source file does. Tewip names the approval gate switched off
|
|
1266
|
+
(`"defaultMode": "bypassPermissions"`), an allow entry that grants a shell with no constraint on
|
|
1267
|
+
the command, every tool server trusted without approval, a credential written into a committed
|
|
1268
|
+
file - located, never printed - and a boundary widened past the project. A `PreToolUse` hook
|
|
1269
|
+
counts as a real gate, because the harness enforces it on every call; a rule in a markdown file
|
|
1270
|
+
is a request to the model and does not.
|
|
1255
1271
|
- **What stands in the way**: a human approval gate, a dry run, a step budget, a tool allow-list -
|
|
1256
1272
|
and says plainly when nothing anywhere in the project has one. A ceiling written as
|
|
1257
1273
|
`max_iterations=None` is reported apart from no ceiling at all, because it reads as deliberate.
|
|
@@ -1,55 +1,13 @@
|
|
|
1
1
|
// `tewip agent`: what this agent is allowed to do, and what stands between it and the real world.
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { CAPABILITY_MEANS, FRAMEWORKS_READ, IRREVERSIBLE, trifectaIn } from './tools.js';
|
|
4
|
+
import {} from './prompt-flow.js';
|
|
5
|
+
import { configSection } from './claude-config.js';
|
|
6
|
+
import { coverageSection } from './tool-tests.js';
|
|
7
|
+
import { GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
|
|
8
|
+
import { scanAgent } from './scan.js';
|
|
9
|
+
export { scanAgent };
|
|
8
10
|
const NL = String.fromCharCode(10);
|
|
9
|
-
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
10
|
-
/**
|
|
11
|
-
* What is not the shipped agent.
|
|
12
|
-
*
|
|
13
|
-
* The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
|
|
14
|
-
* documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
|
|
15
|
-
* all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
|
|
16
|
-
* whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
|
|
17
|
-
* at the front of the file. A list of an agent's tools that is actually a list of test doubles is
|
|
18
|
-
* worse than no list.
|
|
19
|
-
*
|
|
20
|
-
* `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
|
|
21
|
-
*/
|
|
22
|
-
const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|examples?|samples?|docs?|fixtures?|node_modules|\.venv|venv)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|(?:^|[\\/])test_[^\\/]*\.py$/i;
|
|
23
|
-
export function scanAgent(root) {
|
|
24
|
-
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
25
|
-
const tools = [];
|
|
26
|
-
const injections = [];
|
|
27
|
-
const guards = [];
|
|
28
|
-
let emptyBudget = null;
|
|
29
|
-
let filesRead = 0;
|
|
30
|
-
for (const path of scan.files) {
|
|
31
|
-
const where = relative(root, path).replace(/\\/g, '/');
|
|
32
|
-
if (NOT_SHIPPED.test(where))
|
|
33
|
-
continue;
|
|
34
|
-
let text;
|
|
35
|
-
try {
|
|
36
|
-
text = readFileSync(path, 'utf8');
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
filesRead += 1;
|
|
42
|
-
tools.push(...toolsIn(where, text));
|
|
43
|
-
injections.push(...promptFlows(where, text));
|
|
44
|
-
guards.push(...guardsIn(where, text));
|
|
45
|
-
if (!emptyBudget) {
|
|
46
|
-
const empty = budgetSetToNothing(text);
|
|
47
|
-
if (empty)
|
|
48
|
-
emptyBudget = { file: where, text: empty };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return { tools, injections, guards, filesRead, emptyBudget };
|
|
52
|
-
}
|
|
53
11
|
const REACH_READS = {
|
|
54
12
|
destructive: 'cannot be undone',
|
|
55
13
|
deletes: 'deletes something',
|
|
@@ -61,12 +19,13 @@ const REACH_READS = {
|
|
|
61
19
|
'not-sure': '**not decided**',
|
|
62
20
|
};
|
|
63
21
|
export function renderAgent(scan, root) {
|
|
64
|
-
const { tools, injections, guards, filesRead, emptyBudget } = scan;
|
|
22
|
+
const { tools, injections, guards, filesRead, emptyBudget, config, coverage, evals } = scan;
|
|
65
23
|
const lines = ['# What this agent is allowed to do', ''];
|
|
66
24
|
if (tools.length === 0) {
|
|
67
25
|
return [
|
|
68
26
|
'# What this agent is allowed to do',
|
|
69
27
|
'',
|
|
28
|
+
...configSection(config),
|
|
70
29
|
`No tool definitions were found in ${filesRead} file(s).`,
|
|
71
30
|
'',
|
|
72
31
|
'That is a fact about what was looked for, not a finding that this agent has no tools. These',
|
|
@@ -80,6 +39,12 @@ export function renderAgent(scan, root) {
|
|
|
80
39
|
].join(NL);
|
|
81
40
|
}
|
|
82
41
|
const kinds = new Set(guards.map((g) => g.kind));
|
|
42
|
+
// A permission list is an allow-list, and a PreToolUse hook is enforced on every call - both are
|
|
43
|
+
// the real thing rather than a pattern someone wrote in a prompt.
|
|
44
|
+
if (config.hasAllowList)
|
|
45
|
+
kinds.add('tool-allow-list');
|
|
46
|
+
if (config.hasEnforcedHook)
|
|
47
|
+
kinds.add('asks-a-person');
|
|
83
48
|
const risky = tools.filter((t) => IRREVERSIBLE.includes(t.reach));
|
|
84
49
|
const undecided = tools.filter((t) => t.reach === 'not-sure');
|
|
85
50
|
lines.push(`${tools.length} tool(s) in ${filesRead} file(s) read locally. No model was called and nothing left this machine.`, '');
|
|
@@ -138,6 +103,8 @@ export function renderAgent(scan, root) {
|
|
|
138
103
|
lines.push(`- **${capability}** (${CAPABILITY_MEANS[capability]}): ${legTools.length === 0 ? 'nothing found' : legTools.slice(0, 6).map((t) => `\`${t.name}\``).join(', ') + (legTools.length > 6 ? `, and ${legTools.length - 6} more` : '')}`);
|
|
139
104
|
}
|
|
140
105
|
lines.push('');
|
|
106
|
+
lines.push(...coverageSection(coverage, tools, evals));
|
|
107
|
+
lines.push(...configSection(config));
|
|
141
108
|
lines.push('## What stands between them and the real world', '');
|
|
142
109
|
for (const kind of ['asks-a-person', 'dry-run', 'step-budget', 'tool-allow-list']) {
|
|
143
110
|
const found = guards.find((g) => g.kind === kind);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The permissions a coding agent was given in this repository, read from the files that grant them.
|
|
2
|
+
//
|
|
3
|
+
// `tools.ts` reads what an agent you wrote can call. This reads what an agent you *installed* was
|
|
4
|
+
// allowed to do here - Claude Code's `.claude/settings.json` and the project's `.mcp.json`, both of
|
|
5
|
+
// which are committed, both of which anybody with write access can change, and neither of which
|
|
6
|
+
// turns up in a code review the way a source file does.
|
|
7
|
+
//
|
|
8
|
+
// Worth saying plainly, because it is the whole reason this is a separate check: a rule written in
|
|
9
|
+
// a markdown file is a request to the model. A permission in settings.json is enforced by the
|
|
10
|
+
// harness on every tool call. They look equally like configuration and they are not the same kind
|
|
11
|
+
// of thing at all, and only one of them is a control an auditor can rely on.
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
// Spelled with forward slashes, which is how every other path in a Tewip report is spelled and
|
|
15
|
+
// how a reader will search for it. `join` gives a backslash on Windows and the report then names a
|
|
16
|
+
// file that does not look like the one in the repository.
|
|
17
|
+
const FILES = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json'];
|
|
18
|
+
/**
|
|
19
|
+
* An allow entry that grants a shell with no constraint on the command.
|
|
20
|
+
*
|
|
21
|
+
* `Bash(npm run test:*)` is a decision somebody made. `Bash`, `Bash(*)` and `Bash(*:*)` are the
|
|
22
|
+
* absence of one: they permit `rm -rf`, `curl | sh` and anything else, and they are what people
|
|
23
|
+
* write when the prompts became annoying.
|
|
24
|
+
*/
|
|
25
|
+
const UNCONSTRAINED = /^(?:Bash|Execute|Shell|Run)(?:\(\s*\*?\s*(?::\s*\*\s*)?\))?$/i;
|
|
26
|
+
/** A value written into a committed file that looks like a credential rather than a reference. */
|
|
27
|
+
const LOOKS_SECRET = /(?:KEY|TOKEN|SECRET|PASSWORD|PASS|CREDENTIAL|API)/i;
|
|
28
|
+
const read = (root, name) => {
|
|
29
|
+
const path = join(root, ...name.split('/'));
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(path, 'utf8');
|
|
34
|
+
const json = JSON.parse(raw);
|
|
35
|
+
return json && typeof json === 'object' ? { json, raw } : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A file that does not parse is not a file that grants nothing, and saying so beats guessing.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const asList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
43
|
+
export function scanConfig(root) {
|
|
44
|
+
const out = { read: [], findings: [], servers: [], hasAllowList: false, hasEnforcedHook: false };
|
|
45
|
+
for (const name of FILES.slice(0, 2)) {
|
|
46
|
+
const file = read(root, name);
|
|
47
|
+
if (!file)
|
|
48
|
+
continue;
|
|
49
|
+
out.read.push(name);
|
|
50
|
+
const permissions = (file.json.permissions ?? {});
|
|
51
|
+
const allow = asList(permissions.allow);
|
|
52
|
+
const deny = asList(permissions.deny);
|
|
53
|
+
if (allow.length > 0 || deny.length > 0)
|
|
54
|
+
out.hasAllowList = true;
|
|
55
|
+
// The gate switched off outright. Nothing else in this file matters as much.
|
|
56
|
+
if (permissions.defaultMode === 'bypassPermissions') {
|
|
57
|
+
out.findings.push({
|
|
58
|
+
rule: 'permissions-bypassed',
|
|
59
|
+
file: name,
|
|
60
|
+
what: '"defaultMode": "bypassPermissions"',
|
|
61
|
+
why: 'every tool call here runs without asking, including the ones that delete, pay or send. This is the approval gate being switched off in a committed file.',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (permissions.defaultMode === 'acceptEdits') {
|
|
65
|
+
out.findings.push({
|
|
66
|
+
rule: 'edits-auto-accepted',
|
|
67
|
+
file: name,
|
|
68
|
+
what: '"defaultMode": "acceptEdits"',
|
|
69
|
+
why: 'file edits are applied without asking. Weaker than bypassing everything, and still a person out of the loop on every change to the working tree.',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const entry of allow) {
|
|
73
|
+
if (!UNCONSTRAINED.test(entry.trim()))
|
|
74
|
+
continue;
|
|
75
|
+
out.findings.push({
|
|
76
|
+
rule: 'shell-unconstrained',
|
|
77
|
+
file: name,
|
|
78
|
+
what: `"allow": [… "${entry}" …]`,
|
|
79
|
+
why: 'this permits any shell command, not a named one. `Bash(npm run test:*)` is a decision; this is the absence of one.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (file.json.enableAllProjectMcpServers === true) {
|
|
83
|
+
out.findings.push({
|
|
84
|
+
rule: 'mcp-auto-trusted',
|
|
85
|
+
file: name,
|
|
86
|
+
what: '"enableAllProjectMcpServers": true',
|
|
87
|
+
why: 'every server in .mcp.json is trusted without being approved, including any added later by anyone who can commit to this repository.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const env = (file.json.env ?? {});
|
|
91
|
+
for (const [key, value] of Object.entries(env)) {
|
|
92
|
+
// A reference to a variable is fine; a literal is the secret itself, in a committed file.
|
|
93
|
+
if (!LOOKS_SECRET.test(key) || typeof value !== 'string' || value === '' || value.startsWith('$'))
|
|
94
|
+
continue;
|
|
95
|
+
out.findings.push({
|
|
96
|
+
rule: 'secret-in-settings',
|
|
97
|
+
file: name,
|
|
98
|
+
what: `"env": { "${key}": … }`,
|
|
99
|
+
why: 'a credential written into a file that is committed. Tewip does not print the value; the point is that it is there.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const extra = asList(permissions.additionalDirectories);
|
|
103
|
+
if (extra.length > 0) {
|
|
104
|
+
out.findings.push({
|
|
105
|
+
rule: 'boundary-widened',
|
|
106
|
+
file: name,
|
|
107
|
+
what: `"additionalDirectories": [${extra.slice(0, 3).map((d) => `"${d}"`).join(', ')}]`,
|
|
108
|
+
why: 'the agent may read and write outside this project. Whatever is on the other end is inside the blast radius.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const hooks = (file.json.hooks ?? {});
|
|
112
|
+
// PreToolUse is the one that can refuse a call. The others observe it after the fact.
|
|
113
|
+
if (Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0)
|
|
114
|
+
out.hasEnforcedHook = true;
|
|
115
|
+
}
|
|
116
|
+
const mcp = read(root, '.mcp.json');
|
|
117
|
+
if (mcp) {
|
|
118
|
+
out.read.push('.mcp.json');
|
|
119
|
+
const servers = (mcp.json.mcpServers ?? {});
|
|
120
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
121
|
+
const url = typeof spec?.url === 'string' ? spec.url : '';
|
|
122
|
+
const command = typeof spec?.command === 'string' ? spec.command : '';
|
|
123
|
+
const args = asList(spec?.args).join(' ');
|
|
124
|
+
out.servers.push(url
|
|
125
|
+
? { name, where: 'somewhere else', detail: url.replace(/\/\/[^@]*@/, '//') }
|
|
126
|
+
: { name, where: 'this machine', detail: `${command} ${args}`.trim().slice(0, 90) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** What each rule stops, for a reader deciding which to act on first. */
|
|
132
|
+
export const CONFIG_MEANS = {
|
|
133
|
+
'permissions-bypassed': 'nothing is asked before any tool call',
|
|
134
|
+
'edits-auto-accepted': 'file edits are applied without asking',
|
|
135
|
+
'shell-unconstrained': 'any shell command is permitted',
|
|
136
|
+
'mcp-auto-trusted': 'tool servers are trusted without approval',
|
|
137
|
+
'secret-in-settings': 'a credential is committed',
|
|
138
|
+
'boundary-widened': 'the agent may work outside this project',
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* What the coding agent installed here was permitted to do.
|
|
142
|
+
*
|
|
143
|
+
* Rendered whether or not any tools were found, because the two are independent: a repository with
|
|
144
|
+
* no agent code of its own can still hand Claude Code a shell with no constraint on the command,
|
|
145
|
+
* and that is a finding about this repository either way.
|
|
146
|
+
*/
|
|
147
|
+
export function configSection(config) {
|
|
148
|
+
if (config.read.length === 0)
|
|
149
|
+
return [];
|
|
150
|
+
const lines = ['## What the coding agent here is permitted to do', '', `Read: ${config.read.map((f) => `\`${f}\``).join(', ')}.`, ''];
|
|
151
|
+
if (config.servers.length > 0) {
|
|
152
|
+
lines.push('Tool servers wired into this project:', '');
|
|
153
|
+
for (const s of config.servers)
|
|
154
|
+
lines.push(`- \`${s.name}\` — runs ${s.where}: \`${s.detail}\``);
|
|
155
|
+
lines.push('');
|
|
156
|
+
}
|
|
157
|
+
if (config.findings.length === 0) {
|
|
158
|
+
lines.push('Nothing in these files switches off an approval, grants an unconstrained shell, trusts a', 'server without approval or commits a credential.', '');
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
for (const f of config.findings) {
|
|
162
|
+
lines.push(`- **${f.rule}** — ${CONFIG_MEANS[f.rule] ?? ''}`, ` \`${f.what}\` in \`${f.file}\`. ${f.why}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('');
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Reading an agent project once: its tools, its flows, its guards, its tests and its permissions.
|
|
2
|
+
//
|
|
3
|
+
// Split from agent-command.ts, which is the file in this project that keeps arriving at the
|
|
4
|
+
// 300-line limit. The rendering belongs with the thing it renders; the walk belongs here.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join, relative } from 'node:path';
|
|
7
|
+
import { scanSourceFiles } from '../walk.js';
|
|
8
|
+
import { toolsIn } from './tools.js';
|
|
9
|
+
import { promptFlows } from './prompt-flow.js';
|
|
10
|
+
import { budgetSetToNothing, guardsIn } from './guards.js';
|
|
11
|
+
import { scanConfig } from './claude-config.js';
|
|
12
|
+
import { coverageOf, evalEvidence } from './tool-tests.js';
|
|
13
|
+
const NL = String.fromCharCode(10);
|
|
14
|
+
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
15
|
+
/**
|
|
16
|
+
* What is not the shipped agent.
|
|
17
|
+
*
|
|
18
|
+
* The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
|
|
19
|
+
* documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
|
|
20
|
+
* all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
|
|
21
|
+
* whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
|
|
22
|
+
* at the front of the file. A list of an agent's tools that is actually a list of test doubles is
|
|
23
|
+
* worse than no list.
|
|
24
|
+
*
|
|
25
|
+
* `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
|
|
26
|
+
*/
|
|
27
|
+
const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|examples?|samples?|docs?|fixtures?|node_modules|\.venv|venv)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|(?:^|[\\/])test_[^\\/]*\.py$/i;
|
|
28
|
+
export function scanAgent(root) {
|
|
29
|
+
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
30
|
+
const tools = [];
|
|
31
|
+
const tests = [];
|
|
32
|
+
const injections = [];
|
|
33
|
+
const guards = [];
|
|
34
|
+
let emptyBudget = null;
|
|
35
|
+
let filesRead = 0;
|
|
36
|
+
for (const path of scan.files) {
|
|
37
|
+
const where = relative(root, path).replace(/\\/g, '/');
|
|
38
|
+
// Excluded from the tool scan, but not thrown away: these files are the evidence for whether
|
|
39
|
+
// anybody ever wrote a test about a tool, which is a different question from what ships.
|
|
40
|
+
if (NOT_SHIPPED.test(where)) {
|
|
41
|
+
try {
|
|
42
|
+
tests.push({ file: where, text: readFileSync(path, 'utf8') });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A file that cannot be read is not evidence of a test, and is not counted as one.
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = readFileSync(path, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
filesRead += 1;
|
|
57
|
+
tools.push(...toolsIn(where, text));
|
|
58
|
+
injections.push(...promptFlows(where, text));
|
|
59
|
+
guards.push(...guardsIn(where, text));
|
|
60
|
+
if (!emptyBudget) {
|
|
61
|
+
const empty = budgetSetToNothing(text);
|
|
62
|
+
if (empty)
|
|
63
|
+
emptyBudget = { file: where, text: empty };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const manifests = ['package.json', 'pyproject.toml', 'requirements.txt'].flatMap((m) => {
|
|
67
|
+
try {
|
|
68
|
+
return [readFileSync(join(root, m), 'utf8')];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
tools,
|
|
76
|
+
injections,
|
|
77
|
+
guards,
|
|
78
|
+
filesRead,
|
|
79
|
+
emptyBudget,
|
|
80
|
+
coverage: coverageOf(tools.map((t) => t.name), tests),
|
|
81
|
+
evals: evalEvidence(tests, manifests),
|
|
82
|
+
config: scanConfig(root),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval frameworks, by the name they are imported or depended on under.
|
|
3
|
+
*
|
|
4
|
+
* Named rather than guessed at. The point of listing them is to be able to say "this project has
|
|
5
|
+
* one, here it is" or "none of these is here" - and the second sentence is only honest if the list
|
|
6
|
+
* is written down where a reader can see what was looked for.
|
|
7
|
+
*/
|
|
8
|
+
const FRAMEWORKS = [
|
|
9
|
+
{ name: 'DeepEval', re: /\bdeepeval\b/i },
|
|
10
|
+
{ name: 'promptfoo', re: /\bpromptfoo\b/i },
|
|
11
|
+
{ name: 'Ragas', re: /\bragas\b/i },
|
|
12
|
+
{ name: 'LangSmith', re: /\blangsmith\b/i },
|
|
13
|
+
{ name: 'Braintrust', re: /\bbraintrust\b/i },
|
|
14
|
+
{ name: 'Inspect AI', re: /\binspect_ai\b/i },
|
|
15
|
+
{ name: 'Giskard', re: /\bgiskard\b/i },
|
|
16
|
+
{ name: 'TruLens', re: /\btrulens\b/i },
|
|
17
|
+
{ name: 'Langfuse', re: /\blangfuse\b/i },
|
|
18
|
+
{ name: 'OpenAI Evals', re: /\bopenai[-_]?evals\b/i },
|
|
19
|
+
];
|
|
20
|
+
/** A path that holds an eval set rather than a unit test. */
|
|
21
|
+
const EVAL_PATH = /(?:^|[\\/])(?:evals?|benchmarks?|golden|datasets?)[\\/]|\.eval\.[cm]?[jt]s$|_eval\.py$|eval_[^\\/]*\.(?:py|[cm]?[jt]s)$|\bevals?\.ya?ml$/i;
|
|
22
|
+
/**
|
|
23
|
+
* Names too common to decide on.
|
|
24
|
+
*
|
|
25
|
+
* A tool called `get` or `run` appears in every file in the project, so finding it in a test file
|
|
26
|
+
* says nothing at all. Rather than counting those as covered - which is the comfortable direction
|
|
27
|
+
* and the wrong one - they are reported as undecidable, by name.
|
|
28
|
+
*/
|
|
29
|
+
const COMMON = new Set(['get', 'set', 'run', 'add', 'list', 'read', 'write', 'call', 'send', 'find', 'query', 'search', 'name', 'tool', 'test', 'echo', 'exec', 'open', 'close', 'load', 'save']);
|
|
30
|
+
const tooCommonName = (name) => name.length < 5 || COMMON.has(name.toLowerCase());
|
|
31
|
+
export function coverageOf(toolNames, tests) {
|
|
32
|
+
return toolNames.map((name) => {
|
|
33
|
+
if (tooCommonName(name))
|
|
34
|
+
return { name, mentionedIn: [], tooCommon: true };
|
|
35
|
+
// Word boundaries on both sides: `delete_user` must not be matched by `delete_user_profile`,
|
|
36
|
+
// which is a different tool and may well be the only one anybody tested.
|
|
37
|
+
const mention = new RegExp(String.raw `(?<![\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\w-])`);
|
|
38
|
+
return { name, mentionedIn: tests.filter((t) => mention.test(t.text)).map((t) => t.file), tooCommon: false };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function evalEvidence(files, manifests) {
|
|
42
|
+
const frameworks = new Set();
|
|
43
|
+
for (const { name, re } of FRAMEWORKS) {
|
|
44
|
+
if (manifests.some((m) => re.test(m)) || files.some((f) => re.test(f.text)))
|
|
45
|
+
frameworks.add(name);
|
|
46
|
+
}
|
|
47
|
+
const evalFiles = files.filter((f) => EVAL_PATH.test(f.file)).map((f) => f.file);
|
|
48
|
+
return { frameworks: [...frameworks], files: [...new Set(evalFiles)].slice(0, 12) };
|
|
49
|
+
}
|
|
50
|
+
/** The names of every eval framework looked for, so silence about them can be read correctly. */
|
|
51
|
+
export const FRAMEWORKS_LOOKED_FOR = FRAMEWORKS.map((f) => f.name);
|
|
52
|
+
/**
|
|
53
|
+
* Which tools anybody wrote a test about, rendered where the logic for deciding it lives.
|
|
54
|
+
*
|
|
55
|
+
* Split out for the same reason the config section was: agent-command.ts is the file that keeps
|
|
56
|
+
* reaching the 300-line limit, and a report section belongs beside the thing it reports on.
|
|
57
|
+
*/
|
|
58
|
+
export function coverageSection(coverage, tools, evals) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
const IRREV = ['destructive', 'deletes', 'pays', 'sends', 'runs-code'];
|
|
61
|
+
const untested = coverage.filter((c) => !c.tooCommon && c.mentionedIn.length === 0);
|
|
62
|
+
const riskyUntested = untested.filter((u) => IRREV.includes(tools.find((t) => t.name === u.name)?.reach ?? 'reads'));
|
|
63
|
+
lines.push('## Which of these anybody wrote a test about', '');
|
|
64
|
+
lines.push(untested.length === 0
|
|
65
|
+
? 'Every tool whose name is distinctive enough to look for appears in at least one test file.'
|
|
66
|
+
: `**${untested.length} of ${tools.length}** appear in no test file${riskyUntested.length > 0 ? `, and ${riskyUntested.length} of those cannot be undone` : ''}.`, '');
|
|
67
|
+
if (untested.length > 0) {
|
|
68
|
+
for (const u of untested.slice(0, 15)) {
|
|
69
|
+
const reach = tools.find((t) => t.name === u.name)?.reach;
|
|
70
|
+
lines.push(`- \`${u.name}\`${reach && IRREV.includes(reach) ? ' — **and it cannot be undone**' : ''}`);
|
|
71
|
+
}
|
|
72
|
+
if (untested.length > 15)
|
|
73
|
+
lines.push(`…and ${untested.length - 15} more`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
}
|
|
76
|
+
const undecidable = coverage.filter((c) => c.tooCommon);
|
|
77
|
+
if (undecidable.length > 0) {
|
|
78
|
+
lines.push(`Not decided for ${undecidable.length}: ${undecidable.slice(0, 8).map((u) => `\`${u.name}\``).join(', ')}. A name this`, 'short or this common appears everywhere, so finding it in a test file would prove nothing.', '');
|
|
79
|
+
}
|
|
80
|
+
lines.push(evals.frameworks.length > 0
|
|
81
|
+
? `Eval framework in this project: ${evals.frameworks.join(', ')}.`
|
|
82
|
+
: `No eval framework found. Looked for: ${FRAMEWORKS_LOOKED_FOR.join(', ')}.`, '');
|
|
83
|
+
if (evals.files.length > 0)
|
|
84
|
+
lines.push(`Eval sets: ${evals.files.map((f) => `\`${f}\``).join(', ')}.`, '');
|
|
85
|
+
lines.push('A name appearing in a test file means somebody wrote it down there. Whether that test exercises', 'the tool, mocks it, or only imports it is not decided here.', '', 'And the other way round, which matters more: a tool missing from this list is not a tool whose', 'behaviour is untested. It is a tool nothing tests **by that name** - the wiring between the name', 'the model sees and the code behind it. Tewip finds 8 of its own 17 this way, and the engine under', 'each one is covered; what nothing exercises is the tool surface itself.', '');
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|