intentdna 1.9.3 → 1.9.5

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,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { constants, realpathSync } from "node:fs";
4
- import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
4
+ import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
5
5
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  export class WorkspaceObservationError extends Error {
7
7
  code;
@@ -12,10 +12,10 @@ export class WorkspaceObservationError extends Error {
12
12
  }
13
13
  }
14
14
  export const DEFAULT_WORKSPACE_OBSERVATION_LIMITS = {
15
- max_entries: 100_000,
16
- max_bytes: 1_073_741_824,
15
+ max_entries: 250_000,
16
+ max_bytes: 8_589_934_592,
17
17
  max_depth: 64,
18
- max_duration_ms: 30_000,
18
+ max_duration_ms: 120_000,
19
19
  };
20
20
  export const WORKSPACE_OBSERVATION_EXCLUDED_PATHS = [
21
21
  ".dna/runtime",
@@ -27,8 +27,7 @@ const GIT_EXCLUSION_PATHSPECS = [
27
27
  ":(exclude).dna/worktrees/runtime",
28
28
  ":(exclude).dna/worktrees/runtime/**",
29
29
  ];
30
- function metadata(value) {
31
- const record = value;
30
+ function metadata(record) {
32
31
  return {
33
32
  dev: String(record.dev),
34
33
  ino: String(record.ino),
@@ -38,6 +37,17 @@ function metadata(value) {
38
37
  ctime_ns: String(record.ctimeNs),
39
38
  };
40
39
  }
40
+ function specialFileKind(value) {
41
+ if (value.isSocket())
42
+ return "socket";
43
+ if (value.isFIFO())
44
+ return "fifo";
45
+ if (value.isBlockDevice())
46
+ return "block_device";
47
+ if (value.isCharacterDevice())
48
+ return "character_device";
49
+ return null;
50
+ }
41
51
  function sameMetadata(left, right) {
42
52
  return JSON.stringify(left) === JSON.stringify(right);
43
53
  }
@@ -59,6 +69,29 @@ function git(cwd, args) {
59
69
  });
60
70
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
61
71
  }
72
+ function sha256Text(value) {
73
+ return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`;
74
+ }
75
+ function gitIgnoreEvidence(root, relativePath) {
76
+ const result = spawnSync("git", ["check-ignore", "--stdin", "-z", "-v"], {
77
+ cwd: root,
78
+ input: `${relativePath}\0`,
79
+ encoding: "utf8",
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ windowsHide: true,
82
+ });
83
+ if (result.status === 1)
84
+ return null;
85
+ if (result.status !== 0) {
86
+ throw new WorkspaceObservationError("verification_failed", result.stderr?.trim() || `cannot inspect Git ignore status for ${relativePath}`);
87
+ }
88
+ const stdout = result.stdout ?? "";
89
+ const fields = stdout.split("\0");
90
+ if (fields.length !== 5 || fields[3] !== relativePath || fields[4] !== "") {
91
+ throw new WorkspaceObservationError("verification_failed", `Git returned invalid ignore evidence for ${relativePath}`);
92
+ }
93
+ return stdout;
94
+ }
62
95
  function gitSnapshot(root) {
63
96
  const top = git(root, ["rev-parse", "--show-toplevel"]);
64
97
  if (top.status !== 0)
@@ -106,10 +139,12 @@ function validateLimits(input) {
106
139
  }
107
140
  export function workspaceObservationPolicy(limits) {
108
141
  return {
109
- schema_version: "intentdna.workspace_observation_policy.v1",
142
+ schema_version: "intentdna.workspace_observation_policy.v4",
110
143
  limits: validateLimits(limits),
111
144
  excluded_relative_paths: WORKSPACE_OBSERVATION_EXCLUDED_PATHS,
112
145
  git_observation: "head_branch_status_local_config",
146
+ symlink_observation: "reject_unignored_record_gitignored_target_as_opaque",
147
+ special_file_observation: "metadata_only_no_open",
113
148
  };
114
149
  }
115
150
  export async function observeWorkspace(request) {
@@ -127,8 +162,13 @@ export async function observeWorkspace(request) {
127
162
  const now = request.now_ms ?? Date.now;
128
163
  const startedAt = now();
129
164
  const digest = createHash("sha256");
165
+ const policy = workspaceObservationPolicy(limits);
166
+ digest.update(`policy\0${JSON.stringify(policy)}\0`);
130
167
  let entries = 0;
131
168
  let bytes = 0;
169
+ const opaqueExternalDependencies = [];
170
+ const opaqueChecks = [];
171
+ const gitBefore = gitSnapshot(root);
132
172
  const enforce = (depth) => {
133
173
  if (depth > limits.max_depth)
134
174
  throw new WorkspaceObservationError("observation_limit_exceeded", "workspace depth limit exceeded");
@@ -144,16 +184,72 @@ export async function observeWorkspace(request) {
144
184
  };
145
185
  const visit = async (absolutePath, relativePath, depth) => {
146
186
  enforce(depth);
147
- const beforeStatus = await lstat(absolutePath, { bigint: true });
148
- if (beforeStatus.isSymbolicLink()) {
149
- throw new WorkspaceObservationError("verification_failed", `symlink is not allowed: ${relativePath}`);
187
+ let beforeStatus;
188
+ try {
189
+ beforeStatus = await lstat(absolutePath, { bigint: true });
190
+ }
191
+ catch (error) {
192
+ throw new WorkspaceObservationError("source_drift", `entry changed before observation: ${relativePath}`, { cause: error });
150
193
  }
151
194
  entries += 1;
152
195
  enforce(depth);
153
196
  const before = metadata(beforeStatus);
197
+ if (beforeStatus.isSymbolicLink()) {
198
+ if (gitBefore === null) {
199
+ throw new WorkspaceObservationError("verification_failed", `symlink is not observable: ${relativePath}`);
200
+ }
201
+ const ignoreEvidence = gitIgnoreEvidence(root, relativePath);
202
+ if (ignoreEvidence === null) {
203
+ throw new WorkspaceObservationError("verification_failed", `symlink is not observable: ${relativePath}`);
204
+ }
205
+ let linkTarget;
206
+ let resolvedTarget;
207
+ try {
208
+ linkTarget = await readlink(absolutePath);
209
+ resolvedTarget = await realpath(absolutePath);
210
+ await request.hooks?.after_metadata_read?.(relativePath);
211
+ const afterStatus = await lstat(absolutePath, { bigint: true });
212
+ const afterTarget = await readlink(absolutePath);
213
+ if (!afterStatus.isSymbolicLink()
214
+ || !sameMetadata(before, metadata(afterStatus))
215
+ || afterTarget !== linkTarget) {
216
+ throw new WorkspaceObservationError("source_drift", `symlink changed while observing: ${relativePath}`);
217
+ }
218
+ }
219
+ catch (error) {
220
+ if (error instanceof WorkspaceObservationError)
221
+ throw error;
222
+ throw new WorkspaceObservationError("source_drift", `symlink changed while observing: ${relativePath}`, { cause: error });
223
+ }
224
+ const dependency = {
225
+ kind: "gitignored_symlink_target",
226
+ relative_path: relativePath,
227
+ link_target_digest: sha256Text(linkTarget),
228
+ resolved_target_path_digest: sha256Text(resolvedTarget),
229
+ git_ignore_evidence_digest: sha256Text(ignoreEvidence),
230
+ };
231
+ updateRecord("symlink", relativePath, before);
232
+ digest.update(`opaque_external_dependency\0${JSON.stringify(dependency)}\0`);
233
+ opaqueExternalDependencies.push(dependency);
234
+ opaqueChecks.push({
235
+ absolute_path: absolutePath,
236
+ relative_path: relativePath,
237
+ metadata: before,
238
+ link_target: linkTarget,
239
+ resolved_target_path_digest: dependency.resolved_target_path_digest,
240
+ git_ignore_evidence: ignoreEvidence,
241
+ });
242
+ return;
243
+ }
154
244
  if (beforeStatus.isDirectory()) {
155
245
  updateRecord("directory", relativePath, before);
156
- const names = (await readdir(absolutePath)).sort(canonicalNameOrder);
246
+ let names;
247
+ try {
248
+ names = (await readdir(absolutePath)).sort(canonicalNameOrder);
249
+ }
250
+ catch (error) {
251
+ throw new WorkspaceObservationError("source_drift", `directory changed before read: ${relativePath}`, { cause: error });
252
+ }
157
253
  await request.hooks?.after_directory_read?.(relativePath);
158
254
  for (const name of names) {
159
255
  const childRelative = relativePath === "." ? name : `${relativePath}/${name}`;
@@ -161,19 +257,48 @@ export async function observeWorkspace(request) {
161
257
  continue;
162
258
  await visit(join(absolutePath, name), childRelative, depth + 1);
163
259
  }
164
- const after = metadata(await stat(absolutePath, { bigint: true }));
165
- if (!sameMetadata(before, after)) {
260
+ let afterStatus;
261
+ try {
262
+ afterStatus = await lstat(absolutePath, { bigint: true });
263
+ }
264
+ catch (error) {
265
+ throw new WorkspaceObservationError("source_drift", `directory changed while observing: ${relativePath}`, { cause: error });
266
+ }
267
+ if (!afterStatus.isDirectory() || !sameMetadata(before, metadata(afterStatus))) {
166
268
  throw new WorkspaceObservationError("source_drift", `directory changed while observing: ${relativePath}`);
167
269
  }
168
270
  return;
169
271
  }
170
272
  if (!beforeStatus.isFile()) {
171
- throw new WorkspaceObservationError("verification_failed", `special file is not allowed: ${relativePath}`);
273
+ const kind = specialFileKind(beforeStatus);
274
+ if (kind === null) {
275
+ throw new WorkspaceObservationError("verification_failed", `unknown filesystem node is not observable: ${relativePath}`);
276
+ }
277
+ let afterStatus;
278
+ try {
279
+ await request.hooks?.after_metadata_read?.(relativePath);
280
+ afterStatus = await lstat(absolutePath, { bigint: true });
281
+ }
282
+ catch (error) {
283
+ throw new WorkspaceObservationError("source_drift", `${kind} changed while observing: ${relativePath}`, { cause: error });
284
+ }
285
+ if (specialFileKind(afterStatus) !== kind || !sameMetadata(before, metadata(afterStatus))) {
286
+ throw new WorkspaceObservationError("source_drift", `${kind} changed while observing: ${relativePath}`);
287
+ }
288
+ updateRecord(kind, relativePath, before);
289
+ return;
172
290
  }
173
291
  // Node does not expose openat(2), so same-user namespace replacement cannot be
174
292
  // eliminated completely. O_NOFOLLOW plus descriptor/path revalidation closes
175
293
  // the practical symlink and replacement windows without native code.
176
- const handle = await open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
294
+ await request.hooks?.after_metadata_read?.(relativePath);
295
+ let handle;
296
+ try {
297
+ handle = await open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
298
+ }
299
+ catch (error) {
300
+ throw new WorkspaceObservationError("source_drift", `file changed before read: ${relativePath}`, { cause: error });
301
+ }
177
302
  try {
178
303
  const descriptorBefore = metadata(await handle.stat({ bigint: true }));
179
304
  if (!sameMetadata(before, descriptorBefore)) {
@@ -188,9 +313,17 @@ export async function observeWorkspace(request) {
188
313
  digest.update(buffer);
189
314
  }
190
315
  digest.update("\0");
191
- const descriptorAfter = metadata(await handle.stat({ bigint: true }));
192
- const pathAfter = await lstat(absolutePath, { bigint: true });
193
- if (pathAfter.isSymbolicLink() || !sameMetadata(before, descriptorAfter) || !sameMetadata(before, metadata(pathAfter))) {
316
+ await request.hooks?.after_file_read?.(relativePath);
317
+ let descriptorAfter;
318
+ let pathAfter;
319
+ try {
320
+ descriptorAfter = metadata(await handle.stat({ bigint: true }));
321
+ pathAfter = await lstat(absolutePath, { bigint: true });
322
+ }
323
+ catch (error) {
324
+ throw new WorkspaceObservationError("source_drift", `file changed while observing: ${relativePath}`, { cause: error });
325
+ }
326
+ if (!pathAfter.isFile() || !sameMetadata(before, descriptorAfter) || !sameMetadata(before, metadata(pathAfter))) {
194
327
  throw new WorkspaceObservationError("source_drift", `file changed while observing: ${relativePath}`);
195
328
  }
196
329
  }
@@ -198,19 +331,44 @@ export async function observeWorkspace(request) {
198
331
  await handle.close();
199
332
  }
200
333
  };
201
- const gitBefore = gitSnapshot(root);
202
334
  await visit(root, ".", 0);
335
+ for (const check of opaqueChecks) {
336
+ try {
337
+ const status = await lstat(check.absolute_path, { bigint: true });
338
+ const linkTarget = await readlink(check.absolute_path);
339
+ const resolvedTarget = await realpath(check.absolute_path);
340
+ const ignoreEvidence = gitIgnoreEvidence(root, check.relative_path);
341
+ if (!status.isSymbolicLink()
342
+ || !sameMetadata(check.metadata, metadata(status))
343
+ || linkTarget !== check.link_target
344
+ || sha256Text(resolvedTarget) !== check.resolved_target_path_digest
345
+ || ignoreEvidence !== check.git_ignore_evidence) {
346
+ throw new WorkspaceObservationError("source_drift", `opaque external dependency changed while observing: ${check.relative_path}`);
347
+ }
348
+ }
349
+ catch (error) {
350
+ if (error instanceof WorkspaceObservationError)
351
+ throw error;
352
+ throw new WorkspaceObservationError("source_drift", `opaque external dependency changed while observing: ${check.relative_path}`, { cause: error });
353
+ }
354
+ }
355
+ opaqueExternalDependencies.sort((left, right) => canonicalNameOrder(left.relative_path, right.relative_path));
356
+ digest.update(`scope\0${JSON.stringify({
357
+ excluded_relative_paths: WORKSPACE_OBSERVATION_EXCLUDED_PATHS,
358
+ opaque_external_dependencies: opaqueExternalDependencies,
359
+ })}\0`);
203
360
  const gitAfter = gitSnapshot(root);
204
361
  if (JSON.stringify(gitBefore) !== JSON.stringify(gitAfter)) {
205
362
  throw new WorkspaceObservationError("source_drift", "Git workspace state changed while observing");
206
363
  }
207
364
  digest.update(`git\0${JSON.stringify(gitAfter)}\0`);
208
365
  return {
209
- schema_version: "intentdna.workspace_observation.v2",
366
+ schema_version: "intentdna.workspace_observation.v5",
210
367
  observed_digest: `sha256:${digest.digest("hex")}`,
211
368
  entry_count: entries,
212
369
  byte_count: bytes,
213
370
  excluded_relative_paths: WORKSPACE_OBSERVATION_EXCLUDED_PATHS,
371
+ opaque_external_dependencies: opaqueExternalDependencies,
214
372
  git: gitAfter,
215
373
  };
216
374
  }
@@ -505,6 +505,7 @@ export interface PreExecutionGate {
505
505
  action: "block" | "escalate" | "warn";
506
506
  message: string;
507
507
  source_gene: string;
508
+ enforcement?: "runtime" | "prompt_only";
508
509
  origin?: ConstraintOrigin;
509
510
  }
510
511
  export interface PostExecutionValidator {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.9.3",
3
+ "version": "1.9.5",
4
4
  "description": "Intent DNA — Declarative policy layer for AI agent behavior",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",