@phamvuhoang/otto-core 0.22.0 → 0.24.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.
@@ -0,0 +1,55 @@
1
+ import { type SafetyPolicy } from "./safety-policy.js";
2
+ import { type ToolConfig, type ToolDefinition, type ToolSelection } from "./tools.js";
3
+ /**
4
+ * Injectable host surface for {@link runTools} so the bin stays unit-testable
5
+ * without touching real cwd/env/stdio or spawning processes (mirrors
6
+ * `SkillsDeps`). `health` is injected so tests don't shell out.
7
+ */
8
+ export type ToolsDeps = {
9
+ env: NodeJS.ProcessEnv;
10
+ cwd: string;
11
+ out: (msg: string) => void;
12
+ err: (msg: string) => void;
13
+ /** Run a tool's health-check command; resolves to its availability. */
14
+ health: (tool: ToolDefinition) => Promise<{
15
+ ok: boolean;
16
+ detail: string;
17
+ }>;
18
+ };
19
+ /** Render the tool registry inventory. Pure. */
20
+ export declare function formatToolsList(tools: ToolDefinition[], config: ToolConfig): string;
21
+ /** One governance problem with the tool registry. */
22
+ export type ToolAuditFinding = {
23
+ kind: "unreachable" | "no-health-check" | "policy-conflict";
24
+ subject: string;
25
+ detail: string;
26
+ };
27
+ /**
28
+ * Audit the registry against config + safety policy. Flags tools that are
29
+ * enabled but reachable from no stage (`unreachable`), command/mcp/proxy/http
30
+ * tools with no way to verify availability (`no-health-check`), and tools whose
31
+ * declared scope a non-empty repo policy already forbids (`policy-conflict`),
32
+ * e.g. a network domain outside `allowedNetworkDomains`. Pure over its inputs.
33
+ */
34
+ export declare function auditTools(tools: ToolDefinition[], config: ToolConfig): ToolAuditFinding[];
35
+ /**
36
+ * Audit each tool's declared scope against a non-empty repo policy: a tool that
37
+ * declares a network domain or write root the policy forbids is a standing
38
+ * `policy-conflict` (the authorization layer would block every such invocation).
39
+ * Separated from {@link auditTools} so it can take the workspace policy. Pure.
40
+ */
41
+ export declare function auditToolPolicyConflicts(tools: ToolDefinition[], policy: SafetyPolicy): ToolAuditFinding[];
42
+ /** Render audit findings. Pure. */
43
+ export declare function formatToolsAudit(findings: ToolAuditFinding[]): string;
44
+ /** Render which tools are available for a stage and why. Pure. */
45
+ export declare function formatToolsWhy(stage: string, selections: ToolSelection[]): string;
46
+ /**
47
+ * Drive the read-only `otto-tools` operator command over `.otto/tools/`.
48
+ * Subcommands: `list` (default) inventories the registry + config state;
49
+ * `audit` flags governance problems (unreachable, missing health check, policy
50
+ * conflict); `why <stage>` shows which tools a stage may use and why; `health`
51
+ * runs each tool's health-check. Read-only — it never invokes a tool's actual
52
+ * adapter, only its health probe. Resolves to the process exit code.
53
+ */
54
+ export declare function runTools(argv: string[], deps?: ToolsDeps): Promise<number>;
55
+ //# sourceMappingURL=tools-cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools-cli.d.ts","sourceRoot":"","sources":["../src/tools-cli.ts"],"names":[],"mappings":"AAGA,OAAO,EAAoB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAML,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,YAAY,CAAC;AAEpB;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3B,uEAAuE;IACvE,MAAM,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5E,CAAC;AA2CF,gDAAgD;AAChD,wBAAgB,eAAe,CAC7B,KAAK,EAAE,cAAc,EAAE,EACvB,MAAM,EAAE,UAAU,GACjB,MAAM,CAiBR;AAED,qDAAqD;AACrD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,aAAa,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,cAAc,EAAE,EACvB,MAAM,EAAE,UAAU,GACjB,gBAAgB,EAAE,CAwBpB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,cAAc,EAAE,EACvB,MAAM,EAAE,YAAY,GACnB,gBAAgB,EAAE,CAyBpB;AAED,mCAAmC;AACnC,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,GAAG,MAAM,CAKrE;AAED,kEAAkE;AAClE,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,aAAa,EAAE,GAC1B,MAAM,CASR;AAED;;;;;;;GAOG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,GAAE,SAAuB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAyDjB"}
@@ -0,0 +1,196 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { readSafetyPolicy } from "./safety-policy.js";
4
+ import { authorizeToolInvocation, readToolConfig, readTools, selectToolsForStage, toolsDir, } from "./tools.js";
5
+ /** Default health probe: run `healthCheck` in a shell, ok = exit 0. */
6
+ function defaultHealth(tool) {
7
+ if (!tool.healthCheck) {
8
+ return Promise.resolve({ ok: false, detail: "no health check defined" });
9
+ }
10
+ const r = spawnSync(tool.healthCheck, {
11
+ shell: true,
12
+ timeout: tool.timeoutMs ?? 10_000,
13
+ encoding: "utf8",
14
+ });
15
+ if (r.status === 0)
16
+ return Promise.resolve({ ok: true, detail: "ok" });
17
+ const why = r.error?.message ??
18
+ (r.status === null ? "timed out / killed" : `exit ${r.status}`);
19
+ return Promise.resolve({ ok: false, detail: why });
20
+ }
21
+ const defaultDeps = {
22
+ env: process.env,
23
+ cwd: process.cwd(),
24
+ out: (m) => process.stdout.write(`${m}\n`),
25
+ err: (m) => process.stderr.write(`${m}\n`),
26
+ health: defaultHealth,
27
+ };
28
+ const USAGE = "Usage: otto-tools <list|audit|health>\n" + " otto-tools why <stage>";
29
+ function scopeLabel(tool) {
30
+ const parts = [];
31
+ if (tool.networkDomains.length)
32
+ parts.push(`net: ${tool.networkDomains.join(", ")}`);
33
+ if (tool.writeRoots.length)
34
+ parts.push(`write: ${tool.writeRoots.join(", ")}`);
35
+ if (tool.secretRefs.length)
36
+ parts.push(`secrets: ${tool.secretRefs.join(", ")}`);
37
+ return parts.length ? parts.join(" ") : "(no command/network/write scope)";
38
+ }
39
+ /** Render the tool registry inventory. Pure. */
40
+ export function formatToolsList(tools, config) {
41
+ if (tools.length === 0) {
42
+ return "No tools. A tool is a .otto/tools/<name>.json adapter definition. Existing runs are unaffected.";
43
+ }
44
+ const lines = [];
45
+ for (const t of tools) {
46
+ const ov = config.overrides[t.name];
47
+ const enabled = ov?.enabled ?? t.enabled;
48
+ const stages = ov?.stages ?? t.stages;
49
+ lines.push(`- ${t.name} [${t.kind}] ${enabled ? "enabled" : "disabled"} stages: ${stages.length ? stages.join(", ") : "(none)"}`);
50
+ if (t.capabilities.length)
51
+ lines.push(` capabilities: ${t.capabilities.join(", ")}`);
52
+ lines.push(` scope: ${scopeLabel(t)}`);
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+ /**
57
+ * Audit the registry against config + safety policy. Flags tools that are
58
+ * enabled but reachable from no stage (`unreachable`), command/mcp/proxy/http
59
+ * tools with no way to verify availability (`no-health-check`), and tools whose
60
+ * declared scope a non-empty repo policy already forbids (`policy-conflict`),
61
+ * e.g. a network domain outside `allowedNetworkDomains`. Pure over its inputs.
62
+ */
63
+ export function auditTools(tools, config) {
64
+ const findings = [];
65
+ for (const t of tools) {
66
+ const ov = config.overrides[t.name];
67
+ const enabled = ov?.enabled ?? t.enabled;
68
+ const stages = ov?.stages ?? t.stages;
69
+ if (enabled && stages.length === 0) {
70
+ findings.push({
71
+ kind: "unreachable",
72
+ subject: t.name,
73
+ detail: `tool "${t.name}" is enabled but has no stage allowlist — it can never be selected`,
74
+ });
75
+ }
76
+ if (!t.healthCheck && t.kind !== "sdk") {
77
+ findings.push({
78
+ kind: "no-health-check",
79
+ subject: t.name,
80
+ detail: `tool "${t.name}" (${t.kind}) has no healthCheck — availability cannot be verified`,
81
+ });
82
+ }
83
+ }
84
+ return findings.sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject));
85
+ }
86
+ /**
87
+ * Audit each tool's declared scope against a non-empty repo policy: a tool that
88
+ * declares a network domain or write root the policy forbids is a standing
89
+ * `policy-conflict` (the authorization layer would block every such invocation).
90
+ * Separated from {@link auditTools} so it can take the workspace policy. Pure.
91
+ */
92
+ export function auditToolPolicyConflicts(tools, policy) {
93
+ const findings = [];
94
+ for (const t of tools) {
95
+ for (const domain of t.networkDomains) {
96
+ const a = authorizeToolInvocation(policy, t, { domains: [domain] });
97
+ if (a.violations.some((v) => v.kind === "network-domain")) {
98
+ findings.push({
99
+ kind: "policy-conflict",
100
+ subject: t.name,
101
+ detail: `tool "${t.name}" declares domain "${domain}" the repo policy forbids`,
102
+ });
103
+ }
104
+ }
105
+ for (const root of t.writeRoots) {
106
+ const a = authorizeToolInvocation(policy, t, { writePaths: [root] });
107
+ if (a.violations.some((v) => v.kind === "write-root")) {
108
+ findings.push({
109
+ kind: "policy-conflict",
110
+ subject: t.name,
111
+ detail: `tool "${t.name}" declares write root "${root}" the repo policy forbids`,
112
+ });
113
+ }
114
+ }
115
+ }
116
+ return findings.sort((a, b) => a.subject.localeCompare(b.subject));
117
+ }
118
+ /** Render audit findings. Pure. */
119
+ export function formatToolsAudit(findings) {
120
+ if (findings.length === 0)
121
+ return "Tool registry clean (no findings).";
122
+ const lines = [`Tool registry findings (${findings.length}):`];
123
+ for (const f of findings)
124
+ lines.push(` - [${f.kind}] ${f.detail}`);
125
+ return lines.join("\n");
126
+ }
127
+ /** Render which tools are available for a stage and why. Pure. */
128
+ export function formatToolsWhy(stage, selections) {
129
+ if (selections.length === 0)
130
+ return "No tools registered.";
131
+ const lines = [`Tools for stage "${stage}":`];
132
+ for (const s of selections) {
133
+ lines.push(`- ${s.name} [${s.enabled ? "available" : "skip"}] ${s.reason}`);
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+ /**
138
+ * Drive the read-only `otto-tools` operator command over `.otto/tools/`.
139
+ * Subcommands: `list` (default) inventories the registry + config state;
140
+ * `audit` flags governance problems (unreachable, missing health check, policy
141
+ * conflict); `why <stage>` shows which tools a stage may use and why; `health`
142
+ * runs each tool's health-check. Read-only — it never invokes a tool's actual
143
+ * adapter, only its health probe. Resolves to the process exit code.
144
+ */
145
+ export async function runTools(argv, deps = defaultDeps) {
146
+ const arg = argv[0];
147
+ if (arg === "-h" || arg === "--help") {
148
+ deps.out(USAGE);
149
+ return 0;
150
+ }
151
+ const known = ["list", "audit", "why", "health"];
152
+ if (arg !== undefined && !known.includes(arg)) {
153
+ deps.err(`Unknown subcommand '${arg}'.\n${USAGE}`);
154
+ return 1;
155
+ }
156
+ const workspaceDir = resolve(deps.env.OTTO_WORKSPACE ?? deps.cwd);
157
+ const tools = readTools(workspaceDir);
158
+ const config = readToolConfig(workspaceDir);
159
+ if (arg === "why") {
160
+ const stage = argv[1];
161
+ if (!stage) {
162
+ deps.err(`why needs a stage name.\n${USAGE}`);
163
+ return 1;
164
+ }
165
+ deps.out(formatToolsWhy(stage, selectToolsForStage(tools, config, stage)));
166
+ return 0;
167
+ }
168
+ if (arg === "audit") {
169
+ const policy = readSafetyPolicy(workspaceDir);
170
+ const findings = [
171
+ ...auditTools(tools, config),
172
+ ...auditToolPolicyConflicts(tools, policy),
173
+ ].sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject));
174
+ deps.out(formatToolsAudit(findings));
175
+ return findings.length === 0 ? 0 : 1;
176
+ }
177
+ if (arg === "health") {
178
+ if (tools.length === 0) {
179
+ deps.out("No tools to health-check.");
180
+ return 0;
181
+ }
182
+ let allOk = true;
183
+ deps.out(`Tool health (${toolsDir(workspaceDir)})`);
184
+ for (const t of tools) {
185
+ const { ok, detail } = await deps.health(t);
186
+ if (!ok)
187
+ allOk = false;
188
+ deps.out(` ${ok ? "ok " : "FAIL"} ${t.name} ${detail}`);
189
+ }
190
+ return allOk ? 0 : 1;
191
+ }
192
+ deps.out(`Tools (${toolsDir(workspaceDir)})`);
193
+ deps.out(formatToolsList(tools, config));
194
+ return 0;
195
+ }
196
+ //# sourceMappingURL=tools-cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools-cli.js","sourceRoot":"","sources":["../src/tools-cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,164 @@
1
+ import { type PolicyViolation, type SafetyPolicy } from "./safety-policy.js";
2
+ import type { SafetyEvent } from "./run-report.js";
3
+ /**
4
+ * External tool authority layer (issue #111 P19). Otto exposes external tools —
5
+ * local services, MCP servers, HTTP/proxy adapters, SDK shims — to stages only
6
+ * through a typed, repo-local registry under `.otto/tools/<name>.json`, governed
7
+ * by `.otto/policy.json`. A tool is a *declared capability with scope*: which
8
+ * stages may use it, which commands/domains/write-roots/secrets it touches, and
9
+ * how to health-check it. Authority is the INTERSECTION of the tool's declared
10
+ * scope and the repo safety policy — a tool can only narrow, never widen, what a
11
+ * run may do.
12
+ *
13
+ * Pure fs + JSON with safe defaults (absent/malformed → no tools, never throws),
14
+ * mirroring `skills.ts`/`safety-policy.ts`. **Substrate, inert on the loop**: the
15
+ * registry, contract, authorization predicate, and `ToolUsage` evidence type
16
+ * exist and are exercised by the read-only `otto-tools` bin, but no stage invokes
17
+ * a tool this slice — the first consumer is the P20 Headroom adapter. A workspace
18
+ * with no `.otto/tools/` behaves exactly as today.
19
+ *
20
+ * **AFK invariant:** authority comes ONLY from this repo-local registry. Personal
21
+ * MCP/plugin config (Claude/Codex user config) is never inherited into a run —
22
+ * it is not reproducible and not repo-governed.
23
+ */
24
+ /** Transport kind an adapter speaks. */
25
+ export type ToolKind = "command" | "mcp" | "http" | "proxy" | "sdk";
26
+ /**
27
+ * One tool's adapter contract, stored as `.otto/tools/<name>.json`. Every scope
28
+ * field defaults to the most restrictive sensible value: `stages: []` means "no
29
+ * stage" (a tool must opt INTO stages), while the network/write/secret lists
30
+ * empty mean "touches none of that axis".
31
+ */
32
+ export type ToolDefinition = {
33
+ /** Filesystem-safe registry key; also the `<name>.json` basename. */
34
+ name: string;
35
+ kind: ToolKind;
36
+ description?: string;
37
+ /** Capability tags (a selection/`why` key). */
38
+ capabilities: string[];
39
+ /** Stage names this tool may run in; empty = none (opt-in required). */
40
+ stages: string[];
41
+ /** command/proxy: the executable + args template. */
42
+ command?: string;
43
+ /** Env var NAMES the adapter reads (values come from the host env, never inlined). */
44
+ env: string[];
45
+ /** Network domains the tool may reach — checked against policy + this list. */
46
+ networkDomains: string[];
47
+ /** Workspace-relative roots the tool may write — checked against policy + this list. */
48
+ writeRoots: string[];
49
+ /** Secret env-var names the tool needs; surfaced for audit, never inlined. */
50
+ secretRefs: string[];
51
+ /** Per-invocation timeout (ms); absent = adapter default. */
52
+ timeoutMs?: number;
53
+ /** Shell command that exits 0 when the tool is available/healthy. */
54
+ healthCheck?: string;
55
+ /** High-risk action names that require approval (joined with policy). */
56
+ approvalActions: string[];
57
+ /** Registry-level enable flag; config can additionally disable per stage. */
58
+ enabled: boolean;
59
+ };
60
+ /**
61
+ * The structured result an adapter returns (issue #111 output contract). Beyond
62
+ * the payload it carries the levers Otto's evidence model needs: estimated token
63
+ * savings (for compressors like Headroom), any safety events the invocation
64
+ * raised, and a durable retrieval handle when the output is a reversible
65
+ * transform of original content.
66
+ */
67
+ export type ToolResult = {
68
+ ok: boolean;
69
+ output?: unknown;
70
+ /** Estimated tokens saved (compression adapters); absent = not applicable. */
71
+ tokensSaved?: number;
72
+ safetyEvents?: SafetyEvent[];
73
+ /** Durable handle to retrieve the original/reversible content; absent = none. */
74
+ retrievalHandle?: string;
75
+ };
76
+ /** Per-tool override read from `.otto/config.json`'s `tools` block. */
77
+ export type ToolOverride = {
78
+ enabled?: boolean;
79
+ stages?: string[];
80
+ };
81
+ /** The `tools` block of `.otto/config.json`: per-tool enable/stage overrides. */
82
+ export type ToolConfig = {
83
+ overrides: Record<string, ToolOverride>;
84
+ };
85
+ /** Absolute path to the tools registry root (`.otto/tools`). */
86
+ export declare function toolsDir(workspaceDir: string): string;
87
+ /** Absolute path to one tool's definition (`.otto/tools/<name>.json`). */
88
+ export declare function toolPath(workspaceDir: string, name: string): string;
89
+ /**
90
+ * Normalize an untrusted parsed `<name>.json` into a {@link ToolDefinition},
91
+ * filling restrictive defaults. Returns null when it lacks a `name` (so a
92
+ * malformed file is skipped, not crashed on). `kind` defaults to `command`;
93
+ * `enabled` defaults to true; every scope list defaults to empty.
94
+ */
95
+ export declare function parseTool(raw: unknown): ToolDefinition | null;
96
+ /**
97
+ * Read every tool definition under `.otto/tools/` (the `*.json` files), skipping
98
+ * malformed ones, sorted by name. Absent/unreadable dir → `[]` (never throws).
99
+ * Reads ONLY the repo-local registry — personal MCP/plugin config is never
100
+ * consulted (the AFK invariant).
101
+ */
102
+ export declare function readTools(workspaceDir: string): ToolDefinition[];
103
+ /**
104
+ * Read the `tools` block of `.otto/config.json` (per-tool enable/stage
105
+ * overrides). Absent/malformed file or block → no overrides (never throws). Only
106
+ * the `tools` key is read; sibling config blocks (journal, branch) are untouched.
107
+ */
108
+ export declare function readToolConfig(workspaceDir: string): ToolConfig;
109
+ /** Whether a tool is enabled for `stage`, plus a human reason. Pure. */
110
+ export declare function toolEnabledForStage(tool: ToolDefinition, config: ToolConfig, stage: string): {
111
+ enabled: boolean;
112
+ reason: string;
113
+ };
114
+ /** One tool's availability verdict for a stage (the `why` surface). */
115
+ export type ToolSelection = {
116
+ name: string;
117
+ kind: ToolKind;
118
+ enabled: boolean;
119
+ reason: string;
120
+ capabilities: string[];
121
+ };
122
+ /**
123
+ * Rank tools for a stage: enabled-first, then name. Disabled tools are still
124
+ * returned (flagged, with the reason) so `otto-tools why <stage>` shows the full
125
+ * picture — including a tool that exists but is policy/stage-gated off. Pure.
126
+ */
127
+ export declare function selectToolsForStage(tools: ToolDefinition[], config: ToolConfig, stage: string): ToolSelection[];
128
+ /** A concrete tool action to authorize before it runs. */
129
+ export type ToolInvocation = {
130
+ /** Resolved shell command, if the adapter shells out. */
131
+ command?: string;
132
+ /** Domains the invocation would reach. */
133
+ domains?: string[];
134
+ /** Workspace-relative paths the invocation would write. */
135
+ writePaths?: string[];
136
+ /** Named high-risk action (matched against approval lists). */
137
+ action?: string;
138
+ };
139
+ /** The authority verdict for a {@link ToolInvocation}. */
140
+ export type ToolAuthorization = {
141
+ allowed: boolean;
142
+ violations: PolicyViolation[];
143
+ /** One blocked `policy-violation` {@link SafetyEvent} per violation. */
144
+ events: SafetyEvent[];
145
+ };
146
+ /**
147
+ * Authorize a tool invocation. Authority is the INTERSECTION of the repo safety
148
+ * policy and the tool's declared scope, so a tool can only narrow what a run may
149
+ * do:
150
+ *
151
+ * - command — blocked if it matches any repo `blockedCommands` pattern;
152
+ * - domains — each must satisfy BOTH the repo `allowedNetworkDomains` and the
153
+ * tool's declared `networkDomains` (empty tool list = the tool declared no
154
+ * network use, so any domain is out of scope);
155
+ * - writePaths — each must satisfy BOTH the repo `allowedWriteRoots` and the
156
+ * tool's declared `writeRoots`;
157
+ * - action — denied if it appears in the repo `approvalRequiredActions` or the
158
+ * tool's `approvalActions` (no human is present in AFK, so it is blocked).
159
+ *
160
+ * Every breach becomes a blocked `policy-violation` {@link SafetyEvent} for the
161
+ * evidence bundle. `allowed` is true only when there are no violations. Pure.
162
+ */
163
+ export declare function authorizeToolInvocation(policy: SafetyPolicy, tool: ToolDefinition, invocation: ToolInvocation): ToolAuthorization;
164
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"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"}
package/dist/tools.js ADDED
@@ -0,0 +1,228 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_POLICY, checkCommand, checkNetworkDomain, checkWritePath, } from "./safety-policy.js";
4
+ const TOOL_KINDS = new Set([
5
+ "command",
6
+ "mcp",
7
+ "http",
8
+ "proxy",
9
+ "sdk",
10
+ ]);
11
+ const TOOLS_REL = join(".otto", "tools");
12
+ /** Absolute path to the tools registry root (`.otto/tools`). */
13
+ export function toolsDir(workspaceDir) {
14
+ return join(workspaceDir, TOOLS_REL);
15
+ }
16
+ /** Absolute path to one tool's definition (`.otto/tools/<name>.json`). */
17
+ export function toolPath(workspaceDir, name) {
18
+ return join(toolsDir(workspaceDir), `${name}.json`);
19
+ }
20
+ function stringArray(raw) {
21
+ return Array.isArray(raw)
22
+ ? raw.filter((s) => typeof s === "string")
23
+ : [];
24
+ }
25
+ /**
26
+ * Normalize an untrusted parsed `<name>.json` into a {@link ToolDefinition},
27
+ * filling restrictive defaults. Returns null when it lacks a `name` (so a
28
+ * malformed file is skipped, not crashed on). `kind` defaults to `command`;
29
+ * `enabled` defaults to true; every scope list defaults to empty.
30
+ */
31
+ export function parseTool(raw) {
32
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
33
+ return null;
34
+ const o = raw;
35
+ if (typeof o.name !== "string" || o.name.length === 0)
36
+ return null;
37
+ const tool = {
38
+ name: o.name,
39
+ kind: typeof o.kind === "string" && TOOL_KINDS.has(o.kind)
40
+ ? o.kind
41
+ : "command",
42
+ capabilities: stringArray(o.capabilities),
43
+ stages: stringArray(o.stages),
44
+ env: stringArray(o.env),
45
+ networkDomains: stringArray(o.networkDomains),
46
+ writeRoots: stringArray(o.writeRoots),
47
+ secretRefs: stringArray(o.secretRefs),
48
+ approvalActions: stringArray(o.approvalActions),
49
+ enabled: o.enabled !== false,
50
+ };
51
+ if (typeof o.description === "string")
52
+ tool.description = o.description;
53
+ if (typeof o.command === "string")
54
+ tool.command = o.command;
55
+ if (typeof o.healthCheck === "string")
56
+ tool.healthCheck = o.healthCheck;
57
+ if (typeof o.timeoutMs === "number" && Number.isFinite(o.timeoutMs)) {
58
+ tool.timeoutMs = o.timeoutMs;
59
+ }
60
+ return tool;
61
+ }
62
+ /**
63
+ * Read every tool definition under `.otto/tools/` (the `*.json` files), skipping
64
+ * malformed ones, sorted by name. Absent/unreadable dir → `[]` (never throws).
65
+ * Reads ONLY the repo-local registry — personal MCP/plugin config is never
66
+ * consulted (the AFK invariant).
67
+ */
68
+ export function readTools(workspaceDir) {
69
+ let names;
70
+ try {
71
+ names = readdirSync(toolsDir(workspaceDir))
72
+ .filter((f) => f.endsWith(".json"))
73
+ .sort();
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const tools = [];
79
+ for (const file of names) {
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(readFileSync(join(toolsDir(workspaceDir), file), "utf8"));
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ const t = parseTool(parsed);
88
+ if (t)
89
+ tools.push(t);
90
+ }
91
+ return tools;
92
+ }
93
+ /**
94
+ * Read the `tools` block of `.otto/config.json` (per-tool enable/stage
95
+ * overrides). Absent/malformed file or block → no overrides (never throws). Only
96
+ * the `tools` key is read; sibling config blocks (journal, branch) are untouched.
97
+ */
98
+ export function readToolConfig(workspaceDir) {
99
+ let raw;
100
+ try {
101
+ raw = JSON.parse(readFileSync(join(workspaceDir, ".otto", "config.json"), "utf8"));
102
+ }
103
+ catch {
104
+ return { overrides: {} };
105
+ }
106
+ const block = raw.tools;
107
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
108
+ return { overrides: {} };
109
+ }
110
+ const overrides = {};
111
+ for (const [name, v] of Object.entries(block)) {
112
+ if (!v || typeof v !== "object" || Array.isArray(v))
113
+ continue;
114
+ const ov = v;
115
+ const out = {};
116
+ if (typeof ov.enabled === "boolean")
117
+ out.enabled = ov.enabled;
118
+ if (Array.isArray(ov.stages))
119
+ out.stages = stringArray(ov.stages);
120
+ overrides[name] = out;
121
+ }
122
+ return { overrides };
123
+ }
124
+ /** Whether a tool is enabled for `stage`, plus a human reason. Pure. */
125
+ export function toolEnabledForStage(tool, config, stage) {
126
+ const ov = config.overrides[tool.name];
127
+ const enabled = ov?.enabled ?? tool.enabled;
128
+ if (!enabled) {
129
+ return { enabled: false, reason: "disabled in registry/config" };
130
+ }
131
+ const stages = ov?.stages ?? tool.stages;
132
+ if (stages.length === 0) {
133
+ return { enabled: false, reason: "no stage allowlist (opt-in required)" };
134
+ }
135
+ if (!stages.includes(stage)) {
136
+ return {
137
+ enabled: false,
138
+ reason: `stage "${stage}" not in allowlist [${stages.join(", ")}]`,
139
+ };
140
+ }
141
+ return { enabled: true, reason: `enabled for stage "${stage}"` };
142
+ }
143
+ /**
144
+ * Rank tools for a stage: enabled-first, then name. Disabled tools are still
145
+ * returned (flagged, with the reason) so `otto-tools why <stage>` shows the full
146
+ * picture — including a tool that exists but is policy/stage-gated off. Pure.
147
+ */
148
+ export function selectToolsForStage(tools, config, stage) {
149
+ return tools
150
+ .map((tool) => {
151
+ const { enabled, reason } = toolEnabledForStage(tool, config, stage);
152
+ return {
153
+ name: tool.name,
154
+ kind: tool.kind,
155
+ enabled,
156
+ reason,
157
+ capabilities: tool.capabilities,
158
+ };
159
+ })
160
+ .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name));
161
+ }
162
+ /** A single-axis allow-list policy, to reuse the safety-policy predicates. */
163
+ function scopedPolicy(field, values) {
164
+ return { ...DEFAULT_POLICY, [field]: values };
165
+ }
166
+ /**
167
+ * Authorize a tool invocation. Authority is the INTERSECTION of the repo safety
168
+ * policy and the tool's declared scope, so a tool can only narrow what a run may
169
+ * do:
170
+ *
171
+ * - command — blocked if it matches any repo `blockedCommands` pattern;
172
+ * - domains — each must satisfy BOTH the repo `allowedNetworkDomains` and the
173
+ * tool's declared `networkDomains` (empty tool list = the tool declared no
174
+ * network use, so any domain is out of scope);
175
+ * - writePaths — each must satisfy BOTH the repo `allowedWriteRoots` and the
176
+ * tool's declared `writeRoots`;
177
+ * - action — denied if it appears in the repo `approvalRequiredActions` or the
178
+ * tool's `approvalActions` (no human is present in AFK, so it is blocked).
179
+ *
180
+ * Every breach becomes a blocked `policy-violation` {@link SafetyEvent} for the
181
+ * evidence bundle. `allowed` is true only when there are no violations. Pure.
182
+ */
183
+ export function authorizeToolInvocation(policy, tool, invocation) {
184
+ const violations = [];
185
+ if (invocation.command !== undefined) {
186
+ violations.push(...checkCommand(policy, invocation.command));
187
+ }
188
+ for (const domain of invocation.domains ?? []) {
189
+ violations.push(...checkNetworkDomain(policy, domain));
190
+ // Tool scope: an empty declared list means "no network", which must block.
191
+ if (tool.networkDomains.length === 0) {
192
+ violations.push({
193
+ kind: "network-domain",
194
+ subject: domain,
195
+ message: `tool "${tool.name}" declares no network access`,
196
+ });
197
+ }
198
+ else {
199
+ violations.push(...checkNetworkDomain(scopedPolicy("allowedNetworkDomains", tool.networkDomains), domain));
200
+ }
201
+ }
202
+ for (const path of invocation.writePaths ?? []) {
203
+ violations.push(...checkWritePath(policy, path));
204
+ if (tool.writeRoots.length > 0) {
205
+ violations.push(...checkWritePath(scopedPolicy("allowedWriteRoots", tool.writeRoots), path));
206
+ }
207
+ }
208
+ if (invocation.action !== undefined) {
209
+ const needsApproval = policy.approvalRequiredActions.includes(invocation.action) ||
210
+ tool.approvalActions.includes(invocation.action);
211
+ if (needsApproval) {
212
+ violations.push({
213
+ kind: "approval-required",
214
+ subject: invocation.action,
215
+ message: `action "${invocation.action}" requires human approval (unavailable in AFK)`,
216
+ });
217
+ }
218
+ }
219
+ const events = violations.map((v) => ({
220
+ category: "policy-violation",
221
+ kind: v.kind,
222
+ subject: v.subject,
223
+ message: v.message,
224
+ blocked: true,
225
+ }));
226
+ return { allowed: violations.length === 0, violations, events };
227
+ }
228
+ //# sourceMappingURL=tools.js.map