noah-agent 0.1.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/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/agent/auth-gate.js +23 -0
- package/dist/agent/caveman.js +44 -0
- package/dist/agent/login.js +59 -0
- package/dist/cli.js +130 -0
- package/dist/llm/ollama.js +32 -0
- package/dist/llm/providers.js +38 -0
- package/dist/llm/registry.js +19 -0
- package/dist/llm/resolve.js +44 -0
- package/dist/modes/rpc.js +13 -0
- package/dist/platform/adapter.js +47 -0
- package/dist/platform/detect.js +18 -0
- package/dist/platform/linux.js +61 -0
- package/dist/platform/macos.js +51 -0
- package/dist/platform/types.js +5 -0
- package/dist/prompt/system.js +52 -0
- package/dist/runtime.js +124 -0
- package/dist/safety/audit.js +46 -0
- package/dist/safety/confirm.js +17 -0
- package/dist/safety/extension.js +65 -0
- package/dist/safety/policy.js +100 -0
- package/dist/sdk.js +32 -0
- package/dist/session.js +113 -0
- package/dist/sys/health.js +51 -0
- package/dist/sys/probe.js +128 -0
- package/dist/sys/report.js +55 -0
- package/dist/tools/logs.js +24 -0
- package/dist/tools/network.js +47 -0
- package/dist/tools/package.js +40 -0
- package/dist/tools/service.js +45 -0
- package/dist/tools/system.js +33 -0
- package/dist/tui/app.js +104 -0
- package/dist/tui/branding.js +14 -0
- package/dist/tui/components/audit-line.js +37 -0
- package/dist/tui/components/header.js +33 -0
- package/dist/tui/components/noah-footer.js +33 -0
- package/dist/tui/components/request-panel.js +23 -0
- package/dist/tui/components/response-view.js +17 -0
- package/dist/tui/components/safety-block.js +31 -0
- package/dist/tui/components/safety-review.js +36 -0
- package/dist/tui/components/thinking-view.js +22 -0
- package/dist/tui/components/tool-card.js +45 -0
- package/dist/tui/components/util.js +3 -0
- package/dist/tui/preview.js +33 -0
- package/dist/tui/space/app.js +566 -0
- package/dist/tui/space/components.js +261 -0
- package/dist/tui/space/dashboard.js +63 -0
- package/dist/tui/space/theme.js +39 -0
- package/dist/ui/ansi.js +93 -0
- package/dist/ui/badge.js +31 -0
- package/dist/ui/box.js +61 -0
- package/dist/ui/preview.js +37 -0
- package/dist/ui/render.js +140 -0
- package/package.json +68 -0
- package/themes/noah-dark-blue.json +85 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Build the argv (after `sudo`) for a package action on a given manager. */
|
|
2
|
+
function pkgArgs(pm, action, pkg) {
|
|
3
|
+
const p = pkg ? [pkg] : [];
|
|
4
|
+
switch (pm) {
|
|
5
|
+
case "pacman": {
|
|
6
|
+
const flag = { install: "-S", remove: "-R", update: "-Syu" }[action];
|
|
7
|
+
return ["pacman", flag, "--noconfirm", ...p];
|
|
8
|
+
}
|
|
9
|
+
case "apt": {
|
|
10
|
+
const sub = { install: "install", remove: "remove", update: "upgrade" }[action];
|
|
11
|
+
return ["apt-get", sub, "-y", ...p];
|
|
12
|
+
}
|
|
13
|
+
case "dnf": {
|
|
14
|
+
const sub = { install: "install", remove: "remove", update: "upgrade" }[action];
|
|
15
|
+
return ["dnf", sub, "-y", ...p];
|
|
16
|
+
}
|
|
17
|
+
case "zypper": {
|
|
18
|
+
const sub = { install: "install", remove: "remove", update: "update" }[action];
|
|
19
|
+
return ["zypper", sub, "-y", ...p];
|
|
20
|
+
}
|
|
21
|
+
default:
|
|
22
|
+
return ["apt-get", "install", "-y", ...p];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** systemctl actions that change state need root; status is read-only. */
|
|
26
|
+
const MUTATING_SERVICE = new Set([
|
|
27
|
+
"start",
|
|
28
|
+
"stop",
|
|
29
|
+
"restart",
|
|
30
|
+
"enable",
|
|
31
|
+
"disable",
|
|
32
|
+
]);
|
|
33
|
+
export function makeLinuxAdapter(pm, sh) {
|
|
34
|
+
return {
|
|
35
|
+
os: `Linux (${pm})`,
|
|
36
|
+
pkgManager: pm,
|
|
37
|
+
pkg: (action, pkg) => sh("sudo", pkgArgs(pm, action, pkg)),
|
|
38
|
+
service: (name, action) => {
|
|
39
|
+
if (action === "status")
|
|
40
|
+
return sh("systemctl", ["status", name, "--no-pager"]);
|
|
41
|
+
if (MUTATING_SERVICE.has(action))
|
|
42
|
+
return sh("sudo", ["systemctl", action, name]);
|
|
43
|
+
return sh("systemctl", [action, name, "--no-pager"]);
|
|
44
|
+
},
|
|
45
|
+
logs: (unit) => sh("journalctl", [...(unit ? ["-u", unit] : []), "-n", "200", "--no-pager"]),
|
|
46
|
+
net: (action, target) => {
|
|
47
|
+
switch (action) {
|
|
48
|
+
case "info":
|
|
49
|
+
return sh("ip", ["-br", "addr"]);
|
|
50
|
+
case "ports":
|
|
51
|
+
return sh("ss", ["-tlnp"]);
|
|
52
|
+
case "connections":
|
|
53
|
+
return sh("ss", ["-tnp"]);
|
|
54
|
+
case "ping":
|
|
55
|
+
return sh("ping", ["-c", "4", target ?? ""]);
|
|
56
|
+
case "fetch":
|
|
57
|
+
return sh("curl", ["-sSL", "--max-time", "30", target ?? ""]);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function pkgArgs(action, pkg) {
|
|
2
|
+
const p = pkg ? [pkg] : [];
|
|
3
|
+
if (action === "update")
|
|
4
|
+
return ["upgrade", ...p];
|
|
5
|
+
if (action === "remove")
|
|
6
|
+
return ["uninstall", ...p];
|
|
7
|
+
return ["install", ...p];
|
|
8
|
+
}
|
|
9
|
+
export function makeMacosAdapter(sh) {
|
|
10
|
+
return {
|
|
11
|
+
os: "macOS",
|
|
12
|
+
pkgManager: "brew",
|
|
13
|
+
pkg: (action, pkg) => sh("brew", pkgArgs(action, pkg)),
|
|
14
|
+
service: async (name, action) => {
|
|
15
|
+
switch (action) {
|
|
16
|
+
case "start":
|
|
17
|
+
return sh("launchctl", ["start", name]);
|
|
18
|
+
case "stop":
|
|
19
|
+
return sh("launchctl", ["stop", name]);
|
|
20
|
+
case "restart":
|
|
21
|
+
await sh("launchctl", ["stop", name]);
|
|
22
|
+
return sh("launchctl", ["start", name]);
|
|
23
|
+
case "status":
|
|
24
|
+
return sh("launchctl", ["list", name]);
|
|
25
|
+
default:
|
|
26
|
+
// enable/disable map to bootstrap/bootout domains — out of scope.
|
|
27
|
+
return `'${action}' is not supported on macOS launchctl (use start/stop/restart/status).`;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
logs: (unit) => sh("log", [
|
|
31
|
+
"show",
|
|
32
|
+
"--last",
|
|
33
|
+
"5m",
|
|
34
|
+
...(unit ? ["--predicate", `process == "${unit}"`] : []),
|
|
35
|
+
]),
|
|
36
|
+
net: (action, target) => {
|
|
37
|
+
switch (action) {
|
|
38
|
+
case "info":
|
|
39
|
+
return sh("ifconfig", []);
|
|
40
|
+
case "ports":
|
|
41
|
+
return sh("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
|
|
42
|
+
case "connections":
|
|
43
|
+
return sh("netstat", ["-an"]);
|
|
44
|
+
case "ping":
|
|
45
|
+
return sh("ping", ["-c", "4", target ?? ""]);
|
|
46
|
+
case "fetch":
|
|
47
|
+
return sh("curl", ["-sSL", "--max-time", "30", target ?? ""]);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH system prompt.
|
|
3
|
+
*
|
|
4
|
+
* Authored in Pi's own system-prompt style (role line → Available tools →
|
|
5
|
+
* Guidelines → context), but with an OS-operator identity instead of Pi's
|
|
6
|
+
* "expert coding assistant". Installed via DefaultResourceLoader's
|
|
7
|
+
* `systemPromptOverride`, so it REPLACES Pi's base prompt.
|
|
8
|
+
*
|
|
9
|
+
* Pi appends the project context files, skills, current date, and working
|
|
10
|
+
* directory after this string — do NOT include those here.
|
|
11
|
+
*/
|
|
12
|
+
export const NOAH_SYSTEM_PROMPT = `You are NOAH (Native Operating-system Agentic Harness), an AI System Administrator that operates the user's operating system on their behalf. You understand natural language and control the machine — shell, files, packages, and services — across Linux and macOS, safely and autonomously.
|
|
13
|
+
|
|
14
|
+
You are not a blind command runner. You first understand the machine, analyze impact, recommend the best action, and only then execute with approval.
|
|
15
|
+
|
|
16
|
+
Available tools:
|
|
17
|
+
- read: Read the contents of a file
|
|
18
|
+
- bash: Execute a shell command on the host OS
|
|
19
|
+
- edit: Make a precise, exact-match edit to a file
|
|
20
|
+
- write: Create or overwrite a file
|
|
21
|
+
- grep: Search file contents for a pattern
|
|
22
|
+
- find: Find files by name or attributes
|
|
23
|
+
- ls: List directory contents
|
|
24
|
+
- package: Install, remove, or update OS packages via the native package manager (apt/dnf/pacman/zypper on Linux, brew on macOS)
|
|
25
|
+
- service: Start, stop, restart, enable, disable, or check a system service (systemd on Linux, launchd on macOS)
|
|
26
|
+
- network: Inspect networking (info/ports/connections/ping) and fetch URLs over HTTP
|
|
27
|
+
- system: Read live machine telemetry (OS, memory, disks, top processes, failed services)
|
|
28
|
+
- logs: Read recent system logs (journalctl / unified log), optionally for one unit
|
|
29
|
+
|
|
30
|
+
In addition to the tools above, you may have access to other custom tools depending on the system.
|
|
31
|
+
|
|
32
|
+
Operating doctrine (how an AI System Administrator works):
|
|
33
|
+
1. Understand — before installing, changing, or diagnosing, call the system tool (and logs when relevant) to read the machine's real state: OS, memory, disk, processes, services. Never guess what you can measure.
|
|
34
|
+
2. Analyze impact — state what the action will change, what it costs (disk/memory/network/privilege), what could go wrong, and a severity (low / medium / high).
|
|
35
|
+
3. Recommend — give the best action and any safer alternatives, grounded in the telemetry you read. For diagnostics, give root cause, severity, and prioritized fixes.
|
|
36
|
+
4. Execute with approval — only after the user agrees. The safety gate will confirm dangerous actions and block catastrophic ones.
|
|
37
|
+
For a request like "install X": first check disk/memory/existing install via the system tool, report compatibility and impact, recommend, then install and verify. For "why is it slow / free up space / how healthy is my machine": read telemetry, analyze, and answer with root cause + prioritized actions.
|
|
38
|
+
|
|
39
|
+
Guidelines:
|
|
40
|
+
- Plan first: for any multi-step or system-changing task, state a short numbered plan (one line per step), then carry it out step by step.
|
|
41
|
+
- Before installing, making, or changing anything, inspect the machine with the system tool so your recommendation is grounded in real data.
|
|
42
|
+
- Inspect before you change: prefer read-only commands to understand the system state before mutating it.
|
|
43
|
+
- Prefer the package tool over raw shell for installing, removing, or updating software.
|
|
44
|
+
- Prefer the service tool over raw shell for managing services.
|
|
45
|
+
- Use the platform-native way; do not assume a specific distro or package manager — the tools resolve the right backend per OS.
|
|
46
|
+
- Be concise. Report what you did and the outcome, not a play-by-play.
|
|
47
|
+
- Show file paths clearly when working with files.
|
|
48
|
+
|
|
49
|
+
Safety contract (enforced by NOAH, not optional):
|
|
50
|
+
- A safety gate outside your control may require the user to confirm dangerous actions (delete, install, network, privilege, service changes) or hard-block catastrophic ones (e.g. wiping a disk, recursively deleting root). If an action is blocked, stop and explain a safer alternative.
|
|
51
|
+
- Never attempt to bypass, disable, or trick the safety gate, and never instruct the user to do so.
|
|
52
|
+
- Assume every command you run is recorded to an audit trail. Act accountably.`;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH runtime — the single source of truth for how a NOAH agent is wired:
|
|
3
|
+
* OS tools + safety gate + caveman + system prompt + provider registry.
|
|
4
|
+
*
|
|
5
|
+
* Shared by every front-end (interactive TUI, print, RPC, SDK) so they all get
|
|
6
|
+
* identical behaviour. `noahSessionConfig` is pure (for tests); `buildNoahRuntime`
|
|
7
|
+
* and `createNoahSession` assemble live sessions from it.
|
|
8
|
+
*/
|
|
9
|
+
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { buildRegistry } from "./llm/registry.js";
|
|
11
|
+
import { resolveModel } from "./llm/resolve.js";
|
|
12
|
+
import { safetyExtension } from "./safety/extension.js";
|
|
13
|
+
import { packageTool } from "./tools/package.js";
|
|
14
|
+
import { serviceTool } from "./tools/service.js";
|
|
15
|
+
import { networkTool } from "./tools/network.js";
|
|
16
|
+
import { systemTool } from "./tools/system.js";
|
|
17
|
+
import { logsTool } from "./tools/logs.js";
|
|
18
|
+
import { NOAH_SYSTEM_PROMPT } from "./prompt/system.js";
|
|
19
|
+
import { cavemanExtension } from "./agent/caveman.js";
|
|
20
|
+
/** Built-in pi tools + NOAH abstract OS tools. */
|
|
21
|
+
export const NOAH_TOOLS = [
|
|
22
|
+
"read",
|
|
23
|
+
"bash",
|
|
24
|
+
"edit",
|
|
25
|
+
"write",
|
|
26
|
+
"grep",
|
|
27
|
+
"find",
|
|
28
|
+
"ls",
|
|
29
|
+
"package",
|
|
30
|
+
"service",
|
|
31
|
+
"network",
|
|
32
|
+
"system",
|
|
33
|
+
"logs",
|
|
34
|
+
];
|
|
35
|
+
export const NOAH_CUSTOM_TOOLS = [packageTool, serviceTool, networkTool, systemTool, logsTool];
|
|
36
|
+
/** Pure assembly of NOAH's session configuration. */
|
|
37
|
+
export function noahSessionConfig(opts) {
|
|
38
|
+
const getCaveman = opts.getCavemanLevel ?? (() => opts.caveman ?? "off");
|
|
39
|
+
return {
|
|
40
|
+
tools: NOAH_TOOLS,
|
|
41
|
+
customTools: NOAH_CUSTOM_TOOLS,
|
|
42
|
+
systemPromptOverride: () => NOAH_SYSTEM_PROMPT,
|
|
43
|
+
appendSystemPromptOverride: () => [],
|
|
44
|
+
extensionFactories: [
|
|
45
|
+
safetyExtension({ dryRun: opts.dryRun, autoYes: opts.autoYes, confirm: opts.confirm }),
|
|
46
|
+
cavemanExtension(getCaveman),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Build the provider registry and resolve the active model. */
|
|
51
|
+
export async function noahWiring(opts) {
|
|
52
|
+
const { authStorage, modelRegistry } = await buildRegistry();
|
|
53
|
+
const model = resolveModel(modelRegistry, {
|
|
54
|
+
flagModel: opts.model,
|
|
55
|
+
envModel: process.env.NOAH_MODEL,
|
|
56
|
+
});
|
|
57
|
+
return { authStorage, modelRegistry, model };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build a full AgentSessionRuntime (for RPC / session replacement). Reuses the
|
|
61
|
+
* same NOAH config so RPC behaves exactly like the interactive front-ends.
|
|
62
|
+
*/
|
|
63
|
+
export async function buildNoahRuntime(opts) {
|
|
64
|
+
if (opts.dryRun)
|
|
65
|
+
process.env.NOAH_DRY_RUN = "1";
|
|
66
|
+
const { authStorage, modelRegistry, model } = await noahWiring(opts);
|
|
67
|
+
const cfg = noahSessionConfig(opts);
|
|
68
|
+
const scopedModels = modelRegistry.getAvailable().map((m) => ({ model: m }));
|
|
69
|
+
return createAgentSessionRuntime(async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
70
|
+
const services = await createAgentSessionServices({
|
|
71
|
+
cwd,
|
|
72
|
+
authStorage,
|
|
73
|
+
modelRegistry,
|
|
74
|
+
resourceLoaderOptions: {
|
|
75
|
+
systemPromptOverride: cfg.systemPromptOverride,
|
|
76
|
+
appendSystemPromptOverride: cfg.appendSystemPromptOverride,
|
|
77
|
+
extensionFactories: cfg.extensionFactories,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const created = await createAgentSessionFromServices({
|
|
81
|
+
services,
|
|
82
|
+
sessionManager,
|
|
83
|
+
sessionStartEvent,
|
|
84
|
+
model: model,
|
|
85
|
+
scopedModels,
|
|
86
|
+
tools: cfg.tools,
|
|
87
|
+
customTools: cfg.customTools,
|
|
88
|
+
});
|
|
89
|
+
return { ...created, services, diagnostics: services.diagnostics };
|
|
90
|
+
}, { cwd: process.cwd(), agentDir: getAgentDir(), sessionManager: SessionManager.create(process.cwd()) });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* SDK entry — create a NOAH-wired AgentSession to embed in your own app.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* import { createNoahSession } from "noah-agent/sdk";
|
|
97
|
+
* const { session } = await createNoahSession({ dryRun: false, autoYes: false });
|
|
98
|
+
* await session.prompt("install htop and start it");
|
|
99
|
+
*/
|
|
100
|
+
export async function createNoahSession(opts) {
|
|
101
|
+
if (opts.dryRun)
|
|
102
|
+
process.env.NOAH_DRY_RUN = "1";
|
|
103
|
+
const { authStorage, modelRegistry, model } = await noahWiring(opts);
|
|
104
|
+
const cfg = noahSessionConfig(opts);
|
|
105
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
106
|
+
cwd: process.cwd(),
|
|
107
|
+
agentDir: getAgentDir(),
|
|
108
|
+
systemPromptOverride: cfg.systemPromptOverride,
|
|
109
|
+
appendSystemPromptOverride: cfg.appendSystemPromptOverride,
|
|
110
|
+
extensionFactories: cfg.extensionFactories,
|
|
111
|
+
});
|
|
112
|
+
await resourceLoader.reload();
|
|
113
|
+
const { session } = await createAgentSession({
|
|
114
|
+
resourceLoader,
|
|
115
|
+
model: model,
|
|
116
|
+
authStorage,
|
|
117
|
+
modelRegistry,
|
|
118
|
+
scopedModels: modelRegistry.getAvailable().map((m) => ({ model: m })),
|
|
119
|
+
tools: cfg.tools,
|
|
120
|
+
customTools: cfg.customTools,
|
|
121
|
+
sessionManager: opts.sessionManager ?? SessionManager.create(process.cwd()),
|
|
122
|
+
});
|
|
123
|
+
return { session, model, authStorage, modelRegistry };
|
|
124
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log — accountability. Every executed tool call appended as JSONL.
|
|
3
|
+
*/
|
|
4
|
+
import { appendFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
const AUDIT_PATH = join(process.cwd(), ".noah", "audit.jsonl");
|
|
7
|
+
export function appendAudit(entry) {
|
|
8
|
+
try {
|
|
9
|
+
mkdirSync(dirname(AUDIT_PATH), { recursive: true });
|
|
10
|
+
appendFileSync(AUDIT_PATH, JSON.stringify(entry) + "\n");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// never let auditing crash the agent
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function readAudit() {
|
|
17
|
+
if (!existsSync(AUDIT_PATH))
|
|
18
|
+
return [];
|
|
19
|
+
return readFileSync(AUDIT_PATH, "utf8")
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((l) => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(l);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
.filter((e) => e !== null);
|
|
31
|
+
}
|
|
32
|
+
export function printAuditLog() {
|
|
33
|
+
const entries = readAudit();
|
|
34
|
+
if (entries.length === 0) {
|
|
35
|
+
console.log("📜 No audit entries yet.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`📜 NOAH audit log (${entries.length} actions) — ${AUDIT_PATH}\n`);
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const status = e.ok ? "✅" : "❌";
|
|
41
|
+
const cmd = e.input && typeof e.input === "object" && "command" in e.input
|
|
42
|
+
? e.input.command
|
|
43
|
+
: JSON.stringify(e.input);
|
|
44
|
+
console.log(`${status} ${e.ts} [${e.tool}] ${cmd}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal confirmation — the SAFETY REVIEW centerpiece.
|
|
3
|
+
* Own readline (ctx.ui.confirm is a no-op in non-TUI CLI).
|
|
4
|
+
*/
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import * as ui from "../ui/render.js";
|
|
7
|
+
export async function confirmInTerminal(req) {
|
|
8
|
+
process.stdout.write("\n" + ui.safetyReview(req.command, req.reason, req.toolName) + "\n");
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(ui.approvePrompt(), (answer) => {
|
|
12
|
+
rl.close();
|
|
13
|
+
process.stdout.write("\n");
|
|
14
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { classify } from "./policy.js";
|
|
2
|
+
import { confirmInTerminal } from "./confirm.js";
|
|
3
|
+
import { appendAudit } from "./audit.js";
|
|
4
|
+
import * as ui from "../ui/render.js";
|
|
5
|
+
function commandOf(input) {
|
|
6
|
+
if (input && typeof input === "object" && "command" in input) {
|
|
7
|
+
return String(input.command ?? "");
|
|
8
|
+
}
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
11
|
+
export const safetyExtension = (opts) => (pi) => {
|
|
12
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
13
|
+
const command = commandOf(event.input);
|
|
14
|
+
const verdict = classify(event.toolName, event.input);
|
|
15
|
+
// In the TUI/RPC, Pi renders the block/decline itself; only paint our own
|
|
16
|
+
// panels in plain-CLI mode (no dialog UI).
|
|
17
|
+
const hasUI = !!ctx?.hasUI;
|
|
18
|
+
// 1) Catastrophic → hard block, no override.
|
|
19
|
+
if (verdict.action === "deny") {
|
|
20
|
+
if (!hasUI) {
|
|
21
|
+
process.stdout.write("\n" + ui.safetyBlock(command || event.toolName, verdict.reason) + "\n");
|
|
22
|
+
}
|
|
23
|
+
return { block: true, reason: verdict.reason };
|
|
24
|
+
}
|
|
25
|
+
// 2) Dry-run → neutralise ALL side effects, keep the plan visible.
|
|
26
|
+
if (opts.dryRun) {
|
|
27
|
+
if (event.toolName === "bash") {
|
|
28
|
+
// Rewrite to a harmless echo so the step is shown but does nothing.
|
|
29
|
+
const safe = command.replace(/"/g, '\\"');
|
|
30
|
+
event.input.command = command
|
|
31
|
+
? `echo "[DRY-RUN] would run: ${safe}"`
|
|
32
|
+
: `echo "[DRY-RUN] bash"`;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
// Any non-bash mutating tool (write/edit/package/service) must not execute.
|
|
36
|
+
if (verdict.action !== "allow") {
|
|
37
|
+
return {
|
|
38
|
+
block: true,
|
|
39
|
+
reason: `[DRY-RUN] would ${event.toolName}${command ? `: ${command}` : ""} — skipped (no changes made)`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return undefined; // read-only tools are safe to run in dry-run
|
|
43
|
+
}
|
|
44
|
+
// 3) Dangerous → require explicit confirmation.
|
|
45
|
+
if (verdict.action === "confirm" && !opts.autoYes) {
|
|
46
|
+
const req = { toolName: event.toolName, command, reason: verdict.reason };
|
|
47
|
+
const ok = opts.confirm
|
|
48
|
+
? await opts.confirm(req)
|
|
49
|
+
: hasUI
|
|
50
|
+
? await ctx.ui.confirm("NOAH safety review", `${verdict.reason}\n\n${event.toolName}${command ? `: ${command}` : ""}`)
|
|
51
|
+
: await confirmInTerminal(req);
|
|
52
|
+
if (!ok)
|
|
53
|
+
return { block: true, reason: "user declined" };
|
|
54
|
+
}
|
|
55
|
+
return undefined; // allow
|
|
56
|
+
});
|
|
57
|
+
pi.on("tool_result", (event) => {
|
|
58
|
+
appendAudit({
|
|
59
|
+
ts: new Date().toISOString(),
|
|
60
|
+
tool: event.toolName,
|
|
61
|
+
input: event.input,
|
|
62
|
+
ok: !event.isError,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety policy — the heart of the product.
|
|
3
|
+
*
|
|
4
|
+
* classify() decides the fate of every tool call BEFORE it runs:
|
|
5
|
+
* - deny : catastrophic. Hard-blocked, no override.
|
|
6
|
+
* - confirm : dangerous. Requires explicit user approval.
|
|
7
|
+
* - allow : safe. Runs freely.
|
|
8
|
+
*/
|
|
9
|
+
/** Catastrophic patterns — hard-blocked, never overridable. */
|
|
10
|
+
const BLOCKLIST = [
|
|
11
|
+
{ re: /\brm\s+-[a-z]*r[a-z]*f?\s+(\/|~|\$HOME)(\s|$)/i, reason: "recursive delete of root/home" },
|
|
12
|
+
{ re: /\brm\s+-[a-z]*f[a-z]*r?\s+(\/|~)(\s|$)/i, reason: "force delete of root/home" },
|
|
13
|
+
{ re: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: "fork bomb" },
|
|
14
|
+
{ re: /\bmkfs(\.\w+)?\b/i, reason: "filesystem format" },
|
|
15
|
+
{ re: /\bdd\b[^|]*\bof=\/dev\/(disk|sd|nvme|rdisk)/i, reason: "raw disk overwrite" },
|
|
16
|
+
{ re: />\s*\/dev\/(sd|disk|nvme|rdisk)/i, reason: "write to raw disk device" },
|
|
17
|
+
{ re: /\bchmod\s+-R\s+0*777\s+\/(\s|$)/i, reason: "recursive 777 on root" },
|
|
18
|
+
{ re: /\b(shutdown|reboot|halt|poweroff)\b/i, reason: "power state change" },
|
|
19
|
+
{ re: /\b(diskutil|gpt)\s+(erase|eraseDisk|eraseVolume|destroy)/i, reason: "disk erase" },
|
|
20
|
+
];
|
|
21
|
+
/** Dangerous indicators in a shell command — require confirmation. */
|
|
22
|
+
const DANGEROUS_CMD = [
|
|
23
|
+
// --- deletion ---
|
|
24
|
+
/\b(rm|rmdir|unlink|shred)\b/i,
|
|
25
|
+
/\bfind\b[^|]*-delete\b/i,
|
|
26
|
+
/\bfind\b[^|]*-exec\s+(rm|unlink|shred)\b/i,
|
|
27
|
+
/\btruncate\b/i,
|
|
28
|
+
// --- creation / file mutation ---
|
|
29
|
+
/\b(touch|mkdir|mktemp)\b/i,
|
|
30
|
+
/\b(cp|mv|ln|rsync|install)\b/i,
|
|
31
|
+
/\b(tee|dd|sed)\b.*-i/i, // in-place edits
|
|
32
|
+
/\b(tee|dd)\b/i,
|
|
33
|
+
// --- privilege / packages / network / services ---
|
|
34
|
+
/\bsudo\b/i,
|
|
35
|
+
/\b(brew|apt|apt-get|dnf|zypper|yum|port|snap|flatpak)\s+(install|remove|uninstall|upgrade|update|purge)/i,
|
|
36
|
+
/\bpacman\s+-[A-Za-z]*[SRU]/i, // pacman uses flags: -S install, -R remove, -Syu update
|
|
37
|
+
/\b(npm|pnpm|yarn|pip|pip3|gem|cargo|go|gh)\s+(install|add|remove|uninstall)/i,
|
|
38
|
+
/\b(curl|wget)\b/i, // network fetch
|
|
39
|
+
/\b(systemctl|launchctl|service)\b/i,
|
|
40
|
+
/\bkillall?\b/i,
|
|
41
|
+
/\b(chmod|chown|chgrp)\b/i,
|
|
42
|
+
/\bgit\s+(push|reset|clean|checkout\s+--)/i,
|
|
43
|
+
/>\s*\/etc\//i,
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Detect output redirection that writes to a real file (creates/overwrites).
|
|
47
|
+
* Ignores safe sinks (/dev/null, /dev/stdout, /dev/stderr) and fd dups (2>&1, >&2).
|
|
48
|
+
*/
|
|
49
|
+
function writesViaRedirect(cmd) {
|
|
50
|
+
const stripped = cmd
|
|
51
|
+
.replace(/\d*>>?\s*\/dev\/(null|stdout|stderr)/gi, "")
|
|
52
|
+
.replace(/&>>?\s*\/dev\/null/gi, "")
|
|
53
|
+
.replace(/\d*>&\d*/g, "");
|
|
54
|
+
return />>?/.test(stripped);
|
|
55
|
+
}
|
|
56
|
+
/** Tools that mutate state — confirm by default. */
|
|
57
|
+
const MUTATING_TOOLS = new Set(["write", "edit", "package", "service"]);
|
|
58
|
+
function getCommand(input) {
|
|
59
|
+
if (input && typeof input === "object" && "command" in input) {
|
|
60
|
+
return String(input.command ?? "");
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
export function classify(toolName, input) {
|
|
65
|
+
const command = getCommand(input);
|
|
66
|
+
// 1) Catastrophic — hard deny (applies to any command string).
|
|
67
|
+
if (command) {
|
|
68
|
+
for (const { re, reason } of BLOCKLIST) {
|
|
69
|
+
if (re.test(command))
|
|
70
|
+
return { action: "deny", reason: `blocked: ${reason}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 2) Bash with dangerous indicators or file-writing redirects — confirm.
|
|
74
|
+
if (toolName === "bash" && command) {
|
|
75
|
+
for (const re of DANGEROUS_CMD) {
|
|
76
|
+
if (re.test(command))
|
|
77
|
+
return { action: "confirm", reason: "potentially destructive command" };
|
|
78
|
+
}
|
|
79
|
+
if (writesViaRedirect(command)) {
|
|
80
|
+
return { action: "confirm", reason: "command writes to a file" };
|
|
81
|
+
}
|
|
82
|
+
return { action: "allow", reason: "read-only / safe command" };
|
|
83
|
+
}
|
|
84
|
+
// 3) Network tool — only `fetch` (HTTP GET) is risky; gate it (exfiltration).
|
|
85
|
+
if (toolName === "network") {
|
|
86
|
+
const action = input && typeof input === "object" && "action" in input
|
|
87
|
+
? String(input.action)
|
|
88
|
+
: "";
|
|
89
|
+
if (action === "fetch") {
|
|
90
|
+
return { action: "confirm", reason: "network fetch downloads from the internet" };
|
|
91
|
+
}
|
|
92
|
+
return { action: "allow", reason: "read-only network inspection" };
|
|
93
|
+
}
|
|
94
|
+
// 4) Mutating tools — confirm.
|
|
95
|
+
if (MUTATING_TOOLS.has(toolName)) {
|
|
96
|
+
return { action: "confirm", reason: `${toolName} modifies the system` };
|
|
97
|
+
}
|
|
98
|
+
// 5) Everything else (read, grep, find, ls) — allow.
|
|
99
|
+
return { action: "allow", reason: "safe tool" };
|
|
100
|
+
}
|
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH SDK — embed a NOAH-wired agent in your own app (in-process).
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { createNoahSession } from "noah-agent/sdk";
|
|
6
|
+
* const { session } = await createNoahSession({ dryRun: false, autoYes: false });
|
|
7
|
+
* session.subscribe((e) => {
|
|
8
|
+
* if (e.type === "message_update" && e.assistantMessageEvent.type === "text_delta")
|
|
9
|
+
* process.stdout.write(e.assistantMessageEvent.delta);
|
|
10
|
+
* });
|
|
11
|
+
* await session.prompt("install htop and start it");
|
|
12
|
+
* session.dispose();
|
|
13
|
+
*/
|
|
14
|
+
// Core entry points + shared config
|
|
15
|
+
export { createNoahSession, buildNoahRuntime, noahSessionConfig, noahWiring, NOAH_TOOLS, NOAH_CUSTOM_TOOLS, } from "./runtime.js";
|
|
16
|
+
// OS tools
|
|
17
|
+
export { packageTool } from "./tools/package.js";
|
|
18
|
+
export { serviceTool } from "./tools/service.js";
|
|
19
|
+
export { networkTool } from "./tools/network.js";
|
|
20
|
+
// Safety
|
|
21
|
+
export { classify } from "./safety/policy.js";
|
|
22
|
+
export { safetyExtension } from "./safety/extension.js";
|
|
23
|
+
export { appendAudit, readAudit } from "./safety/audit.js";
|
|
24
|
+
// Providers / models
|
|
25
|
+
export { buildRegistry } from "./llm/registry.js";
|
|
26
|
+
export { resolveModel } from "./llm/resolve.js";
|
|
27
|
+
// Token saver / extensions
|
|
28
|
+
export { cavemanExtension, cavemanInstruction, CAVEMAN_LEVELS, } from "./agent/caveman.js";
|
|
29
|
+
// Platform adapter
|
|
30
|
+
export { platform } from "./platform/adapter.js";
|
|
31
|
+
// Prompt
|
|
32
|
+
export { NOAH_SYSTEM_PROMPT } from "./prompt/system.js";
|