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