tewip-ai 0.74.0 → 0.76.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,22 @@ 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.
|
|
1255
|
+
- **What the coding agent installed here was permitted to do.** `.claude/settings.json` and
|
|
1256
|
+
`.mcp.json` are committed, anybody with write access can change them, and neither turns up in a
|
|
1257
|
+
review the way a source file does. Tewip names the approval gate switched off
|
|
1258
|
+
(`"defaultMode": "bypassPermissions"`), an allow entry that grants a shell with no constraint on
|
|
1259
|
+
the command, every tool server trusted without approval, a credential written into a committed
|
|
1260
|
+
file - located, never printed - and a boundary widened past the project. A `PreToolUse` hook
|
|
1261
|
+
counts as a real gate, because the harness enforces it on every call; a rule in a markdown file
|
|
1262
|
+
is a request to the model and does not.
|
|
1247
1263
|
- **What stands in the way**: a human approval gate, a dry run, a step budget, a tool allow-list -
|
|
1248
1264
|
and says plainly when nothing anywhere in the project has one. A ceiling written as
|
|
1249
1265
|
`max_iterations=None` is reported apart from no ceiling at all, because it reads as deliberate.
|
|
@@ -3,6 +3,8 @@ 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';
|
|
7
|
+
import { configSection, scanConfig } from './claude-config.js';
|
|
6
8
|
import { budgetSetToNothing, guardsIn, GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
|
|
7
9
|
const NL = String.fromCharCode(10);
|
|
8
10
|
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
@@ -22,6 +24,7 @@ const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|ex
|
|
|
22
24
|
export function scanAgent(root) {
|
|
23
25
|
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
24
26
|
const tools = [];
|
|
27
|
+
const injections = [];
|
|
25
28
|
const guards = [];
|
|
26
29
|
let emptyBudget = null;
|
|
27
30
|
let filesRead = 0;
|
|
@@ -38,6 +41,7 @@ export function scanAgent(root) {
|
|
|
38
41
|
}
|
|
39
42
|
filesRead += 1;
|
|
40
43
|
tools.push(...toolsIn(where, text));
|
|
44
|
+
injections.push(...promptFlows(where, text));
|
|
41
45
|
guards.push(...guardsIn(where, text));
|
|
42
46
|
if (!emptyBudget) {
|
|
43
47
|
const empty = budgetSetToNothing(text);
|
|
@@ -45,7 +49,7 @@ export function scanAgent(root) {
|
|
|
45
49
|
emptyBudget = { file: where, text: empty };
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
|
-
return { tools, guards, filesRead, emptyBudget };
|
|
52
|
+
return { tools, injections, guards, filesRead, emptyBudget, config: scanConfig(root) };
|
|
49
53
|
}
|
|
50
54
|
const REACH_READS = {
|
|
51
55
|
destructive: 'cannot be undone',
|
|
@@ -58,12 +62,13 @@ const REACH_READS = {
|
|
|
58
62
|
'not-sure': '**not decided**',
|
|
59
63
|
};
|
|
60
64
|
export function renderAgent(scan, root) {
|
|
61
|
-
const { tools, guards, filesRead, emptyBudget } = scan;
|
|
65
|
+
const { tools, injections, guards, filesRead, emptyBudget, config } = scan;
|
|
62
66
|
const lines = ['# What this agent is allowed to do', ''];
|
|
63
67
|
if (tools.length === 0) {
|
|
64
68
|
return [
|
|
65
69
|
'# What this agent is allowed to do',
|
|
66
70
|
'',
|
|
71
|
+
...configSection(config),
|
|
67
72
|
`No tool definitions were found in ${filesRead} file(s).`,
|
|
68
73
|
'',
|
|
69
74
|
'That is a fact about what was looked for, not a finding that this agent has no tools. These',
|
|
@@ -77,6 +82,12 @@ export function renderAgent(scan, root) {
|
|
|
77
82
|
].join(NL);
|
|
78
83
|
}
|
|
79
84
|
const kinds = new Set(guards.map((g) => g.kind));
|
|
85
|
+
// A permission list is an allow-list, and a PreToolUse hook is enforced on every call - both are
|
|
86
|
+
// the real thing rather than a pattern someone wrote in a prompt.
|
|
87
|
+
if (config.hasAllowList)
|
|
88
|
+
kinds.add('tool-allow-list');
|
|
89
|
+
if (config.hasEnforcedHook)
|
|
90
|
+
kinds.add('asks-a-person');
|
|
80
91
|
const risky = tools.filter((t) => IRREVERSIBLE.includes(t.reach));
|
|
81
92
|
const undecided = tools.filter((t) => t.reach === 'not-sure');
|
|
82
93
|
lines.push(`${tools.length} tool(s) in ${filesRead} file(s) read locally. No model was called and nothing left this machine.`, '');
|
|
@@ -109,6 +120,19 @@ export function renderAgent(scan, root) {
|
|
|
109
120
|
if (undecided.length > 0) {
|
|
110
121
|
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
122
|
}
|
|
123
|
+
if (injections.length > 0) {
|
|
124
|
+
const armed = injections.filter((i) => i.withTools);
|
|
125
|
+
lines.push('## Text somebody else wrote, glued into a prompt', '', armed.length > 0
|
|
126
|
+
? `${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.`
|
|
127
|
+
: `${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.`, '');
|
|
128
|
+
for (const i of injections.slice(0, 20)) {
|
|
129
|
+
lines.push(`- ${i.file}:${i.line} — ${i.origin}, in by ${i.through}${i.withTools ? ' — **and this function has tools**' : ''}`);
|
|
130
|
+
lines.push(` \`${i.evidence}\``);
|
|
131
|
+
}
|
|
132
|
+
if (injections.length > 20)
|
|
133
|
+
lines.push(`…and ${injections.length - 20} more`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
}
|
|
112
136
|
lines.push('## The lethal trifecta', '');
|
|
113
137
|
if (trifecta.complete) {
|
|
114
138
|
lines.push('All three legs are present. Breaking any one of them breaks the attack:', '');
|
|
@@ -122,6 +146,7 @@ export function renderAgent(scan, root) {
|
|
|
122
146
|
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
147
|
}
|
|
124
148
|
lines.push('');
|
|
149
|
+
lines.push(...configSection(config));
|
|
125
150
|
lines.push('## What stands between them and the real world', '');
|
|
126
151
|
for (const kind of ['asks-a-person', 'dry-run', 'step-budget', 'tool-allow-list']) {
|
|
127
152
|
const found = guards.find((g) => g.kind === kind);
|
|
@@ -133,7 +158,7 @@ export function renderAgent(scan, root) {
|
|
|
133
158
|
if (emptyBudget) {
|
|
134
159
|
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
160
|
}
|
|
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.', '');
|
|
161
|
+
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
162
|
return lines.join(NL);
|
|
138
163
|
}
|
|
139
164
|
const rank = (r) => (IRREVERSIBLE.includes(r) ? 0 : r === 'not-sure' ? 1 : r === 'writes' ? 2 : 3);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The permissions a coding agent was given in this repository, read from the files that grant them.
|
|
2
|
+
//
|
|
3
|
+
// `tools.ts` reads what an agent you wrote can call. This reads what an agent you *installed* was
|
|
4
|
+
// allowed to do here - Claude Code's `.claude/settings.json` and the project's `.mcp.json`, both of
|
|
5
|
+
// which are committed, both of which anybody with write access can change, and neither of which
|
|
6
|
+
// turns up in a code review the way a source file does.
|
|
7
|
+
//
|
|
8
|
+
// Worth saying plainly, because it is the whole reason this is a separate check: a rule written in
|
|
9
|
+
// a markdown file is a request to the model. A permission in settings.json is enforced by the
|
|
10
|
+
// harness on every tool call. They look equally like configuration and they are not the same kind
|
|
11
|
+
// of thing at all, and only one of them is a control an auditor can rely on.
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
// Spelled with forward slashes, which is how every other path in a Tewip report is spelled and
|
|
15
|
+
// how a reader will search for it. `join` gives a backslash on Windows and the report then names a
|
|
16
|
+
// file that does not look like the one in the repository.
|
|
17
|
+
const FILES = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json'];
|
|
18
|
+
/**
|
|
19
|
+
* An allow entry that grants a shell with no constraint on the command.
|
|
20
|
+
*
|
|
21
|
+
* `Bash(npm run test:*)` is a decision somebody made. `Bash`, `Bash(*)` and `Bash(*:*)` are the
|
|
22
|
+
* absence of one: they permit `rm -rf`, `curl | sh` and anything else, and they are what people
|
|
23
|
+
* write when the prompts became annoying.
|
|
24
|
+
*/
|
|
25
|
+
const UNCONSTRAINED = /^(?:Bash|Execute|Shell|Run)(?:\(\s*\*?\s*(?::\s*\*\s*)?\))?$/i;
|
|
26
|
+
/** A value written into a committed file that looks like a credential rather than a reference. */
|
|
27
|
+
const LOOKS_SECRET = /(?:KEY|TOKEN|SECRET|PASSWORD|PASS|CREDENTIAL|API)/i;
|
|
28
|
+
const read = (root, name) => {
|
|
29
|
+
const path = join(root, ...name.split('/'));
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(path, 'utf8');
|
|
34
|
+
const json = JSON.parse(raw);
|
|
35
|
+
return json && typeof json === 'object' ? { json, raw } : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// A file that does not parse is not a file that grants nothing, and saying so beats guessing.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const asList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
43
|
+
export function scanConfig(root) {
|
|
44
|
+
const out = { read: [], findings: [], servers: [], hasAllowList: false, hasEnforcedHook: false };
|
|
45
|
+
for (const name of FILES.slice(0, 2)) {
|
|
46
|
+
const file = read(root, name);
|
|
47
|
+
if (!file)
|
|
48
|
+
continue;
|
|
49
|
+
out.read.push(name);
|
|
50
|
+
const permissions = (file.json.permissions ?? {});
|
|
51
|
+
const allow = asList(permissions.allow);
|
|
52
|
+
const deny = asList(permissions.deny);
|
|
53
|
+
if (allow.length > 0 || deny.length > 0)
|
|
54
|
+
out.hasAllowList = true;
|
|
55
|
+
// The gate switched off outright. Nothing else in this file matters as much.
|
|
56
|
+
if (permissions.defaultMode === 'bypassPermissions') {
|
|
57
|
+
out.findings.push({
|
|
58
|
+
rule: 'permissions-bypassed',
|
|
59
|
+
file: name,
|
|
60
|
+
what: '"defaultMode": "bypassPermissions"',
|
|
61
|
+
why: 'every tool call here runs without asking, including the ones that delete, pay or send. This is the approval gate being switched off in a committed file.',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (permissions.defaultMode === 'acceptEdits') {
|
|
65
|
+
out.findings.push({
|
|
66
|
+
rule: 'edits-auto-accepted',
|
|
67
|
+
file: name,
|
|
68
|
+
what: '"defaultMode": "acceptEdits"',
|
|
69
|
+
why: 'file edits are applied without asking. Weaker than bypassing everything, and still a person out of the loop on every change to the working tree.',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const entry of allow) {
|
|
73
|
+
if (!UNCONSTRAINED.test(entry.trim()))
|
|
74
|
+
continue;
|
|
75
|
+
out.findings.push({
|
|
76
|
+
rule: 'shell-unconstrained',
|
|
77
|
+
file: name,
|
|
78
|
+
what: `"allow": [… "${entry}" …]`,
|
|
79
|
+
why: 'this permits any shell command, not a named one. `Bash(npm run test:*)` is a decision; this is the absence of one.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (file.json.enableAllProjectMcpServers === true) {
|
|
83
|
+
out.findings.push({
|
|
84
|
+
rule: 'mcp-auto-trusted',
|
|
85
|
+
file: name,
|
|
86
|
+
what: '"enableAllProjectMcpServers": true',
|
|
87
|
+
why: 'every server in .mcp.json is trusted without being approved, including any added later by anyone who can commit to this repository.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const env = (file.json.env ?? {});
|
|
91
|
+
for (const [key, value] of Object.entries(env)) {
|
|
92
|
+
// A reference to a variable is fine; a literal is the secret itself, in a committed file.
|
|
93
|
+
if (!LOOKS_SECRET.test(key) || typeof value !== 'string' || value === '' || value.startsWith('$'))
|
|
94
|
+
continue;
|
|
95
|
+
out.findings.push({
|
|
96
|
+
rule: 'secret-in-settings',
|
|
97
|
+
file: name,
|
|
98
|
+
what: `"env": { "${key}": … }`,
|
|
99
|
+
why: 'a credential written into a file that is committed. Tewip does not print the value; the point is that it is there.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const extra = asList(permissions.additionalDirectories);
|
|
103
|
+
if (extra.length > 0) {
|
|
104
|
+
out.findings.push({
|
|
105
|
+
rule: 'boundary-widened',
|
|
106
|
+
file: name,
|
|
107
|
+
what: `"additionalDirectories": [${extra.slice(0, 3).map((d) => `"${d}"`).join(', ')}]`,
|
|
108
|
+
why: 'the agent may read and write outside this project. Whatever is on the other end is inside the blast radius.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const hooks = (file.json.hooks ?? {});
|
|
112
|
+
// PreToolUse is the one that can refuse a call. The others observe it after the fact.
|
|
113
|
+
if (Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0)
|
|
114
|
+
out.hasEnforcedHook = true;
|
|
115
|
+
}
|
|
116
|
+
const mcp = read(root, '.mcp.json');
|
|
117
|
+
if (mcp) {
|
|
118
|
+
out.read.push('.mcp.json');
|
|
119
|
+
const servers = (mcp.json.mcpServers ?? {});
|
|
120
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
121
|
+
const url = typeof spec?.url === 'string' ? spec.url : '';
|
|
122
|
+
const command = typeof spec?.command === 'string' ? spec.command : '';
|
|
123
|
+
const args = asList(spec?.args).join(' ');
|
|
124
|
+
out.servers.push(url
|
|
125
|
+
? { name, where: 'somewhere else', detail: url.replace(/\/\/[^@]*@/, '//') }
|
|
126
|
+
: { name, where: 'this machine', detail: `${command} ${args}`.trim().slice(0, 90) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** What each rule stops, for a reader deciding which to act on first. */
|
|
132
|
+
export const CONFIG_MEANS = {
|
|
133
|
+
'permissions-bypassed': 'nothing is asked before any tool call',
|
|
134
|
+
'edits-auto-accepted': 'file edits are applied without asking',
|
|
135
|
+
'shell-unconstrained': 'any shell command is permitted',
|
|
136
|
+
'mcp-auto-trusted': 'tool servers are trusted without approval',
|
|
137
|
+
'secret-in-settings': 'a credential is committed',
|
|
138
|
+
'boundary-widened': 'the agent may work outside this project',
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* What the coding agent installed here was permitted to do.
|
|
142
|
+
*
|
|
143
|
+
* Rendered whether or not any tools were found, because the two are independent: a repository with
|
|
144
|
+
* no agent code of its own can still hand Claude Code a shell with no constraint on the command,
|
|
145
|
+
* and that is a finding about this repository either way.
|
|
146
|
+
*/
|
|
147
|
+
export function configSection(config) {
|
|
148
|
+
if (config.read.length === 0)
|
|
149
|
+
return [];
|
|
150
|
+
const lines = ['## What the coding agent here is permitted to do', '', `Read: ${config.read.map((f) => `\`${f}\``).join(', ')}.`, ''];
|
|
151
|
+
if (config.servers.length > 0) {
|
|
152
|
+
lines.push('Tool servers wired into this project:', '');
|
|
153
|
+
for (const s of config.servers)
|
|
154
|
+
lines.push(`- \`${s.name}\` — runs ${s.where}: \`${s.detail}\``);
|
|
155
|
+
lines.push('');
|
|
156
|
+
}
|
|
157
|
+
if (config.findings.length === 0) {
|
|
158
|
+
lines.push('Nothing in these files switches off an approval, grants an unconstrained shell, trusts a', 'server without approval or commits a credential.', '');
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
for (const f of config.findings) {
|
|
162
|
+
lines.push(`- **${f.rule}** — ${CONFIG_MEANS[f.rule] ?? ''}`, ` \`${f.what}\` in \`${f.file}\`. ${f.why}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('');
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
@@ -0,0 +1,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
|
-
/**
|
|
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.76.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": [
|