@oscharko-dev/keiko-tools 0.2.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 (59) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/browser/cdp-client.d.ts +36 -0
  3. package/dist/browser/cdp-client.d.ts.map +1 -0
  4. package/dist/browser/cdp-client.js +218 -0
  5. package/dist/browser/errors.d.ts +27 -0
  6. package/dist/browser/errors.d.ts.map +1 -0
  7. package/dist/browser/errors.js +61 -0
  8. package/dist/browser/session.d.ts +46 -0
  9. package/dist/browser/session.d.ts.map +1 -0
  10. package/dist/browser/session.js +759 -0
  11. package/dist/browser/types.d.ts +49 -0
  12. package/dist/browser/types.d.ts.map +1 -0
  13. package/dist/browser/types.js +2 -0
  14. package/dist/browser/validators.d.ts +6 -0
  15. package/dist/browser/validators.d.ts.map +1 -0
  16. package/dist/browser/validators.js +97 -0
  17. package/dist/errors.d.ts +3 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +4 -0
  20. package/dist/exec.d.ts +45 -0
  21. package/dist/exec.d.ts.map +1 -0
  22. package/dist/exec.js +372 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +33 -0
  26. package/dist/patch-content.d.ts +11 -0
  27. package/dist/patch-content.d.ts.map +1 -0
  28. package/dist/patch-content.js +130 -0
  29. package/dist/patch-normalize.d.ts +2 -0
  30. package/dist/patch-normalize.d.ts.map +1 -0
  31. package/dist/patch-normalize.js +85 -0
  32. package/dist/patch-parse.d.ts +9 -0
  33. package/dist/patch-parse.d.ts.map +1 -0
  34. package/dist/patch-parse.js +201 -0
  35. package/dist/patch.d.ts +22 -0
  36. package/dist/patch.d.ts.map +1 -0
  37. package/dist/patch.js +469 -0
  38. package/dist/registry.d.ts +35 -0
  39. package/dist/registry.d.ts.map +1 -0
  40. package/dist/registry.js +240 -0
  41. package/dist/sandbox.d.ts +9 -0
  42. package/dist/sandbox.d.ts.map +1 -0
  43. package/dist/sandbox.js +131 -0
  44. package/dist/schemas.d.ts +3 -0
  45. package/dist/schemas.d.ts.map +1 -0
  46. package/dist/schemas.js +51 -0
  47. package/dist/terminal-policy.d.ts +10 -0
  48. package/dist/terminal-policy.d.ts.map +1 -0
  49. package/dist/terminal-policy.js +306 -0
  50. package/dist/types.d.ts +5 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +18 -0
  53. package/dist/version.d.ts +2 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +4 -0
  56. package/dist/writer.d.ts +8 -0
  57. package/dist/writer.d.ts.map +1 -0
  58. package/dist/writer.js +20 -0
  59. package/package.json +42 -0
@@ -0,0 +1,49 @@
1
+ export interface NormalizedNavigateUrl {
2
+ readonly url: string;
3
+ readonly host: string;
4
+ readonly originOnly: string;
5
+ readonly port: number;
6
+ }
7
+ export interface BrowserViewportPx {
8
+ readonly width: number;
9
+ readonly height: number;
10
+ }
11
+ export type BrowserSessionStatus = "open" | "closed";
12
+ export interface BrowserSessionMeta {
13
+ readonly sessionId: string;
14
+ readonly cdpPort: number;
15
+ readonly targetId: string;
16
+ readonly status: BrowserSessionStatus;
17
+ readonly createdAt: number;
18
+ }
19
+ export interface BrowserNavigateResult {
20
+ readonly originOnly: string;
21
+ readonly httpStatus: number | null;
22
+ }
23
+ export interface BrowserScreenshotPreview {
24
+ readonly seq: number;
25
+ readonly viewportPx: BrowserViewportPx;
26
+ readonly dataBase64: string;
27
+ readonly persisted: false;
28
+ }
29
+ export interface BrowserScreenshotPersisted {
30
+ readonly seq: number;
31
+ readonly viewportPx: BrowserViewportPx;
32
+ readonly persisted: true;
33
+ readonly path: string;
34
+ readonly sha256: string;
35
+ readonly bytes: number;
36
+ }
37
+ export type BrowserScreenshotResult = BrowserScreenshotPreview | BrowserScreenshotPersisted;
38
+ export interface BrowserContentResult {
39
+ readonly seq: number;
40
+ readonly byteLength: number;
41
+ readonly redactedHtml: string;
42
+ }
43
+ export interface CdpReachability {
44
+ readonly reachable: boolean;
45
+ readonly userAgent: string | null;
46
+ readonly browserVersion: string | null;
47
+ readonly webSocketDebuggerUrl: string | null;
48
+ }
49
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/browser/types.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IAIpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAErB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAGtB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,oBAAoB,GAAG,MAAM,GAAG,QAAQ,CAAC;AAErD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,iBAAiB,CAAC;IAEvC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC;CAC3B;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,iBAAiB,CAAC;IACvC,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IAEzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,uBAAuB,GAAG,wBAAwB,GAAG,0BAA0B,CAAC;AAE5F,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9C"}
@@ -0,0 +1,2 @@
1
+ // Data shapes for the browser tool (ADR-0017). Pure types: no runtime imports.
2
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { NormalizedNavigateUrl } from "./types.js";
2
+ export declare function normalizeCdpPort(value: unknown): number;
3
+ export declare function normalizeNavigateUrl(raw: unknown): NormalizedNavigateUrl;
4
+ export declare function isLoopbackHost(host: string): boolean;
5
+ export declare function isLoopbackUrl(raw: string): boolean;
6
+ //# sourceMappingURL=validators.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../src/browser/validators.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AASxD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAcvD;AAKD,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,qBAAqB,CA6BxE;AA8BD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEpD;AAMD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAUlD"}
@@ -0,0 +1,97 @@
1
+ // ADR-0017 D2 — pure URL validation. NO filesystem, NO network. The localhost→127.0.0.1 rewrite
2
+ // happens here so that downstream code never hands `localhost` to the OS resolver, eliminating the
3
+ // /etc/hosts attack surface even if a downstream caller forgets the rule.
4
+ import { BrowserToolError } from "./errors.js";
5
+ const MIN_PORT = 1024;
6
+ const MAX_PORT = 65535;
7
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
8
+ // Strict literal-host policy: IPv4-mapped IPv6 forms like `::ffff:127.0.0.1` are
9
+ // REJECTED on purpose. ADR-0017 D2 normalises only `localhost`/`127.0.0.1`/`::1`.
10
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
11
+ export function normalizeCdpPort(value) {
12
+ if (typeof value !== "number") {
13
+ throw new BrowserToolError("BAD_PORT", "CDP port must be a number.");
14
+ }
15
+ if (!Number.isInteger(value)) {
16
+ throw new BrowserToolError("BAD_PORT", "CDP port must be an integer.");
17
+ }
18
+ if (value < MIN_PORT || value > MAX_PORT) {
19
+ throw new BrowserToolError("BAD_PORT", `CDP port must be in the range ${String(MIN_PORT)}-${String(MAX_PORT)}.`);
20
+ }
21
+ return value;
22
+ }
23
+ // Parses the URL with the WHATWG parser, rejects non-http(s) schemes, rewrites `localhost` to its
24
+ // literal IP before any further use, then enforces literal-IP loopback + bounded-port. Returns the
25
+ // canonical {url, originOnly, host, port} struct the CDP layer consumes.
26
+ export function normalizeNavigateUrl(raw) {
27
+ if (typeof raw !== "string" || raw.length === 0) {
28
+ throw new BrowserToolError("BAD_URL", "Navigate URL must be a non-empty string.");
29
+ }
30
+ const parsed = parseUrlOrThrow(raw);
31
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
32
+ throw new BrowserToolError("SCHEME_NOT_ALLOWED", "Navigate URL must use the http or https scheme.");
33
+ }
34
+ const rewritten = rewriteLocalhost(parsed);
35
+ const host = bareHost(rewritten);
36
+ if (!LOOPBACK_HOSTS.has(host)) {
37
+ throw new BrowserToolError("ORIGIN_NOT_ALLOWED", "Navigate URL host must be a loopback literal (127.0.0.1 or ::1).");
38
+ }
39
+ if (rewritten.port === "") {
40
+ throw new BrowserToolError("BAD_PORT", "Navigate URL must include an explicit port.");
41
+ }
42
+ const port = normalizeCdpPort(Number.parseInt(rewritten.port, 10));
43
+ return {
44
+ url: rewritten.toString(),
45
+ host,
46
+ originOnly: rewritten.origin,
47
+ port,
48
+ };
49
+ }
50
+ function parseUrlOrThrow(raw) {
51
+ try {
52
+ return new URL(raw);
53
+ }
54
+ catch {
55
+ throw new BrowserToolError("BAD_URL", "Navigate URL is not a valid URL.");
56
+ }
57
+ }
58
+ // `URL.hostname` returns `localhost` unchanged; we rewrite to 127.0.0.1 and rebuild the URL so the
59
+ // canonical .url field handed to CDP never contains `localhost`.
60
+ function rewriteLocalhost(parsed) {
61
+ if (parsed.hostname !== "localhost")
62
+ return parsed;
63
+ const clone = new URL(parsed.toString());
64
+ clone.hostname = "127.0.0.1";
65
+ return clone;
66
+ }
67
+ // URL.hostname returns `[::1]` for IPv6 input; strip brackets so the LOOPBACK_HOSTS set comparison
68
+ // is performed against the bare literal `::1`.
69
+ function bareHost(parsed) {
70
+ const h = parsed.hostname;
71
+ if (h.startsWith("[") && h.endsWith("]"))
72
+ return h.slice(1, -1);
73
+ return h;
74
+ }
75
+ // Narrow host-string check: takes a bare hostname (no scheme, no port, no brackets stripped)
76
+ // and returns true if it is a loopback literal. Used by session.ts to validate the host
77
+ // component of webSocketDebuggerUrl returned by /json/version (ADR-0017 D2 H1).
78
+ export function isLoopbackHost(host) {
79
+ return LOOPBACK_HOSTS.has(host);
80
+ }
81
+ // Re-check after a navigation completes (ADR-0017 D2 layer 2). The CDP `frameNavigated` event
82
+ // reports the effective URL; if it drifted to a non-loopback origin (server-side redirect), the
83
+ // session manager must stop loading and refuse subsequent capture. Pure: takes the post-navigate
84
+ // URL string and returns the typed result.
85
+ export function isLoopbackUrl(raw) {
86
+ let parsed;
87
+ try {
88
+ parsed = new URL(raw);
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ if (!ALLOWED_SCHEMES.has(parsed.protocol))
94
+ return false;
95
+ const host = bareHost(parsed);
96
+ return LOOPBACK_HOSTS.has(host);
97
+ }
@@ -0,0 +1,3 @@
1
+ export { TOOL_CODES, ToolError, ToolArgumentError, UnknownToolError, CommandDeniedError, CommandTimeoutError, CommandCancelledError, OutputLimitError, PatchValidationError, PatchApplyDisabledError, PatchApplyError, } from "@oscharko-dev/keiko-security/errors/tools";
2
+ export type { ToolCode } from "@oscharko-dev/keiko-security/errors/tools";
3
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,UAAU,EACV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,eAAe,GAChB,MAAM,2CAA2C,CAAC;AACnD,YAAY,EAAE,QAAQ,EAAE,MAAM,2CAA2C,CAAC"}
package/dist/errors.js ADDED
@@ -0,0 +1,4 @@
1
+ // Re-export shim: the tools error taxonomy now lives in @oscharko-dev/keiko-security
2
+ // (issue #159, ADR-0019). All existing import sites (`from "./errors.js"`) keep resolving
3
+ // unchanged via this barrel.
4
+ export { TOOL_CODES, ToolError, ToolArgumentError, UnknownToolError, CommandDeniedError, CommandTimeoutError, CommandCancelledError, OutputLimitError, PatchValidationError, PatchApplyDisabledError, PatchApplyError, } from "@oscharko-dev/keiko-security/errors/tools";
package/dist/exec.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { type BackendAvailability } from "@oscharko-dev/keiko-sandbox";
3
+ import { type WorkspaceFs, type WorkspaceInfo } from "@oscharko-dev/keiko-workspace";
4
+ import type { CommandResult, CommandRule, SandboxPolicy } from "./types.js";
5
+ export interface SpawnOptions {
6
+ readonly cwd: string;
7
+ readonly env: Record<string, string>;
8
+ readonly shell: false;
9
+ readonly detached: boolean;
10
+ }
11
+ export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
12
+ export interface ExecutableResolverDeps {
13
+ readonly workspace: WorkspaceInfo;
14
+ readonly processEnv: NodeJS.ProcessEnv;
15
+ readonly fs?: WorkspaceFs | undefined;
16
+ }
17
+ export type ExecutableResolver = (command: string, deps: ExecutableResolverDeps) => string;
18
+ export declare const nodeSpawnFn: SpawnFn;
19
+ export interface HomeProvider {
20
+ readonly make: () => string;
21
+ readonly cleanup: (dir: string) => void;
22
+ }
23
+ export declare const nodeHomeProvider: HomeProvider;
24
+ export interface RunCommandDeps {
25
+ readonly workspace: WorkspaceInfo;
26
+ readonly policy: SandboxPolicy;
27
+ readonly commandRules: readonly CommandRule[];
28
+ readonly spawn: SpawnFn;
29
+ readonly resolveExecutable?: ExecutableResolver | undefined;
30
+ readonly processEnv: NodeJS.ProcessEnv;
31
+ readonly now: () => number;
32
+ readonly fs?: WorkspaceFs | undefined;
33
+ readonly home?: HomeProvider | undefined;
34
+ readonly sandboxAvailability?: BackendAvailability | undefined;
35
+ readonly platform?: NodeJS.Platform | undefined;
36
+ }
37
+ export interface RunCommandInput {
38
+ readonly command: string;
39
+ readonly args: readonly string[];
40
+ readonly cwd: string | undefined;
41
+ readonly timeoutMs: number | undefined;
42
+ readonly signal: AbortSignal;
43
+ }
44
+ export declare function runCommand(input: RunCommandInput, deps: RunCommandDeps): Promise<CommandResult>;
45
+ //# sourceMappingURL=exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAKvD,OAAO,EAGL,KAAK,mBAAmB,EACzB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,aAAa,EACnB,MAAM,+BAA+B,CAAC;AAIvC,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAsB,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhG,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,MAAM,OAAO,GAAG,CACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,YAAY,KAClB,YAAY,CAAC;AAElB,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC;IACvC,QAAQ,CAAC,EAAE,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CACvC;AAED,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,sBAAsB,KAAK,MAAM,CAAC;AAE3F,eAAO,MAAM,WAAW,EAAE,OACc,CAAC;AAKzC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,eAAO,MAAM,gBAAgB,EAAE,YAM9B,CAAC;AAEF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,SAAS,WAAW,EAAE,CAAC;IAC9C,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,iBAAiB,CAAC,EAAE,kBAAkB,GAAG,SAAS,CAAC;IAC5D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC;IACvC,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAE3B,QAAQ,CAAC,EAAE,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAEtC,QAAQ,CAAC,IAAI,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;IAKzC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;IAC/D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,GAAG,SAAS,CAAC;CACjD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AA6bD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,EAAE,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CA2B/F"}
package/dist/exec.js ADDED
@@ -0,0 +1,372 @@
1
+ // Command execution — the spawn boundary. Deny-by-default allowlist is checked BEFORE any
2
+ // spawn; the child runs with a clean name-allowlisted env, no shell, and a resolved-in-workspace
3
+ // cwd. Timeout and abort both kill the process group (SIGTERM→SIGKILL after the grace period).
4
+ // stdout/stderr are byte-capped and redacted before they leave this layer (ADR-0006 D3/D5).
5
+ //
6
+ // node:child_process is imported ONLY for the default SpawnFn adapter; all decision logic lives
7
+ // in sandbox.ts (pure). Tests inject a fake SpawnFn for the allowlist/timeout/cancel paths and a
8
+ // real `node`-spawn for the env-isolation / no-shell / real-cancellation integration cases.
9
+ import { spawn as nodeSpawn } from "node:child_process";
10
+ import { accessSync, constants, mkdtempSync, realpathSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { delimiter, join, resolve as resolvePath } from "node:path";
13
+ import { redact } from "@oscharko-dev/keiko-security";
14
+ import { planIsolatedRun, probeBackends, } from "@oscharko-dev/keiko-sandbox";
15
+ import { containedRealPathInfo, isDenied, isWithinWorkspace, PathDeniedError, resolveWithinWorkspace, } from "@oscharko-dev/keiko-workspace";
16
+ import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
17
+ import { CommandCancelledError, CommandDeniedError, CommandTimeoutError } from "./errors.js";
18
+ import { buildSandboxEnv, collectSensitiveEnvValues, isCommandAllowed } from "./sandbox.js";
19
+ export const nodeSpawnFn = (command, args, options) => nodeSpawn(command, [...args], options);
20
+ export const nodeHomeProvider = {
21
+ make: () => mkdtempSync(join(tmpdir(), "keiko-home-")),
22
+ cleanup: (dir) => {
23
+ // Best-effort: a leftover temp dir is not worth failing or rejecting the command over.
24
+ rmSync(dir, { recursive: true, force: true });
25
+ },
26
+ };
27
+ const POSIX = process.platform !== "win32";
28
+ // Kills the whole process group on POSIX (negative pid) so orphaned grandchildren die too;
29
+ // on Windows, best-effort child.kill() (a tree-kill needs a dependency we cannot add).
30
+ function killGroup(child, sig) {
31
+ const pid = child.pid;
32
+ if (pid === undefined) {
33
+ return;
34
+ }
35
+ try {
36
+ if (POSIX) {
37
+ process.kill(-pid, sig);
38
+ }
39
+ else {
40
+ child.kill(sig);
41
+ }
42
+ }
43
+ catch {
44
+ // The child already exited; nothing to signal. Swallowing here keeps termination idempotent.
45
+ }
46
+ }
47
+ const TRUNCATED_OUTPUT_MARKER = "[TRUNCATED OUTPUT REDACTED]";
48
+ function hasPathSeparator(value) {
49
+ return value.includes("/") || value.includes("\\");
50
+ }
51
+ function hasNul(value) {
52
+ return value.includes("\u0000");
53
+ }
54
+ function realRoot(fs, root) {
55
+ try {
56
+ return fs.realPath(root);
57
+ }
58
+ catch {
59
+ return root;
60
+ }
61
+ }
62
+ function assertBareExecutable(command) {
63
+ if (command.length === 0 || hasNul(command) || hasPathSeparator(command)) {
64
+ throw new CommandDeniedError("executable must be a bare PATH-resolved name", command);
65
+ }
66
+ }
67
+ function pathEntries(processEnv) {
68
+ const pathValue = processEnv.PATH ?? "";
69
+ return pathValue.length === 0 ? [] : pathValue.split(delimiter).filter(Boolean);
70
+ }
71
+ function executableExtensions(processEnv) {
72
+ if (process.platform !== "win32") {
73
+ return [""];
74
+ }
75
+ return (processEnv.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
76
+ .split(";")
77
+ .filter((value) => value.length > 0);
78
+ }
79
+ function candidateExecutable(command, rawEntry, ext) {
80
+ const candidate = resolvePath(resolvePath(rawEntry), command + ext);
81
+ try {
82
+ accessSync(candidate, constants.X_OK);
83
+ return { path: candidate, real: realpathSync(candidate) };
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ function assertExecutableOutsideWorkspace(command, lexicalWorkspaceRoot, realWorkspaceRoot, candidate) {
90
+ if (isWithinWorkspace(lexicalWorkspaceRoot, candidate.path) ||
91
+ isWithinWorkspace(realWorkspaceRoot, candidate.real)) {
92
+ throw new CommandDeniedError(`executable resolves inside workspace: ${command}`, command);
93
+ }
94
+ }
95
+ function defaultResolveExecutable(command, deps) {
96
+ assertBareExecutable(command);
97
+ const fs = deps.fs ?? nodeWorkspaceFs;
98
+ const lexicalWorkspaceRoot = deps.workspace.root;
99
+ const realWorkspaceRoot = realRoot(fs, lexicalWorkspaceRoot);
100
+ for (const rawEntry of pathEntries(deps.processEnv)) {
101
+ for (const ext of executableExtensions(deps.processEnv)) {
102
+ const candidate = candidateExecutable(command, rawEntry, ext);
103
+ if (candidate === undefined) {
104
+ continue;
105
+ }
106
+ assertExecutableOutsideWorkspace(command, lexicalWorkspaceRoot, realWorkspaceRoot, candidate);
107
+ return candidate.real;
108
+ }
109
+ }
110
+ throw new CommandDeniedError(`executable not found on PATH: ${command}`, command);
111
+ }
112
+ // Resolves the isolation wrapper binary (e.g. bwrap) to a real absolute path through the same
113
+ // resolver as the inner command, so the wrapper is PATH-resolved and proven to live outside the
114
+ // workspace before it is ever spawned.
115
+ function resolveWrapperExecutable(name, deps) {
116
+ const resolver = deps.resolveExecutable ?? defaultResolveExecutable;
117
+ return resolver(name, { workspace: deps.workspace, processEnv: deps.processEnv, fs: deps.fs });
118
+ }
119
+ // Decides what to spawn. Inherited network → run the executable directly. network:"none" → ask
120
+ // keiko-sandbox for an enforcing wrapper; a fail-closed decision throws (the command never spawns),
121
+ // so untrusted code is never executed without an enforced egress boundary.
122
+ function resolveSpawnTarget(input, deps, executable, cwd) {
123
+ if (deps.policy.network !== "none") {
124
+ return { command: executable, args: input.args, attestation: undefined };
125
+ }
126
+ const platform = deps.platform ?? process.platform;
127
+ const availability = deps.sandboxAvailability ?? probeBackends(deps.processEnv, platform);
128
+ const decision = planIsolatedRun({
129
+ command: executable,
130
+ args: input.args,
131
+ cwd,
132
+ network: "none",
133
+ filesystem: deps.policy.filesystem ?? "inherit",
134
+ }, availability, platform);
135
+ if (decision.kind === "fail-closed") {
136
+ throw new CommandDeniedError(decision.reason, input.command);
137
+ }
138
+ if (decision.kind === "passthrough") {
139
+ return { command: executable, args: input.args, attestation: decision.attestation };
140
+ }
141
+ return {
142
+ command: resolveWrapperExecutable(decision.command, deps),
143
+ args: decision.args,
144
+ attestation: decision.attestation,
145
+ };
146
+ }
147
+ function appendCapped(buffers, sink, chunk, max) {
148
+ if (buffers.truncated) {
149
+ return false;
150
+ }
151
+ const remaining = max - buffers.total;
152
+ if (chunk.length <= remaining) {
153
+ sink.push(chunk);
154
+ buffers.total += chunk.length;
155
+ return false;
156
+ }
157
+ if (remaining > 0) {
158
+ sink.push(chunk.subarray(0, remaining));
159
+ buffers.total = max;
160
+ }
161
+ buffers.truncated = true;
162
+ return true; // signals the caller to kill the child (flood protection)
163
+ }
164
+ // Resolves the validated cwd. Lexical containment first, then symlink containment via realpath
165
+ // (S-H1): a cwd that is a symlink escaping the root or resolving into an always-denied path must
166
+ // not become the spawn cwd. Both cases surface as workspace path errors, which the host maps to a
167
+ // tool error — the command never spawns.
168
+ function resolveCwd(deps, cwd) {
169
+ const lexical = resolveWithinWorkspace(deps.workspace.root, cwd ?? ".");
170
+ const fs = deps.fs ?? nodeWorkspaceFs;
171
+ const rel = lexical.slice(deps.workspace.root.length).replace(/^[/\\]/, "");
172
+ if (isDenied(rel === "" ? (cwd ?? ".") : rel)) {
173
+ throw new PathDeniedError("path matches an always-on deny pattern", cwd ?? ".");
174
+ }
175
+ const info = containedRealPathInfo(fs, deps.workspace.root, lexical);
176
+ if (isDenied(info.realRelative)) {
177
+ throw new PathDeniedError("path matches an always-on deny pattern", cwd ?? ".");
178
+ }
179
+ return lexical;
180
+ }
181
+ function buildResult(input, buffers, state, exitCode, termSignal, deps, startedAt, attestation) {
182
+ const secrets = collectSensitiveEnvValues(deps.processEnv, deps.policy.envAllowlist);
183
+ const attest = attestation === undefined ? {} : { attestation };
184
+ if (buffers.truncated) {
185
+ return {
186
+ command: input.command,
187
+ args: input.args,
188
+ exitCode,
189
+ signal: termSignal,
190
+ stdout: TRUNCATED_OUTPUT_MARKER,
191
+ stderr: TRUNCATED_OUTPUT_MARKER,
192
+ durationMs: deps.now() - startedAt,
193
+ timedOut: state.timedOut,
194
+ truncated: true,
195
+ ...attest,
196
+ };
197
+ }
198
+ return {
199
+ command: input.command,
200
+ args: input.args,
201
+ exitCode,
202
+ signal: termSignal,
203
+ stdout: redact(Buffer.concat(buffers.out).toString("utf8"), secrets),
204
+ stderr: redact(Buffer.concat(buffers.err).toString("utf8"), secrets),
205
+ durationMs: deps.now() - startedAt,
206
+ timedOut: state.timedOut,
207
+ truncated: buffers.truncated,
208
+ ...attest,
209
+ };
210
+ }
211
+ function cleanup(state, signal) {
212
+ if (state.timer !== undefined) {
213
+ clearTimeout(state.timer);
214
+ }
215
+ if (state.graceTimer !== undefined) {
216
+ clearTimeout(state.graceTimer);
217
+ }
218
+ if (state.onAbort !== undefined) {
219
+ signal.removeEventListener("abort", state.onAbort);
220
+ }
221
+ // Remove the ephemeral HOME exactly once, on whichever settle path fires first (C5).
222
+ if (!state.homeCleaned && state.home !== undefined && state.homeDir !== undefined) {
223
+ state.homeCleaned = true;
224
+ state.home.cleanup(state.homeDir);
225
+ }
226
+ }
227
+ // Escalates from SIGTERM to SIGKILL after the grace period so a child ignoring SIGTERM is still
228
+ // guaranteed to terminate within terminationGraceMs of the trigger.
229
+ function terminate(child, policy, state) {
230
+ killGroup(child, "SIGTERM");
231
+ state.graceTimer = setTimeout(() => {
232
+ killGroup(child, "SIGKILL");
233
+ }, policy.terminationGraceMs);
234
+ state.graceTimer.unref();
235
+ }
236
+ function wireStreams(child, buffers, policy, state) {
237
+ const onData = (sink) => (chunk) => {
238
+ if (appendCapped(buffers, sink, chunk, policy.maxOutputBytes)) {
239
+ terminate(child, policy, state); // output flood → kill
240
+ }
241
+ };
242
+ child.stdout?.on("data", onData(buffers.out));
243
+ child.stderr?.on("data", onData(buffers.err));
244
+ }
245
+ function settleOnClose(ctx, resolve, reject) {
246
+ ctx.child.on("close", (code, signalName) => {
247
+ if (ctx.state.settled) {
248
+ return;
249
+ }
250
+ ctx.state.settled = true;
251
+ cleanup(ctx.state, ctx.input.signal);
252
+ if (ctx.state.timedOut) {
253
+ reject(new CommandTimeoutError("command timed out", timeoutOf(ctx)));
254
+ return;
255
+ }
256
+ if (ctx.input.signal.aborted) {
257
+ reject(new CommandCancelledError("command cancelled"));
258
+ return;
259
+ }
260
+ resolve(buildResult(ctx.input, ctx.buffers, ctx.state, code, signalName, ctx.deps, ctx.startedAt, ctx.attestation));
261
+ });
262
+ ctx.child.on("error", (error) => {
263
+ if (ctx.state.settled) {
264
+ return;
265
+ }
266
+ ctx.state.settled = true;
267
+ cleanup(ctx.state, ctx.input.signal);
268
+ reject(error);
269
+ });
270
+ }
271
+ function timeoutOf(ctx) {
272
+ return ctx.input.timeoutMs ?? ctx.deps.policy.defaultTimeoutMs;
273
+ }
274
+ function armTimersAndAbort(ctx) {
275
+ const ms = timeoutOf(ctx);
276
+ ctx.state.timer = setTimeout(() => {
277
+ ctx.state.timedOut = true;
278
+ terminate(ctx.child, ctx.deps.policy, ctx.state);
279
+ }, ms);
280
+ ctx.state.timer.unref();
281
+ const onAbort = () => {
282
+ terminate(ctx.child, ctx.deps.policy, ctx.state);
283
+ };
284
+ ctx.state.onAbort = onAbort;
285
+ if (ctx.input.signal.aborted) {
286
+ onAbort();
287
+ }
288
+ else {
289
+ ctx.input.signal.addEventListener("abort", onAbort, { once: true });
290
+ }
291
+ }
292
+ function asError(error, fallback) {
293
+ return error instanceof Error ? error : new Error(fallback);
294
+ }
295
+ function validateRunCommandInput(input, deps) {
296
+ if (!Array.isArray(deps.policy.envAllowlist) || deps.policy.envAllowlist.length === 0) {
297
+ throw new CommandDeniedError("sandbox envAllowlist must be a non-empty array", input.command);
298
+ }
299
+ const decision = isCommandAllowed(deps.commandRules, input.command, input.args);
300
+ if (!decision.allowed) {
301
+ throw new CommandDeniedError(decision.reason ?? "command denied", input.command);
302
+ }
303
+ }
304
+ function resolveExecutable(input, deps) {
305
+ const resolver = deps.resolveExecutable ?? defaultResolveExecutable;
306
+ return resolver(input.command, {
307
+ workspace: deps.workspace,
308
+ processEnv: deps.processEnv,
309
+ fs: deps.fs,
310
+ });
311
+ }
312
+ function createRunState(home, homeDir) {
313
+ return {
314
+ settled: false,
315
+ timedOut: false,
316
+ timer: undefined,
317
+ graceTimer: undefined,
318
+ onAbort: undefined,
319
+ home,
320
+ homeDir,
321
+ homeCleaned: false,
322
+ };
323
+ }
324
+ function spawnChild(input, deps, target, cwd, env, state) {
325
+ try {
326
+ return deps.spawn(target.command, target.args, { cwd, env, shell: false, detached: POSIX });
327
+ }
328
+ catch (error) {
329
+ cleanup(state, input.signal);
330
+ throw asError(error, "spawn failed");
331
+ }
332
+ }
333
+ function runSpawnedChild(ctx) {
334
+ return new Promise((resolve, reject) => {
335
+ wireStreams(ctx.child, ctx.buffers, ctx.deps.policy, ctx.state);
336
+ settleOnClose(ctx, resolve, reject);
337
+ armTimersAndAbort(ctx);
338
+ });
339
+ }
340
+ // Runs an allowlisted command. Rejects with CommandDeniedError (before spawn) for a denied
341
+ // command or a workspace-escaping cwd (PathEscapeError), CommandTimeoutError on timeout, and
342
+ // CommandCancelledError on abort; otherwise resolves a redacted, byte-capped CommandResult. All
343
+ // failure paths are Promise rejections — the function never throws synchronously.
344
+ export function runCommand(input, deps) {
345
+ try {
346
+ validateRunCommandInput(input, deps);
347
+ const executable = resolveExecutable(input, deps);
348
+ const cwd = resolveCwd(deps, input.cwd);
349
+ const target = resolveSpawnTarget(input, deps, executable, cwd);
350
+ const env = buildSandboxEnv(deps.processEnv, deps.policy.envAllowlist);
351
+ const home = deps.home ?? nodeHomeProvider;
352
+ const homeDir = home.make();
353
+ env.HOME = homeDir;
354
+ env.USERPROFILE = homeDir;
355
+ const state = createRunState(home, homeDir);
356
+ const child = spawnChild(input, deps, target, cwd, env, state);
357
+ const buffers = { out: [], err: [], total: 0, truncated: false };
358
+ const ctx = {
359
+ child,
360
+ input,
361
+ deps,
362
+ buffers,
363
+ state,
364
+ startedAt: deps.now(),
365
+ attestation: target.attestation,
366
+ };
367
+ return runSpawnedChild(ctx);
368
+ }
369
+ catch (error) {
370
+ return Promise.reject(asError(error, "command execution failed"));
371
+ }
372
+ }
@@ -0,0 +1,20 @@
1
+ export type { CommandResult, CommandRule, CommandRunInput, FilesystemPolicy, NetworkPolicy, PatchApplyResult, PatchChangeKind, PatchConflict, PatchFileChange, PatchHunk, PatchLimits, PatchRejection, PatchRejectionCode, PatchValidation, SandboxPolicy, ToolHostConfig, ToolHostConfigInput, } from "./types.js";
2
+ export { DEFAULT_COMMAND_RULES, DEFAULT_ENV_ALLOWLIST, DEFAULT_PATCH_LIMITS, DEFAULT_SANDBOX_POLICY, DEFAULT_TOOL_HOST_CONFIG, resolveToolHostConfig, } from "./types.js";
3
+ export { CommandCancelledError, CommandDeniedError, CommandTimeoutError, OutputLimitError, PatchApplyDisabledError, PatchApplyError, PatchValidationError, TOOL_CODES, ToolArgumentError, ToolError, UnknownToolError, type ToolCode, } from "./errors.js";
4
+ export { buildSandboxEnv, collectSensitiveEnvValues, isCommandAllowed, type CommandDecision, } from "./sandbox.js";
5
+ export type { WorkspaceWriter } from "./writer.js";
6
+ export { runCommand, type ExecutableResolver, type ExecutableResolverDeps, type HomeProvider, type RunCommandDeps, type RunCommandInput, type SpawnFn, type SpawnOptions, } from "./exec.js";
7
+ export { applyPatch, buildRestorePatch, invertPatch, renderDryRun, validatePatch, type ApplyDeps, type ValidateDeps, } from "./patch.js";
8
+ export { normalizeUnifiedDiffHunks } from "./patch-normalize.js";
9
+ export { parseUnifiedDiff, PatchParseError, type ParsedPatch } from "./patch-parse.js";
10
+ export { computeFileContent, type ApplyOutcome, type HunkConflict } from "./patch-content.js";
11
+ export { TOOL_DEFINITIONS } from "./schemas.js";
12
+ export { WorkspaceToolHost } from "./registry.js";
13
+ export * from "./terminal-policy.js";
14
+ export { BROWSER_ERROR_CODES, BrowserToolError, type BrowserErrorCode } from "./browser/errors.js";
15
+ export { isLoopbackHost, isLoopbackUrl, normalizeCdpPort, normalizeNavigateUrl, } from "./browser/validators.js";
16
+ export type { BrowserContentResult, BrowserNavigateResult, BrowserScreenshotPersisted, BrowserScreenshotPreview, BrowserScreenshotResult, BrowserSessionMeta, BrowserSessionStatus, BrowserViewportPx, CdpReachability, NormalizedNavigateUrl, } from "./browser/types.js";
17
+ export { CdpClient, PERMITTED_CDP_METHODS, type CdpCloseListener, type CdpClientOptions, type CdpEventListener, } from "./browser/cdp-client.js";
18
+ export { createBrowserSessionManager, type BrowserEventEmitter, type BrowserEventEnvelope, type BrowserEventKind, type BrowserSessionManager, type BrowserSessionManagerOptions, type BrowserSideFileWriter, } from "./browser/session.js";
19
+ export { KEIKO_TOOLS_VERSION } from "./version.js";
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,YAAY,EACV,aAAa,EACb,WAAW,EACX,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,eAAe,EACf,SAAS,EACT,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,cAAc,EACd,mBAAmB,GACpB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,oBAAoB,EACpB,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,YAAY,CAAC;AAGpB,OAAO,EACL,qBAAqB,EACrB,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,UAAU,EACV,iBAAiB,EACjB,SAAS,EACT,gBAAgB,EAChB,KAAK,QAAQ,GACd,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,eAAe,EACf,yBAAyB,EACzB,gBAAgB,EAChB,KAAK,eAAe,GACrB,MAAM,cAAc,CAAC;AAGtB,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD,OAAO,EACL,UAAU,EACV,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,YAAY,GAClB,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,UAAU,EACV,iBAAiB,EACjB,WAAW,EACX,YAAY,EACZ,aAAa,EACb,KAAK,SAAS,EACd,KAAK,YAAY,GAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACvF,OAAO,EAAE,kBAAkB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAG9F,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAKlD,cAAc,sBAAsB,CAAC;AAGrC,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EACL,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EACV,oBAAoB,EACpB,qBAAqB,EACrB,0BAA0B,EAC1B,wBAAwB,EACxB,uBAAuB,EACvB,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,qBAAqB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,SAAS,EACT,qBAAqB,EACrB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,GACtB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,2BAA2B,EAC3B,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,4BAA4B,EACjC,KAAK,qBAAqB,GAC3B,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC"}