@agentconnect.md/daemon 1.41.0-rc.35 → 1.41.0-rc.36

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { accessSync, constants, readFileSync, statSync } from "node:fs";
4
- import { spawn } from "node:child_process";
5
- import { delimiter, isAbsolute, join, resolve } from "node:path";
3
+ import { accessSync, chmodSync, constants, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { delimiter, dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
6
6
  //#region \0rolldown/runtime.js
7
7
  var __create = Object.create;
8
8
  var __defProp = Object.defineProperty;
@@ -8562,6 +8562,279 @@ var ShimClient = class {
8562
8562
  }
8563
8563
  };
8564
8564
  //#endregion
8565
+ //#region src/shim/file-sink.ts
8566
+ /** Relative path segments, rejecting anything that could escape the root. */
8567
+ const SinkRelPathSchema = array(string().min(1).max(255)).min(1).max(8).refine((segments) => segments.every((segment) => segment !== "." && segment !== ".." && !segment.includes("/") && !segment.includes("\\")), { message: "path segments must be plain names" });
8568
+ const FileSinkPayloadSchema = discriminatedUnion("op", [object({
8569
+ op: literal("write"),
8570
+ root: string().min(1),
8571
+ relPath: SinkRelPathSchema,
8572
+ content: string()
8573
+ }), object({
8574
+ op: literal("clear"),
8575
+ root: string().min(1)
8576
+ })]);
8577
+ /** Resolve a sink path and refuse anything that leaves the root, whichever side runs it. */
8578
+ function resolveSinkPath(root, relPath) {
8579
+ if (!isAbsolute(root)) throw new Error(`sink root must be absolute: ${root}`);
8580
+ const base = resolve(root);
8581
+ const target = normalize(join(base, ...relPath));
8582
+ if (target !== base && !target.startsWith(base + sep)) throw new Error("sink path escapes its root");
8583
+ return target;
8584
+ }
8585
+ function mkdirPrivate(dir) {
8586
+ mkdirSync(dir, {
8587
+ recursive: true,
8588
+ mode: 448
8589
+ });
8590
+ try {
8591
+ chmodSync(dir, 448);
8592
+ } catch {}
8593
+ }
8594
+ /** Today's behaviour: write the daemon's own disk. */
8595
+ var LocalFileSink = class {
8596
+ async clear(root) {
8597
+ const dir = resolve(root);
8598
+ try {
8599
+ for (const entry of readdirSync(dir)) rmSync(join(dir, entry), {
8600
+ recursive: true,
8601
+ force: true
8602
+ });
8603
+ } catch (err) {
8604
+ if (err.code === "ENOENT") return void 0;
8605
+ return `config files could not be cleared (${err.message})`;
8606
+ }
8607
+ }
8608
+ async write(root, relPath, content) {
8609
+ const file = resolveSinkPath(root, relPath);
8610
+ const dir = resolve(root);
8611
+ if (dirname(file) !== dir) mkdirPrivate(dirname(file));
8612
+ else mkdirPrivate(dir);
8613
+ writeFileSync(file, content, { mode: 384 });
8614
+ try {
8615
+ chmodSync(file, 384);
8616
+ } catch {}
8617
+ }
8618
+ };
8619
+ /**
8620
+ * Apply a sink operation inside the sandbox. The shim re-validates the path itself rather
8621
+ * than trusting that the daemon already did: a check on the other side of a channel is not
8622
+ * a check on this side, and the shim is the half that touches the filesystem.
8623
+ */
8624
+ async function applyFileSinkPayload(payload, sink = new LocalFileSink()) {
8625
+ const parsed = FileSinkPayloadSchema.parse(payload);
8626
+ if (parsed.op === "clear") {
8627
+ const error = await sink.clear(parsed.root);
8628
+ if (error) throw new Error(error);
8629
+ return;
8630
+ }
8631
+ resolveSinkPath(parsed.root, parsed.relPath);
8632
+ await sink.write(parsed.root, parsed.relPath, parsed.content);
8633
+ }
8634
+ //#endregion
8635
+ //#region src/shim/git-exec.ts
8636
+ /**
8637
+ * The `exec` payload for a git invocation inside the sandbox.
8638
+ *
8639
+ * argv only, never a composed shell string: the arguments are assembled from workspace
8640
+ * configuration that a repository can influence, and a shell would turn that into an
8641
+ * injection surface. `cwd` is validated on the shim side too — a daemon-side check says
8642
+ * nothing about the filesystem the shim is standing on.
8643
+ */
8644
+ const GitExecPayloadSchema = object({
8645
+ tool: literal("git"),
8646
+ cwd: string().min(1).optional(),
8647
+ args: array(string()).min(1).max(64),
8648
+ /** How long the caller will wait, so the sandbox kills the child before the request is
8649
+ * abandoned — otherwise a slow git keeps running (and keeps index.lock) past the point
8650
+ * anything is listening for its result. Bounded again on the shim side. */
8651
+ timeoutMs: number().int().min(1e3).max(9e5).optional(),
8652
+ /** The COMPLETE environment for this invocation, replacing rather than extending whatever
8653
+ * the sandbox has. Callers build it by sanitizing (stripping host GIT_CONFIG_*, protocol
8654
+ * allowances and so on), so merging would defeat that; the shim must apply it as given.
8655
+ * Scoped to the request, so a runtime cannot read the credential pointers back out of its
8656
+ * own process environment afterwards. */
8657
+ env: record(string(), string()).optional()
8658
+ });
8659
+ object({
8660
+ code: number().int(),
8661
+ stdout: string(),
8662
+ stderr: string()
8663
+ });
8664
+ //#endregion
8665
+ //#region src/shim/exec-handler.ts
8666
+ /**
8667
+ * The git subcommands a sandbox will run, enforced HERE.
8668
+ *
8669
+ * The daemon declares a closed inventory, but a declaration on the sending side is not a
8670
+ * control: this process is the one that spawns git, so this is where the list has to be
8671
+ * checked. Anything else and a compromised daemon — or a bug in one — reaches arbitrary git,
8672
+ * which through `-c`, hooks and `--upload-pack` reaches arbitrary execution.
8673
+ */
8674
+ const ALLOWED_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set([
8675
+ "check-ref-format",
8676
+ "clean",
8677
+ "clone",
8678
+ "config",
8679
+ "diff",
8680
+ "fetch",
8681
+ "log",
8682
+ "pull",
8683
+ "remote",
8684
+ "reset",
8685
+ "rev-list",
8686
+ "rev-parse",
8687
+ "status",
8688
+ "update-ref",
8689
+ "worktree"
8690
+ ]);
8691
+ /**
8692
+ * Argument forms refused regardless of subcommand: each turns a git invocation into an
8693
+ * arbitrary-execution primitive, so no member of the inventory above may carry one.
8694
+ *
8695
+ * Matched as PREFIXES, never as exact tokens. Every one of these has at least three accepted
8696
+ * spellings — separated (`-c k=v`), attached (`-ck=v`) and long-with-equals (`--config=k=v`) —
8697
+ * all measured against git 2.43, and an exact-token list catches only the first. `-c` in
8698
+ * particular is not just a global option: `git clone -c` is clone's OWN option, which is how
8699
+ * `-cprotocol.ext.allow=always ext::<helper>` reaches a helper the caller names.
8700
+ *
8701
+ * No argv the daemon sends starts with `-c` or `--config` (`--count` does not match `/^-c/`,
8702
+ * whose second character is `c`), so the width costs nothing real.
8703
+ */
8704
+ const REFUSED_ARGUMENT = [
8705
+ /^-c/,
8706
+ /^--config/,
8707
+ /^--exec-path/,
8708
+ /^--upload-pack/,
8709
+ /^--receive-pack/
8710
+ ];
8711
+ /**
8712
+ * Options that reach execution for ONE subcommand, where the same spelling is ordinary
8713
+ * elsewhere — so the check has to be subcommand-aware rather than a blanket ban.
8714
+ *
8715
+ * `git clone -u <program>` is `--upload-pack` and runs the program, attached spelling included
8716
+ * (`-u<program>`); measured, not assumed. Meanwhile `status -u` is `--untracked-files` and
8717
+ * `fetch -u` is `--update-head-ok`, and the daemon sends both — a blanket `-u` refusal would
8718
+ * break every status call. `git config -e` opens `GIT_EDITOR`, also measured, and no call site
8719
+ * edits config.
8720
+ */
8721
+ const REFUSED_SUBCOMMAND_ARGUMENT = {
8722
+ clone: [/^-u/],
8723
+ config: [/^-e$/, /^--edit/]
8724
+ };
8725
+ /**
8726
+ * Per-stream raw ceiling — a cheap first bound, NOT the authoritative one.
8727
+ *
8728
+ * The result travels as ONE shim frame, and `MAX_FRAME_BYTES` is 256 KiB, so a 32 MiB buffer did
8729
+ * not mean "large results are allowed": it meant a large result was assembled and then dropped
8730
+ * by the transport, taking the channel with it.
8731
+ */
8732
+ const MAX_STREAM_BYTES = 64 * 1024;
8733
+ const MAX_RESPONSE_BYTES = MAX_FRAME_BYTES - 4 * 1024;
8734
+ /** Shell convention for a signalled child, so a killed git is never mistaken for exit 0. */
8735
+ const SIGNAL_EXIT_BASE = 128;
8736
+ const SIGNAL_NUMBERS = {
8737
+ SIGTERM: 15,
8738
+ SIGKILL: 9,
8739
+ SIGINT: 2,
8740
+ SIGHUP: 1
8741
+ };
8742
+ /** Applied when the caller names no deadline; the ceiling bounds one that is too generous. */
8743
+ const DEFAULT_TIMEOUT_MS = 12e4;
8744
+ const MAX_TIMEOUT_MS = 15 * 6e4;
8745
+ var ExecRefusedError = class extends Error {
8746
+ constructor(message) {
8747
+ super(message);
8748
+ this.name = "ExecRefusedError";
8749
+ }
8750
+ };
8751
+ /** Resolve symlinks before comparing: a lexical prefix check passes for `<root>/link` even when
8752
+ * the link points outside, so containment has to be decided on canonical paths. */
8753
+ function canonical(path) {
8754
+ try {
8755
+ return realpathSync(normalize(resolve(path)));
8756
+ } catch {
8757
+ throw new ExecRefusedError(`cwd does not resolve: ${path}`);
8758
+ }
8759
+ }
8760
+ /** Refuse a cwd that escapes the workspace root, whatever the daemon asked for. */
8761
+ function resolveCwd(root, requested) {
8762
+ const base = canonical(root);
8763
+ if (!requested) return base;
8764
+ if (!isAbsolute(requested)) throw new ExecRefusedError("cwd must be absolute");
8765
+ const target = canonical(requested);
8766
+ if (target !== base && !target.startsWith(base + sep)) throw new ExecRefusedError("cwd escapes the workspace root");
8767
+ return target;
8768
+ }
8769
+ /**
8770
+ * Serve the shim's non-ACP capabilities inside the sandbox.
8771
+ *
8772
+ * Every check here duplicates one the daemon already made, deliberately. The daemon is the
8773
+ * trusted half and this is the half holding the filesystem: a check that only runs on the far
8774
+ * side of a channel protects nothing on this side.
8775
+ */
8776
+ function createExecHandler(deps) {
8777
+ return async (capability, payload) => {
8778
+ if (capability === "materialize") {
8779
+ await applyFileSinkPayload(payload);
8780
+ return null;
8781
+ }
8782
+ if (capability === "exec") return runGit(payload, deps);
8783
+ throw new ExecRefusedError(`capability ${capability} is not served by this handler`);
8784
+ };
8785
+ }
8786
+ async function runGit(payload, deps) {
8787
+ const parsed = GitExecPayloadSchema.parse(payload);
8788
+ const [subcommand, ...rest] = parsed.args;
8789
+ if (!subcommand || !ALLOWED_GIT_SUBCOMMANDS.has(subcommand)) throw new ExecRefusedError(`git ${subcommand ?? "(none)"} is not in the permitted inventory`);
8790
+ const perSubcommand = REFUSED_SUBCOMMAND_ARGUMENT[subcommand] ?? [];
8791
+ for (const argument of parsed.args) if (REFUSED_ARGUMENT.some((pattern) => pattern.test(argument))) throw new ExecRefusedError(`argument ${argument} is refused`);
8792
+ for (const argument of rest) if (perSubcommand.some((pattern) => pattern.test(argument))) throw new ExecRefusedError(`argument ${argument} is refused for git ${subcommand}`);
8793
+ const cwd = resolveCwd(deps.workspaceRoot, parsed.cwd);
8794
+ const timeoutMs = Math.min(parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS, deps.timeoutMs ?? MAX_TIMEOUT_MS);
8795
+ return await new Promise((resolvePromise, reject) => {
8796
+ execFile("git", parsed.args, {
8797
+ cwd,
8798
+ ...parsed.env ? { env: parsed.env } : {},
8799
+ timeout: timeoutMs,
8800
+ killSignal: "SIGTERM",
8801
+ maxBuffer: MAX_STREAM_BYTES
8802
+ }, (error, stdout, stderr) => {
8803
+ const failure = error;
8804
+ if (failure?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
8805
+ reject(new ExecRefusedError(`git ${subcommand} produced more than ${MAX_STREAM_BYTES} bytes on one stream, which does not fit a shim frame`));
8806
+ return;
8807
+ }
8808
+ const deliver = (result) => {
8809
+ const serialized = Buffer.byteLength(JSON.stringify(result), "utf8");
8810
+ if (serialized > MAX_RESPONSE_BYTES) {
8811
+ reject(new ExecRefusedError(`git ${subcommand} result is ${serialized} bytes once encoded, over the ${MAX_RESPONSE_BYTES} a shim frame can carry`));
8812
+ return;
8813
+ }
8814
+ resolvePromise(result);
8815
+ };
8816
+ if (failure && (failure.killed === true || typeof failure.signal === "string")) {
8817
+ const signal = failure.signal ?? "";
8818
+ deliver({
8819
+ code: SIGNAL_EXIT_BASE + (SIGNAL_NUMBERS[signal] ?? 0),
8820
+ stdout: String(stdout),
8821
+ stderr: `${String(stderr)}\ngit ${subcommand} was terminated${signal ? ` by ${signal}` : ""} after ${timeoutMs}ms`
8822
+ });
8823
+ return;
8824
+ }
8825
+ if (failure && typeof failure.code === "string") {
8826
+ reject(new ExecRefusedError(`git could not be run: ${failure.message}`));
8827
+ return;
8828
+ }
8829
+ deliver({
8830
+ code: typeof failure?.code === "number" ? failure.code : 0,
8831
+ stdout: String(stdout),
8832
+ stderr: String(stderr)
8833
+ });
8834
+ });
8835
+ });
8836
+ }
8837
+ //#endregion
8565
8838
  //#region src/shim/path-resolve.ts
8566
8839
  /**
8567
8840
  * Resolve a command in THIS filesystem, which inside a sandbox is the only one that counts.
@@ -8609,6 +8882,10 @@ async function main() {
8609
8882
  path: opts.path
8610
8883
  }),
8611
8884
  resolveCommand: resolveCommandInPath,
8885
+ handle: createExecHandler({
8886
+ workspaceRoot: process.env["AC_SHIM_WORKSPACE_ROOT"] ?? "/agent",
8887
+ log
8888
+ }),
8612
8889
  log
8613
8890
  });
8614
8891
  for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {