tewip-ai 0.74.0 → 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 CHANGED
@@ -1244,6 +1244,14 @@ offline, in CI, before anything runs.
1244
1244
  text the agent did not write can tell it to read the private thing and post it somewhere, and
1245
1245
  every step is a tool it was given on purpose. A system prompt is not a defence, because whoever
1246
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.
1247
1255
  - **What stands in the way**: a human approval gate, a dry run, a step budget, a tool allow-list -
1248
1256
  and says plainly when nothing anywhere in the project has one. A ceiling written as
1249
1257
  `max_iterations=None` is reported apart from no ceiling at all, because it reads as deliberate.
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { relative } from 'node:path';
4
4
  import { scanSourceFiles } from '../walk.js';
5
5
  import { CAPABILITY_MEANS, FRAMEWORKS_READ, IRREVERSIBLE, toolsIn, trifectaIn } from './tools.js';
6
+ import { promptFlows } from './prompt-flow.js';
6
7
  import { budgetSetToNothing, guardsIn, GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
7
8
  const NL = String.fromCharCode(10);
8
9
  const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
@@ -22,6 +23,7 @@ const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|ex
22
23
  export function scanAgent(root) {
23
24
  const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
24
25
  const tools = [];
26
+ const injections = [];
25
27
  const guards = [];
26
28
  let emptyBudget = null;
27
29
  let filesRead = 0;
@@ -38,6 +40,7 @@ export function scanAgent(root) {
38
40
  }
39
41
  filesRead += 1;
40
42
  tools.push(...toolsIn(where, text));
43
+ injections.push(...promptFlows(where, text));
41
44
  guards.push(...guardsIn(where, text));
42
45
  if (!emptyBudget) {
43
46
  const empty = budgetSetToNothing(text);
@@ -45,7 +48,7 @@ export function scanAgent(root) {
45
48
  emptyBudget = { file: where, text: empty };
46
49
  }
47
50
  }
48
- return { tools, guards, filesRead, emptyBudget };
51
+ return { tools, injections, guards, filesRead, emptyBudget };
49
52
  }
50
53
  const REACH_READS = {
51
54
  destructive: 'cannot be undone',
@@ -58,7 +61,7 @@ const REACH_READS = {
58
61
  'not-sure': '**not decided**',
59
62
  };
60
63
  export function renderAgent(scan, root) {
61
- const { tools, guards, filesRead, emptyBudget } = scan;
64
+ const { tools, injections, guards, filesRead, emptyBudget } = scan;
62
65
  const lines = ['# What this agent is allowed to do', ''];
63
66
  if (tools.length === 0) {
64
67
  return [
@@ -109,6 +112,19 @@ export function renderAgent(scan, root) {
109
112
  if (undecided.length > 0) {
110
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}`), '');
111
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
+ }
112
128
  lines.push('## The lethal trifecta', '');
113
129
  if (trifecta.complete) {
114
130
  lines.push('All three legs are present. Breaking any one of them breaks the attack:', '');
@@ -133,7 +149,7 @@ export function renderAgent(scan, root) {
133
149
  if (emptyBudget) {
134
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.', '');
135
151
  }
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.', '');
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.', '');
137
153
  return lines.join(NL);
138
154
  }
139
155
  const rank = (r) => (IRREVERSIBLE.includes(r) ? 0 : r === 'not-sure' ? 1 : r === 'writes' ? 2 : 3);
@@ -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
+ }
@@ -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
- /** A value is only dangerous here if it is glued into the string, not passed beside it. */
16
- function gluedIn(argument, expression) {
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.74.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": [