tewip-ai 0.73.1 → 0.75.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 +49 -0
- package/dist/cli/help.js +8 -0
- package/dist/cli/verbs-assure.js +5 -0
- package/dist/engine/agent/agent-command.js +170 -0
- package/dist/engine/agent/guards.js +74 -0
- package/dist/engine/agent/prompt-flow.js +145 -0
- package/dist/engine/agent/source-text.js +96 -0
- package/dist/engine/agent/tools.js +187 -0
- package/dist/engine/portfolio/portfolio-command.js +12 -0
- package/dist/engine/portfolio/portfolio.js +2 -1
- package/dist/engine/taint/flow.js +16 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1216,6 +1216,55 @@ With `--act` it carries the decision out, and this is where the refusals matter
|
|
|
1216
1216
|
- Everything it did or refused goes to `.tewip/actions.jsonl`, with the run that justified it.
|
|
1217
1217
|
- It **will not repeat itself**: the same attempt is refused until a new run gives it new evidence.
|
|
1218
1218
|
|
|
1219
|
+
## What is your AI agent allowed to do? (Phase 139)
|
|
1220
|
+
|
|
1221
|
+
```bash
|
|
1222
|
+
tewip agent # this project
|
|
1223
|
+
tewip agent ~/work/bot # another one
|
|
1224
|
+
tewip agent --gate # non-zero when nothing stands in the way
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
Every tool in this market evaluates an agent by **running** it - Langfuse, Phoenix, RAGAS, TruLens,
|
|
1228
|
+
DeepEval, Promptfoo, Giskard, garak. They need a runtime, a token budget and usually an account, and
|
|
1229
|
+
most judge with another model, so the same agent scores differently on Tuesday. They answer *is this
|
|
1230
|
+
agent any good*.
|
|
1231
|
+
|
|
1232
|
+
Nobody answers the question an IT department has to sign off on: **what is this agent allowed to do,
|
|
1233
|
+
and what stands between it and the real world?** That one is decidable by reading, for free,
|
|
1234
|
+
offline, in CI, before anything runs.
|
|
1235
|
+
|
|
1236
|
+
- **Every tool it can call**, with the file and line. Reads LangChain and LangGraph, the MCP SDKs in
|
|
1237
|
+
Python and TypeScript, CrewAI, the OpenAI Agents SDK, the Vercel AI SDK and Anthropic's tool
|
|
1238
|
+
shape. A framework it does not read is reported as silence, never as approval.
|
|
1239
|
+
- **Which of them cannot be undone.** When a tool declares `destructiveHint` or `readOnlyHint`, that
|
|
1240
|
+
is believed over any guess - the author said it. Otherwise the name decides, then the opening
|
|
1241
|
+
clause of the description, and a tool that says neither is printed as **undecided**, never folded
|
|
1242
|
+
into "reads".
|
|
1243
|
+
- **The lethal trifecta.** Untrusted input, private data and a way to send data out, in one loop:
|
|
1244
|
+
text the agent did not write can tell it to read the private thing and post it somewhere, and
|
|
1245
|
+
every step is a tool it was given on purpose. A system prompt is not a defence, because whoever
|
|
1246
|
+
writes the untrusted text writes the other half of the prompt. Tewip names the tools in each leg.
|
|
1247
|
+
- **Text somebody else wrote, glued into a prompt.** The trifecta above is an argument about what
|
|
1248
|
+
*could* happen, from the tool list. This is what the code does: a value off a web page, a ticket,
|
|
1249
|
+
an inbox or a retriever, written **into** the prompt string rather than passed beside it. A prompt
|
|
1250
|
+
has no parameter binding to fall back on, so once the text is in the string the model cannot tell
|
|
1251
|
+
your instruction from theirs. Reported apart when the same function also hands the model tools,
|
|
1252
|
+
because that is the difference between a wrong answer and a tool call somebody else chose. On
|
|
1253
|
+
1,691 files of real agent code: **zero findings**, which is the precision claim; recall is
|
|
1254
|
+
measured against planted examples and said to be.
|
|
1255
|
+
- **What stands in the way**: a human approval gate, a dry run, a step budget, a tool allow-list -
|
|
1256
|
+
and says plainly when nothing anywhere in the project has one. A ceiling written as
|
|
1257
|
+
`max_iterations=None` is reported apart from no ceiling at all, because it reads as deliberate.
|
|
1258
|
+
|
|
1259
|
+
It will not tell you that a gate which exists stands in front of these particular tools - that is a
|
|
1260
|
+
question about the running graph - and it says so in those words rather than letting you assume it.
|
|
1261
|
+
|
|
1262
|
+
**Measured on real agents.** The official MCP servers: 27 tools, 6 that cannot be undone, every one
|
|
1263
|
+
of those confirmed by its own `destructiveHint: true`. The OpenAI Agents SDK and CrewAI: no tools,
|
|
1264
|
+
correctly - they are frameworks, and every candidate in them was a test fixture or a docstring
|
|
1265
|
+
example. Each of those three repositories broke an earlier version of this check, in the same two
|
|
1266
|
+
ways every time: prose read as code, or code read as prose.
|
|
1267
|
+
|
|
1219
1268
|
## What an IT department has to answer (Phases 133-137)
|
|
1220
1269
|
|
|
1221
1270
|
Five commands that produce evidence rather than a judgement. All five are rules-only: no model is
|
package/dist/cli/help.js
CHANGED
|
@@ -95,6 +95,14 @@ Usage:
|
|
|
95
95
|
Compare a Figma frame against the running page: the words the
|
|
96
96
|
design specifies and the controls its layer names imply. Says
|
|
97
97
|
"the design says X, the page says Y". No model, no cost.
|
|
98
|
+
tewip agent [<folder>] [--out agent.md] [--gate]
|
|
99
|
+
What an AI agent in this project is allowed to do, read from
|
|
100
|
+
its source before it runs: every tool it can call, which of
|
|
101
|
+
them cannot be undone, and whether anything asks a person
|
|
102
|
+
first. Names the lethal trifecta when it is there - untrusted
|
|
103
|
+
input, private data and a way to send data out, in one loop.
|
|
104
|
+
Reads LangChain, LangGraph, the MCP SDKs, CrewAI, the OpenAI
|
|
105
|
+
Agents SDK and the Vercel AI SDK. No model, no cost.
|
|
98
106
|
tewip audit [--all] [--gate]
|
|
99
107
|
Who ran Tewip here, when, and with what. Each entry carries
|
|
100
108
|
the hash of the one before it, so an entry changed or removed
|
package/dist/cli/verbs-assure.js
CHANGED
|
@@ -23,6 +23,11 @@ export async function assureVerb(verb, args) {
|
|
|
23
23
|
runAttest({ root: process.cwd(), name: flag(args, 'name'), runId: flag(args, 'run'), out: flag(args, 'out'), json: flag(args, 'json') });
|
|
24
24
|
return true;
|
|
25
25
|
}
|
|
26
|
+
case 'agent': {
|
|
27
|
+
const { runAgent } = await import('../engine/agent/agent-command.js');
|
|
28
|
+
runAgent({ root: args.find((a) => !a.startsWith('--')) ?? process.cwd(), out: flag(args, 'out'), gate: args.includes('--gate') });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
26
31
|
case 'audit': {
|
|
27
32
|
const { runAudit } = await import('../engine/audit/audit-command.js');
|
|
28
33
|
runAudit({ root: process.cwd(), all: args.includes('--all'), gate: args.includes('--gate') });
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// `tewip agent`: what this agent is allowed to do, and what stands between it and the real world.
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
import { scanSourceFiles } from '../walk.js';
|
|
5
|
+
import { CAPABILITY_MEANS, FRAMEWORKS_READ, IRREVERSIBLE, toolsIn, trifectaIn } from './tools.js';
|
|
6
|
+
import { promptFlows } from './prompt-flow.js';
|
|
7
|
+
import { budgetSetToNothing, guardsIn, GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
|
|
8
|
+
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
|
+
const REACH_READS = {
|
|
54
|
+
destructive: 'cannot be undone',
|
|
55
|
+
deletes: 'deletes something',
|
|
56
|
+
pays: 'moves money',
|
|
57
|
+
sends: 'sends something out of the building',
|
|
58
|
+
'runs-code': 'runs code or a shell command',
|
|
59
|
+
writes: 'changes stored data',
|
|
60
|
+
reads: 'reads only',
|
|
61
|
+
'not-sure': '**not decided**',
|
|
62
|
+
};
|
|
63
|
+
export function renderAgent(scan, root) {
|
|
64
|
+
const { tools, injections, guards, filesRead, emptyBudget } = scan;
|
|
65
|
+
const lines = ['# What this agent is allowed to do', ''];
|
|
66
|
+
if (tools.length === 0) {
|
|
67
|
+
return [
|
|
68
|
+
'# What this agent is allowed to do',
|
|
69
|
+
'',
|
|
70
|
+
`No tool definitions were found in ${filesRead} file(s).`,
|
|
71
|
+
'',
|
|
72
|
+
'That is a fact about what was looked for, not a finding that this agent has no tools. These',
|
|
73
|
+
'are the shapes read here:',
|
|
74
|
+
'',
|
|
75
|
+
...FRAMEWORKS_READ.map((f) => `- ${f}`),
|
|
76
|
+
'',
|
|
77
|
+
'An agent built another way, or one whose tools are assembled at run time from a registry, is',
|
|
78
|
+
'invisible to this and is not reported as safe. Nothing here was sent anywhere; no model was',
|
|
79
|
+
'called.',
|
|
80
|
+
].join(NL);
|
|
81
|
+
}
|
|
82
|
+
const kinds = new Set(guards.map((g) => g.kind));
|
|
83
|
+
const risky = tools.filter((t) => IRREVERSIBLE.includes(t.reach));
|
|
84
|
+
const undecided = tools.filter((t) => t.reach === 'not-sure');
|
|
85
|
+
lines.push(`${tools.length} tool(s) in ${filesRead} file(s) read locally. No model was called and nothing left this machine.`, '');
|
|
86
|
+
// The lethal trifecta leads when it is present, because it outranks everything else here: the
|
|
87
|
+
// other findings are about what the agent may do by mistake, and this one is about what somebody
|
|
88
|
+
// else can make it do on purpose.
|
|
89
|
+
const trifecta = trifectaIn(tools);
|
|
90
|
+
if (trifecta.complete) {
|
|
91
|
+
lines.push('> **This agent has all three legs of the lethal trifecta.**', '>', '> It can pull in text an outsider writes, it can read data that is not meant to leave, and it', '> can send data out - in one loop. Text the agent did not write can tell it to read the', '> private thing and post it somewhere, and every step of that is a tool it was given on', '> purpose. A system prompt is not a defence here: whoever writes the untrusted text writes the', '> other half of the prompt.', '');
|
|
92
|
+
}
|
|
93
|
+
// The headline, because it is the sentence somebody has to act on.
|
|
94
|
+
if (risky.length > 0 && !kinds.has('asks-a-person')) {
|
|
95
|
+
lines.push(`**${risky.length} of them do something that cannot be undone, and nothing in this project stops to ask a person.**`, '');
|
|
96
|
+
}
|
|
97
|
+
else if (risky.length > 0) {
|
|
98
|
+
lines.push(`${risky.length} of them do something that cannot be undone. Something here does ask a person - whether it stands in front of these tools is below.`, '');
|
|
99
|
+
}
|
|
100
|
+
lines.push('## The tools', '', '| tool | reaches | where |', '| --- | --- | --- |');
|
|
101
|
+
for (const t of [...tools].sort((a, b) => rank(a.reach) - rank(b.reach) || a.name.localeCompare(b.name))) {
|
|
102
|
+
lines.push(`| \`${t.name}\` | ${REACH_READS[t.reach]} | ${t.file}:${t.line} |`);
|
|
103
|
+
}
|
|
104
|
+
lines.push('');
|
|
105
|
+
if (risky.length > 0) {
|
|
106
|
+
lines.push('## The ones that cannot be taken back', '');
|
|
107
|
+
for (const t of risky) {
|
|
108
|
+
lines.push(`- \`${t.name}\` — ${REACH_READS[t.reach]}, because ${t.why}. ${t.file}:${t.line}`);
|
|
109
|
+
}
|
|
110
|
+
lines.push('');
|
|
111
|
+
}
|
|
112
|
+
if (undecided.length > 0) {
|
|
113
|
+
lines.push('## The ones this could not decide about', '', 'Read these yourself. They are listed here rather than counted as harmless, which is the only', 'honest thing to do with a tool whose name does not say what it touches.', '', ...undecided.map((t) => `- \`${t.name}\`${t.description ? ` — "${t.description.slice(0, 90)}"` : ''} — ${t.file}:${t.line}`), '');
|
|
114
|
+
}
|
|
115
|
+
if (injections.length > 0) {
|
|
116
|
+
const armed = injections.filter((i) => i.withTools);
|
|
117
|
+
lines.push('## Text somebody else wrote, glued into a prompt', '', armed.length > 0
|
|
118
|
+
? `${injections.length} place(s), and ${armed.length} of them in a function that also hands the model tools. A prompt has no parameter binding: once the text is in the string, the model cannot tell your instruction from theirs.`
|
|
119
|
+
: `${injections.length} place(s). None is in a function that also hands the model tools, so the worst case here is a wrong answer rather than a tool call somebody else chose.`, '');
|
|
120
|
+
for (const i of injections.slice(0, 20)) {
|
|
121
|
+
lines.push(`- ${i.file}:${i.line} — ${i.origin}, in by ${i.through}${i.withTools ? ' — **and this function has tools**' : ''}`);
|
|
122
|
+
lines.push(` \`${i.evidence}\``);
|
|
123
|
+
}
|
|
124
|
+
if (injections.length > 20)
|
|
125
|
+
lines.push(`…and ${injections.length - 20} more`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
}
|
|
128
|
+
lines.push('## The lethal trifecta', '');
|
|
129
|
+
if (trifecta.complete) {
|
|
130
|
+
lines.push('All three legs are present. Breaking any one of them breaks the attack:', '');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const missing = Object.keys(trifecta.legs).filter((c) => trifecta.legs[c].length === 0);
|
|
134
|
+
lines.push(`Not all three legs are here - nothing was found that ${missing.map((m) => CAPABILITY_MEANS[m]).join(', nor that ')}.`, '', 'That is worth keeping true. Adding one tool is all it takes, and the tool that completes it is', 'usually the harmless-looking one.', '');
|
|
135
|
+
}
|
|
136
|
+
for (const capability of Object.keys(trifecta.legs)) {
|
|
137
|
+
const legTools = trifecta.legs[capability];
|
|
138
|
+
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
|
+
}
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push('## What stands between them and the real world', '');
|
|
142
|
+
for (const kind of ['asks-a-person', 'dry-run', 'step-budget', 'tool-allow-list']) {
|
|
143
|
+
const found = guards.find((g) => g.kind === kind);
|
|
144
|
+
lines.push(found
|
|
145
|
+
? `- **${kind}** — found at ${found.file}:${found.line}. ${GUARD_MEANS[kind]}.`
|
|
146
|
+
: `- **${kind}** — nothing found anywhere in this project. Without it: ${GUARD_PREVENTS[kind]}.`);
|
|
147
|
+
}
|
|
148
|
+
lines.push('');
|
|
149
|
+
if (emptyBudget) {
|
|
150
|
+
lines.push('### A ceiling that is written down and set to nothing', '', `\`${emptyBudget.text}\` in ${emptyBudget.file}. A budget removed this way reads as deliberate, and a check`, 'that only looked for the word would have called it a budget.', '');
|
|
151
|
+
}
|
|
152
|
+
lines.push('## What this does not check', '', '- **Whether a gate that exists stands in front of these tools.** Finding `interrupt()` in a', ' project means somebody wrote one, not that the refund tool is behind it. That is a question', ' about the graph at run time, and reading the source does not answer it.', '- **Whether the agent is ever given these tools.** A tool can be defined and never passed to a', ' model. This lists what exists, not what is wired up.', '- **What the model will actually do with them.** No prompt was judged and nothing was run.', '- **Untrusted text reaching a prompt through more than one step.** A value that passes through', ' two variables, another function or a different file is not followed, and is not reported.', '- **Tools assembled at run time**, from a registry, a plugin folder or a config file.', '- **Frameworks not in the list above.** Silence about a framework is silence, not approval.', '', 'Every line here is re-derivable: run `tewip agent` in this directory and you get it back.', '');
|
|
153
|
+
return lines.join(NL);
|
|
154
|
+
}
|
|
155
|
+
const rank = (r) => (IRREVERSIBLE.includes(r) ? 0 : r === 'not-sure' ? 1 : r === 'writes' ? 2 : 3);
|
|
156
|
+
export function runAgent(cmd) {
|
|
157
|
+
const scan = scanAgent(cmd.root);
|
|
158
|
+
const report = renderAgent(scan, cmd.root);
|
|
159
|
+
if (cmd.out)
|
|
160
|
+
writeFileSync(cmd.out, report, 'utf8');
|
|
161
|
+
else
|
|
162
|
+
console.log(report);
|
|
163
|
+
const asksAPerson = scan.guards.some((g) => g.kind === 'asks-a-person');
|
|
164
|
+
const ungated = asksAPerson ? 0 : scan.tools.filter((t) => IRREVERSIBLE.includes(t.reach)).length;
|
|
165
|
+
// The gate fails on the trifecta whether or not a person is asked: an approval step in front of
|
|
166
|
+
// one tool does not close a path that runs through three.
|
|
167
|
+
if (cmd.gate && (ungated > 0 || trifectaIn(scan.tools).complete))
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
return { report, ungated };
|
|
170
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// What stands between an agent's tools and the real world.
|
|
2
|
+
//
|
|
3
|
+
// The decision tree every serious write-up of agent safety draws is the same: is the tool on an
|
|
4
|
+
// allow-list, is the input clean, is there a budget, is this a dry run, and for anything that pays,
|
|
5
|
+
// deletes or sends - ask a human. Each of those is a thing somebody either wrote in this project or
|
|
6
|
+
// did not, and a file can be read for it.
|
|
7
|
+
//
|
|
8
|
+
// What a reader must not take from this: that finding `interrupt()` somewhere means the dangerous
|
|
9
|
+
// tools are gated. It means a gate exists in this project. Whether it stands in front of the tool
|
|
10
|
+
// that issues refunds is a question about the running graph, and this does not answer it - the
|
|
11
|
+
// report says so in those words. The useful half is the other direction: when nothing anywhere in
|
|
12
|
+
// a project asks a person anything, and the agent can delete and pay, that is not a maybe.
|
|
13
|
+
import { blank } from './source-text.js';
|
|
14
|
+
const NL = String.fromCharCode(10);
|
|
15
|
+
export const GUARD_MEANS = {
|
|
16
|
+
'asks-a-person': 'something here stops and waits for a person',
|
|
17
|
+
'dry-run': 'something here can run without touching anything',
|
|
18
|
+
'step-budget': 'the loop has a declared ceiling, so it cannot run forever',
|
|
19
|
+
'tool-allow-list': 'the tools are drawn from a named list rather than whatever is in scope',
|
|
20
|
+
};
|
|
21
|
+
/** Why each one matters, in the words of the failure it prevents. */
|
|
22
|
+
export const GUARD_PREVENTS = {
|
|
23
|
+
'asks-a-person': 'a refund, a deletion or an email going out because a model decided it should',
|
|
24
|
+
'dry-run': 'having to find out what it would do by letting it do it',
|
|
25
|
+
'step-budget': 'loop divergence - the agent retrying the same failing tool until the bill arrives',
|
|
26
|
+
'tool-allow-list': 'a tool reaching the model because it was in scope, not because anyone chose it',
|
|
27
|
+
};
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
{
|
|
30
|
+
kind: 'asks-a-person',
|
|
31
|
+
// LangGraph's interrupt and interrupt_before, LangChain's human tools, and the plain words
|
|
32
|
+
// people use when they write the gate themselves.
|
|
33
|
+
re: /\b(?:interrupt(?:_before|_after)?\s*[(=]|human_in_the_loop|humanInTheLoop|HumanInputRun|requires?_approval|requiresApproval|needs_approval|await_approval|ask_human|askHuman|confirm_with_user|human_review)\b/g,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
kind: 'dry-run',
|
|
37
|
+
re: /\b(?:dry_run|dryRun|DRY_RUN|--dry-run|preview_only|previewOnly|simulate_only|no_execute)\b/g,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: 'step-budget',
|
|
41
|
+
re: /\b(?:max_iterations|maxIterations|max_steps|maxSteps|recursion_limit|recursionLimit|max_turns|maxTurns|max_tool_calls|maxToolCalls|max_tool_roundtrips|stop_after_attempt)\b/g,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
kind: 'tool-allow-list',
|
|
45
|
+
re: /\b(?:allowed_tools|allowedTools|tool_allow_?list|toolAllow_?[Ll]ist|permitted_tools|enabled_tools|ALLOWED_TOOLS)\b/g,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
const lineOf = (text, index) => text.slice(0, index).split(NL).length;
|
|
49
|
+
export function guardsIn(file, text) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const code = blank(text, /\.py$/i.test(file)).calls;
|
|
52
|
+
for (const { kind, re } of PATTERNS) {
|
|
53
|
+
const m = re.exec(code);
|
|
54
|
+
re.lastIndex = 0;
|
|
55
|
+
if (!m)
|
|
56
|
+
continue;
|
|
57
|
+
const line = lineOf(text, m.index);
|
|
58
|
+
const evidence = (text.split(NL)[line - 1] ?? '').trim().slice(0, 120);
|
|
59
|
+
out.push({ kind, file, line, evidence });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A step budget that is written down but set to nothing.
|
|
65
|
+
*
|
|
66
|
+
* `max_iterations=None` and `recursion_limit = 0` are how a ceiling gets removed without anybody
|
|
67
|
+
* deleting the line, and a check that only looks for the word would call that a budget. Reported
|
|
68
|
+
* apart from "no budget at all" because it is worse: it reads as deliberate.
|
|
69
|
+
*/
|
|
70
|
+
export function budgetSetToNothing(raw) {
|
|
71
|
+
const text = blank(raw, false).calls;
|
|
72
|
+
const m = /\b(?:max_iterations|maxIterations|max_steps|maxSteps|recursion_limit|recursionLimit|max_turns|maxTurns)\s*[:=]\s*(None|null|0|-1|Infinity|float\(["']inf["']\))/.exec(text);
|
|
73
|
+
return m ? m[0].replace(/\s+/g, ' ') : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Text somebody else wrote, glued into a prompt, in a loop that holds tools.
|
|
2
|
+
//
|
|
3
|
+
// `tools.ts` answers this at the level of capability: does this agent have a tool that reads
|
|
4
|
+
// untrusted text, a tool that reads private data, and a tool that sends data out. That is the
|
|
5
|
+
// lethal trifecta and it is worth knowing. But it is an argument about what could happen.
|
|
6
|
+
//
|
|
7
|
+
// This is the same question one level down, about what the code actually does: is a value that
|
|
8
|
+
// came off a web page, a ticket, an inbox or a retriever **glued into the prompt string itself**.
|
|
9
|
+
// Glued, not passed beside it - the distinction that matters in SQL matters more here, because a
|
|
10
|
+
// prompt has no parameter binding to fall back on. Once the text is in the prompt, the model cannot
|
|
11
|
+
// tell the instruction you wrote from the instruction the attacker wrote, and no amount of "ignore
|
|
12
|
+
// any instructions in the document below" changes that.
|
|
13
|
+
//
|
|
14
|
+
// The same refusal as the SQL checker next door: a source and a sink inside one function body,
|
|
15
|
+
// directly or through one named variable. No call graph, no cross-file inference, no maybes. A
|
|
16
|
+
// list of maybes gets switched off, and then somebody has written "we check for prompt injection"
|
|
17
|
+
// on a form.
|
|
18
|
+
import { gluedIn, sameBody } from '../taint/flow.js';
|
|
19
|
+
const NL = String.fromCharCode(10);
|
|
20
|
+
/**
|
|
21
|
+
* Where text somebody outside the building can write comes into an agent.
|
|
22
|
+
*
|
|
23
|
+
* Framework-specific on purpose, and short on purpose. Every entry here is a shape where the value
|
|
24
|
+
* is *text from elsewhere*, not a number or a flag: a fetched page, a retrieved document, the body
|
|
25
|
+
* of a ticket or a message. A general "anything from a function call" source would match the whole
|
|
26
|
+
* program and find nothing worth reading.
|
|
27
|
+
*/
|
|
28
|
+
const SOURCES = [
|
|
29
|
+
{ re: String.raw `(?:requests|httpx|session)\s*\.\s*(?:get|post)\s*\([^)]*\)\s*\.\s*(?:text|content|json\(\))`, origin: 'a web page this agent fetched' },
|
|
30
|
+
{ re: String.raw `(?:await\s+)?(?:fetch|axios\s*\.\s*get)\s*\([^)]*\)[^;\n]*?\.\s*(?:text|json|data)\s*\(?\)?`, origin: 'a web page this agent fetched' },
|
|
31
|
+
{ re: String.raw `\w*(?:page|html|soup|article|scraped?)\w*\s*\.\s*(?:content|text|get_text|inner_text|innerText)\s*\(?\)?`, origin: 'a web page this agent fetched' },
|
|
32
|
+
{ re: String.raw `\w*(?:retriever|vectorstore|index|store)\w*\s*\.\s*(?:invoke|search|similarity_search|get_relevant_documents|query)\s*\([^)]*\)`, origin: 'a document this agent retrieved' },
|
|
33
|
+
{ re: String.raw `\w*(?:doc|document|chunk|result|passage)\w*\s*\.\s*(?:page_content|pageContent|text)\b`, origin: 'a document this agent retrieved' },
|
|
34
|
+
{ re: String.raw `\w*(?:issue|ticket|comment|review|pr|pull_request)\w*\s*\.\s*(?:body|description|text)\b`, origin: 'a ticket or comment somebody else wrote' },
|
|
35
|
+
{ re: String.raw `\w*(?:email|mail|message|msg)\w*\s*\.\s*(?:body|payload|text|content|get_payload\(\))`, origin: 'a message this agent received' },
|
|
36
|
+
{ re: String.raw `\w*(?:tool_result|toolResult|observation|tool_output)\w*\b`, origin: 'the output of another tool' },
|
|
37
|
+
];
|
|
38
|
+
/** Where a prompt is built or sent. */
|
|
39
|
+
const PROMPT = /\b(?:messages|prompt|system|instructions|user_message|userMessage|input|content|template)\s*[:=]|\b(?:SystemMessage|HumanMessage|ChatPromptTemplate|PromptTemplate)\s*(?:\.\s*from_\w+\s*)?\(|\.\s*(?:invoke|complete|create|generate|run|predict|chat)\s*\(/g;
|
|
40
|
+
/** The same body also handing the model tools - which is what turns exposure into reach. */
|
|
41
|
+
// `[:=,})]` and not just `[:=]`: `client.create({ tools, messages })` is the shorthand both the
|
|
42
|
+
// Anthropic and Vercel SDKs are written in, and `tools` there is followed by a comma. Missing it
|
|
43
|
+
// reported the commonest shape in JavaScript as a prompt with no tools behind it, which is exactly
|
|
44
|
+
// the difference between exposure and reach.
|
|
45
|
+
const TOOLS_HERE = /\btools\b\s*[:=,})]|\btool_choice\s*[:=]|\bbind_tools\s*\(|\.\s*with_tools\s*\(|\bToolNode\s*\(/;
|
|
46
|
+
/** The statement a line sits in, which is the string a value is or is not glued into. */
|
|
47
|
+
function statementAt(text, at) {
|
|
48
|
+
let start = at;
|
|
49
|
+
let depth = 0;
|
|
50
|
+
// Walk back to the start of the expression: a line break at bracket depth zero.
|
|
51
|
+
for (let i = at; i > 0 && at - i < 2000; i -= 1) {
|
|
52
|
+
const ch = text[i];
|
|
53
|
+
if (ch === ')' || ch === ']' || ch === '}')
|
|
54
|
+
depth += 1;
|
|
55
|
+
else if (ch === '(' || ch === '[' || ch === '{') {
|
|
56
|
+
if (depth === 0)
|
|
57
|
+
break;
|
|
58
|
+
depth -= 1;
|
|
59
|
+
}
|
|
60
|
+
else if (ch === NL && depth === 0) {
|
|
61
|
+
start = i + 1;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
start = i;
|
|
65
|
+
}
|
|
66
|
+
let end = at;
|
|
67
|
+
depth = 0;
|
|
68
|
+
for (let i = at; i < text.length && i - at < 2000; i += 1) {
|
|
69
|
+
const ch = text[i];
|
|
70
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
71
|
+
depth += 1;
|
|
72
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
73
|
+
if (depth === 0) {
|
|
74
|
+
end = i;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
depth -= 1;
|
|
78
|
+
}
|
|
79
|
+
else if ((ch === NL || ch === ';') && depth === 0) {
|
|
80
|
+
end = i;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
end = i + 1;
|
|
84
|
+
}
|
|
85
|
+
return text.slice(start, end);
|
|
86
|
+
}
|
|
87
|
+
export function promptFlows(file, text) {
|
|
88
|
+
const found = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const assigned = [];
|
|
91
|
+
for (const { re, origin } of SOURCES) {
|
|
92
|
+
// `name = <source>` in Python, and `const name = <source>` in JavaScript.
|
|
93
|
+
for (const m of text.matchAll(new RegExp(String.raw `(?:(?:const|let|var)\s+)?(\w+)\s*=\s*[^=\n]*?(` + re + String.raw `)`, 'g'))) {
|
|
94
|
+
assigned.push({ name: m[1], at: m.index ?? 0, source: m[2], origin });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const prompt of text.matchAll(PROMPT)) {
|
|
98
|
+
const at = prompt.index ?? 0;
|
|
99
|
+
const statement = statementAt(text, at);
|
|
100
|
+
if (!statement.trim())
|
|
101
|
+
continue;
|
|
102
|
+
const line = text.slice(0, at).split(NL).length;
|
|
103
|
+
// Tools in the same body: looked for from the start of the enclosing function rather than the
|
|
104
|
+
// whole file, so a tool list in one handler does not arm a prompt built in another.
|
|
105
|
+
const withTools = TOOLS_HERE.test(text.slice(Math.max(0, at - 3000), at + 3000));
|
|
106
|
+
// Directly: the fetched value is written inside the prompt string itself.
|
|
107
|
+
for (const { re, origin } of SOURCES) {
|
|
108
|
+
const hit = new RegExp(re).exec(statement);
|
|
109
|
+
if (!hit)
|
|
110
|
+
continue;
|
|
111
|
+
const how = gluedIn(statement, hit[0]);
|
|
112
|
+
if (!how)
|
|
113
|
+
continue;
|
|
114
|
+
// One finding per untrusted value, not per place a prompt is mentioned: `messages=` and
|
|
115
|
+
// `.create(` both match the same statement, and reporting it twice reads as two problems.
|
|
116
|
+
const key = `direct:${hit[0]}:${statement.slice(0, 40)}`;
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
found.push({ file, line, source: hit[0].replace(/\s+/g, ' ').slice(0, 80), origin, through: how, withTools, evidence: statement.replace(/\s+/g, ' ').slice(0, 160) });
|
|
121
|
+
}
|
|
122
|
+
// Through one variable, assigned from an untrusted value earlier in the same body.
|
|
123
|
+
for (const a of assigned) {
|
|
124
|
+
if (a.at > at || !sameBody(text, a.at, at))
|
|
125
|
+
continue;
|
|
126
|
+
const how = gluedIn(statement, a.name);
|
|
127
|
+
if (!how)
|
|
128
|
+
continue;
|
|
129
|
+
const key = `via:${a.name}:${a.at}`;
|
|
130
|
+
if (seen.has(key))
|
|
131
|
+
continue;
|
|
132
|
+
seen.add(key);
|
|
133
|
+
found.push({
|
|
134
|
+
file,
|
|
135
|
+
line,
|
|
136
|
+
source: a.source.replace(/\s+/g, ' ').slice(0, 80),
|
|
137
|
+
origin: a.origin,
|
|
138
|
+
through: `${how}, through \`${a.name}\``,
|
|
139
|
+
withTools,
|
|
140
|
+
evidence: statement.replace(/\s+/g, ' ').slice(0, 160),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Reading source as source: one scanner that knows a comment from a string from a pattern.
|
|
2
|
+
//
|
|
3
|
+
// Both halves of this module needed it and both got it wrong in a different direction first, which
|
|
4
|
+
// is the argument for having it once.
|
|
5
|
+
//
|
|
6
|
+
// `tools.ts` blanked comments with a plain regex and lost three real tools from the official MCP
|
|
7
|
+
// filesystem server - `copy_file`, `move_file`, `search_files` - because a glob in a string holds
|
|
8
|
+
// `/*`, so "blank from `/*` to the next `*/`" ate the code between two unrelated path patterns.
|
|
9
|
+
//
|
|
10
|
+
// `guards.ts` blanked strings without stripping comments first, and an apostrophe in an English
|
|
11
|
+
// sentence - "Tewip's own server" - opened a string that stayed open for the rest of the file, so
|
|
12
|
+
// every later line was one quote out of step and the checker found its own regex and reported it
|
|
13
|
+
// as a human approval gate.
|
|
14
|
+
//
|
|
15
|
+
// Both are the same mistake: deciding what a character means without tracking what you are inside.
|
|
16
|
+
const NL = String.fromCharCode(10);
|
|
17
|
+
const blankOut = (s) => s.replace(/[^\n]/g, ' ');
|
|
18
|
+
/**
|
|
19
|
+
* One pass, both answers, lengths and line breaks preserved throughout.
|
|
20
|
+
*
|
|
21
|
+
* Keeping the length means every index into either result still points at the same place in the
|
|
22
|
+
* original, which is what lets a tool's description be read back out of the untouched source after
|
|
23
|
+
* the docstring it lives in has been blanked for matching.
|
|
24
|
+
*/
|
|
25
|
+
export function blank(text, python) {
|
|
26
|
+
const code = [];
|
|
27
|
+
const calls = [];
|
|
28
|
+
const keep = (s) => {
|
|
29
|
+
code.push(s);
|
|
30
|
+
calls.push(s);
|
|
31
|
+
};
|
|
32
|
+
const drop = (s) => {
|
|
33
|
+
code.push(blankOut(s));
|
|
34
|
+
calls.push(blankOut(s));
|
|
35
|
+
};
|
|
36
|
+
let before = '';
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < text.length) {
|
|
39
|
+
const two = text.slice(i, i + 2);
|
|
40
|
+
const three = text.slice(i, i + 3);
|
|
41
|
+
// A comment is blanked in both, because nothing in one is code.
|
|
42
|
+
if ((python && text[i] === '#') || (!python && two === '//')) {
|
|
43
|
+
const end = text.indexOf(NL, i);
|
|
44
|
+
const stop = end === -1 ? text.length : end;
|
|
45
|
+
drop(text.slice(i, stop));
|
|
46
|
+
i = stop;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!python && two === '/*') {
|
|
50
|
+
const end = text.indexOf('*/', i + 2);
|
|
51
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
52
|
+
drop(text.slice(i, stop));
|
|
53
|
+
i = stop;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// A Python docstring is documentation, and a framework documents its own decorator by showing
|
|
57
|
+
// it being used. crewAI's `@tool` docstring contains `@tool` and `def greet(...)`.
|
|
58
|
+
if (python && (three === '"""' || three === "'''")) {
|
|
59
|
+
const end = text.indexOf(three, i + 3);
|
|
60
|
+
const stop = end === -1 ? text.length : end + 3;
|
|
61
|
+
drop(text.slice(i, stop));
|
|
62
|
+
i = stop;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const ch = text[i];
|
|
66
|
+
const quote = ch === '"' || ch === "'" || ch === '`';
|
|
67
|
+
// A regex literal, not a division: only where an expression cannot have just ended.
|
|
68
|
+
const regex = !python && ch === '/' && (before === '' || '(,=:[!&|?{};+*'.includes(before));
|
|
69
|
+
if (!quote && !regex) {
|
|
70
|
+
keep(ch);
|
|
71
|
+
if (!/\s/.test(ch))
|
|
72
|
+
before = ch;
|
|
73
|
+
i += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const close = quote ? ch : '/';
|
|
77
|
+
let j = i + 1;
|
|
78
|
+
while (j < text.length && text[j] !== close) {
|
|
79
|
+
if (text[j] === '\\') {
|
|
80
|
+
j += 2;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// An unterminated quote must not swallow the file; only a backtick spans lines.
|
|
84
|
+
if (text[j] === NL && quote && ch !== '`')
|
|
85
|
+
break;
|
|
86
|
+
j += 1;
|
|
87
|
+
}
|
|
88
|
+
const inner = text.slice(i + 1, j);
|
|
89
|
+
const closer = text[j] ?? '';
|
|
90
|
+
code.push(ch, inner, closer);
|
|
91
|
+
calls.push(ch, blankOut(inner), closer);
|
|
92
|
+
before = closer || before;
|
|
93
|
+
i = j + 1;
|
|
94
|
+
}
|
|
95
|
+
return { code: code.join(''), calls: calls.join('') };
|
|
96
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// What an AI agent is allowed to do, read from its source before it ever runs.
|
|
2
|
+
//
|
|
3
|
+
// Every tool in this market evaluates an agent by running it: Langfuse, Phoenix, RAGAS, TruLens,
|
|
4
|
+
// DeepEval, Promptfoo, Giskard, garak. They need a runtime, a token budget and usually an account,
|
|
5
|
+
// and most judge with another model, so the same agent scores differently on Tuesday. They answer
|
|
6
|
+
// "is this agent any good".
|
|
7
|
+
//
|
|
8
|
+
// Nobody answers the question an IT department actually has to sign off on: **what is this agent
|
|
9
|
+
// allowed to do, and what stands between it and the real world?** That one is decidable by
|
|
10
|
+
// reading, for free, offline, in CI, before anything runs. It is also the question an auditor
|
|
11
|
+
// asks, and the one a post-mortem starts from.
|
|
12
|
+
//
|
|
13
|
+
// The honesty rule is the same as everywhere else in Tewip. A tool whose reach cannot be decided
|
|
14
|
+
// from its name and description is reported as undecided, never as safe. "No approval gate found"
|
|
15
|
+
// is a fact about this source, not a verdict about the running system, and the report says which
|
|
16
|
+
// it is.
|
|
17
|
+
import { blank } from './source-text.js';
|
|
18
|
+
const NL = String.fromCharCode(10);
|
|
19
|
+
const PYTHON_SHAPES = [
|
|
20
|
+
// LangChain / LangGraph: @tool("name") or a bare @tool above the function that names it.
|
|
21
|
+
{ framework: 'LangChain @tool', re: /@tool\s*\(\s*["']([\w.-]+)["']/g },
|
|
22
|
+
{ framework: 'LangChain @tool', re: /@tool(?:\s*\(\s*\))?\s*\n\s*(?:async\s+)?def\s+(\w+)/g },
|
|
23
|
+
// MCP Python SDK, and FastMCP.
|
|
24
|
+
{ framework: 'MCP @tool', re: /@(?:mcp|server|app)\.tool\s*\(\s*(?:\)\s*)?\n\s*(?:async\s+)?def\s+(\w+)/g },
|
|
25
|
+
{ framework: 'MCP @tool', re: /@(?:mcp|server|app)\.tool\s*\(\s*name\s*=\s*["']([\w.-]+)["']/g },
|
|
26
|
+
// OpenAI Agents SDK.
|
|
27
|
+
{ framework: 'OpenAI @function_tool', re: /@function_tool(?:\s*\([^)]*\))?\s*\n\s*(?:async\s+)?def\s+(\w+)/g },
|
|
28
|
+
// Explicit construction, LangChain and CrewAI alike.
|
|
29
|
+
{ framework: 'Tool(name=...)', re: /\b(?:Structured)?Tool\s*\(\s*name\s*=\s*["']([\w.\s-]+)["']/g },
|
|
30
|
+
];
|
|
31
|
+
const JS_SHAPES = [
|
|
32
|
+
// MCP TypeScript SDK.
|
|
33
|
+
{ framework: 'MCP server.tool()', re: /\b(?:server|mcp|app)\s*\.\s*(?:tool|registerTool)\s*\(\s*["']([\w.-]+)["']/g },
|
|
34
|
+
// Vercel AI SDK: tools: { theName: tool({ ... }) }
|
|
35
|
+
{ framework: 'AI SDK tool()', re: /\b([\w.-]+)\s*:\s*tool\s*\(\s*\{/g },
|
|
36
|
+
// Anthropic's own tool shape, and anything else that writes a JSON schema by hand.
|
|
37
|
+
{ framework: 'name + input_schema', re: /name\s*:\s*["']([\w.-]+)["'][^}]{0,400}?input_schema\s*:/g },
|
|
38
|
+
// OpenAI function calling.
|
|
39
|
+
{ framework: 'OpenAI function', re: /type\s*:\s*["']function["'][^}]{0,200}?name\s*:\s*["']([\w.-]+)["']/g },
|
|
40
|
+
// LangChain JS.
|
|
41
|
+
{ framework: 'DynamicStructuredTool', re: /new\s+Dynamic(?:Structured)?Tool\s*\(\s*\{[^}]{0,200}?name\s*:\s*["']([\w.-]+)["']/g },
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* What the tool does to the world, from its name and its description.
|
|
45
|
+
*
|
|
46
|
+
* Ordered most-serious first on purpose: `delete_and_notify` is a delete, not a send. A name that
|
|
47
|
+
* matches nothing is `not-sure`, which is printed as undecided - never folded into "reads", because
|
|
48
|
+
* the whole value of this report is that it does not quietly downgrade a risk it failed to parse.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* `ENDING` matters more than the word lists do.
|
|
52
|
+
*
|
|
53
|
+
* Without it these were written `\b(?:read|...)\b`, and `\bread\b` does not match "Reads" - the
|
|
54
|
+
* trailing s removes the word boundary. Tool names survive that, because `read_file` splits on the
|
|
55
|
+
* underscore, but descriptions do not: they are written in the third person. "Reads test files and
|
|
56
|
+
* says..." matched nothing, and all seventeen tools in Tewip's own MCP server came back undecided
|
|
57
|
+
* while the file sat there saying exactly what each one did.
|
|
58
|
+
*/
|
|
59
|
+
const ENDING = String.raw `(?:e?[sd]|ing)?\b`;
|
|
60
|
+
const verbs = (words) => new RegExp(String.raw `\b(?:${words})${ENDING}`, 'i');
|
|
61
|
+
const REACH = [
|
|
62
|
+
{ reach: 'deletes', words: verbs('delete|destroy|drop|purge|wipe|remove|revoke|terminate|uninstall|erase|truncate') },
|
|
63
|
+
{ reach: 'pays', words: verbs('pay|payment|refund|charge|transfer|invoice|payout|purchase|checkout|billing|subscribe|withdraw') },
|
|
64
|
+
{ reach: 'runs-code', words: verbs('exec|execute|shell|bash|terminal|subprocess|run_code|runcode|eval|interpreter|command') },
|
|
65
|
+
{ reach: 'sends', words: verbs('send|email|mail|post|publish|notify|notification|sms|slack|tweet|broadcast|message|dispatch|share|invite') },
|
|
66
|
+
{ reach: 'writes', words: verbs('write|create|update|insert|upsert|upload|save|edit|modify|patch|rename|commit|merge|deploy|provision|grant') },
|
|
67
|
+
{ reach: 'reads', words: verbs('get|list|read|search|fetch|find|query|lookup|show|describe|view|count|check|status|say|report|explain|answer|tell|trace|plan|prove|recall') },
|
|
68
|
+
];
|
|
69
|
+
/** Everything that cannot be taken back once the model has called it. */
|
|
70
|
+
export const IRREVERSIBLE = ['destructive', 'deletes', 'pays', 'sends', 'runs-code'];
|
|
71
|
+
export const CAPABILITY_MEANS = {
|
|
72
|
+
'untrusted-input': 'pulls in text somebody outside your company can write',
|
|
73
|
+
'private-data': 'reads data that is not meant to leave',
|
|
74
|
+
'can-send-out': 'can put data somewhere outside',
|
|
75
|
+
};
|
|
76
|
+
const CAPABILITIES = [
|
|
77
|
+
{
|
|
78
|
+
capability: 'untrusted-input',
|
|
79
|
+
words: /\b(?:fetch|browse|scrape|crawl|web|url|http|search|google|bing|read_email|readmail|inbox|gmail|ticket|issue|comment|review|pdf|document|attachment|rss|feed|webhook_in|transcri)\w*/i,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
capability: 'private-data',
|
|
83
|
+
words: /\b(?:sql|query|database|db|select|table|record|customer|user|account|employee|salary|patient|secret|credential|token|key|env|vault|file|read_file|readfile|fs|s3|bucket|storage|internal|crm|ledger)\w*/i,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
capability: 'can-send-out',
|
|
87
|
+
words: /\b(?:send|email|mail|smtp|post|publish|webhook|slack|discord|teams|sms|twilio|tweet|upload|push|notify|share|export|forward|broadcast|comment|reply)\w*/i,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
export function capabilitiesOf(name, description) {
|
|
91
|
+
const text = `${name.replace(/[_-]/g, ' ')} ${description}`;
|
|
92
|
+
return CAPABILITIES.filter(({ words }) => words.test(text)).map(({ capability }) => capability);
|
|
93
|
+
}
|
|
94
|
+
export function trifectaIn(tools) {
|
|
95
|
+
const legs = { 'untrusted-input': [], 'private-data': [], 'can-send-out': [] };
|
|
96
|
+
for (const tool of tools)
|
|
97
|
+
for (const c of tool.capabilities)
|
|
98
|
+
legs[c].push(tool);
|
|
99
|
+
return { complete: Object.values(legs).every((l) => l.length > 0), legs };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* What the author declared, which beats anything guessed from a name.
|
|
103
|
+
*
|
|
104
|
+
* MCP tools carry `readOnlyHint` and `destructiveHint`, and a tool that says of itself that it is
|
|
105
|
+
* read-only is better evidence than any verb list. Both shapes are read: written inline, and
|
|
106
|
+
* written once as a named constant and reused - `annotations: readOnly`, with
|
|
107
|
+
* `const readOnly = { readOnlyHint: true, ... }` further up the same file, which is how Tewip's own
|
|
108
|
+
* server does it and therefore the shape most likely to be missed by a check written against the
|
|
109
|
+
* documentation instead of against real code.
|
|
110
|
+
*/
|
|
111
|
+
function declaredHint(text, from) {
|
|
112
|
+
const window = text.slice(from, from + 1200);
|
|
113
|
+
const named = /annotations\s*:\s*([A-Za-z_$][\w$]*)/.exec(window);
|
|
114
|
+
const where = named
|
|
115
|
+
? new RegExp(String.raw `\b(?:const|let|var)\s+${named[1]}\s*(?::[^=]{0,120})?=\s*\{([^}]{0,300})\}`).exec(text)?.[1] ?? ''
|
|
116
|
+
: window;
|
|
117
|
+
if (/destructiveHint\s*:\s*true/.test(where))
|
|
118
|
+
return { reach: 'destructive', why: 'its own annotation says destructiveHint: true' };
|
|
119
|
+
if (/readOnlyHint\s*:\s*true/.test(where))
|
|
120
|
+
return { reach: 'reads', why: 'it declares readOnlyHint: true' };
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
export function reachOf(name, description) {
|
|
124
|
+
// The name first. A description is marketing for the model and often says "safely" about a tool
|
|
125
|
+
// that drops a table; the name is what the person who wrote it called the thing.
|
|
126
|
+
for (const { reach, words } of REACH) {
|
|
127
|
+
const hit = words.exec(name.replace(/[_-]/g, ' '));
|
|
128
|
+
if (hit)
|
|
129
|
+
return { reach, why: `its name contains "${hit[0]}"` };
|
|
130
|
+
}
|
|
131
|
+
// Only the opening clause of the description, where a tool says what it does: "Reads test files
|
|
132
|
+
// and says...". Searching the whole of it matches verbs in prose that are about something else
|
|
133
|
+
// entirely - Tewip's own `explain_failure` describes "history on this commit versus others" and
|
|
134
|
+
// was classified as a tool that writes, on the strength of the word "commit" in a sentence about
|
|
135
|
+
// reading. A first clause is a claim about the tool; the rest is explanation.
|
|
136
|
+
const opening = description.split(/[.;:]/)[0]?.slice(0, 120) ?? '';
|
|
137
|
+
for (const { reach, words } of REACH) {
|
|
138
|
+
const hit = words.exec(opening);
|
|
139
|
+
if (hit)
|
|
140
|
+
return { reach, why: `its description opens with "${hit[0]}"` };
|
|
141
|
+
}
|
|
142
|
+
return { reach: 'not-sure', why: 'neither its name nor its description says what it does to the world' };
|
|
143
|
+
}
|
|
144
|
+
const lineOf = (text, index) => text.slice(0, index).split(NL).length;
|
|
145
|
+
/** The description written beside a tool, when one is written at all. */
|
|
146
|
+
function describedNear(text, from) {
|
|
147
|
+
const window = text.slice(from, from + 600);
|
|
148
|
+
const described =
|
|
149
|
+
// Up to 800: a real tool description is a paragraph, not a label. Capped at 200 this matched
|
|
150
|
+
// none of the seventeen in Tewip's own MCP server, so every one of them came back undecided.
|
|
151
|
+
/description\s*[:=]\s*["'`]((?:[^"'`\\]|\\.){3,800})["'`]/.exec(window) ??
|
|
152
|
+
// A Python docstring on the line after the def is the description the framework sends.
|
|
153
|
+
/def\s+\w+[^\n]*\n\s*(?:"""|''')\s*([^\n"']{3,200})/.exec(window);
|
|
154
|
+
return (described?.[1] ?? '').trim().replace(/\s+/g, ' ');
|
|
155
|
+
}
|
|
156
|
+
export function toolsIn(file, text) {
|
|
157
|
+
const python = /\.py$/i.test(file);
|
|
158
|
+
const source = blank(text, python).code;
|
|
159
|
+
const found = new Map();
|
|
160
|
+
for (const { framework, re } of python ? PYTHON_SHAPES : JS_SHAPES) {
|
|
161
|
+
for (const m of source.matchAll(re)) {
|
|
162
|
+
const name = m[1]?.trim();
|
|
163
|
+
if (!name || name.length > 60)
|
|
164
|
+
continue;
|
|
165
|
+
const line = lineOf(source, m.index ?? 0);
|
|
166
|
+
// One entry per name per file: the same tool matched by two shapes is one tool.
|
|
167
|
+
const key = `${name}:${file}`;
|
|
168
|
+
if (found.has(key))
|
|
169
|
+
continue;
|
|
170
|
+
// The original text, not the blanked one: a Python docstring *is* the description, and it
|
|
171
|
+
// has just been blanked for matching. The indexes line up because blanking kept the lengths.
|
|
172
|
+
const description = describedNear(text, m.index ?? 0);
|
|
173
|
+
// Declared first, guessed second.
|
|
174
|
+
const { reach, why } = declaredHint(source, m.index ?? 0) ?? reachOf(name, description);
|
|
175
|
+
found.set(key, { name, file, line, shape: framework, description, reach, why, capabilities: capabilitiesOf(name, description) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [...found.values()];
|
|
179
|
+
}
|
|
180
|
+
/** The frameworks this knows how to read, named in the report so the silence is not mistaken. */
|
|
181
|
+
export const FRAMEWORKS_READ = [
|
|
182
|
+
'LangChain and LangGraph (`@tool`, `Tool(name=...)`, `DynamicStructuredTool`)',
|
|
183
|
+
'the MCP SDKs, Python and TypeScript (`@mcp.tool`, `server.tool(...)`)',
|
|
184
|
+
'the OpenAI Agents SDK (`@function_tool`) and OpenAI function calling',
|
|
185
|
+
'the Vercel AI SDK (`tool({ ... })`)',
|
|
186
|
+
"Anthropic's tool shape (`name` beside `input_schema`)",
|
|
187
|
+
];
|
|
@@ -17,6 +17,8 @@ import { NEEDS_A_LOOK } from '../sbom/licences.js';
|
|
|
17
17
|
import { findBarriers } from '../access/access-command.js';
|
|
18
18
|
import { migrationsUnder, readMigration } from '../schema/schema-command.js';
|
|
19
19
|
import { vouchAll } from '../vouch/vouch-command.js';
|
|
20
|
+
import { scanAgent } from '../agent/agent-command.js';
|
|
21
|
+
import { IRREVERSIBLE } from '../agent/tools.js';
|
|
20
22
|
import { CHECKS, renderPortfolio } from './portfolio.js';
|
|
21
23
|
const ran = (count, detail) => ({ ran: true, count, detail });
|
|
22
24
|
const JS = /\.(?:[cm]?[jt]sx?)$/;
|
|
@@ -65,6 +67,16 @@ export function examine(path, quick = false) {
|
|
|
65
67
|
const risky = found.filter((f) => triageSecret(f, path, new Set()).verdict === 'real-risk');
|
|
66
68
|
return ran(risky.length, `${found.length} shape(s) matched, ${risky.length} need action`);
|
|
67
69
|
});
|
|
70
|
+
// An agent's irreversible tools belong on this page for the same reason the rest do: at forty
|
|
71
|
+
// repositories nobody opens each one, and "which of these can delete things on its own" is the
|
|
72
|
+
// question that now gets asked first.
|
|
73
|
+
cells['agent tools'] = attempt(() => {
|
|
74
|
+
const scan = scanAgent(path);
|
|
75
|
+
if (scan.tools.length === 0)
|
|
76
|
+
return { ran: false, why: 'no agent tool definitions found, in the frameworks this reads' };
|
|
77
|
+
const risky = scan.tools.filter((t) => IRREVERSIBLE.includes(t.reach));
|
|
78
|
+
return ran(risky.length, `${scan.tools.length} tool(s), ${risky.length} that cannot be undone`);
|
|
79
|
+
});
|
|
68
80
|
cells.injection = attempt(() => {
|
|
69
81
|
const files = scanSourceFiles(path, 20_000, (f) => JS.test(f) && !/\.(?:test|spec)\./.test(f)).files;
|
|
70
82
|
let n = 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CHECKS = ['secrets', 'injection', 'containers', 'licences', 'access', 'migrations', 'hollow tests'];
|
|
1
|
+
export const CHECKS = ['secrets', 'injection', 'agent tools', 'containers', 'licences', 'access', 'migrations', 'hollow tests'];
|
|
2
2
|
/** What each column counts, so the header is not seven words a reader has to guess at. */
|
|
3
3
|
export const COUNTS = {
|
|
4
4
|
secrets: 'credentials that need somebody to act now',
|
|
@@ -7,6 +7,7 @@ export const COUNTS = {
|
|
|
7
7
|
licences: 'dependencies whose licence needs a decision',
|
|
8
8
|
access: 'accessibility barriers, not warnings',
|
|
9
9
|
migrations: 'migrations that destroy data or break the running version',
|
|
10
|
+
'agent tools': 'tools an AI agent can call that cannot be undone',
|
|
10
11
|
'hollow tests': 'tests that cannot fail',
|
|
11
12
|
};
|
|
12
13
|
export function worstFirst(rows) {
|
|
@@ -12,8 +12,14 @@ const SINKS = [
|
|
|
12
12
|
* value bound as a parameter is never parsed as syntax, and an explicit escape says so.
|
|
13
13
|
*/
|
|
14
14
|
const MADE_SAFE = /\b(?:parseInt|parseFloat|Number|encodeURIComponent|escape|escapeId|sqlstring\.escape)\s*\(/;
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A value is only dangerous here if it is glued into the string, not passed beside it.
|
|
17
|
+
*
|
|
18
|
+
* Exported because the agent checks need exactly this question about a different kind of string: a
|
|
19
|
+
* prompt. The Python shapes are here rather than in a second copy for the same reason - an f-string
|
|
20
|
+
* glues a value in exactly as a template literal does, and the difference is only spelling.
|
|
21
|
+
*/
|
|
22
|
+
export function gluedIn(argument, expression) {
|
|
17
23
|
const e = expression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
24
|
if (new RegExp(String.raw `\$\{[^}]*` + e, 's').test(argument))
|
|
19
25
|
return 'a template literal';
|
|
@@ -21,6 +27,13 @@ function gluedIn(argument, expression) {
|
|
|
21
27
|
return 'string concatenation';
|
|
22
28
|
if (new RegExp(String.raw `\.concat\s*\([^)]*` + e).test(argument))
|
|
23
29
|
return 'concat()';
|
|
30
|
+
// Python: an f-string, old-style %, and .format(), which glue a value in just as firmly.
|
|
31
|
+
if (new RegExp(String.raw `f["'][^"']*\{[^}]*` + e, 's').test(argument))
|
|
32
|
+
return 'an f-string';
|
|
33
|
+
if (new RegExp(String.raw `%\s*\(?[^)]*` + e).test(argument))
|
|
34
|
+
return '% formatting';
|
|
35
|
+
if (new RegExp(String.raw `\.format\s*\([^)]*` + e).test(argument))
|
|
36
|
+
return '.format()';
|
|
24
37
|
return null;
|
|
25
38
|
}
|
|
26
39
|
/** The first argument of a call that starts at `from`, which is the one parsed as syntax. */
|
|
@@ -48,7 +61,7 @@ function firstArgument(text, from) {
|
|
|
48
61
|
* joined. Crude - it finds the enclosing braces - and crude in the safe direction: a body it
|
|
49
62
|
* cannot determine yields no finding at all.
|
|
50
63
|
*/
|
|
51
|
-
function sameBody(text, a, b) {
|
|
64
|
+
export function sameBody(text, a, b) {
|
|
52
65
|
if (a > b)
|
|
53
66
|
return false;
|
|
54
67
|
let depth = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.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": [
|