miii-agent 0.1.7 → 0.1.8

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.
Files changed (3) hide show
  1. package/README.md +23 -3
  2. package/dist/cli.js +159 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # miii
2
2
 
3
+ > small · simple · smart · strategic · semantic
4
+ >
3
5
  > Your code never leaves your machine. No API keys. No cloud. No bullshit.
4
6
 
5
7
  **miii** is a local-first AI coding agent that lives in your terminal. Powered by [Ollama](https://ollama.com), it reads your code, writes features, runs tests, and fixes bugs — entirely on your hardware, at native speed.
@@ -10,6 +12,18 @@
10
12
 
11
13
  ---
12
14
 
15
+ ## The name
16
+
17
+ **miii** stands for five principles it's built around:
18
+
19
+ - **small** — tight codebase, no bloat. You can read the whole thing.
20
+ - **simple** — no API keys, no accounts, no config ceremony. Just run it.
21
+ - **smart** — decomposes problems and verifies its own work like an engineer.
22
+ - **strategic** — plans before it acts; tools are gated, paths are confined.
23
+ - **semantic** — works from the meaning of your code, not blind text matching.
24
+
25
+ ---
26
+
13
27
  ## Demo
14
28
 
15
29
  ![miii demo](demo.gif)
@@ -115,7 +129,7 @@ miii ships with a built-in tool suite the agent can invoke autonomously:
115
129
  | `grep` | Regex search across files |
116
130
  | `run_bash` | Execute shell commands |
117
131
 
118
- Every sensitive operation is gated by a permission system — you approve what the agent can touch.
132
+ Every sensitive operation is gated by a permission system — you approve what the agent can touch, and "always" approvals persist to `~/.miii/permissions.json` so you're never asked twice. File tools are confined to your working directory; `../` traversal and absolute paths outside it are rejected.
119
133
 
120
134
  ---
121
135
 
@@ -137,8 +151,9 @@ graph TD
137
151
 
138
152
  subgraph Agent ["Agent Layer"]
139
153
  AgentLoop -->|"chat request"| Adapter["Ollama Adapter\n(agent/adapter.ts)"]
140
- AgentLoop -->|"tool call"| ToolRegistry["Tool Registry\n(tools/registry.ts)"]
141
- AgentLoop -->|"permission check"| Policy["Permission Policy\n(permissions/policy.ts)"]
154
+ AgentLoop -->|"1. validate input"| Validate["Input Validator\n(tools/validate.ts)"]
155
+ AgentLoop -->|"2. permission check"| Policy["Permission Policy\n(permissions/policy.ts)"]
156
+ AgentLoop -->|"3. tool call"| ToolRegistry["Tool Registry\n(tools/registry.ts)"]
142
157
  AgentLoop -->|"events"| EventBus["Event Bus\n(hooks/bus.ts)"]
143
158
  end
144
159
 
@@ -149,6 +164,9 @@ graph TD
149
164
  ToolRegistry --> Glob["glob"]
150
165
  ToolRegistry --> Grep["grep"]
151
166
  ToolRegistry --> RunBash["run_bash"]
167
+ ReadFile -.-> Confine["Path Confinement\n(tools/paths.ts)"]
168
+ WriteFile -.-> Confine
169
+ EditFile -.-> Confine
152
170
  end
153
171
 
154
172
  Adapter -->|"HTTP streaming"| Ollama["Ollama\n(local LLM server)"]
@@ -159,9 +177,11 @@ graph TD
159
177
 
160
178
  subgraph Storage ["Local Storage"]
161
179
  Config["~/.miii/config.json\n(model, host, effort)"]
180
+ Rules["~/.miii/permissions.json\n(saved allow rules)"]
162
181
  end
163
182
 
164
183
  App -.->|"reads"| Config
184
+ Policy -.->|"reads / persists 'always'"| Rules
165
185
  ```
166
186
 
167
187
  ---
package/dist/cli.js CHANGED
@@ -7,8 +7,8 @@ import { createElement } from "react";
7
7
  // src/ui/App.tsx
8
8
  import { useState as useState4, useEffect as useEffect3 } from "react";
9
9
  import { Box as Box10, Text as Text10, useApp } from "ink";
10
- import { homedir as homedir3 } from "os";
11
- import { sep } from "path";
10
+ import { homedir as homedir4 } from "os";
11
+ import { sep as sep2 } from "path";
12
12
 
13
13
  // src/ollama/client.ts
14
14
  import { Ollama } from "ollama";
@@ -549,8 +549,8 @@ function searchFiles(cwd, query) {
549
549
  scored.sort((a, b) => a[0] - b[0] || a[1].length - b[1].length);
550
550
  return scored.slice(0, MAX_RESULTS).map(([, f]) => f);
551
551
  }
552
- function FilePicker({ matches, cursor }) {
553
- if (matches.length === 0) return null;
552
+ function FilePicker({ matches: matches2, cursor }) {
553
+ if (matches2.length === 0) return null;
554
554
  return /* @__PURE__ */ jsxs7(
555
555
  Box7,
556
556
  {
@@ -561,7 +561,7 @@ function FilePicker({ matches, cursor }) {
561
561
  marginBottom: 0,
562
562
  paddingX: 1,
563
563
  children: [
564
- matches.map((f, i) => {
564
+ matches2.map((f, i) => {
565
565
  const active = i === cursor;
566
566
  return /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsxs7(Text7, { bold: active, color: active ? "blue" : void 0, dimColor: !active, children: [
567
567
  active ? "\u276F " : " ",
@@ -846,6 +846,7 @@ function PermissionPrompt({ req, cursor }) {
846
846
  const label = TOOL_LABEL[req.toolName] ?? req.toolName;
847
847
  const options = [
848
848
  { label: "Yes", key: "yes" },
849
+ { label: "Yes, don't ask again for this", key: "always" },
849
850
  { label: "No", key: "no" }
850
851
  ];
851
852
  const summary = summarizeInput(req.input);
@@ -914,6 +915,23 @@ import { useState as useState3, useRef } from "react";
914
915
 
915
916
  // src/tools/edit_file.ts
916
917
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
918
+
919
+ // src/tools/paths.ts
920
+ import { resolve, relative as relative2, isAbsolute, sep } from "path";
921
+ function confinePath(p) {
922
+ if (typeof p !== "string" || p.length === 0) {
923
+ throw new Error("Path is required.");
924
+ }
925
+ const root = process.cwd();
926
+ const abs = resolve(root, p);
927
+ const rel = relative2(root, abs);
928
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
929
+ throw new Error(`Path "${p}" is outside the working directory (${root}). Access denied.`);
930
+ }
931
+ return abs;
932
+ }
933
+
934
+ // src/tools/edit_file.ts
917
935
  var edit_file = {
918
936
  name: "edit_file",
919
937
  description: "Replace an exact string in a file. old_str must be unique.",
@@ -927,16 +945,21 @@ var edit_file = {
927
945
  required: ["path", "old_str", "new_str"]
928
946
  },
929
947
  handler: ({ path, old_str, new_str }) => {
930
- const src = readFileSync3(path, "utf-8");
931
- const first = src.indexOf(old_str);
932
- if (first === -1) {
933
- return { content: `old_str not found in ${path}`, is_error: true };
934
- }
935
- if (src.indexOf(old_str, first + 1) !== -1) {
936
- return { content: `old_str not unique in ${path}`, is_error: true };
948
+ try {
949
+ const abs = confinePath(path);
950
+ const src = readFileSync3(abs, "utf-8");
951
+ const first = src.indexOf(old_str);
952
+ if (first === -1) {
953
+ return { content: `old_str not found in ${path}`, is_error: true };
954
+ }
955
+ if (src.indexOf(old_str, first + 1) !== -1) {
956
+ return { content: `old_str not unique in ${path}`, is_error: true };
957
+ }
958
+ writeFileSync3(abs, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
959
+ return { content: `Edited ${path}` };
960
+ } catch (err) {
961
+ return { content: err instanceof Error ? err.message : String(err), is_error: true };
937
962
  }
938
- writeFileSync3(path, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
939
- return { content: `Edited ${path}` };
940
963
  }
941
964
  };
942
965
 
@@ -955,7 +978,7 @@ var read_file = {
955
978
  handler: ({ path }) => {
956
979
  try {
957
980
  const MAX = 2e5;
958
- const raw = readFileSync4(path, "utf-8");
981
+ const raw = readFileSync4(confinePath(path), "utf-8");
959
982
  const truncated = raw.length > MAX;
960
983
  const body = truncated ? raw.slice(0, MAX) + `
961
984
  [truncated: ${raw.length - MAX} more chars]` : raw;
@@ -982,8 +1005,9 @@ var write_file = {
982
1005
  },
983
1006
  handler: ({ path, content }) => {
984
1007
  try {
985
- mkdirSync3(dirname(path), { recursive: true });
986
- writeFileSync4(path, content, "utf-8");
1008
+ const abs = confinePath(path);
1009
+ mkdirSync3(dirname(abs), { recursive: true });
1010
+ writeFileSync4(abs, content, "utf-8");
987
1011
  return { content: `Wrote ${path} (${content.length} bytes)` };
988
1012
  } catch (err) {
989
1013
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
@@ -1161,6 +1185,42 @@ function toOllamaTools(tools = TOOLS) {
1161
1185
  }));
1162
1186
  }
1163
1187
 
1188
+ // src/tools/validate.ts
1189
+ import { z } from "zod";
1190
+ function propSchema(spec) {
1191
+ if (spec.enum && spec.enum.length) return z.enum(spec.enum);
1192
+ switch (spec.type) {
1193
+ case "string":
1194
+ return z.string();
1195
+ case "number":
1196
+ return z.number();
1197
+ case "integer":
1198
+ return z.number().int();
1199
+ case "boolean":
1200
+ return z.boolean();
1201
+ case "array":
1202
+ return z.array(z.unknown());
1203
+ case "object":
1204
+ return z.record(z.unknown());
1205
+ default:
1206
+ return z.unknown();
1207
+ }
1208
+ }
1209
+ function toZod(schema) {
1210
+ const required = new Set(schema.required ?? []);
1211
+ const shape = {};
1212
+ for (const [key, spec] of Object.entries(schema.properties)) {
1213
+ shape[key] = required.has(key) ? propSchema(spec) : z.unknown().optional();
1214
+ }
1215
+ return z.object(shape).passthrough();
1216
+ }
1217
+ function validateInput(schema, input) {
1218
+ const result = toZod(schema).safeParse(input ?? {});
1219
+ if (result.success) return null;
1220
+ const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1221
+ return `Invalid arguments: ${issues}`;
1222
+ }
1223
+
1164
1224
  // src/prompt/system.ts
1165
1225
  function buildSystemPrompt(tools, cwd) {
1166
1226
  const toolLines = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
@@ -1235,16 +1295,65 @@ ${toolLines}
1235
1295
  - Treat a green test run or a successful command as the completion signal. If it fails, fix and re-run.
1236
1296
 
1237
1297
  # Permissions
1238
- - When a new bash command pattern, file path, or glob pattern is needed, ask the user once; on approval it persists as a Tool(pattern) rule (e.g. Bash(npm test *), WriteFile(src/*)).
1298
+ - File tools are confined to the working directory; paths outside it are denied.
1299
+ - Each tool call may prompt the user for approval. If they choose "don't ask again", the exact command or path is persisted to ~/.miii/permissions.json and the same call is auto-allowed thereafter.
1239
1300
  `;
1240
1301
  }
1241
1302
 
1242
1303
  // src/permissions/policy.ts
1243
- var DEFAULT_ALLOW = /* @__PURE__ */ new Set(["read_file"]);
1304
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync3, renameSync } from "fs";
1305
+ import { join as join4 } from "path";
1306
+ import { homedir as homedir3 } from "os";
1307
+ var RULES_DIR = join4(homedir3(), ".miii");
1308
+ var RULES_PATH = join4(RULES_DIR, "permissions.json");
1309
+ function loadRules() {
1310
+ if (!existsSync3(RULES_PATH)) return [];
1311
+ try {
1312
+ const data = JSON.parse(readFileSync5(RULES_PATH, "utf-8"));
1313
+ return Array.isArray(data.rules) ? data.rules : [];
1314
+ } catch {
1315
+ return [];
1316
+ }
1317
+ }
1318
+ function saveRules(rules) {
1319
+ mkdirSync4(RULES_DIR, { recursive: true });
1320
+ const tmp = RULES_PATH + ".tmp";
1321
+ writeFileSync5(tmp, JSON.stringify({ rules }, null, 2), "utf-8");
1322
+ renameSync(tmp, RULES_PATH);
1323
+ }
1324
+ function addRule(tool, pattern) {
1325
+ const rules = loadRules();
1326
+ if (rules.some((r) => r.tool === tool && r.pattern === pattern)) return;
1327
+ rules.push({ tool, pattern });
1328
+ saveRules(rules);
1329
+ }
1330
+ function subjectFor(toolName, input) {
1331
+ const obj = input ?? {};
1332
+ if (toolName === "run_bash") return typeof obj.command === "string" ? obj.command : "";
1333
+ if (typeof obj.path === "string") return obj.path;
1334
+ return "";
1335
+ }
1336
+ function globToRegExp(glob2) {
1337
+ const escaped = glob2.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1338
+ const pattern = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
1339
+ return new RegExp(`^${pattern}$`);
1340
+ }
1341
+ function matches(rule, toolName, subject) {
1342
+ if (rule.tool !== toolName) return false;
1343
+ try {
1344
+ return globToRegExp(rule.pattern).test(subject);
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ }
1244
1349
  async function check(toolName, input, ctx) {
1245
- if (DEFAULT_ALLOW.has(toolName)) return "allow";
1350
+ const subject = subjectFor(toolName, input);
1351
+ const rules = loadRules();
1352
+ if (rules.some((r) => matches(r, toolName, subject))) return "allow";
1246
1353
  const answer = await ctx.ask(toolName, input);
1247
- return answer === "no" ? "deny" : "allow";
1354
+ if (answer === "no") return "deny";
1355
+ if (answer === "always") addRule(toolName, subject);
1356
+ return "allow";
1248
1357
  }
1249
1358
 
1250
1359
  // src/agent/adapter.ts
@@ -1494,6 +1603,18 @@ async function* runAgent(opts) {
1494
1603
  yield { type: "tool-result", block: r2 };
1495
1604
  continue;
1496
1605
  }
1606
+ const invalid = validateInput(tool.input_schema, use.input);
1607
+ if (invalid) {
1608
+ const r2 = {
1609
+ type: "tool_result",
1610
+ tool_use_id: use.id,
1611
+ content: `${invalid} for ${use.name}.`,
1612
+ is_error: true
1613
+ };
1614
+ results.push(r2);
1615
+ yield { type: "tool-result", block: r2 };
1616
+ continue;
1617
+ }
1497
1618
  const decision = await check(use.name, use.input, permissions);
1498
1619
  if (decision === "deny") {
1499
1620
  const r2 = {
@@ -1507,7 +1628,10 @@ async function* runAgent(opts) {
1507
1628
  yield { type: "tool-result", block: r2 };
1508
1629
  continue;
1509
1630
  }
1510
- await hooks?.firePre(use);
1631
+ try {
1632
+ await hooks?.firePre(use);
1633
+ } catch {
1634
+ }
1511
1635
  let r;
1512
1636
  try {
1513
1637
  const out = await tool.handler(use.input);
@@ -1525,7 +1649,10 @@ async function* runAgent(opts) {
1525
1649
  is_error: true
1526
1650
  };
1527
1651
  }
1528
- await hooks?.firePost(use, r);
1652
+ try {
1653
+ await hooks?.firePost(use, r);
1654
+ } catch {
1655
+ }
1529
1656
  results.push(r);
1530
1657
  yield { type: "tool-result", block: r };
1531
1658
  }
@@ -1556,8 +1683,8 @@ function useAgentRunner(model, activeCtx) {
1556
1683
  const abortRef = useRef(null);
1557
1684
  const pendingPermissionRef = useRef(null);
1558
1685
  function askPermission(toolName, input) {
1559
- return new Promise((resolve) => {
1560
- const req = { toolName, input, resolve };
1686
+ return new Promise((resolve2) => {
1687
+ const req = { toolName, input, resolve: resolve2 };
1561
1688
  pendingPermissionRef.current = req;
1562
1689
  setPermissionCursor(0);
1563
1690
  setPendingPermission(req);
@@ -1566,7 +1693,7 @@ function useAgentRunner(model, activeCtx) {
1566
1693
  function resolvePermission(cursor) {
1567
1694
  const req = pendingPermissionRef.current;
1568
1695
  if (!req) return;
1569
- const answers = ["yes", "no"];
1696
+ const answers = ["yes", "always", "no"];
1570
1697
  pendingPermissionRef.current = null;
1571
1698
  setPendingPermission(null);
1572
1699
  req.resolve(answers[cursor]);
@@ -1902,7 +2029,7 @@ function useKeyboard(opts) {
1902
2029
  return;
1903
2030
  }
1904
2031
  if (key.downArrow) {
1905
- setPermissionCursor((i) => Math.min(1, i + 1));
2032
+ setPermissionCursor((i) => Math.min(2, i + 1));
1906
2033
  return;
1907
2034
  }
1908
2035
  if (key.return) {
@@ -1914,7 +2041,7 @@ function useKeyboard(opts) {
1914
2041
  if (state === "ready") {
1915
2042
  if (busyRef.current) return;
1916
2043
  const paletteOpen = input.startsWith("/");
1917
- const matches = paletteOpen ? filteredCommands(input) : [];
2044
+ const matches2 = paletteOpen ? filteredCommands(input) : [];
1918
2045
  const mention = !paletteOpen ? parseMention(input) : null;
1919
2046
  const fileMatches = mention ? searchFiles(process.cwd(), mention.query) : [];
1920
2047
  const fileOpen = mention !== null && fileMatches.length > 0;
@@ -1923,11 +2050,11 @@ function useKeyboard(opts) {
1923
2050
  return;
1924
2051
  }
1925
2052
  if (paletteOpen && key.downArrow) {
1926
- setPaletteCursor((i) => Math.min(matches.length - 1, i + 1));
2053
+ setPaletteCursor((i) => Math.min(matches2.length - 1, i + 1));
1927
2054
  return;
1928
2055
  }
1929
- if (paletteOpen && (key.tab || key.return) && matches[paletteCursor] && input !== matches[paletteCursor].name) {
1930
- setInput(() => matches[paletteCursor].name);
2056
+ if (paletteOpen && (key.tab || key.return) && matches2[paletteCursor] && input !== matches2[paletteCursor].name) {
2057
+ setInput(() => matches2[paletteCursor].name);
1931
2058
  setPaletteCursor(() => 0);
1932
2059
  return;
1933
2060
  }
@@ -2046,7 +2173,7 @@ async function checkForUpdate() {
2046
2173
  import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
2047
2174
  function App() {
2048
2175
  const { exit } = useApp();
2049
- const cwd = process.cwd().replace(homedir3(), "~").split(sep).join("/");
2176
+ const cwd = process.cwd().replace(homedir4(), "~").split(sep2).join("/");
2050
2177
  const [cfg, setCfg] = useState4(loadConfig());
2051
2178
  const [models, setModels] = useState4([]);
2052
2179
  const [contexts, setContexts] = useState4({});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Terminal AI coding agent powered by Ollama",
5
5
  "type": "module",
6
6
  "bin": {