sept-agent 0.1.2 → 0.1.4

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 (52) hide show
  1. package/CONTRIBUTING.md +8 -4
  2. package/README.md +33 -8
  3. package/dist/src/cli/commands.js +4 -0
  4. package/dist/src/cli/commands.js.map +1 -1
  5. package/dist/src/cli/hands-docker.d.ts +7 -0
  6. package/dist/src/cli/hands-docker.js +182 -0
  7. package/dist/src/cli/hands-docker.js.map +1 -0
  8. package/dist/src/cli/hands-finish.d.ts +2 -0
  9. package/dist/src/cli/hands-finish.js +164 -0
  10. package/dist/src/cli/hands-finish.js.map +1 -0
  11. package/dist/src/cli/hands-owner.d.ts +10 -0
  12. package/dist/src/cli/hands-owner.js +83 -0
  13. package/dist/src/cli/hands-owner.js.map +1 -0
  14. package/dist/src/cli/hands-record.d.ts +33 -0
  15. package/dist/src/cli/hands-record.js +164 -0
  16. package/dist/src/cli/hands-record.js.map +1 -0
  17. package/dist/src/cli/hands.d.ts +3 -0
  18. package/dist/src/cli/hands.js +169 -0
  19. package/dist/src/cli/hands.js.map +1 -0
  20. package/dist/src/cli/options.d.ts +1 -0
  21. package/dist/src/cli/options.js +2 -1
  22. package/dist/src/cli/options.js.map +1 -1
  23. package/dist/src/cli.js +1 -1
  24. package/dist/src/cli.js.map +1 -1
  25. package/dist/src/execution/hands-process-owner.d.ts +2 -4
  26. package/dist/src/execution/hands-process-owner.js +3 -1
  27. package/dist/src/execution/hands-process-owner.js.map +1 -1
  28. package/dist/src/execution/index.d.ts +4 -0
  29. package/dist/src/execution/index.js +4 -0
  30. package/dist/src/execution/index.js.map +1 -1
  31. package/examples/incident-investigator/README.md +4 -4
  32. package/examples/incident-investigator/package.json +1 -1
  33. package/examples/node-app/README.md +4 -4
  34. package/examples/node-app/package.json +1 -1
  35. package/package.json +1 -8
  36. package/src/cli/commands.ts +4 -0
  37. package/src/cli/hands-docker.ts +198 -0
  38. package/src/cli/hands-finish.ts +204 -0
  39. package/src/cli/hands-owner.ts +118 -0
  40. package/src/cli/hands-record.ts +213 -0
  41. package/src/cli/hands.ts +207 -0
  42. package/src/cli/options.ts +3 -1
  43. package/src/cli.ts +1 -1
  44. package/src/execution/hands-process-owner.ts +4 -5
  45. package/src/execution/index.ts +10 -0
  46. package/docs/architecture.md +0 -168
  47. package/docs/index.md +0 -55
  48. package/docs/models.md +0 -78
  49. package/docs/observability.md +0 -63
  50. package/docs/pi-contract.md +0 -482
  51. package/docs/sdk.md +0 -180
  52. package/docs/worker-api.md +0 -138
@@ -0,0 +1,213 @@
1
+ /** Private ownership receipts for the optional local hands setup commands. */
2
+ import {
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ chmodSync,
8
+ openSync,
9
+ fsyncSync,
10
+ closeSync,
11
+ } from "node:fs";
12
+ import { dirname, isAbsolute, join, normalize } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import { atomicWrite, SeptError } from "../durable/index.js";
15
+
16
+ /** Fixed paths returned directly as existing createWorker options; no credential is generated. */
17
+ export interface HandsPaths {
18
+ stateRoot: string;
19
+ agentDir: string;
20
+ workspaceRoot: string;
21
+ handsConfigurationPath: string;
22
+ }
23
+
24
+ /** Durable exclusive setup identity survives partial creation and never grants task authority. */
25
+ export interface HandsReceipt {
26
+ version: 1;
27
+ owner: string;
28
+ engineSocket: string;
29
+ socketIdentity: string;
30
+ daemonId: string;
31
+ image: string;
32
+ containerId?: string;
33
+ generation?: string;
34
+ phase: "prepared" | "creating" | "running" | "ready" | "failed" | "finished";
35
+ }
36
+
37
+ /** Derive the sole supported fresh-instance layout. */
38
+ export function handsPaths(root: string): HandsPaths {
39
+ return {
40
+ stateRoot: join(root, "state"),
41
+ agentDir: join(root, "agent"),
42
+ workspaceRoot: join(root, "workspace"),
43
+ handsConfigurationPath: join(root, "hands.json"),
44
+ };
45
+ }
46
+
47
+ /** Refuse lexical aliases and mutable ancestors before creating or reading ownership. */
48
+ export function trustedHandsRoot(value: string | undefined, fresh = false): string {
49
+ if (
50
+ value === undefined ||
51
+ !isAbsolute(value) ||
52
+ value.length > 4096 ||
53
+ /[\x00-\x1f,]/.test(value)
54
+ ) {
55
+ throw new SeptError(
56
+ "hands requires a canonical absolute --root without control characters or commas",
57
+ );
58
+ }
59
+ const existing = fresh ? dirname(value) : value;
60
+ if (realpathSync(existing) !== existing || normalize(value) !== value) {
61
+ throw new SeptError("Hands root must be canonical");
62
+ }
63
+ validateAncestors(existing);
64
+ return value;
65
+ }
66
+
67
+ function validateAncestors(existing: string): void {
68
+ for (let path = existing; ; path = dirname(path)) {
69
+ const stat = lstatSync(path);
70
+ const owner = stat.uid === 0 || stat.uid === process.getuid?.();
71
+ const sticky = stat.uid === 0 && (stat.mode & 0o1000) !== 0;
72
+ if (!stat.isDirectory() || !owner || ((stat.mode & 0o022) !== 0 && !sticky)) {
73
+ throw new SeptError("Hands root ancestry is not trusted");
74
+ }
75
+ if (path === dirname(path)) {
76
+ return;
77
+ }
78
+ }
79
+ }
80
+
81
+ /** Create only a new root: a failed or foreign instance is never overwritten or adopted by init. */
82
+ export function createHandsRoot(root: string): HandsPaths {
83
+ mkdirSync(root, { mode: 0o755 });
84
+ chmodSync(root, 0o755);
85
+ const paths = handsPaths(root);
86
+ for (const directory of [
87
+ paths.stateRoot,
88
+ paths.agentDir,
89
+ paths.workspaceRoot,
90
+ join(root, "evidence"),
91
+ join(root, "docker-cli"),
92
+ ]) {
93
+ mkdirSync(directory, { mode: 0o700 });
94
+ }
95
+ mkdirSync(join(root, "resources"), { mode: 0o755 });
96
+ chmodSync(join(root, "resources"), 0o755);
97
+ syncSetupDirectory(root);
98
+ syncSetupDirectory(dirname(root));
99
+ return paths;
100
+ }
101
+
102
+ function syncSetupDirectory(path: string): void {
103
+ const descriptor = openSync(path, "r");
104
+ try {
105
+ fsyncSync(descriptor);
106
+ } finally {
107
+ closeSync(descriptor);
108
+ }
109
+ }
110
+
111
+ /** Persist recovery ownership before creating a container and after every identity transition. */
112
+ export function writeHandsReceipt(root: string, receipt: HandsReceipt): void {
113
+ atomicWrite(join(root, "evidence", "setup.json"), receipt);
114
+ }
115
+
116
+ /** Read bounded private ownership, refusing edited schema, symlinks, hardlinks or broad permissions. */
117
+ export function readHandsReceipt(root: string): HandsReceipt {
118
+ const path = join(root, "evidence", "setup.json");
119
+ const raw = readHandsMetadata(path, 16 * 1024);
120
+ if (!isRecord(raw)) {
121
+ throw new SeptError("Hands setup ownership is malformed");
122
+ }
123
+ return decodeReceipt(raw);
124
+ }
125
+
126
+ /** Read private bounded setup metadata without presenting hostile JSON or filesystem diagnostics. */
127
+ export function readHandsMetadata(path: string, maximum: number): unknown {
128
+ try {
129
+ const file = lstatSync(path);
130
+ if (
131
+ !file.isFile() ||
132
+ file.nlink !== 1 ||
133
+ file.uid !== process.getuid?.() ||
134
+ (file.mode & 0o077) !== 0 ||
135
+ file.size > maximum
136
+ ) {
137
+ throw new SeptError("Hands metadata must be a bounded private owned regular file");
138
+ }
139
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
140
+ return raw;
141
+ } catch (error: unknown) {
142
+ throw new SeptError("Hands setup metadata is unavailable or malformed", { cause: error });
143
+ }
144
+ }
145
+
146
+ function isRecord(value: unknown): value is Record<string, unknown> {
147
+ return typeof value === "object" && value !== null && !Array.isArray(value);
148
+ }
149
+
150
+ function decodeReceipt(raw: Record<string, unknown>): HandsReceipt {
151
+ const keys = [
152
+ "version",
153
+ "owner",
154
+ "engineSocket",
155
+ "socketIdentity",
156
+ "daemonId",
157
+ "image",
158
+ "containerId",
159
+ "generation",
160
+ "phase",
161
+ ];
162
+ if (Object.keys(raw).some((key) => !keys.includes(key)) || raw.version !== 1) {
163
+ throw new SeptError("Hands setup ownership is malformed");
164
+ }
165
+ const containerId =
166
+ raw.containerId === undefined ? undefined : text(raw.containerId, /^[a-f0-9]{64}$/);
167
+ const generation = raw.generation === undefined ? undefined : text(raw.generation);
168
+ return {
169
+ version: 1,
170
+ owner: text(raw.owner, /^[a-f0-9-]{36}$/),
171
+ engineSocket: text(raw.engineSocket),
172
+ socketIdentity: text(raw.socketIdentity),
173
+ daemonId: text(raw.daemonId),
174
+ image: text(raw.image, /^sha256:[a-f0-9]{64}$/),
175
+ phase: receiptPhase(raw.phase),
176
+ ...(containerId === undefined ? {} : { containerId }),
177
+ ...(generation === undefined ? {} : { generation }),
178
+ };
179
+ }
180
+
181
+ function text(value: unknown, pattern?: RegExp): string {
182
+ if (
183
+ typeof value !== "string" ||
184
+ value.length === 0 ||
185
+ value.length > 4096 ||
186
+ /[\x00-\x1f]/.test(value)
187
+ ) {
188
+ throw new SeptError("Hands setup ownership text is malformed");
189
+ }
190
+ if (pattern !== undefined && !pattern.test(value)) {
191
+ throw new SeptError("Hands setup ownership identity is malformed");
192
+ }
193
+ return value;
194
+ }
195
+
196
+ function receiptPhase(value: unknown): HandsReceipt["phase"] {
197
+ switch (value) {
198
+ case "prepared":
199
+ case "creating":
200
+ case "running":
201
+ case "ready":
202
+ case "failed":
203
+ case "finished":
204
+ return value;
205
+ default:
206
+ throw new SeptError("Hands setup phase is malformed");
207
+ }
208
+ }
209
+
210
+ /** Generate the unguessable container ownership label before any Engine effect. */
211
+ export function newHandsOwner(): string {
212
+ return randomUUID();
213
+ }
@@ -0,0 +1,207 @@
1
+ /** Optional local setup composition; the existing worker and policy authority remain unchanged. */
2
+ import { realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { acquireStateRootLease, atomicWrite, SeptError } from "../durable/index.js";
5
+ import { DockerEngineControl, loadHandsConfiguration } from "../execution/index.js";
6
+ import { finishHands } from "./hands-finish.js";
7
+ import { runPermissions } from "./permissions.js";
8
+ import type { CliInvocation } from "./options.js";
9
+ import {
10
+ createHandsRoot,
11
+ handsPaths,
12
+ newHandsOwner,
13
+ trustedHandsRoot,
14
+ writeHandsReceipt,
15
+ type HandsReceipt,
16
+ } from "./hands-record.js";
17
+ import { createHandsArguments, handsDocker } from "./hands-docker.js";
18
+ import { captureHandsOwner, inspectHandsOwner, stopHandsOwner } from "./hands-owner.js";
19
+
20
+ /** Execute the two installed operator commands; signals join cleanup before the process exits. */
21
+ export async function runHands(invocation: CliInvocation): Promise<unknown> {
22
+ if (invocation.action !== "init" && invocation.action !== "finish") {
23
+ throw new SeptError("Usage: sept hands init|finish --root <absolute-new-instance>");
24
+ }
25
+ const abort = new AbortController();
26
+ const cancel = (): void => {
27
+ abort.abort();
28
+ };
29
+ process.on("SIGINT", cancel);
30
+ process.on("SIGTERM", cancel);
31
+ try {
32
+ return invocation.action === "init"
33
+ ? await initialize(invocation.options, abort.signal)
34
+ : await finishHands(invocation.options);
35
+ } finally {
36
+ process.off("SIGINT", cancel);
37
+ process.off("SIGTERM", cancel);
38
+ }
39
+ }
40
+
41
+ function assertNotCancelled(signal: AbortSignal): void {
42
+ if (signal.aborted) {
43
+ throw new SeptError("Hands setup cancelled; ownership and recovery receipts retained");
44
+ }
45
+ }
46
+
47
+ async function initialize(options: Record<string, string>, signal: AbortSignal): Promise<unknown> {
48
+ const root = trustedHandsRoot(options.root, true);
49
+ if (options.access !== "files" && options.access !== "none") {
50
+ throw new SeptError("hands init requires explicit --access files|none");
51
+ }
52
+ if (
53
+ options.image === undefined ||
54
+ options.image.length > 512 ||
55
+ options.image.startsWith("-") ||
56
+ options["engine-socket"] === undefined
57
+ ) {
58
+ throw new SeptError("hands init requires --image and explicit local --engine-socket");
59
+ }
60
+ const engine = await DockerEngineControl.open(options["engine-socket"]);
61
+ try {
62
+ assertNotCancelled(signal);
63
+ createHandsRoot(root);
64
+ return await initializeOwned(root, options, engine, signal);
65
+ } finally {
66
+ await engine.close();
67
+ }
68
+ }
69
+
70
+ async function initializeOwned(
71
+ root: string,
72
+ options: Record<string, string>,
73
+ engine: DockerEngineControl,
74
+ signal: AbortSignal,
75
+ ): Promise<unknown> {
76
+ const release = acquireStateRootLease(join(root, "evidence"));
77
+ const receipt = initialReceipt(options, engine);
78
+ try {
79
+ writeHandsReceipt(root, receipt);
80
+ resolveImage(root, receipt, options.image ?? "");
81
+ await createContainer(root, receipt, engine, signal);
82
+ await activatePermissions(root, options.access === "files");
83
+ assertNotCancelled(signal);
84
+ receipt.phase = "ready";
85
+ writeHandsReceipt(root, receipt);
86
+ return {
87
+ ...handsPaths(root),
88
+ containerId: receipt.containerId,
89
+ generation: receipt.generation,
90
+ access: options.access,
91
+ };
92
+ } catch (error: unknown) {
93
+ await initializationFailure(root, receipt, engine);
94
+ throw new SeptError("Hands initialization failed; recovery evidence retained under --root", {
95
+ cause: error,
96
+ });
97
+ } finally {
98
+ release();
99
+ }
100
+ }
101
+
102
+ function resolveImage(root: string, receipt: HandsReceipt, imageName: string): void {
103
+ const image = handsDocker(root, receipt, ["image", "inspect", "--format", "{{.Id}}", imageName]);
104
+ if (!/^sha256:[a-f0-9]{64}$/.test(image)) {
105
+ throw new SeptError("Hands image must already be locally available with immutable identity");
106
+ }
107
+ receipt.image = image;
108
+ }
109
+
110
+ function initialReceipt(
111
+ options: Record<string, string>,
112
+ engine: DockerEngineControl,
113
+ ): HandsReceipt {
114
+ return {
115
+ version: 1,
116
+ owner: newHandsOwner(),
117
+ engineSocket: realpathSync(options["engine-socket"] ?? ""),
118
+ socketIdentity: engine.socketIdentity,
119
+ daemonId: engine.daemonId,
120
+ image: `sha256:${"0".repeat(64)}`,
121
+ phase: "prepared",
122
+ };
123
+ }
124
+
125
+ async function createContainer(
126
+ root: string,
127
+ receipt: HandsReceipt,
128
+ engine: DockerEngineControl,
129
+ signal: AbortSignal,
130
+ ): Promise<void> {
131
+ receipt.phase = "creating";
132
+ writeHandsReceipt(root, receipt);
133
+ receipt.containerId = handsDocker(root, receipt, createHandsArguments(root, receipt));
134
+ await inspectHandsOwner(root, receipt, engine);
135
+ assertNotCancelled(signal);
136
+ handsDocker(root, receipt, ["start", receipt.containerId]);
137
+ const state = await inspectHandsOwner(root, receipt, engine);
138
+ if (typeof state.StartedAt !== "string" || state.Running !== true) {
139
+ throw new SeptError("Hands start generation was not established");
140
+ }
141
+ receipt.generation = state.StartedAt;
142
+ writeHandsReceipt(root, receipt);
143
+ const paths = handsPaths(root);
144
+ atomicWrite(paths.handsConfigurationPath, {
145
+ profile: "docker-linux-warm-v1",
146
+ engineSocket: receipt.engineSocket,
147
+ containerId: receipt.containerId,
148
+ generation: receipt.generation,
149
+ workspace: paths.workspaceRoot,
150
+ resources: join(root, "resources"),
151
+ dataDomain: "private",
152
+ workspaceLimits: { bytes: 134217728, inodes: 32768 },
153
+ shell: false,
154
+ readOnlyMounts: [],
155
+ });
156
+ await captureHandsOwner(
157
+ root,
158
+ receipt,
159
+ loadHandsConfiguration(paths.handsConfigurationPath),
160
+ engine,
161
+ );
162
+ assertNotCancelled(signal);
163
+ }
164
+
165
+ async function activatePermissions(root: string, files: boolean): Promise<void> {
166
+ const paths = handsPaths(root);
167
+ const policy = join(root, "evidence", "initial-policy.json");
168
+ atomicWrite(policy, {
169
+ mode: "scoped",
170
+ workspace: { read: files, write: files },
171
+ selected: [],
172
+ execute: false,
173
+ network: false,
174
+ });
175
+ await runPermissions(
176
+ {
177
+ command: "permissions",
178
+ action: "set",
179
+ options: { hands: paths.handsConfigurationPath, policy, revision: "0" },
180
+ },
181
+ paths.stateRoot,
182
+ paths.agentDir,
183
+ );
184
+ }
185
+
186
+ async function initializationFailure(
187
+ root: string,
188
+ receipt: HandsReceipt,
189
+ engine: DockerEngineControl,
190
+ ): Promise<void> {
191
+ let stopped = receipt.phase === "prepared";
192
+ try {
193
+ if (!stopped) {
194
+ await stopHandsOwner(root, receipt, engine);
195
+ stopped = true;
196
+ }
197
+ } catch {
198
+ // Unknown ownership never authorizes a broader stop; retain evidence for explicit reconciliation.
199
+ } finally {
200
+ receipt.phase = "failed";
201
+ writeHandsReceipt(root, receipt);
202
+ atomicWrite(join(root, "evidence", "initialization-failure.json"), {
203
+ stopped,
204
+ reconciliationRequired: !stopped,
205
+ });
206
+ }
207
+ }
@@ -6,6 +6,7 @@
6
6
  import { CliError } from "./errors.js";
7
7
 
8
8
  const OPTION_KEYS = {
9
+ hands: ["root", "engine-socket", "image", "access"],
9
10
  worker: ["hands", "agent-dir", "host", "port", "state-dir", "workspace-root"],
10
11
  run: [
11
12
  "hands",
@@ -83,7 +84,8 @@ export function parseCliInvocation(argv: string[]): CliInvocation | undefined {
83
84
  if (!isCommand(command)) {
84
85
  return undefined;
85
86
  }
86
- const hasAction = command === "models" || command === "auth" || command === "permissions";
87
+ const hasAction =
88
+ command === "models" || command === "auth" || command === "permissions" || command === "hands";
87
89
  const action = hasAction ? argv[3] : undefined;
88
90
  const options = parseOptions(argv.slice(hasAction ? 4 : 3), OPTION_KEYS[command]);
89
91
  return action === undefined ? { command, options } : { command, action, options };
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ async function main(): Promise<void> {
15
15
  const invocation = parseCliInvocation(process.argv);
16
16
  if (invocation === undefined) {
17
17
  process.stderr.write(
18
- "Usage: sept worker|run|doctor|health|models|auth|permissions [action] [options]\n",
18
+ "Usage: sept worker|run|doctor|health|models|auth|permissions|hands [action] [options]\n",
19
19
  );
20
20
  process.exitCode = 2;
21
21
  return;
@@ -15,10 +15,7 @@ import type {
15
15
  NamespaceLifetime,
16
16
  } from "./contracts.js";
17
17
 
18
- interface ProcessIdentity {
19
- pid: number;
20
- start: string;
21
- }
18
+ type ProcessIdentity = ReturnType<typeof parseProcessIdentity>;
22
19
  interface CommandOptions {
23
20
  network: boolean;
24
21
  timeout?: number;
@@ -169,6 +166,8 @@ export class HandsProcessOwner {
169
166
  }
170
167
  private async waitCommand(attachment: HandsAttachment, state: CommandState): Promise<void> {
171
168
  while (!state.stop.signal.aborted) {
169
+ // EOF may arrive while inspection is in flight; only a later Engine read can prove a closed stream lost its exec.
170
+ const closedBeforeInspection = state.attachmentClosed;
172
171
  const inspected = await this.engine.inspectExec(attachment.execId);
173
172
  if (inspected.ContainerID !== this.configuration.containerId) {
174
173
  throw new SeptError("Hands command exec identity changed");
@@ -176,7 +175,7 @@ export class HandsProcessOwner {
176
175
  if (inspected.Running === false) {
177
176
  return;
178
177
  }
179
- if (state.attachmentClosed) {
178
+ if (closedBeforeInspection) {
180
179
  attachment.assertJoined();
181
180
  throw new SeptError("Hands command stream closed before its exec joined");
182
181
  }
@@ -34,3 +34,13 @@ export {
34
34
 
35
35
  /** Exact-family filter generation for trusted consumer-owned network setup. */
36
36
  export { networkFirewall } from "./network-profile.js";
37
+
38
+ /** Local setup uses the same authenticated Engine and pinned namespace cleanup as the worker. */
39
+ export { DockerEngineControl } from "./docker-engine.js";
40
+ export {
41
+ captureNamespaceLifetime,
42
+ stopCapturedNamespace,
43
+ parseNamespaceLifetime,
44
+ } from "./hands-processes.js";
45
+
46
+ export { guardedCommand } from "./hands-guarded-command.js";