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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/checker/authority.d.ts +13 -0
- package/dist/checker/authority.js +87 -0
- package/dist/checker/backend/legacy-ts.d.ts +26 -0
- package/dist/checker/backend/legacy-ts.js +1936 -0
- package/dist/checker/config.d.ts +84 -0
- package/dist/checker/config.js +391 -0
- package/dist/checker/coverage.d.ts +78 -0
- package/dist/checker/coverage.js +84 -0
- package/dist/checker/diagnose.d.ts +89 -0
- package/dist/checker/diagnose.js +734 -0
- package/dist/checker/index.d.ts +8 -0
- package/dist/checker/index.js +8 -0
- package/dist/checker/init.d.ts +38 -0
- package/dist/checker/init.js +205 -0
- package/dist/checker/propagate.d.ts +69 -0
- package/dist/checker/propagate.js +259 -0
- package/dist/checker/summarize.d.ts +27 -0
- package/dist/checker/summarize.js +411 -0
- package/dist/cli/analyze.d.ts +33 -0
- package/dist/cli/analyze.js +98 -0
- package/dist/cli/approvals.d.ts +28 -0
- package/dist/cli/approvals.js +55 -0
- package/dist/cli/diff.d.ts +66 -0
- package/dist/cli/diff.js +235 -0
- package/dist/cli/github.d.ts +33 -0
- package/dist/cli/github.js +41 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +385 -0
- package/dist/cli/worktree.d.ts +75 -0
- package/dist/cli/worktree.js +154 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/core/approvals.d.ts +82 -0
- package/dist/core/approvals.js +0 -0
- package/dist/core/authority-diff.d.ts +98 -0
- package/dist/core/authority-diff.js +209 -0
- package/dist/core/authority.d.ts +109 -0
- package/dist/core/authority.js +50 -0
- package/dist/core/backend.d.ts +355 -0
- package/dist/core/backend.js +1 -0
- package/dist/core/budget.d.ts +61 -0
- package/dist/core/budget.js +95 -0
- package/dist/core/capability.d.ts +53 -0
- package/dist/core/capability.js +117 -0
- package/dist/core/config.d.ts +59 -0
- package/dist/core/config.js +10 -0
- package/dist/core/diagnostic.d.ts +126 -0
- package/dist/core/diagnostic.js +13 -0
- package/dist/core/effects.d.ts +39 -0
- package/dist/core/effects.js +72 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/location.d.ts +15 -0
- package/dist/core/location.js +1 -0
- package/dist/core/sql.d.ts +22 -0
- package/dist/core/sql.js +38 -0
- package/dist/core/summary.d.ts +240 -0
- package/dist/core/summary.js +8 -0
- package/dist/core/symbol-id.d.ts +25 -0
- package/dist/core/symbol-id.js +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime/child-process.d.ts +29 -0
- package/dist/runtime/child-process.js +124 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +8 -0
- package/dist/runtime/enforce.d.ts +52 -0
- package/dist/runtime/enforce.js +95 -0
- package/dist/runtime/fs.d.ts +46 -0
- package/dist/runtime/fs.js +188 -0
- package/dist/runtime/hono.d.ts +55 -0
- package/dist/runtime/hono.js +68 -0
- package/dist/runtime/index.d.ts +71 -0
- package/dist/runtime/index.js +126 -0
- package/dist/runtime/next.d.ts +95 -0
- package/dist/runtime/next.js +60 -0
- package/dist/runtime/pg.d.ts +48 -0
- package/dist/runtime/pg.js +122 -0
- package/dist/stubs/constructors.d.ts +34 -0
- package/dist/stubs/constructors.js +111 -0
- package/dist/stubs/data-clients.d.ts +9 -0
- package/dist/stubs/data-clients.js +109 -0
- package/dist/stubs/http-capabilities.d.ts +15 -0
- package/dist/stubs/http-capabilities.js +70 -0
- package/dist/stubs/mutating-builtins.d.ts +1 -0
- package/dist/stubs/mutating-builtins.js +48 -0
- package/dist/stubs/node-builtins.d.ts +2 -0
- package/dist/stubs/node-builtins.js +77 -0
- package/dist/stubs/pure-builtins.d.ts +1 -0
- package/dist/stubs/pure-builtins.js +89 -0
- package/docs/diagnostics/README.md +519 -0
- package/docs/limitations.md +712 -0
- 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
|
+
}
|