ambit-ts 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.
Files changed (96) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +403 -0
  4. package/dist/checker/authority.d.ts +13 -0
  5. package/dist/checker/authority.js +87 -0
  6. package/dist/checker/backend/legacy-ts.d.ts +26 -0
  7. package/dist/checker/backend/legacy-ts.js +1936 -0
  8. package/dist/checker/config.d.ts +84 -0
  9. package/dist/checker/config.js +391 -0
  10. package/dist/checker/coverage.d.ts +78 -0
  11. package/dist/checker/coverage.js +84 -0
  12. package/dist/checker/diagnose.d.ts +89 -0
  13. package/dist/checker/diagnose.js +734 -0
  14. package/dist/checker/index.d.ts +8 -0
  15. package/dist/checker/index.js +8 -0
  16. package/dist/checker/init.d.ts +38 -0
  17. package/dist/checker/init.js +205 -0
  18. package/dist/checker/propagate.d.ts +69 -0
  19. package/dist/checker/propagate.js +259 -0
  20. package/dist/checker/summarize.d.ts +27 -0
  21. package/dist/checker/summarize.js +411 -0
  22. package/dist/cli/analyze.d.ts +33 -0
  23. package/dist/cli/analyze.js +98 -0
  24. package/dist/cli/approvals.d.ts +28 -0
  25. package/dist/cli/approvals.js +55 -0
  26. package/dist/cli/diff.d.ts +66 -0
  27. package/dist/cli/diff.js +235 -0
  28. package/dist/cli/github.d.ts +33 -0
  29. package/dist/cli/github.js +41 -0
  30. package/dist/cli/main.d.ts +8 -0
  31. package/dist/cli/main.js +385 -0
  32. package/dist/cli/worktree.d.ts +75 -0
  33. package/dist/cli/worktree.js +154 -0
  34. package/dist/config.d.ts +12 -0
  35. package/dist/config.js +10 -0
  36. package/dist/core/approvals.d.ts +82 -0
  37. package/dist/core/approvals.js +0 -0
  38. package/dist/core/authority-diff.d.ts +98 -0
  39. package/dist/core/authority-diff.js +209 -0
  40. package/dist/core/authority.d.ts +109 -0
  41. package/dist/core/authority.js +50 -0
  42. package/dist/core/backend.d.ts +355 -0
  43. package/dist/core/backend.js +1 -0
  44. package/dist/core/budget.d.ts +61 -0
  45. package/dist/core/budget.js +95 -0
  46. package/dist/core/capability.d.ts +53 -0
  47. package/dist/core/capability.js +117 -0
  48. package/dist/core/config.d.ts +59 -0
  49. package/dist/core/config.js +10 -0
  50. package/dist/core/diagnostic.d.ts +126 -0
  51. package/dist/core/diagnostic.js +13 -0
  52. package/dist/core/effects.d.ts +39 -0
  53. package/dist/core/effects.js +72 -0
  54. package/dist/core/index.d.ts +13 -0
  55. package/dist/core/index.js +13 -0
  56. package/dist/core/location.d.ts +15 -0
  57. package/dist/core/location.js +1 -0
  58. package/dist/core/sql.d.ts +22 -0
  59. package/dist/core/sql.js +38 -0
  60. package/dist/core/summary.d.ts +240 -0
  61. package/dist/core/summary.js +8 -0
  62. package/dist/core/symbol-id.d.ts +25 -0
  63. package/dist/core/symbol-id.js +23 -0
  64. package/dist/index.d.ts +14 -0
  65. package/dist/index.js +14 -0
  66. package/dist/runtime/child-process.d.ts +29 -0
  67. package/dist/runtime/child-process.js +124 -0
  68. package/dist/runtime/context.d.ts +37 -0
  69. package/dist/runtime/context.js +8 -0
  70. package/dist/runtime/enforce.d.ts +52 -0
  71. package/dist/runtime/enforce.js +95 -0
  72. package/dist/runtime/fs.d.ts +46 -0
  73. package/dist/runtime/fs.js +188 -0
  74. package/dist/runtime/hono.d.ts +55 -0
  75. package/dist/runtime/hono.js +68 -0
  76. package/dist/runtime/index.d.ts +71 -0
  77. package/dist/runtime/index.js +126 -0
  78. package/dist/runtime/next.d.ts +95 -0
  79. package/dist/runtime/next.js +60 -0
  80. package/dist/runtime/pg.d.ts +48 -0
  81. package/dist/runtime/pg.js +122 -0
  82. package/dist/stubs/constructors.d.ts +34 -0
  83. package/dist/stubs/constructors.js +111 -0
  84. package/dist/stubs/data-clients.d.ts +9 -0
  85. package/dist/stubs/data-clients.js +109 -0
  86. package/dist/stubs/http-capabilities.d.ts +15 -0
  87. package/dist/stubs/http-capabilities.js +70 -0
  88. package/dist/stubs/mutating-builtins.d.ts +1 -0
  89. package/dist/stubs/mutating-builtins.js +48 -0
  90. package/dist/stubs/node-builtins.d.ts +2 -0
  91. package/dist/stubs/node-builtins.js +77 -0
  92. package/dist/stubs/pure-builtins.d.ts +1 -0
  93. package/dist/stubs/pure-builtins.js +89 -0
  94. package/docs/diagnostics/README.md +519 -0
  95. package/docs/limitations.md +712 -0
  96. package/package.json +89 -0
@@ -0,0 +1,95 @@
1
+ import { capabilityCovers, formatCapability, parseCapability } from "../core/index.js";
2
+ import { currentContext } from "./context.js";
3
+ /**
4
+ * The capability decision every hook shares (DESIGN.md §4.4).
5
+ *
6
+ * Separate from `index.ts` so that `fs.ts`, `child-process.ts` and `pg.ts` can
7
+ * import the decision without importing the module that installs them, and so
8
+ * that the `runtime.unscoped` policy is one variable rather than one per hook.
9
+ */
10
+ export class AmbitCapabilityError extends Error {
11
+ capability;
12
+ constructor(capability, granted, detail) {
13
+ const grantedList = granted.map(formatCapability).join(", ") || "(none)";
14
+ const suffix = detail ? ` — ${detail}` : "";
15
+ super(`capability ${capability} is not granted by this entrypoint (granted: ${grantedList})${suffix}`);
16
+ this.name = "AmbitCapabilityError";
17
+ this.capability = capability;
18
+ }
19
+ }
20
+ let unscopedPolicy = "allow";
21
+ /**
22
+ * Set the process-wide `runtime.unscoped` policy (DESIGN.md §4.4).
23
+ *
24
+ * Process-wide and not per-entrypoint on purpose. The policy only applies
25
+ * where there is *no* context, so a per-entrypoint override would be read
26
+ * only by concurrent work running outside every entrypoint — which makes it a
27
+ * mutable global that unrelated calls observe changing mid-flight, not a
28
+ * setting scoped to anything. Set it once at startup.
29
+ */
30
+ export function setUnscopedPolicy(policy) {
31
+ unscopedPolicy = policy;
32
+ }
33
+ /**
34
+ * Decide one capability without throwing, so a callback-style API can deliver
35
+ * the denial the way its caller expects (DESIGN.md §4.4: "synchronous APIs
36
+ * `throw`, callback APIs use `process.nextTick(callback, error)`, and Promise
37
+ * APIs reject").
38
+ *
39
+ * Returns the error to deliver, or `undefined` when the operation is allowed.
40
+ * Every decision inside a context — allowed or denied — is appended to the
41
+ * context's {@link AuditEntry} list.
42
+ */
43
+ export function checkCapability(required, detail) {
44
+ const context = currentContext();
45
+ if (!context) {
46
+ if (unscopedPolicy === "deny") {
47
+ return new AmbitCapabilityError(required, [], detail ?? "no @entrypoint context is active");
48
+ }
49
+ if (unscopedPolicy === "warn") {
50
+ process.emitWarning(`capability ${required} used outside any @entrypoint context`, "AmbitUnscoped");
51
+ }
52
+ return undefined;
53
+ }
54
+ const parsed = parseCapability(required);
55
+ const allowed = parsed !== undefined && context.capabilities.some((grant) => capabilityCovers(grant, parsed));
56
+ const entry = {
57
+ capability: required,
58
+ allowed,
59
+ reason: allowed ? "granted" : "denied",
60
+ };
61
+ context.audit.push(entry);
62
+ if (allowed)
63
+ return undefined;
64
+ return new AmbitCapabilityError(required, context.capabilities, detail);
65
+ }
66
+ /**
67
+ * Decide several capabilities at once, all of which are required. Used where
68
+ * one operation touches two resources (`fs.rename`) or where the source does
69
+ * not fix the direction, so both directions are required (an opaque SQL
70
+ * statement — DESIGN.md §4.4 (c)).
71
+ *
72
+ * Every capability is checked, so the audit records all of them, and the
73
+ * first denial is the one reported.
74
+ */
75
+ export function checkCapabilities(required, detail) {
76
+ let first;
77
+ for (const capability of required) {
78
+ const error = checkCapability(capability, detail);
79
+ if (error && !first)
80
+ first = error;
81
+ }
82
+ return first;
83
+ }
84
+ /**
85
+ * Check one capability against the active entrypoint context, throwing
86
+ * {@link AmbitCapabilityError} when it is not granted. Exported so an adapter
87
+ * for an unhooked client can perform the same check.
88
+ *
89
+ * With no active context the `runtime.unscoped` policy decides (§4.4).
90
+ */
91
+ export function requireCapability(required, detail) {
92
+ const error = checkCapability(required, detail);
93
+ if (error)
94
+ throw error;
95
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Runtime enforcement for `node:fs` and `node:fs/promises` (DESIGN.md §4.4
3
+ * (a), (b)).
4
+ *
5
+ * The mechanism is a monkeypatch of the module's own exports object, chosen
6
+ * over `diagnostics_channel` (which cannot block: a subscriber's `throw` does
7
+ * not stop `publish`) and over a loader hook (which covers nothing a
8
+ * monkeypatch does not). What a monkeypatch covers depends on *when* it is
9
+ * installed, not on the mechanism:
10
+ *
11
+ * - installed before anything has `import`ed `node:fs` — from a
12
+ * `node --import` / `--require` preload — every form is covered, named ESM
13
+ * imports included;
14
+ * - installed from inside the module graph, every `fs.readFile()` through the
15
+ * default export or `requireBuiltin("node:fs")`, but not a named import
16
+ * (`import { readFile } from "node:fs"`) that was already bound.
17
+ *
18
+ * `docs/limitations.md` states both, and neither covers native addons, a
19
+ * child process, or another worker thread.
20
+ */
21
+ /** Which paths an operation reads and which it writes, by argument position. */
22
+ interface FsRule {
23
+ readonly reads?: readonly number[];
24
+ readonly writes?: readonly number[];
25
+ }
26
+ /**
27
+ * The capabilities one path argument needs. Exported so a caller can see
28
+ * exactly what a given path resolves to before granting it.
29
+ *
30
+ * Relative paths resolve against `process.cwd()` *at the time of the call*,
31
+ * `Buffer` paths are decoded, and a `file:` URL is converted — §4.4 (b)'s
32
+ * normalisation, so that one file has one spelling in a grant.
33
+ */
34
+ export declare function fsCapabilities(rule: FsRule, args: readonly unknown[]): readonly string[];
35
+ /** The capabilities an `open` needs, from its `flags` argument (default `"r"`). */
36
+ export declare function openCapabilities(args: readonly unknown[]): readonly string[];
37
+ /**
38
+ * Replace the checked members of `node:fs` and `node:fs/promises`, returning
39
+ * the function that puts every one of them back.
40
+ *
41
+ * Same shape as `installFetchHook`: install returns restore, and restore only
42
+ * undoes what is still ours, so a hook installed on top of this one is not
43
+ * clobbered (P5: backing out at any time).
44
+ */
45
+ export declare function installFsHook(): () => void;
46
+ export {};
@@ -0,0 +1,188 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { checkCapabilities } from "./enforce.js";
5
+ /**
6
+ * `require`, not `import`, and the difference is load-bearing.
7
+ *
8
+ * A builtin's ESM namespace is a snapshot of its properties taken when that
9
+ * builtin is first `import`ed. `import fs from "node:fs"` here would take
10
+ * that snapshot before this module has patched anything, and every
11
+ * `import { readFileSync } from "node:fs"` in the application would then be
12
+ * bound to the original — even from a preload. Reaching the module through
13
+ * `createRequire` leaves the snapshot to be taken later, from the patched object,
14
+ * which is what makes §4.4 (a)'s preload row true. Measured both ways.
15
+ */
16
+ const requireBuiltin = createRequire(import.meta.url);
17
+ const fs = requireBuiltin("node:fs");
18
+ const fsPromises = requireBuiltin("node:fs/promises");
19
+ /**
20
+ * The operations this hook checks. Anything absent is not checked and not
21
+ * recorded — `docs/limitations.md` lists what that leaves out rather than
22
+ * letting the omission read as "allowed".
23
+ *
24
+ * `open` is checked by flags rather than by name, so it appears in
25
+ * {@link FLAG_OPERATIONS} instead. `createReadStream` / `createWriteStream`
26
+ * are absent on purpose: both go through `fs.open`, which this hook replaces,
27
+ * so they are covered at that entry point (measured).
28
+ */
29
+ const OPERATIONS = new Map([
30
+ ["readFile", { reads: [0] }],
31
+ ["readdir", { reads: [0] }],
32
+ ["access", { reads: [0] }],
33
+ ["stat", { reads: [0] }],
34
+ ["lstat", { reads: [0] }],
35
+ ["realpath", { reads: [0] }],
36
+ ["readlink", { reads: [0] }],
37
+ ["writeFile", { writes: [0] }],
38
+ ["appendFile", { writes: [0] }],
39
+ ["mkdir", { writes: [0] }],
40
+ ["rmdir", { writes: [0] }],
41
+ ["rm", { writes: [0] }],
42
+ ["unlink", { writes: [0] }],
43
+ ["truncate", { writes: [0] }],
44
+ ["chmod", { writes: [0] }],
45
+ ["symlink", { writes: [1] }],
46
+ ["rename", { reads: [0], writes: [1] }],
47
+ ["copyFile", { reads: [0], writes: [1] }],
48
+ ["link", { reads: [0], writes: [1] }],
49
+ ]);
50
+ /** `open` decides its direction from `flags`, so it is handled apart from {@link OPERATIONS}. */
51
+ const FLAG_OPERATIONS = new Set(["open"]);
52
+ /**
53
+ * `existsSync` is the one read that must not answer `false` when denied
54
+ * (DESIGN.md §4.4 (a)): "not there" and "not allowed to look" are different
55
+ * answers, and returning `false` would report the second as the first.
56
+ */
57
+ const SYNC_ONLY = new Set(["existsSync"]);
58
+ /**
59
+ * The capabilities one path argument needs. Exported so a caller can see
60
+ * exactly what a given path resolves to before granting it.
61
+ *
62
+ * Relative paths resolve against `process.cwd()` *at the time of the call*,
63
+ * `Buffer` paths are decoded, and a `file:` URL is converted — §4.4 (b)'s
64
+ * normalisation, so that one file has one spelling in a grant.
65
+ */
66
+ export function fsCapabilities(rule, args) {
67
+ const required = [];
68
+ for (const index of rule.reads ?? []) {
69
+ const target = normalizePath(args[index]);
70
+ if (target !== undefined)
71
+ required.push(`fs:read:${target}`);
72
+ }
73
+ for (const index of rule.writes ?? []) {
74
+ const target = normalizePath(args[index]);
75
+ if (target !== undefined)
76
+ required.push(`fs:write:${target}`);
77
+ }
78
+ return required;
79
+ }
80
+ /**
81
+ * `undefined` for an argument that names no path — a file descriptor, which
82
+ * `fs.readSync(fd)` takes. The path was checked when the descriptor was
83
+ * opened; checking it again here is impossible, not merely skipped.
84
+ */
85
+ function normalizePath(value) {
86
+ if (typeof value === "number")
87
+ return undefined;
88
+ let text;
89
+ if (typeof value === "string")
90
+ text = value;
91
+ else if (value instanceof URL)
92
+ text = fileURLToPath(value);
93
+ else if (Buffer.isBuffer(value))
94
+ text = value.toString("utf8");
95
+ else
96
+ return undefined;
97
+ return path.resolve(text);
98
+ }
99
+ /** The capabilities an `open` needs, from its `flags` argument (default `"r"`). */
100
+ export function openCapabilities(args) {
101
+ const target = normalizePath(args[0]);
102
+ if (target === undefined)
103
+ return [];
104
+ const flags = args[1];
105
+ const rule = openFlagsRule(flags);
106
+ return fsCapabilities(rule, [target, target]);
107
+ }
108
+ function openFlagsRule(flags) {
109
+ // A missing or non-flag second argument means the default, `"r"`.
110
+ if (typeof flags === "number") {
111
+ const access = flags & 0b11;
112
+ if (access === fs.constants.O_WRONLY)
113
+ return { writes: [1] };
114
+ if (access === fs.constants.O_RDWR)
115
+ return { reads: [0], writes: [1] };
116
+ return { reads: [0] };
117
+ }
118
+ if (typeof flags !== "string")
119
+ return { reads: [0] };
120
+ const reads = flags.startsWith("r") || flags.includes("+");
121
+ const writes = flags.includes("w") || flags.includes("a") || flags.includes("+");
122
+ return {
123
+ ...(reads ? { reads: [0] } : {}),
124
+ ...(writes ? { writes: [1] } : {}),
125
+ };
126
+ }
127
+ /**
128
+ * Replace the checked members of `node:fs` and `node:fs/promises`, returning
129
+ * the function that puts every one of them back.
130
+ *
131
+ * Same shape as `installFetchHook`: install returns restore, and restore only
132
+ * undoes what is still ours, so a hook installed on top of this one is not
133
+ * clobbered (P5: backing out at any time).
134
+ */
135
+ export function installFsHook() {
136
+ const restores = [];
137
+ for (const [name, rule] of OPERATIONS) {
138
+ patch(fs, name, (args) => fsCapabilities(rule, args), "callback", restores);
139
+ patch(fs, `${name}Sync`, (args) => fsCapabilities(rule, args), "sync", restores);
140
+ patch(fsPromises, name, (args) => fsCapabilities(rule, args), "promise", restores);
141
+ }
142
+ for (const name of FLAG_OPERATIONS) {
143
+ patch(fs, name, openCapabilities, "callback", restores);
144
+ patch(fs, `${name}Sync`, openCapabilities, "sync", restores);
145
+ patch(fsPromises, name, openCapabilities, "promise", restores);
146
+ }
147
+ for (const name of SYNC_ONLY) {
148
+ patch(fs, name, (args) => fsCapabilities({ reads: [0] }, args), "sync", restores);
149
+ }
150
+ return () => {
151
+ for (const restore of restores)
152
+ restore();
153
+ };
154
+ }
155
+ /**
156
+ * Deliver a denial the way the API's own errors arrive: `throw` for the sync
157
+ * family, `process.nextTick(callback, error)` for the callback family, a
158
+ * rejected promise for `fs/promises` (DESIGN.md §4.4 (a)). A callback API that
159
+ * threw synchronously would break `try`/`catch`-free call sites that are
160
+ * correct as written.
161
+ */
162
+ function patch(host, name, capabilities, family, restores) {
163
+ const original = host[name];
164
+ if (typeof original !== "function")
165
+ return;
166
+ const target = original;
167
+ const hooked = function (...args) {
168
+ const error = checkCapabilities(capabilities(args));
169
+ if (!error)
170
+ return target.apply(this, args);
171
+ if (family === "promise")
172
+ return Promise.reject(error);
173
+ if (family === "callback") {
174
+ const callback = args[args.length - 1];
175
+ if (typeof callback === "function") {
176
+ process.nextTick(callback, error);
177
+ return undefined;
178
+ }
179
+ }
180
+ throw error;
181
+ };
182
+ Object.defineProperty(hooked, "name", { value: name });
183
+ host[name] = hooked;
184
+ restores.push(() => {
185
+ if (host[name] === hooked)
186
+ host[name] = original;
187
+ });
188
+ }
@@ -0,0 +1,55 @@
1
+ import type { Context, Env, Handler, Input } from "hono";
2
+ import type { AmbitSpec } from "./index.ts";
3
+ /**
4
+ * The Hono adapter (DESIGN.md §4.4, "Mapping contracts to handlers").
5
+ *
6
+ * `hono` is imported for **types only** and is a devDependency here, never a
7
+ * dependency of the published package — the same reason `installPgHook(pg)`
8
+ * takes the module from the caller instead of importing `pg` (§4.4 (c)).
9
+ * Nothing in this file imports `typescript` or anything under `src/checker/`.
10
+ */
11
+ /**
12
+ * Register a contract-bearing handler on a Hono route, establishing the
13
+ * entrypoint's capability set and budget for the whole request.
14
+ *
15
+ * ```ts
16
+ * app.post("/orders", ambitHandler(
17
+ * { capabilities: ["db:write:orders"], budget: { timeMs: 800 } },
18
+ * createOrder,
19
+ * async (c) => [await c.req.json<CreateOrderInput>()] as const,
20
+ * ));
21
+ * ```
22
+ *
23
+ * The three arguments are the decision §4.4 records, not a convenience:
24
+ *
25
+ * - `spec` sits in the same call as `handler`, so a literal one *is* that
26
+ * handler's `@capabilities` and `@budget`: the tags need not repeat what the
27
+ * registration already says. Where both are written, `ambit check` compares
28
+ * them — the capability list against `@capabilities` (`AMB-E010`), and
29
+ * `spec.budget` against `@budget` (`AMB-E011`, with an omitted `onExceed`
30
+ * defaulted to `throw` on both sides) — and a disagreement is an error. The
31
+ * two halves are judged independently: a spec may write one as a literal and
32
+ * build the other at runtime, and `AMB-W004` reports whichever half could
33
+ * neither declare nor be compared, where the handler's own JSDoc is then the
34
+ * only declaration. Explicit registration was chosen over generated contract
35
+ * data because the contract is then a value in the module — it survives a
36
+ * build that strips comments, and a bundler that renames everything.
37
+ * - `decode` keeps the framework out of `handler`. Hono's `Context` is in no
38
+ * stub table, so a `c.req.json()` inside a contract-bearing function would
39
+ * make that function's requirement partly `unknown` (`AMB-W003`). Isolated
40
+ * here, the handler stays framework-agnostic and statically analyzable.
41
+ * It runs **inside** the context: reading the request body counts toward
42
+ * `timeMs`.
43
+ *
44
+ * `AmbitCapabilityError` and `AmbitBudgetError` are **not** translated into
45
+ * HTTP statuses. A denied capability is this server's own code exceeding its
46
+ * grant, which is not what 403 says, and the message names the granted set —
47
+ * so it goes to the framework's error handler, not to the client (§4.4).
48
+ *
49
+ * A handler registered without this adapter establishes no context at all;
50
+ * what its operations do then is `setUnscopedPolicy`'s decision (`allow` by
51
+ * default). The adapter never invents an empty-capability context for a route
52
+ * it was not given, because a deny-everything context is indistinguishable
53
+ * from a missed registration.
54
+ */
55
+ export declare function ambitHandler<Args extends readonly unknown[], Result, E extends Env = Env, P extends string = string, I extends Input = Input>(spec: AmbitSpec, handler: (...args: Args) => Result | Promise<Result>, decode: (c: Context<E, P, I>) => readonly [...Args] | Promise<readonly [...Args]>): Handler<E, P, I>;
@@ -0,0 +1,68 @@
1
+ import { withAmbit } from "./index.js";
2
+ /**
3
+ * The Hono adapter (DESIGN.md §4.4, "Mapping contracts to handlers").
4
+ *
5
+ * `hono` is imported for **types only** and is a devDependency here, never a
6
+ * dependency of the published package — the same reason `installPgHook(pg)`
7
+ * takes the module from the caller instead of importing `pg` (§4.4 (c)).
8
+ * Nothing in this file imports `typescript` or anything under `src/checker/`.
9
+ */
10
+ /**
11
+ * Register a contract-bearing handler on a Hono route, establishing the
12
+ * entrypoint's capability set and budget for the whole request.
13
+ *
14
+ * ```ts
15
+ * app.post("/orders", ambitHandler(
16
+ * { capabilities: ["db:write:orders"], budget: { timeMs: 800 } },
17
+ * createOrder,
18
+ * async (c) => [await c.req.json<CreateOrderInput>()] as const,
19
+ * ));
20
+ * ```
21
+ *
22
+ * The three arguments are the decision §4.4 records, not a convenience:
23
+ *
24
+ * - `spec` sits in the same call as `handler`, so a literal one *is* that
25
+ * handler's `@capabilities` and `@budget`: the tags need not repeat what the
26
+ * registration already says. Where both are written, `ambit check` compares
27
+ * them — the capability list against `@capabilities` (`AMB-E010`), and
28
+ * `spec.budget` against `@budget` (`AMB-E011`, with an omitted `onExceed`
29
+ * defaulted to `throw` on both sides) — and a disagreement is an error. The
30
+ * two halves are judged independently: a spec may write one as a literal and
31
+ * build the other at runtime, and `AMB-W004` reports whichever half could
32
+ * neither declare nor be compared, where the handler's own JSDoc is then the
33
+ * only declaration. Explicit registration was chosen over generated contract
34
+ * data because the contract is then a value in the module — it survives a
35
+ * build that strips comments, and a bundler that renames everything.
36
+ * - `decode` keeps the framework out of `handler`. Hono's `Context` is in no
37
+ * stub table, so a `c.req.json()` inside a contract-bearing function would
38
+ * make that function's requirement partly `unknown` (`AMB-W003`). Isolated
39
+ * here, the handler stays framework-agnostic and statically analyzable.
40
+ * It runs **inside** the context: reading the request body counts toward
41
+ * `timeMs`.
42
+ *
43
+ * `AmbitCapabilityError` and `AmbitBudgetError` are **not** translated into
44
+ * HTTP statuses. A denied capability is this server's own code exceeding its
45
+ * grant, which is not what 403 says, and the message names the granted set —
46
+ * so it goes to the framework's error handler, not to the client (§4.4).
47
+ *
48
+ * A handler registered without this adapter establishes no context at all;
49
+ * what its operations do then is `setUnscopedPolicy`'s decision (`allow` by
50
+ * default). The adapter never invents an empty-capability context for a route
51
+ * it was not given, because a deny-everything context is indistinguishable
52
+ * from a missed registration.
53
+ */
54
+ export function ambitHandler(spec, handler, decode) {
55
+ const scoped = withAmbit(spec, async (c) =>
56
+ // `decode` may return a `readonly` tuple — `[x] as const` is what a caller
57
+ // naturally writes — and a readonly tuple spreads into rest parameters.
58
+ handler(...(await decode(c))));
59
+ return async (c) => {
60
+ const result = await scoped(c);
61
+ // `Response.json` rather than `c.json`: the handler's return type is the
62
+ // domain's, not Hono's `JSONValue`, and forcing it through that generic
63
+ // would mean casting every handler. The cost is that Hono's RPC type
64
+ // inference (`hc`) sees `Response`, not the handler's shape —
65
+ // `docs/limitations.md` records it.
66
+ return result instanceof Response ? result : Response.json(result ?? null);
67
+ };
68
+ }
@@ -0,0 +1,71 @@
1
+ import type { BudgetInput } from "../core/index.ts";
2
+ export type { BudgetInput, OnExceed } from "../core/index.ts";
3
+ export { installChildProcessHook, spawnCapability } from "./child-process.ts";
4
+ export type { AmbitContext, AuditEntry, UnscopedPolicy } from "./context.ts";
5
+ export { currentContext } from "./context.ts";
6
+ export { AmbitCapabilityError, checkCapabilities, checkCapability, requireCapability, setUnscopedPolicy, } from "./enforce.ts";
7
+ export { fsCapabilities, installFsHook } from "./fs.ts";
8
+ export { installPgHook, pgCapabilities } from "./pg.ts";
9
+ /**
10
+ * Runtime enforcement for an entrypoint (DESIGN.md §4.4, §4.5).
11
+ *
12
+ * Deliberately narrow, and the narrowness is the point: this enforces what it
13
+ * can actually check and says nothing about the rest. What is enforced today:
14
+ *
15
+ * - **capabilities**, for `globalThis.fetch` ({@link installFetchHook}),
16
+ * `node:fs` and `node:fs/promises` ({@link installFsHook}),
17
+ * `node:child_process` ({@link installChildProcessHook}), and the `pg`
18
+ * client ({@link installPgHook}) — each once its hook has been installed.
19
+ * Every other operation is unhooked and therefore unenforced: `mysql2`,
20
+ * `@prisma/client`, `drizzle-orm`, `mongodb`, and every LLM SDK.
21
+ * - **`timeMs`**, checked when the handler settles (`throw`/`warn`) or via an
22
+ * `AbortSignal` (`abort`).
23
+ *
24
+ * What is **not** enforced: `costUsd` and `llmCalls`. There is no LLM hook, so
25
+ * nothing would increment those counters; reporting them as enforced would be
26
+ * a number nobody measures. They are carried on the context for an adapter to
27
+ * use and are otherwise inert.
28
+ */
29
+ export interface AmbitSpec {
30
+ /** The entrypoint's `@capabilities`, as written (`db:read:users`). */
31
+ readonly capabilities?: readonly string[];
32
+ /**
33
+ * The entrypoint's `@budget`, as written — `onExceed` may be omitted, and
34
+ * `{ timeMs: 500 }` is a complete spec. The default is resolved here, once,
35
+ * so the context an adapter reads always carries a settled policy.
36
+ */
37
+ readonly budget?: BudgetInput;
38
+ }
39
+ export declare class AmbitBudgetError extends Error {
40
+ constructor(message: string);
41
+ }
42
+ /**
43
+ * Run `handler` with the entrypoint's capability set and budget established
44
+ * (DESIGN.md §4.4: "Where there is no adapter, insert
45
+ * `withAmbit(spec, handler)` by hand").
46
+ *
47
+ * A malformed capability string throws at wrap time rather than being
48
+ * dropped: a grant that does not parse would silently become "grants nothing",
49
+ * and the handler would fail in a way that looks like a policy decision.
50
+ */
51
+ export declare function withAmbit<Args extends readonly unknown[], Result>(spec: AmbitSpec, handler: (...args: Args) => Result | Promise<Result>): (...args: Args) => Promise<Result>;
52
+ /**
53
+ * Wrap `globalThis.fetch` so every request is checked against the active
54
+ * entrypoint's capabilities as `http:<method>:<host>`.
55
+ *
56
+ * One of four hooks; see {@link installFsHook},
57
+ * {@link installChildProcessHook} and {@link installPgHook} for the rest.
58
+ * DESIGN.md §4.4's remaining targets — `mysql2`, `@prisma/client`,
59
+ * `drizzle-orm`, `mongodb`, and the LLM SDKs — are not hooked, and calling
60
+ * them is neither checked nor recorded.
61
+ *
62
+ * Returns a function that restores the original `fetch`, so a test — or a
63
+ * consumer backing Ambit out (P5) — can undo it.
64
+ */
65
+ export declare function installFetchHook(): () => void;
66
+ /**
67
+ * `http:<method>:<host>` for a request. The host includes the port when the
68
+ * URL carries one (`127.0.0.1:8080`), which is why a capability's target
69
+ * segment is allowed to contain a colon.
70
+ */
71
+ export declare function fetchCapability(input: Parameters<typeof fetch>[0], init?: RequestInit): string;
@@ -0,0 +1,126 @@
1
+ import { DEFAULT_ON_EXCEED, parseCapability } from "../core/index.js";
2
+ import { currentContext, runInContext } from "./context.js";
3
+ import { requireCapability } from "./enforce.js";
4
+ export { installChildProcessHook, spawnCapability } from "./child-process.js";
5
+ export { currentContext } from "./context.js";
6
+ export { AmbitCapabilityError, checkCapabilities, checkCapability, requireCapability, setUnscopedPolicy, } from "./enforce.js";
7
+ export { fsCapabilities, installFsHook } from "./fs.js";
8
+ export { installPgHook, pgCapabilities } from "./pg.js";
9
+ export class AmbitBudgetError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "AmbitBudgetError";
13
+ }
14
+ }
15
+ /**
16
+ * Run `handler` with the entrypoint's capability set and budget established
17
+ * (DESIGN.md §4.4: "Where there is no adapter, insert
18
+ * `withAmbit(spec, handler)` by hand").
19
+ *
20
+ * A malformed capability string throws at wrap time rather than being
21
+ * dropped: a grant that does not parse would silently become "grants nothing",
22
+ * and the handler would fail in a way that looks like a policy decision.
23
+ */
24
+ export function withAmbit(spec, handler) {
25
+ const capabilities = (spec.capabilities ?? []).map((text) => {
26
+ const capability = parseCapability(text);
27
+ if (!capability) {
28
+ throw new TypeError(`withAmbit: "${text}" is not a <resource>:<action>:<target> capability`);
29
+ }
30
+ return capability;
31
+ });
32
+ // Resolved once, at wrap time: neither depends on the arguments, and the
33
+ // context must carry a settled `onExceed` so an adapter reading it does not
34
+ // have to know the default.
35
+ const timeMs = spec.budget?.timeMs;
36
+ const onExceed = spec.budget?.onExceed ?? DEFAULT_ON_EXCEED;
37
+ const budget = spec.budget ? { ...spec.budget, onExceed } : undefined;
38
+ return async (...args) => {
39
+ const controller = new AbortController();
40
+ const context = {
41
+ capabilities,
42
+ ...(budget ? { budget } : {}),
43
+ startedAt: Date.now(),
44
+ signal: controller.signal,
45
+ audit: [],
46
+ };
47
+ let timer;
48
+ if (timeMs !== undefined && onExceed === "abort") {
49
+ timer = setTimeout(() => controller.abort(new AmbitBudgetError(exceeded(timeMs))), timeMs);
50
+ timer.unref?.();
51
+ }
52
+ try {
53
+ const result = await runInContext(context, () => handler(...args));
54
+ const elapsed = Date.now() - context.startedAt;
55
+ if (timeMs !== undefined && elapsed > timeMs) {
56
+ // Checked after the fact, and that is all this can honestly be:
57
+ // §4.5 says a budget does not undo spend already incurred or stop
58
+ // work that cannot be cancelled.
59
+ if (onExceed === "throw")
60
+ throw new AmbitBudgetError(exceeded(timeMs, elapsed));
61
+ if (onExceed === "warn")
62
+ process.emitWarning(exceeded(timeMs, elapsed), "AmbitBudget");
63
+ }
64
+ return result;
65
+ }
66
+ finally {
67
+ if (timer)
68
+ clearTimeout(timer);
69
+ }
70
+ };
71
+ }
72
+ function exceeded(timeMs, elapsed) {
73
+ const actual = elapsed === undefined ? "" : ` (took ${elapsed}ms)`;
74
+ return `budget timeMs=${timeMs} exceeded${actual}`;
75
+ }
76
+ /**
77
+ * Wrap `globalThis.fetch` so every request is checked against the active
78
+ * entrypoint's capabilities as `http:<method>:<host>`.
79
+ *
80
+ * One of four hooks; see {@link installFsHook},
81
+ * {@link installChildProcessHook} and {@link installPgHook} for the rest.
82
+ * DESIGN.md §4.4's remaining targets — `mysql2`, `@prisma/client`,
83
+ * `drizzle-orm`, `mongodb`, and the LLM SDKs — are not hooked, and calling
84
+ * them is neither checked nor recorded.
85
+ *
86
+ * Returns a function that restores the original `fetch`, so a test — or a
87
+ * consumer backing Ambit out (P5) — can undo it.
88
+ */
89
+ export function installFetchHook() {
90
+ const original = globalThis.fetch;
91
+ if (typeof original !== "function") {
92
+ throw new TypeError("installFetchHook: globalThis.fetch is not available");
93
+ }
94
+ const hooked = async (input, init) => {
95
+ requireCapability(fetchCapability(input, init));
96
+ // `onExceed: "abort"` aborts the entrypoint's signal; without joining it
97
+ // to the request, the signal would fire and the request would keep going,
98
+ // making the "abort" row of the README table false.
99
+ const budgetSignal = currentContext()?.signal;
100
+ if (!budgetSignal)
101
+ return original(input, init);
102
+ const signal = init?.signal ? AbortSignal.any([budgetSignal, init.signal]) : budgetSignal;
103
+ return original(input, { ...init, signal });
104
+ };
105
+ globalThis.fetch = hooked;
106
+ return () => {
107
+ // Only restore if nothing else re-wrapped fetch in the meantime;
108
+ // clobbering someone else's hook would be worse than leaving ours on.
109
+ if (globalThis.fetch === hooked)
110
+ globalThis.fetch = original;
111
+ };
112
+ }
113
+ /**
114
+ * `http:<method>:<host>` for a request. The host includes the port when the
115
+ * URL carries one (`127.0.0.1:8080`), which is why a capability's target
116
+ * segment is allowed to contain a colon.
117
+ */
118
+ export function fetchCapability(input, init) {
119
+ const url = input instanceof URL
120
+ ? input
121
+ : new URL(typeof input === "string" ? input : input.url);
122
+ const method = (init?.method ??
123
+ (typeof input === "object" && "method" in input ? input.method : undefined) ??
124
+ "GET").toLowerCase();
125
+ return `http:${method}:${url.host}`;
126
+ }