tewip-ai 0.73.1 → 0.74.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
@@ -1216,6 +1216,47 @@ 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
+ - **What stands in the way**: a human approval gate, a dry run, a step budget, a tool allow-list -
1248
+ and says plainly when nothing anywhere in the project has one. A ceiling written as
1249
+ `max_iterations=None` is reported apart from no ceiling at all, because it reads as deliberate.
1250
+
1251
+ It will not tell you that a gate which exists stands in front of these particular tools - that is a
1252
+ question about the running graph - and it says so in those words rather than letting you assume it.
1253
+
1254
+ **Measured on real agents.** The official MCP servers: 27 tools, 6 that cannot be undone, every one
1255
+ of those confirmed by its own `destructiveHint: true`. The OpenAI Agents SDK and CrewAI: no tools,
1256
+ correctly - they are frameworks, and every candidate in them was a test fixture or a docstring
1257
+ example. Each of those three repositories broke an earlier version of this check, in the same two
1258
+ ways every time: prose read as code, or code read as prose.
1259
+
1219
1260
  ## What an IT department has to answer (Phases 133-137)
1220
1261
 
1221
1262
  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
@@ -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,154 @@
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 { budgetSetToNothing, guardsIn, GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
7
+ const NL = String.fromCharCode(10);
8
+ const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
9
+ /**
10
+ * What is not the shipped agent.
11
+ *
12
+ * The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
13
+ * documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
14
+ * all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
15
+ * whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
16
+ * at the front of the file. A list of an agent's tools that is actually a list of test doubles is
17
+ * worse than no list.
18
+ *
19
+ * `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
20
+ */
21
+ 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;
22
+ export function scanAgent(root) {
23
+ const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
24
+ const tools = [];
25
+ const guards = [];
26
+ let emptyBudget = null;
27
+ let filesRead = 0;
28
+ for (const path of scan.files) {
29
+ const where = relative(root, path).replace(/\\/g, '/');
30
+ if (NOT_SHIPPED.test(where))
31
+ continue;
32
+ let text;
33
+ try {
34
+ text = readFileSync(path, 'utf8');
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ filesRead += 1;
40
+ tools.push(...toolsIn(where, text));
41
+ guards.push(...guardsIn(where, text));
42
+ if (!emptyBudget) {
43
+ const empty = budgetSetToNothing(text);
44
+ if (empty)
45
+ emptyBudget = { file: where, text: empty };
46
+ }
47
+ }
48
+ return { tools, guards, filesRead, emptyBudget };
49
+ }
50
+ const REACH_READS = {
51
+ destructive: 'cannot be undone',
52
+ deletes: 'deletes something',
53
+ pays: 'moves money',
54
+ sends: 'sends something out of the building',
55
+ 'runs-code': 'runs code or a shell command',
56
+ writes: 'changes stored data',
57
+ reads: 'reads only',
58
+ 'not-sure': '**not decided**',
59
+ };
60
+ export function renderAgent(scan, root) {
61
+ const { tools, guards, filesRead, emptyBudget } = scan;
62
+ const lines = ['# What this agent is allowed to do', ''];
63
+ if (tools.length === 0) {
64
+ return [
65
+ '# What this agent is allowed to do',
66
+ '',
67
+ `No tool definitions were found in ${filesRead} file(s).`,
68
+ '',
69
+ 'That is a fact about what was looked for, not a finding that this agent has no tools. These',
70
+ 'are the shapes read here:',
71
+ '',
72
+ ...FRAMEWORKS_READ.map((f) => `- ${f}`),
73
+ '',
74
+ 'An agent built another way, or one whose tools are assembled at run time from a registry, is',
75
+ 'invisible to this and is not reported as safe. Nothing here was sent anywhere; no model was',
76
+ 'called.',
77
+ ].join(NL);
78
+ }
79
+ const kinds = new Set(guards.map((g) => g.kind));
80
+ const risky = tools.filter((t) => IRREVERSIBLE.includes(t.reach));
81
+ const undecided = tools.filter((t) => t.reach === 'not-sure');
82
+ lines.push(`${tools.length} tool(s) in ${filesRead} file(s) read locally. No model was called and nothing left this machine.`, '');
83
+ // The lethal trifecta leads when it is present, because it outranks everything else here: the
84
+ // other findings are about what the agent may do by mistake, and this one is about what somebody
85
+ // else can make it do on purpose.
86
+ const trifecta = trifectaIn(tools);
87
+ if (trifecta.complete) {
88
+ 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.', '');
89
+ }
90
+ // The headline, because it is the sentence somebody has to act on.
91
+ if (risky.length > 0 && !kinds.has('asks-a-person')) {
92
+ lines.push(`**${risky.length} of them do something that cannot be undone, and nothing in this project stops to ask a person.**`, '');
93
+ }
94
+ else if (risky.length > 0) {
95
+ 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.`, '');
96
+ }
97
+ lines.push('## The tools', '', '| tool | reaches | where |', '| --- | --- | --- |');
98
+ for (const t of [...tools].sort((a, b) => rank(a.reach) - rank(b.reach) || a.name.localeCompare(b.name))) {
99
+ lines.push(`| \`${t.name}\` | ${REACH_READS[t.reach]} | ${t.file}:${t.line} |`);
100
+ }
101
+ lines.push('');
102
+ if (risky.length > 0) {
103
+ lines.push('## The ones that cannot be taken back', '');
104
+ for (const t of risky) {
105
+ lines.push(`- \`${t.name}\` — ${REACH_READS[t.reach]}, because ${t.why}. ${t.file}:${t.line}`);
106
+ }
107
+ lines.push('');
108
+ }
109
+ if (undecided.length > 0) {
110
+ 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}`), '');
111
+ }
112
+ lines.push('## The lethal trifecta', '');
113
+ if (trifecta.complete) {
114
+ lines.push('All three legs are present. Breaking any one of them breaks the attack:', '');
115
+ }
116
+ else {
117
+ const missing = Object.keys(trifecta.legs).filter((c) => trifecta.legs[c].length === 0);
118
+ 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.', '');
119
+ }
120
+ for (const capability of Object.keys(trifecta.legs)) {
121
+ const legTools = trifecta.legs[capability];
122
+ 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` : '')}`);
123
+ }
124
+ lines.push('');
125
+ lines.push('## What stands between them and the real world', '');
126
+ for (const kind of ['asks-a-person', 'dry-run', 'step-budget', 'tool-allow-list']) {
127
+ const found = guards.find((g) => g.kind === kind);
128
+ lines.push(found
129
+ ? `- **${kind}** — found at ${found.file}:${found.line}. ${GUARD_MEANS[kind]}.`
130
+ : `- **${kind}** — nothing found anywhere in this project. Without it: ${GUARD_PREVENTS[kind]}.`);
131
+ }
132
+ lines.push('');
133
+ if (emptyBudget) {
134
+ 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.', '');
135
+ }
136
+ 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.', '- **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.', '');
137
+ return lines.join(NL);
138
+ }
139
+ const rank = (r) => (IRREVERSIBLE.includes(r) ? 0 : r === 'not-sure' ? 1 : r === 'writes' ? 2 : 3);
140
+ export function runAgent(cmd) {
141
+ const scan = scanAgent(cmd.root);
142
+ const report = renderAgent(scan, cmd.root);
143
+ if (cmd.out)
144
+ writeFileSync(cmd.out, report, 'utf8');
145
+ else
146
+ console.log(report);
147
+ const asksAPerson = scan.guards.some((g) => g.kind === 'asks-a-person');
148
+ const ungated = asksAPerson ? 0 : scan.tools.filter((t) => IRREVERSIBLE.includes(t.reach)).length;
149
+ // The gate fails on the trifecta whether or not a person is asked: an approval step in front of
150
+ // one tool does not close a path that runs through three.
151
+ if (cmd.gate && (ungated > 0 || trifectaIn(scan.tools).complete))
152
+ process.exitCode = 1;
153
+ return { report, ungated };
154
+ }
@@ -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,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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.73.1",
3
+ "version": "0.74.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": [